feat(FN-6880): author optional-group container in the node editor (U4)
Render and author optional-group as a third group-container kind beside foreach/loop: register the node type (OptionalGroupNode) so it renders with a header + defaultOn badge and parentId template children, treat it as a group everywhere in workflow-flow-mapping (irToFlow children, flowToIr template reassembly, cascade-delete, condition-editable), add a palette entry, and an inspector defaultOn toggle. Includes a node-help entry and round-trip / toggle / cascade-delete tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -236,6 +236,8 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
|
||||
// Step-inversion (KTD-3/4/12/15).
|
||||
{ kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } },
|
||||
{ kind: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } },
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group container holds a template subgraph run once when the task enables it (per-task `enabledWorkflowSteps`, seeded from `defaultOn`) and skipped otherwise.
|
||||
{ kind: "optional-group", label: "Optional group", icon: ToggleRight, presetConfig: { defaultOn: false } },
|
||||
{ kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } },
|
||||
{ kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } },
|
||||
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
|
||||
@@ -287,6 +289,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"notify",
|
||||
@@ -1356,16 +1359,19 @@ function InnerEditor({
|
||||
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
|
||||
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
|
||||
|
||||
if (kind === "foreach" || kind === "loop") {
|
||||
if (kind === "foreach" || kind === "loop" || kind === "optional-group") {
|
||||
// Template groups render as React Flow group nodes. Foreach seeds the
|
||||
// required step-execute seam; loop seeds a regular prompt so authors can
|
||||
// wire the repeated body immediately. The group node must precede its
|
||||
// child for React Flow's parent extent to apply.
|
||||
// required step-execute seam; loop + optional-group seed a regular prompt
|
||||
// so authors can wire the body immediately. The group node must precede
|
||||
// its child for React Flow's parent extent to apply.
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is authored exactly like a foreach/loop region — drop nodes inside; the subgraph runs once when the task enables the group.
|
||||
const childId = foreachChildFlowId(id, newNodeId());
|
||||
const childLabel =
|
||||
kind === "foreach"
|
||||
? t("workflowNodes.stepExecuteLabel", "Step execute")
|
||||
: t("workflowNodes.loopStepLabel", "Loop step");
|
||||
: kind === "optional-group"
|
||||
? t("workflowNodes.optionalGroupStepLabel", "Optional step")
|
||||
: t("workflowNodes.loopStepLabel", "Loop step");
|
||||
const childConfig = kind === "foreach" ? { seam: "step-execute" } : { prompt: "" };
|
||||
setNodes((ns) => [
|
||||
...ns,
|
||||
@@ -1941,11 +1947,14 @@ function InnerEditor({
|
||||
let errorBadge: string | undefined;
|
||||
if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column");
|
||||
if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message;
|
||||
const isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop";
|
||||
const isTemplateGroup =
|
||||
n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group";
|
||||
const emptyHint =
|
||||
n.data.kind === "loop"
|
||||
? t("workflowNodes.loopEmptyHint", "Drag loop steps here")
|
||||
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
|
||||
: n.data.kind === "optional-group"
|
||||
? t("workflowNodes.optionalGroupEmptyHint", "Drag optional steps here")
|
||||
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
|
||||
const templateEmpty = isTemplateGroup ? (childCount.get(n.id) ?? 0) === 0 : undefined;
|
||||
if (
|
||||
errorBadge === n.data.errorBadge &&
|
||||
@@ -4108,6 +4117,27 @@ function InnerEditor({
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{/* FNXC:WorkflowOptionalGroup 2026-06-21-11:30: The optional-group inspector exposes the workflow-author `defaultOn` default (whether new tasks enable the group). The group name reuses the shared Name field above; the body is authored by dropping nodes inside, identical to foreach/loop. */}
|
||||
{selectedNode.data.kind === "optional-group" ? (
|
||||
<>
|
||||
<label className="wf-field wf-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="wf-optional-group-default-on"
|
||||
checked={Boolean(selectedNode.data.config?.defaultOn)}
|
||||
onChange={(e) => updateSelectedData({ config: { defaultOn: e.target.checked } })}
|
||||
/>
|
||||
<span>{t("workflowNodes.optionalGroupDefaultOn", "Enabled by default for new tasks")}</span>
|
||||
</label>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t(
|
||||
"workflowNodes.optionalGroupNote",
|
||||
"Runs the steps inside this group once when the task enables it (seeded from this default), and skips them when disabled. Drop the optional steps into the region.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "step-review" ? (
|
||||
<>
|
||||
<label className="wf-field">
|
||||
|
||||
@@ -1475,6 +1475,51 @@ function stepwiseDef(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
/** A v2 workflow with an optional-group container (defaultOn:false) holding one
|
||||
* template child, so the editor's optional-group surfaces have something to
|
||||
* render, toggle, and delete. */
|
||||
function optionalGroupDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-OPT",
|
||||
kind: "workflow",
|
||||
name: "Optional",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "Optional",
|
||||
columns: [
|
||||
{ id: "plan", name: "Plan", traits: [{ trait: "intake" }] },
|
||||
{ id: "in-progress", name: "In progress", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
name: "Browser verification",
|
||||
template: {
|
||||
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
},
|
||||
layout: {},
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
@@ -1531,6 +1576,80 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
expect(template.nodes[0].config?.seam).toBe("step-execute");
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group must be
|
||||
// authorable like a foreach/loop — added from the palette as a registered group
|
||||
// container (not react-flow__node-default), filled with nodes, named, toggled
|
||||
// for defaultOn, and deleted with its children cascaded.
|
||||
it("adds an optional-group from the palette and round-trips its template on save", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Optional group").closest("button")!);
|
||||
// Renders via the registered group component (wf-node-optional-group), NOT
|
||||
// React Flow's default fallback.
|
||||
await waitFor(() => expect(screen.getByTestId("wf-node-optional-group")).toBeInTheDocument(), { timeout: 5000 });
|
||||
// No empty hint — the palette seeded an optional step inside.
|
||||
expect(screen.queryByTestId("wf-optional-group-empty")).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { id: string; kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const group = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(group).toBeTruthy();
|
||||
const template = group!.config!.template as { nodes: unknown[] };
|
||||
expect(template.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("toggles optional-group defaultOn, marks the editor dirty, and persists on save", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...optionalGroupDef(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
const group = await screen.findByTestId("wf-node-optional-group");
|
||||
fireEvent.click(group);
|
||||
|
||||
const toggle = await screen.findByTestId("wf-optional-group-default-on");
|
||||
expect((toggle as HTMLInputElement).checked).toBe(false);
|
||||
fireEvent.click(toggle);
|
||||
expect((toggle as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const opt = ir.nodes.find((n) => n.kind === "optional-group");
|
||||
expect(opt!.config!.defaultOn).toBe(true);
|
||||
});
|
||||
|
||||
it("deletes an optional-group and removes its parentId children (no orphans)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const group = await screen.findByTestId("wf-node-optional-group");
|
||||
// The seeded template child renders as a parented flow node.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(group);
|
||||
fireEvent.click(await screen.findByTestId("wf-delete-node"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-node-optional-group")).not.toBeInTheDocument());
|
||||
// The template child is gone too (cascade) — no orphaned parentId node.
|
||||
expect(
|
||||
document.querySelector(`.react-flow__node[data-id="${foreachChildFlowId("opt", "verify")}"]`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits foreach mode/isolation/concurrency/maxReworkCycles inspector fields", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
@@ -766,6 +766,104 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
expect(template.edges).toEqual([{ from: "try", to: "check", condition: "success" }]);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group's template
|
||||
// subgraph must round-trip through the editor's parentId-child rendering exactly
|
||||
// like foreach/loop — irToFlow renders the template as parented children;
|
||||
// flowToIr reassembles them into config.template, preserving defaultOn/name.
|
||||
it("round-trips an optional-group template (children partitioned by parentId) losslessly", () => {
|
||||
const optionalIr: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: "optional",
|
||||
columns: ir.columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: true,
|
||||
name: "Browser verification",
|
||||
template: {
|
||||
nodes: [
|
||||
{ id: "verify", kind: "prompt", config: { prompt: "verify in browser" } },
|
||||
{ id: "check", kind: "gate", config: { prompt: "ok?" } },
|
||||
],
|
||||
edges: [{ from: "verify", to: "check", condition: "success" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
const def = makeDef(optionalIr);
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const columns = columnsOf(def);
|
||||
|
||||
// The optional-group renders via the registered group component (type
|
||||
// "optional-group", NOT react-flow__node-default) with parented children.
|
||||
const group = nodes.find((n) => n.id === "opt");
|
||||
expect(group?.type).toBe("optional-group");
|
||||
expect(group?.data.kind).toBe("optional-group");
|
||||
// The group node keeps defaultOn/name; the template is stripped onto children.
|
||||
expect(group?.data.config?.defaultOn).toBe(true);
|
||||
expect((group?.data.config as Record<string, unknown>)?.template).toBeUndefined();
|
||||
const children = nodes.filter((n) => n.parentId === "opt");
|
||||
expect(children.map((c) => templateNodeIdFromChild("opt", c.id)).sort()).toEqual(["check", "verify"]);
|
||||
|
||||
const { ir: out } = flowToIr("optional", nodes, edges, columns);
|
||||
if (out.version !== "v2") throw new Error("expected v2");
|
||||
const opt = out.nodes.find((n) => n.id === "opt");
|
||||
expect(opt?.kind).toBe("optional-group");
|
||||
const cfg = opt?.config as Record<string, unknown>;
|
||||
expect(cfg.defaultOn).toBe(true);
|
||||
expect(cfg.name).toBe("Browser verification");
|
||||
const template = cfg.template as { nodes: { id: string }[]; edges: { from: string; to: string }[] };
|
||||
expect(template.nodes.map((n) => n.id)).toEqual(["verify", "check"]);
|
||||
expect(template.edges).toEqual([{ from: "verify", to: "check", condition: "success" }]);
|
||||
// Top-level edges exclude the intra-template ones.
|
||||
expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual(["start->opt", "opt->end"]);
|
||||
});
|
||||
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
|
||||
// cascade its parentId children (no orphans) — same rule foreach/loop follow.
|
||||
it("cascade-deletes an optional-group's template children", () => {
|
||||
const optionalIr: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: "optional-del",
|
||||
columns: ir.columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{
|
||||
id: "opt",
|
||||
kind: "optional-group",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [{ id: "verify", kind: "prompt", config: { prompt: "verify" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "opt", condition: "success" },
|
||||
{ from: "opt", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
const { nodes, edges } = irToFlow(makeDef(optionalIr));
|
||||
expect(nodes.some((n) => n.parentId === "opt")).toBe(true);
|
||||
const result = cascadeDelete(nodes, edges, ["opt"]);
|
||||
expect(result.nodes.some((n) => n.id === "opt")).toBe(false);
|
||||
expect(result.nodes.some((n) => n.parentId === "opt")).toBe(false);
|
||||
});
|
||||
|
||||
it("inserts loop fragments with their template children intact", () => {
|
||||
const fragment: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell } from "lucide-react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell, ToggleRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { nodeConfigSummary } from "./node-summary";
|
||||
import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
|
||||
@@ -28,6 +28,7 @@ export type WorkflowEditorNodeKind =
|
||||
| "join"
|
||||
| "foreach"
|
||||
| "loop"
|
||||
| "optional-group"
|
||||
| WorkflowNodeKindStepReview
|
||||
| WorkflowNodeKindParseSteps
|
||||
| "code"
|
||||
@@ -65,6 +66,7 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
|
||||
join: Merge,
|
||||
foreach: Repeat,
|
||||
loop: Repeat,
|
||||
"optional-group": ToggleRight,
|
||||
[WORKFLOW_NODE_KIND_STEP_REVIEW]: ClipboardCheck,
|
||||
[WORKFLOW_NODE_KIND_PARSE_STEPS]: ListChecks,
|
||||
code: Code2,
|
||||
@@ -197,6 +199,42 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
|
||||
An `optional-group` renders as a React Flow group container (mirroring `ForeachGroupNode`/`LoopGroupNode`): template nodes are children (parentId = group id). The header shows the group name plus a `defaultOn` badge ("default on" / "default off") so an author can see, at a glance, whether new tasks enable this group. An unregistered kind falls back to `react-flow__node-default` with missing children — registration in `workflowNodeTypes` (below) is what keeps the container rendering with its body.
|
||||
*/
|
||||
function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
const { t } = useTranslation("app");
|
||||
const defaultOn = data.config?.defaultOn === true;
|
||||
const isEmpty = data.templateEmpty === true;
|
||||
return (
|
||||
<div
|
||||
className={`wf-foreach-group wf-optional-group${data.errorBadge ? " wf-node--error" : ""}`}
|
||||
data-testid="wf-node-optional-group"
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div className="wf-foreach-header">
|
||||
<span className="wf-node-icon">
|
||||
<ToggleRight size={14} aria-hidden />
|
||||
</span>
|
||||
<span className="wf-node-label">{data.label || "optional-group"}</span>
|
||||
<span className="wf-node-badge" data-testid="wf-optional-group-default-badge">
|
||||
{defaultOn
|
||||
? t("workflowNodes.optionalGroupDefaultOn", "default on")
|
||||
: t("workflowNodes.optionalGroupDefaultOff", "default off")}
|
||||
</span>
|
||||
</div>
|
||||
{isEmpty && (
|
||||
<div className="wf-foreach-empty" data-testid="wf-optional-group-empty">
|
||||
{data.emptyHint || "Drag optional steps here"}
|
||||
</div>
|
||||
)}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const workflowNodeTypes = {
|
||||
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
|
||||
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />,
|
||||
@@ -209,6 +247,7 @@ export const workflowNodeTypes = {
|
||||
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
|
||||
foreach: ({ data }: NodeProps) => <ForeachGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
loop: ({ data }: NodeProps) => <LoopGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
"optional-group": ({ data }: NodeProps) => <OptionalGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
"step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
|
||||
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
|
||||
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,
|
||||
|
||||
@@ -16,6 +16,7 @@ const EDITOR_KINDS = [
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"code",
|
||||
|
||||
@@ -141,6 +141,18 @@ const NODE_HELP: Record<string, NodeHelp> = {
|
||||
outputs: "The final iteration's result.",
|
||||
edges: "One outgoing edge (success) on exit. Exits on condition match, max iterations, or timeout.",
|
||||
},
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: An optional-group is a container whose body runs once when the task enables it and is skipped otherwise. Enable state is the per-task `enabledWorkflowSteps` facet, seeded from the group's `defaultOn`.
|
||||
"optional-group": {
|
||||
title: "Optional group",
|
||||
summary:
|
||||
"Holds a group of steps that run only when the task has this group enabled. Enabled tasks run the group's steps once at this position; disabled tasks pass straight through. Renders as a group you drop step nodes into.",
|
||||
configure:
|
||||
"Set the group Name and whether it is Enabled by default for new tasks (defaultOn). A task can override the default per-task. Drop the optional steps inside the region.",
|
||||
inputs: "The task arriving from upstream, plus prior context.",
|
||||
outputs: "The group's result when enabled; an unchanged pass-through when disabled.",
|
||||
edges:
|
||||
"success once the group finishes (or is skipped). A template failure inside an enabled group routes the group's failure edge.",
|
||||
},
|
||||
"step-review": {
|
||||
title: "Step review",
|
||||
summary:
|
||||
|
||||
@@ -38,6 +38,16 @@ interface WorkflowLoopConfig {
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-11:30:
|
||||
An `optional-group` is a third container kind alongside `foreach`/`loop`. It carries `defaultOn`/`name` plus a `template:{nodes,edges}` subgraph authored inline as React Flow `parentId` children (reusing the `foreachChildFlowId` namespacing). It is special-cased everywhere foreach/loop are: group-template detection, child reassembly in flowToIr, intra-template edge folding, cascade delete, and condition-editability. Single-pass, no rework/iteration — but the editor mapping treats its template identically to foreach/loop.
|
||||
*/
|
||||
interface WorkflowOptionalGroupConfig {
|
||||
defaultOn?: boolean;
|
||||
name?: string;
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
|
||||
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape
|
||||
// can migrate; callers should prefer WorkflowFieldDefinition directly.
|
||||
@@ -170,6 +180,7 @@ const SAME_KIND_EDITOR_NODE_KINDS = new Set<WorkflowIrNodeKind>([
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"optional-group",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"code",
|
||||
@@ -263,10 +274,17 @@ function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined {
|
||||
return cfg as WorkflowLoopConfig;
|
||||
}
|
||||
|
||||
function optionalGroupConfigOf(node: WorkflowIrNode): WorkflowOptionalGroupConfig | undefined {
|
||||
if (node.kind !== "optional-group") return undefined;
|
||||
const cfg = node.config as Partial<WorkflowOptionalGroupConfig> | undefined;
|
||||
if (!cfg || !cfg.template) return undefined;
|
||||
return cfg as WorkflowOptionalGroupConfig;
|
||||
}
|
||||
|
||||
function groupTemplateConfigOf(
|
||||
node: WorkflowIrNode,
|
||||
): WorkflowForeachConfig | WorkflowLoopConfig | undefined {
|
||||
return foreachConfigOf(node) ?? loopConfigOf(node);
|
||||
): WorkflowForeachConfig | WorkflowLoopConfig | WorkflowOptionalGroupConfig | undefined {
|
||||
return foreachConfigOf(node) ?? loopConfigOf(node) ?? optionalGroupConfigOf(node);
|
||||
}
|
||||
|
||||
/** CSS class for an edge given its condition + rework kind. Rework takes
|
||||
@@ -451,7 +469,9 @@ export function flowToIr(
|
||||
}
|
||||
}
|
||||
const groupIds = new Set(
|
||||
topNodes.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop").map((n) => n.id),
|
||||
topNodes
|
||||
.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop" || n.data.kind === "optional-group")
|
||||
.map((n) => n.id),
|
||||
);
|
||||
const hasFields = Array.isArray(fields) && fields.length > 0;
|
||||
const hasSettings = Array.isArray(settings) && settings.length > 0;
|
||||
@@ -477,8 +497,19 @@ export function flowToIr(
|
||||
}
|
||||
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
|
||||
}
|
||||
if (data.kind === "foreach" || data.kind === "loop" || originalKind === "retry-backoff") {
|
||||
if (originalKind && originalKind !== "foreach" && originalKind !== "loop" && originalKind !== "retry-backoff") {
|
||||
if (
|
||||
data.kind === "foreach" ||
|
||||
data.kind === "loop" ||
|
||||
data.kind === "optional-group" ||
|
||||
originalKind === "retry-backoff"
|
||||
) {
|
||||
if (
|
||||
originalKind &&
|
||||
originalKind !== "foreach" &&
|
||||
originalKind !== "loop" &&
|
||||
originalKind !== "optional-group" &&
|
||||
originalKind !== "retry-backoff"
|
||||
) {
|
||||
return { id: localId, kind: originalKind, config: config && Object.keys(config).length ? config : undefined };
|
||||
}
|
||||
// Reassemble the template from this group's children.
|
||||
@@ -608,9 +639,10 @@ function isProtectedFromDelete(node: FlowNode<WorkflowFlowNodeData>): boolean {
|
||||
* Delete the requested node and/or edge ids from the flow graph, applying R6's
|
||||
* cascade rules:
|
||||
* - Deleting a node removes ALL edges incident to it (no auto-bridging).
|
||||
* - Deleting a `foreach`/`loop` group node also deletes its template children
|
||||
* (nodes with `parentId === groupId`) and every edge incident to those
|
||||
* children (React Flow does not cascade parents — handled explicitly).
|
||||
* - Deleting a `foreach`/`loop`/`optional-group` group node also deletes its
|
||||
* template children (nodes with `parentId === groupId`) and every edge
|
||||
* incident to those children (React Flow does not cascade parents — handled
|
||||
* explicitly).
|
||||
* - `start`/`end` nodes and column band nodes are never deleted: they are
|
||||
* filtered out of the requested ids up front (and their incident edges are
|
||||
* therefore preserved).
|
||||
@@ -633,7 +665,7 @@ export function cascadeDelete(
|
||||
const node = nodeById.get(id);
|
||||
if (!node || isProtectedFromDelete(node)) continue;
|
||||
deleteNodeIds.add(id);
|
||||
if (node.data.kind === "foreach" || node.data.kind === "loop") {
|
||||
if (node.data.kind === "foreach" || node.data.kind === "loop" || node.data.kind === "optional-group") {
|
||||
for (const child of nodes) {
|
||||
if (child.parentId === id) deleteNodeIds.add(child.id);
|
||||
}
|
||||
@@ -660,7 +692,7 @@ export function cascadeDelete(
|
||||
|
||||
/** Editor node kinds whose edges expose a success/failure condition select
|
||||
* (KTD-2). step-review uses verdict controls; all other kinds are read-only. */
|
||||
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop"]);
|
||||
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop", "optional-group"]);
|
||||
|
||||
/** Decide what the edge inspector renders for an edge sourced from `sourceKind`:
|
||||
* - "verdicts": step-review verdict select + rework checkbox (existing);
|
||||
|
||||
Reference in New Issue
Block a user