Files
buzz/desktop/src/features/workflows/ui/WorkflowCard.tsx
Taylor Ho edc4a09aaa feat(workflows): add responsive library card actions (#6008)
**Category:** improvement
**User Impact:** Users can scan what each workflow does and trigger,
edit, duplicate, enable, disable, or delete it directly from the
library.
**Problem:** The workflow list buried common actions and did not expose
each automation's trigger-to-action shape at a glance.
**Solution:** Add a responsive workflow library with a persistent create
tile, compact trigger/action diagrams, prominent workflow titles with
supporting descriptions, and shared card actions while preserving
existing detail, editor, and run-history entry points. Card toggles
refresh both list and open-detail caches so status and definition stay
consistent.

<details>
<summary>File changes</summary>

**desktop/src/features/workflows/ui/WorkflowActionsMenu.tsx**
Adds a shared card menu for trigger, edit, duplicate, enable/disable,
and delete actions.

**desktop/src/features/workflows/ui/WorkflowCard.tsx**
Reworks cards around the prototype's visual hierarchy: color-coded
trigger, action flow, sentence-case eyebrow, prominent title, supporting
description, status, channel, and update date without a footer clock
icon.

**desktop/src/features/workflows/ui/WorkflowsView.tsx**
Adds the responsive grid, create tile, mutation wiring, and list/detail
cache invalidation. Container breakpoints keep cards two-across at
medium widths and three-across in the 1280px desktop layout.

**desktop/src/features/workflows/ui/workflowDefinition.ts**
Adds immutable enabled-state updates plus narrow trigger and
first-action readers used only to select card icons.

**desktop/src/features/workflows/ui/workflowDefinition.test.mjs**
Covers neutral icon selection, enabled-state immutability, and status
presentation.

**desktop/tests/e2e/workflows.spec.ts**
Covers the create tile, title/description hierarchy, selected-card
enable/disable consistency, and deterministic narrow/medium/wide
captures while retaining existing action coverage.

</details>

## Reproduction steps

1. Open **Workflows** and confirm the create tile stays first as cards
flow from one to three columns with available width.
2. Confirm each card shows a sentence-case trigger eyebrow, prominent
workflow title, supporting description when present, status, channel,
and update date without a clock icon.
3. Open a card's overflow menu and trigger, edit, duplicate,
enable/disable, or delete the workflow.
4. Leave the detail panel open while toggling and confirm its badge and
JSON definition update with the card.

## Screenshots

Real built E2E UI with representative workflow data at three viewport
sizes.

### Narrow — 800 × 720

![Workflow library at 800 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-narrow-482d1b4c8.png)

### Medium — 1024 × 720

![Workflow library at 1024 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-medium-482d1b4c8.png)

### Wide — 1280 × 720

![Workflow library at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-482d1b4c8.png)

### Card actions

![Workflow library actions at 1280 by
720](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6008/workflow-library-wide-actions-482d1b4c8.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-17 16:56:34 +00:00

183 lines
6.1 KiB
TypeScript

import {
ArrowRight,
CalendarClock,
CircleCheckBig,
GitPullRequest,
Hash,
MessageCircle,
MessageSquare,
Send,
SmilePlus,
Timer,
Webhook,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Workflow } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { WorkflowActionsMenu } from "./WorkflowActionsMenu";
import {
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowEnabled,
getWorkflowPrimaryAction,
getWorkflowTriggerSummary,
getWorkflowTriggerType,
} from "./workflowDefinition";
type WorkflowCardProps = {
workflow: Workflow;
channelName?: string;
isActive?: boolean;
isTogglingEnabled?: boolean;
onSelect: (workflowId: string) => void;
onTrigger: (workflowId: string) => void;
onToggleEnabled: (workflow: Workflow) => void;
onEdit: (workflow: Workflow) => void;
onDuplicate: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
};
const TRIGGER_ICONS: Record<string, LucideIcon> = {
diff_posted: GitPullRequest,
message_posted: MessageSquare,
reaction_added: SmilePlus,
schedule: CalendarClock,
webhook: Webhook,
};
const ACTION_ICONS: Record<string, LucideIcon> = {
add_reaction: SmilePlus,
call_webhook: Webhook,
delay: Timer,
request_approval: CircleCheckBig,
send_dm: MessageCircle,
send_message: Send,
set_channel_topic: Hash,
};
const TRIGGER_ACCENTS: Record<string, string> = {
diff_posted: "border-violet-400/30 bg-violet-600 text-white",
message_posted: "border-blue-400/30 bg-blue-600 text-white",
reaction_added: "border-pink-400/30 bg-pink-600 text-white",
schedule: "border-emerald-400/30 bg-emerald-600 text-white",
webhook: "border-orange-300/30 bg-orange-500 text-white",
};
function StatusBadge({ status }: { status: Workflow["status"] }) {
return (
<span
className={cn(
"rounded-full border border-border/65 bg-background/80 px-2 py-1 text-2xs font-semibold uppercase tracking-wider shadow-xs",
status === "active" ? "text-foreground" : "text-muted-foreground",
)}
>
{status}
</span>
);
}
export function WorkflowCard({
workflow,
channelName,
isActive = false,
isTogglingEnabled = false,
onSelect,
onTrigger,
onToggleEnabled,
onEdit,
onDuplicate,
onDelete,
}: WorkflowCardProps) {
const displayStatus = getWorkflowDisplayStatus(workflow);
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
const description = getWorkflowDescription(workflow.definition);
const triggerType = getWorkflowTriggerType(workflow.definition);
const actionType = getWorkflowPrimaryAction(workflow.definition);
const TriggerIcon = triggerType ? TRIGGER_ICONS[triggerType] : undefined;
const ActionIcon = actionType ? ACTION_ICONS[actionType] : undefined;
const triggerAccent = triggerType ? TRIGGER_ACCENTS[triggerType] : undefined;
return (
<div
className={cn(
"group relative min-h-60 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/50 p-5 text-left text-foreground shadow-xs transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/65 hover:shadow-md",
isActive && "border-primary/50 bg-primary/5 ring-1 ring-primary/30",
)}
data-testid={`workflow-card-${workflow.id}`}
>
<button
className="absolute inset-0 z-0 rounded-2xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={() => onSelect(workflow.id)}
type="button"
>
<span className="sr-only">View {workflow.name}</span>
</button>
<div className="pointer-events-none relative z-10 flex h-full min-h-48 flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2" aria-hidden="true">
<span
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl border shadow-xs",
triggerAccent ?? "border-slate-400/30 bg-slate-600 text-white",
)}
>
{TriggerIcon ? (
<TriggerIcon className="h-5 w-5" />
) : (
<Zap className="h-5 w-5" />
)}
</span>
{ActionIcon ? (
<>
<ArrowRight className="h-4 w-4 text-muted-foreground/60" />
<span className="flex h-9 w-9 items-center justify-center rounded-xl border border-border/65 bg-background/80 text-muted-foreground shadow-xs">
<ActionIcon className="h-5 w-5" />
</span>
</>
) : null}
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<StatusBadge status={displayStatus} />
<WorkflowActionsMenu
isEnabled={getWorkflowEnabled(workflow.definition)}
isTogglingEnabled={isTogglingEnabled}
onDelete={() => onDelete(workflow)}
onDuplicate={() => onDuplicate(workflow)}
onEdit={() => onEdit(workflow)}
onToggleEnabled={() => onToggleEnabled(workflow)}
onTrigger={() => onTrigger(workflow.id)}
/>
</div>
</div>
{triggerSummary ? (
<p className="mt-4 line-clamp-1 text-xs font-semibold text-muted-foreground">
{triggerSummary}
</p>
) : null}
<h3 className="mt-1 line-clamp-2 text-xl font-bold leading-tight tracking-tight">
{workflow.name}
</h3>
{description ? (
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
<div className="mt-auto flex min-w-0 items-end justify-between gap-3 pt-5 text-muted-foreground">
<p className="min-w-0 truncate text-2xs">
{channelName ? `#${channelName}` : "Channel workflow"}
</p>
<span className="shrink-0 text-2xs">
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>
</div>
</div>
</div>
);
}