FN-7136: preserve workflow edges between adjacent group nodes
Keep workflow graph edges connected when optional-group containers appear consecutively. - Reject stale saved top-level layout coordinates only when connected container spans overlap in the same visual row. - Advance auto-layout layers by each consecutive container layer's rendered width. - Add coverage for desktop flow mapping, auto-layout, mobile graph rendering, and release-note changeset. Files changed: .../fn-7136-consecutive-optional-group-edges.md | 7 ++ .../__tests__/workflow-auto-layout.test.ts | 116 +++++++++++++++++++- .../__tests__/workflow-flow-mapping.test.ts | 122 ++++++++++++++++++++- .../__tests__/workflow-mobile-graph.test.ts | 10 +- .../app/components/workflow-auto-layout.ts | 3 + .../app/components/workflow-flow-mapping.ts | 50 ++++++++- 6 files changed, 299 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7136 Fusion-Task-Lineage: 1fa1e528-a68e-4eee-9018-6752fe62a1f5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7136-consecutive-optional-group-edges.md
Normal file
7
.changeset/fn-7136-consecutive-optional-group-edges.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix workflow view so the Code Review and Browser Verification blocks show connected edges.
|
||||||
|
category: fix
|
||||||
|
dev: Auto-layout/fallback spacing now advances by every consecutive container node's rendered width so back-to-back optional-group/foreach/loop nodes no longer overlap adjacent handles; covered by a consecutive-container connectivity regression test.
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
|
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
|
||||||
import type { WorkflowIrColumn } from "@fusion/core";
|
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||||
|
import type { WorkflowDefinition, WorkflowIrColumn } from "@fusion/core";
|
||||||
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
||||||
import { autoLayout, applyAutoLayout } from "../workflow-auto-layout";
|
import { autoLayout, applyAutoLayout, WF_AUTO_LAYOUT_GAP_X } from "../workflow-auto-layout";
|
||||||
import {
|
import {
|
||||||
strictColumnForY,
|
strictColumnForY,
|
||||||
bandTop,
|
bandTop,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
COLUMN_BAND_HEIGHT,
|
COLUMN_BAND_HEIGHT,
|
||||||
FOREACH_GROUP_WIDTH,
|
FOREACH_GROUP_WIDTH,
|
||||||
WF_CARD_MAX_WIDTH,
|
WF_CARD_MAX_WIDTH,
|
||||||
|
irToFlow,
|
||||||
} from "../workflow-flow-mapping";
|
} from "../workflow-flow-mapping";
|
||||||
|
|
||||||
type N = FlowNode<WorkflowFlowNodeData>;
|
type N = FlowNode<WorkflowFlowNodeData>;
|
||||||
@@ -51,6 +53,59 @@ function midBand(index: number): number {
|
|||||||
return bandTop(index) + COLUMN_BAND_HEIGHT / 2;
|
return bandTop(index) + COLUMN_BAND_HEIGHT / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition {
|
||||||
|
return {
|
||||||
|
id: ir.name,
|
||||||
|
kind: "workflow",
|
||||||
|
name: ir.name,
|
||||||
|
description: "",
|
||||||
|
ir,
|
||||||
|
layout: {},
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeWidthFromFlow(node: FlowNode<WorkflowFlowNodeData>): number {
|
||||||
|
return typeof node.style?.width === "number" ? node.style.width : WF_CARD_MAX_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAutoLayoutRunConnected(
|
||||||
|
name: string,
|
||||||
|
def: WorkflowDefinition,
|
||||||
|
run: readonly string[],
|
||||||
|
): void {
|
||||||
|
const columns = def.ir.version === "v2" ? def.ir.columns : [];
|
||||||
|
const flow = irToFlow(def);
|
||||||
|
const byId = new Map(flow.nodes.map((candidate) => [candidate.id, candidate] as const));
|
||||||
|
const positions = autoLayout(flow.nodes, flow.edges, columns);
|
||||||
|
|
||||||
|
for (const id of run) {
|
||||||
|
const node = byId.get(id);
|
||||||
|
const position = positions.get(id);
|
||||||
|
expect(node, `${name} ${id} node`).toBeTruthy();
|
||||||
|
expect(position, `${name} ${id} position`).toBeTruthy();
|
||||||
|
if (node && position && node.data.column) {
|
||||||
|
expect(strictColumnForY(position.y, columns), `${name} ${id} column`).toBe(node.data.column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < run.length - 1; index++) {
|
||||||
|
const currentId = run[index];
|
||||||
|
const nextId = run[index + 1];
|
||||||
|
const current = byId.get(currentId)!;
|
||||||
|
const next = byId.get(nextId)!;
|
||||||
|
const currentPosition = positions.get(currentId)!;
|
||||||
|
const nextPosition = positions.get(nextId)!;
|
||||||
|
expect(
|
||||||
|
nextPosition.x,
|
||||||
|
`${name} ${currentId}->${nextId} should leave rendered-width gap`,
|
||||||
|
).toBeGreaterThanOrEqual(currentPosition.x + nodeWidthFromFlow(current) + WF_AUTO_LAYOUT_GAP_X);
|
||||||
|
expect(current.type, `${name} ${currentId} type`).toBe(current.data.kind);
|
||||||
|
expect(next.type, `${name} ${nextId} type`).toBe(next.data.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("autoLayout — v2 (column-preserving)", () => {
|
describe("autoLayout — v2 (column-preserving)", () => {
|
||||||
it("linear chain: strictly increasing x and every node keeps its column", () => {
|
it("linear chain: strictly increasing x and every node keeps its column", () => {
|
||||||
const nodes: N[] = [
|
const nodes: N[] = [
|
||||||
@@ -170,8 +225,61 @@ describe("autoLayout — foreach / unreachable / cycles", () => {
|
|||||||
];
|
];
|
||||||
const pos = autoLayout(nodes, [edge("start", "verify"), edge("verify", "review"), edge("review", "end")], COLUMNS_3);
|
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("verify")!.x).toBeGreaterThanOrEqual(pos.get("start")!.x + WF_CARD_MAX_WIDTH + WF_AUTO_LAYOUT_GAP_X);
|
||||||
expect(pos.get("review")!.x).toBeGreaterThanOrEqual(pos.get("verify")!.x + FOREACH_GROUP_WIDTH);
|
expect(pos.get("review")!.x).toBeGreaterThanOrEqual(pos.get("verify")!.x + FOREACH_GROUP_WIDTH + WF_AUTO_LAYOUT_GAP_X);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps built-in consecutive container runs connected in the editor layout", () => {
|
||||||
|
assertAutoLayoutRunConnected("coding", workflowDef(BUILTIN_CODING_WORKFLOW_IR), [
|
||||||
|
"execute",
|
||||||
|
"browser-verification",
|
||||||
|
"code-review",
|
||||||
|
"review",
|
||||||
|
]);
|
||||||
|
assertAutoLayoutRunConnected("stepwise", workflowDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR), [
|
||||||
|
"steps",
|
||||||
|
"browser-verification",
|
||||||
|
"code-review",
|
||||||
|
"review",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps consecutive optional-group foreach and loop containers connected", () => {
|
||||||
|
const synthetic: WorkflowDefinition["ir"] = {
|
||||||
|
version: "v2",
|
||||||
|
name: "container-run",
|
||||||
|
columns: COLUMNS_3,
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start", column: "triage" },
|
||||||
|
{
|
||||||
|
id: "optional",
|
||||||
|
kind: "optional-group",
|
||||||
|
column: "in-progress",
|
||||||
|
config: { defaultOn: false, template: { nodes: [], edges: [] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "foreach",
|
||||||
|
kind: "foreach",
|
||||||
|
column: "in-progress",
|
||||||
|
config: { source: "task-steps", template: { nodes: [], edges: [] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loop",
|
||||||
|
kind: "loop",
|
||||||
|
column: "in-progress",
|
||||||
|
config: { maxIterations: 2, template: { nodes: [], edges: [] } },
|
||||||
|
},
|
||||||
|
{ id: "end", kind: "end", column: "done" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "optional", condition: "success" },
|
||||||
|
{ from: "optional", to: "foreach", condition: "success" },
|
||||||
|
{ from: "foreach", to: "loop", condition: "success" },
|
||||||
|
{ from: "loop", to: "end", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assertAutoLayoutRunConnected("synthetic", workflowDef(synthetic), ["optional", "foreach", "loop", "end"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repositions a foreach group but leaves its parentId children untouched", () => {
|
it("repositions a foreach group but leaves its parentId children untouched", () => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
cascadeDelete,
|
cascadeDelete,
|
||||||
COLUMN_BAND_HEIGHT,
|
COLUMN_BAND_HEIGHT,
|
||||||
WF_CARD_WIDTH,
|
WF_CARD_WIDTH,
|
||||||
|
WF_FALLBACK_NODE_GAP,
|
||||||
WF_CARD_MAX_WIDTH,
|
WF_CARD_MAX_WIDTH,
|
||||||
WF_CARD_HEIGHT,
|
WF_CARD_HEIGHT,
|
||||||
FOREACH_GROUP_WIDTH,
|
FOREACH_GROUP_WIDTH,
|
||||||
@@ -79,6 +80,27 @@ function assertContainerHandles(kind: "optional-group" | "foreach" | "loop", dat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertRunDoesNotOverlap(
|
||||||
|
name: string,
|
||||||
|
nodes: FlowNode<WorkflowFlowNodeData>[],
|
||||||
|
run: readonly string[],
|
||||||
|
gap = 0,
|
||||||
|
): void {
|
||||||
|
const byId = new Map(nodes.map((node) => [node.id, node] as const));
|
||||||
|
for (let index = 0; index < run.length - 1; index++) {
|
||||||
|
const currentId = run[index];
|
||||||
|
const nextId = run[index + 1];
|
||||||
|
const current = byId.get(currentId);
|
||||||
|
const next = byId.get(nextId);
|
||||||
|
expect(current, `${name} ${currentId}`).toBeTruthy();
|
||||||
|
expect(next, `${name} ${nextId}`).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
next!.position.x,
|
||||||
|
`${name} ${currentId}->${nextId} should leave rendered-width gap`,
|
||||||
|
).toBeGreaterThanOrEqual(current!.position.x + nodeWidth(current!) + gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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"] = {
|
||||||
@@ -374,11 +396,12 @@ describe("workflow-flow-mapping v2 round-trip", () => {
|
|||||||
{ id: "start", type: "start", position: { x: 0, y: 0 }, data: { kind: "start", label: "start", column: "todo" } },
|
{ id: "start", type: "start", position: { x: 0, y: 0 }, data: { kind: "start", label: "start", column: "todo" } },
|
||||||
{ id: "end", type: "end", position: { x: 100, y: 0 }, data: { kind: "end", label: "end", column: "todo" } },
|
{ id: "end", type: "end", position: { x: 100, y: 0 }, data: { kind: "end", label: "end", column: "todo" } },
|
||||||
];
|
];
|
||||||
const staleIr = flowToIr("wf", nodes, [], nextColumns).ir;
|
const startToEnd = [{ id: "e-start-end", source: "start", target: "end", data: { condition: "success" } }];
|
||||||
|
const staleIr = flowToIr("wf", nodes, startToEnd, nextColumns).ir;
|
||||||
expect(() => parseWorkflowIr(staleIr)).toThrow(/references undefined column 'todo'/);
|
expect(() => parseWorkflowIr(staleIr)).toThrow(/references undefined column 'todo'/);
|
||||||
|
|
||||||
const reconciled = reconcileNodeColumns(nodes.filter((node) => !isColumnBandNode(node.id)), nextColumns);
|
const reconciled = reconcileNodeColumns(nodes.filter((node) => !isColumnBandNode(node.id)), nextColumns);
|
||||||
const { ir: out } = flowToIr("wf", [...columnsToBandNodes(nextColumns), ...reconciled], [], nextColumns);
|
const { ir: out } = flowToIr("wf", [...columnsToBandNodes(nextColumns), ...reconciled], startToEnd, nextColumns);
|
||||||
|
|
||||||
expect(() => parseWorkflowIr(out)).not.toThrow();
|
expect(() => parseWorkflowIr(out)).not.toThrow();
|
||||||
if (out.version !== "v2") throw new Error("expected v2");
|
if (out.version !== "v2") throw new Error("expected v2");
|
||||||
@@ -912,7 +935,9 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
|||||||
kind: "optional-group" | "foreach" | "loop";
|
kind: "optional-group" | "foreach" | "loop";
|
||||||
}> = [
|
}> = [
|
||||||
{ name: "coding browser verification", ir: BUILTIN_CODING_WORKFLOW_IR, id: "browser-verification", kind: "optional-group" },
|
{ name: "coding browser verification", ir: BUILTIN_CODING_WORKFLOW_IR, id: "browser-verification", kind: "optional-group" },
|
||||||
|
{ name: "coding code review", ir: BUILTIN_CODING_WORKFLOW_IR, id: "code-review", kind: "optional-group" },
|
||||||
{ name: "stepwise browser verification", ir: BUILTIN_STEPWISE_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 code review", ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR, id: "code-review", kind: "optional-group" },
|
||||||
{ name: "stepwise foreach", ir: BUILTIN_STEPWISE_CODING_WORKFLOW_IR, id: "steps", kind: "foreach" },
|
{ 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: "coding inserted loop", ir: codingWithLoop, id: "diagnostic-loop", kind: "loop" },
|
||||||
{ name: "stepwise inserted loop", ir: stepwiseWithLoop, id: "post-steps-loop", kind: "loop" },
|
{ name: "stepwise inserted loop", ir: stepwiseWithLoop, id: "post-steps-loop", kind: "loop" },
|
||||||
@@ -954,6 +979,55 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
|||||||
expect(nodes.filter((node) => node.parentId === testCase.id).every((node) => node.zIndex! > group!.zIndex!), testCase.name).toBe(true);
|
expect(nodes.filter((node) => node.parentId === testCase.id).every((node) => node.zIndex! > group!.zIndex!), testCase.name).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertRunDoesNotOverlap("coding consecutive fallback", irToFlow(makeDef(BUILTIN_CODING_WORKFLOW_IR)).nodes, [
|
||||||
|
"execute",
|
||||||
|
"browser-verification",
|
||||||
|
"code-review",
|
||||||
|
"review",
|
||||||
|
]);
|
||||||
|
assertRunDoesNotOverlap("stepwise consecutive fallback", irToFlow(makeDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).nodes, [
|
||||||
|
"steps",
|
||||||
|
"browser-verification",
|
||||||
|
"code-review",
|
||||||
|
"review",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const consecutiveMixedContainers: WorkflowDefinition["ir"] = {
|
||||||
|
version: "v2",
|
||||||
|
name: "mixed-containers",
|
||||||
|
columns: BUILTIN_CODING_WORKFLOW_IR.columns,
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start", column: "todo" },
|
||||||
|
{
|
||||||
|
id: "optional",
|
||||||
|
kind: "optional-group",
|
||||||
|
column: "in-progress",
|
||||||
|
config: { defaultOn: false, template: { nodes: [], edges: [] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "foreach",
|
||||||
|
kind: "foreach",
|
||||||
|
column: "in-progress",
|
||||||
|
config: { source: "task-steps", template: { nodes: [], edges: [] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loop",
|
||||||
|
kind: "loop",
|
||||||
|
column: "in-progress",
|
||||||
|
config: { maxIterations: 2, template: { nodes: [], edges: [] } },
|
||||||
|
},
|
||||||
|
{ id: "end", kind: "end", column: "done" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "optional", condition: "success" },
|
||||||
|
{ from: "optional", to: "foreach", condition: "success" },
|
||||||
|
{ from: "foreach", to: "loop", condition: "success" },
|
||||||
|
{ from: "loop", to: "end", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const mixedFlow = irToFlow(makeDef(consecutiveMixedContainers));
|
||||||
|
assertRunDoesNotOverlap("mixed consecutive containers", mixedFlow.nodes, ["optional", "foreach", "loop", "end"]);
|
||||||
|
|
||||||
const staleBuiltInLayout: WorkflowDefinition["layout"] = {
|
const staleBuiltInLayout: WorkflowDefinition["layout"] = {
|
||||||
start: { x: 60, y: 160 },
|
start: { x: 60, y: 160 },
|
||||||
execute: { x: 230, y: 160 },
|
execute: { x: 230, y: 160 },
|
||||||
@@ -973,6 +1047,50 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
|||||||
expect(targetRight, `stale layout ${edge.source}->${edge.target} should not overlap`).toBeLessThanOrEqual(source.position.x);
|
expect(targetRight, `stale layout ${edge.source}->${edge.target} should not overlap`).toBeLessThanOrEqual(source.position.x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const completeButStaleLayout = Object.fromEntries(
|
||||||
|
BUILTIN_CODING_WORKFLOW_IR.nodes.map((node, index) => [node.id, { x: 60 + index * 170, y: 160 }]),
|
||||||
|
);
|
||||||
|
const { nodes: completeStaleNodes } = irToFlow(makeDef(BUILTIN_CODING_WORKFLOW_IR, completeButStaleLayout));
|
||||||
|
const completeStaleById = new Map(completeStaleNodes.map((node) => [node.id, node] as const));
|
||||||
|
expect(completeStaleById.get("browser-verification")?.position.x).not.toBe(completeButStaleLayout["browser-verification"].x);
|
||||||
|
assertRunDoesNotOverlap("complete stale coding layout", completeStaleNodes, [
|
||||||
|
"execute",
|
||||||
|
"browser-verification",
|
||||||
|
"code-review",
|
||||||
|
"review",
|
||||||
|
], WF_FALLBACK_NODE_GAP);
|
||||||
|
|
||||||
|
const compactManualIr: WorkflowDefinition["ir"] = {
|
||||||
|
version: "v2",
|
||||||
|
name: "compact-manual-containers",
|
||||||
|
columns: BUILTIN_CODING_WORKFLOW_IR.columns,
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start", column: "todo" },
|
||||||
|
{ id: "optional", kind: "optional-group", column: "in-progress", config: { template: { nodes: [], edges: [] } } },
|
||||||
|
{ id: "review", kind: "prompt", column: "in-progress", config: { prompt: "review" } },
|
||||||
|
{ id: "vertical", kind: "optional-group", column: "in-progress", config: { template: { nodes: [], edges: [] } } },
|
||||||
|
{ id: "end", kind: "end", column: "done" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "optional", condition: "success" },
|
||||||
|
{ from: "optional", to: "review", condition: "success" },
|
||||||
|
{ from: "review", to: "vertical", condition: "success" },
|
||||||
|
{ from: "vertical", to: "end", condition: "success" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const compactManualLayout: WorkflowDefinition["layout"] = {
|
||||||
|
start: { x: 40, y: 160 },
|
||||||
|
optional: { x: 280, y: 160 },
|
||||||
|
review: { x: 850, y: 160 },
|
||||||
|
vertical: { x: 850, y: 460 },
|
||||||
|
end: { x: 1420, y: 460 },
|
||||||
|
};
|
||||||
|
const { nodes: compactManualNodes } = irToFlow(makeDef(compactManualIr, compactManualLayout));
|
||||||
|
const compactManualById = new Map(compactManualNodes.map((node) => [node.id, node] as const));
|
||||||
|
expect(compactManualById.get("optional")?.position).toEqual(compactManualLayout.optional);
|
||||||
|
expect(compactManualById.get("review")?.position).toEqual(compactManualLayout.review);
|
||||||
|
expect(compactManualById.get("vertical")?.position).toEqual(compactManualLayout.vertical);
|
||||||
});
|
});
|
||||||
|
|
||||||
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
|
// FNXC:WorkflowOptionalGroup 2026-06-21-11:30: Deleting an optional-group must
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ 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", () => {
|
it("summarizes consecutive optional-group container connections for built-in mobile outlines", () => {
|
||||||
for (const [name, ir, incoming] of [
|
for (const [name, ir, incoming] of [
|
||||||
["coding", BUILTIN_CODING_WORKFLOW_IR, "execute"],
|
["coding", BUILTIN_CODING_WORKFLOW_IR, "execute"],
|
||||||
["stepwise", BUILTIN_STEPWISE_CODING_WORKFLOW_IR, "steps"],
|
["stepwise", BUILTIN_STEPWISE_CODING_WORKFLOW_IR, "steps"],
|
||||||
@@ -141,9 +141,11 @@ describe("buildMobileWorkflowGraph", () => {
|
|||||||
const { nodes, edges } = irToFlow(workflowDef(ir));
|
const { nodes, edges } = irToFlow(workflowDef(ir));
|
||||||
const rows = buildMobileWorkflowGraph(nodes, edges, ir.version === "v2" ? ir.columns : []);
|
const rows = buildMobileWorkflowGraph(nodes, edges, ir.version === "v2" ? ir.columns : []);
|
||||||
const browserVerification = rows.find((row) => row.id === "browser-verification");
|
const browserVerification = rows.find((row) => row.id === "browser-verification");
|
||||||
|
const codeReview = rows.find((row) => row.id === "code-review");
|
||||||
const incomingRow = rows.find((row) => row.id === incoming);
|
const incomingRow = rows.find((row) => row.id === incoming);
|
||||||
|
|
||||||
expect(browserVerification?.kind, name).toBe("optional-group");
|
expect(browserVerification?.kind, name).toBe("optional-group");
|
||||||
|
expect(codeReview?.kind, name).toBe("optional-group");
|
||||||
expect(incomingRow?.outgoing.some((out) => out.target === "browser-verification"), name).toBe(true);
|
expect(incomingRow?.outgoing.some((out) => out.target === "browser-verification"), name).toBe(true);
|
||||||
expect(browserVerification?.outgoing.map((out) => [out.target, out.label]), name).toEqual(
|
expect(browserVerification?.outgoing.map((out) => [out.target, out.label]), name).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@@ -151,6 +153,12 @@ describe("buildMobileWorkflowGraph", () => {
|
|||||||
["end", "failure"],
|
["end", "failure"],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
expect(codeReview?.outgoing.map((out) => [out.target, out.label]), name).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
["review", "success"],
|
||||||
|
["end", "failure"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ export function autoLayout(
|
|||||||
/*
|
/*
|
||||||
* FNXC:WorkflowContainerEdges 2026-06-26-08:05:
|
* 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.
|
* 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.
|
||||||
|
*
|
||||||
|
* FNXC:WorkflowContainerEdges 2026-06-27-22:16:
|
||||||
|
* The invariant is run-length based, not single-container based: every layer in a consecutive optional-group/foreach/loop sequence must advance by that layer's rendered width so N adjacent containers keep visible target/source edge handles.
|
||||||
*/
|
*/
|
||||||
const layerMaxWidths = new Map<number, number>();
|
const layerMaxWidths = new Map<number, number>();
|
||||||
for (const [layerIndex, layerIds] of layers) {
|
for (const [layerIndex, layerIds] of layers) {
|
||||||
|
|||||||
@@ -292,6 +292,23 @@ function fallbackWidthForNode(node: WorkflowIrNode): number {
|
|||||||
return groupTemplateConfigOf(node) ? FOREACH_GROUP_WIDTH : WF_CARD_WIDTH;
|
return groupTemplateConfigOf(node) ? FOREACH_GROUP_WIDTH : WF_CARD_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackHeightForNode(node: WorkflowIrNode): number {
|
||||||
|
return groupTemplateConfigOf(node) ? FOREACH_GROUP_HEIGHT : WF_CARD_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContainerNode(node: WorkflowIrNode): boolean {
|
||||||
|
return Boolean(groupTemplateConfigOf(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function verticalSpansOverlap(
|
||||||
|
a: { y: number },
|
||||||
|
aHeight: number,
|
||||||
|
b: { y: number },
|
||||||
|
bHeight: number,
|
||||||
|
): boolean {
|
||||||
|
return a.y < b.y + bHeight && b.y < a.y + aHeight;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:WorkflowContainerEdges 2026-06-26-07:30:
|
* 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.
|
* 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.
|
||||||
@@ -306,6 +323,32 @@ function fallbackXPositionsForNodes(nodes: readonly WorkflowIrNode[], originX =
|
|||||||
return positions;
|
return positions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasUsableTopLevelLayout(def: WorkflowDefinition): boolean {
|
||||||
|
if (!def.ir.nodes.every((node) => Boolean(def.layout?.[node.id]))) return false;
|
||||||
|
|
||||||
|
const byId = new Map(def.ir.nodes.map((node) => [node.id, node] as const));
|
||||||
|
for (const edge of def.ir.edges) {
|
||||||
|
if (edge.kind === "rework") continue;
|
||||||
|
const source = byId.get(edge.from);
|
||||||
|
const target = byId.get(edge.to);
|
||||||
|
const sourcePos = def.layout?.[edge.from];
|
||||||
|
const targetPos = def.layout?.[edge.to];
|
||||||
|
if (!source || !target || !sourcePos || !targetPos) continue;
|
||||||
|
if (!isContainerNode(source) && !isContainerNode(target)) continue;
|
||||||
|
if (!verticalSpansOverlap(sourcePos, fallbackHeightForNode(source), targetPos, fallbackHeightForNode(target))) continue;
|
||||||
|
|
||||||
|
const sourceRight = sourcePos.x + fallbackWidthForNode(source);
|
||||||
|
const targetRight = targetPos.x + fallbackWidthForNode(target);
|
||||||
|
if (sourcePos.x <= targetPos.x) {
|
||||||
|
if (sourceRight > targetPos.x) return false;
|
||||||
|
} else if (targetRight > sourcePos.x) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
@@ -365,10 +408,13 @@ export function irToFlow(def: WorkflowDefinition): {
|
|||||||
/*
|
/*
|
||||||
* FNXC:WorkflowContainerEdges 2026-06-26-07:58:
|
* 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.
|
* 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.
|
||||||
|
*
|
||||||
|
* FNXC:WorkflowContainerEdges 2026-06-27-22:15:
|
||||||
|
* A layout can also be complete-but-stale after consecutive 560px containers are inserted into a formerly card-spaced path. The read-only graph does not run auto-layout, so reject saved top-level coordinates only when a connected optional-group/foreach/loop span actually overlaps another node in the same visual row. This preserves intentional compact or vertical custom layouts while repairing stale container-handle occlusion.
|
||||||
*/
|
*/
|
||||||
const hasCompleteTopLevelLayout = def.ir.nodes.every((node) => Boolean(def.layout?.[node.id]));
|
const useSavedTopLevelLayout = hasUsableTopLevelLayout(def);
|
||||||
const stepNodes = def.ir.nodes.map((node): FlowNode<WorkflowFlowNodeData> => {
|
const stepNodes = def.ir.nodes.map((node): FlowNode<WorkflowFlowNodeData> => {
|
||||||
const pos = hasCompleteTopLevelLayout ? def.layout?.[node.id] : undefined;
|
const pos = useSavedTopLevelLayout ? 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user