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}"]`);
|
container.querySelector(`[data-node-id="${nodeId}"]`);
|
||||||
|
|
||||||
describe("WorkflowGraph", () => {
|
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", () => {
|
it("renders nodes and edges", () => {
|
||||||
const { container } = render(<WorkflowGraph model={mockModel} />);
|
const { container } = render(<WorkflowGraph model={mockModel} />);
|
||||||
expect(screen.getByText("Start")).toBeInTheDocument();
|
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 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) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -52,20 +58,24 @@ const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected:
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
{...(data.contract
|
||||||
|
? { "aria-label": `${data.label} workflow contract${data.summary ? `, ${data.summary}` : ""}` }
|
||||||
|
: {})}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
|
{...(data.contract ? { "data-contract": data.contract } : {})}
|
||||||
data-node-id={data.nodeId}
|
data-node-id={data.nodeId}
|
||||||
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
|
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
|
||||||
style={{ borderColor: nodeColor(data) }}
|
style={{ borderColor: nodeColor(data) }}
|
||||||
>
|
>
|
||||||
<Handle type="target" position={Position.Top} />
|
<Handle type="target" position={targetPosition} />
|
||||||
<div className="graph-node__label">{data.label}</div>
|
<div className="graph-node__label">{data.label}</div>
|
||||||
{data.nodeRef && (
|
{data.nodeRef && (
|
||||||
<div className="graph-node__ref">{data.nodeRef}</div>
|
<div className="graph-node__ref">{data.nodeRef}</div>
|
||||||
)}
|
)}
|
||||||
{data.detail && <div className="graph-node__detail">{data.detail}</div>}
|
{data.detail && <div className="graph-node__detail">{data.detail}</div>}
|
||||||
{data.summary && <div className="graph-node__summary">{data.summary}</div>}
|
{data.summary && <div className="graph-node__summary">{data.summary}</div>}
|
||||||
<Handle type="source" position={Position.Bottom} />
|
<Handle type="source" position={sourcePosition} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -89,9 +99,14 @@ export const WorkflowGraph = ({
|
|||||||
type: "custom",
|
type: "custom",
|
||||||
position: n.position,
|
position: n.position,
|
||||||
selected: activeNodeId === n.id,
|
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(
|
const edges: Edge[] = useMemo(
|
||||||
@@ -102,8 +117,9 @@ export const WorkflowGraph = ({
|
|||||||
target: e.target,
|
target: e.target,
|
||||||
label: e.label,
|
label: e.label,
|
||||||
type: "default",
|
type: "default",
|
||||||
|
className: `graph-edge--${e.kind ?? "route"}`,
|
||||||
selected: activeEdgeId === e.id,
|
selected: activeEdgeId === e.id,
|
||||||
selectable: Boolean(onEdgeSelect),
|
selectable: e.kind !== "contract" && Boolean(onEdgeSelect),
|
||||||
})),
|
})),
|
||||||
[activeEdgeId, model.edges, onEdgeSelect],
|
[activeEdgeId, model.edges, onEdgeSelect],
|
||||||
);
|
);
|
||||||
@@ -117,9 +133,10 @@ export const WorkflowGraph = ({
|
|||||||
|
|
||||||
const handleEdgeClick = useCallback(
|
const handleEdgeClick = useCallback(
|
||||||
(_event: React.MouseEvent, edge: Edge) => {
|
(_event: React.MouseEvent, edge: Edge) => {
|
||||||
|
if (model.edges.find((candidate) => candidate.id === edge.id)?.kind === "contract") return;
|
||||||
onEdgeSelect?.(edge.id);
|
onEdgeSelect?.(edge.id);
|
||||||
},
|
},
|
||||||
[onEdgeSelect],
|
[model.edges, onEdgeSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (model.nodes.length === 0) {
|
if (model.nodes.length === 0) {
|
||||||
@@ -131,7 +148,11 @@ export const WorkflowGraph = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import dagre from "@dagrejs/dagre";
|
import dagre from "@dagrejs/dagre";
|
||||||
|
|
||||||
export type WorkflowGraphNodeKind =
|
export type WorkflowGraphNodeKind =
|
||||||
|
| "contract"
|
||||||
| "use"
|
| "use"
|
||||||
| "subgraph"
|
| "subgraph"
|
||||||
| "condition"
|
| "condition"
|
||||||
@@ -18,6 +19,7 @@ export type WorkflowGraphNodeData = {
|
|||||||
readonly summary?: string;
|
readonly summary?: string;
|
||||||
readonly nodeRef: string | null;
|
readonly nodeRef: string | null;
|
||||||
readonly raw: Readonly<Record<string, unknown>>;
|
readonly raw: Readonly<Record<string, unknown>>;
|
||||||
|
readonly contract?: "input" | "state" | "output" | "outcomes";
|
||||||
readonly onSelect?: (nodeId: string) => void;
|
readonly onSelect?: (nodeId: string) => void;
|
||||||
readonly isActive?: boolean;
|
readonly isActive?: boolean;
|
||||||
};
|
};
|
||||||
@@ -33,11 +35,13 @@ export type WorkflowGraphEdge = {
|
|||||||
readonly source: string;
|
readonly source: string;
|
||||||
readonly target: string;
|
readonly target: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
readonly kind?: "route" | "contract";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowGraphModel = {
|
export type WorkflowGraphModel = {
|
||||||
readonly nodes: ReadonlyArray<WorkflowGraphNode>;
|
readonly nodes: ReadonlyArray<WorkflowGraphNode>;
|
||||||
readonly edges: ReadonlyArray<WorkflowGraphEdge>;
|
readonly edges: ReadonlyArray<WorkflowGraphEdge>;
|
||||||
|
readonly direction?: "TB" | "LR";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowGraphLayoutOptions = {
|
export type WorkflowGraphLayoutOptions = {
|
||||||
@@ -80,6 +84,8 @@ const mapNodeKind = (type: unknown): WorkflowGraphNodeKind => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "node":
|
case "node":
|
||||||
return "use";
|
return "use";
|
||||||
|
case "contract":
|
||||||
|
return "contract";
|
||||||
case "subgraph":
|
case "subgraph":
|
||||||
return "subgraph";
|
return "subgraph";
|
||||||
case "condition":
|
case "condition":
|
||||||
@@ -104,6 +110,7 @@ const buildLabel = (
|
|||||||
const overriddenLabel = labelOverride?.(node);
|
const overriddenLabel = labelOverride?.(node);
|
||||||
if (overriddenLabel) return overriddenLabel;
|
if (overriddenLabel) return overriddenLabel;
|
||||||
const type = typeof node.type === "string" ? node.type : "";
|
const type = typeof node.type === "string" ? node.type : "";
|
||||||
|
if (type === "contract" && typeof node.label === "string") return node.label;
|
||||||
if (type === "end") {
|
if (type === "end") {
|
||||||
return typeof node.outcome === "string" ? node.outcome : "End";
|
return typeof node.outcome === "string" ? node.outcome : "End";
|
||||||
}
|
}
|
||||||
@@ -179,6 +186,12 @@ export const buildWorkflowGraph = (
|
|||||||
...(typeof node.summary === "string" ? { summary: node.summary } : {}),
|
...(typeof node.summary === "string" ? { summary: node.summary } : {}),
|
||||||
nodeRef: typeof node.node === "string" ? node.node : null,
|
nodeRef: typeof node.node === "string" ? node.node : null,
|
||||||
raw: node,
|
raw: node,
|
||||||
|
...(node.contract === "input" ||
|
||||||
|
node.contract === "state" ||
|
||||||
|
node.contract === "output" ||
|
||||||
|
node.contract === "outcomes"
|
||||||
|
? { contract: node.contract }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
position: {
|
position: {
|
||||||
x: pos.x - layout.nodeWidth / 2,
|
x: pos.x - layout.nodeWidth / 2,
|
||||||
@@ -192,8 +205,14 @@ export const buildWorkflowGraph = (
|
|||||||
const target = String(edge.to);
|
const target = String(edge.to);
|
||||||
const label = String(edge.outcome ?? "");
|
const label = String(edge.outcome ?? "");
|
||||||
const id = workflowGraphEdgeId(source, label, target);
|
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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1731,6 +1731,17 @@ tbody tr:hover {
|
|||||||
padding-top: 0.35rem;
|
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 {
|
.workflow-graph .react-flow__handle {
|
||||||
width: 0.55rem;
|
width: 0.55rem;
|
||||||
height: 0.55rem;
|
height: 0.55rem;
|
||||||
@@ -2023,6 +2034,16 @@ tbody tr:hover {
|
|||||||
min-width: 0;
|
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 {
|
.authoring-path-picker {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -31,6 +31,27 @@ const workspace: DraftWorkspace = {
|
|||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
describe("AuthoringGraph", () => {
|
describe("AuthoringGraph", () => {
|
||||||
|
it("selects workflow contracts without listing derived connectors as routes", () => {
|
||||||
|
const onSelectionChange = vi.fn<(selection: WorkbenchSelection) => void>();
|
||||||
|
const { container } = render(
|
||||||
|
<AuthoringGraph
|
||||||
|
draft={{
|
||||||
|
...workspace.draft,
|
||||||
|
input_schema: { type: "object", properties: { query: { type: "string" } } },
|
||||||
|
}}
|
||||||
|
selection={{ kind: "canvas" }}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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", () => {
|
it("renders the projected graph and marks the selected node", () => {
|
||||||
const selection: WorkbenchSelection = { kind: "node", nodeId: "review" };
|
const selection: WorkbenchSelection = { kind: "node", nodeId: "review" };
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -15,14 +15,21 @@ export const AuthoringGraph = ({
|
|||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
}: AuthoringGraphProps) => {
|
}: AuthoringGraphProps) => {
|
||||||
const model = useMemo(() => projectAuthoringGraph(draft), [draft]);
|
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 =
|
const activeEdgeId =
|
||||||
selection.kind === "edge"
|
selection.kind === "edge"
|
||||||
? model.edges.find(
|
? routeEdges.find(
|
||||||
(edge) => edge.source === selection.stepId && edge.label === selection.outcome,
|
(edge) => edge.source === selection.stepId && edge.label === selection.outcome,
|
||||||
)?.id ?? null
|
)?.id ?? null
|
||||||
: null;
|
: null;
|
||||||
const selectEdge = (edgeId: string): void => {
|
const selectEdge = (edgeId: string): void => {
|
||||||
const edge = model.edges.find((candidate) => candidate.id === edgeId);
|
const edge = routeEdges.find((candidate) => candidate.id === edgeId);
|
||||||
if (edge) {
|
if (edge) {
|
||||||
onSelectionChange({
|
onSelectionChange({
|
||||||
kind: "edge",
|
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 (
|
return (
|
||||||
<section aria-label="Workflow graph" className="authoring-graph">
|
<section aria-label="Workflow graph" className="authoring-graph">
|
||||||
@@ -45,17 +60,17 @@ export const AuthoringGraph = ({
|
|||||||
</div>
|
</div>
|
||||||
<WorkflowGraph
|
<WorkflowGraph
|
||||||
activeEdgeId={activeEdgeId}
|
activeEdgeId={activeEdgeId}
|
||||||
activeNodeId={selection.kind === "node" ? selection.nodeId : null}
|
activeNodeId={activeNodeId}
|
||||||
model={model}
|
model={model}
|
||||||
onCanvasSelect={() => onSelectionChange({ kind: "canvas" })}
|
onCanvasSelect={() => onSelectionChange({ kind: "canvas" })}
|
||||||
onEdgeSelect={selectEdge}
|
onEdgeSelect={selectEdge}
|
||||||
onNodeSelect={(nodeId) => onSelectionChange({ kind: "node", nodeId })}
|
onNodeSelect={selectNode}
|
||||||
/>
|
/>
|
||||||
<div aria-label="Route outcomes" className="authoring-graph__routes">
|
<div aria-label="Route outcomes" className="authoring-graph__routes">
|
||||||
<h3>Route outcomes</h3>
|
<h3>Route outcomes</h3>
|
||||||
{model.edges.length > 0 ? (
|
{routeEdges.length > 0 ? (
|
||||||
<ul>
|
<ul>
|
||||||
{model.edges.map((edge) => (
|
{routeEdges.map((edge) => (
|
||||||
<li key={edge.id}>
|
<li key={edge.id}>
|
||||||
<button
|
<button
|
||||||
aria-pressed={activeEdgeId === edge.id}
|
aria-pressed={activeEdgeId === edge.id}
|
||||||
|
|||||||
@@ -224,6 +224,22 @@ export const ContextInspector = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
} else if (selection.kind === "contract") {
|
||||||
|
const contractNode = graph.nodes.find(
|
||||||
|
(candidate) => candidate.data.contract === selection.contract,
|
||||||
|
);
|
||||||
|
const title = selection.contract.charAt(0).toUpperCase() + selection.contract.slice(1);
|
||||||
|
content = (
|
||||||
|
<section
|
||||||
|
aria-labelledby="contract-selection-heading"
|
||||||
|
className="authoring-inspector__selection"
|
||||||
|
>
|
||||||
|
<p className="workspace-route-pending__eyebrow">Workflow projection</p>
|
||||||
|
<h2 id="contract-selection-heading">{title} contract</h2>
|
||||||
|
<p>{contractNode?.data.summary ?? "No contract fields are declared."}</p>
|
||||||
|
<p>This read-only projection is derived from the canonical draft.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
|
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
|
||||||
content = (
|
content = (
|
||||||
@@ -260,7 +276,7 @@ export const ContextInspector = ({
|
|||||||
<button onClick={() => void controller.reapply()} type="button">Reapply local form</button>
|
<button onClick={() => void controller.reapply()} type="button">Reapply local form</button>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<DeferredActions />
|
{selection.kind !== "contract" && <DeferredActions />}
|
||||||
<RawDraft draft={draft.draft} />
|
<RawDraft draft={draft.draft} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -228,6 +228,27 @@ describe("DraftWorkbench", () => {
|
|||||||
expect(within(inspector as HTMLElement).getByRole("textbox", { name: "Outcome" })).toHaveValue("ok");
|
expect(within(inspector as HTMLElement).getByRole("textbox", { name: "Outcome" })).toHaveValue("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps workflow contract selection when the mobile inspector closes and reopens", async () => {
|
||||||
|
setViewport(390);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { container } = render(<DraftWorkbench draft={workspace} />);
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector('[data-node-id="contract:state"]')!);
|
||||||
|
const inspector = container.querySelector("#draft-workbench-inspector") as HTMLElement;
|
||||||
|
expect(inspector).toHaveAttribute("open", "");
|
||||||
|
expect(within(inspector).getByRole("heading", { name: "State contract" })).toBeInTheDocument();
|
||||||
|
expect(within(inspector).queryByRole("heading", { name: "Deferred actions" })).toBeNull();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Close context inspector" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Open context inspector" }));
|
||||||
|
|
||||||
|
expect(within(inspector).getByRole("heading", { name: "State contract" })).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('[data-node-id="contract:state"]')).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a dirty inspector form mounted and intact across mobile close and reopen", async () => {
|
it("keeps a dirty inspector form mounted and intact across mobile close and reopen", async () => {
|
||||||
setViewport(390);
|
setViewport(390);
|
||||||
mockedUseAuthoringCapabilityDetail.mockReturnValue({
|
mockedUseAuthoringCapabilityDetail.mockReturnValue({
|
||||||
|
|||||||
@@ -164,7 +164,12 @@ export const DraftWorkbench = ({
|
|||||||
(nextSelection: WorkbenchSelection): void => {
|
(nextSelection: WorkbenchSelection): void => {
|
||||||
controller.select(nextSelection);
|
controller.select(nextSelection);
|
||||||
onSelectionChange?.(nextSelection);
|
onSelectionChange?.(nextSelection);
|
||||||
if (isMobile && (nextSelection.kind === "edge" || nextSelection.kind === "node")) {
|
if (
|
||||||
|
isMobile &&
|
||||||
|
(nextSelection.kind === "edge" ||
|
||||||
|
nextSelection.kind === "node" ||
|
||||||
|
nextSelection.kind === "contract")
|
||||||
|
) {
|
||||||
setOpenSheet("inspector");
|
setOpenSheet("inspector");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,18 +24,172 @@ const draft = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("projectAuthoringGraph", () => {
|
describe("projectAuthoringGraph", () => {
|
||||||
|
const contractDraft = {
|
||||||
|
name: "contract-workflow",
|
||||||
|
start: "collect",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { query: { type: "string" }, limit: { type: "integer" } },
|
||||||
|
},
|
||||||
|
state_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
report: { type: "string", default: "", reducer: "wf.std.replace" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { text: { type: "string" } },
|
||||||
|
},
|
||||||
|
outcomes: ["ok", "cancelled"],
|
||||||
|
output: [{ path: "state.report", target: "text" }],
|
||||||
|
steps: {
|
||||||
|
collect: {
|
||||||
|
use: "demo.collect",
|
||||||
|
input: [{ path: "input.query", target: "query" }],
|
||||||
|
output: [{ source: "text", target: "state.report" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routes: { collect: { ok: "__end__" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("projects four stable workflow contract nodes without persisting fake steps", () => {
|
||||||
|
const model = projectAuthoringGraph(contractDraft);
|
||||||
|
const contracts = model.nodes.filter((node) => node.data.kind === "contract");
|
||||||
|
|
||||||
|
expect(contracts.map((node) => node.id)).toEqual([
|
||||||
|
"contract:input",
|
||||||
|
"contract:outcomes",
|
||||||
|
"contract:output",
|
||||||
|
"contract:state",
|
||||||
|
]);
|
||||||
|
expect(contracts.map((node) => [node.data.label, node.data.summary])).toEqual([
|
||||||
|
["Input", "2 fields · entry collect"],
|
||||||
|
["Outcomes", "2 outcomes"],
|
||||||
|
["Output", "1 field · 1 binding"],
|
||||||
|
["State", "1 field · 1 reducer · 1 default"],
|
||||||
|
]);
|
||||||
|
expect(Object.keys(contractDraft.steps)).toEqual(["collect"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives entry and binding connectors separately from persisted routes", () => {
|
||||||
|
const model = projectAuthoringGraph(contractDraft);
|
||||||
|
const connectors = model.edges.map((edge) => [
|
||||||
|
edge.source,
|
||||||
|
edge.label,
|
||||||
|
edge.target,
|
||||||
|
(edge as { readonly kind?: string }).kind,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(connectors).toContainEqual([
|
||||||
|
"contract:input",
|
||||||
|
"reads · starts",
|
||||||
|
"collect",
|
||||||
|
"contract",
|
||||||
|
]);
|
||||||
|
expect(connectors).toContainEqual(["collect", "writes", "contract:state", "contract"]);
|
||||||
|
expect(connectors).toContainEqual(["contract:state", "projects", "contract:output", "contract"]);
|
||||||
|
expect(connectors).toContainEqual(["collect", "ok", "__end__", "route"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes string and reference-object state reducers", () => {
|
||||||
|
const model = projectAuthoringGraph({
|
||||||
|
...contractDraft,
|
||||||
|
state_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
report: { type: "string", reducer: "wf.std.replace" },
|
||||||
|
issues: {
|
||||||
|
type: "array",
|
||||||
|
reducer: { capability: "wf.std.append", config: { deduplicate: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.nodes.find((node) => node.id === "contract:state")?.data.summary).toBe(
|
||||||
|
"2 fields · 2 reducers",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps contract ids and positions stable across insertion order", () => {
|
||||||
|
const reordered = {
|
||||||
|
...contractDraft,
|
||||||
|
steps: { collect: contractDraft.steps.collect },
|
||||||
|
output_schema: {
|
||||||
|
...contractDraft.output_schema,
|
||||||
|
properties: { text: { type: "string" } },
|
||||||
|
},
|
||||||
|
input_schema: {
|
||||||
|
...contractDraft.input_schema,
|
||||||
|
properties: { limit: { type: "integer" }, query: { type: "string" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const contractPositions = (value: typeof contractDraft) =>
|
||||||
|
projectAuthoringGraph(value).nodes
|
||||||
|
.filter((node) => node.data.kind === "contract")
|
||||||
|
.map((node) => [node.id, node.position]);
|
||||||
|
|
||||||
|
expect(contractPositions(reordered)).toEqual(contractPositions(contractDraft));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits only the connector derived from a malformed binding", () => {
|
||||||
|
const model = projectAuthoringGraph({
|
||||||
|
...contractDraft,
|
||||||
|
steps: {
|
||||||
|
collect: {
|
||||||
|
...contractDraft.steps.collect,
|
||||||
|
input: [{ path: { root: "input", parts: [""] }, target: "query" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.edges.some((edge) => edge.label === "starts")).toBe(true);
|
||||||
|
expect(model.edges.some((edge) => edge.label === "writes")).toBe(true);
|
||||||
|
expect(model.edges.some((edge) => edge.label === "projects")).toBe(true);
|
||||||
|
expect(model.edges.some((edge) => edge.label === "reads" && edge.source === "contract:input"))
|
||||||
|
.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits malformed step-output and workflow-output connectors independently", () => {
|
||||||
|
const malformedStepOutput = projectAuthoringGraph({
|
||||||
|
...contractDraft,
|
||||||
|
steps: {
|
||||||
|
collect: {
|
||||||
|
...contractDraft.steps.collect,
|
||||||
|
output: [{ source: "text", target: { root: "state", parts: [""] } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(malformedStepOutput.edges.some((edge) => edge.label === "writes")).toBe(false);
|
||||||
|
expect(malformedStepOutput.edges.some((edge) => edge.label.includes("starts"))).toBe(true);
|
||||||
|
expect(malformedStepOutput.edges.some((edge) => edge.label === "projects")).toBe(true);
|
||||||
|
|
||||||
|
const malformedWorkflowOutput = projectAuthoringGraph({
|
||||||
|
...contractDraft,
|
||||||
|
output: [{ path: { root: "state", parts: [""] }, target: "text" }],
|
||||||
|
});
|
||||||
|
expect(malformedWorkflowOutput.edges.some((edge) => edge.label === "projects")).toBe(false);
|
||||||
|
expect(malformedWorkflowOutput.edges.some((edge) => edge.label === "writes")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("projects normal, interrupt, and terminal nodes with labelled routes", () => {
|
it("projects normal, interrupt, and terminal nodes with labelled routes", () => {
|
||||||
const model = projectAuthoringGraph(draft);
|
const model = projectAuthoringGraph(draft);
|
||||||
|
|
||||||
expect(model.nodes.map((node) => [node.id, node.data.kind])).toEqual([
|
expect(model.nodes.map((node) => [node.id, node.data.kind])).toEqual([
|
||||||
["__end__", "end"],
|
["__end__", "end"],
|
||||||
["collect", "use"],
|
["collect", "use"],
|
||||||
|
["contract:input", "contract"],
|
||||||
|
["contract:outcomes", "contract"],
|
||||||
|
["contract:output", "contract"],
|
||||||
|
["contract:state", "contract"],
|
||||||
["review", "interrupt"],
|
["review", "interrupt"],
|
||||||
]);
|
]);
|
||||||
expect(model.edges.map((edge) => [edge.source, edge.label, edge.target])).toEqual([
|
expect(model.edges.map((edge) => [edge.source, edge.label, edge.target])).toEqual([
|
||||||
["collect", "ok", "review"],
|
["collect", "ok", "review"],
|
||||||
["review", "approved", "__end__"],
|
["review", "approved", "__end__"],
|
||||||
["review", "needs_changes", "collect"],
|
["review", "needs_changes", "collect"],
|
||||||
|
["contract:input", "starts", "collect"],
|
||||||
]);
|
]);
|
||||||
expect(model.nodes.find((node) => node.id === "collect")?.data.nodeRef).toBe(
|
expect(model.nodes.find((node) => node.id === "collect")?.data.nodeRef).toBe(
|
||||||
"demo.collect",
|
"demo.collect",
|
||||||
@@ -164,4 +318,10 @@ describe("WorkbenchSelection", () => {
|
|||||||
deriveInsertionContext({ kind: "capability", qualifiedName: "demo.collect" }),
|
deriveInsertionContext({ kind: "capability", qualifiedName: "demo.collect" }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not derive insertion context from workflow contracts", () => {
|
||||||
|
expect(
|
||||||
|
deriveInsertionContext({ kind: "contract", contract: "input" } as WorkbenchSelection),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js";
|
import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js";
|
||||||
import { outputBindingRows, stepInputBindingRows } from "./selected-step-dataflow.js";
|
import type { InputExpression, InputPath, StepInputBinding } from "../domain/draft-workspace-models.js";
|
||||||
|
import { parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||||
|
import {
|
||||||
|
inputBindingRows,
|
||||||
|
outputBindingRows,
|
||||||
|
stepInputBindingRows,
|
||||||
|
} from "./selected-step-dataflow.js";
|
||||||
|
|
||||||
type JsonRecord = Readonly<Record<string, unknown>>;
|
type JsonRecord = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
@@ -7,7 +13,11 @@ export type WorkbenchSelection =
|
|||||||
| { readonly kind: "canvas" }
|
| { readonly kind: "canvas" }
|
||||||
| { readonly kind: "capability"; readonly qualifiedName: string }
|
| { readonly kind: "capability"; readonly qualifiedName: string }
|
||||||
| { readonly kind: "node"; readonly nodeId: string }
|
| { readonly kind: "node"; readonly nodeId: string }
|
||||||
| { readonly kind: "edge"; readonly stepId: string; readonly outcome: string };
|
| { readonly kind: "edge"; readonly stepId: string; readonly outcome: string }
|
||||||
|
| {
|
||||||
|
readonly kind: "contract";
|
||||||
|
readonly contract: "input" | "state" | "output" | "outcomes";
|
||||||
|
};
|
||||||
|
|
||||||
export type InsertionContext = {
|
export type InsertionContext = {
|
||||||
readonly routeFromStep: string;
|
readonly routeFromStep: string;
|
||||||
@@ -16,6 +26,8 @@ export type InsertionContext = {
|
|||||||
|
|
||||||
const EMPTY_GRAPH: WorkflowGraphModel = { nodes: [], edges: [] };
|
const EMPTY_GRAPH: WorkflowGraphModel = { nodes: [], edges: [] };
|
||||||
|
|
||||||
|
type ContractKind = Extract<WorkbenchSelection, { readonly kind: "contract" }>["contract"];
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is JsonRecord =>
|
const isRecord = (value: unknown): value is JsonRecord =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
@@ -190,6 +202,172 @@ const keyedPlan = (draft: JsonRecord): {
|
|||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fieldCount = (schema: unknown): number => {
|
||||||
|
const record = recordValue(schema);
|
||||||
|
const properties = recordValue(record?.properties) ?? recordValue(record?.fields);
|
||||||
|
return properties === null ? 0 : Object.keys(properties).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateMetadataCounts = (schema: unknown): { reducers: number; defaults: number } => {
|
||||||
|
const record = recordValue(schema);
|
||||||
|
const fields = recordValue(record?.properties) ?? recordValue(record?.fields);
|
||||||
|
if (fields === null) return { reducers: 0, defaults: 0 };
|
||||||
|
let reducers = 0;
|
||||||
|
let defaults = 0;
|
||||||
|
for (const field of Object.values(fields)) {
|
||||||
|
const definition = recordValue(field);
|
||||||
|
const reducer = definition?.reducer;
|
||||||
|
if (typeof reducer === "string" || recordValue(reducer) !== null) reducers += 1;
|
||||||
|
if (definition !== null && Object.hasOwn(definition, "default")) defaults += 1;
|
||||||
|
}
|
||||||
|
return { reducers, defaults };
|
||||||
|
};
|
||||||
|
|
||||||
|
const countLabel = (count: number, singular: string): string =>
|
||||||
|
`${count} ${singular}${count === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
|
const contractNode = (
|
||||||
|
contract: ContractKind,
|
||||||
|
label: string,
|
||||||
|
summary: string,
|
||||||
|
): Record<string, unknown> => ({
|
||||||
|
id: `contract:${contract}`,
|
||||||
|
type: "contract",
|
||||||
|
contract,
|
||||||
|
label,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contractNodes = (draft: JsonRecord): Array<Record<string, unknown>> => {
|
||||||
|
const start = stringValue(draft.start);
|
||||||
|
const outputBindings = inputBindingRows(draft.output)
|
||||||
|
.filter((row) => row.kind === "canonical").length;
|
||||||
|
const outcomes = stringList(draft.outcomes);
|
||||||
|
const stateMetadata = stateMetadataCounts(draft.state_schema);
|
||||||
|
return [
|
||||||
|
contractNode(
|
||||||
|
"input",
|
||||||
|
"Input",
|
||||||
|
[countLabel(fieldCount(draft.input_schema), "field"), start ? `entry ${start}` : null]
|
||||||
|
.filter((value): value is string => value !== null)
|
||||||
|
.join(" · "),
|
||||||
|
),
|
||||||
|
contractNode(
|
||||||
|
"state",
|
||||||
|
"State",
|
||||||
|
[
|
||||||
|
countLabel(fieldCount(draft.state_schema), "field"),
|
||||||
|
stateMetadata.reducers > 0 ? countLabel(stateMetadata.reducers, "reducer") : null,
|
||||||
|
stateMetadata.defaults > 0 ? countLabel(stateMetadata.defaults, "default") : null,
|
||||||
|
]
|
||||||
|
.filter((value): value is string => value !== null)
|
||||||
|
.join(" · "),
|
||||||
|
),
|
||||||
|
contractNode(
|
||||||
|
"output",
|
||||||
|
"Output",
|
||||||
|
[
|
||||||
|
countLabel(fieldCount(draft.output_schema), "field"),
|
||||||
|
countLabel(outputBindings, "binding"),
|
||||||
|
].join(" · "),
|
||||||
|
),
|
||||||
|
contractNode("outcomes", "Outcomes", countLabel(outcomes.length, "outcome")),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputPathRoot = (path: InputPath): "input" | "state" | "context" | null => {
|
||||||
|
if (typeof path !== "string") return path.root;
|
||||||
|
const parts = parseTOMLPath(path);
|
||||||
|
const root = parts?.[0];
|
||||||
|
return root === "input" || root === "state" || root === "context" ? root : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expressionRoots = (expression: InputExpression, roots: Set<"input" | "state">): void => {
|
||||||
|
if (expression.kind === "path") {
|
||||||
|
const root = inputPathRoot(expression.path);
|
||||||
|
if (root === "input" || root === "state") roots.add(root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (expression.kind === "array") {
|
||||||
|
for (const item of expression.items) expressionRoots(item, roots);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (expression.kind === "object") {
|
||||||
|
for (const item of Object.values(expression.fields)) expressionRoots(item, roots);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindingRoots = (bindings: unknown): Set<"input" | "state"> => {
|
||||||
|
const roots = new Set<"input" | "state">();
|
||||||
|
for (const row of stepInputBindingRows(bindings)) {
|
||||||
|
if (row.kind !== "canonical") continue;
|
||||||
|
const binding: StepInputBinding = row.value;
|
||||||
|
if ("path" in binding) {
|
||||||
|
const root = inputPathRoot(binding.path);
|
||||||
|
if (root === "input" || root === "state") roots.add(root);
|
||||||
|
} else if ("expression" in binding) {
|
||||||
|
expressionRoots(binding.expression, roots);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContractConnector = {
|
||||||
|
readonly from: string;
|
||||||
|
readonly outcome: string;
|
||||||
|
readonly to: string;
|
||||||
|
readonly kind: "contract";
|
||||||
|
};
|
||||||
|
|
||||||
|
const contractConnectors = (
|
||||||
|
draft: JsonRecord,
|
||||||
|
executableNodes: ReadonlyArray<JsonRecord>,
|
||||||
|
): ContractConnector[] => {
|
||||||
|
const nodeIds = nodeIdsFor(executableNodes);
|
||||||
|
const labels = new Map<string, Set<string>>();
|
||||||
|
const add = (from: string, label: string, to: string): void => {
|
||||||
|
const key = `${from}\u0000${to}`;
|
||||||
|
const existing = labels.get(key) ?? new Set<string>();
|
||||||
|
existing.add(label);
|
||||||
|
labels.set(key, existing);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = stringValue(draft.start);
|
||||||
|
if (start !== null && nodeIds.has(start)) add("contract:input", "starts", start);
|
||||||
|
|
||||||
|
const steps = recordValue(draft.steps);
|
||||||
|
if (steps !== null) {
|
||||||
|
for (const [stepId, step] of sortedRecords(steps)) {
|
||||||
|
for (const root of bindingRoots(step.input)) add(`contract:${root}`, "reads", stepId);
|
||||||
|
if (outputBindingRows(step.output).some((row) => row.kind === "canonical")) {
|
||||||
|
add(stepId, "writes", "contract:state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const node of executableNodes) {
|
||||||
|
const stepId = stringValue(node.id);
|
||||||
|
if (stepId === null) continue;
|
||||||
|
for (const root of bindingRoots(node.input)) add(`contract:${root}`, "reads", stepId);
|
||||||
|
if (outputBindingRows(node.output).some((row) => row.kind === "canonical")) {
|
||||||
|
add(stepId, "writes", "contract:state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of inputBindingRows(draft.output)) {
|
||||||
|
if (row.kind !== "canonical" || !("path" in row.value)) continue;
|
||||||
|
const root = inputPathRoot(row.value.path);
|
||||||
|
if (root === "input" || root === "state") add(`contract:${root}`, "projects", "contract:output");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...labels.entries()]
|
||||||
|
.map(([key, values]) => {
|
||||||
|
const [from = "", to = ""] = key.split("\u0000");
|
||||||
|
return { from, to, outcome: [...values].toSorted().join(" · "), kind: "contract" as const };
|
||||||
|
})
|
||||||
|
.toSorted((left, right) => `${left.from}\u0000${left.to}`.localeCompare(`${right.from}\u0000${right.to}`));
|
||||||
|
};
|
||||||
|
|
||||||
/** Project the stored draft into the existing Dagre-backed graph model.
|
/** Project the stored draft into the existing Dagre-backed graph model.
|
||||||
*
|
*
|
||||||
* Draft workspaces store keyed authoring steps while lifecycle views receive a
|
* Draft workspaces store keyed authoring steps while lifecycle views receive a
|
||||||
@@ -206,7 +384,13 @@ export const projectAuthoringGraph = (draft: JsonRecord | null): WorkflowGraphMo
|
|||||||
const rightKey = `${String(right.from)}\u0000${String(right.outcome)}\u0000${String(right.to)}`;
|
const rightKey = `${String(right.from)}\u0000${String(right.outcome)}\u0000${String(right.to)}`;
|
||||||
return leftKey.localeCompare(rightKey);
|
return leftKey.localeCompare(rightKey);
|
||||||
});
|
});
|
||||||
return buildWorkflowGraph({ nodes: plan.nodes, edges });
|
const nodes = [...plan.nodes, ...contractNodes(draft)];
|
||||||
|
const contractEdges = contractConnectors(draft, plan.nodes);
|
||||||
|
const routeEdges = edges.map((edge) => ({ ...edge, kind: "route" }));
|
||||||
|
return buildWorkflowGraph(
|
||||||
|
{ nodes, edges: [...routeEdges, ...contractEdges] },
|
||||||
|
{ direction: "LR", nodeWidth: 208, nodeHeight: 68, nodesep: 54, ranksep: 96 },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deriveInsertionContext = (
|
export const deriveInsertionContext = (
|
||||||
|
|||||||
@@ -192,6 +192,30 @@ describe("useDraftAuthoring", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears connector insertion context when controlled selection moves to a contract", async () => {
|
||||||
|
const initial = workspace();
|
||||||
|
authoringClient.addCapabilityStep.mockResolvedValue(workspace({ revision: 4 }));
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ selection }) => useDraftAuthoring({ draft: initial, initialSelection: selection }),
|
||||||
|
{
|
||||||
|
initialProps: {
|
||||||
|
selection: { kind: "edge", stepId: "read", outcome: "ok" } as WorkbenchSelection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ selection: { kind: "contract", contract: "state" } });
|
||||||
|
expect(result.current.insertionContext).toBeNull();
|
||||||
|
await act(async () => result.current.addCapability(capabilityInput));
|
||||||
|
|
||||||
|
expect(authoringClient.addCapabilityStep).toHaveBeenCalledWith(
|
||||||
|
expect.not.objectContaining({ routeFromStep: expect.anything() }),
|
||||||
|
);
|
||||||
|
expect(authoringClient.addCapabilityStep).toHaveBeenCalledWith(
|
||||||
|
expect.not.objectContaining({ routeFromOutcome: expect.anything() }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates capabilities, replaces routes, and validates against the current revision", async () => {
|
it("updates capabilities, replaces routes, and validates against the current revision", async () => {
|
||||||
const initial = workspace({ revision: 7 });
|
const initial = workspace({ revision: 7 });
|
||||||
authoringClient.updateCapabilityStep.mockResolvedValue(workspace({ revision: 8 }));
|
authoringClient.updateCapabilityStep.mockResolvedValue(workspace({ revision: 8 }));
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ const sameSelection = (
|
|||||||
return left.qualifiedName === right.qualifiedName;
|
return left.qualifiedName === right.qualifiedName;
|
||||||
}
|
}
|
||||||
if (left.kind === "node" && right.kind === "node") return left.nodeId === right.nodeId;
|
if (left.kind === "node" && right.kind === "node") return left.nodeId === right.nodeId;
|
||||||
|
if (left.kind === "contract" && right.kind === "contract") {
|
||||||
|
return left.contract === right.contract;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
left.kind === "edge" &&
|
left.kind === "edge" &&
|
||||||
right.kind === "edge" &&
|
right.kind === "edge" &&
|
||||||
@@ -225,8 +228,12 @@ export const useDraftAuthoring = ({
|
|||||||
const adoptsSelectionInput = !sameSelection(state.selectionInput, initialSelection);
|
const adoptsSelectionInput = !sameSelection(state.selectionInput, initialSelection);
|
||||||
const selection = adoptsSelectionInput ? initialSelection : state.selection;
|
const selection = adoptsSelectionInput ? initialSelection : state.selection;
|
||||||
const insertionContext =
|
const insertionContext =
|
||||||
adoptsSelectionInput && initialSelection.kind === "edge"
|
adoptsSelectionInput
|
||||||
? deriveInsertionContext(initialSelection)
|
? initialSelection.kind === "edge"
|
||||||
|
? deriveInsertionContext(initialSelection)
|
||||||
|
: initialSelection.kind === "contract"
|
||||||
|
? null
|
||||||
|
: state.insertionContext
|
||||||
: state.insertionContext;
|
: state.insertionContext;
|
||||||
const resetGeneration =
|
const resetGeneration =
|
||||||
state.resetGeneration + (adoptsDraftInput || adoptsSelectionInput ? 1 : 0);
|
state.resetGeneration + (adoptsDraftInput || adoptsSelectionInput ? 1 : 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user