mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
fix(ui): routines list — border each group as a card, drop row dividers (PAP-10748)
Previously the whole list was one bordered box with a divider line under
every routine row. Per feedback, promote each group into its own bordered
card and remove the per-row dividers so routines sit borderless inside.
- Routines.tsx: stack groups with gap-3; wrap each Collapsible in its own
rounded border card (overflow-hidden); header border-b only while open.
- RoutineList.tsx: add divider prop (default true to preserve other lists);
the grouped list passes divider={false}.
- Add storybook story for the grouped-cards layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
dd70dd5355
commit
64cb3c1a4e
3 files changed
+162
-45
No files matched your search
@@ -63,6 +63,7 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
disableRunNow = false,
|
||||
disableToggle = false,
|
||||
hideArchiveAction = false,
|
||||
divider = true,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onToggleArchived,
|
||||
@@ -80,6 +81,8 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
disableRunNow?: boolean;
|
||||
disableToggle?: boolean;
|
||||
hideArchiveAction?: boolean;
|
||||
/** Render a bottom divider between consecutive rows. Off when the group is its own card. */
|
||||
divider?: boolean;
|
||||
onRunNow: (routine: TRoutine) => void;
|
||||
onToggleEnabled: (routine: TRoutine, enabled: boolean) => void;
|
||||
onToggleArchived?: (routine: TRoutine) => void;
|
||||
@@ -95,7 +98,9 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
|
||||
className={`group flex flex-col gap-3 px-3 py-3 transition-colors hover:bg-accent/50 sm:flex-row sm:items-center no-underline text-inherit${
|
||||
divider ? " border-b border-border last:border-b-0" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
+53
-44
@@ -885,51 +885,60 @@ export function Routines() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border">
|
||||
{routineGroups.map((group) => (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={!routineViewState.collapsedGroups.includes(group.key)}
|
||||
onOpenChange={(open) => {
|
||||
updateRoutineView({
|
||||
collapsedGroups: open
|
||||
? routineViewState.collapsedGroups.filter((item) => item !== group.key)
|
||||
: [...routineViewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{group.label ? (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
<div className="flex flex-col gap-3">
|
||||
{routineGroups.map((group) => {
|
||||
const isOpen = !routineViewState.collapsedGroups.includes(group.key);
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
updateRoutineView({
|
||||
collapsedGroups: open
|
||||
? routineViewState.collapsedGroups.filter((item) => item !== group.key)
|
||||
: [...routineViewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
className="overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
{group.label ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2${
|
||||
isOpen ? " border-b border-border" : ""
|
||||
}`}
|
||||
>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((routine) => (
|
||||
<RoutineListRow
|
||||
key={routine.id}
|
||||
routine={routine}
|
||||
projectById={projectById}
|
||||
agentById={agentById}
|
||||
runningRoutineId={runningRoutineId}
|
||||
statusMutationRoutineId={statusMutationRoutineId}
|
||||
href={`/routines/${routine.id}`}
|
||||
runNowButton
|
||||
onRunNow={handleRunNow}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((routine) => (
|
||||
<RoutineListRow
|
||||
key={routine.id}
|
||||
routine={routine}
|
||||
projectById={projectById}
|
||||
agentById={agentById}
|
||||
runningRoutineId={runningRoutineId}
|
||||
statusMutationRoutineId={statusMutationRoutineId}
|
||||
href={`/routines/${routine.id}`}
|
||||
runNowButton
|
||||
divider={false}
|
||||
onRunNow={handleRunNow}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
RoutineListRow,
|
||||
type RoutineListAgentSummary,
|
||||
type RoutineListProjectSummary,
|
||||
type RoutineListRowItem,
|
||||
} from "@/components/RoutineList";
|
||||
|
||||
const projectById = new Map<string, RoutineListProjectSummary>([
|
||||
["p1", { name: "Board UI", color: "#6366f1" }],
|
||||
["p2", { name: "Growth", color: "#10b981" }],
|
||||
]);
|
||||
const agentById = new Map<string, RoutineListAgentSummary>([
|
||||
["a1", { name: "CodexCoder", icon: null }],
|
||||
["a2", { name: "Digest Bot", icon: null }],
|
||||
]);
|
||||
|
||||
type Group = { key: string; label: string | null; items: RoutineListRowItem[] };
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{
|
||||
key: "p1",
|
||||
label: "Board UI",
|
||||
items: [
|
||||
{ id: "r1", title: "Weekly digest", status: "active", projectId: "p1", assigneeAgentId: "a2", lastRun: { triggeredAt: "2026-06-09T08:00:00Z", status: "succeeded" } },
|
||||
{ id: "r2", title: "Triage stale issues", status: "active", projectId: "p1", assigneeAgentId: "a1", lastRun: { triggeredAt: "2026-06-08T08:00:00Z", status: "succeeded" } },
|
||||
{ id: "r3", title: "Nightly changelog draft", status: "paused", projectId: "p1", assigneeAgentId: "a1", lastRun: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "p2",
|
||||
label: "Growth",
|
||||
items: [
|
||||
{ id: "r4", title: "Lead enrichment sweep", status: "active", projectId: "p2", assigneeAgentId: "a1", lastRun: { triggeredAt: "2026-06-09T06:00:00Z", status: "running" } },
|
||||
{ id: "r5", title: "Outbound follow-ups", status: "active", projectId: "p2", assigneeAgentId: "a2", lastRun: { triggeredAt: "2026-06-07T06:00:00Z", status: "failed" } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function GroupedList() {
|
||||
const [collapsed, setCollapsed] = useState<string[]>([]);
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl p-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
{GROUPS.map((group) => {
|
||||
const isOpen = !collapsed.includes(group.key);
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) =>
|
||||
setCollapsed((prev) => (open ? prev.filter((k) => k !== group.key) : [...prev, group.key]))
|
||||
}
|
||||
className="overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
{group.label ? (
|
||||
<div className={`flex items-center gap-2 px-3 py-2${isOpen ? " border-b border-border" : ""}`}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">{group.label}</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">{group.items.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((routine) => (
|
||||
<RoutineListRow
|
||||
key={routine.id}
|
||||
routine={routine}
|
||||
projectById={projectById}
|
||||
agentById={agentById}
|
||||
runningRoutineId={null}
|
||||
statusMutationRoutineId={null}
|
||||
href={`#${routine.id}`}
|
||||
runNowButton
|
||||
divider={false}
|
||||
onRunNow={() => {}}
|
||||
onToggleEnabled={() => {}}
|
||||
onToggleArchived={() => {}}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof GroupedList> = {
|
||||
title: "Product/Routines · List (grouped cards)",
|
||||
component: GroupedList,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof GroupedList>;
|
||||
|
||||
export const GroupedCards: Story = {};
|
||||
Reference in new issue
Block a user