feat: mutate canonical drafts from workbench

This commit is contained in:
lda
2026-08-09 07:18:57 +07:00 Verified
parent 29cbcc66c5
commit 71f997f221
14 changed files with 1225 additions and 79 deletions
@@ -0,0 +1,52 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
afterEach(() => cleanup());
describe("CapabilityNodeForm", () => {
it("submits explicit node metadata and serialized schema bindings", async () => {
const user = userEvent.setup();
const submissions: unknown[] = [];
render(
<CapabilityNodeForm
capabilityName="demo.enrich"
inputSchema={{
type: "object",
properties: { title: { type: "string" } },
}}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
await user.type(screen.getByRole("textbox", { name: "Step id" }), "enrich");
await user.type(screen.getByRole("textbox", { name: "Description" }), "Enrich report");
await user.type(screen.getByRole("textbox", { name: "Title" }), "Quarterly report");
await user.click(screen.getByRole("button", { name: "Add node" }));
expect(submissions[0]).toMatchObject({
stepId: "enrich",
capabilityName: "demo.enrich",
description: "Enrich report",
inputBindings: [{ target: "title", value: { value: "Quarterly report" } }],
});
});
it("reports local edits as dirty and keeps them when submission fails", async () => {
const user = userEvent.setup();
const dirtyStates: boolean[] = [];
render(
<CapabilityNodeForm
capabilityName="demo.enrich"
onDirtyChange={(dirty) => dirtyStates.push(dirty)}
onSubmit={() => { throw new Error("not sent"); }}
/>,
);
const stepId = screen.getByRole("textbox", { name: "Step id" });
await user.type(stepId, "enrich");
expect(dirtyStates.at(-1)).toBe(true);
expect(stepId).toHaveValue("enrich");
});
});
@@ -0,0 +1,166 @@
import { useRef } from "react";
import type { InputBinding } from "../domain/draft-workspace-models.js";
import { SchemaForm } from "../schema-form/SchemaForm.js";
import type { FieldSources } from "../schema-form/schema-values.js";
import type { SchemaValueIssue } from "../schema-form/schema-values.js";
export type CapabilityNodeFormValue = {
readonly stepId: string;
readonly capabilityName: string;
readonly description: string | null;
readonly retry: number | null;
readonly timeoutSeconds: number | null;
readonly inputBindings: ReadonlyArray<InputBinding> | null;
readonly inputMap?: Record<string, string> | null;
readonly routes?: Record<string, string> | null;
readonly bindOutputs?: Record<string, string>;
};
export type CapabilityNodeFormProps = {
readonly capabilityName: string;
readonly inputSchema?: unknown;
readonly initialValue?: Partial<CapabilityNodeFormValue>;
readonly initialInputValue?: unknown;
readonly initialInputSources?: FieldSources;
readonly diagnostics?: ReadonlyArray<SchemaValueIssue>;
readonly onSubmit: (value: CapabilityNodeFormValue) => void | Promise<void>;
readonly onDirtyChange?: (dirty: boolean) => void;
readonly submitLabel?: string;
readonly hidden?: boolean;
};
const emptySchema = { type: "object", properties: {} };
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const literalBindingsFor = (value: unknown): InputBinding[] => {
if (!isRecord(value)) return [];
return Object.entries(value).flatMap(([target, literal]) =>
literal === undefined
? []
: [{ target, value: { value: literal } } satisfies InputBinding],
);
};
export const CapabilityNodeForm = ({
capabilityName,
inputSchema = emptySchema,
initialValue,
initialInputValue,
initialInputSources,
diagnostics,
onSubmit,
onDirtyChange,
submitLabel = "Add node",
hidden = false,
}: CapabilityNodeFormProps) => {
const stepIdRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLInputElement>(null);
const retryRef = useRef<HTMLInputElement>(null);
const timeoutSecondsRef = useRef<HTMLInputElement>(null);
const dirtyRef = useRef(false);
const markDirty = (): void => {
if (dirtyRef.current) return;
dirtyRef.current = true;
onDirtyChange?.(true);
};
return (
<div className="authoring-form" hidden={hidden}>
<SchemaForm
{...(diagnostics === undefined ? {} : { diagnostics })}
{...(initialInputSources === undefined ? {} : { initialSources: initialInputSources })}
{...(initialInputValue === undefined ? {} : { initialValue: initialInputValue })}
onDirtyChange={markDirty}
onSubmit={(result) => {
if (result.issues.length > 0) return;
const inputBindings: InputBinding[] = [
...result.bindings.map((binding) => ({
target: binding.target,
path: binding.path,
})),
...literalBindingsFor(result.value),
];
void onSubmit({
stepId: stepIdRef.current?.value ?? "",
capabilityName,
description: descriptionRef.current?.value.trim() || null,
retry:
retryRef.current?.value.trim() === ""
? null
: Number(retryRef.current?.value ?? ""),
timeoutSeconds:
timeoutSecondsRef.current?.value.trim() === ""
? null
: Number(timeoutSecondsRef.current?.value ?? ""),
inputBindings,
});
}}
renderBeforeFields={
<>
<label>
Step id
<input
aria-label="Step id"
defaultValue={initialValue?.stepId ?? ""}
ref={stepIdRef}
onChange={(event) => {
markDirty();
}}
/>
</label>
<label>
Description
<input
aria-label="Description"
defaultValue={initialValue?.description ?? ""}
ref={descriptionRef}
onChange={(event) => {
markDirty();
}}
/>
</label>
<label>
Retry
<input
aria-label="Retry"
defaultValue={
initialValue?.retry === null || initialValue?.retry === undefined
? ""
: String(initialValue.retry)
}
inputMode="numeric"
ref={retryRef}
onChange={(event) => {
markDirty();
}}
type="number"
/>
</label>
<label>
Timeout seconds
<input
aria-label="Timeout seconds"
defaultValue={
initialValue?.timeoutSeconds === null || initialValue?.timeoutSeconds === undefined
? ""
: String(initialValue.timeoutSeconds)
}
inputMode="numeric"
ref={timeoutSecondsRef}
onChange={(event) => {
markDirty();
}}
type="number"
/>
</label>
</>
}
submitLabel={submitLabel}
schema={inputSchema}
/>
</div>
);
};
@@ -4,11 +4,15 @@ import type { CapabilitySummary } from "../domain/capability-models.js";
import { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
import { withDiagnosticKeys } from "./diagnostic-key.js";
import { formatBoundedJson } from "./format-bounded-json.js";
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
import { RouteForm } from "./RouteForm.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
type ContextInspectorProps = {
readonly draft: DraftWorkspace;
readonly capabilities: ReadonlyArray<CapabilitySummary>;
readonly selection: WorkbenchSelection;
readonly controller: DraftAuthoringController;
};
const formatStatus = (status: DraftWorkspace["status"]): string =>
@@ -96,7 +100,7 @@ const DeferredActions = () => (
</section>
);
export const ContextInspector = ({ draft, capabilities, selection }: ContextInspectorProps) => {
export const ContextInspector = ({ draft, capabilities, selection, controller }: ContextInspectorProps) => {
const graph = projectAuthoringGraph(draft.draft);
let content: ReactNode;
@@ -105,35 +109,54 @@ export const ContextInspector = ({ draft, capabilities, selection }: ContextInsp
<div className="draft-detail__panels">
<DraftSummary draft={draft} />
<Diagnostics diagnostics={draft.diagnostics} />
<button onClick={() => void controller.validate()} type="button">
Validate draft
</button>
</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>
<>
<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 ?? "Configure this capability before adding it."}</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>
<CapabilityNodeForm
key={`capability:${selection.qualifiedName}:${controller.resetGeneration}`}
capabilityName={selection.qualifiedName}
onDirtyChange={controller.markDirty}
onSubmit={controller.addCapability}
/>
</>
);
} 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>
<>
<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>
<RouteForm
key={`edge:${selection.stepId}:${selection.outcome}:${controller.resetGeneration}`}
initialValue={{ stepId: selection.stepId, outcome: selection.outcome, target: edge?.target ?? "" }}
onSubmit={controller.setRoute}
onDirtyChange={controller.markDirty}
/>
</>
);
} else {
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
@@ -147,7 +170,15 @@ export const ContextInspector = ({ draft, capabilities, selection }: ContextInsp
<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>}
{!unsupported && (
<CapabilityNodeForm
key={`node:${selection.nodeId}:${controller.resetGeneration}`}
capabilityName={node?.data.nodeRef ?? selection.nodeId}
initialValue={{ stepId: selection.nodeId }}
onDirtyChange={controller.markDirty}
onSubmit={controller.updateCapability}
/>
)}
</section>
);
}
@@ -159,6 +190,15 @@ export const ContextInspector = ({ draft, capabilities, selection }: ContextInsp
<h2>Inspector</h2>
</div>
{content}
{controller.phase === "saving" && <p role="status">Saving canonical draft...</p>}
{controller.phase === "error" && <p role="alert">{controller.message ?? "Draft mutation failed."}</p>}
{controller.phase === "conflict" && (
<section aria-label="Revision conflict" className="authoring-inspector__conflict">
<p role="alert">{controller.message ?? "The draft changed on the server."}</p>
<button onClick={() => void controller.reload()} type="button">Reload server draft</button>
<button onClick={() => void controller.reapply()} type="button">Reapply local form</button>
</section>
)}
<DeferredActions />
<RawDraft draft={draft.draft} />
</aside>
@@ -1,16 +1,19 @@
import { useState } from "react";
import { useCallback, useEffect } from "react";
import { useBlocker } from "react-router-dom";
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";
import { useDraftAuthoring } from "./useDraftAuthoring.js";
type DraftWorkbenchProps = {
readonly draft: DraftWorkspace;
readonly capabilities?: ReadonlyArray<CapabilitySummary>;
readonly initialSelection?: WorkbenchSelection;
readonly onSelectionChange?: (selection: WorkbenchSelection) => void;
readonly enableNavigationProtection?: boolean;
};
const EMPTY_CAPABILITIES: ReadonlyArray<CapabilitySummary> = [];
@@ -20,22 +23,60 @@ export const DraftWorkbench = ({
capabilities = EMPTY_CAPABILITIES,
initialSelection = { kind: "canvas" },
onSelectionChange,
enableNavigationProtection = false,
}: DraftWorkbenchProps) => {
const [selection, setSelection] = useState<WorkbenchSelection>(initialSelection);
const select = (nextSelection: WorkbenchSelection): void => {
setSelection(nextSelection);
onSelectionChange?.(nextSelection);
};
const controller = useDraftAuthoring({ draft, initialSelection });
const select = useCallback(
(nextSelection: WorkbenchSelection): void => {
controller.select(nextSelection);
onSelectionChange?.(nextSelection);
},
[controller, onSelectionChange],
);
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>
<>
{enableNavigationProtection && <DirtyNavigationProtection dirty={controller.dirty} />}
<div className="draft-workbench" data-selection-kind={controller.selection.kind}>
<CapabilityPalette
capabilities={capabilities}
onSelectionChange={select}
selection={controller.selection}
/>
<AuthoringGraph
draft={controller.draft.draft}
onSelectionChange={select}
selection={controller.selection}
/>
<ContextInspector
capabilities={capabilities}
controller={controller}
draft={controller.draft}
selection={controller.selection}
/>
</div>
</>
);
};
const DirtyNavigationProtection = ({ dirty }: { readonly dirty: boolean }) => {
const blocker = useBlocker(dirty);
useEffect(() => {
if (!dirty) return;
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [dirty]);
if (blocker.state !== "blocked") return null;
return (
<aside aria-label="Unsaved changes" className="draft-workbench__navigation-warning" role="alert">
<p>Unsaved form changes will be lost.</p>
<button onClick={() => blocker.proceed()} type="button">Leave page</button>
<button onClick={() => blocker.reset()} type="button">Stay</button>
</aside>
);
};
@@ -0,0 +1,59 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { RouteForm } from "./RouteForm.js";
afterEach(() => cleanup());
describe("RouteForm", () => {
it("replaces a selected route through explicit source, outcome, and target fields", async () => {
const user = userEvent.setup();
const submissions: unknown[] = [];
render(
<RouteForm
initialValue={{ stepId: "read", outcome: "ok", target: "publish" }}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
await user.clear(screen.getByRole("textbox", { name: "Target step" }));
await user.type(screen.getByRole("textbox", { name: "Target step" }), "archive");
await user.click(screen.getByRole("button", { name: "Set route" }));
expect(submissions).toEqual([
{ stepId: "read", outcome: "ok", target: "archive" },
]);
});
it("does not discard dirty values when the form is hidden and shown again", async () => {
const user = userEvent.setup();
let hidden = false;
const { rerender } = render(
<RouteForm
hidden={hidden}
initialValue={{ stepId: "read", outcome: "ok", target: "publish" }}
onSubmit={() => undefined}
/>,
);
await user.clear(screen.getByRole("textbox", { name: "Target step" }));
await user.type(screen.getByRole("textbox", { name: "Target step" }), "archive");
hidden = true;
rerender(
<RouteForm
hidden={hidden}
initialValue={{ stepId: "read", outcome: "ok", target: "publish" }}
onSubmit={() => undefined}
/>,
);
hidden = false;
rerender(
<RouteForm
hidden={hidden}
initialValue={{ stepId: "read", outcome: "ok", target: "publish" }}
onSubmit={() => undefined}
/>,
);
expect(screen.getByRole("textbox", { name: "Target step" })).toHaveValue("archive");
});
});
@@ -0,0 +1,83 @@
import { useRef } from "react";
export type RouteFormValue = {
readonly stepId: string;
readonly outcome: string;
readonly target: string;
};
export type RouteFormProps = {
readonly initialValue?: Partial<RouteFormValue>;
readonly onSubmit: (value: RouteFormValue) => void | Promise<void>;
readonly onDirtyChange?: (dirty: boolean) => void;
readonly hidden?: boolean;
readonly submitLabel?: string;
};
export const RouteForm = ({
initialValue,
onSubmit,
onDirtyChange,
hidden = false,
submitLabel = "Set route",
}: RouteFormProps) => {
const stepIdRef = useRef<HTMLInputElement>(null);
const outcomeRef = useRef<HTMLInputElement>(null);
const targetRef = useRef<HTMLInputElement>(null);
const dirtyRef = useRef(false);
const markDirty = (): void => {
if (dirtyRef.current) return;
dirtyRef.current = true;
onDirtyChange?.(true);
};
return (
<form
className="authoring-form"
hidden={hidden}
onSubmit={(event) => {
event.preventDefault();
void onSubmit({
stepId: stepIdRef.current?.value ?? "",
outcome: outcomeRef.current?.value ?? "",
target: targetRef.current?.value ?? "",
});
}}
>
<label>
Source step
<input
aria-label="Source step"
defaultValue={initialValue?.stepId ?? ""}
ref={stepIdRef}
onChange={(event) => {
markDirty();
}}
/>
</label>
<label>
Outcome
<input
aria-label="Outcome"
defaultValue={initialValue?.outcome ?? ""}
ref={outcomeRef}
onChange={(event) => {
markDirty();
}}
/>
</label>
<label>
Target step
<input
aria-label="Target step"
defaultValue={initialValue?.target ?? ""}
ref={targetRef}
onChange={(event) => {
markDirty();
}}
/>
</label>
<button type="submit">{submitLabel}</button>
</form>
);
};
@@ -0,0 +1,277 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useConsoleWorkspace } from "../context.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { DraftAuthoringClient } from "../domain/draft-authoring-client.js";
import type { DraftWorkspaceClient } from "../domain/draft-workspace-client.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
import type { ConsoleWriteExecutor } from "../domain/write-executor.js";
import type { OperationName } from "../../connection/contracts.js";
import type { WorkbenchSelection } from "./authoring-graph.js";
import { createDraftAuthoringClient } from "../domain/draft-authoring-client.js";
import { createDraftWorkspaceClient } from "../domain/draft-workspace-client.js";
import { useDraftAuthoring } from "./useDraftAuthoring.js";
vi.mock("../context.js", () => ({ useConsoleWorkspace: vi.fn() }));
vi.mock("../domain/draft-authoring-client.js", () => ({
createDraftAuthoringClient: vi.fn(),
}));
vi.mock("../domain/draft-workspace-client.js", () => ({
createDraftWorkspaceClient: vi.fn(),
}));
const mockedUseConsoleWorkspace = vi.mocked(useConsoleWorkspace);
const mockedCreateAuthoringClient = vi.mocked(createDraftAuthoringClient);
const mockedCreateWorkspaceClient = vi.mocked(createDraftWorkspaceClient);
const workspace = (overrides: Partial<DraftWorkspace> = {}): DraftWorkspace => ({
workspaceId: "draft-report",
revision: 3,
title: "Report",
status: "invalid",
diagnostics: [],
summary: {
name: "report",
start: null,
stepCount: 0,
routeCount: 0,
steps: [],
},
draft: { steps: {}, routes: {} },
...overrides,
});
const capabilityInput = {
stepId: "enrich",
capabilityName: "demo.enrich",
description: "Enrich report",
retry: 1,
timeoutSeconds: 30,
inputBindings: [],
bindOutputs: {},
};
const createEmpty = vi.fn<DraftAuthoringClient["createEmpty"]>();
const createFromCapability = vi.fn<DraftAuthoringClient["createFromCapability"]>();
const addCapabilityStep = vi.fn<DraftAuthoringClient["addCapabilityStep"]>();
const updateCapabilityStep = vi.fn<DraftAuthoringClient["updateCapabilityStep"]>();
const setRoute = vi.fn<DraftAuthoringClient["setRoute"]>();
const validate = vi.fn<DraftAuthoringClient["validate"]>();
const list = vi.fn<DraftWorkspaceClient["list"]>();
const load = vi.fn<DraftWorkspaceClient["load"]>();
const authoringClient = {
createEmpty,
createFromCapability,
addCapabilityStep,
updateCapabilityStep,
setRoute,
validate,
} satisfies DraftAuthoringClient;
const workspaceClient = { list, load } satisfies DraftWorkspaceClient;
let contextValue: {
connectedTarget: string | null;
writeExecutor: ConsoleWriteExecutor | null;
readExecutor: ConsoleReadExecutor | null;
};
const testRun = async function <T>(
_operation: OperationName,
_params: unknown,
_decode: (value: unknown) => T,
): Promise<T> {
throw new Error("test executor is not called");
};
const testExecutor: ConsoleReadExecutor & ConsoleWriteExecutor = { run: testRun };
beforeEach(() => {
vi.clearAllMocks();
contextValue = {
connectedTarget: "server-a",
writeExecutor: testExecutor,
readExecutor: testExecutor,
};
mockedUseConsoleWorkspace.mockImplementation(() => ({
...contextValue,
connection: {
phase: "connected",
draftTarget: "server-a",
connectedTarget: "server-a",
serverStatus: "ok",
storeRoot: "/tmp",
durationMs: 1,
message: null,
evidence: [],
},
recordEvidence: vi.fn(),
}));
mockedCreateAuthoringClient.mockReturnValue(authoringClient);
mockedCreateWorkspaceClient.mockReturnValue(workspaceClient);
});
describe("useDraftAuthoring", () => {
it("adds an unconnected capability without inventing route information", async () => {
const initial = workspace();
const canonical = workspace({
revision: 4,
draft: { steps: { enrich: { use: "demo.enrich" } }, routes: {} },
summary: { ...initial.summary, stepCount: 1, steps: ["enrich"] },
});
authoringClient.addCapabilityStep.mockResolvedValue(canonical);
const { result } = renderHook(() => useDraftAuthoring({ draft: initial }));
await act(async () => result.current.addCapability(capabilityInput));
expect(authoringClient.addCapabilityStep).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 3,
stepId: "enrich",
capabilityName: "demo.enrich",
description: "Enrich report",
retry: 1,
timeoutSeconds: 30,
inputBindings: [],
bindOutputs: {},
});
expect(result.current.draft).toBe(canonical);
expect(result.current.selection).toEqual({ kind: "node", nodeId: "enrich" });
expect(result.current.dirty).toBe(false);
});
it("lowers selected node and connector insertion context separately", async () => {
const initial = workspace();
authoringClient.addCapabilityStep.mockResolvedValue(workspace({ revision: 4 }));
const { result, rerender } = renderHook(
({ selection }) => useDraftAuthoring({ draft: initial, initialSelection: selection }),
{ initialProps: { selection: { kind: "node", nodeId: "read" } as WorkbenchSelection } },
);
await act(async () => result.current.addCapability(capabilityInput));
expect(authoringClient.addCapabilityStep).toHaveBeenLastCalledWith(
expect.objectContaining({ routeFromStep: "read" }),
);
expect(authoringClient.addCapabilityStep.mock.calls.at(-1)?.[0]).not.toHaveProperty(
"routeFromOutcome",
);
rerender({ selection: { kind: "edge", stepId: "read", outcome: "ok" } });
await act(async () => result.current.addCapability({ ...capabilityInput, stepId: "publish" }));
expect(authoringClient.addCapabilityStep).toHaveBeenLastCalledWith(
expect.objectContaining({ routeFromStep: "read", routeFromOutcome: "ok" }),
);
});
it("updates capabilities, replaces routes, and validates against the current revision", async () => {
const initial = workspace({ revision: 7 });
authoringClient.updateCapabilityStep.mockResolvedValue(workspace({ revision: 8 }));
authoringClient.setRoute.mockResolvedValue(workspace({ revision: 9 }));
authoringClient.validate.mockResolvedValue(workspace({ revision: 9, status: "valid" }));
const { result } = renderHook(() =>
useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "read" },
}),
);
await act(async () => result.current.updateCapability({ ...capabilityInput, stepId: "read" }));
await act(async () =>
result.current.setRoute({ stepId: "read", outcome: "ok", target: "publish" }),
);
await act(async () => result.current.validate());
expect(authoringClient.updateCapabilityStep).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: "draft-report", revision: 7, stepId: "read" }),
);
expect(authoringClient.setRoute).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 8,
stepId: "read",
outcome: "ok",
target: "publish",
});
expect(authoringClient.validate).toHaveBeenCalledWith("draft-report");
expect(result.current.draft.status).toBe("valid");
});
it("preserves dirty form ownership on ordinary failures and revision conflicts", async () => {
const initial = workspace();
authoringClient.addCapabilityStep.mockRejectedValueOnce(new Error("server unavailable"));
const { result } = renderHook(() => useDraftAuthoring({ draft: initial }));
await act(async () => result.current.addCapability(capabilityInput));
expect(result.current.phase).toBe("error");
expect(result.current.draft).toBe(initial);
expect(result.current.dirty).toBe(true);
const conflict = workspace({
revision: 4,
status: "conflict",
diagnostics: [
{
code: "revision_conflict",
path: "revision",
message: "Draft changed on the server.",
stepId: null,
repairHint: null,
details: {},
},
],
});
authoringClient.addCapabilityStep.mockResolvedValue(conflict);
await act(async () => result.current.addCapability(capabilityInput));
expect(result.current.phase).toBe("conflict");
expect(result.current.draft).toBe(conflict);
expect(result.current.dirty).toBe(true);
});
it("reloads explicitly and coalesces duplicate submissions", async () => {
const initial = workspace();
const reloaded = workspace({ revision: 10, status: "valid" });
let resolveAdd: ((value: DraftWorkspace) => void) | undefined;
authoringClient.addCapabilityStep.mockReturnValue(
new Promise<DraftWorkspace>((resolve) => {
resolveAdd = resolve;
}),
);
workspaceClient.load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({ draft: initial }));
let first: Promise<void> | undefined;
let second: Promise<void> | undefined;
act(() => {
first = result.current.addCapability(capabilityInput);
second = result.current.addCapability(capabilityInput);
});
expect(first).toBe(second);
expect(authoringClient.addCapabilityStep).toHaveBeenCalledTimes(1);
resolveAdd?.(workspace({ revision: 11 }));
await act(async () => first);
await act(async () => result.current.reload());
expect(workspaceClient.load).toHaveBeenCalledWith("draft-report");
expect(result.current.draft).toBe(reloaded);
expect(result.current.dirty).toBe(false);
});
it("rejects a mutation response after the connection target changes", async () => {
const initial = workspace();
let resolveAdd: ((value: DraftWorkspace) => void) | undefined;
authoringClient.addCapabilityStep.mockReturnValue(
new Promise<DraftWorkspace>((resolve) => {
resolveAdd = resolve;
}),
);
const { result, rerender } = renderHook(() => useDraftAuthoring({ draft: initial }));
act(() => {
void result.current.addCapability(capabilityInput);
});
contextValue = { ...contextValue, connectedTarget: "server-b" };
rerender();
resolveAdd?.(workspace({ revision: 12 }));
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
expect(result.current.draft).toBe(initial);
expect(result.current.dirty).toBe(true);
});
});
@@ -0,0 +1,382 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConsoleWorkspace } from "../context.js";
import {
createDraftAuthoringClient,
type DraftAuthoringClient,
} from "../domain/draft-authoring-client.js";
import {
createDraftWorkspaceClient,
type DraftWorkspaceClient,
} from "../domain/draft-workspace-client.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { CapabilityNodeFormValue } from "./CapabilityNodeForm.js";
import type { RouteFormValue } from "./RouteForm.js";
import { deriveInsertionContext, type WorkbenchSelection } from "./authoring-graph.js";
export type DraftAuthoringPhase = "idle" | "saving" | "conflict" | "error";
export interface DraftAuthoringController {
readonly draft: DraftWorkspace;
readonly selection: WorkbenchSelection;
readonly dirty: boolean;
readonly phase: DraftAuthoringPhase;
readonly message: string | null;
readonly resetGeneration: number;
readonly addCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly setRoute: (input: RouteFormValue) => Promise<void>;
readonly validate: () => Promise<void>;
readonly reload: () => Promise<void>;
readonly reapply: () => Promise<void>;
readonly select: (selection: WorkbenchSelection) => void;
readonly markDirty: () => void;
}
export type UseDraftAuthoringOptions = {
readonly draft: DraftWorkspace;
readonly initialSelection?: WorkbenchSelection;
};
type AuthoringState = {
readonly draft: DraftWorkspace;
readonly draftInput: DraftWorkspace;
readonly selection: WorkbenchSelection;
readonly selectionInput: WorkbenchSelection;
readonly dirty: boolean;
readonly phase: DraftAuthoringPhase;
readonly message: string | null;
readonly resetGeneration: number;
};
type Provenance = {
readonly workspaceId: string;
readonly connectedTarget: string | null;
readonly writeExecutor: object | null;
readonly readExecutor: object | null;
};
type PendingMutation = {
readonly key: string;
readonly promise: Promise<void>;
};
type LastSubmission =
| { readonly kind: "add"; readonly input: CapabilityNodeFormValue }
| { readonly kind: "update"; readonly input: CapabilityNodeFormValue }
| { readonly kind: "route"; readonly input: RouteFormValue }
| null;
const canvasSelection: WorkbenchSelection = { kind: "canvas" };
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const sameProvenance = (left: Provenance, right: Provenance): boolean =>
left.workspaceId === right.workspaceId &&
left.connectedTarget === right.connectedTarget &&
left.writeExecutor === right.writeExecutor &&
left.readExecutor === right.readExecutor;
const sameSelection = (
left: WorkbenchSelection,
right: WorkbenchSelection,
): boolean => {
if (left.kind !== right.kind) return false;
if (left.kind === "canvas" || right.kind === "canvas") return true;
if (left.kind === "capability" && right.kind === "capability") {
return left.qualifiedName === right.qualifiedName;
}
if (left.kind === "node" && right.kind === "node") return left.nodeId === right.nodeId;
return (
left.kind === "edge" &&
right.kind === "edge" &&
left.stepId === right.stepId &&
left.outcome === right.outcome
);
};
const mutationKey = (kind: string, input: unknown, revision: number): string => {
const encoded = JSON.stringify(input);
return `${kind}:${revision}:${encoded ?? "undefined"}`;
};
export const useDraftAuthoring = ({
draft: initialDraft,
initialSelection = canvasSelection,
}: UseDraftAuthoringOptions): DraftAuthoringController => {
const { connectedTarget, readExecutor, writeExecutor } = useConsoleWorkspace();
const authoringClient = useMemo<DraftAuthoringClient | null>(
() => (writeExecutor ? createDraftAuthoringClient(writeExecutor) : null),
[writeExecutor],
);
const workspaceClient = useMemo<DraftWorkspaceClient | null>(
() => (readExecutor ? createDraftWorkspaceClient(readExecutor) : null),
[readExecutor],
);
const [state, setState] = useState<AuthoringState>(() => ({
draft: initialDraft,
draftInput: initialDraft,
selection: initialSelection,
selectionInput: initialSelection,
dirty: false,
phase: "idle",
message: null,
resetGeneration: 0,
}));
const pendingRef = useRef<PendingMutation | null>(null);
const lastSubmissionRef = useRef<LastSubmission>(null);
const adoptsDraftInput =
state.draftInput !== initialDraft &&
(initialDraft.workspaceId !== state.draft.workspaceId || !state.dirty);
const draft = adoptsDraftInput ? initialDraft : state.draft;
const adoptsSelectionInput = !sameSelection(state.selectionInput, initialSelection);
const selection = adoptsSelectionInput ? initialSelection : state.selection;
const resetGeneration =
state.resetGeneration + (adoptsDraftInput || adoptsSelectionInput ? 1 : 0);
const currentProvenance: Provenance = useMemo(() => ({
workspaceId: draft.workspaceId,
connectedTarget,
writeExecutor,
readExecutor,
}), [connectedTarget, draft.workspaceId, readExecutor, writeExecutor]);
const currentDraftRef = useRef(draft);
const currentSelectionRef = useRef(selection);
const currentProvenanceRef = useRef(currentProvenance);
useEffect(() => {
currentDraftRef.current = draft;
currentSelectionRef.current = selection;
currentProvenanceRef.current = currentProvenance;
}, [currentProvenance, draft, selection]);
const select = useCallback((selection: WorkbenchSelection): void => {
setState((current) => ({
...current,
selection,
selectionInput: initialSelection,
}));
}, [initialSelection]);
const markDirty = useCallback((): void => {
setState((current) =>
current.dirty ? current : { ...current, dirty: true, phase: "idle", message: null },
);
}, []);
const commitResponse = useCallback(
(
response: DraftWorkspace,
requestProvenance: Provenance,
nextSelection?: WorkbenchSelection,
): void => {
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
setState((current) => ({
...current,
draft: response,
selection: nextSelection ?? current.selection,
dirty: response.status === "conflict" ? true : false,
phase: response.status === "conflict" ? "conflict" : "idle",
message:
response.status === "conflict"
? (response.diagnostics[0]?.message ?? "The draft changed on the server.")
: null,
resetGeneration:
response.status === "conflict" ? current.resetGeneration : current.resetGeneration + 1,
}));
},
[],
);
const runMutation = useCallback(
(
kind: string,
input: unknown,
operation: (client: DraftAuthoringClient, requestDraft: DraftWorkspace) => Promise<DraftWorkspace>,
nextSelection?: WorkbenchSelection,
): Promise<void> => {
const requestDraft = currentDraftRef.current;
const key = mutationKey(kind, input, requestDraft.revision);
const pending = pendingRef.current;
if (pending?.key === key) return pending.promise;
if (!authoringClient) {
return Promise.resolve().then(() => {
setState((current) => ({
...current,
dirty: true,
phase: "error",
message: "Connect to a workflow server before authoring a draft.",
}));
});
}
const requestProvenance = currentProvenanceRef.current;
setState((current) => ({
...current,
dirty: true,
phase: "saving",
message: null,
}));
const promise = operation(authoringClient, requestDraft)
.then((response) => commitResponse(response, requestProvenance, nextSelection))
.catch((error: unknown) => {
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
setState((current) => ({
...current,
dirty: true,
phase: "error",
message: errorMessage(error),
}));
})
.finally(() => {
if (pendingRef.current?.promise === promise) pendingRef.current = null;
});
pendingRef.current = { key, promise };
return promise;
},
[authoringClient, commitResponse],
);
const addCapability = useCallback(
(input: CapabilityNodeFormValue): Promise<void> => {
lastSubmissionRef.current = { kind: "add", input };
const insertion = deriveInsertionContext(currentSelectionRef.current);
return runMutation(
"add",
{ input, insertion },
(client, requestDraft) =>
client.addCapabilityStep({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: input.stepId,
capabilityName: input.capabilityName,
...(insertion?.routeFromStep
? { routeFromStep: insertion.routeFromStep }
: {}),
...(insertion?.routeFromOutcome
? { routeFromOutcome: insertion.routeFromOutcome }
: {}),
...(input.routes !== undefined ? { routes: input.routes } : {}),
...(input.inputMap !== undefined ? { inputMap: input.inputMap } : {}),
...(input.inputBindings !== undefined ? { inputBindings: input.inputBindings } : {}),
...(input.bindOutputs !== undefined ? { bindOutputs: input.bindOutputs } : {}),
description: input.description,
retry: input.retry,
timeoutSeconds: input.timeoutSeconds,
}),
{ kind: "node", nodeId: input.stepId },
);
},
[runMutation],
);
const updateCapability = useCallback(
(input: CapabilityNodeFormValue): Promise<void> => {
lastSubmissionRef.current = { kind: "update", input };
return runMutation(
"update",
input,
(client, requestDraft) =>
client.updateCapabilityStep({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: input.stepId,
update: {
description: input.description,
input: input.inputBindings,
retry: input.retry,
timeoutSeconds: input.timeoutSeconds,
},
}),
);
},
[runMutation],
);
const setRoute = useCallback(
(input: RouteFormValue): Promise<void> => {
lastSubmissionRef.current = { kind: "route", input };
return runMutation(
"route",
input,
(client, requestDraft) =>
client.setRoute({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: input.stepId,
outcome: input.outcome,
target: input.target,
}),
);
},
[runMutation],
);
const validate = useCallback((): Promise<void> => {
if (!authoringClient) {
setState((current) => ({
...current,
phase: "error",
message: "Connect to a workflow server before validating a draft.",
}));
return Promise.resolve();
}
const requestProvenance = currentProvenanceRef.current;
setState((current) => ({ ...current, phase: "saving", message: null }));
return authoringClient
.validate(currentDraftRef.current.workspaceId)
.then((response) => commitResponse(response, requestProvenance))
.catch((error: unknown) => {
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
setState((current) => ({ ...current, phase: "error", message: errorMessage(error) }));
});
}, [authoringClient, commitResponse]);
const reload = useCallback((): Promise<void> => {
if (!workspaceClient) return Promise.resolve();
const requestProvenance = currentProvenanceRef.current;
return workspaceClient
.load(currentDraftRef.current.workspaceId)
.then((response) => {
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
setState((current) => ({
...current,
draft: response,
draftInput: initialDraft,
dirty: false,
phase: "idle",
message: null,
resetGeneration: current.resetGeneration + 1,
}));
})
.catch((error: unknown) => {
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
setState((current) => ({ ...current, phase: "error", message: errorMessage(error) }));
});
}, [initialDraft, workspaceClient]);
const reapply = useCallback((): Promise<void> => {
const last = lastSubmissionRef.current;
if (last === null) return Promise.resolve();
if (last.kind === "add") return addCapability(last.input);
if (last.kind === "update") return updateCapability(last.input);
return setRoute(last.input);
}, [addCapability, setRoute, updateCapability]);
return {
draft,
selection,
dirty: state.dirty,
phase: state.phase,
message: state.message,
resetGeneration,
addCapability,
updateCapability,
setRoute,
validate,
reload,
reapply,
select,
markDirty,
};
};
+10 -2
View File
@@ -1,5 +1,5 @@
import { useOutletContext } from "react-router-dom";
import type { ConnectionState, EvidenceRecord } from "../app/state.js";
import { initialState, type ConnectionState, type EvidenceRecord } from "../app/state.js";
import type { ConsoleReadExecutor } from "./domain/read-executor.js";
import type { ConsoleWriteExecutor } from "./domain/write-executor.js";
@@ -11,5 +11,13 @@ export type ConsoleWorkspaceContextValue = {
readonly writeExecutor: ConsoleWriteExecutor | null;
};
const STANDALONE_CONTEXT: ConsoleWorkspaceContextValue = {
connection: initialState(),
connectedTarget: null,
recordEvidence: () => undefined,
readExecutor: null,
writeExecutor: null,
};
export const useConsoleWorkspace = (): ConsoleWorkspaceContextValue =>
useOutletContext<ConsoleWorkspaceContextValue>();
useOutletContext<ConsoleWorkspaceContextValue>() ?? STANDALONE_CONTEXT;
@@ -11,7 +11,13 @@ const titleFor = (workspace: DraftWorkspace): string =>
const formatStatus = (status: DraftWorkspace["status"]): string =>
status.charAt(0).toUpperCase() + status.slice(1);
export const DraftDetailRoute = () => {
export type DraftDetailRouteProps = {
readonly enableNavigationProtection?: boolean;
};
export const DraftDetailRoute = ({
enableNavigationProtection = false,
}: DraftDetailRouteProps) => {
const { workspaceId = null } = useParams<{ workspaceId: string }>();
const drafts = useDraftWorkspace(workspaceId);
const draft =
@@ -48,7 +54,10 @@ export const DraftDetailRoute = () => {
</p>
</header>
<DraftWorkbench draft={draft} />
<DraftWorkbench
draft={draft}
enableNavigationProtection={enableNavigationProtection}
/>
</>
)}
</div>
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from "react";
import { useState, type FormEvent, type ReactNode } from "react";
import { normalizeSchema, type FieldSource, type SchemaField } from "./schema-field.js";
import { SchemaFieldControl } from "./SchemaFieldControl.js";
import {
@@ -17,6 +17,8 @@ export type SchemaFormProps = {
readonly initialSources?: FieldSources;
readonly diagnostics?: ReadonlyArray<SchemaValueIssue>;
readonly onSubmit?: (result: SchemaSerializationResult) => void;
readonly onDirtyChange?: (dirty: boolean) => void;
readonly renderBeforeFields?: ReactNode;
readonly submitLabel?: string;
readonly sourceSuggestions?: ReadonlyArray<string>;
};
@@ -95,6 +97,8 @@ export const SchemaForm = ({
initialSources = EMPTY_SOURCES,
diagnostics = EMPTY_DIAGNOSTICS,
onSubmit,
onDirtyChange,
renderBeforeFields,
submitLabel = "Save form",
sourceSuggestions = EMPTY_SUGGESTIONS,
}: SchemaFormProps) => {
@@ -108,6 +112,7 @@ export const SchemaForm = ({
const handleValueChange = (changedField: SchemaField, nextValue: unknown): void => {
setValues((current: unknown) => setAtPath(current, changedField.path, nextValue));
onDirtyChange?.(true);
const currentSource = sources[sourceKey(changedField)];
if (currentSource?.mode === "literal") {
setSources((current) => ({
@@ -118,6 +123,7 @@ export const SchemaForm = ({
};
const handleSourceChange = (changedField: SchemaField, source: FieldSource): void => {
onDirtyChange?.(true);
if (source.mode === "literal") {
setValues((current: unknown) => setAtPath(current, changedField.path, source.value));
}
@@ -125,6 +131,7 @@ export const SchemaForm = ({
};
const handleArrayItemRemove = (arrayField: SchemaField, index: number): void => {
onDirtyChange?.(true);
setValues((current: unknown) => {
const arrayValue = readAtPath(current, arrayField.path);
if (!Array.isArray(arrayValue)) return current;
@@ -151,6 +158,7 @@ export const SchemaForm = ({
return (
<form className="schema-form" onSubmit={handleSubmit}>
{renderBeforeFields}
<SchemaFieldControl
diagnostics={allDiagnostics}
field={field}