mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**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  ### Medium — 1024 × 720  ### Wide — 1280 × 720  ### Card actions  --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
258 lines
7.6 KiB
TypeScript
258 lines
7.6 KiB
TypeScript
import * as React from "react";
|
|
import { stringify as yamlStringify } from "yaml";
|
|
|
|
import {
|
|
useCreateWorkflowMutation,
|
|
useUpdateWorkflowMutation,
|
|
} from "@/features/workflows/hooks";
|
|
import type { Channel, Workflow } from "@/shared/api/types";
|
|
import { getRelayHttpUrl } from "@/shared/api/tauri";
|
|
import { Button } from "@/shared/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/shared/ui/dialog";
|
|
import { ChannelCombobox } from "./ChannelCombobox";
|
|
import { WorkflowFormBuilder } from "./WorkflowFormBuilder";
|
|
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
|
|
import { FieldLabel } from "./workflowFormPrimitives";
|
|
|
|
type DialogMode = "create" | "edit" | "duplicate";
|
|
|
|
type WorkflowDialogProps = {
|
|
channels: Channel[];
|
|
mode: DialogMode;
|
|
onOpenChange: (open: boolean) => void;
|
|
open: boolean;
|
|
workflow?: Workflow | null;
|
|
};
|
|
|
|
function getInitialYaml(
|
|
mode: DialogMode,
|
|
workflow: Workflow | null | undefined,
|
|
): string {
|
|
if (!workflow) return "";
|
|
const def = { ...workflow.definition };
|
|
if (mode === "duplicate") {
|
|
def.name = `${def.name ?? workflow.name} (copy)`;
|
|
}
|
|
return yamlStringify(def);
|
|
}
|
|
|
|
const TITLES: Record<DialogMode, string> = {
|
|
create: "Create Workflow",
|
|
edit: "Edit Workflow",
|
|
duplicate: "Duplicate Workflow",
|
|
};
|
|
|
|
const SUBMIT_LABELS: Record<DialogMode, string> = {
|
|
create: "Create",
|
|
edit: "Save",
|
|
duplicate: "Create Copy",
|
|
};
|
|
|
|
const PENDING_LABELS: Record<DialogMode, string> = {
|
|
create: "Creating...",
|
|
edit: "Saving...",
|
|
duplicate: "Creating...",
|
|
};
|
|
|
|
export function WorkflowDialog({
|
|
channels,
|
|
mode,
|
|
onOpenChange,
|
|
open,
|
|
workflow,
|
|
}: WorkflowDialogProps) {
|
|
const channelId =
|
|
mode === "edit" && workflow?.channelId
|
|
? workflow.channelId
|
|
: (channels[0]?.id ?? "");
|
|
|
|
const [selectedChannelId, setSelectedChannelId] = React.useState(channelId);
|
|
const [yamlDefinition, setYamlDefinition] = React.useState(() =>
|
|
getInitialYaml(mode, workflow),
|
|
);
|
|
const [savedWebhookInfo, setSavedWebhookInfo] = React.useState<{
|
|
relayHttpUrl: string;
|
|
webhookSecret: string;
|
|
workflowId: string;
|
|
} | null>(null);
|
|
|
|
const createMutation = useCreateWorkflowMutation(selectedChannelId);
|
|
const updateMutation = useUpdateWorkflowMutation(
|
|
workflow?.id ?? "",
|
|
workflow?.revision ?? "",
|
|
);
|
|
const mutation = mode === "edit" ? updateMutation : createMutation;
|
|
|
|
const selectedChannel =
|
|
channels.find((c) => c.id === selectedChannelId) ?? null;
|
|
|
|
const defaultChannelId = channels[0]?.id ?? "";
|
|
const workflowChannelId = workflow?.channelId ?? null;
|
|
const resetCreate = createMutation.reset;
|
|
const resetUpdate = updateMutation.reset;
|
|
|
|
// Re-initialize when dialog opens or workflow/mode changes
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
const newChannelId =
|
|
mode === "edit" && workflowChannelId
|
|
? workflowChannelId
|
|
: defaultChannelId;
|
|
setSelectedChannelId(newChannelId);
|
|
setYamlDefinition(getInitialYaml(mode, workflow));
|
|
setSavedWebhookInfo(null);
|
|
resetCreate();
|
|
resetUpdate();
|
|
}
|
|
}, [
|
|
open,
|
|
mode,
|
|
workflow,
|
|
workflowChannelId,
|
|
defaultChannelId,
|
|
resetCreate,
|
|
resetUpdate,
|
|
]);
|
|
|
|
const handleOpenChange = React.useCallback(
|
|
(nextOpen: boolean) => {
|
|
if (!nextOpen) {
|
|
resetCreate();
|
|
resetUpdate();
|
|
}
|
|
onOpenChange(nextOpen);
|
|
},
|
|
[onOpenChange, resetCreate, resetUpdate],
|
|
);
|
|
|
|
async function handleSubmit() {
|
|
if (!selectedChannelId || !yamlDefinition.trim()) return;
|
|
|
|
try {
|
|
const saved = await mutation.mutateAsync(yamlDefinition);
|
|
handleOpenChange(false);
|
|
if (saved.webhookSecret) {
|
|
const relayHttpUrl = await getRelayHttpUrl();
|
|
setSavedWebhookInfo({
|
|
relayHttpUrl,
|
|
webhookSecret: saved.webhookSecret,
|
|
workflowId: saved.workflow.id,
|
|
});
|
|
}
|
|
} catch {
|
|
// React Query stores the error; keep the dialog open.
|
|
}
|
|
}
|
|
|
|
const showChannelSelector = mode !== "edit" && channels.length > 1;
|
|
const showChannelInfo = mode !== "edit" && channels.length === 1;
|
|
|
|
return (
|
|
<>
|
|
<Dialog onOpenChange={handleOpenChange} open={open}>
|
|
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
|
|
<DialogHeader className="flex-shrink-0">
|
|
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
|
<DialogDescription>
|
|
{mode === "edit"
|
|
? "Modify the workflow definition."
|
|
: channels.length === 1
|
|
? "Create a workflow scoped to this channel."
|
|
: "Define a workflow and assign it to a channel."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
|
|
{showChannelSelector ? (
|
|
<div className="space-y-1.5">
|
|
<FieldLabel htmlFor="wf-channel-select">Channel</FieldLabel>
|
|
<ChannelCombobox
|
|
channels={channels}
|
|
disabled={mutation.isPending}
|
|
id="wf-channel-select"
|
|
onChange={(value) => {
|
|
mutation.reset();
|
|
setSelectedChannelId(value);
|
|
}}
|
|
value={selectedChannelId}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{selectedChannel
|
|
? `New workflows will belong to ${selectedChannel.name}.`
|
|
: "Join or create a channel before adding a workflow."}
|
|
</p>
|
|
</div>
|
|
) : (showChannelInfo || mode === "edit") && selectedChannel ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{mode === "edit"
|
|
? "Editing workflow in"
|
|
: "This workflow will be created in"}{" "}
|
|
<span className="font-medium text-foreground">
|
|
{selectedChannel.name}
|
|
</span>
|
|
.
|
|
</p>
|
|
) : null}
|
|
|
|
<WorkflowFormBuilder
|
|
disabled={mutation.isPending}
|
|
onChange={(yaml) => {
|
|
mutation.reset();
|
|
setYamlDefinition(yaml);
|
|
}}
|
|
yaml={yamlDefinition}
|
|
/>
|
|
|
|
{mutation.error instanceof Error ? (
|
|
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
{mutation.error.message}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex flex-shrink-0 justify-end gap-2 border-t border-border pt-4">
|
|
<Button
|
|
onClick={() => handleOpenChange(false)}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={
|
|
!selectedChannelId ||
|
|
!yamlDefinition.trim() ||
|
|
mutation.isPending
|
|
}
|
|
onClick={handleSubmit}
|
|
type="button"
|
|
>
|
|
{mutation.isPending ? PENDING_LABELS[mode] : SUBMIT_LABELS[mode]}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{savedWebhookInfo ? (
|
|
<WorkflowWebhookSecretDialog
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) {
|
|
setSavedWebhookInfo(null);
|
|
}
|
|
}}
|
|
open
|
|
relayHttpUrl={savedWebhookInfo.relayHttpUrl}
|
|
webhookSecret={savedWebhookInfo.webhookSecret}
|
|
workflowId={savedWebhookInfo.workflowId}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|