From 91284f516d53f9dc2dcb3c096b949da3186b68eb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 00:06:06 -0700 Subject: [PATCH] feat(dashboard): template picker on workflow creation --- .../app/components/WorkflowNodeEditor.css | 65 ++++++ .../app/components/WorkflowNodeEditor.tsx | 192 +++++++++++++++++- .../__tests__/WorkflowNodeEditor.test.tsx | 133 +++++++++++- packages/i18n/locales/en/app.json | 10 +- packages/i18n/src/resources.d.ts | 10 +- 5 files changed, 396 insertions(+), 14 deletions(-) diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index e60b994d70..db64168a36 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -737,6 +737,71 @@ } /* Create-workflow dialog (KTD-7). */ +/* Template picker (U4/R7): radiogroup of Blank + built-ins + user workflows. */ +.wf-template-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + max-height: 220px; + overflow-y: auto; + padding: var(--space-2xs); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.wf-template-section { + margin: var(--space-xs) 0 var(--space-2xs); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); +} + +.wf-template-option { + display: flex; + flex-direction: column; + gap: var(--space-2xs); + padding: var(--space-xs) var(--space-sm); + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text); +} + +.wf-template-option:hover { + background: var(--bg-hover); +} + +.wf-template-option.selected { + border-color: var(--accent); + background: var(--bg-active); +} + +.wf-template-option:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.wf-template-option-name { + font-size: 0.85rem; + font-weight: 600; +} + +.wf-template-option-desc { + font-size: 0.78rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wf-template-option-count { + font-size: 0.72rem; + color: var(--text-tertiary); +} + .wf-create-error { margin: var(--space-xs) 0 0; font-size: 0.8rem; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index e6ebe26b01..dc94008c5d 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -46,6 +46,7 @@ import { flowToIr, emptyWorkflowIr, emptyWorkflowLayout, + copyIrWithFreshIds, columnsOf, fieldsOf, columnsToBandNodes, @@ -170,16 +171,39 @@ const USER_NODE_KINDS: ReadonlySet = new Set copy") while untouched and inherits the source description. */ function CreateWorkflowDialog({ + workflows, onCreate, onClose, }: { - onCreate: (name: string, description: string) => Promise; + workflows: WorkflowDefinition[]; + onCreate: (name: string, description: string, template: WorkflowCreateTemplate) => Promise; onClose: () => void; }) { const { t } = useTranslation("app"); @@ -187,12 +211,88 @@ function CreateWorkflowDialog({ const [description, setDescription] = useState(""); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + // Tracks whether the user has edited the name; once true, selecting a template + // no longer overwrites it (R7: prefill only when untouched). + const [nameTouched, setNameTouched] = useState(false); const nameRef = useRef(null); + const optionRefs = useRef>([]); + + // Build the option list: Blank first (default), then built-in workflows, then + // the user's own kind="workflow" definitions. Fragments are excluded entirely. + const templates = useMemo(() => { + const blank: WorkflowCreateTemplate = { + id: null, + name: t("workflows.templateBlank", "Blank"), + description: t("workflows.templateBlankDescription", "Start from an empty start → end graph."), + nodeCount: 0, + builtin: false, + }; + const usable = workflows.filter((w) => w.kind !== "fragment"); + const toTemplate = (w: WorkflowDefinition): WorkflowCreateTemplate => ({ + id: w.id, + name: w.name, + description: w.description ?? "", + nodeCount: w.ir.nodes.length, + source: w, + builtin: isBuiltinWorkflowId(w.id), + }); + const builtins = usable.filter((w) => isBuiltinWorkflowId(w.id)).map(toTemplate); + const yours = usable.filter((w) => !isBuiltinWorkflowId(w.id)).map(toTemplate); + return [blank, ...builtins, ...yours]; + }, [workflows, t]); + + const [selectedIndex, setSelectedIndex] = useState(0); + const selected = templates[selectedIndex] ?? templates[0]; useEffect(() => { nameRef.current?.focus(); }, []); + // Apply a template selection: move the radio focus state and (R7) prefill the + // name (" copy") + description from the source, but only while the user + // has not edited the name. + const selectTemplate = useCallback( + (index: number) => { + const tmpl = templates[index]; + if (!tmpl) return; + setSelectedIndex(index); + if (!nameTouched) { + if (tmpl.id === null) { + setName(""); + setDescription(""); + } else { + setName(t("workflows.templateCopyName", "{{name}} copy", { name: tmpl.name })); + setDescription(tmpl.description); + } + } + if (error) setError(null); + }, + [templates, nameTouched, error, t], + ); + + // ArrowUp/Down move the radio selection; Enter confirms and shifts focus to + // the name input. Other keys (incl. Escape) bubble to the dialog handler. + const handleOptionKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown" || e.key === "ArrowRight") { + e.preventDefault(); + const next = Math.min(selectedIndex + 1, templates.length - 1); + selectTemplate(next); + optionRefs.current[next]?.focus(); + } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + e.preventDefault(); + const prev = Math.max(selectedIndex - 1, 0); + selectTemplate(prev); + optionRefs.current[prev]?.focus(); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectTemplate(selectedIndex); + nameRef.current?.focus(); + } + }, + [selectedIndex, templates.length, selectTemplate], + ); + const overlayProps = useOverlayDismiss(onClose); const handleSubmit = useCallback( @@ -206,16 +306,21 @@ function CreateWorkflowDialog({ setSubmitting(true); setError(null); try { - await onCreate(trimmed, description.trim()); + await onCreate(trimmed, description.trim(), selected); // Success path closes the dialog from the parent. } catch (err) { setError(getErrorMessage(err) || t("workflows.createFailed", "Failed to create workflow")); setSubmitting(false); } }, - [name, description, onCreate, t], + [name, description, selected, onCreate, t], ); + // Section boundaries for group headers (built-ins / your workflows). Blank is + // always index 0; built-ins follow, then user workflows. + const firstBuiltinIndex = templates.findIndex((tmpl) => tmpl.id !== null && tmpl.builtin); + const firstYoursIndex = templates.findIndex((tmpl) => tmpl.id !== null && !tmpl.builtin); + return (
+
+ {t("workflows.templatePickerLabel", "Start from")} +
+ {templates.map((tmpl, index) => { + const isSelected = index === selectedIndex; + const optionKey = tmpl.id ?? "blank"; + return ( +
+ {index === firstBuiltinIndex && firstBuiltinIndex >= 0 && ( +

+ {t("workflows.templateSectionBuiltin", "Built-in workflows")} +

+ )} + {index === firstYoursIndex && firstYoursIndex >= 0 && ( +

+ {t("workflows.templateSectionYours", "Your workflows")} +

+ )} +
{ + optionRefs.current[index] = el; + }} + role="radio" + aria-checked={isSelected} + tabIndex={isSelected ? 0 : -1} + className={`wf-template-option${isSelected ? " selected" : ""}`} + data-testid={tmpl.id === null ? "wf-template-option-blank" : `wf-template-option-${tmpl.id}`} + onClick={() => { + selectTemplate(index); + optionRefs.current[index]?.focus(); + }} + onKeyDown={handleOptionKeyDown} + > + {tmpl.name} + {tmpl.description && ( + {tmpl.description} + )} + {tmpl.id !== null && ( + + {t("workflows.templateNodeCount", "{{count}} nodes", { count: tmpl.nodeCount })} + + )} +
+
+ ); + })} +
+
{createOpen && ( - + )}
diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index fdff0b44e3..448cd8e088 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -76,7 +76,25 @@ function v2Def(): WorkflowDefinition { function builtinDef(): WorkflowDefinition { const d = v2Def(); - return { ...d, id: "builtin:coding", name: "Default coding workflow" }; + return { ...d, id: "builtin:coding", name: "Default coding workflow", description: "Ships with Fusion" }; +} + +function fragmentDef(): WorkflowDefinition { + return { + id: "WF-FRAG", + kind: "fragment", + name: "Lint fragment", + description: "A single lint step", + ir: { + version: "v1", + name: "Lint fragment", + nodes: [{ id: "lint", kind: "gate", config: { scriptName: "lint" } }], + edges: [], + }, + layout: { lint: { x: 0, y: 0 } }, + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + }; } function def(): WorkflowDefinition { @@ -872,6 +890,119 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir expect((nameInput as HTMLInputElement).value).toBe("Dup"); }); + // ── Template picker (U4/R7) ──────────────────────────────────────────────── + + it("shows Blank first (selected), built-ins, and user workflows; fragments absent", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef(), v2Def(), fragmentDef()]); + render( {}} addToast={() => {}} />); + // Open via the strip "New workflow" button (the empty CTA only shows with no + // workflows; here we have some, so use the toolbar button). + fireEvent.click(await screen.findByTestId("wf-new-workflow")); + await screen.findByTestId("wf-create-dialog"); + + const blank = screen.getByTestId("wf-template-option-blank"); + expect(blank).toHaveAttribute("aria-checked", "true"); + // Blank is the first radio in the group. + const group = screen.getByTestId("wf-template-list"); + const options = within(group).getAllByRole("radio"); + expect(options[0]).toBe(blank); + + // Built-in + user workflow present; fragment excluded. + expect(screen.getByTestId("wf-template-option-builtin:coding")).toBeInTheDocument(); + expect(screen.getByTestId("wf-template-option-WF-002")).toBeInTheDocument(); + expect(screen.queryByTestId("wf-template-option-WF-FRAG")).not.toBeInTheDocument(); + }); + + it("renders node count text for template entries", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + render( {}} addToast={() => {}} />); + fireEvent.click(await screen.findByTestId("wf-new-workflow")); + await screen.findByTestId("wf-create-dialog"); + // v2Def has 3 IR nodes (start, step, end). + expect(screen.getByTestId("wf-template-option-WF-002")).toHaveTextContent("3 nodes"); + }); + + it("with no user workflows lists Blank + built-ins only (no Your-workflows header)", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + render( {}} addToast={() => {}} />); + fireEvent.click(await screen.findByTestId("wf-new-workflow")); + await screen.findByTestId("wf-create-dialog"); + expect(screen.getByTestId("wf-template-option-blank")).toBeInTheDocument(); + expect(screen.getByTestId("wf-template-option-builtin:coding")).toBeInTheDocument(); + expect(screen.queryByText("Your workflows")).not.toBeInTheDocument(); + }); + + it("selecting a builtin template prefills ' copy' and inherits the description", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + render( {}} addToast={() => {}} />); + fireEvent.click(await screen.findByTestId("wf-new-workflow")); + await screen.findByTestId("wf-create-dialog"); + + fireEvent.click(screen.getByTestId("wf-template-option-builtin:coding")); + expect((screen.getByTestId("wf-create-name") as HTMLInputElement).value).toBe( + "Default coding workflow copy", + ); + expect((screen.getByTestId("wf-create-description") as HTMLTextAreaElement).value).toBe( + "Ships with Fusion", + ); + }); + + it("submitting a template seeds a fresh-ID copy: same node count, all ids differ, description inherited", async () => { + const builtin = builtinDef(); + vi.mocked(fetchWorkflows).mockResolvedValue([builtin]); + vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-NEW", name: "Default coding workflow copy" }); + render( {}} addToast={() => {}} />); + fireEvent.click(await screen.findByTestId("wf-new-workflow")); + await screen.findByTestId("wf-create-dialog"); + + fireEvent.click(screen.getByTestId("wf-template-option-builtin:coding")); + fireEvent.click(screen.getByTestId("wf-create-submit")); + + await waitFor(() => expect(createWorkflow).toHaveBeenCalled()); + const [input] = vi.mocked(createWorkflow).mock.calls[0]; + const created = input as { name: string; description?: string; kind?: string; ir: { nodes: { id: string }[] } }; + expect(created.kind).toBe("workflow"); + expect(created.description).toBe("Ships with Fusion"); + // Same node count as the source IR. + expect(created.ir.nodes).toHaveLength(builtin.ir.nodes.length); + // Every node id is fresh (none shared with the source). + const sourceIds = new Set(builtin.ir.nodes.map((n) => n.id)); + for (const n of created.ir.nodes) { + expect(sourceIds.has(n.id)).toBe(false); + } + }); + + it("blank flow seeds an emptyWorkflowIr-shaped graph (start → end)", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-NEW", name: "Fresh" }); + render( {}} addToast={() => {}} />); + fireEvent.click(await screen.findByTestId("wf-new-workflow")); + await screen.findByTestId("wf-create-dialog"); + + // Blank is default-selected; just name + submit. + fireEvent.change(screen.getByTestId("wf-create-name"), { target: { value: "Fresh" } }); + fireEvent.click(screen.getByTestId("wf-create-submit")); + + await waitFor(() => expect(createWorkflow).toHaveBeenCalled()); + const [input] = vi.mocked(createWorkflow).mock.calls[0]; + const created = input as { ir: { nodes: { kind: string }[]; edges: unknown[] } }; + expect(created.ir.nodes.map((n) => n.kind)).toEqual(["start", "end"]); + expect(created.ir.edges).toHaveLength(1); + }); + + it("ArrowDown moves the selected radio (keyboard a11y)", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + render( {}} addToast={() => {}} />); + fireEvent.click(await screen.findByTestId("wf-new-workflow")); + await screen.findByTestId("wf-create-dialog"); + + const blank = screen.getByTestId("wf-template-option-blank"); + expect(blank).toHaveAttribute("aria-checked", "true"); + fireEvent.keyDown(blank, { key: "ArrowDown" }); + expect(blank).toHaveAttribute("aria-checked", "false"); + expect(screen.getByTestId("wf-template-option-builtin:coding")).toHaveAttribute("aria-checked", "true"); + }); + // ── Delete confirm ───────────────────────────────────────────────────────── it("does not delete when no ConfirmDialogProvider is mounted (fallback cancels)", async () => { diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index f8f9f5bad9..914d487c72 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6861,7 +6861,15 @@ "saved": "Workflow saved", "savedNotCompilable": "Workflow saved but cannot be compiled", "saveFailed": "Failed to save workflow", - "selectOrCreate": "Select or create a workflow to start editing." + "selectOrCreate": "Select or create a workflow to start editing.", + "templateBlank": "Blank", + "templateBlankDescription": "Start from an empty start → end graph.", + "templateCopyName": "{{name}} copy", + "templateNodeCount_one": "{{count}} nodes", + "templateNodeCount_other": "{{count}} nodes", + "templatePickerLabel": "Start from", + "templateSectionBuiltin": "Built-in workflows", + "templateSectionYours": "Your workflows" }, "workflowSelector": { "switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 5b0378badd..05891c149d 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -6869,7 +6869,15 @@ export default interface Resources { "saveFailed": "Failed to save workflow", "saved": "Workflow saved", "savedNotCompilable": "Workflow saved but cannot be compiled", - "selectOrCreate": "Select or create a workflow to start editing." + "selectOrCreate": "Select or create a workflow to start editing.", + "templateBlank": "Blank", + "templateBlankDescription": "Start from an empty start → end graph.", + "templateCopyName": "{{name}} copy", + "templateNodeCount_one": "{{count}} nodes", + "templateNodeCount_other": "{{count}} nodes", + "templatePickerLabel": "Start from", + "templateSectionBuiltin": "Built-in workflows", + "templateSectionYours": "Your workflows" }, "workspace": { "projectRoot": "Project Root",