FN-6024: fix built-in workflow graph edges
Ensure built-in workflow editor graphs render connected, clickable edges above swimlane backgrounds. - add explicit React Flow z-index layering so built-in workflow edges render above column bands while nodes stay interactive - keep swimlane band backgrounds translucent and preserve failure/rework edge styling for read-only built-in workflows - expand workflow editor tests to cover built-in/custom edge projection, handle rendering, and CSS edge visibility contracts - document the built-in workflow editor edge behavior and add a patch changeset for @runfusion/fusion Files changed: .changeset/FN-6024-built-in-workflow-edges.md | 5 + docs/dashboard-guide.md | 1 + packages/dashboard/app/components/WorkflowNodeEditor.css | 2 +- packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts | 25 ++++ - packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx | 106 ++++++++++++++++++++- packages/dashboard/app/components/workflow-flow-mapping.ts | 15 ++- 6 files changed, 145 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6024 Fusion-Task-Lineage: 7151d59b-b591-4642-95ca-93cc35198d41
This commit is contained in:
5
.changeset/FN-6024-built-in-workflow-edges.md
Normal file
5
.changeset/FN-6024-built-in-workflow-edges.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix built-in workflow editor graph edge visibility so read-only built-in workflows render connected, clickable React Flow edges for success, failure, and rework paths.
|
||||
@@ -107,6 +107,7 @@ Navigation:
|
||||
|
||||
Behavior:
|
||||
- Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels
|
||||
- Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology.
|
||||
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Definitions remain available for custom workflow schema authoring.
|
||||
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those controls write workflow setting values for the active default workflow.
|
||||
- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata
|
||||
|
||||
@@ -802,7 +802,7 @@
|
||||
|
||||
.wf-column-band {
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 65%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -30,11 +30,30 @@ function extractMediaBlocks(css: string, query: string): string[] {
|
||||
}
|
||||
|
||||
function findRule(blocks: string[], selector: RegExp): string {
|
||||
const rule = blocks.map((block) => block.match(selector)?.[0] ?? "").find(Boolean) ?? "";
|
||||
const globalSelector = new RegExp(selector.source, selector.flags.includes("g") ? selector.flags : `${selector.flags}g`);
|
||||
const matches = blocks.flatMap((block) => [...block.matchAll(globalSelector)].map((match) => match[0]));
|
||||
const rule = matches.at(-1) ?? "";
|
||||
expect(rule).toBeTruthy();
|
||||
return rule;
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor edge visibility CSS contract", () => {
|
||||
it("keeps swimlane bands translucent so built-in workflow edges remain visible", () => {
|
||||
const editorCss = readComponentCss("WorkflowNodeEditor.css");
|
||||
const columnBandRule = findRule([editorCss], /\.wf-column-band\s*\{[^}]*\}/);
|
||||
expect(columnBandRule).toMatch(/background\s*:\s*color-mix\(in srgb, var\(--bg-secondary\) 65%, transparent\)\s*;/);
|
||||
expect(columnBandRule).toMatch(/pointer-events\s*:\s*none\s*;/);
|
||||
|
||||
const reworkRule = findRule([editorCss], /\.wf-edge-rework \.react-flow__edge-path\s*\{[^}]*\}/);
|
||||
expect(reworkRule).toMatch(/stroke\s*:\s*var\(--accent, var\(--ws-info\)\)\s*;/);
|
||||
expect(reworkRule).toMatch(/stroke-dasharray\s*:\s*5 4\s*;/);
|
||||
|
||||
const failureRule = findRule([editorCss], /\.react-flow__edge\.wf-edge-failure \.react-flow__edge-path\s*\{[^}]*\}/);
|
||||
expect(failureRule).toMatch(/stroke\s*:\s*var\(--ws-error\)\s*;/);
|
||||
expect(failureRule).toMatch(/stroke-dasharray\s*:\s*2 4\s*;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowNodeEditor mobile CSS contract", () => {
|
||||
it("FN-5992 preserves desktop editor min-width while adding full-screen mobile overrides", () => {
|
||||
const baseCss = loadAllAppCssBaseOnly();
|
||||
@@ -49,7 +68,7 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
|
||||
expect(editorModalRule).toMatch(/border-radius\s*:\s*0\s*;/);
|
||||
expect(editorModalRule).toMatch(/resize\s*:\s*none\s*;/);
|
||||
|
||||
const sidebarRule = findRule(mobileBlocks, /\.wf-editor-sidebar\s*\{[^}]*\}/);
|
||||
const sidebarRule = findRule(mobileBlocks, /\.wf-editor-body--list-stage \.wf-editor-sidebar\s*\{[^}]*\}/);
|
||||
expect(sidebarRule).toMatch(/width\s*:\s*100%\s*;/);
|
||||
|
||||
const inspectorRule = findRule(mobileBlocks, /\.wf-editor-inspector\s*\{[^}]*\}/);
|
||||
@@ -60,7 +79,7 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
|
||||
expect(settingsRule).toMatch(/min-width\s*:\s*0\s*;/);
|
||||
|
||||
const canvasWrapRule = findRule(mobileBlocks, /\.wf-editor-canvas-wrap\s*\{[^}]*\}/);
|
||||
expect(canvasWrapRule).toMatch(/min-height\s*:\s*40vh\s*;/);
|
||||
expect(canvasWrapRule).toMatch(/min-height\s*:\s*0\s*;/);
|
||||
});
|
||||
|
||||
it("FN-5992 covers create dialog and AI panel mobile overlays", () => {
|
||||
|
||||
@@ -2,8 +2,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
|
||||
import type { WorkflowDefinition, Settings } from "@fusion/core";
|
||||
import type { Agent } from "../../api";
|
||||
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||
import {
|
||||
irToFlow,
|
||||
flowToIr,
|
||||
emptyWorkflowIr,
|
||||
emptyWorkflowLayout,
|
||||
foreachChildFlowId,
|
||||
WF_EDGE_INTERACTION_WIDTH,
|
||||
} from "../workflow-flow-mapping";
|
||||
import {
|
||||
BUILTIN_CODING_WORKFLOW_IR,
|
||||
BUILTIN_PR_WORKFLOW_IR,
|
||||
BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
|
||||
BUILTIN_WORKFLOWS,
|
||||
} from "@fusion/core";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchWorkflows: vi.fn(),
|
||||
@@ -133,6 +145,36 @@ function builtinDef(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function builtinPrDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "builtin:pr-workflow",
|
||||
kind: "workflow",
|
||||
name: "PR lifecycle (built-in)",
|
||||
description: "Ships with Fusion",
|
||||
ir: BUILTIN_PR_WORKFLOW_IR,
|
||||
layout: {},
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function edgeRenderableAssertion(definition: WorkflowDefinition) {
|
||||
const flow = irToFlow(definition);
|
||||
const nodeIds = new Set(flow.nodes.map((node) => node.id));
|
||||
expect(flow.edges.length, `${definition.id} should project edges`).toBeGreaterThan(0);
|
||||
for (const edge of flow.edges) {
|
||||
expect(nodeIds.has(edge.source), `${definition.id} edge ${edge.id} source ${edge.source}`).toBe(true);
|
||||
expect(nodeIds.has(edge.target), `${definition.id} edge ${edge.id} target ${edge.target}`).toBe(true);
|
||||
expect(edge.interactionWidth, `${definition.id} edge ${edge.id} interaction width`).toBe(
|
||||
WF_EDGE_INTERACTION_WIDTH,
|
||||
);
|
||||
expect(edge.zIndex, `${definition.id} edge ${edge.id} z-index`).toBeGreaterThan(0);
|
||||
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined();
|
||||
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined();
|
||||
}
|
||||
return flow;
|
||||
}
|
||||
|
||||
function fragmentDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-FRAG",
|
||||
@@ -203,6 +245,37 @@ describe("workflow-flow-mapping", () => {
|
||||
expect(ir.edges).toEqual([{ from: "start", to: "end", condition: "success" }]);
|
||||
expect(emptyWorkflowLayout().start).toBeDefined();
|
||||
});
|
||||
|
||||
it("projects every built-in workflow to connected, clickable React Flow edges", () => {
|
||||
expect(BUILTIN_WORKFLOWS.map((workflow) => workflow.id).sort()).toEqual(
|
||||
expect.arrayContaining(["builtin:coding", "builtin:stepwise-coding", "builtin:pr-workflow"]),
|
||||
);
|
||||
|
||||
for (const workflow of BUILTIN_WORKFLOWS) {
|
||||
edgeRenderableAssertion(workflow);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps built-in edge endpoints connected when layout is empty, undefined, or populated", () => {
|
||||
const populated = BUILTIN_WORKFLOWS.find((workflow) => workflow.id === "builtin:pr-workflow");
|
||||
expect(populated).toBeDefined();
|
||||
edgeRenderableAssertion(populated!);
|
||||
edgeRenderableAssertion({ ...populated!, layout: {} });
|
||||
edgeRenderableAssertion({ ...populated!, layout: undefined });
|
||||
});
|
||||
|
||||
it("projects custom v1 and v2 workflows to the same connected edge contract", () => {
|
||||
edgeRenderableAssertion(def());
|
||||
edgeRenderableAssertion(v2Def());
|
||||
});
|
||||
|
||||
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
|
||||
const { edges } = edgeRenderableAssertion(builtinDef());
|
||||
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
|
||||
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual(["execute", "merge", "review"]);
|
||||
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
|
||||
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowNodeEditor", () => {
|
||||
@@ -767,6 +840,7 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -799,6 +873,31 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
|
||||
expect(flowNodeIds).toContain(foreachChildFlowId("steps", "step-done"));
|
||||
});
|
||||
|
||||
it("renders built-in and custom workflow nodes with handles matching projected edges", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinPrDef(), v2Def()]);
|
||||
const { container } = render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByTestId("wf-readonly-banner");
|
||||
await waitFor(() => expect(screen.getAllByTestId("wf-node-hold").length).toBeGreaterThan(0));
|
||||
const builtInFlow = irToFlow(builtinPrDef());
|
||||
for (const edge of builtInFlow.edges.filter((candidate) => candidate.source === "pr-create")) {
|
||||
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.source}"][data-handlepos="right"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.target}"][data-handlepos="left"]`)).toBeInTheDocument();
|
||||
}
|
||||
expect(builtInFlow.edges.some((edge) => edge.label === "open")).toBe(true);
|
||||
expect(builtInFlow.edges.some((edge) => edge.label === "failed")).toBe(true);
|
||||
expect(builtInFlow.edges.some((edge) => edge.label === "failure")).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByText("Custom"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-readonly-banner")).not.toBeInTheDocument());
|
||||
const customFlow = irToFlow(v2Def());
|
||||
for (const edge of customFlow.edges) {
|
||||
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.source}"][data-handlepos="right"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.target}"][data-handlepos="left"]`)).toBeInTheDocument();
|
||||
}
|
||||
expect(customFlow.edges.every((edge) => edge.label === "success")).toBe(true);
|
||||
});
|
||||
|
||||
it("irToFlow on the built-in stepwise IR yields a foreach group + rework-styled template edge (editor load path)", () => {
|
||||
// Mirrors exactly what the editor's load effect feeds React Flow:
|
||||
// const flow = irToFlow(activeWorkflow)
|
||||
@@ -817,6 +916,7 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
|
||||
// its rework styling so the editor shows the bounded loop-back.
|
||||
const reworkEdges = edges.filter((e) => e.data?.kind === "rework");
|
||||
expect(reworkEdges.length).toBeGreaterThan(0);
|
||||
expect(group?.zIndex).toBeLessThan(reworkEdges[0]?.zIndex ?? 0);
|
||||
expect(reworkEdges.every((e) => e.animated === true && e.className === "wf-edge-rework")).toBe(true);
|
||||
expect(reworkEdges.some((e) => e.source === foreachChildFlowId("steps", "step-review"))).toBe(true);
|
||||
});
|
||||
@@ -864,7 +964,7 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
|
||||
fireEvent.click(executeNode!);
|
||||
|
||||
expect((screen.getByLabelText("Prompt") as HTMLTextAreaElement).value).toContain(
|
||||
"Fusion's standard implementation prompt",
|
||||
"task execution agent",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ export const COLUMN_BAND_HEIGHT = 220;
|
||||
export const COLUMN_BAND_WIDTH = 5000;
|
||||
export const COLUMN_BAND_X = -40;
|
||||
export const COLUMN_BAND_TOP = 0;
|
||||
/** Layering: swimlane/template groups sit below routed edges; step cards sit above. */
|
||||
const WF_BACKGROUND_GROUP_Z_INDEX = 0;
|
||||
const WF_EDGE_Z_INDEX = 1;
|
||||
const WF_STEP_NODE_Z_INDEX = 2;
|
||||
/** React Flow node id for a column band group node. */
|
||||
export const columnBandNodeId = (columnId: string): string => `__col__:${columnId}`;
|
||||
export const isColumnBandNode = (id: string): boolean => id.startsWith("__col__:");
|
||||
@@ -153,8 +157,8 @@ export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode<Workfl
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
deletable: false,
|
||||
// Bands sit behind step nodes so steps remain clickable/draggable.
|
||||
zIndex: -1,
|
||||
// Bands sit behind routed edges and step nodes so built-in topology stays visible.
|
||||
zIndex: WF_BACKGROUND_GROUP_Z_INDEX,
|
||||
style: {
|
||||
width: COLUMN_BAND_WIDTH,
|
||||
height: COLUMN_BAND_HEIGHT,
|
||||
@@ -212,6 +216,7 @@ function irEdgeToFlow(edge: WorkflowIrEdge, index: number, idScope = ""): FlowEd
|
||||
className: edgeClassName(condition, isRework),
|
||||
interactionWidth: WF_EDGE_INTERACTION_WIDTH,
|
||||
markerEnd: undefined,
|
||||
zIndex: WF_EDGE_Z_INDEX,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,6 +274,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
extent: "parent",
|
||||
data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },
|
||||
deletable: true,
|
||||
zIndex: WF_STEP_NODE_Z_INDEX,
|
||||
});
|
||||
});
|
||||
template.edges.forEach((edge, eIdx) => {
|
||||
@@ -289,6 +295,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
},
|
||||
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
|
||||
deletable: true,
|
||||
zIndex: WF_BACKGROUND_GROUP_Z_INDEX,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,6 +310,7 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
column,
|
||||
},
|
||||
deletable: node.kind !== "start" && node.kind !== "end",
|
||||
zIndex: WF_STEP_NODE_Z_INDEX,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -941,6 +949,7 @@ function irNodeToFlowNode(
|
||||
position,
|
||||
data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } },
|
||||
deletable: node.kind !== "start" && node.kind !== "end",
|
||||
zIndex: WF_STEP_NODE_Z_INDEX,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1018,6 +1027,7 @@ export function insertFragment(
|
||||
extent: "parent",
|
||||
data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },
|
||||
deletable: true,
|
||||
zIndex: WF_STEP_NODE_Z_INDEX,
|
||||
});
|
||||
});
|
||||
template.edges.forEach((edge, eIdx) => {
|
||||
@@ -1037,6 +1047,7 @@ export function insertFragment(
|
||||
},
|
||||
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
|
||||
deletable: true,
|
||||
zIndex: WF_BACKGROUND_GROUP_Z_INDEX,
|
||||
};
|
||||
}
|
||||
return irNodeToFlowNode(node, id, pos);
|
||||
|
||||
Reference in New Issue
Block a user