edge.kind === "contract")}
+ data-testid="workflow-graph"
+ >
>;
+ 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;
readonly edges: ReadonlyArray;
+ 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 };
};
diff --git a/web/apps/console/src/styles/global.css b/web/apps/console/src/styles/global.css
index 3c8368ac..a99432e8 100644
--- a/web/apps/console/src/styles/global.css
+++ b/web/apps/console/src/styles/global.css
@@ -1731,6 +1731,17 @@ tbody tr:hover {
padding-top: 0.35rem;
}
+.graph-node--contract {
+ border-color: #747b74 !important;
+ border-style: double;
+ background: #f1f0ea;
+ box-shadow: none;
+}
+
+.graph-node--contract .graph-node__label {
+ text-transform: none;
+}
+
.workflow-graph .react-flow__handle {
width: 0.55rem;
height: 0.55rem;
@@ -2023,6 +2034,16 @@ tbody tr:hover {
min-width: 0;
}
+.workflow-graph .graph-edge--contract .react-flow__edge-path {
+ stroke: #687568;
+ stroke-width: 1.5;
+ stroke-dasharray: 6 5;
+}
+
+.workflow-graph .graph-edge--contract .react-flow__edge-text {
+ fill: #536053;
+}
+
.authoring-path-picker {
display: grid;
min-width: 0;
diff --git a/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx b/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx
index 0b2f2ee0..2eca96a7 100644
--- a/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx
+++ b/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx
@@ -31,6 +31,27 @@ const workspace: DraftWorkspace = {
afterEach(() => cleanup());
describe("AuthoringGraph", () => {
+ it("selects workflow contracts without listing derived connectors as routes", () => {
+ const onSelectionChange = vi.fn<(selection: WorkbenchSelection) => void>();
+ const { container } = render(
+ ,
+ );
+
+ fireEvent.click(container.querySelector('[data-node-id="contract:input"]')!);
+ expect(onSelectionChange).toHaveBeenCalledWith({ kind: "contract", contract: "input" });
+ expect(screen.getByLabelText("Route outcomes")).not.toHaveTextContent("starts");
+
+ fireEvent.click(container.querySelector(".react-flow__pane")!);
+ expect(onSelectionChange).toHaveBeenLastCalledWith({ kind: "canvas" });
+ });
+
it("renders the projected graph and marks the selected node", () => {
const selection: WorkbenchSelection = { kind: "node", nodeId: "review" };
const { container } = render(
diff --git a/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx b/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx
index 5ab830c1..15c211aa 100644
--- a/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx
+++ b/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx
@@ -15,14 +15,21 @@ export const AuthoringGraph = ({
onSelectionChange,
}: AuthoringGraphProps) => {
const model = useMemo(() => projectAuthoringGraph(draft), [draft]);
+ const routeEdges = model.edges.filter((edge) => edge.kind !== "contract");
+ const activeNodeId =
+ selection.kind === "node"
+ ? selection.nodeId
+ : selection.kind === "contract"
+ ? `contract:${selection.contract}`
+ : null;
const activeEdgeId =
selection.kind === "edge"
- ? model.edges.find(
+ ? routeEdges.find(
(edge) => edge.source === selection.stepId && edge.label === selection.outcome,
)?.id ?? null
: null;
const selectEdge = (edgeId: string): void => {
- const edge = model.edges.find((candidate) => candidate.id === edgeId);
+ const edge = routeEdges.find((candidate) => candidate.id === edgeId);
if (edge) {
onSelectionChange({
kind: "edge",
@@ -31,6 +38,14 @@ export const AuthoringGraph = ({
});
}
};
+ const selectNode = (nodeId: string): void => {
+ const node = model.nodes.find((candidate) => candidate.id === nodeId);
+ if (node?.data.contract) {
+ onSelectionChange({ kind: "contract", contract: node.data.contract });
+ return;
+ }
+ onSelectionChange({ kind: "node", nodeId });
+ };
return (
@@ -45,17 +60,17 @@ export const AuthoringGraph = ({