feat: project workflow contracts in graph
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user