feat: compose draft authoring workbench
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { AuthoringGraph } from "./AuthoringGraph.js";
|
||||
import type { WorkbenchSelection } from "./authoring-graph.js";
|
||||
|
||||
const workspace: DraftWorkspace = {
|
||||
workspaceId: "draft-review",
|
||||
revision: 2,
|
||||
title: "Review workflow",
|
||||
status: "invalid",
|
||||
diagnostics: [],
|
||||
summary: {
|
||||
name: "review-workflow",
|
||||
start: "collect",
|
||||
stepCount: 2,
|
||||
routeCount: 2,
|
||||
steps: ["collect", "review"],
|
||||
},
|
||||
draft: {
|
||||
name: "review-workflow",
|
||||
start: "collect",
|
||||
steps: {
|
||||
collect: { use: "demo.collect" },
|
||||
review: { interrupt: { kind: "approval" } },
|
||||
},
|
||||
routes: { collect: { ok: "review" }, review: { submitted: "__end__" } },
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("AuthoringGraph", () => {
|
||||
it("renders the projected graph and marks the selected node", () => {
|
||||
const selection: WorkbenchSelection = { kind: "node", nodeId: "review" };
|
||||
const { container } = render(
|
||||
<AuthoringGraph draft={workspace.draft} selection={selection} onSelectionChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("collect")).not.toHaveLength(0);
|
||||
expect(screen.getByText("approval")).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-node-id="review"]')).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects a connector by its source step and outcome", () => {
|
||||
const onSelectionChange = vi.fn<(selection: WorkbenchSelection) => void>();
|
||||
const { container } = render(
|
||||
<AuthoringGraph draft={workspace.draft} selection={{ kind: "canvas" }} onSelectionChange={onSelectionChange} />,
|
||||
);
|
||||
|
||||
const edgeButton = container.querySelector('[data-edge-id="e-collect-review-0"]');
|
||||
expect(edgeButton).not.toBeNull();
|
||||
fireEvent.click(edgeButton!);
|
||||
|
||||
expect(onSelectionChange).toHaveBeenCalledWith({
|
||||
kind: "edge",
|
||||
stepId: "collect",
|
||||
outcome: "ok",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo } from "react";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { WorkflowGraph } from "../../graph/WorkflowGraph.js";
|
||||
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
|
||||
|
||||
type AuthoringGraphProps = {
|
||||
readonly draft: DraftWorkspace["draft"];
|
||||
readonly selection: WorkbenchSelection;
|
||||
readonly onSelectionChange: (selection: WorkbenchSelection) => void;
|
||||
};
|
||||
|
||||
export const AuthoringGraph = ({
|
||||
draft,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
}: AuthoringGraphProps) => {
|
||||
const model = useMemo(() => projectAuthoringGraph(draft), [draft]);
|
||||
const activeEdgeId =
|
||||
selection.kind === "edge"
|
||||
? model.edges.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);
|
||||
if (edge) {
|
||||
onSelectionChange({
|
||||
kind: "edge",
|
||||
stepId: edge.source,
|
||||
outcome: edge.label,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="Workflow graph" className="authoring-graph">
|
||||
<div className="authoring-graph__heading">
|
||||
<div>
|
||||
<p className="workspace-route-pending__eyebrow">Authoring canvas</p>
|
||||
<h2>Workflow graph</h2>
|
||||
</div>
|
||||
<span className="authoring-graph__selection" aria-live="polite">
|
||||
{selection.kind === "canvas" ? "Canvas" : selection.kind}
|
||||
</span>
|
||||
</div>
|
||||
<WorkflowGraph
|
||||
activeEdgeId={activeEdgeId}
|
||||
activeNodeId={selection.kind === "node" ? selection.nodeId : null}
|
||||
model={model}
|
||||
onCanvasSelect={() => onSelectionChange({ kind: "canvas" })}
|
||||
onEdgeSelect={selectEdge}
|
||||
onNodeSelect={(nodeId) => onSelectionChange({ kind: "node", nodeId })}
|
||||
/>
|
||||
<div aria-label="Route outcomes" className="authoring-graph__routes">
|
||||
<h3>Route outcomes</h3>
|
||||
{model.edges.length > 0 ? (
|
||||
<ul>
|
||||
{model.edges.map((edge) => (
|
||||
<li key={edge.id}>
|
||||
<button
|
||||
aria-pressed={activeEdgeId === edge.id}
|
||||
className="authoring-graph__route"
|
||||
data-edge-id={edge.id}
|
||||
onClick={() => selectEdge(edge.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{edge.source}</span>
|
||||
<strong>{edge.label || "unnamed"}</strong>
|
||||
<span>{edge.target}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p>No routes in this draft.</p>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CapabilitySummary } from "../domain/capability-models.js";
|
||||
import type { WorkbenchSelection } from "./authoring-graph.js";
|
||||
|
||||
type CapabilityPaletteProps = {
|
||||
readonly capabilities?: ReadonlyArray<CapabilitySummary>;
|
||||
readonly selection: WorkbenchSelection;
|
||||
readonly onSelectionChange: (selection: WorkbenchSelection) => void;
|
||||
};
|
||||
|
||||
const EMPTY_CAPABILITIES: ReadonlyArray<CapabilitySummary> = [];
|
||||
|
||||
export const CapabilityPalette = ({
|
||||
capabilities = EMPTY_CAPABILITIES,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
}: CapabilityPaletteProps) => (
|
||||
<aside aria-label="Capability palette" className="capability-palette" role="region">
|
||||
<header className="capability-palette__header">
|
||||
<p className="workspace-route-pending__eyebrow">Available interfaces</p>
|
||||
<h2>Capabilities</h2>
|
||||
<p>Choose a capability to inspect its contract before adding it in the next authoring slice.</p>
|
||||
</header>
|
||||
{capabilities.length > 0 ? (
|
||||
<ul className="capability-palette__list">
|
||||
{capabilities.map((capability) => {
|
||||
const isSelected =
|
||||
selection.kind === "capability" && selection.qualifiedName === capability.name;
|
||||
return (
|
||||
<li key={capability.name}>
|
||||
<button
|
||||
aria-label={capability.name}
|
||||
aria-pressed={isSelected}
|
||||
className="capability-palette__item"
|
||||
data-selected={isSelected}
|
||||
onClick={() =>
|
||||
onSelectionChange({ kind: "capability", qualifiedName: capability.name })
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<strong>{capability.name}</strong>
|
||||
<span>{capability.kind === "node_spec" ? "Node spec" : "Wrapper artifact"}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="capability-palette__empty">No capability catalog is loaded for this draft yet.</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { CapabilitySummary } from "../domain/capability-models.js";
|
||||
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
|
||||
|
||||
const MAX_RAW_DRAFT_CHARS = 12_000;
|
||||
const TRUNCATION_MARKER = "... truncated ...";
|
||||
|
||||
type ContextInspectorProps = {
|
||||
readonly draft: DraftWorkspace;
|
||||
readonly capabilities: ReadonlyArray<CapabilitySummary>;
|
||||
readonly selection: WorkbenchSelection;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const formatStatus = (status: DraftWorkspace["status"]): string =>
|
||||
status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === "string") return value;
|
||||
const encoded = JSON.stringify(value);
|
||||
return encoded ?? String(value);
|
||||
};
|
||||
|
||||
export const formatBoundedJson = (
|
||||
value: unknown,
|
||||
maxChars = MAX_RAW_DRAFT_CHARS,
|
||||
): string => {
|
||||
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
|
||||
const contentLimit = Math.max(0, maxChars);
|
||||
let output = "";
|
||||
let truncated = false;
|
||||
const activeObjects = new WeakSet<object>();
|
||||
|
||||
const append = (chunk: string): void => {
|
||||
if (truncated) return;
|
||||
if (output.length + chunk.length > contentLimit) {
|
||||
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
output += chunk;
|
||||
};
|
||||
|
||||
const appendJsonString = (text: string): void => {
|
||||
append('"');
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
if (truncated) return;
|
||||
const code = text.charCodeAt(index);
|
||||
if (code === 0x22) append('\\"');
|
||||
else if (code === 0x5c) append("\\\\");
|
||||
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const nextCode = text.charCodeAt(index + 1);
|
||||
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
||||
append(text.slice(index, index + 2));
|
||||
index++;
|
||||
} else append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
} else append(text.charAt(index));
|
||||
}
|
||||
if (!truncated) append('"');
|
||||
};
|
||||
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
if (truncated) return;
|
||||
if (current === null || typeof current !== "object") {
|
||||
if (typeof current === "string") appendJsonString(current);
|
||||
else if (typeof current === "number") append(Number.isFinite(current) ? String(current) : "null");
|
||||
else if (typeof current === "boolean") append(current ? "true" : "false");
|
||||
else append("null");
|
||||
return;
|
||||
}
|
||||
if (activeObjects.has(current)) {
|
||||
append('"[Circular]"');
|
||||
return;
|
||||
}
|
||||
activeObjects.add(current);
|
||||
const indent = " ".repeat(depth);
|
||||
const childIndent = " ".repeat(depth + 1);
|
||||
if (Array.isArray(current)) {
|
||||
append("[");
|
||||
let first = true;
|
||||
for (const item of current) {
|
||||
if (truncated) break;
|
||||
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
|
||||
visit(item, depth + 1);
|
||||
first = false;
|
||||
}
|
||||
if (!truncated) append(first ? "]" : `\n${indent}]`);
|
||||
} else {
|
||||
if (!isRecord(current)) {
|
||||
activeObjects.delete(current);
|
||||
return;
|
||||
}
|
||||
const record = current;
|
||||
append("{");
|
||||
let first = true;
|
||||
for (const key in record) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, key) || truncated) continue;
|
||||
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
|
||||
appendJsonString(key);
|
||||
append(": ");
|
||||
visit(record[key], depth + 1);
|
||||
first = false;
|
||||
}
|
||||
if (!truncated) append(first ? "}" : `\n${indent}}`);
|
||||
}
|
||||
activeObjects.delete(current);
|
||||
};
|
||||
|
||||
visit(value, 0);
|
||||
if (!truncated) return output;
|
||||
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
|
||||
return `${output.slice(0, markerStart)}${truncationMarker}`;
|
||||
};
|
||||
|
||||
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Diagnostic = ({ diagnostic }: { readonly diagnostic: DraftDiagnostic }) => (
|
||||
<li className="draft-detail__diagnostic">
|
||||
<dl>
|
||||
<Fact label="Code" value={diagnostic.code} />
|
||||
<Fact label="Path" value={diagnostic.path} />
|
||||
<Fact label="Message" value={diagnostic.message} />
|
||||
<Fact label="Step id" value={diagnostic.stepId ?? "none"} />
|
||||
<Fact label="Repair hint" value={diagnostic.repairHint ?? "none"} />
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
|
||||
const DraftSummary = ({ draft }: { readonly draft: DraftWorkspace }) => (
|
||||
<section aria-labelledby="draft-detail-summary-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-summary-heading">Draft summary</h2>
|
||||
<dl className="draft-detail__facts">
|
||||
<Fact label="Status" value={formatStatus(draft.status)} />
|
||||
<Fact label="Revision" value={`Revision ${draft.revision}`} />
|
||||
<Fact label="Start step" value={formatValue(draft.summary.start)} />
|
||||
<Fact label="Step count" value={String(draft.summary.stepCount)} />
|
||||
<Fact label="Route count" value={String(draft.summary.routeCount)} />
|
||||
</dl>
|
||||
<h3>Step ids</h3>
|
||||
<ul className="draft-detail__steps">
|
||||
{draft.summary.steps.map((stepId) => <li key={stepId}>{stepId}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<DraftDiagnostic> }) => (
|
||||
<section aria-labelledby="draft-detail-diagnostics-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
|
||||
{diagnostics.length > 0 ? (
|
||||
<ol className="draft-detail__diagnostics">
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
|
||||
))}
|
||||
</ol>
|
||||
) : <p>No diagnostics reported.</p>}
|
||||
</section>
|
||||
);
|
||||
|
||||
const RawDraft = ({ draft }: { readonly draft: DraftWorkspace["draft"] }) => (
|
||||
<details className="draft-detail__raw">
|
||||
<summary>Raw draft document</summary>
|
||||
{draft ? (
|
||||
<pre aria-label="Raw draft JSON, horizontally scrollable" role="region" tabIndex={0}>
|
||||
{formatBoundedJson(draft)}
|
||||
</pre>
|
||||
) : <p>Full draft document was not returned</p>}
|
||||
</details>
|
||||
);
|
||||
|
||||
const DeferredActions = () => (
|
||||
<section className="authoring-inspector__deferred" aria-labelledby="deferred-actions-heading">
|
||||
<h3 id="deferred-actions-heading">Deferred actions</h3>
|
||||
<div className="authoring-inspector__deferred-actions">
|
||||
{[
|
||||
"Undo — Later",
|
||||
"Redo — Later",
|
||||
"Delete node — Later",
|
||||
"Delete route — Later",
|
||||
"Add other step — Later",
|
||||
"Create artifact — Later",
|
||||
].map((label) => <button disabled key={label} type="button">{label}</button>)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const ContextInspector = ({ draft, capabilities, selection }: ContextInspectorProps) => {
|
||||
const graph = projectAuthoringGraph(draft.draft);
|
||||
|
||||
let content: ReactNode;
|
||||
if (selection.kind === "canvas") {
|
||||
content = (
|
||||
<div className="draft-detail__panels">
|
||||
<DraftSummary draft={draft} />
|
||||
<Diagnostics diagnostics={draft.diagnostics} />
|
||||
</div>
|
||||
);
|
||||
} else if (selection.kind === "capability") {
|
||||
const capability = capabilities.find((item) => item.name === selection.qualifiedName);
|
||||
content = (
|
||||
<section className="authoring-inspector__selection" aria-labelledby="capability-selection-heading">
|
||||
<p className="workspace-route-pending__eyebrow">Selected capability</p>
|
||||
<h2 id="capability-selection-heading">{selection.qualifiedName}</h2>
|
||||
<p>{capability?.description ?? "Inspect this contract before configuring a new node."}</p>
|
||||
<dl className="authoring-inspector__facts">
|
||||
<Fact label="Kind" value={capability?.kind === "wrapper_artifact" ? "Wrapper artifact" : "Node spec"} />
|
||||
<Fact label="Outcomes" value={capability?.outcomes.join(", ") || "none"} />
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
} else if (selection.kind === "edge") {
|
||||
const edge = graph.edges.find(
|
||||
(candidate) => candidate.source === selection.stepId && candidate.label === selection.outcome,
|
||||
);
|
||||
content = (
|
||||
<section className="authoring-inspector__selection" aria-labelledby="route-selection-heading">
|
||||
<p className="workspace-route-pending__eyebrow">Selected connector</p>
|
||||
<h2 id="route-selection-heading">Route inspector</h2>
|
||||
<dl className="authoring-inspector__facts">
|
||||
<Fact label="Source step" value={selection.stepId} />
|
||||
<Fact label="Outcome" value={selection.outcome || "unnamed"} />
|
||||
<Fact label="Target" value={edge?.target ?? "unknown"} />
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
} else {
|
||||
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
|
||||
const unsupported = node?.data.kind === "unsupported";
|
||||
content = (
|
||||
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
|
||||
<p className="workspace-route-pending__eyebrow">Selected step</p>
|
||||
<h2 id="node-selection-heading">{selection.nodeId}</h2>
|
||||
<dl className="authoring-inspector__facts">
|
||||
<Fact label="Kind" value={node?.data.kind ?? "unknown"} />
|
||||
<Fact label="Reference" value={node?.data.nodeRef ?? "none"} />
|
||||
</dl>
|
||||
{unsupported && <p role="status">Read-only: unsupported step kind.</p>}
|
||||
{!unsupported && <p>Capability configuration will be available in the next authoring slice.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside aria-label="Context inspector" className="context-inspector" role="region">
|
||||
<div className="context-inspector__heading">
|
||||
<p className="workspace-route-pending__eyebrow">Selection context</p>
|
||||
<h2>Inspector</h2>
|
||||
</div>
|
||||
{content}
|
||||
<DeferredActions />
|
||||
<RawDraft draft={draft.draft} />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { DraftWorkbench } from "./DraftWorkbench.js";
|
||||
|
||||
const workspace: DraftWorkspace = {
|
||||
workspaceId: "draft-review",
|
||||
revision: 2,
|
||||
title: "Review workflow",
|
||||
status: "invalid",
|
||||
diagnostics: [
|
||||
{
|
||||
code: "missing_route",
|
||||
path: "routes.review",
|
||||
message: "Review needs a route.",
|
||||
stepId: "review",
|
||||
repairHint: "Add a submitted route.",
|
||||
details: {},
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
name: "review-workflow",
|
||||
start: "collect",
|
||||
stepCount: 2,
|
||||
routeCount: 1,
|
||||
steps: ["collect", "review"],
|
||||
},
|
||||
draft: {
|
||||
name: "review-workflow",
|
||||
start: "collect",
|
||||
steps: { collect: { use: "demo.collect" }, review: { interrupt: { kind: "approval" } } },
|
||||
routes: { collect: { ok: "review" } },
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("DraftWorkbench", () => {
|
||||
it("keeps palette, graph, and inspector visible in the desktop shell", () => {
|
||||
render(
|
||||
<DraftWorkbench
|
||||
draft={workspace}
|
||||
capabilities={[
|
||||
{
|
||||
kind: "node_spec",
|
||||
name: "demo.collect",
|
||||
sourceId: "demo",
|
||||
description: "Collect source material.",
|
||||
outcomes: ["ok"],
|
||||
inputFields: [],
|
||||
outputFields: [],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("region", { name: "Capability palette" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Workflow graph" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Context inspector" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "demo.collect" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Draft summary")).toBeInTheDocument();
|
||||
expect(screen.getByText("Review needs a route.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the raw draft collapsed and exposes all deferred actions without handlers", () => {
|
||||
render(<DraftWorkbench draft={workspace} />);
|
||||
|
||||
const raw = screen.getByText("Raw draft document").closest("details");
|
||||
expect(raw).not.toHaveAttribute("open");
|
||||
for (const label of [
|
||||
"Undo — Later",
|
||||
"Redo — Later",
|
||||
"Delete node — Later",
|
||||
"Delete route — Later",
|
||||
"Add other step — Later",
|
||||
"Create artifact — Later",
|
||||
]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import type { CapabilitySummary } from "../domain/capability-models.js";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { AuthoringGraph } from "./AuthoringGraph.js";
|
||||
import { CapabilityPalette } from "./CapabilityPalette.js";
|
||||
import { ContextInspector } from "./ContextInspector.js";
|
||||
import type { WorkbenchSelection } from "./authoring-graph.js";
|
||||
|
||||
type DraftWorkbenchProps = {
|
||||
readonly draft: DraftWorkspace;
|
||||
readonly capabilities?: ReadonlyArray<CapabilitySummary>;
|
||||
readonly initialSelection?: WorkbenchSelection;
|
||||
readonly onSelectionChange?: (selection: WorkbenchSelection) => void;
|
||||
};
|
||||
|
||||
export const DraftWorkbench = ({
|
||||
draft,
|
||||
capabilities = [],
|
||||
initialSelection = { kind: "canvas" },
|
||||
onSelectionChange,
|
||||
}: DraftWorkbenchProps) => {
|
||||
const [selection, setSelection] = useState<WorkbenchSelection>(initialSelection);
|
||||
const select = (nextSelection: WorkbenchSelection): void => {
|
||||
setSelection(nextSelection);
|
||||
onSelectionChange?.(nextSelection);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="draft-workbench" data-selection-kind={selection.kind}>
|
||||
<CapabilityPalette
|
||||
capabilities={capabilities}
|
||||
onSelectionChange={select}
|
||||
selection={selection}
|
||||
/>
|
||||
<AuthoringGraph draft={draft.draft} onSelectionChange={select} selection={selection} />
|
||||
<ContextInspector capabilities={capabilities} draft={draft} selection={selection} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveInsertionContext,
|
||||
projectAuthoringGraph,
|
||||
type WorkbenchSelection,
|
||||
} from "./authoring-graph.js";
|
||||
|
||||
const draft = {
|
||||
name: "review-workflow",
|
||||
start: "collect",
|
||||
steps: {
|
||||
collect: { use: "demo.collect", desc: "Collect the source material." },
|
||||
review: {
|
||||
interrupt: {
|
||||
kind: "approval",
|
||||
outcomes: ["approved", "needs_changes"],
|
||||
},
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
collect: { ok: "review" },
|
||||
review: { approved: "__end__", needs_changes: "collect" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("projectAuthoringGraph", () => {
|
||||
it("projects normal, interrupt, and terminal nodes with labelled routes", () => {
|
||||
const model = projectAuthoringGraph(draft);
|
||||
|
||||
expect(model.nodes.map((node) => [node.id, node.data.kind])).toEqual([
|
||||
["__end__", "end"],
|
||||
["collect", "use"],
|
||||
["review", "interrupt"],
|
||||
]);
|
||||
expect(model.edges.map((edge) => [edge.id, edge.source, edge.label, edge.target])).toEqual([
|
||||
["e-collect-review-0", "collect", "ok", "review"],
|
||||
["e-review-__end__-1", "review", "approved", "__end__"],
|
||||
["e-review-collect-2", "review", "needs_changes", "collect"],
|
||||
]);
|
||||
expect(model.nodes.find((node) => node.id === "collect")?.data.nodeRef).toBe(
|
||||
"demo.collect",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps projection ids and positions stable when step insertion order changes", () => {
|
||||
const reordered = {
|
||||
...draft,
|
||||
steps: { review: draft.steps.review, collect: draft.steps.collect },
|
||||
routes: { review: draft.routes.review, collect: draft.routes.collect },
|
||||
};
|
||||
|
||||
expect(projectAuthoringGraph(reordered)).toEqual(projectAuthoringGraph(draft));
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkbenchSelection", () => {
|
||||
it("derives explicit route insertion only from a selected connector", () => {
|
||||
const edgeSelection: WorkbenchSelection = {
|
||||
kind: "edge",
|
||||
stepId: "review",
|
||||
outcome: "approved",
|
||||
};
|
||||
|
||||
expect(deriveInsertionContext(edgeSelection)).toEqual({
|
||||
routeFromStep: "review",
|
||||
routeFromOutcome: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps node insertion connected to a step without inventing an outcome", () => {
|
||||
expect(deriveInsertionContext({ kind: "node", nodeId: "collect" })).toEqual({
|
||||
routeFromStep: "collect",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not derive insertion context from canvas or capability selection", () => {
|
||||
expect(deriveInsertionContext({ kind: "canvas" })).toBeNull();
|
||||
expect(
|
||||
deriveInsertionContext({ kind: "capability", qualifiedName: "demo.collect" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js";
|
||||
|
||||
type JsonRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
export type WorkbenchSelection =
|
||||
| { readonly kind: "canvas" }
|
||||
| { readonly kind: "capability"; readonly qualifiedName: string }
|
||||
| { readonly kind: "node"; readonly nodeId: string }
|
||||
| { readonly kind: "edge"; readonly stepId: string; readonly outcome: string };
|
||||
|
||||
export type InsertionContext = {
|
||||
readonly routeFromStep: string;
|
||||
readonly routeFromOutcome?: string;
|
||||
};
|
||||
|
||||
const EMPTY_GRAPH: WorkflowGraphModel = { nodes: [], edges: [] };
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const recordValue = (value: unknown): JsonRecord | null =>
|
||||
isRecord(value) ? value : null;
|
||||
|
||||
const stringValue = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.length > 0 ? value : null;
|
||||
|
||||
const stringList = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
|
||||
const stepKind = (step: JsonRecord): string => {
|
||||
for (const kind of [
|
||||
"use",
|
||||
"interrupt",
|
||||
"subgraph",
|
||||
"condition",
|
||||
"when",
|
||||
"choose",
|
||||
"match",
|
||||
"foreach",
|
||||
"join",
|
||||
"end",
|
||||
]) {
|
||||
if (kind in step) return kind;
|
||||
}
|
||||
return "unsupported";
|
||||
};
|
||||
|
||||
const nodeForStep = (id: string, step: JsonRecord): JsonRecord => {
|
||||
const kind = stepKind(step);
|
||||
const payload = recordValue(step[kind]);
|
||||
const graphType =
|
||||
kind === "use"
|
||||
? "node"
|
||||
: kind === "when" || kind === "choose" || kind === "match"
|
||||
? "condition"
|
||||
: kind;
|
||||
const node: Record<string, unknown> = {
|
||||
id,
|
||||
type: graphType,
|
||||
detail: stringValue(step.desc),
|
||||
};
|
||||
|
||||
if (kind === "use") node.node = stringValue(step.use) ?? id;
|
||||
if (kind === "interrupt") {
|
||||
node.kind = stringValue(payload?.kind) ?? "Interrupt";
|
||||
node.outcomes = stringList(payload?.outcomes);
|
||||
}
|
||||
if (kind === "end") {
|
||||
node.outcome = stringValue(payload?.outcome) ?? "ok";
|
||||
}
|
||||
if (kind === "subgraph") {
|
||||
const workflow = recordValue(payload?.workflow);
|
||||
node.workflow = stringValue(workflow?.name) ?? stringValue(workflow?.artifact_id);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const sortedRecords = (value: JsonRecord | null): Array<[string, JsonRecord]> =>
|
||||
value === null
|
||||
? []
|
||||
: Object.entries(value)
|
||||
.filter((entry): entry is [string, JsonRecord] => isRecord(entry[1]))
|
||||
.toSorted(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
const sortedEntries = (value: JsonRecord | null): Array<[string, unknown]> =>
|
||||
value === null
|
||||
? []
|
||||
: Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right));
|
||||
|
||||
const routesForSteps = (routes: JsonRecord | null): Array<Record<string, unknown>> => {
|
||||
if (routes === null) return [];
|
||||
const edges: Array<Record<string, unknown>> = [];
|
||||
for (const [from, outcomes] of sortedEntries(routes)) {
|
||||
for (const [outcome, target] of sortedEntries(recordValue(outcomes))) {
|
||||
const targetId = stringValue(target);
|
||||
if (targetId === null) continue;
|
||||
edges.push({ from, outcome, to: targetId });
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
};
|
||||
|
||||
const compiledPlan = (draft: JsonRecord): {
|
||||
readonly nodes: Array<JsonRecord>;
|
||||
readonly edges: Array<Record<string, unknown>>;
|
||||
} => {
|
||||
const rawNodes = Array.isArray(draft.nodes)
|
||||
? draft.nodes.filter(isRecord)
|
||||
: [];
|
||||
const rawEdges = Array.isArray(draft.edges)
|
||||
? draft.edges.filter(isRecord).map((edge) => ({ ...edge }))
|
||||
: routesForSteps(recordValue(draft.routes));
|
||||
const nodes = rawNodes.map((node) => ({ ...node }));
|
||||
const nodeIds = new Set(nodes.map((node) => stringValue(node.id)).filter((id): id is string => id !== null));
|
||||
|
||||
for (const edge of rawEdges) {
|
||||
const target = stringValue(edge.to);
|
||||
if (target === "__end__" && !nodeIds.has(target)) {
|
||||
nodes.push({ id: "__end__", type: "end", outcome: "ok" });
|
||||
nodeIds.add(target);
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges: rawEdges };
|
||||
};
|
||||
|
||||
const keyedPlan = (draft: JsonRecord): {
|
||||
readonly nodes: Array<JsonRecord>;
|
||||
readonly edges: Array<Record<string, unknown>>;
|
||||
} => {
|
||||
const steps = recordValue(draft.steps);
|
||||
if (steps === null) return { nodes: [], edges: [] };
|
||||
const nodes = sortedRecords(steps).map(([id, step]) => nodeForStep(id, step));
|
||||
const edges = routesForSteps(recordValue(draft.routes));
|
||||
const nodeIds = new Set(nodes.map((node) => stringValue(node.id)).filter((id): id is string => id !== null));
|
||||
if (edges.some((edge) => edge.to === "__end__") && !nodeIds.has("__end__")) {
|
||||
nodes.push({ id: "__end__", type: "end", outcome: "ok" });
|
||||
}
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
/** Project the stored draft into the existing Dagre-backed graph model.
|
||||
*
|
||||
* Draft workspaces store keyed authoring steps while lifecycle views receive a
|
||||
* compiled `nodes`/`edges` plan. Keeping both lowerings here lets the graph
|
||||
* boundary stay singular and keeps browser selection separate from draft data.
|
||||
*/
|
||||
export const projectAuthoringGraph = (draft: JsonRecord | null): WorkflowGraphModel => {
|
||||
if (draft === null) return EMPTY_GRAPH;
|
||||
const plan = Array.isArray(draft.nodes) || Array.isArray(draft.edges)
|
||||
? compiledPlan(draft)
|
||||
: keyedPlan(draft);
|
||||
const edges = plan.edges.toSorted((left, right) => {
|
||||
const leftKey = `${String(left.from)}\u0000${String(left.outcome)}\u0000${String(left.to)}`;
|
||||
const rightKey = `${String(right.from)}\u0000${String(right.outcome)}\u0000${String(right.to)}`;
|
||||
return leftKey.localeCompare(rightKey);
|
||||
});
|
||||
return buildWorkflowGraph({ nodes: plan.nodes, edges });
|
||||
};
|
||||
|
||||
export const deriveInsertionContext = (
|
||||
selection: WorkbenchSelection,
|
||||
): InsertionContext | null => {
|
||||
if (selection.kind === "edge") {
|
||||
return {
|
||||
routeFromStep: selection.stepId,
|
||||
routeFromOutcome: selection.outcome,
|
||||
};
|
||||
}
|
||||
if (selection.kind === "node") return { routeFromStep: selection.nodeId };
|
||||
return null;
|
||||
};
|
||||
@@ -97,13 +97,14 @@ describe("DraftDetailRoute", () => {
|
||||
expect(screen.getByText("Route summarize.ok to __end__.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the raw draft closed by default and exposes no mutation controls", () => {
|
||||
it("keeps the raw draft closed and marks deferred mutation controls unavailable", () => {
|
||||
const { container } = renderRoute();
|
||||
|
||||
const details = container.querySelector("details");
|
||||
expect(details).not.toBeNull();
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Undo — Later" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete node — Later" })).toBeDisabled();
|
||||
expect(screen.queryByRole("link", { name: /compile|artifact|save|edit|mutate/i })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,194 +1,17 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import type {
|
||||
DraftDiagnostic,
|
||||
DraftWorkspace,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
|
||||
export { formatBoundedJson } from "../authoring/ContextInspector.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
|
||||
const MAX_RAW_DRAFT_CHARS = 12_000;
|
||||
const TRUNCATION_MARKER = "... truncated ...";
|
||||
|
||||
const titleFor = (workspace: DraftWorkspace): string =>
|
||||
workspace.title?.trim() || workspace.workspaceId;
|
||||
|
||||
const formatStatus = (status: DraftWorkspace["status"]): string =>
|
||||
status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === "string") return value;
|
||||
const encoded = JSON.stringify(value);
|
||||
return encoded ?? String(value);
|
||||
};
|
||||
|
||||
// Traverse until the display budget is exhausted so a large remote object is
|
||||
// never fully materialized just to produce a clipped escape-hatch preview.
|
||||
export const formatBoundedJson = (value: unknown, maxChars = MAX_RAW_DRAFT_CHARS): string => {
|
||||
const truncationMarker = TRUNCATION_MARKER.slice(0, Math.max(0, maxChars));
|
||||
const contentLimit = Math.max(0, maxChars);
|
||||
let output = "";
|
||||
let truncated = false;
|
||||
const activeObjects = new WeakSet<object>();
|
||||
|
||||
const append = (chunk: string): void => {
|
||||
if (truncated) return;
|
||||
if (output.length + chunk.length > contentLimit) {
|
||||
output += chunk.slice(0, Math.max(0, contentLimit - output.length));
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
output += chunk;
|
||||
};
|
||||
|
||||
const appendJsonString = (value: string): void => {
|
||||
append('"');
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (truncated) return;
|
||||
const code = value.charCodeAt(index);
|
||||
if (code === 0x22) append('\\"');
|
||||
else if (code === 0x5c) append("\\\\");
|
||||
else if (code < 0x20) append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const nextCode = value.charCodeAt(index + 1);
|
||||
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
||||
append(value.slice(index, index + 2));
|
||||
index++;
|
||||
} else {
|
||||
append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
append(`\\u${code.toString(16).padStart(4, "0")}`);
|
||||
} else {
|
||||
append(value.charAt(index));
|
||||
}
|
||||
}
|
||||
if (!truncated) append('"');
|
||||
};
|
||||
|
||||
const visit = (current: unknown, depth: number): void => {
|
||||
if (truncated) return;
|
||||
if (current === null || typeof current !== "object") {
|
||||
if (typeof current === "string") {
|
||||
appendJsonString(current);
|
||||
} else if (typeof current === "number") {
|
||||
append(Number.isFinite(current) ? String(current) : "null");
|
||||
} else if (typeof current === "boolean") {
|
||||
append(current ? "true" : "false");
|
||||
} else {
|
||||
append("null");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeObjects.has(current)) {
|
||||
append('"[Circular]"');
|
||||
return;
|
||||
}
|
||||
activeObjects.add(current);
|
||||
const indent = " ".repeat(depth);
|
||||
const childIndent = " ".repeat(depth + 1);
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
append("[");
|
||||
let first = true;
|
||||
for (const item of current) {
|
||||
if (truncated) break;
|
||||
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
|
||||
visit(item, depth + 1);
|
||||
first = false;
|
||||
}
|
||||
if (!truncated) append(first ? "]" : `\n${indent}]`);
|
||||
} else {
|
||||
const record = current as Record<string, unknown>;
|
||||
append("{");
|
||||
let first = true;
|
||||
for (const key in record) {
|
||||
if (!Object.prototype.hasOwnProperty.call(current, key) || truncated) continue;
|
||||
append(first ? `\n${childIndent}` : `,\n${childIndent}`);
|
||||
appendJsonString(key);
|
||||
append(": ");
|
||||
visit(record[key], depth + 1);
|
||||
first = false;
|
||||
}
|
||||
if (!truncated) append(first ? "}" : `\n${indent}}`);
|
||||
}
|
||||
activeObjects.delete(current);
|
||||
};
|
||||
|
||||
visit(value, 0);
|
||||
if (!truncated) return output;
|
||||
const markerStart = Math.max(0, contentLimit - truncationMarker.length);
|
||||
return `${output.slice(0, markerStart)}${truncationMarker}`;
|
||||
};
|
||||
|
||||
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Diagnostic = ({ diagnostic }: { readonly diagnostic: DraftDiagnostic }) => (
|
||||
<li className="draft-detail__diagnostic">
|
||||
<dl>
|
||||
<Fact label="Code" value={diagnostic.code} />
|
||||
<Fact label="Path" value={diagnostic.path} />
|
||||
<Fact label="Message" value={diagnostic.message} />
|
||||
<Fact label="Step id" value={diagnostic.stepId ?? "none"} />
|
||||
<Fact label="Repair hint" value={diagnostic.repairHint ?? "none"} />
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
|
||||
const DraftFacts = ({ draft }: { readonly draft: DraftWorkspace }) => (
|
||||
<section aria-labelledby="draft-detail-summary-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-summary-heading">Draft summary</h2>
|
||||
<dl className="draft-detail__facts">
|
||||
<Fact label="Status" value={formatStatus(draft.status)} />
|
||||
<Fact label="Revision" value={`Revision ${draft.revision}`} />
|
||||
<Fact label="Start step" value={formatValue(draft.summary.start)} />
|
||||
<Fact label="Step count" value={String(draft.summary.stepCount)} />
|
||||
<Fact label="Route count" value={String(draft.summary.routeCount)} />
|
||||
</dl>
|
||||
|
||||
<h3>Step ids</h3>
|
||||
<ul className="draft-detail__steps">
|
||||
{draft.summary.steps.map((stepId) => <li key={stepId}>{stepId}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
|
||||
const Diagnostics = ({ diagnostics }: { readonly diagnostics: ReadonlyArray<DraftDiagnostic> }) => (
|
||||
<section aria-labelledby="draft-detail-diagnostics-heading" className="draft-detail__panel">
|
||||
<h2 id="draft-detail-diagnostics-heading">Diagnostics</h2>
|
||||
{diagnostics.length > 0 ? (
|
||||
<ol className="draft-detail__diagnostics">
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<Diagnostic key={`${diagnostic.code}-${diagnostic.path}-${index}`} diagnostic={diagnostic} />
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p>No diagnostics reported.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const RawDraft = ({ draft }: { readonly draft: Record<string, unknown> | null }) => (
|
||||
<details className="draft-detail__raw">
|
||||
<summary>Raw draft document</summary>
|
||||
{draft ? (
|
||||
<pre
|
||||
aria-label="Raw draft JSON, horizontally scrollable"
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
>
|
||||
{formatBoundedJson(draft)}
|
||||
</pre>
|
||||
) : (
|
||||
<p>Full draft document was not returned</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
|
||||
export const DraftDetailRoute = () => {
|
||||
const { workspaceId = null } = useParams<{ workspaceId: string }>();
|
||||
const drafts = useDraftWorkspace(workspaceId);
|
||||
@@ -215,7 +38,7 @@ export const DraftDetailRoute = () => {
|
||||
{draft && (
|
||||
<>
|
||||
<header className="draft-detail__header">
|
||||
<p className="workspace-route-pending__eyebrow">Read-only draft</p>
|
||||
<p className="workspace-route-pending__eyebrow">Draft authoring workbench</p>
|
||||
<h1>{titleFor(draft)}</h1>
|
||||
<p className="draft-detail__workspace-id">{draft.workspaceId}</p>
|
||||
<p className="draft-detail__status-line">
|
||||
@@ -226,11 +49,7 @@ export const DraftDetailRoute = () => {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="draft-detail__panels">
|
||||
<DraftFacts draft={draft} />
|
||||
<Diagnostics diagnostics={draft.diagnostics} />
|
||||
</div>
|
||||
<RawDraft draft={draft.draft} />
|
||||
<DraftWorkbench draft={draft} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user