diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index db64168a36..a821614f1e 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -302,6 +302,116 @@ letter-spacing: 0.04em; } +/* U9/R8: palette Templates section — collapsible, grouped, filterable. */ +.wf-templates { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm); + border-bottom: 1px solid var(--border); +} + +.wf-templates-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.wf-templates-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text); + font-weight: 600; + cursor: pointer; +} + +.wf-templates-toggle:hover { + background: var(--bg-tertiary); +} + +.wf-templates-filter { + flex: 0 1 220px; + padding: var(--space-xs) var(--space-sm); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 0.8rem; +} + +.wf-templates-body { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.wf-templates-conflict { + padding: var(--space-xs) var(--space-sm); + background: color-mix(in srgb, var(--ws-error) 8%, var(--bg-secondary)); + border: 1px solid var(--ws-error); + border-radius: var(--radius-sm); + color: var(--ws-error); + font-size: 0.8rem; +} + +.wf-templates-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.wf-templates-group-title { + margin: 0; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); +} + +.wf-templates-entries { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.wf-templates-entry { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 0.8rem; + cursor: pointer; + transition: background var(--transition-fast); +} + +.wf-templates-entry:hover { + background: var(--bg-tertiary); +} + +.wf-templates-entry:disabled { + opacity: 0.6; + cursor: default; +} + +.wf-templates-badge { + padding: 0 var(--space-xs); + background: var(--accent-subtle, rgba(59, 130, 246, 0.12)); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 0.7rem; +} + /* Neutralize the fieldset wrapper so it only gates interactivity, not layout. */ .wf-inspector-fields { display: contents; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index dc94008c5d..115d8cf4d7 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -14,8 +14,8 @@ import { type Edge as FlowEdge, } from "@xyflow/react"; import { useTranslation } from "react-i18next"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload } from "lucide-react"; -import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, Library } from "lucide-react"; +import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, @@ -30,6 +30,8 @@ import { fetchModels, fetchAgents, fetchDiscoveredSkills, + fetchWorkflowStepTemplates, + fetchPluginWorkflowStepTemplates, type ModelInfo, } from "../api"; import type { Agent } from "../api"; @@ -47,6 +49,8 @@ import { emptyWorkflowIr, emptyWorkflowLayout, copyIrWithFreshIds, + insertFragment, + fragmentSeamConflicts, columnsOf, fieldsOf, columnsToBandNodes, @@ -154,6 +158,38 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, ]; +/** Map a step template to a single pre-configured editor node (kind + config), + * mirroring the U1 `stepInputToNode` converter's field mapping (mode → kind; + * prompt/scriptName/toolMode/gateMode/model overrides → config). Inserting one + * template thus produces the same node the steps→IR migration would. */ +function stepTemplateToNode(tpl: WorkflowStepTemplate): { + kind: WorkflowEditorNodeKind; + label: string; + config: Record; +} { + const config: Record = { + name: tpl.name, + // Always carry gateMode so a materialized node round-trips both modes. + gateMode: tpl.gateMode ?? "advisory", + }; + if (tpl.description) config.description = tpl.description; + + if (tpl.mode === "script") { + if (tpl.scriptName) config.scriptName = tpl.scriptName; + return { kind: "script", label: tpl.name, config }; + } + + // prompt mode (default) + config.prompt = tpl.prompt ?? ""; + config.toolMode = tpl.toolMode === "coding" ? "coding" : "readonly"; + // Model overrides only round-trip when BOTH are present (compiler requirement). + if (tpl.modelProvider && tpl.modelId) { + config.modelProvider = tpl.modelProvider; + config.modelId = tpl.modelId; + } + return { kind: "prompt", label: tpl.name, config }; +} + // Node kinds a user authors from the palette. Structural/derived nodes // (start/end and column bands — which map to data.kind "start") are excluded, so // a fresh start→end graph counts as trivial. Used by the palette-hint (R9). @@ -497,6 +533,26 @@ function InnerEditor({ // built-in pair so the select is never empty; replaced by the live catalog // (built-ins + plugin parsers) once GET /api/step-parsers resolves. const [stepParsers, setStepParsers] = useState([...BUILTIN_STEP_PARSERS]); + + // U9/R8: palette Templates section sources. Built-in + plugin step templates + // (fetched once on open) and the fragment definitions (derived from the loaded + // workflow list, kind === "fragment"). The collapsed state persists in + // localStorage; the inline conflict error is the persistent seam-duplication + // notice rendered inside the section. + const [stepTemplates, setStepTemplates] = useState([]); + const [pluginTemplates, setPluginTemplates] = useState< + Array<{ pluginId: string; template: WorkflowStepTemplate }> + >([]); + const templatesCollapsedStorageKey = "fusion:wf-templates-collapsed"; + const [templatesCollapsed, setTemplatesCollapsed] = useState(() => { + try { + return localStorage.getItem(templatesCollapsedStorageKey) === "1"; + } catch { + return false; + } + }); + const [templateFilter, setTemplateFilter] = useState(""); + const [templateConflict, setTemplateConflict] = useState(null); // Wrapper around so keyboard deletion can return focus to the // canvas container (R6) instead of leaving it on a now-removed node. const canvasRef = useRef(null); @@ -565,6 +621,73 @@ function InnerEditor({ }; }, [projectId]); + // U9/R8: built-in + plugin step templates for the palette Templates section. + // Fetched once on open; non-fatal on failure (the subsections simply stay + // empty and hide). Fragments come from the workflow list, not a separate fetch. + useEffect(() => { + let cancelled = false; + fetchWorkflowStepTemplates() + .then((res) => { + if (!cancelled) setStepTemplates(res.templates ?? []); + }) + .catch(() => { + // Non-fatal: Built-in steps subsection stays empty. + }); + fetchPluginWorkflowStepTemplates() + .then((res) => { + if (!cancelled) setPluginTemplates(res.templates ?? []); + }) + .catch(() => { + // Non-fatal: Plugin steps subsection stays empty. + }); + return () => { + cancelled = true; + }; + }, []); + + // Persist the Templates section collapsed state. + useEffect(() => { + try { + localStorage.setItem(templatesCollapsedStorageKey, templatesCollapsed ? "1" : "0"); + } catch { + // localStorage unavailable (private mode / SSR): non-fatal. + } + }, [templatesCollapsed]); + + // U9/R8: fragment definitions surface from the loaded workflow list (kind === + // "fragment"); they are excluded from the sidebar workflow list elsewhere. + const fragments = useMemo( + () => workflows.filter((w) => w.kind === "fragment"), + [workflows], + ); + + // U9/R8: alphabetical, filtered subsection entries. The filter (a single text + // input) matches across all groups by name and only appears once the combined + // entry count exceeds 8. Empty subsections are hidden by the render. + const templateGroups = useMemo(() => { + const q = templateFilter.trim().toLowerCase(); + const matches = (name: string) => !q || name.toLowerCase().includes(q); + const byName = (a: T, b: T) => + a.name.localeCompare(b.name); + + const fragmentEntries = [...fragments] + .sort(byName) + .filter((f) => matches(f.name)); + const stepEntries = [...stepTemplates] + .sort(byName) + .filter((s) => matches(s.name)); + const pluginEntries = [...pluginTemplates] + .sort((a, b) => a.template.name.localeCompare(b.template.name)) + .filter((p) => matches(p.template.name)); + + return { fragmentEntries, stepEntries, pluginEntries }; + }, [fragments, stepTemplates, pluginTemplates, templateFilter]); + + // Total entries available (pre-filter) — drives whether the filter input shows. + const templateTotalCount = + fragments.length + stepTemplates.length + pluginTemplates.length; + const hasAnyTemplate = templateTotalCount > 0; + // Composition violations (client mirror of validateColumnTraits). const columnViolations: TraitViolation[] = useMemo( () => (columns.length ? validateColumnsClient(columns, traitCatalog) : []), @@ -856,6 +979,46 @@ function InnerEditor({ [setNodes, t], ); + // U9/R8: insert a step template (built-in or plugin) as ONE pre-configured + // node, mapping its fields the same way the U1 converter does. Reuses the + // addNode path so layout/selection/dirty all behave identically. + const handleInsertStepTemplate = useCallback( + (tpl: WorkflowStepTemplate) => { + if (isBuiltin) return; + const { kind, label, config } = stepTemplateToNode(tpl); + addNode(kind, label, config); + }, + [isBuiltin, addNode], + ); + + // U9/R8: insert a fragment definition's body into the active graph. Pre-validates + // seam duplication via fragmentSeamConflicts; on conflict, surfaces a persistent + // inline error inside the Templates section and does NOT insert. Otherwise + // insertFragment remaps ids + rewires internal edges, landing nodes at a fixed + // offset from the canvas origin. + const handleInsertFragment = useCallback( + (fragment: WorkflowDefinition) => { + if (isBuiltin) return; + const conflicts = fragmentSeamConflicts(fragment.ir, nodes); + if (conflicts.length > 0) { + setTemplateConflict(conflicts.join(", ")); + return; + } + setTemplateConflict(null); + const result = insertFragment( + nodes, + edges, + fragment.ir, + { x: 240, y: 200 + (nodes.length % 4) * 40 }, + fragment.layout, + ); + setNodes(result.nodes); + setEdges(result.edges); + setSelectedNodeId(result.insertedNodeIds[0] ?? null); + }, + [isBuiltin, nodes, edges, setNodes, setEdges], + ); + // Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions // only; bands and foreach template children are left in place. Marks the // editor dirty automatically via the layout serialization in isDirty. @@ -1605,6 +1768,149 @@ function InnerEditor({ )} + {hasAnyTemplate && ( +
+
+ + {!templatesCollapsed && templateTotalCount > 8 && ( + setTemplateFilter(e.target.value)} + placeholder={t( + "workflowNodes.templateFilterPlaceholder", + "Filter templates", + )} + aria-label={t( + "workflowNodes.templateFilterLabel", + "Filter templates", + )} + /> + )} +
+ + {!templatesCollapsed && ( +
+ {templateConflict && ( +
+ {t( + "workflowNodes.templateSeamConflict", + 'This fragment duplicates the "{{seam}}" seam already on the canvas, so it can\'t be inserted.', + { seam: templateConflict }, + )} +
+ )} + + {templateGroups.fragmentEntries.length > 0 && ( +
+

+ {t("workflowNodes.templatesFragments", "Fragments")} +

+
+ {templateGroups.fragmentEntries.map((f) => ( + + ))} +
+
+ )} + + {templateGroups.stepEntries.length > 0 && ( +
+

+ {t("workflowNodes.templatesBuiltinSteps", "Built-in steps")} +

+
+ {templateGroups.stepEntries.map((s) => ( + + ))} +
+
+ )} + + {templateGroups.pluginEntries.length > 0 && ( +
+

+ {t("workflowNodes.templatesPluginSteps", "Plugin steps")} +

+
+ {templateGroups.pluginEntries.map(({ pluginId, template }) => ( + + ))} +
+
+ )} +
+ )} +
+ )} + {validationError && (
{validationError} diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 448cd8e088..b414490d34 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -26,11 +26,16 @@ vi.mock("../../api", () => ({ fetchModels: vi.fn(), fetchAgents: vi.fn(), fetchDiscoveredSkills: vi.fn(), + // Default to resolved empty lists so editors mounted by tests that don't + // exercise the Templates section don't reject the on-open prefetch. + fetchWorkflowStepTemplates: vi.fn().mockResolvedValue({ templates: [] }), + fetchPluginWorkflowStepTemplates: vi.fn().mockResolvedValue({ templates: [] }), })); import { fireEvent } from "@testing-library/react"; -import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps, exportWorkflow, importWorkflow, ApiRequestError } from "../../api"; +import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels, migrateLegacyWorkflowSteps, exportWorkflow, importWorkflow, ApiRequestError, fetchWorkflowStepTemplates, fetchPluginWorkflowStepTemplates } from "../../api"; import type { TraitCatalogEntry } from "../../api"; +import type { WorkflowStepTemplate } from "@fusion/core"; import { WorkflowNodeEditor } from "../WorkflowNodeEditor"; import { ConfirmDialogProvider } from "../../hooks/useConfirm"; @@ -1397,3 +1402,230 @@ describe("WorkflowNodeEditor — U5 import/export", () => { ); }); }); + +describe("WorkflowNodeEditor — U9 palette Templates section", () => { + // A clean prompt-mode built-in step template + a script-mode one. + function stepTpl(over: Partial = {}): WorkflowStepTemplate { + return { + id: "qa-check", + name: "QA Check", + description: "Run lint and tests", + category: "Quality", + prompt: "You are a QA tester.", + ...over, + }; + } + + function pluginTpl(): { pluginId: string; template: WorkflowStepTemplate } { + return { + pluginId: "acme-plugin", + template: stepTpl({ id: "acme-scan", name: "Acme Scan", prompt: "Scan it." }), + }; + } + + // A clean fragment (no seam) — one gate node. + function cleanFragment(over: Partial = {}): WorkflowDefinition { + return { + id: "WF-FRAG-A", + kind: "fragment", + name: "Lint fragment", + description: "A single lint step", + ir: { + version: "v1", + name: "Lint fragment", + nodes: [ + { id: "start", kind: "start" }, + { id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint", gateMode: "gate" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "lint", condition: "success" }, + { from: "lint", to: "end", condition: "success" }, + ], + }, + layout: {}, + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + ...over, + }; + } + + // A fragment that carries a "merge" seam (collides with def()'s merge node). + function mergeFragment(): WorkflowDefinition { + return { + id: "WF-FRAG-MERGE", + kind: "fragment", + name: "Boundary fragment", + description: "Carries a merge seam", + ir: { + version: "v1", + name: "Boundary fragment", + nodes: [ + { id: "start", kind: "start" }, + { id: "m", kind: "prompt", config: { seam: "merge" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "m", condition: "success" }, + { from: "m", to: "end", condition: "success" }, + ], + }, + layout: {}, + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + }; + } + + beforeEach(() => { + vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG); + vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]); + vi.mocked(fetchModels).mockResolvedValue({ models: [] }); + vi.mocked(migrateLegacyWorkflowSteps).mockResolvedValue({ migrated: 0, skipped: 0 }); + vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ templates: [] }); + vi.mocked(fetchPluginWorkflowStepTemplates).mockResolvedValue({ templates: [] }); + try { + localStorage.removeItem("fusion:wf-templates-collapsed"); + } catch { + // ignore + } + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders three subsections — alphabetical, with plugin owner badge", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([ + def(), + cleanFragment({ id: "WF-FRAG-B", name: "Zeta fragment" }), + cleanFragment({ id: "WF-FRAG-A", name: "Alpha fragment" }), + ]); + vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ + templates: [stepTpl({ id: "zed", name: "Zed Step" }), stepTpl({ id: "qa-check", name: "QA Check" })], + }); + vi.mocked(fetchPluginWorkflowStepTemplates).mockResolvedValue({ templates: [pluginTpl()] }); + + render( {}} addToast={() => {}} />); + + const section = await screen.findByTestId("wf-palette-templates"); + // Three subsection headers present. + expect(within(section).getByText("Fragments")).toBeInTheDocument(); + expect(within(section).getByText("Built-in steps")).toBeInTheDocument(); + expect(within(section).getByText("Plugin steps")).toBeInTheDocument(); + + // Fragments alphabetical: Alpha before Zeta. + expect(screen.getByTestId("wf-tpl-fragment-WF-FRAG-A")).toBeInTheDocument(); + const fragBtns = within(section) + .getAllByText(/fragment/i) + .map((n) => n.textContent); + const alphaIdx = fragBtns.findIndex((t) => /Alpha/.test(t ?? "")); + const zetaIdx = fragBtns.findIndex((t) => /Zeta/.test(t ?? "")); + expect(alphaIdx).toBeLessThan(zetaIdx); + + // Plugin entry shows the owner badge. + const pluginEntry = screen.getByTestId("wf-tpl-plugin-acme-scan"); + expect(pluginEntry).toHaveTextContent("acme-plugin"); + }); + + it("clicking a step-template entry adds a pre-configured node", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([def()]); + vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ + templates: [stepTpl({ id: "qa-check", name: "QA Check", prompt: "test it" })], + }); + + render( {}} addToast={() => {}} />); + await screen.findByTestId("wf-palette-templates"); + + const before = screen.queryAllByTestId("wf-node-prompt").length; + fireEvent.click(screen.getByTestId("wf-tpl-step-qa-check")); + await waitFor(() => + expect(screen.queryAllByTestId("wf-node-prompt").length).toBe(before + 1), + ); + }); + + it("clicking a fragment with a duplicate merge seam surfaces the inline conflict; no insertion", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([def(), mergeFragment()]); + + render( {}} addToast={() => {}} />); + await screen.findByTestId("wf-palette-templates"); + + const beforeNodes = document.querySelectorAll('[data-testid^="wf-node-"]').length; + fireEvent.click(screen.getByTestId("wf-tpl-fragment-WF-FRAG-MERGE")); + + const conflict = await screen.findByTestId("wf-tpl-conflict"); + expect(conflict).toHaveAttribute("role", "alert"); + expect(conflict).toHaveTextContent(/merge/); + // No node added. + expect(document.querySelectorAll('[data-testid^="wf-node-"]').length).toBe(beforeNodes); + }); + + it("clicking a clean fragment inserts its non-start/end nodes", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([def(), cleanFragment()]); + + render( {}} addToast={() => {}} />); + await screen.findByTestId("wf-palette-templates"); + + const beforeGates = screen.queryAllByTestId("wf-node-gate").length; + fireEvent.click(screen.getByTestId("wf-tpl-fragment-WF-FRAG-A")); + // cleanFragment has exactly one body node (the gate) after start/end strip. + await waitFor(() => + expect(screen.getAllByTestId("wf-node-gate").length).toBe(beforeGates + 1), + ); + }); + + it("filter input is absent with ≤8 entries and present with >8; filtering narrows entries", async () => { + // 1 fragment + 8 built-in steps = 9 entries (> 8). + const manySteps = Array.from({ length: 8 }, (_, i) => + stepTpl({ id: `s-${i}`, name: `Step ${i}` }), + ); + vi.mocked(fetchWorkflows).mockResolvedValue([def(), cleanFragment()]); + vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ templates: manySteps }); + + render( {}} addToast={() => {}} />); + await screen.findByTestId("wf-palette-templates"); + + const filter = await screen.findByTestId("wf-template-filter"); + // All 8 step entries present pre-filter. + expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(8); + // Filter to "Step 3" → only that step survives. + fireEvent.change(filter, { target: { value: "Step 3" } }); + await waitFor(() => expect(screen.getAllByTestId(/^wf-tpl-step-/).length).toBe(1)); + expect(screen.getByTestId("wf-tpl-step-s-3")).toBeInTheDocument(); + // Fragment (name "Lint fragment") no longer matches. + expect(screen.queryByTestId("wf-tpl-fragment-WF-FRAG-A")).not.toBeInTheDocument(); + }); + + it("filter input absent with ≤8 entries", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([def(), cleanFragment()]); + vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ + templates: [stepTpl(), stepTpl({ id: "two", name: "Two" })], + }); + + render( {}} addToast={() => {}} />); + await screen.findByTestId("wf-palette-templates"); + expect(screen.queryByTestId("wf-template-filter")).not.toBeInTheDocument(); + }); + + it("hides the Fragments subsection when no fragments exist", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([def()]); + vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ templates: [stepTpl()] }); + + render( {}} addToast={() => {}} />); + const section = await screen.findByTestId("wf-palette-templates"); + expect(within(section).queryByText("Fragments")).not.toBeInTheDocument(); + expect(within(section).getByText("Built-in steps")).toBeInTheDocument(); + }); + + it("disables all entries when the active workflow is a built-in", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef(), cleanFragment()]); + vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ templates: [stepTpl()] }); + vi.mocked(fetchPluginWorkflowStepTemplates).mockResolvedValue({ templates: [pluginTpl()] }); + + render( {}} addToast={() => {}} />); + await screen.findByTestId("wf-palette-templates"); + + expect(screen.getByTestId("wf-tpl-fragment-WF-FRAG-A")).toBeDisabled(); + expect(screen.getByTestId("wf-tpl-step-qa-check")).toBeDisabled(); + expect(screen.getByTestId("wf-tpl-plugin-acme-scan")).toBeDisabled(); + }); +}); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 914d487c72..e748b6a20f 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -6791,6 +6791,7 @@ "foreachWorktree": "Per-step worktree", "gateBlocks": "Gate (blocks)", "gateMode": "Gate mode", + "insertTemplate": "Insert template {{name}}", "interpreterOnly": "This workflow branches, so it runs on the graph interpreter — it can't compile to the linear step engine, but it will still run.", "joinAll": "All branches", "joinAny": "Any branch", @@ -6820,6 +6821,13 @@ "summaryHoldRelease": "Release: {{release}}", "summaryNotConfigured": "Not configured", "summaryReviewType": "{{type}} review", + "templateFilterLabel": "Filter templates", + "templateFilterPlaceholder": "Filter templates", + "templateSeamConflict": "This fragment duplicates the \"{{seam}}\" seam already on the canvas, so it can't be inserted.", + "templatesBuiltinSteps": "Built-in steps", + "templatesFragments": "Fragments", + "templatesPluginSteps": "Plugin steps", + "templatesSection": "Templates", "trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out." }, "workflows": {