feat: project workflow contracts in graph

This commit is contained in:
lda
2026-08-14 22:19:02 +07:00 Verified
parent 22bcba3596
commit 737689d015
13 changed files with 602 additions and 23 deletions
@@ -79,6 +79,71 @@ const findNodeById = (container: HTMLElement, nodeId: string): HTMLElement | nul
container.querySelector(`[data-node-id="${nodeId}"]`);
describe("WorkflowGraph", () => {
it("renders contract nodes and non-selectable derived connectors horizontally", () => {
const onNodeSelect = vi.fn();
const onEdgeSelect = vi.fn();
const contractModel: WorkflowGraphModel = {
direction: "LR",
nodes: [
{
id: "contract:input",
data: {
nodeId: "contract:input",
kind: "contract",
contract: "input",
label: "Input",
summary: "2 fields · entry collect",
nodeRef: null,
raw: {},
},
position: { x: 0, y: 0 },
},
{
id: "collect",
data: {
nodeId: "collect",
kind: "use",
label: "Collect",
nodeRef: "demo.collect",
raw: {},
},
position: { x: 250, y: 0 },
},
],
edges: [{
id: "contract-edge",
source: "contract:input",
target: "collect",
label: "starts",
kind: "contract",
}],
};
const { container } = render(
<WorkflowGraph
model={contractModel}
onEdgeSelect={onEdgeSelect}
onNodeSelect={onNodeSelect}
/>,
);
const node = findNodeById(container, "contract:input");
// React Flow keeps unmeasured test nodes hidden; reveal the wrapper so the
// accessibility query exercises the same name exposed after browser layout.
node?.closest<HTMLElement>(".react-flow__node")?.style.setProperty("visibility", "visible");
expect(screen.getByRole("button", { name: /input workflow contract/i })).toBe(node);
expect(node).toHaveAttribute("data-contract", "input");
expect(node?.querySelector(".react-flow__handle-left")).not.toBeNull();
expect(node?.querySelector(".react-flow__handle-right")).not.toBeNull();
expect(screen.getByTestId("workflow-graph")).toHaveAttribute(
"data-derived-connectors",
"true",
);
fireEvent.click(node!);
fireEvent.keyDown(node!, { key: "Enter" });
expect(onNodeSelect).toHaveBeenCalledTimes(2);
expect(onEdgeSelect).not.toHaveBeenCalled();
});
it("renders nodes and edges", () => {
const { container } = render(<WorkflowGraph model={mockModel} />);
expect(screen.getByText("Start")).toBeInTheDocument();
+29 -8
View File
@@ -40,8 +40,14 @@ const nodeColor = (data: WorkflowGraphNodeData): string => {
}
};
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
type RenderedNodeData = WorkflowGraphNodeData & {
readonly direction: "TB" | "LR";
};
const CustomNode = ({ data, selected }: { data: RenderedNodeData; selected: boolean }) => {
const isActive = data.isActive;
const targetPosition = data.direction === "LR" ? Position.Left : Position.Top;
const sourcePosition = data.direction === "LR" ? Position.Right : Position.Bottom;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
@@ -52,20 +58,24 @@ const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected:
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
{...(data.contract
? { "aria-label": `${data.label} workflow contract${data.summary ? `, ${data.summary}` : ""}` }
: {})}
aria-pressed={selected}
data-active={isActive}
{...(data.contract ? { "data-contract": data.contract } : {})}
data-node-id={data.nodeId}
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
style={{ borderColor: nodeColor(data) }}
>
<Handle type="target" position={Position.Top} />
<Handle type="target" position={targetPosition} />
<div className="graph-node__label">{data.label}</div>
{data.nodeRef && (
<div className="graph-node__ref">{data.nodeRef}</div>
)}
{data.detail && <div className="graph-node__detail">{data.detail}</div>}
{data.summary && <div className="graph-node__summary">{data.summary}</div>}
<Handle type="source" position={Position.Bottom} />
<Handle type="source" position={sourcePosition} />
</div>
);
};
@@ -89,9 +99,14 @@ export const WorkflowGraph = ({
type: "custom",
position: n.position,
selected: activeNodeId === n.id,
data: { ...n.data, isActive: activeNodeId === n.id, onSelect: onNodeSelect },
data: {
...n.data,
direction: model.direction ?? "TB",
isActive: activeNodeId === n.id,
onSelect: onNodeSelect,
},
})),
[model.nodes, activeNodeId, onNodeSelect],
[model.direction, model.nodes, activeNodeId, onNodeSelect],
);
const edges: Edge[] = useMemo(
@@ -102,8 +117,9 @@ export const WorkflowGraph = ({
target: e.target,
label: e.label,
type: "default",
className: `graph-edge--${e.kind ?? "route"}`,
selected: activeEdgeId === e.id,
selectable: Boolean(onEdgeSelect),
selectable: e.kind !== "contract" && Boolean(onEdgeSelect),
})),
[activeEdgeId, model.edges, onEdgeSelect],
);
@@ -117,9 +133,10 @@ export const WorkflowGraph = ({
const handleEdgeClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
if (model.edges.find((candidate) => candidate.id === edge.id)?.kind === "contract") return;
onEdgeSelect?.(edge.id);
},
[onEdgeSelect],
[model.edges, onEdgeSelect],
);
if (model.nodes.length === 0) {
@@ -131,7 +148,11 @@ export const WorkflowGraph = ({
}
return (
<div className="workflow-graph" data-testid="workflow-graph">
<div
className="workflow-graph"
data-derived-connectors={model.edges.some((edge) => edge.kind === "contract")}
data-testid="workflow-graph"
>
<ReactFlow
nodes={nodes}
edges={edges}
+21 -2
View File
@@ -1,6 +1,7 @@
import dagre from "@dagrejs/dagre";
export type WorkflowGraphNodeKind =
| "contract"
| "use"
| "subgraph"
| "condition"
@@ -18,6 +19,7 @@ export type WorkflowGraphNodeData = {
readonly summary?: string;
readonly nodeRef: string | null;
readonly raw: Readonly<Record<string, unknown>>;
readonly contract?: "input" | "state" | "output" | "outcomes";
readonly onSelect?: (nodeId: string) => void;
readonly isActive?: boolean;
};
@@ -33,11 +35,13 @@ export type WorkflowGraphEdge = {
readonly source: string;
readonly target: string;
readonly label: string;
readonly kind?: "route" | "contract";
};
export type WorkflowGraphModel = {
readonly nodes: ReadonlyArray<WorkflowGraphNode>;
readonly edges: ReadonlyArray<WorkflowGraphEdge>;
readonly direction?: "TB" | "LR";
};
export type WorkflowGraphLayoutOptions = {
@@ -80,6 +84,8 @@ const mapNodeKind = (type: unknown): WorkflowGraphNodeKind => {
switch (type) {
case "node":
return "use";
case "contract":
return "contract";
case "subgraph":
return "subgraph";
case "condition":
@@ -104,6 +110,7 @@ const buildLabel = (
const overriddenLabel = labelOverride?.(node);
if (overriddenLabel) return overriddenLabel;
const type = typeof node.type === "string" ? node.type : "";
if (type === "contract" && typeof node.label === "string") return node.label;
if (type === "end") {
return typeof node.outcome === "string" ? node.outcome : "End";
}
@@ -179,6 +186,12 @@ export const buildWorkflowGraph = (
...(typeof node.summary === "string" ? { summary: node.summary } : {}),
nodeRef: typeof node.node === "string" ? node.node : null,
raw: node,
...(node.contract === "input" ||
node.contract === "state" ||
node.contract === "output" ||
node.contract === "outcomes"
? { contract: node.contract }
: {}),
},
position: {
x: pos.x - layout.nodeWidth / 2,
@@ -192,8 +205,14 @@ export const buildWorkflowGraph = (
const target = String(edge.to);
const label = String(edge.outcome ?? "");
const id = workflowGraphEdgeId(source, label, target);
return { id, source, target, label };
return {
id,
source,
target,
label,
kind: edge.kind === "contract" ? "contract" : "route",
};
});
return { nodes, edges };
return { nodes, edges, direction: layout.direction };
};