FN-7412: connect template block boundary guides
Connect workflow template containers to their internal entry and exit steps with visual-only boundary guides. - Generalize optional-group boundary metadata and generated guide edges to foreach and loop template containers. - Render non-connectable template boundary handles on foreach, loop, and optional-group nodes while keeping saved IR free of visual-only topology. - Add coverage for stepwise foreach rendering, live boundary refresh, mobile graph filtering, and changeset release notes. Files changed: .changeset/fn-7412-template-boundary-connectors.md | 7 ++ .../app/components/WorkflowNodeEditor.tsx | 13 +- .../__tests__/WorkflowNodeEditor.test.tsx | 16 +-- .../__tests__/workflow-flow-mapping.test.ts | 135 +++++++++++++++++++-- .../__tests__/workflow-mobile-graph.test.ts | 20 +++ .../app/components/nodes/WorkflowNodeTypes.tsx | 25 ++-- .../app/components/workflow-flow-mapping.ts | 117 +++++++++--------- 7 files changed, 245 insertions(+), 88 deletions(-) Fusion-Task-Id: FN-7412 Fusion-Task-Lineage: 2b36de80-b1d0-4a43-85e5-ce47ba9ac7ff Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7412-template-boundary-connectors.md
Normal file
7
.changeset/fn-7412-template-boundary-connectors.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Show workflow template block boundary connectors in the graph editor.
|
||||
category: fix
|
||||
dev: Adds visual-only foreach/loop/optional-group template boundary edges that are filtered from persisted IR.
|
||||
@@ -82,7 +82,7 @@ import {
|
||||
edgeConditionEditability,
|
||||
buildConnectionEdge,
|
||||
cascadeDelete,
|
||||
refreshOptionalGroupVisualBoundaries,
|
||||
refreshTemplateContainerVisualBoundaries,
|
||||
WF_EDGE_INTERACTION_WIDTH,
|
||||
FOREACH_GROUP_WIDTH,
|
||||
FOREACH_GROUP_HEIGHT,
|
||||
@@ -1380,7 +1380,7 @@ function InnerEditor({
|
||||
return;
|
||||
}
|
||||
setEdges((eds) => {
|
||||
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, [...eds, result.edge]);
|
||||
const refreshed = refreshTemplateContainerVisualBoundaries(nodes, [...eds, result.edge]);
|
||||
setNodes(refreshed.nodes);
|
||||
return refreshed.edges;
|
||||
});
|
||||
@@ -1397,7 +1397,7 @@ function InnerEditor({
|
||||
(changes: EdgeChange<FlowEdge>[]) => {
|
||||
setEdges((eds) => {
|
||||
const changedEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
|
||||
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, changedEdges);
|
||||
const refreshed = refreshTemplateContainerVisualBoundaries(nodes, changedEdges);
|
||||
setNodes(refreshed.nodes);
|
||||
return refreshed.edges;
|
||||
});
|
||||
@@ -1493,8 +1493,7 @@ function InnerEditor({
|
||||
deletable: true,
|
||||
},
|
||||
] satisfies FlowNode<WorkflowFlowNodeData>[];
|
||||
if (kind !== "optional-group") return nextNodes;
|
||||
const refreshed = refreshOptionalGroupVisualBoundaries(nextNodes, edges);
|
||||
const refreshed = refreshTemplateContainerVisualBoundaries(nextNodes, edges);
|
||||
setEdges(refreshed.edges);
|
||||
return refreshed.nodes;
|
||||
});
|
||||
@@ -1813,7 +1812,7 @@ function InnerEditor({
|
||||
className: edgeClassName(condition, rework),
|
||||
};
|
||||
});
|
||||
const refreshed = refreshOptionalGroupVisualBoundaries(nodes, updated);
|
||||
const refreshed = refreshTemplateContainerVisualBoundaries(nodes, updated);
|
||||
setNodes(refreshed.nodes);
|
||||
return refreshed.edges;
|
||||
});
|
||||
@@ -1831,7 +1830,7 @@ function InnerEditor({
|
||||
let next: { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } | null = null;
|
||||
setNodes((ns) => {
|
||||
const deleted = cascadeDelete(ns, edges, idSet);
|
||||
next = refreshOptionalGroupVisualBoundaries(deleted.nodes, deleted.edges);
|
||||
next = refreshTemplateContainerVisualBoundaries(deleted.nodes, deleted.edges);
|
||||
return next.nodes;
|
||||
});
|
||||
if (next) setEdges((next as { edges: FlowEdge[] }).edges);
|
||||
|
||||
@@ -269,11 +269,11 @@ function edgeRenderableAssertion(definition: WorkflowDefinition) {
|
||||
);
|
||||
expect(edge.zIndex, `${definition.id} edge ${edge.id} z-index`).toBeGreaterThan(0);
|
||||
if (isVisualOnlyWorkflowEdge(edge) && edge.data?.boundary === "entry") {
|
||||
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBe("optional-boundary-entry");
|
||||
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBe("template-boundary-entry");
|
||||
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined();
|
||||
} else if (isVisualOnlyWorkflowEdge(edge) && edge.data?.boundary === "exit") {
|
||||
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined();
|
||||
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBe("optional-boundary-exit");
|
||||
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBe("template-boundary-exit");
|
||||
} else {
|
||||
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined();
|
||||
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined();
|
||||
@@ -2049,10 +2049,10 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
*/
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.body.querySelector(`.react-flow__handle.source[data-nodeid="${seededGroupId}"][data-handleid="optional-boundary-entry"]`),
|
||||
document.body.querySelector(`.react-flow__handle.source[data-nodeid="${seededGroupId}"][data-handleid="template-boundary-entry"]`),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
document.body.querySelector(`.react-flow__handle.target[data-nodeid="${seededGroupId}"][data-handleid="optional-boundary-exit"]`),
|
||||
document.body.querySelector(`.react-flow__handle.target[data-nodeid="${seededGroupId}"][data-handleid="template-boundary-exit"]`),
|
||||
).toBeInTheDocument();
|
||||
expect(document.body.querySelector(`.react-flow__handle.target[data-nodeid="${seededChildId}"][data-handlepos="left"]`)).toBeInTheDocument();
|
||||
expect(document.body.querySelector(`.react-flow__handle.source[data-nodeid="${seededChildId}"][data-handlepos="right"]`)).toBeInTheDocument();
|
||||
@@ -2512,8 +2512,8 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
|
||||
|
||||
const boundaryEdges = flow.edges.filter((edge) => isVisualOnlyWorkflowEdge(edge) && (edge.source === groupId || edge.target === groupId));
|
||||
expect(boundaryEdges, `${groupId} visual boundary edges`).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ source: groupId, sourceHandle: "optional-boundary-entry", target: childFlowId }),
|
||||
expect.objectContaining({ source: childFlowId, target: groupId, targetHandle: "optional-boundary-exit" }),
|
||||
expect.objectContaining({ source: groupId, sourceHandle: "template-boundary-entry", target: childFlowId }),
|
||||
expect.objectContaining({ source: childFlowId, target: groupId, targetHandle: "template-boundary-exit" }),
|
||||
]));
|
||||
/*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-22:47:
|
||||
@@ -2534,10 +2534,10 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
const entryBoundaryHandle = document.body.querySelector(
|
||||
`.react-flow__handle.source[data-nodeid="${groupId}"][data-handlepos="left"][data-handleid="optional-boundary-entry"]`,
|
||||
`.react-flow__handle.source[data-nodeid="${groupId}"][data-handlepos="left"][data-handleid="template-boundary-entry"]`,
|
||||
);
|
||||
const exitBoundaryHandle = document.body.querySelector(
|
||||
`.react-flow__handle.target[data-nodeid="${groupId}"][data-handlepos="right"][data-handleid="optional-boundary-exit"]`,
|
||||
`.react-flow__handle.target[data-nodeid="${groupId}"][data-handlepos="right"][data-handleid="template-boundary-exit"]`,
|
||||
);
|
||||
expect(entryBoundaryHandle, `${groupId} left boundary source handle`).toBeInTheDocument();
|
||||
expect(exitBoundaryHandle, `${groupId} right boundary target handle`).toBeInTheDocument();
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
wouldCreateCycle,
|
||||
buildConnectionEdge,
|
||||
cascadeDelete,
|
||||
refreshOptionalGroupVisualBoundaries,
|
||||
refreshTemplateContainerVisualBoundaries,
|
||||
COLUMN_BAND_HEIGHT,
|
||||
WF_CARD_WIDTH,
|
||||
WF_FALLBACK_NODE_GAP,
|
||||
@@ -87,7 +87,7 @@ function assertRenderedHandles(
|
||||
}
|
||||
|
||||
function assertContainerHandles(kind: "optional-group" | "foreach" | "loop", data: WorkflowFlowNodeData): void {
|
||||
assertRenderedHandles(kind, data, kind === "optional-group" ? { target: 2, source: 2 } : { target: 1, source: 1 });
|
||||
assertRenderedHandles(kind, data, { target: 2, source: 2 });
|
||||
}
|
||||
|
||||
function assertRunDoesNotOverlap(
|
||||
@@ -1026,7 +1026,7 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
label: "success",
|
||||
};
|
||||
|
||||
const connected = refreshOptionalGroupVisualBoundaries(initial.nodes, [...initial.edges, realInternalEdge]);
|
||||
const connected = refreshTemplateContainerVisualBoundaries(initial.nodes, [...initial.edges, realInternalEdge]);
|
||||
const connectedById = new Map(connected.nodes.map((node) => [node.id, node] as const));
|
||||
expect(connectedById.get("opt::alpha")?.data.optionalGroupBoundary).toEqual({ entry: true, exit: false });
|
||||
expect(connectedById.get("opt::beta")?.data.optionalGroupBoundary).toEqual({ entry: false, exit: true });
|
||||
@@ -1035,7 +1035,7 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
"opt::beta->opt",
|
||||
]);
|
||||
|
||||
const disconnected = refreshOptionalGroupVisualBoundaries(
|
||||
const disconnected = refreshTemplateContainerVisualBoundaries(
|
||||
connected.nodes,
|
||||
connected.edges.filter((edge) => edge.id !== realInternalEdge.id),
|
||||
);
|
||||
@@ -1104,6 +1104,123 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("derives visual-only template boundary connectors across foreach, loop, and optional-group states", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "single child",
|
||||
nodes: [{ id: "only", kind: "prompt" as const, config: { prompt: "only" } }],
|
||||
edges: [] as NonNullable<WorkflowDefinition["ir"]["edges"]>,
|
||||
visual: ["box->box::only", "box::only->box"],
|
||||
boundaries: { only: { entry: true, exit: true } },
|
||||
},
|
||||
{
|
||||
name: "linear children",
|
||||
nodes: [
|
||||
{ id: "alpha", kind: "prompt" as const, config: { prompt: "alpha" } },
|
||||
{ id: "beta", kind: "gate" as const },
|
||||
],
|
||||
edges: [{ from: "alpha", to: "beta", condition: "success" }],
|
||||
visual: ["box->box::alpha", "box::beta->box"],
|
||||
boundaries: { alpha: { entry: true, exit: false }, beta: { entry: false, exit: true } },
|
||||
},
|
||||
{
|
||||
name: "independent children",
|
||||
nodes: [
|
||||
{ id: "alpha", kind: "prompt" as const, config: { prompt: "alpha" } },
|
||||
{ id: "beta", kind: "prompt" as const, config: { prompt: "beta" } },
|
||||
],
|
||||
edges: [] as NonNullable<WorkflowDefinition["ir"]["edges"]>,
|
||||
visual: ["box->box::alpha", "box->box::beta", "box::alpha->box", "box::beta->box"],
|
||||
boundaries: { alpha: { entry: true, exit: true }, beta: { entry: true, exit: true } },
|
||||
},
|
||||
{
|
||||
name: "empty template",
|
||||
nodes: [] as NonNullable<WorkflowDefinition["ir"]["nodes"]>,
|
||||
edges: [] as NonNullable<WorkflowDefinition["ir"]["edges"]>,
|
||||
visual: [] as string[],
|
||||
boundaries: {},
|
||||
},
|
||||
{
|
||||
name: "rework cycle ignored for boundaries",
|
||||
nodes: [
|
||||
{ id: "alpha", kind: "prompt" as const, config: { prompt: "alpha" } },
|
||||
{ id: "beta", kind: "step-review" as const, config: { type: "code" } },
|
||||
],
|
||||
edges: [
|
||||
{ from: "alpha", to: "beta", condition: "success" },
|
||||
{ from: "beta", to: "alpha", condition: "outcome:revise", kind: "rework" as const },
|
||||
],
|
||||
visual: ["box->box::alpha", "box::beta->box"],
|
||||
boundaries: { alpha: { entry: true, exit: false }, beta: { entry: false, exit: true } },
|
||||
},
|
||||
];
|
||||
|
||||
for (const containerKind of ["foreach", "loop", "optional-group"] as const) {
|
||||
for (const testCase of cases) {
|
||||
const template = { nodes: testCase.nodes, edges: testCase.edges };
|
||||
const config =
|
||||
containerKind === "foreach"
|
||||
? { source: "task-steps" as const, template }
|
||||
: containerKind === "loop"
|
||||
? { maxIterations: 2, template }
|
||||
: { defaultOn: true, template };
|
||||
const containerIr: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: `${containerKind}-${testCase.name}`,
|
||||
columns: ir.columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{ id: "box", kind: containerKind, column: "in-progress", config },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "box", condition: "success" },
|
||||
{ from: "box", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
|
||||
const { nodes, edges } = irToFlow(makeDef(containerIr));
|
||||
const byId = new Map(nodes.map((node) => [node.id, node] as const));
|
||||
for (const [childId, boundary] of Object.entries(testCase.boundaries)) {
|
||||
expect(byId.get(`box::${childId}`)?.data.templateBoundary, `${containerKind} ${testCase.name} ${childId}`).toEqual(boundary);
|
||||
}
|
||||
expect(edges.filter((edge) => isVisualOnlyWorkflowEdge(edge)).map((edge) => `${edge.source}->${edge.target}`).sort()).toEqual(
|
||||
testCase.visual.sort(),
|
||||
);
|
||||
|
||||
const { ir: out } = flowToIr(containerIr.name, nodes, edges, columnsOf(makeDef(containerIr)));
|
||||
if (out.version !== "v2") throw new Error("expected v2");
|
||||
expect(out.edges.map((edge) => `${edge.from}->${edge.to}`)).toEqual(["start->box", "box->end"]);
|
||||
const outBox = out.nodes.find((node) => node.id === "box")!;
|
||||
const outTemplate = outBox.config?.template as { nodes?: Array<{ config?: Record<string, unknown> }>; edges?: unknown[] };
|
||||
expect(outTemplate.edges).toEqual(testCase.edges);
|
||||
expect(outTemplate.nodes?.map((node) => node.config?.templateBoundary)).toEqual(testCase.nodes.map(() => undefined));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("connects built-in stepwise foreach steps to visual-only boundary guides", () => {
|
||||
const { nodes, edges } = irToFlow(makeDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR));
|
||||
const byId = new Map(nodes.map((node) => [node.id, node] as const));
|
||||
expect(byId.get("steps")?.type).toBe("foreach");
|
||||
expect(byId.get(foreachChildFlowId("steps", "step-execute"))?.data.templateBoundary).toEqual({ entry: true, exit: false });
|
||||
expect(byId.get(foreachChildFlowId("steps", "step-done"))?.data.templateBoundary).toEqual({ entry: false, exit: true });
|
||||
expect(edges.filter((edge) => isVisualOnlyWorkflowEdge(edge) && (edge.source === "steps" || edge.target === "steps"))).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ source: "steps", sourceHandle: "template-boundary-entry", target: foreachChildFlowId("steps", "step-execute") }),
|
||||
expect.objectContaining({ source: foreachChildFlowId("steps", "step-done"), target: "steps", targetHandle: "template-boundary-exit" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const { ir: out } = flowToIr("stepwise", nodes, edges, columnsOf(makeDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)));
|
||||
if (out.version !== "v2") throw new Error("expected v2");
|
||||
expect(out.edges.some((edge) => edge.from === "steps" && edge.to.includes("step"))).toBe(false);
|
||||
const steps = out.nodes.find((node) => node.id === "steps")!;
|
||||
const template = steps.config?.template as { nodes?: Array<{ config?: Record<string, unknown> }>; edges?: Array<{ from: string; to: string }> };
|
||||
expect(template.nodes?.map((node) => node.config?.templateBoundary)).toEqual([undefined, undefined, undefined]);
|
||||
expect(template.edges?.some((edge) => edge.from === "steps" || edge.to === "steps")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks built-in Plan Review and Code Review single children as optional-group entry and exit boundaries", () => {
|
||||
for (const [workflowName, builtinIr] of [
|
||||
["coding", BUILTIN_CODING_WORKFLOW_IR],
|
||||
@@ -1451,7 +1568,11 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
expect(group?.type).toBe("loop");
|
||||
expect(child?.position).toEqual({ x: 86, y: 132 });
|
||||
expect(inserted.nodes.filter((n) => n.parentId === group?.id)).toHaveLength(1);
|
||||
expect(inserted.edges).toHaveLength(0);
|
||||
expect(inserted.edges.filter((edge) => !isVisualOnlyWorkflowEdge(edge))).toHaveLength(0);
|
||||
expect(inserted.edges.filter((edge) => isVisualOnlyWorkflowEdge(edge)).map((edge) => `${edge.source}->${edge.target}`).sort()).toEqual([
|
||||
`${group?.id}->${child?.id}`,
|
||||
`${child?.id}->${group?.id}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips a code node config (source + timeoutMs)", () => {
|
||||
@@ -1627,10 +1748,10 @@ describe("edge-condition authoring (U2)", () => {
|
||||
});
|
||||
|
||||
// visual-only optional-group boundary handles are reserved for generated guide edges.
|
||||
expect(buildConnectionEdge({ source: "a", sourceHandle: "optional-boundary-entry", target: "b" }, edges, nodes)).toEqual({
|
||||
expect(buildConnectionEdge({ source: "a", sourceHandle: "template-boundary-entry", target: "b" }, edges, nodes)).toEqual({
|
||||
error: "reserved-handle",
|
||||
});
|
||||
expect(buildConnectionEdge({ source: "a", target: "b", targetHandle: "optional-boundary-exit" }, edges, nodes)).toEqual({
|
||||
expect(buildConnectionEdge({ source: "a", target: "b", targetHandle: "template-boundary-exit" }, edges, nodes)).toEqual({
|
||||
error: "reserved-handle",
|
||||
});
|
||||
|
||||
|
||||
@@ -185,6 +185,26 @@ describe("buildMobileWorkflowGraph", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters foreach boundary chrome while preserving real stepwise template child edges", () => {
|
||||
const { nodes, edges } = irToFlow(workflowDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR));
|
||||
const rows = buildMobileWorkflowGraph(
|
||||
nodes,
|
||||
edges,
|
||||
BUILTIN_STEPWISE_CODING_WORKFLOW_IR.version === "v2" ? BUILTIN_STEPWISE_CODING_WORKFLOW_IR.columns : [],
|
||||
);
|
||||
const steps = rows.find((row) => row.id === "steps");
|
||||
expect(steps?.outgoing.some((out) => out.label === "entry" || out.label === "exit")).toBe(false);
|
||||
expect(steps?.outgoing.some((out) => out.target.includes("step-"))).toBe(false);
|
||||
|
||||
const execute = steps?.children.find((child) => child.id === foreachChildFlowId("steps", "step-execute"));
|
||||
const done = steps?.children.find((child) => child.id === foreachChildFlowId("steps", "step-done"));
|
||||
expect(execute?.outgoing.map((out) => [out.target, out.label])).toContainEqual([
|
||||
foreachChildFlowId("steps", "step-review"),
|
||||
"success",
|
||||
]);
|
||||
expect(done?.outgoing.some((out) => out.target === "steps" || out.label === "exit")).toBe(false);
|
||||
});
|
||||
|
||||
it("nests foreach template children without exposing local ids as top-level rows", () => {
|
||||
const childId = foreachChildFlowId("each", "step");
|
||||
const rows = buildMobileWorkflowGraph(
|
||||
|
||||
@@ -15,8 +15,8 @@ const WORKFLOW_NODE_KIND_GATE: WorkflowNodeKindGate = `${"ga"}te`;
|
||||
const WORKFLOW_NODE_KIND_STEP_REVIEW: WorkflowNodeKindStepReview = `${"st"}ep-review`;
|
||||
const WORKFLOW_NODE_KIND_PARSE_STEPS: WorkflowNodeKindParseSteps = `parse-${"st"}eps`;
|
||||
const WORKFLOW_NODE_SEAM_STEP_EXECUTE = `${"st"}ep-execute`;
|
||||
const OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE = "optional-boundary-entry";
|
||||
const OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE = "optional-boundary-exit";
|
||||
const TEMPLATE_BOUNDARY_ENTRY_HANDLE = "template-boundary-entry";
|
||||
const TEMPLATE_BOUNDARY_EXIT_HANDLE = "template-boundary-exit";
|
||||
|
||||
export type WorkflowEditorNodeKind =
|
||||
| "start"
|
||||
@@ -54,9 +54,11 @@ export interface WorkflowFlowNodeData {
|
||||
/** template group only: the localized empty-state hint string. */
|
||||
emptyHint?: string;
|
||||
/**
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-21:37:
|
||||
* Optional-group template children expose boundary ownership for editor visuals only. Entry/exit flags let mapping and renderer tests prove Plan Review/Code Review single-child blocks are connected to their container without persisting fake topology into the workflow IR.
|
||||
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
|
||||
* Template container children expose visual entry/exit ownership for editor chrome only. Foreach, loop, and optional-group blocks use these flags and derived edges to make internal template boundaries readable without persisting fake topology into workflow IR.
|
||||
*/
|
||||
templateBoundary?: { entry: boolean; exit: boolean };
|
||||
/** Back-compat alias for existing optional-group tests and call sites while generalized templateBoundary becomes canonical. */
|
||||
optionalGroupBoundary?: { entry: boolean; exit: boolean };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -157,6 +159,7 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
data-testid="wf-node-foreach"
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
|
||||
<div className="wf-foreach-header">
|
||||
<span className="wf-node-icon">
|
||||
<Repeat size={14} aria-hidden />
|
||||
@@ -171,6 +174,7 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
</div>
|
||||
)}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
<Handle id={TEMPLATE_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
@@ -187,6 +191,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
data-testid="wf-node-loop"
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
|
||||
<div className="wf-foreach-header">
|
||||
<span className="wf-node-icon">
|
||||
<Repeat size={14} aria-hidden />
|
||||
@@ -201,6 +206,7 @@ function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
</div>
|
||||
)}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
<Handle id={TEMPLATE_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
@@ -210,11 +216,8 @@ 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.
|
||||
|
||||
FNXC:WorkflowOptionalGroup 2026-06-29-22:47:
|
||||
Optional-group containers own the real workflow entry and exit boundaries. Keep the standard left target/right source handles for top-level graph edges, and add dedicated left source/right target handles for visual-only template boundary connectors so entry and exit guides attach to the side that matches execution flow.
|
||||
|
||||
FNXC:WorkflowOptionalGroup 2026-06-29-23:20:
|
||||
The visual-only boundary connectors must never become authorable topology. Mark their dedicated handles non-connectable so users cannot drag persisted edges from the entry/exit guides into the optional group's template children.
|
||||
FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
|
||||
Template containers own visual entry and exit guide anchors separately from real workflow topology. Keep the standard left target/right source handles for top-level graph edges, and add dedicated non-connectable left source/right target handles for visual-only template boundary connectors so foreach, loop, and optional-group entry/exit guides attach to the side that matches execution flow.
|
||||
*/
|
||||
function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
const { t } = useTranslation("app");
|
||||
@@ -226,7 +229,7 @@ function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
data-testid="wf-node-optional-group"
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<Handle id={OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
|
||||
<Handle id={TEMPLATE_BOUNDARY_ENTRY_HANDLE} type="source" position={Position.Left} isConnectable={false} />
|
||||
<div className="wf-foreach-header">
|
||||
<span className="wf-node-icon">
|
||||
<ToggleRight size={14} aria-hidden />
|
||||
@@ -244,7 +247,7 @@ function OptionalGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
</div>
|
||||
)}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
<Handle id={OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} />
|
||||
<Handle id={TEMPLATE_BOUNDARY_EXIT_HANDLE} type="target" position={Position.Right} isConnectable={false} />
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,8 +47,8 @@ interface WorkflowOptionalGroupConfig {
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
const OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE = "optional-boundary-entry";
|
||||
const OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE = "optional-boundary-exit";
|
||||
export const TEMPLATE_BOUNDARY_ENTRY_HANDLE = "template-boundary-entry";
|
||||
export const TEMPLATE_BOUNDARY_EXIT_HANDLE = "template-boundary-exit";
|
||||
|
||||
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
|
||||
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape
|
||||
@@ -286,11 +286,11 @@ function optionalGroupConfigOf(node: WorkflowIrNode): WorkflowOptionalGroupConfi
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-29-20:10:
|
||||
Optional-group template entry/exit connectivity is visually owned by the container's outer handles. Child boundary handles must not imply disconnected IR edges, so derive child boundary metadata from forward internal template edges only; rework loops route backward and cannot erase the review-step exit or execute-step entry.
|
||||
FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
|
||||
Foreach, loop, and optional-group template entry/exit connectivity is visual editor chrome owned by the container boundary, not workflow topology. Derive child boundary metadata from forward internal template edges only; rework loops route backward and cannot erase an entry or exit guide.
|
||||
*/
|
||||
function optionalGroupTemplateBoundaryById(
|
||||
template: WorkflowOptionalGroupConfig["template"],
|
||||
function templateBoundaryById(
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] },
|
||||
): Map<string, { entry: boolean; exit: boolean }> {
|
||||
const templateNodeIds = new Set(template.nodes.map((node) => node.id));
|
||||
const incomingForward = new Set<string>();
|
||||
@@ -448,29 +448,17 @@ function templateBoundaryNodeIds(template: { nodes: WorkflowIrNode[]; edges: Wor
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-20:41:
|
||||
* Single-node optional groups such as Plan Review and Code Review looked disconnected because their executable template child had no internal template edge. Add read-only boundary connector edges in React Flow so the child visibly participates in the block, but mark them visual-only and filter them out of save/mobile serialization so the workflow IR keeps the real optional-group entry/exit contract. Boundary connectors use the same forward-edge-only rule as child handle metadata because rework loops are review routing, not alternate optional-group entry/exit ownership.
|
||||
*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-20:56:
|
||||
* Surface enumeration for FN-7249 keeps the fix constrained to editor visualization surfaces: desktop React Flow handles/edges, mobile outline filtering, parentId template children, and built-in Plan Review/Code Review single-child optional groups. Preserve saved/manual layouts and the core optional-group execution contract while repairing only visual child-boundary connectivity.
|
||||
*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-21:25:
|
||||
* Optional groups may have multiple independent template entries or exits. Emit one visual-only connector per boundary child so boundary-handle suppression never creates a disconnected child with no corresponding container-owned visual path.
|
||||
*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-22:16:
|
||||
* Boundary connector edges are explanatory editor chrome, not workflow topology. Keep them non-selectable and non-deletable so authors cannot mistake the visual entry/exit guides for persisted optional-group template edges.
|
||||
*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-22:47:
|
||||
* Boundary connector edges must attach entry guides to a left-side container source handle and exit guides to a right-side container target handle. The normal optional-group target/source handles remain reserved for top-level workflow edges, so visual-only child connectors do not reverse the perceived execution boundary.
|
||||
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
|
||||
* Template containers such as stepwise foreach blocks need visible boundary-to-child guides so internal template nodes do not look disconnected. Emit one visual-only connector per forward-edge-derived entry/exit child for foreach, loop, and optional-group containers, but keep these edges non-selectable, non-deletable, and filtered from save/mobile serialization because they are editor/read-only chrome rather than workflow topology.
|
||||
*/
|
||||
function optionalGroupBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: readonly string[], exitFlowIds: readonly string[]): FlowEdge[] {
|
||||
function templateBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: readonly string[], exitFlowIds: readonly string[]): FlowEdge[] {
|
||||
const visualEdges: FlowEdge[] = [];
|
||||
for (const entryFlowId of entryFlowIds) {
|
||||
const entryId = templateNodeIdFromChild(groupId, entryFlowId);
|
||||
visualEdges.push({
|
||||
id: `e-${groupId}-boundary-entry-${entryId}`,
|
||||
source: groupId,
|
||||
sourceHandle: OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE,
|
||||
sourceHandle: TEMPLATE_BOUNDARY_ENTRY_HANDLE,
|
||||
target: entryFlowId,
|
||||
label: "entry",
|
||||
data: { condition: "entry", visualOnly: WF_TEMPLATE_BOUNDARY_EDGE_KIND, boundary: "entry" },
|
||||
@@ -488,7 +476,7 @@ function optionalGroupBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: rea
|
||||
id: `e-${groupId}-boundary-exit-${exitId}`,
|
||||
source: exitFlowId,
|
||||
target: groupId,
|
||||
targetHandle: OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE,
|
||||
targetHandle: TEMPLATE_BOUNDARY_EXIT_HANDLE,
|
||||
label: "exit",
|
||||
data: { condition: "exit", visualOnly: WF_TEMPLATE_BOUNDARY_EDGE_KIND, boundary: "exit" },
|
||||
className: "wf-edge-template-boundary",
|
||||
@@ -502,10 +490,10 @@ function optionalGroupBoundaryEdgesForFlowIds(groupId: string, entryFlowIds: rea
|
||||
return visualEdges;
|
||||
}
|
||||
|
||||
function optionalGroupBoundaryEdges(node: WorkflowIrNode, template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }): FlowEdge[] {
|
||||
if (node.kind !== "optional-group" || template.nodes.length === 0) return [];
|
||||
function templateBoundaryEdges(node: WorkflowIrNode, template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }): FlowEdge[] {
|
||||
if (!groupTemplateConfigOf(node) || template.nodes.length === 0) return [];
|
||||
const { entryIds, exitIds } = templateBoundaryNodeIds(template);
|
||||
return optionalGroupBoundaryEdgesForFlowIds(
|
||||
return templateBoundaryEdgesForFlowIds(
|
||||
node.id,
|
||||
entryIds.map((entryId) => foreachChildFlowId(node.id, entryId)),
|
||||
exitIds.map((exitId) => foreachChildFlowId(node.id, exitId)),
|
||||
@@ -513,10 +501,10 @@ function optionalGroupBoundaryEdges(node: WorkflowIrNode, template: { nodes: Wor
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-23:31:
|
||||
* Optional-group boundary connector edges are derived editor chrome. Recompute them after live canvas node/edge mutations so adding, deleting, or retagging internal template edges immediately moves entry/exit guides without waiting for a save/reload round-trip.
|
||||
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
|
||||
* Template boundary connector edges are derived editor chrome. Recompute them after live canvas node/edge mutations so adding, deleting, or retagging internal foreach/loop/optional-group template edges immediately moves entry/exit guides without waiting for a save/reload round-trip.
|
||||
*/
|
||||
export function refreshOptionalGroupVisualBoundaries(
|
||||
export function refreshTemplateContainerVisualBoundaries(
|
||||
nodes: FlowNode<WorkflowFlowNodeData>[],
|
||||
edges: FlowEdge[],
|
||||
): { nodes: FlowNode<WorkflowFlowNodeData>[]; edges: FlowEdge[] } {
|
||||
@@ -530,12 +518,12 @@ export function refreshOptionalGroupVisualBoundaries(
|
||||
|
||||
const groupIds = new Set(
|
||||
nodes
|
||||
.filter((node) => node.data.kind === "optional-group")
|
||||
.filter((node) => node.data.kind === "optional-group" || node.data.kind === "foreach" || node.data.kind === "loop")
|
||||
.map((node) => node.id),
|
||||
);
|
||||
const childToOptionalGroup = new Map<string, string>();
|
||||
const childToTemplateContainer = new Map<string, string>();
|
||||
for (const groupId of groupIds) {
|
||||
for (const child of childrenByGroup.get(groupId) ?? []) childToOptionalGroup.set(child.id, groupId);
|
||||
for (const child of childrenByGroup.get(groupId) ?? []) childToTemplateContainer.set(child.id, groupId);
|
||||
}
|
||||
|
||||
const nonVisualEdges = edges.filter((edge) => !isVisualOnlyWorkflowEdge(edge));
|
||||
@@ -566,25 +554,44 @@ export function refreshOptionalGroupVisualBoundaries(
|
||||
if (boundary.entry) entryFlowIds.push(child.id);
|
||||
if (boundary.exit) exitFlowIds.push(child.id);
|
||||
}
|
||||
nextVisualEdges.push(...optionalGroupBoundaryEdgesForFlowIds(groupId, entryFlowIds, exitFlowIds));
|
||||
nextVisualEdges.push(...templateBoundaryEdgesForFlowIds(groupId, entryFlowIds, exitFlowIds));
|
||||
}
|
||||
|
||||
const nextNodes = nodes.map((node) => {
|
||||
const optionalGroupId = childToOptionalGroup.get(node.id);
|
||||
if (!optionalGroupId) {
|
||||
if (!node.data.optionalGroupBoundary) return node;
|
||||
const { optionalGroupBoundary: _boundary, ...data } = node.data;
|
||||
const containerId = childToTemplateContainer.get(node.id);
|
||||
if (!containerId) {
|
||||
if (!node.data.templateBoundary && !node.data.optionalGroupBoundary) return node;
|
||||
const { templateBoundary: _templateBoundary, optionalGroupBoundary: _optionalBoundary, ...data } = node.data;
|
||||
return { ...node, data };
|
||||
}
|
||||
const boundary = boundaryByChild.get(node.id);
|
||||
if (!boundary) return node;
|
||||
if (node.data.optionalGroupBoundary?.entry === boundary.entry && node.data.optionalGroupBoundary?.exit === boundary.exit) return node;
|
||||
return { ...node, data: { ...node.data, optionalGroupBoundary: boundary } };
|
||||
const optionalCompat = nodes.find((candidate) => candidate.id === containerId)?.data.kind === "optional-group"
|
||||
? boundary
|
||||
: undefined;
|
||||
if (
|
||||
node.data.templateBoundary?.entry === boundary.entry &&
|
||||
node.data.templateBoundary?.exit === boundary.exit &&
|
||||
node.data.optionalGroupBoundary?.entry === optionalCompat?.entry &&
|
||||
node.data.optionalGroupBoundary?.exit === optionalCompat?.exit
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
templateBoundary: boundary,
|
||||
...(optionalCompat ? { optionalGroupBoundary: optionalCompat } : { optionalGroupBoundary: undefined }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: nextNodes, edges: [...nonVisualEdges, ...nextVisualEdges] };
|
||||
}
|
||||
|
||||
export const refreshOptionalGroupVisualBoundaries = refreshTemplateContainerVisualBoundaries;
|
||||
|
||||
/** Build React Flow nodes/edges from a stored workflow definition. v2 columns
|
||||
* render as swimlane band group nodes; step nodes carry their `column`. A
|
||||
* `foreach` node renders as a group whose template subgraph nodes are children
|
||||
@@ -620,9 +627,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
const groupCfg = groupTemplateConfigOf(node);
|
||||
if (groupCfg) {
|
||||
const template = groupCfg.template;
|
||||
const optionalGroupBoundaries = node.kind === "optional-group"
|
||||
? optionalGroupTemplateBoundaryById(template)
|
||||
: undefined;
|
||||
const templateBoundaries = templateBoundaryById(template);
|
||||
// Render template nodes as children of this group (parentId = group id).
|
||||
template.nodes.forEach((inner, innerIdx) => {
|
||||
const childFlowId = foreachChildFlowId(node.id, inner.id);
|
||||
@@ -633,7 +638,8 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
y: FOREACH_CHILD_Y,
|
||||
};
|
||||
const innerKind = editorKind(inner);
|
||||
const optionalGroupBoundary = optionalGroupBoundaries?.get(inner.id);
|
||||
const templateBoundary = templateBoundaries.get(inner.id);
|
||||
const optionalGroupBoundary = node.kind === "optional-group" ? templateBoundary : undefined;
|
||||
childNodes.push({
|
||||
id: childFlowId,
|
||||
type: innerKind,
|
||||
@@ -645,6 +651,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
...dataIrKind(inner, innerKind),
|
||||
label: nodeLabel(inner),
|
||||
config: { ...(inner.config ?? {}) },
|
||||
...(templateBoundary ? { templateBoundary } : {}),
|
||||
...(optionalGroupBoundary ? { optionalGroupBoundary } : {}),
|
||||
},
|
||||
deletable: true,
|
||||
@@ -654,7 +661,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
template.edges.forEach((edge, eIdx) => {
|
||||
childEdges.push(irEdgeToFlow(edge, eIdx, `${node.id}${FOREACH_CHILD_SEP}`));
|
||||
});
|
||||
childEdges.push(...optionalGroupBoundaryEdges(node, template));
|
||||
childEdges.push(...templateBoundaryEdges(node, template));
|
||||
// Strip the template off the group node's own config (children carry it).
|
||||
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -1034,8 +1041,8 @@ export type BuildConnectionResult =
|
||||
| { edge: FlowEdge }
|
||||
| { error: "missing-endpoint" | "duplicate" | "cycle" | "reserved-handle" };
|
||||
|
||||
function isOptionalGroupBoundaryConnectionHandle(handleId: string | null | undefined): boolean {
|
||||
return handleId === OPTIONAL_GROUP_BOUNDARY_ENTRY_HANDLE || handleId === OPTIONAL_GROUP_BOUNDARY_EXIT_HANDLE;
|
||||
function isTemplateBoundaryConnectionHandle(handleId: string | null | undefined): boolean {
|
||||
return handleId === TEMPLATE_BOUNDARY_ENTRY_HANDLE || handleId === TEMPLATE_BOUNDARY_EXIT_HANDLE;
|
||||
}
|
||||
|
||||
/** Construct a new success edge for a React Flow connection, reimplementing the
|
||||
@@ -1059,12 +1066,12 @@ export function buildConnectionEdge(
|
||||
if (!source || !target) return { error: "missing-endpoint" };
|
||||
|
||||
/*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-29-23:20:
|
||||
* Optional-group boundary handles are visual guide anchors owned by refreshOptionalGroupVisualBoundaries, not editable workflow topology. Reject connection gestures that mention them so stale DOM, test mocks, or browser quirks cannot persist a fake group↔child edge if React Flow ever reports a boundary handle as connectable.
|
||||
* FNXC:WorkflowTemplateBoundaries 2026-07-01-00:00:
|
||||
* Template boundary handles are visual guide anchors owned by refreshTemplateContainerVisualBoundaries, not editable workflow topology. Reject connection gestures that mention them so stale DOM, test mocks, or browser quirks cannot persist a fake container↔child edge if React Flow ever reports a boundary handle as connectable.
|
||||
*/
|
||||
if (
|
||||
isOptionalGroupBoundaryConnectionHandle(connection.sourceHandle) ||
|
||||
isOptionalGroupBoundaryConnectionHandle(connection.targetHandle)
|
||||
isTemplateBoundaryConnectionHandle(connection.sourceHandle) ||
|
||||
isTemplateBoundaryConnectionHandle(connection.targetHandle)
|
||||
) {
|
||||
return { error: "reserved-handle" };
|
||||
}
|
||||
@@ -1473,9 +1480,7 @@ export function insertFragment(
|
||||
if (groupCfg) {
|
||||
const template = groupCfg.template;
|
||||
const groupKind = editorKind(node);
|
||||
const optionalGroupBoundaries = node.kind === "optional-group"
|
||||
? optionalGroupTemplateBoundaryById(template)
|
||||
: undefined;
|
||||
const templateBoundaries = templateBoundaryById(template);
|
||||
template.nodes.forEach((inner, innerIdx) => {
|
||||
const innerKind = editorKind(inner);
|
||||
const childPos =
|
||||
@@ -1483,7 +1488,8 @@ export function insertFragment(
|
||||
x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X,
|
||||
y: FOREACH_CHILD_Y,
|
||||
};
|
||||
const optionalGroupBoundary = optionalGroupBoundaries?.get(inner.id);
|
||||
const templateBoundary = templateBoundaries.get(inner.id);
|
||||
const optionalGroupBoundary = node.kind === "optional-group" ? templateBoundary : undefined;
|
||||
childNodes.push({
|
||||
id: foreachChildFlowId(id, inner.id),
|
||||
type: innerKind,
|
||||
@@ -1495,6 +1501,7 @@ export function insertFragment(
|
||||
...dataIrKind(inner, innerKind),
|
||||
label: nodeLabel(inner),
|
||||
config: { ...(inner.config ?? {}) },
|
||||
...(templateBoundary ? { templateBoundary } : {}),
|
||||
...(optionalGroupBoundary ? { optionalGroupBoundary } : {}),
|
||||
},
|
||||
deletable: true,
|
||||
@@ -1504,7 +1511,7 @@ export function insertFragment(
|
||||
template.edges.forEach((edge, eIdx) => {
|
||||
childEdges.push(irEdgeToFlow(edge, eIdx, `${id}${FOREACH_CHILD_SEP}`));
|
||||
});
|
||||
childEdges.push(...optionalGroupBoundaryEdges({ ...node, id }, template));
|
||||
childEdges.push(...templateBoundaryEdges({ ...node, id }, template));
|
||||
// The group node keeps everything except the template (children carry it).
|
||||
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user