FN-7016: keep workflow container edges connected
Keep workflow container fallback layouts connected in the editor. - Space fallback and auto-layout columns by each node's rendered width so wide containers do not overlap adjacent handles. - Treat incomplete top-level saved layouts as stale and fall back to a coherent graph layout. - Cover optional-group, foreach, and mobile graph mapping with regression tests. Files changed: .changeset/fn-7016-optional-group-edges.md | 7 + .../__tests__/workflow-auto-layout.test.ts | 18 +++ .../__tests__/workflow-flow-mapping.test.ts | 154 ++++++++++++++++++++- .../__tests__/workflow-mobile-graph.test.ts | 37 ++++- .../app/components/workflow-auto-layout.ts | 28 +++- .../app/components/workflow-flow-mapping.ts | 40 +++++- 6 files changed, 271 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-7016 Fusion-Task-Lineage: a009fc2d-05cb-41cc-8be1-c5522f7398ff
This commit is contained in:
7
.changeset/fn-7016-optional-group-edges.md
Normal file
7
.changeset/fn-7016-optional-group-edges.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix workflow editor so the Browser Verification block shows connected edges.
|
||||||
|
category: fix
|
||||||
|
dev: optional-group/foreach/loop container nodes now render connectable handles without adjacent layer overlap in WorkflowNodeEditor.
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
bandTop,
|
bandTop,
|
||||||
columnBandNodeId,
|
columnBandNodeId,
|
||||||
COLUMN_BAND_HEIGHT,
|
COLUMN_BAND_HEIGHT,
|
||||||
|
FOREACH_GROUP_WIDTH,
|
||||||
|
WF_CARD_MAX_WIDTH,
|
||||||
} from "../workflow-flow-mapping";
|
} from "../workflow-flow-mapping";
|
||||||
|
|
||||||
type N = FlowNode<WorkflowFlowNodeData>;
|
type N = FlowNode<WorkflowFlowNodeData>;
|
||||||
@@ -156,6 +158,22 @@ describe("autoLayout — v1 (free placement)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("autoLayout — foreach / unreachable / cycles", () => {
|
describe("autoLayout — foreach / unreachable / cycles", () => {
|
||||||
|
it("spaces layers by container width so group handles do not overlap following nodes", () => {
|
||||||
|
const nodes: N[] = [
|
||||||
|
node("start", "start", 0, midBand(0), { column: "triage" }),
|
||||||
|
node("verify", "optional-group", 0, midBand(1), {
|
||||||
|
column: "in-progress",
|
||||||
|
style: { width: FOREACH_GROUP_WIDTH, height: 220 },
|
||||||
|
}),
|
||||||
|
node("review", "prompt", 0, midBand(1), { column: "in-progress" }),
|
||||||
|
node("end", "end", 0, midBand(2), { column: "done" }),
|
||||||
|
];
|
||||||
|
const pos = autoLayout(nodes, [edge("start", "verify"), edge("verify", "review"), edge("review", "end")], COLUMNS_3);
|
||||||
|
|
||||||
|
expect(pos.get("verify")!.x).toBeGreaterThanOrEqual(pos.get("start")!.x + WF_CARD_MAX_WIDTH);
|
||||||
|
expect(pos.get("review")!.x).toBeGreaterThanOrEqual(pos.get("verify")!.x + FOREACH_GROUP_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
it("repositions a foreach group but leaves its parentId children untouched", () => {
|
it("repositions a foreach group but leaves its parentId children untouched", () => {
|
||||||
const childPos = { x: 30, y: 56 };
|
const childPos = { x: 30, y: 56 };
|
||||||
const nodes: N[] = [
|
const nodes: N[] = [
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { createElement } from "react";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { WorkflowDefinition, WorkflowIrNodeKind } from "@fusion/core";
|
import type { WorkflowDefinition, WorkflowIrNodeKind } from "@fusion/core";
|
||||||
import { parseWorkflowIr, validateColumnTraits } from "@fusion/core";
|
import {
|
||||||
import type { Node as FlowNode } from "@xyflow/react";
|
BUILTIN_CODING_WORKFLOW_IR,
|
||||||
|
BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
|
||||||
|
parseWorkflowIr,
|
||||||
|
validateColumnTraits,
|
||||||
|
} from "@fusion/core";
|
||||||
|
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
|
||||||
|
import { ReactFlowProvider } from "@xyflow/react";
|
||||||
import {
|
import {
|
||||||
irToFlow,
|
irToFlow,
|
||||||
flowToIr,
|
flowToIr,
|
||||||
@@ -34,22 +42,43 @@ import {
|
|||||||
FOREACH_CHILD_X,
|
FOREACH_CHILD_X,
|
||||||
FOREACH_CHILD_Y,
|
FOREACH_CHILD_Y,
|
||||||
} from "../workflow-flow-mapping";
|
} from "../workflow-flow-mapping";
|
||||||
|
import { workflowNodeTypes } from "../nodes/WorkflowNodeTypes";
|
||||||
import type { WorkflowEditorNodeKind, WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
import type { WorkflowEditorNodeKind, WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
||||||
import type { TraitCatalogEntry } from "../../api";
|
import type { TraitCatalogEntry } from "../../api";
|
||||||
|
|
||||||
function makeDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition {
|
function makeDef(ir: WorkflowDefinition["ir"], layout: WorkflowDefinition["layout"] = {}): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
id: "WF-001",
|
id: "WF-001",
|
||||||
kind: "workflow",
|
kind: "workflow",
|
||||||
name: ir.name,
|
name: ir.name,
|
||||||
description: "",
|
description: "",
|
||||||
ir,
|
ir,
|
||||||
layout: {},
|
layout,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nodeWidth(node: FlowNode<WorkflowFlowNodeData>): number {
|
||||||
|
const width = node.style?.width;
|
||||||
|
return typeof width === "number" ? width : WF_CARD_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContainerHandles(kind: "optional-group" | "foreach" | "loop", data: WorkflowFlowNodeData): void {
|
||||||
|
const Component = workflowNodeTypes[kind];
|
||||||
|
const { container, unmount } = render(
|
||||||
|
createElement(ReactFlowProvider, null, createElement(Component, { data, id: `${kind}-handle-check` })),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const root = container.querySelector(`[data-testid="wf-node-${kind}"]`);
|
||||||
|
expect(root).not.toBeNull();
|
||||||
|
expect(root?.querySelectorAll(".react-flow__handle.target")).toHaveLength(1);
|
||||||
|
expect(root?.querySelectorAll(".react-flow__handle.source")).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("workflow-flow-mapping name preservation", () => {
|
describe("workflow-flow-mapping name preservation", () => {
|
||||||
it("does not inject synthetic names for unnamed start/end/merge nodes on round-trip", () => {
|
it("does not inject synthetic names for unnamed start/end/merge nodes on round-trip", () => {
|
||||||
const ir: WorkflowDefinition["ir"] = {
|
const ir: WorkflowDefinition["ir"] = {
|
||||||
@@ -829,6 +858,123 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
|||||||
expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual(["start->opt", "opt->end"]);
|
expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual(["start->opt", "opt->end"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:WorkflowContainerEdges 2026-06-26-07:30:
|
||||||
|
* Browser Verification is an optional-group container on the built-in workflow path. The mapping invariant is broader than that repro: optional-group/foreach/loop containers must keep top-level edges attached to the container id, layer routed edges above the group background, render exactly one target/source handle pair, and use width-aware fallback positions so adjacent nodes do not occlude those handles.
|
||||||
|
*/
|
||||||
|
it("keeps container edges and handles connected across built-in workflow containers", () => {
|
||||||
|
const loopNode = {
|
||||||
|
id: "diagnostic-loop",
|
||||||
|
kind: "loop" as const,
|
||||||
|
column: "in-progress",
|
||||||
|
config: {
|
||||||
|
maxIterations: 2,
|
||||||
|
template: {
|
||||||
|
nodes: [{ id: "loop-check", kind: "prompt" as const, config: { prompt: "Check once" } }],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const codingWithLoop: WorkflowDefinition["ir"] = {
|
||||||
|
...BUILTIN_CODING_WORKFLOW_IR,
|
||||||
|
name: "builtin-coding-with-loop-test",
|
||||||
|
nodes: BUILTIN_CODING_WORKFLOW_IR.nodes.flatMap((node) =>
|
||||||
|
node.id === "browser-verification" ? [loopNode, node] : [node],
|
||||||
|
),
|
||||||
|
edges: [
|
||||||
|
...BUILTIN_CODING_WORKFLOW_IR.edges.filter(
|
||||||
|
(edge) => !(edge.from === "execute" && edge.to === "browser-verification"),
|
||||||
|
),
|
||||||
|
{ from: "execute", to: "diagnostic-loop", condition: "success" },
|
||||||
|
{ from: "diagnostic-loop", to: "browser-verification", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const stepwiseLoopNode = { ...loopNode, id: "post-steps-loop" };
|
||||||
|
const stepwiseWithLoop: WorkflowDefinition["ir"] = {
|
||||||
|
...BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
|
||||||
|
name: "builtin-stepwise-with-loop-test",
|
||||||
|
nodes: BUILTIN_STEPWISE_CODING_WORKFLOW_IR.nodes.flatMap((node) =>
|
||||||
|
node.id === "browser-verification" ? [stepwiseLoopNode, node] : [node],
|
||||||
|
),
|
||||||
|
edges: [
|
||||||
|
...BUILTIN_STEPWISE_CODING_WORKFLOW_IR.edges.filter(
|
||||||
|
(edge) => !(edge.from === "steps" && edge.to === "browser-verification"),
|
||||||
|
),
|
||||||
|
{ from: "steps", to: "post-steps-loop", condition: "success" },
|
||||||
|
{ from: "post-steps-loop", to: "browser-verification", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases: Array<{
|
||||||
|
name: string;
|
||||||
|
ir: WorkflowDefinition["ir"];
|
||||||
|
id: string;
|
||||||
|
kind: "optional-group" | "foreach" | "loop";
|
||||||
|
}> = [
|
||||||
|
{ name: "coding browser verification", ir: BUILTIN_CODING_WORKFLOW_IR, id: "browser-verification", kind: "optional-group" },
|
||||||
|
{ name: "stepwise browser verification", ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR, id: "browser-verification", kind: "optional-group" },
|
||||||
|
{ name: "stepwise foreach", ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR, id: "steps", kind: "foreach" },
|
||||||
|
{ name: "coding inserted loop", ir: codingWithLoop, id: "diagnostic-loop", kind: "loop" },
|
||||||
|
{ name: "stepwise inserted loop", ir: stepwiseWithLoop, id: "post-steps-loop", kind: "loop" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const { nodes, edges } = irToFlow(makeDef(testCase.ir));
|
||||||
|
const byId = new Map(nodes.map((node) => [node.id, node] as const));
|
||||||
|
const group = byId.get(testCase.id);
|
||||||
|
expect(group, testCase.name).toBeTruthy();
|
||||||
|
expect(group?.type, testCase.name).toBe(testCase.kind);
|
||||||
|
expect(group?.data.kind, testCase.name).toBe(testCase.kind);
|
||||||
|
expect(group?.style?.width, testCase.name).toBe(FOREACH_GROUP_WIDTH);
|
||||||
|
assertContainerHandles(testCase.kind, group!.data);
|
||||||
|
|
||||||
|
const connectedTopLevelEdges = edges.filter((edge) => edge.source === testCase.id || edge.target === testCase.id);
|
||||||
|
expect(connectedTopLevelEdges.length, testCase.name).toBeGreaterThan(0);
|
||||||
|
for (const edge of connectedTopLevelEdges) {
|
||||||
|
expect(edge.source.includes("::"), `${testCase.name} source should be top-level`).toBe(false);
|
||||||
|
expect(edge.target.includes("::"), `${testCase.name} target should be top-level`).toBe(false);
|
||||||
|
expect(edge.zIndex, `${testCase.name} edge should layer above group`).toBeGreaterThan(group!.zIndex ?? 0);
|
||||||
|
const source = byId.get(edge.source);
|
||||||
|
const target = byId.get(edge.target);
|
||||||
|
expect(source, `${testCase.name} source node ${edge.source}`).toBeTruthy();
|
||||||
|
expect(target, `${testCase.name} target node ${edge.target}`).toBeTruthy();
|
||||||
|
if (source && target) {
|
||||||
|
const sourceRight = source.position.x + nodeWidth(source);
|
||||||
|
const targetRight = target.position.x + nodeWidth(target);
|
||||||
|
if (source.position.x <= target.position.x) {
|
||||||
|
expect(sourceRight, `${testCase.name} ${edge.source}->${edge.target} should not overlap`).toBeLessThanOrEqual(target.position.x);
|
||||||
|
} else {
|
||||||
|
expect(targetRight, `${testCase.name} ${edge.source}->${edge.target} should not overlap`).toBeLessThanOrEqual(source.position.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateEdges = edges.filter((edge) => edge.source.includes("::") || edge.target.includes("::"));
|
||||||
|
expect(templateEdges.some((edge) => edge.source === testCase.id || edge.target === testCase.id), testCase.name).toBe(false);
|
||||||
|
expect(nodes.filter((node) => node.parentId === testCase.id).every((node) => node.zIndex! > group!.zIndex!), testCase.name).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staleBuiltInLayout: WorkflowDefinition["layout"] = {
|
||||||
|
start: { x: 60, y: 160 },
|
||||||
|
execute: { x: 230, y: 160 },
|
||||||
|
review: { x: 400, y: 160 },
|
||||||
|
end: { x: 1420, y: 160 },
|
||||||
|
};
|
||||||
|
const { nodes: staleNodes, edges: staleEdges } = irToFlow(makeDef(BUILTIN_CODING_WORKFLOW_IR, staleBuiltInLayout));
|
||||||
|
const staleById = new Map(staleNodes.map((node) => [node.id, node] as const));
|
||||||
|
for (const edge of staleEdges.filter((candidate) => candidate.source === "browser-verification" || candidate.target === "browser-verification")) {
|
||||||
|
const source = staleById.get(edge.source)!;
|
||||||
|
const target = staleById.get(edge.target)!;
|
||||||
|
const sourceRight = source.position.x + nodeWidth(source);
|
||||||
|
const targetRight = target.position.x + nodeWidth(target);
|
||||||
|
if (source.position.x <= target.position.x) {
|
||||||
|
expect(sourceRight, `stale layout ${edge.source}->${edge.target} should not overlap`).toBeLessThanOrEqual(target.position.x);
|
||||||
|
} else {
|
||||||
|
expect(targetRight, `stale layout ${edge.source}->${edge.target} should not overlap`).toBeLessThanOrEqual(source.position.x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
|
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
|
||||||
// cascade its parentId children (no orphans) — same rule foreach/loop follow.
|
// cascade its parentId children (no orphans) — same rule foreach/loop follow.
|
||||||
it("cascade-deletes an optional-group's template children", () => {
|
it("cascade-deletes an optional-group's template children", () => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||||
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
|
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
|
||||||
import { buildMobileWorkflowGraph, reorderWorkflowNode } from "../workflow-mobile-graph";
|
import { buildMobileWorkflowGraph, reorderWorkflowNode } from "../workflow-mobile-graph";
|
||||||
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
||||||
import { columnBandNodeId, foreachChildFlowId } from "../workflow-flow-mapping";
|
import { columnBandNodeId, foreachChildFlowId, irToFlow } from "../workflow-flow-mapping";
|
||||||
|
|
||||||
function node(
|
function node(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -30,6 +31,19 @@ function edge(id: string, source: string, target: string, condition = "success")
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowDef(ir: typeof BUILTIN_CODING_WORKFLOW_IR) {
|
||||||
|
return {
|
||||||
|
id: ir.name,
|
||||||
|
kind: "workflow" as const,
|
||||||
|
name: ir.name,
|
||||||
|
description: "",
|
||||||
|
ir,
|
||||||
|
layout: {},
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function rowOrder(nodes: FlowNode<WorkflowFlowNodeData>[]): string[] {
|
function rowOrder(nodes: FlowNode<WorkflowFlowNodeData>[]): string[] {
|
||||||
return buildMobileWorkflowGraph(nodes, []).map((row) => row.id);
|
return buildMobileWorkflowGraph(nodes, []).map((row) => row.id);
|
||||||
}
|
}
|
||||||
@@ -119,6 +133,27 @@ describe("buildMobileWorkflowGraph", () => {
|
|||||||
expect(rows[1].summary).toBe("Gate (blocks)");
|
expect(rows[1].summary).toBe("Gate (blocks)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("summarizes Browser Verification container connections for built-in mobile outlines", () => {
|
||||||
|
for (const [name, ir, incoming] of [
|
||||||
|
["coding", BUILTIN_CODING_WORKFLOW_IR, "execute"],
|
||||||
|
["stepwise", BUILTIN_STEPWISE_CODING_WORKFLOW_IR, "steps"],
|
||||||
|
] as const) {
|
||||||
|
const { nodes, edges } = irToFlow(workflowDef(ir));
|
||||||
|
const rows = buildMobileWorkflowGraph(nodes, edges, ir.version === "v2" ? ir.columns : []);
|
||||||
|
const browserVerification = rows.find((row) => row.id === "browser-verification");
|
||||||
|
const incomingRow = rows.find((row) => row.id === incoming);
|
||||||
|
|
||||||
|
expect(browserVerification?.kind, name).toBe("optional-group");
|
||||||
|
expect(incomingRow?.outgoing.some((out) => out.target === "browser-verification"), name).toBe(true);
|
||||||
|
expect(browserVerification?.outgoing.map((out) => [out.target, out.label]), name).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
["code-review", "success"],
|
||||||
|
["end", "failure"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves branch edges and column labels while ignoring column band nodes", () => {
|
it("preserves branch edges and column labels while ignoring column band nodes", () => {
|
||||||
const rows = buildMobileWorkflowGraph(
|
const rows = buildMobileWorkflowGraph(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ function isLayoutable(node: LayoutNode): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function layoutNodeWidth(node: LayoutNode): number {
|
||||||
|
const width = node.style?.width;
|
||||||
|
return typeof width === "number" ? width : WF_CARD_MAX_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign each layoutable node a layer index via longest-path layering from the
|
* Assign each layoutable node a layer index via longest-path layering from the
|
||||||
* start node. Rework edges (data.kind === "rework") are ignored for layering.
|
* start node. Rework edges (data.kind === "rework") are ignored for layering.
|
||||||
@@ -150,6 +155,25 @@ export function autoLayout(
|
|||||||
|
|
||||||
const v2 = columns.length > 0;
|
const v2 = columns.length > 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:WorkflowContainerEdges 2026-06-26-08:05:
|
||||||
|
* Auto-layout must space graph layers by the widest rendered node in each prior layer. Container nodes render wider than cards, so fixed card-width layer spacing can place the next optional-group/foreach/loop on top of the previous container's source handle.
|
||||||
|
*/
|
||||||
|
const layerMaxWidths = new Map<number, number>();
|
||||||
|
for (const [layerIndex, layerIds] of layers) {
|
||||||
|
layerMaxWidths.set(
|
||||||
|
layerIndex,
|
||||||
|
Math.max(...layerIds.map((id) => layoutNodeWidth(byId.get(id)!)), WF_CARD_MAX_WIDTH),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sortedLayerIndexes = [...layers.keys()].sort((a, b) => a - b);
|
||||||
|
const layerXByIndex = new Map<number, number>();
|
||||||
|
let nextLayerX = ORIGIN_X;
|
||||||
|
for (const layerIndex of sortedLayerIndexes) {
|
||||||
|
layerXByIndex.set(layerIndex, nextLayerX);
|
||||||
|
nextLayerX += (layerMaxWidths.get(layerIndex) ?? WF_CARD_MAX_WIDTH) + WF_AUTO_LAYOUT_GAP_X;
|
||||||
|
}
|
||||||
|
|
||||||
for (const [layerIndex, layerIds] of layers) {
|
for (const [layerIndex, layerIds] of layers) {
|
||||||
const sorted = [...layerIds].sort((a, b) => {
|
const sorted = [...layerIds].sort((a, b) => {
|
||||||
const na = byId.get(a)!;
|
const na = byId.get(a)!;
|
||||||
@@ -158,7 +182,7 @@ export function autoLayout(
|
|||||||
return a < b ? -1 : a > b ? 1 : 0;
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const layerX = ORIGIN_X + layerIndex * WF_AUTO_LAYOUT_SPACING;
|
const layerX = layerXByIndex.get(layerIndex) ?? ORIGIN_X;
|
||||||
|
|
||||||
if (!v2) {
|
if (!v2) {
|
||||||
// v1: free placement — within-layer index × row height.
|
// v1: free placement — within-layer index × row height.
|
||||||
@@ -203,7 +227,7 @@ export function autoLayout(
|
|||||||
const y = firstY + rowInBucket * ROW_HEIGHT;
|
const y = firstY + rowInBucket * ROW_HEIGHT;
|
||||||
// Stagger x by half-spacing per overflow bucket so wrapped rows don't
|
// Stagger x by half-spacing per overflow bucket so wrapped rows don't
|
||||||
// collide with the un-staggered column while staying in the same band.
|
// collide with the un-staggered column while staying in the same band.
|
||||||
const x = layerX + staggerBucket * (WF_AUTO_LAYOUT_SPACING / 2);
|
const x = layerX + staggerBucket * ((layerMaxWidths.get(layerIndex) ?? WF_CARD_MAX_WIDTH) + WF_AUTO_LAYOUT_GAP_X) / 2;
|
||||||
|
|
||||||
positions.set(id, { x, y });
|
positions.set(id, { x, y });
|
||||||
rowsPerColumn.set(colId ?? `__idx${safeColIndex}`, used + 1);
|
rowsPerColumn.set(colId ?? `__idx${safeColIndex}`, used + 1);
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const FOREACH_GROUP_HEIGHT = 220;
|
|||||||
export const FOREACH_CHILD_X = 30;
|
export const FOREACH_CHILD_X = 30;
|
||||||
export const FOREACH_CHILD_Y = 56;
|
export const FOREACH_CHILD_Y = 56;
|
||||||
export const FOREACH_CHILD_STEP_X = 260;
|
export const FOREACH_CHILD_STEP_X = 260;
|
||||||
|
export const WF_FALLBACK_GRAPH_X = 80;
|
||||||
|
export const WF_FALLBACK_NODE_GAP = WF_CARD_WIDTH / 2;
|
||||||
|
|
||||||
const FOREACH_CHILD_SEP = "::";
|
const FOREACH_CHILD_SEP = "::";
|
||||||
/** Compose a globally-unique flow-node id for a template child. */
|
/** Compose a globally-unique flow-node id for a template child. */
|
||||||
@@ -286,6 +288,24 @@ function groupTemplateConfigOf(
|
|||||||
return foreachConfigOf(node) ?? loopConfigOf(node) ?? optionalGroupConfigOf(node);
|
return foreachConfigOf(node) ?? loopConfigOf(node) ?? optionalGroupConfigOf(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackWidthForNode(node: WorkflowIrNode): number {
|
||||||
|
return groupTemplateConfigOf(node) ? FOREACH_GROUP_WIDTH : WF_CARD_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:WorkflowContainerEdges 2026-06-26-07:30:
|
||||||
|
* Container nodes are much wider than step cards, so fallback graph layout must advance by each rendered node width. Fixed index spacing lets optional-group/foreach/loop backgrounds overlap adjacent handles, making correct top-level edges look visually disconnected in the workflow editor.
|
||||||
|
*/
|
||||||
|
function fallbackXPositionsForNodes(nodes: readonly WorkflowIrNode[], originX = WF_FALLBACK_GRAPH_X): Map<string, number> {
|
||||||
|
const positions = new Map<string, number>();
|
||||||
|
let nextX = originX;
|
||||||
|
for (const node of nodes) {
|
||||||
|
positions.set(node.id, nextX);
|
||||||
|
nextX += fallbackWidthForNode(node) + WF_FALLBACK_NODE_GAP;
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
/** CSS class for an edge given its condition + rework kind. Rework takes
|
/** CSS class for an edge given its condition + rework kind. Rework takes
|
||||||
* precedence; failure edges get the distinct failure styling; success and other
|
* precedence; failure edges get the distinct failure styling; success and other
|
||||||
* conditions get no class (default styling). R2's two-channel rule (label always
|
* conditions get no class (default styling). R2's two-channel rule (label always
|
||||||
@@ -341,13 +361,20 @@ export function irToFlow(def: WorkflowDefinition): {
|
|||||||
const childNodes: FlowNode<WorkflowFlowNodeData>[] = [];
|
const childNodes: FlowNode<WorkflowFlowNodeData>[] = [];
|
||||||
const childEdges: FlowEdge[] = [];
|
const childEdges: FlowEdge[] = [];
|
||||||
|
|
||||||
const stepNodes = def.ir.nodes.map((node, index): FlowNode<WorkflowFlowNodeData> => {
|
const fallbackXById = fallbackXPositionsForNodes(def.ir.nodes);
|
||||||
const pos = def.layout?.[node.id];
|
/*
|
||||||
|
* FNXC:WorkflowContainerEdges 2026-06-26-07:58:
|
||||||
|
* Built-in workflow layouts can lag behind newly inserted container nodes. Mixing stale saved positions with fallback-only optional-group/foreach/loop positions reintroduces overlapping handles, so an incomplete top-level layout falls back as one coherent graph instead of preserving disconnected partial coordinates.
|
||||||
|
*/
|
||||||
|
const hasCompleteTopLevelLayout = def.ir.nodes.every((node) => Boolean(def.layout?.[node.id]));
|
||||||
|
const stepNodes = def.ir.nodes.map((node): FlowNode<WorkflowFlowNodeData> => {
|
||||||
|
const pos = hasCompleteTopLevelLayout ? def.layout?.[node.id] : undefined;
|
||||||
const kind = editorKind(node);
|
const kind = editorKind(node);
|
||||||
const column = isV2(def.ir) ? node.column : undefined;
|
const column = isV2(def.ir) ? node.column : undefined;
|
||||||
const colIndex = column ? columns.findIndex((c) => c.id === column) : -1;
|
const colIndex = column ? columns.findIndex((c) => c.id === column) : -1;
|
||||||
// Default placement seeds the node inside its column band when no persisted
|
// Default placement seeds the node inside its column band when no persisted
|
||||||
// layout exists; otherwise we honor the saved absolute position.
|
// layout exists; otherwise we honor the saved absolute position.
|
||||||
|
const fallbackX = fallbackXById.get(node.id) ?? WF_FALLBACK_GRAPH_X;
|
||||||
const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120;
|
const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120;
|
||||||
|
|
||||||
const groupCfg = groupTemplateConfigOf(node);
|
const groupCfg = groupTemplateConfigOf(node);
|
||||||
@@ -382,7 +409,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
|||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: kind,
|
type: kind,
|
||||||
position: pos ?? { x: 80 + index * 180, y: fallbackY },
|
position: pos ?? { x: fallbackX, y: fallbackY },
|
||||||
data: {
|
data: {
|
||||||
kind,
|
kind,
|
||||||
...dataIrKind(node, kind),
|
...dataIrKind(node, kind),
|
||||||
@@ -400,7 +427,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
|||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: kind,
|
type: kind,
|
||||||
position: pos ?? { x: 80 + index * 180, y: fallbackY },
|
position: pos ?? { x: fallbackX, y: fallbackY },
|
||||||
data: {
|
data: {
|
||||||
kind,
|
kind,
|
||||||
...dataIrKind(node, kind),
|
...dataIrKind(node, kind),
|
||||||
@@ -1159,6 +1186,7 @@ export function insertFragment(
|
|||||||
const minX = placed.length ? Math.min(...placed.map((p) => p.x)) : 0;
|
const minX = placed.length ? Math.min(...placed.map((p) => p.x)) : 0;
|
||||||
const minY = placed.length ? Math.min(...placed.map((p) => p.y)) : 0;
|
const minY = placed.length ? Math.min(...placed.map((p) => p.y)) : 0;
|
||||||
|
|
||||||
|
const fallbackXById = fallbackXPositionsForNodes(bodyNodes, position.x);
|
||||||
const insertedNodeIds: string[] = [];
|
const insertedNodeIds: string[] = [];
|
||||||
// Template group children are expanded into parented child flow nodes (the
|
// Template group children are expanded into parented child flow nodes (the
|
||||||
// same way irToFlow does), so an inserted group round-trips its full template
|
// same way irToFlow does), so an inserted group round-trips its full template
|
||||||
@@ -1166,13 +1194,13 @@ export function insertFragment(
|
|||||||
// otherwise rebuild as an empty template from the absent children).
|
// otherwise rebuild as an empty template from the absent children).
|
||||||
const childNodes: FlowNode<WorkflowFlowNodeData>[] = [];
|
const childNodes: FlowNode<WorkflowFlowNodeData>[] = [];
|
||||||
const childEdges: FlowEdge[] = [];
|
const childEdges: FlowEdge[] = [];
|
||||||
const newNodes = bodyNodes.map((node, index): FlowNode<WorkflowFlowNodeData> => {
|
const newNodes = bodyNodes.map((node): FlowNode<WorkflowFlowNodeData> => {
|
||||||
const id = idMap.get(node.id)!;
|
const id = idMap.get(node.id)!;
|
||||||
insertedNodeIds.push(id);
|
insertedNodeIds.push(id);
|
||||||
const fromLayout = layout?.[node.id];
|
const fromLayout = layout?.[node.id];
|
||||||
const pos = fromLayout
|
const pos = fromLayout
|
||||||
? { x: position.x + (fromLayout.x - minX), y: position.y + (fromLayout.y - minY) }
|
? { x: position.x + (fromLayout.x - minX), y: position.y + (fromLayout.y - minY) }
|
||||||
: { x: position.x + index * 180, y: position.y };
|
: { x: fallbackXById.get(node.id) ?? position.x, y: position.y };
|
||||||
const groupCfg = groupTemplateConfigOf(node);
|
const groupCfg = groupTemplateConfigOf(node);
|
||||||
if (groupCfg) {
|
if (groupCfg) {
|
||||||
const template = groupCfg.template;
|
const template = groupCfg.template;
|
||||||
|
|||||||
Reference in New Issue
Block a user