feat(dashboard): palette templates section with fragments and step templates
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user