Files
buzz/desktop/src/features/workflows/ui/WorkflowsView.tsx
T
Taylor HoandGitHub 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

327 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Plus, RefreshCw } from "lucide-react";
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { stringify as yamlStringify } from "yaml";
import {
allWorkflowsQueryKey,
workflowListFocusRefetchPolicy,
workflowQueryKey,
} from "@/features/workflows/hooks";
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel";
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import type { Channel, Workflow } from "@/shared/api/types";
import {
deleteWorkflow,
getChannelsWorkflows,
triggerWorkflow,
updateWorkflow,
} from "@/shared/api/tauriWorkflows";
import { Button } from "@/shared/ui/button";
import { PageHeader } from "@/shared/ui/PageHeader";
import { Skeleton } from "@/shared/ui/skeleton";
import { getWorkflowEnabled, withWorkflowEnabled } from "./workflowDefinition";
type WorkflowsViewProps = {
channels: Channel[];
onCloseWorkflow: () => void;
onSelectWorkflow: (workflowId: string) => void;
selectedWorkflowId: string | null;
};
type WorkflowWithChannel = {
workflow: Workflow;
channelName: string;
};
type DialogState =
| { mode: "closed" }
| { mode: "create" }
| { mode: "edit"; workflow: Workflow }
| { mode: "duplicate"; workflow: Workflow };
const WORKFLOW_CARD_GRID_CLASS =
"grid grid-cols-1 gap-3 [@container(min-width:38rem)]:grid-cols-2 [@container(min-width:54rem)]:grid-cols-3";
function WorkflowsListSkeleton() {
return (
<div className={WORKFLOW_CARD_GRID_CLASS}>
{["first", "second", "third", "fourth"].map((card) => (
<div
className="flex min-h-60 flex-col rounded-2xl border bg-card p-5"
key={card}
>
<div className="flex items-start justify-between">
<Skeleton className="h-9 w-9 rounded-xl" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
<Skeleton className="mt-5 h-6 w-3/4" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-4/5" />
<Skeleton className="mt-auto h-4 w-32" />
</div>
))}
</div>
);
}
function CreateWorkflowCard({ onClick }: { onClick: () => void }) {
return (
<button
aria-label="Create Workflow"
className="group relative flex min-h-60 w-full min-w-0 items-center justify-center overflow-hidden rounded-2xl border border-dashed border-border/80 bg-transparent text-muted-foreground shadow-xs transition-colors hover:border-border hover:bg-muted/70 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="new-workflow-card"
onClick={onClick}
type="button"
>
<Plus className="h-7 w-7 transition-colors" />
</button>
);
}
export function WorkflowsView({
channels,
onCloseWorkflow,
onSelectWorkflow,
selectedWorkflowId,
}: WorkflowsViewProps) {
const [dialogState, setDialogState] = React.useState<DialogState>({
mode: "closed",
});
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const queryClient = useQueryClient();
const memberChannels = channels.filter((c) => c.isMember);
const channelIds = memberChannels.map((c) => c.id).sort();
const channelIdKey = channelIds.join(",");
const allWorkflowsQuery = useQuery({
queryKey: allWorkflowsQueryKey(channelIdKey),
queryFn: async () => {
// Single batched relay query for all member channels, then group by the
// channel_id each workflow carries — replaces the per-channel fanout.
const channelNameById = new Map(
memberChannels.map((channel) => [channel.id, channel.name]),
);
const workflows = await getChannelsWorkflows(channelIds);
const results: WorkflowWithChannel[] = [];
for (const workflow of workflows) {
results.push({
workflow,
channelName: workflow.channelId
? (channelNameById.get(workflow.channelId) ?? "")
: "",
});
}
return results;
},
enabled: memberChannels.length > 0,
...workflowListFocusRefetchPolicy,
});
const allWorkflows = allWorkflowsQuery.data ?? [];
const triggerMutation = useMutation({
mutationFn: (workflowId: string) => triggerWorkflow(workflowId),
onSuccess: () => {
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === "workflow-runs",
});
},
});
const deleteMutation = useMutation({
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
onSuccess: (_data, workflowId) => {
if (selectedWorkflowId === workflowId) {
onCloseWorkflow();
}
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
query.queryKey[0] === "workflows-all",
});
},
});
const toggleEnabledMutation = useMutation({
mutationFn: (workflow: Workflow) =>
updateWorkflow(
workflow.id,
yamlStringify(
withWorkflowEnabled(
workflow.definition,
!getWorkflowEnabled(workflow.definition),
),
),
workflow.revision,
),
onError: (error) => {
toast.error("Couldnt change workflow status", {
description:
error instanceof Error
? error.message
: "The workflow was not changed. Try again.",
});
},
onSuccess: (_data, workflow) => {
void queryClient.invalidateQueries({
queryKey: workflowQueryKey(workflow.id),
});
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
query.queryKey[0] === "workflows-all",
});
},
});
const triggerOne = triggerMutation.mutate;
const handleTrigger = React.useCallback(
(workflowId: string) => triggerOne(workflowId),
[triggerOne],
);
const handleDelete = React.useCallback(
(workflow: Workflow) => setDeleteTarget(workflow),
[],
);
const deleteOne = deleteMutation.mutate;
const handleConfirmDelete = React.useCallback(
(workflow: Workflow) => {
deleteOne(workflow.id);
setDeleteTarget(null);
},
[deleteOne],
);
const handleEdit = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "edit", workflow }),
[],
);
const handleDuplicate = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "duplicate", workflow }),
[],
);
const toggleEnabled = toggleEnabledMutation.mutate;
const handleToggleEnabled = React.useCallback(
(workflow: Workflow) => toggleEnabled(workflow),
[toggleEnabled],
);
const handleDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setDialogState({ mode: "closed" });
}
}, []);
return (
<div
className="relative flex min-h-0 flex-1 overflow-hidden"
data-testid="workflows-view"
>
<div
className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-7 sm:px-6 sm:py-8"
data-scroll-restoration-id="workflows-list"
>
<div className="mx-auto w-full max-w-6xl space-y-8 [container-type:inline-size]">
<PageHeader
action={
<Button
aria-label="Refresh workflows"
disabled={allWorkflowsQuery.isFetching}
onClick={() => void allWorkflowsQuery.refetch()}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-4 w-4 ${allWorkflowsQuery.isFetching ? "animate-spin" : ""}`}
/>
</Button>
}
description="Automations that keep your community moving."
title="Workflows"
/>
{allWorkflowsQuery.isLoading ? (
<WorkflowsListSkeleton />
) : allWorkflowsQuery.isError ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<p className="text-sm text-red-400">Failed to load workflows</p>
<Button
onClick={() => void allWorkflowsQuery.refetch()}
size="sm"
variant="outline"
>
Retry
</Button>
</div>
) : (
<div className={WORKFLOW_CARD_GRID_CLASS}>
<CreateWorkflowCard
onClick={() => setDialogState({ mode: "create" })}
/>
{allWorkflows.map(({ workflow, channelName }) => (
<WorkflowCard
channelName={channelName}
isActive={selectedWorkflowId === workflow.id}
isTogglingEnabled={
toggleEnabledMutation.isPending &&
toggleEnabledMutation.variables?.id === workflow.id
}
key={workflow.id}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onEdit={handleEdit}
onSelect={onSelectWorkflow}
onToggleEnabled={handleToggleEnabled}
onTrigger={handleTrigger}
workflow={workflow}
/>
))}
</div>
)}
</div>
</div>
{selectedWorkflowId ? (
<div className="w-[400px] shrink-0">
<WorkflowDetailPanel
key={selectedWorkflowId}
onClose={onCloseWorkflow}
onEdit={handleEdit}
workflowId={selectedWorkflowId}
/>
</div>
) : null}
<WorkflowDialog
channels={memberChannels}
mode={dialogState.mode === "closed" ? "create" : dialogState.mode}
onOpenChange={handleDialogOpenChange}
open={dialogState.mode !== "closed"}
workflow={
dialogState.mode === "edit" || dialogState.mode === "duplicate"
? dialogState.workflow
: null
}
/>
<WorkflowDeleteDialog
onConfirm={handleConfirmDelete}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
open={deleteTarget !== null}
workflow={deleteTarget}
/>
</div>
);
}