feat: mutate canonical drafts from workbench
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user