feat(dashboard): palette templates section with fragments and step templates

This commit is contained in:
gsxdsm
2026-06-05 00:20:12 -07:00
parent 91284f516d
commit 0c0387264f
4 changed files with 659 additions and 3 deletions

View File

@@ -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;

View File

@@ -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<string, unknown>;
} {
const config: Record<string, unknown> = {
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<string[]>([...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<WorkflowStepTemplate[]>([]);
const [pluginTemplates, setPluginTemplates] = useState<
Array<{ pluginId: string; template: WorkflowStepTemplate }>
>([]);
const templatesCollapsedStorageKey = "fusion:wf-templates-collapsed";
const [templatesCollapsed, setTemplatesCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(templatesCollapsedStorageKey) === "1";
} catch {
return false;
}
});
const [templateFilter, setTemplateFilter] = useState("");
const [templateConflict, setTemplateConflict] = useState<string | null>(null);
// Wrapper around <ReactFlow> so keyboard deletion can return focus to the
// canvas container (R6) instead of leaving it on a now-removed node.
const canvasRef = useRef<HTMLDivElement>(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 = <T extends { name: string }>(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({
</div>
)}
{hasAnyTemplate && (
<section
className="wf-templates"
data-testid="wf-palette-templates"
aria-label={t("workflowNodes.templatesSection", "Templates")}
>
<div className="wf-templates-header">
<button
type="button"
className="wf-templates-toggle"
aria-expanded={!templatesCollapsed}
data-testid="wf-templates-toggle"
onClick={() => setTemplatesCollapsed((c) => !c)}
>
{templatesCollapsed ? (
<ChevronRight size={13} />
) : (
<ChevronDown size={13} />
)}
<Library size={13} />{" "}
{t("workflowNodes.templatesSection", "Templates")}
</button>
{!templatesCollapsed && templateTotalCount > 8 && (
<input
type="text"
className="wf-templates-filter"
data-testid="wf-template-filter"
value={templateFilter}
onChange={(e) => setTemplateFilter(e.target.value)}
placeholder={t(
"workflowNodes.templateFilterPlaceholder",
"Filter templates",
)}
aria-label={t(
"workflowNodes.templateFilterLabel",
"Filter templates",
)}
/>
)}
</div>
{!templatesCollapsed && (
<div className="wf-templates-body">
{templateConflict && (
<div
className="wf-templates-conflict"
role="alert"
data-testid="wf-tpl-conflict"
>
{t(
"workflowNodes.templateSeamConflict",
'This fragment duplicates the "{{seam}}" seam already on the canvas, so it can\'t be inserted.',
{ seam: templateConflict },
)}
</div>
)}
{templateGroups.fragmentEntries.length > 0 && (
<div className="wf-templates-group">
<h4 className="wf-templates-group-title">
{t("workflowNodes.templatesFragments", "Fragments")}
</h4>
<div className="wf-templates-entries">
{templateGroups.fragmentEntries.map((f) => (
<button
key={f.id}
type="button"
className="wf-templates-entry"
data-testid={`wf-tpl-fragment-${f.id}`}
disabled={isBuiltin}
aria-label={t(
"workflowNodes.insertTemplate",
"Insert template {{name}}",
{ name: f.name },
)}
onClick={() => handleInsertFragment(f)}
>
{f.name}
</button>
))}
</div>
</div>
)}
{templateGroups.stepEntries.length > 0 && (
<div className="wf-templates-group">
<h4 className="wf-templates-group-title">
{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}
</h4>
<div className="wf-templates-entries">
{templateGroups.stepEntries.map((s) => (
<button
key={s.id}
type="button"
className="wf-templates-entry"
data-testid={`wf-tpl-step-${s.id}`}
disabled={isBuiltin}
aria-label={t(
"workflowNodes.insertTemplate",
"Insert template {{name}}",
{ name: s.name },
)}
onClick={() => handleInsertStepTemplate(s)}
>
{s.name}
</button>
))}
</div>
</div>
)}
{templateGroups.pluginEntries.length > 0 && (
<div className="wf-templates-group">
<h4 className="wf-templates-group-title">
{t("workflowNodes.templatesPluginSteps", "Plugin steps")}
</h4>
<div className="wf-templates-entries">
{templateGroups.pluginEntries.map(({ pluginId, template }) => (
<button
key={`${pluginId}:${template.id}`}
type="button"
className="wf-templates-entry"
data-testid={`wf-tpl-plugin-${template.id}`}
disabled={isBuiltin}
aria-label={t(
"workflowNodes.insertTemplate",
"Insert template {{name}}",
{ name: template.name },
)}
onClick={() => handleInsertStepTemplate(template)}
>
{template.name}
<span className="wf-templates-badge">{pluginId}</span>
</button>
))}
</div>
</div>
)}
</div>
)}
</section>
)}
{validationError && (
<div className="wf-editor-banner" role="alert">
{validationError}

View File

@@ -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> = {}): 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> = {}): 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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();
});
});

View File

@@ -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": {