fix: preserve canonical authoring state
This commit is contained in:
@@ -29,8 +29,8 @@ describe("CapabilityNodeForm", () => {
|
||||
stepId: "enrich",
|
||||
capabilityName: "demo.enrich",
|
||||
description: "Enrich report",
|
||||
inputBindings: [{ target: "title", value: { value: "Quarterly report" } }],
|
||||
});
|
||||
inputBindings: [{ target: "title", value: "Quarterly report" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports local edits as dirty and keeps them when submission fails", async () => {
|
||||
@@ -39,6 +39,7 @@ describe("CapabilityNodeForm", () => {
|
||||
render(
|
||||
<CapabilityNodeForm
|
||||
capabilityName="demo.enrich"
|
||||
inputSchema={{ type: "object", properties: {} }}
|
||||
onDirtyChange={(dirty) => dirtyStates.push(dirty)}
|
||||
onSubmit={() => { throw new Error("not sent"); }}
|
||||
/>,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useMemo, 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";
|
||||
import { normalizeSchema } from "../schema-form/schema-field.js";
|
||||
import {
|
||||
serializeSchemaValues,
|
||||
type FieldSources,
|
||||
type SchemaSerializationResult,
|
||||
type SchemaValueIssue,
|
||||
} from "../schema-form/schema-values.js";
|
||||
|
||||
export type CapabilityNodeFormValue = {
|
||||
readonly stepId: string;
|
||||
@@ -18,39 +23,29 @@ export type CapabilityNodeFormValue = {
|
||||
|
||||
export type CapabilityNodeFormProps = {
|
||||
readonly capabilityName: string;
|
||||
readonly inputSchema?: unknown;
|
||||
readonly inputSchema: unknown;
|
||||
readonly initialValue?: Partial<CapabilityNodeFormValue>;
|
||||
readonly initialInputValue?: unknown;
|
||||
readonly initialInputSources?: FieldSources;
|
||||
readonly diagnostics?: ReadonlyArray<SchemaValueIssue>;
|
||||
readonly metadataDiagnostics?: ReadonlyArray<SchemaValueIssue>;
|
||||
readonly onSubmit: (value: CapabilityNodeFormValue) => void | Promise<void>;
|
||||
readonly onValueChange?: (value: CapabilityNodeFormValue) => 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,
|
||||
inputSchema,
|
||||
initialValue,
|
||||
initialInputValue,
|
||||
initialInputSources,
|
||||
diagnostics,
|
||||
metadataDiagnostics = [],
|
||||
onSubmit,
|
||||
onValueChange,
|
||||
onDirtyChange,
|
||||
submitLabel = "Add node",
|
||||
hidden = false,
|
||||
@@ -60,6 +55,44 @@ export const CapabilityNodeForm = ({
|
||||
const retryRef = useRef<HTMLInputElement>(null);
|
||||
const timeoutSecondsRef = useRef<HTMLInputElement>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
const initialSchemaResult = useMemo(
|
||||
() => serializeSchemaValues(
|
||||
normalizeSchema(inputSchema),
|
||||
initialInputValue,
|
||||
initialInputSources,
|
||||
),
|
||||
[initialInputSources, initialInputValue, inputSchema],
|
||||
);
|
||||
const schemaResultRef = useRef<SchemaSerializationResult | null>(null);
|
||||
useEffect(() => {
|
||||
schemaResultRef.current = initialSchemaResult;
|
||||
}, [initialSchemaResult]);
|
||||
|
||||
const valueFor = (result: SchemaSerializationResult): CapabilityNodeFormValue => ({
|
||||
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: [
|
||||
...result.bindings,
|
||||
...result.literalBindings,
|
||||
],
|
||||
});
|
||||
|
||||
const notifyValueChange = (result: SchemaSerializationResult): void => {
|
||||
schemaResultRef.current = result;
|
||||
onValueChange?.(valueFor(result));
|
||||
};
|
||||
|
||||
const metadataMessage = (field: string): string | null =>
|
||||
metadataDiagnostics.find((diagnostic) => diagnostic.path.at(-1) === field)?.message ?? null;
|
||||
|
||||
const markDirty = (): void => {
|
||||
if (dirtyRef.current) return;
|
||||
@@ -67,6 +100,11 @@ export const CapabilityNodeForm = ({
|
||||
onDirtyChange?.(true);
|
||||
};
|
||||
|
||||
const notifyMetadataChange = (): void => {
|
||||
markDirty();
|
||||
onValueChange?.(valueFor(schemaResultRef.current ?? initialSchemaResult));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="authoring-form" hidden={hidden}>
|
||||
<SchemaForm
|
||||
@@ -74,29 +112,11 @@ export const CapabilityNodeForm = ({
|
||||
{...(initialInputSources === undefined ? {} : { initialSources: initialInputSources })}
|
||||
{...(initialInputValue === undefined ? {} : { initialValue: initialInputValue })}
|
||||
onDirtyChange={markDirty}
|
||||
onValueChange={notifyValueChange}
|
||||
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,
|
||||
});
|
||||
notifyValueChange(result);
|
||||
void Promise.resolve(onSubmit(valueFor(result))).catch(() => undefined);
|
||||
}}
|
||||
renderBeforeFields={
|
||||
<>
|
||||
@@ -107,9 +127,10 @@ export const CapabilityNodeForm = ({
|
||||
defaultValue={initialValue?.stepId ?? ""}
|
||||
ref={stepIdRef}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
/>
|
||||
{metadataMessage("stepId") && <p role="alert">{metadataMessage("stepId")}</p>}
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
@@ -118,9 +139,10 @@ export const CapabilityNodeForm = ({
|
||||
defaultValue={initialValue?.description ?? ""}
|
||||
ref={descriptionRef}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
/>
|
||||
{metadataMessage("desc") && <p role="alert">{metadataMessage("desc")}</p>}
|
||||
</label>
|
||||
<label>
|
||||
Retry
|
||||
@@ -134,10 +156,11 @@ export const CapabilityNodeForm = ({
|
||||
inputMode="numeric"
|
||||
ref={retryRef}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
{metadataMessage("retry") && <p role="alert">{metadataMessage("retry")}</p>}
|
||||
</label>
|
||||
<label>
|
||||
Timeout seconds
|
||||
@@ -151,10 +174,11 @@ export const CapabilityNodeForm = ({
|
||||
inputMode="numeric"
|
||||
ref={timeoutSecondsRef}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
{metadataMessage("timeout_seconds") && <p role="alert">{metadataMessage("timeout_seconds")}</p>}
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CapabilityDetail } from "../domain/capability-models.js";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
|
||||
import { ContextInspector } from "./ContextInspector.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const draft: DraftWorkspace = {
|
||||
workspaceId: "draft-report",
|
||||
revision: 3,
|
||||
title: "Report",
|
||||
status: "invalid",
|
||||
diagnostics: [],
|
||||
summary: {
|
||||
name: "report",
|
||||
start: "read",
|
||||
stepCount: 1,
|
||||
routeCount: 0,
|
||||
steps: ["read"],
|
||||
},
|
||||
draft: {
|
||||
steps: {
|
||||
read: {
|
||||
use: "demo.read",
|
||||
input: [
|
||||
{ target: "title", value: "Existing title" },
|
||||
{ target: "count", path: "input.count" },
|
||||
],
|
||||
desc: "Read the report",
|
||||
retry: 2,
|
||||
timeout_seconds: 45,
|
||||
},
|
||||
},
|
||||
routes: {},
|
||||
},
|
||||
};
|
||||
|
||||
const detail: CapabilityDetail = {
|
||||
kind: "node_spec",
|
||||
name: "demo.read",
|
||||
sourceId: "demo",
|
||||
description: "Read a report",
|
||||
isAsync: false,
|
||||
outcomes: ["ok"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
count: { type: "integer" },
|
||||
},
|
||||
},
|
||||
outputSchema: { type: "object", properties: {} },
|
||||
wrapperHints: {},
|
||||
acceptsContext: false,
|
||||
};
|
||||
|
||||
const controller = {
|
||||
draft,
|
||||
selection: { kind: "node", nodeId: "read" },
|
||||
dirty: false,
|
||||
phase: "idle",
|
||||
message: null,
|
||||
resetGeneration: 0,
|
||||
addCapability: vi.fn(),
|
||||
updateCapability: vi.fn(),
|
||||
setRoute: vi.fn(),
|
||||
validate: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
reapply: vi.fn(),
|
||||
select: vi.fn(),
|
||||
markDirty: vi.fn(),
|
||||
rememberCapabilityForm: vi.fn(),
|
||||
rememberRouteForm: vi.fn(),
|
||||
} satisfies DraftAuthoringController;
|
||||
|
||||
describe("ContextInspector", () => {
|
||||
it("binds the inspected capability schema and canonical node values", () => {
|
||||
render(
|
||||
<ContextInspector
|
||||
capabilities={[]}
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={controller}
|
||||
draft={draft}
|
||||
selection={{ kind: "node", nodeId: "read" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue("Read the report");
|
||||
expect(screen.getByRole("spinbutton", { name: "Retry" })).toHaveValue(2);
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveValue(45);
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue("Existing title");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for Count" })).toHaveValue("input.count");
|
||||
expect(screen.getAllByText("Bind")).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("maps server diagnostics to the selected node form", () => {
|
||||
const diagnosticDraft: DraftWorkspace = {
|
||||
...draft,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "invalid_input",
|
||||
path: "steps[read].input.title",
|
||||
message: "Title is not accepted.",
|
||||
stepId: "read",
|
||||
repairHint: null,
|
||||
details: {},
|
||||
},
|
||||
{
|
||||
code: "invalid_retry",
|
||||
path: "steps[read].retry",
|
||||
message: "Retry must be non-negative.",
|
||||
stepId: "read",
|
||||
repairHint: null,
|
||||
details: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<ContextInspector
|
||||
capabilities={[]}
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={controller}
|
||||
draft={diagnosticDraft}
|
||||
selection={{ kind: "node", nodeId: "read" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { CapabilitySummary } from "../domain/capability-models.js";
|
||||
import type { CapabilityDetail, CapabilitySummary } from "../domain/capability-models.js";
|
||||
import type { SchemaValueIssue } from "../schema-form/schema-values.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";
|
||||
import { canonicalCapabilityFormData } from "./canonical-capability-form.js";
|
||||
|
||||
type ContextInspectorProps = {
|
||||
readonly draft: DraftWorkspace;
|
||||
readonly capabilities: ReadonlyArray<CapabilitySummary>;
|
||||
readonly selection: WorkbenchSelection;
|
||||
readonly controller: DraftAuthoringController;
|
||||
readonly capabilityDetail: CapabilityDetail | null;
|
||||
readonly capabilityDetailPhase: "disconnected" | "loading" | "ready" | "error";
|
||||
readonly capabilityDetailMessage: string | null;
|
||||
};
|
||||
|
||||
const formatStatus = (status: DraftWorkspace["status"]): string =>
|
||||
@@ -24,6 +29,58 @@ const formatValue = (value: unknown): string => {
|
||||
return encoded ?? String(value);
|
||||
};
|
||||
|
||||
const diagnosticParts = (path: string): string[] => {
|
||||
const normalized = path.startsWith("/")
|
||||
? path.slice(1).replaceAll("~1", "/").replaceAll("~0", "~")
|
||||
: path.replace(/\[([^\]]+)\]/g, ".$1");
|
||||
return normalized.split(".").filter((part) => part.length > 0);
|
||||
};
|
||||
|
||||
const fieldDiagnostic = (
|
||||
diagnostic: DraftDiagnostic,
|
||||
stepId: string,
|
||||
): SchemaValueIssue | null => {
|
||||
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return null;
|
||||
const parts = diagnosticParts(diagnostic.path);
|
||||
const stepIndex = parts.findIndex((part) => part === stepId);
|
||||
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return null;
|
||||
const inputIndex = parts.indexOf("input");
|
||||
if (inputIndex < 0) return null;
|
||||
return { path: parts.slice(inputIndex + 1), message: diagnostic.message };
|
||||
};
|
||||
|
||||
const metadataDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
|
||||
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return [];
|
||||
const parts = diagnosticParts(diagnostic.path);
|
||||
const stepIndex = parts.findIndex((part) => part === stepId);
|
||||
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return [];
|
||||
if (parts.includes("input")) return [];
|
||||
const field = parts.at(-1);
|
||||
return field === "desc" || field === "retry" || field === "timeout_seconds"
|
||||
? [{ path: [field], message: diagnostic.message }]
|
||||
: [];
|
||||
});
|
||||
|
||||
const inputDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
|
||||
const issue = fieldDiagnostic(diagnostic, stepId);
|
||||
return issue === null ? [] : [issue];
|
||||
});
|
||||
|
||||
const routeDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
selection: Extract<WorkbenchSelection, { readonly kind: "edge" }>,
|
||||
): ReadonlyArray<DraftDiagnostic> => diagnostics.filter((diagnostic) => {
|
||||
if (diagnostic.stepId !== null && diagnostic.stepId !== selection.stepId) return false;
|
||||
const parts = diagnosticParts(diagnostic.path);
|
||||
return parts.includes("routes") || diagnostic.stepId === selection.stepId;
|
||||
});
|
||||
|
||||
const Fact = ({ label, value }: { readonly label: string; readonly value: string }) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
@@ -100,7 +157,15 @@ const DeferredActions = () => (
|
||||
</section>
|
||||
);
|
||||
|
||||
export const ContextInspector = ({ draft, capabilities, selection, controller }: ContextInspectorProps) => {
|
||||
export const ContextInspector = ({
|
||||
draft,
|
||||
capabilities,
|
||||
selection,
|
||||
controller,
|
||||
capabilityDetail,
|
||||
capabilityDetailPhase,
|
||||
capabilityDetailMessage,
|
||||
}: ContextInspectorProps) => {
|
||||
const graph = projectAuthoringGraph(draft.draft);
|
||||
|
||||
let content: ReactNode;
|
||||
@@ -127,12 +192,21 @@ export const ContextInspector = ({ draft, capabilities, selection, controller }:
|
||||
<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}
|
||||
/>
|
||||
{capabilityDetailPhase === "loading" && <p role="status">Loading capability schema...</p>}
|
||||
{capabilityDetailPhase === "error" && (
|
||||
<p role="alert">{capabilityDetailMessage ?? "Capability schema failed to load."}</p>
|
||||
)}
|
||||
{capabilityDetailPhase === "ready" && capabilityDetail !== null && (
|
||||
<CapabilityNodeForm
|
||||
key={`capability:${selection.qualifiedName}:${controller.resetGeneration}`}
|
||||
capabilityName={selection.qualifiedName}
|
||||
diagnostics={[]}
|
||||
inputSchema={capabilityDetail.inputSchema}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.addCapability}
|
||||
onValueChange={(value) => controller.rememberCapabilityForm("add", value)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (selection.kind === "edge") {
|
||||
@@ -154,7 +228,9 @@ export const ContextInspector = ({ draft, capabilities, selection, controller }:
|
||||
key={`edge:${selection.stepId}:${selection.outcome}:${controller.resetGeneration}`}
|
||||
initialValue={{ stepId: selection.stepId, outcome: selection.outcome, target: edge?.target ?? "" }}
|
||||
onSubmit={controller.setRoute}
|
||||
diagnostics={routeDiagnostics(draft.diagnostics, selection)}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onValueChange={controller.rememberRouteForm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -170,15 +246,32 @@ export const ContextInspector = ({ draft, capabilities, selection, controller }:
|
||||
<Fact label="Reference" value={node?.data.nodeRef ?? "none"} />
|
||||
</dl>
|
||||
{unsupported && <p role="status">Read-only: unsupported step kind.</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}
|
||||
/>
|
||||
{!unsupported && capabilityDetailPhase === "loading" && (
|
||||
<p role="status">Loading capability schema...</p>
|
||||
)}
|
||||
{!unsupported && capabilityDetailPhase === "error" && (
|
||||
<p role="alert">{capabilityDetailMessage ?? "Capability schema failed to load."}</p>
|
||||
)}
|
||||
{!unsupported && capabilityDetailPhase === "ready" && capabilityDetail !== null && (() => {
|
||||
const formData = canonicalCapabilityFormData(draft, selection.nodeId);
|
||||
if (formData === null) return <p role="status">Canonical node data is unavailable.</p>;
|
||||
return (
|
||||
<CapabilityNodeForm
|
||||
key={`node:${selection.nodeId}:${controller.resetGeneration}`}
|
||||
capabilityName={formData.capabilityName}
|
||||
diagnostics={inputDiagnostics(draft.diagnostics, selection.nodeId)}
|
||||
initialInputSources={formData.initialInputSources}
|
||||
initialInputValue={formData.initialInputValue}
|
||||
initialValue={formData.initialValue}
|
||||
inputSchema={capabilityDetail.inputSchema}
|
||||
metadataDiagnostics={metadataDiagnostics(draft.diagnostics, selection.nodeId)}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.updateCapability}
|
||||
onValueChange={(value) => controller.rememberCapabilityForm("update", value)}
|
||||
submitLabel="Apply changes"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +283,9 @@ export const ContextInspector = ({ draft, capabilities, selection, controller }:
|
||||
<h2>Inspector</h2>
|
||||
</div>
|
||||
{content}
|
||||
{selection.kind !== "canvas" && draft.diagnostics.length > 0 && (
|
||||
<Diagnostics diagnostics={draft.diagnostics} />
|
||||
)}
|
||||
{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" && (
|
||||
|
||||
@@ -5,7 +5,8 @@ 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 { projectAuthoringGraph, type WorkbenchSelection } from "./authoring-graph.js";
|
||||
import { useAuthoringCapabilityDetail } from "./useAuthoringCapabilityDetail.js";
|
||||
import { useDraftAuthoring } from "./useDraftAuthoring.js";
|
||||
|
||||
type DraftWorkbenchProps = {
|
||||
@@ -26,6 +27,15 @@ export const DraftWorkbench = ({
|
||||
enableNavigationProtection = false,
|
||||
}: DraftWorkbenchProps) => {
|
||||
const controller = useDraftAuthoring({ draft, initialSelection });
|
||||
const graph = projectAuthoringGraph(draft.draft);
|
||||
let capabilityName: string | null = null;
|
||||
if (controller.selection.kind === "capability") {
|
||||
capabilityName = controller.selection.qualifiedName;
|
||||
} else if (controller.selection.kind === "node") {
|
||||
const nodeId = controller.selection.nodeId;
|
||||
capabilityName = graph.nodes.find((node) => node.id === nodeId)?.data.nodeRef ?? null;
|
||||
}
|
||||
const capabilityDetail = useAuthoringCapabilityDetail(capabilityName);
|
||||
const select = useCallback(
|
||||
(nextSelection: WorkbenchSelection): void => {
|
||||
controller.select(nextSelection);
|
||||
@@ -50,6 +60,9 @@ export const DraftWorkbench = ({
|
||||
/>
|
||||
<ContextInspector
|
||||
capabilities={capabilities}
|
||||
capabilityDetail={capabilityDetail.detail}
|
||||
capabilityDetailMessage={capabilityDetail.message}
|
||||
capabilityDetailPhase={capabilityDetail.phase}
|
||||
controller={controller}
|
||||
draft={controller.draft}
|
||||
selection={controller.selection}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef } from "react";
|
||||
import type { DraftDiagnostic } from "../domain/draft-workspace-models.js";
|
||||
|
||||
export type RouteFormValue = {
|
||||
readonly stepId: string;
|
||||
@@ -9,6 +10,8 @@ export type RouteFormValue = {
|
||||
export type RouteFormProps = {
|
||||
readonly initialValue?: Partial<RouteFormValue>;
|
||||
readonly onSubmit: (value: RouteFormValue) => void | Promise<void>;
|
||||
readonly onValueChange?: (value: RouteFormValue) => void;
|
||||
readonly diagnostics?: ReadonlyArray<DraftDiagnostic>;
|
||||
readonly onDirtyChange?: (dirty: boolean) => void;
|
||||
readonly hidden?: boolean;
|
||||
readonly submitLabel?: string;
|
||||
@@ -17,6 +20,8 @@ export type RouteFormProps = {
|
||||
export const RouteForm = ({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
onValueChange,
|
||||
diagnostics = [],
|
||||
onDirtyChange,
|
||||
hidden = false,
|
||||
submitLabel = "Set route",
|
||||
@@ -30,6 +35,15 @@ export const RouteForm = ({
|
||||
dirtyRef.current = true;
|
||||
onDirtyChange?.(true);
|
||||
};
|
||||
const readValue = (): RouteFormValue => ({
|
||||
stepId: stepIdRef.current?.value ?? "",
|
||||
outcome: outcomeRef.current?.value ?? "",
|
||||
target: targetRef.current?.value ?? "",
|
||||
});
|
||||
const notifyValueChange = (): void => {
|
||||
markDirty();
|
||||
onValueChange?.(readValue());
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -37,11 +51,7 @@ export const RouteForm = ({
|
||||
hidden={hidden}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit({
|
||||
stepId: stepIdRef.current?.value ?? "",
|
||||
outcome: outcomeRef.current?.value ?? "",
|
||||
target: targetRef.current?.value ?? "",
|
||||
});
|
||||
void Promise.resolve(onSubmit(readValue())).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
@@ -51,7 +61,7 @@ export const RouteForm = ({
|
||||
defaultValue={initialValue?.stepId ?? ""}
|
||||
ref={stepIdRef}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
notifyValueChange();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -62,7 +72,7 @@ export const RouteForm = ({
|
||||
defaultValue={initialValue?.outcome ?? ""}
|
||||
ref={outcomeRef}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
notifyValueChange();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -73,10 +83,13 @@ export const RouteForm = ({
|
||||
defaultValue={initialValue?.target ?? ""}
|
||||
ref={targetRef}
|
||||
onChange={(event) => {
|
||||
markDirty();
|
||||
notifyValueChange();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<p key={`${diagnostic.code}:${diagnostic.path}`} role="alert">{diagnostic.message}</p>
|
||||
))}
|
||||
<button type="submit">{submitLabel}</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -67,10 +67,8 @@ describe("WorkbenchSelection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps node insertion connected to a step without inventing an outcome", () => {
|
||||
expect(deriveInsertionContext({ kind: "node", nodeId: "collect" })).toEqual({
|
||||
routeFromStep: "collect",
|
||||
});
|
||||
it("does not derive an incoming route from a node without an outcome", () => {
|
||||
expect(deriveInsertionContext({ kind: "node", nodeId: "collect" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not derive insertion context from canvas or capability selection", () => {
|
||||
|
||||
@@ -199,6 +199,5 @@ export const deriveInsertionContext = (
|
||||
routeFromOutcome: selection.outcome,
|
||||
};
|
||||
}
|
||||
if (selection.kind === "node") return { routeFromStep: selection.nodeId };
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
CapabilityNodeFormValue,
|
||||
} from "./CapabilityNodeForm.js";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import type { FieldSources } from "../schema-form/schema-values.js";
|
||||
import { formatTOMLPath, parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type CanonicalCapabilityFormData = {
|
||||
readonly capabilityName: string;
|
||||
readonly initialValue: Partial<CapabilityNodeFormValue>;
|
||||
readonly initialInputValue: unknown;
|
||||
readonly initialInputSources: FieldSources;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const pathParts = (value: unknown): ReadonlyArray<string> | null => {
|
||||
if (typeof value === "string") return parseTOMLPath(value);
|
||||
if (!isRecord(value) || !Array.isArray(value.parts)) return null;
|
||||
if (!value.parts.every((part): part is string => typeof part === "string")) return null;
|
||||
return value.parts;
|
||||
};
|
||||
|
||||
const localPath = (value: unknown): string | null => {
|
||||
if (typeof value === "string") return value;
|
||||
if (!isRecord(value) || value.root !== "local") return null;
|
||||
const parts = pathParts(value);
|
||||
return parts === null ? null : formatTOMLPath(parts);
|
||||
};
|
||||
|
||||
const sourcePath = (value: unknown): string | null => {
|
||||
if (typeof value === "string") return value;
|
||||
if (!isRecord(value) || !["input", "state", "context"].includes(String(value.root))) {
|
||||
return null;
|
||||
}
|
||||
const parts = pathParts(value);
|
||||
return parts === null ? null : formatTOMLPath([String(value.root), ...parts]);
|
||||
};
|
||||
|
||||
const setPath = (current: unknown, path: string, value: unknown): unknown => {
|
||||
const parts = path === "." ? [] : parseTOMLPath(path);
|
||||
if (parts === null) return current;
|
||||
if (parts.length === 0) return value;
|
||||
const [head, ...tail] = parts;
|
||||
if (head === undefined) return current;
|
||||
if (current === undefined && Number.isInteger(Number(head))) {
|
||||
return setPath([], path, value);
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(head);
|
||||
if (!Number.isInteger(index)) return current;
|
||||
const next = [...current];
|
||||
next[index] = setPath(next[index], formatTOMLPath(tail), value);
|
||||
return next;
|
||||
}
|
||||
const next = isRecord(current) ? { ...current } : {};
|
||||
next[head] = setPath(next[head], formatTOMLPath(tail), value);
|
||||
return next;
|
||||
};
|
||||
|
||||
const metadataValue = (step: JsonRecord, key: string): string | number | null | undefined => {
|
||||
const value = step[key];
|
||||
if (value === null || typeof value === "string" || typeof value === "number") return value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const canonicalCapabilityFormData = (
|
||||
draft: DraftWorkspace,
|
||||
stepId: string,
|
||||
): CanonicalCapabilityFormData | null => {
|
||||
const steps = isRecord(draft.draft) ? draft.draft.steps : undefined;
|
||||
if (!isRecord(steps) || !isRecord(steps[stepId])) return null;
|
||||
const step = steps[stepId];
|
||||
const capabilityName = step.use;
|
||||
if (typeof capabilityName !== "string" || capabilityName.trim() === "") return null;
|
||||
|
||||
const description = metadataValue(step, "desc");
|
||||
const retry = metadataValue(step, "retry");
|
||||
const timeoutSeconds = metadataValue(step, "timeout_seconds");
|
||||
const initialValue = {
|
||||
stepId,
|
||||
...(typeof description === "string" || description === null ? { description } : {}),
|
||||
...(typeof retry === "number" || retry === null ? { retry } : {}),
|
||||
...(typeof timeoutSeconds === "number" || timeoutSeconds === null
|
||||
? { timeoutSeconds }
|
||||
: {}),
|
||||
} satisfies Partial<CapabilityNodeFormValue>;
|
||||
const initialInputSources: Record<string, { readonly mode: "bind"; readonly sourcePath: string }> = {};
|
||||
let initialInputValue: unknown = undefined;
|
||||
const input = step.input;
|
||||
if (Array.isArray(input)) {
|
||||
for (const rawBinding of input) {
|
||||
if (!isRecord(rawBinding)) continue;
|
||||
const target = localPath(rawBinding.target);
|
||||
if (target === null) continue;
|
||||
if ("path" in rawBinding) {
|
||||
const path = sourcePath(rawBinding.path);
|
||||
if (path !== null) initialInputSources[target] = { mode: "bind", sourcePath: path };
|
||||
continue;
|
||||
}
|
||||
if ("value" in rawBinding) {
|
||||
initialInputValue = setPath(initialInputValue, target, rawBinding.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
capabilityName,
|
||||
initialValue,
|
||||
initialInputValue,
|
||||
initialInputSources,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useConsoleWorkspace } from "../context.js";
|
||||
import {
|
||||
createCapabilityClient,
|
||||
type CapabilityClient,
|
||||
} from "../domain/capability-client.js";
|
||||
import type { CapabilityDetail } from "../domain/capability-models.js";
|
||||
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
|
||||
|
||||
export type AuthoringCapabilityDetailState = {
|
||||
readonly phase: "disconnected" | "loading" | "ready" | "error";
|
||||
readonly detail: CapabilityDetail | null;
|
||||
readonly message: string | null;
|
||||
};
|
||||
|
||||
type Request = {
|
||||
readonly name: string;
|
||||
readonly target: string;
|
||||
readonly executor: ConsoleReadExecutor;
|
||||
};
|
||||
|
||||
type DetailState = {
|
||||
readonly request: Request | null;
|
||||
readonly detail: CapabilityDetail | null;
|
||||
readonly message: string | null;
|
||||
};
|
||||
|
||||
const sameRequest = (left: Request | null, right: Request | null): boolean =>
|
||||
left !== null &&
|
||||
right !== null &&
|
||||
left.name === right.name &&
|
||||
left.target === right.target &&
|
||||
left.executor === right.executor;
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export const useAuthoringCapabilityDetail = (
|
||||
name: string | null,
|
||||
): AuthoringCapabilityDetailState => {
|
||||
const { connectedTarget, readExecutor } = useConsoleWorkspace();
|
||||
const client = useMemo<CapabilityClient | null>(
|
||||
() => (readExecutor ? createCapabilityClient(readExecutor) : null),
|
||||
[readExecutor],
|
||||
);
|
||||
const request = useMemo<Request | null>(
|
||||
() => {
|
||||
if (!client || !connectedTarget || !name || !readExecutor) return null;
|
||||
return { name, target: connectedTarget, executor: readExecutor };
|
||||
},
|
||||
[client, connectedTarget, name, readExecutor],
|
||||
);
|
||||
const [state, setState] = useState<DetailState>({
|
||||
request: null,
|
||||
detail: null,
|
||||
message: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || request === null) return;
|
||||
let active = true;
|
||||
void client.inspect(request.name)
|
||||
.then((detail) => {
|
||||
if (!active) return;
|
||||
setState({ request, detail, message: null });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setState({ request, detail: null, message: errorMessage(error) });
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [client, request]);
|
||||
|
||||
if (request === null) {
|
||||
return { phase: "disconnected", detail: null, message: null };
|
||||
}
|
||||
if (!sameRequest(state.request, request)) {
|
||||
return { phase: "loading", detail: null, message: null };
|
||||
}
|
||||
if (state.message !== null) {
|
||||
return { phase: "error", detail: null, message: state.message };
|
||||
}
|
||||
return { phase: "ready", detail: state.detail, message: null };
|
||||
};
|
||||
@@ -147,8 +147,8 @@ describe("useDraftAuthoring", () => {
|
||||
);
|
||||
|
||||
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(
|
||||
"routeFromStep",
|
||||
);
|
||||
expect(authoringClient.addCapabilityStep.mock.calls.at(-1)?.[0]).not.toHaveProperty(
|
||||
"routeFromOutcome",
|
||||
@@ -196,7 +196,12 @@ describe("useDraftAuthoring", () => {
|
||||
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 }));
|
||||
const { result } = renderHook(() =>
|
||||
useDraftAuthoring({
|
||||
draft: initial,
|
||||
initialSelection: { kind: "capability", qualifiedName: "demo.enrich" },
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => result.current.addCapability(capabilityInput));
|
||||
expect(result.current.phase).toBe("error");
|
||||
@@ -222,6 +227,86 @@ describe("useDraftAuthoring", () => {
|
||||
expect(result.current.phase).toBe("conflict");
|
||||
expect(result.current.draft).toBe(conflict);
|
||||
expect(result.current.dirty).toBe(true);
|
||||
expect(result.current.selection).toEqual({
|
||||
kind: "capability",
|
||||
qualifiedName: "demo.enrich",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send different writes or validation concurrently for one revision", async () => {
|
||||
const initial = workspace({ revision: 6 });
|
||||
let resolveAdd: ((value: DraftWorkspace) => void) | undefined;
|
||||
authoringClient.addCapabilityStep.mockReturnValueOnce(
|
||||
new Promise<DraftWorkspace>((resolve) => { resolveAdd = resolve; }),
|
||||
);
|
||||
const { result } = renderHook(() => useDraftAuthoring({ draft: initial }));
|
||||
|
||||
let first: Promise<void> | undefined;
|
||||
act(() => { first = result.current.addCapability(capabilityInput); });
|
||||
const second = result.current.addCapability({ ...capabilityInput, stepId: "publish" });
|
||||
const validation = result.current.validate();
|
||||
|
||||
await expect(second).rejects.toThrow("Another draft authoring request is in progress.");
|
||||
await expect(validation).rejects.toThrow("Another draft authoring request is in progress.");
|
||||
expect(authoringClient.addCapabilityStep).toHaveBeenCalledTimes(1);
|
||||
expect(authoringClient.validate).not.toHaveBeenCalled();
|
||||
|
||||
resolveAdd?.(workspace({ revision: 7 }));
|
||||
await act(async () => first);
|
||||
});
|
||||
|
||||
it("coalesces duplicate validation requests for the loaded revision", async () => {
|
||||
const initial = workspace({ revision: 8 });
|
||||
let resolveValidation: ((value: DraftWorkspace) => void) | undefined;
|
||||
authoringClient.validate.mockReturnValueOnce(
|
||||
new Promise<DraftWorkspace>((resolve) => { resolveValidation = resolve; }),
|
||||
);
|
||||
const { result } = renderHook(() => useDraftAuthoring({ draft: initial }));
|
||||
|
||||
let first: Promise<void> | undefined;
|
||||
let second: Promise<void> | undefined;
|
||||
act(() => {
|
||||
first = result.current.validate();
|
||||
second = result.current.validate();
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(authoringClient.validate).toHaveBeenCalledTimes(1);
|
||||
resolveValidation?.(workspace({ revision: 8, status: "valid" }));
|
||||
await act(async () => first);
|
||||
});
|
||||
|
||||
it("rejects mutation, validation, and reload responses for another workspace", async () => {
|
||||
const initial = workspace();
|
||||
const wrongWorkspace = workspace({ workspaceId: "other-workspace", revision: 99 });
|
||||
authoringClient.addCapabilityStep.mockResolvedValueOnce(wrongWorkspace);
|
||||
authoringClient.validate.mockResolvedValueOnce(wrongWorkspace);
|
||||
workspaceClient.load.mockResolvedValueOnce(wrongWorkspace);
|
||||
const { result } = renderHook(() => useDraftAuthoring({ draft: initial }));
|
||||
|
||||
await act(async () => result.current.addCapability(capabilityInput));
|
||||
expect(result.current.draft).toBe(initial);
|
||||
await act(async () => result.current.validate());
|
||||
expect(result.current.draft).toBe(initial);
|
||||
await act(async () => result.current.reload());
|
||||
expect(result.current.draft).toBe(initial);
|
||||
});
|
||||
|
||||
it("reapplies the current preserved form value after a conflict", async () => {
|
||||
const initial = workspace();
|
||||
const conflict = workspace({ revision: 4, status: "conflict" });
|
||||
authoringClient.addCapabilityStep
|
||||
.mockResolvedValueOnce(conflict)
|
||||
.mockResolvedValueOnce(workspace({ revision: 5 }));
|
||||
const { result } = renderHook(() => useDraftAuthoring({ draft: initial }));
|
||||
|
||||
await act(async () => result.current.addCapability(capabilityInput));
|
||||
act(() => result.current.rememberCapabilityForm("add", { ...capabilityInput, description: "edited after conflict" }));
|
||||
await act(async () => result.current.reapply());
|
||||
|
||||
expect(authoringClient.addCapabilityStep).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ description: "edited after conflict" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reloads explicitly and coalesces duplicate submissions", async () => {
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface DraftAuthoringController {
|
||||
readonly validate: () => Promise<void>;
|
||||
readonly reload: () => Promise<void>;
|
||||
readonly reapply: () => Promise<void>;
|
||||
readonly rememberCapabilityForm: (
|
||||
kind: "add" | "update",
|
||||
input: CapabilityNodeFormValue,
|
||||
) => void;
|
||||
readonly rememberRouteForm: (input: RouteFormValue) => void;
|
||||
readonly select: (selection: WorkbenchSelection) => void;
|
||||
readonly markDirty: () => void;
|
||||
}
|
||||
@@ -95,6 +100,11 @@ const sameSelection = (
|
||||
);
|
||||
};
|
||||
|
||||
const responseMatchesRequest = (
|
||||
response: DraftWorkspace,
|
||||
requestProvenance: Provenance,
|
||||
): boolean => response.workspaceId === requestProvenance.workspaceId;
|
||||
|
||||
const mutationKey = (kind: string, input: unknown, revision: number): string => {
|
||||
const encoded = JSON.stringify(input);
|
||||
return `${kind}:${revision}:${encoded ?? "undefined"}`;
|
||||
@@ -169,12 +179,18 @@ export const useDraftAuthoring = ({
|
||||
response: DraftWorkspace,
|
||||
requestProvenance: Provenance,
|
||||
nextSelection?: WorkbenchSelection,
|
||||
): void => {
|
||||
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
|
||||
): boolean => {
|
||||
if (
|
||||
!sameProvenance(requestProvenance, currentProvenanceRef.current) ||
|
||||
!responseMatchesRequest(response, requestProvenance)
|
||||
) return false;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
draft: response,
|
||||
selection: nextSelection ?? current.selection,
|
||||
selection:
|
||||
response.status === "conflict"
|
||||
? current.selection
|
||||
: nextSelection ?? current.selection,
|
||||
dirty: response.status === "conflict" ? true : false,
|
||||
phase: response.status === "conflict" ? "conflict" : "idle",
|
||||
message:
|
||||
@@ -184,6 +200,7 @@ export const useDraftAuthoring = ({
|
||||
resetGeneration:
|
||||
response.status === "conflict" ? current.resetGeneration : current.resetGeneration + 1,
|
||||
}));
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -199,6 +216,16 @@ export const useDraftAuthoring = ({
|
||||
const key = mutationKey(kind, input, requestDraft.revision);
|
||||
const pending = pendingRef.current;
|
||||
if (pending?.key === key) return pending.promise;
|
||||
if (pending !== null) {
|
||||
const error = new Error("Another draft authoring request is in progress.");
|
||||
setState((current) => ({
|
||||
...current,
|
||||
dirty: true,
|
||||
phase: "error",
|
||||
message: error.message,
|
||||
}));
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!authoringClient) {
|
||||
return Promise.resolve().then(() => {
|
||||
setState((current) => ({
|
||||
@@ -218,7 +245,12 @@ export const useDraftAuthoring = ({
|
||||
message: null,
|
||||
}));
|
||||
const promise = operation(authoringClient, requestDraft)
|
||||
.then((response) => commitResponse(response, requestProvenance, nextSelection))
|
||||
.then((response) => {
|
||||
const committed = commitResponse(response, requestProvenance, nextSelection);
|
||||
if (!committed && sameProvenance(requestProvenance, currentProvenanceRef.current)) {
|
||||
throw new Error("The authoring response did not match the requested workspace.");
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
|
||||
setState((current) => ({
|
||||
@@ -322,14 +354,32 @@ export const useDraftAuthoring = ({
|
||||
return Promise.resolve();
|
||||
}
|
||||
const requestProvenance = currentProvenanceRef.current;
|
||||
const key = mutationKey("validate", requestProvenance.workspaceId, currentDraftRef.current.revision);
|
||||
const pending = pendingRef.current;
|
||||
if (pending?.key === key) return pending.promise;
|
||||
if (pending !== null) {
|
||||
const error = new Error("Another draft authoring request is in progress.");
|
||||
setState((current) => ({ ...current, phase: "error", message: error.message }));
|
||||
return Promise.reject(error);
|
||||
}
|
||||
setState((current) => ({ ...current, phase: "saving", message: null }));
|
||||
return authoringClient
|
||||
.validate(currentDraftRef.current.workspaceId)
|
||||
.then((response) => commitResponse(response, requestProvenance))
|
||||
const promise = authoringClient
|
||||
.validate(requestProvenance.workspaceId)
|
||||
.then((response) => {
|
||||
const committed = commitResponse(response, requestProvenance);
|
||||
if (!committed && sameProvenance(requestProvenance, currentProvenanceRef.current)) {
|
||||
throw new Error("The validation response did not match the requested workspace.");
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
|
||||
setState((current) => ({ ...current, phase: "error", message: errorMessage(error) }));
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingRef.current?.promise === promise) pendingRef.current = null;
|
||||
});
|
||||
pendingRef.current = { key, promise };
|
||||
return promise;
|
||||
}, [authoringClient, commitResponse]);
|
||||
|
||||
const reload = useCallback((): Promise<void> => {
|
||||
@@ -338,7 +388,19 @@ export const useDraftAuthoring = ({
|
||||
return workspaceClient
|
||||
.load(currentDraftRef.current.workspaceId)
|
||||
.then((response) => {
|
||||
if (!sameProvenance(requestProvenance, currentProvenanceRef.current)) return;
|
||||
if (
|
||||
!sameProvenance(requestProvenance, currentProvenanceRef.current) ||
|
||||
!responseMatchesRequest(response, requestProvenance)
|
||||
) {
|
||||
if (sameProvenance(requestProvenance, currentProvenanceRef.current)) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "The reload response did not match the requested workspace.",
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState((current) => ({
|
||||
...current,
|
||||
draft: response,
|
||||
@@ -363,6 +425,17 @@ export const useDraftAuthoring = ({
|
||||
return setRoute(last.input);
|
||||
}, [addCapability, setRoute, updateCapability]);
|
||||
|
||||
const rememberCapabilityForm = useCallback(
|
||||
(kind: "add" | "update", input: CapabilityNodeFormValue): void => {
|
||||
lastSubmissionRef.current = { kind, input };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const rememberRouteForm = useCallback((input: RouteFormValue): void => {
|
||||
lastSubmissionRef.current = { kind: "route", input };
|
||||
}, []);
|
||||
|
||||
return {
|
||||
draft,
|
||||
selection,
|
||||
@@ -376,6 +449,8 @@ export const useDraftAuthoring = ({
|
||||
validate,
|
||||
reload,
|
||||
reapply,
|
||||
rememberCapabilityForm,
|
||||
rememberRouteForm,
|
||||
select,
|
||||
markDirty,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export type InputPathBinding = {
|
||||
|
||||
export type InputValueBinding = {
|
||||
readonly target: LocalInputPath;
|
||||
readonly value: JsonObject;
|
||||
readonly value: unknown;
|
||||
};
|
||||
|
||||
export type InputBinding = InputPathBinding | InputValueBinding;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { DraftWorkbench } from "../authoring/DraftWorkbench.js";
|
||||
import { useDraftWorkspace } from "./useDraftWorkspace.js";
|
||||
import { useCapabilityDiscovery } from "./useCapabilityDiscovery.js";
|
||||
|
||||
const titleFor = (workspace: DraftWorkspace): string =>
|
||||
workspace.title?.trim() || workspace.workspaceId;
|
||||
@@ -20,6 +21,7 @@ export const DraftDetailRoute = ({
|
||||
}: DraftDetailRouteProps) => {
|
||||
const { workspaceId = null } = useParams<{ workspaceId: string }>();
|
||||
const drafts = useDraftWorkspace(workspaceId);
|
||||
const capabilities = useCapabilityDiscovery();
|
||||
const draft =
|
||||
drafts.selected?.workspaceId === workspaceId ? drafts.selected : null;
|
||||
|
||||
@@ -55,6 +57,7 @@ export const DraftDetailRoute = ({
|
||||
</header>
|
||||
|
||||
<DraftWorkbench
|
||||
capabilities={capabilities.items}
|
||||
draft={draft}
|
||||
enableNavigationProtection={enableNavigationProtection}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SchemaFormProps = {
|
||||
readonly initialSources?: FieldSources;
|
||||
readonly diagnostics?: ReadonlyArray<SchemaValueIssue>;
|
||||
readonly onSubmit?: (result: SchemaSerializationResult) => void;
|
||||
readonly onValueChange?: (result: SchemaSerializationResult) => void;
|
||||
readonly onDirtyChange?: (dirty: boolean) => void;
|
||||
readonly renderBeforeFields?: ReactNode;
|
||||
readonly submitLabel?: string;
|
||||
@@ -97,6 +98,7 @@ export const SchemaForm = ({
|
||||
initialSources = EMPTY_SOURCES,
|
||||
diagnostics = EMPTY_DIAGNOSTICS,
|
||||
onSubmit,
|
||||
onValueChange,
|
||||
onDirtyChange,
|
||||
renderBeforeFields,
|
||||
submitLabel = "Save form",
|
||||
@@ -111,42 +113,52 @@ export const SchemaForm = ({
|
||||
const allDiagnostics = [...diagnostics, ...submitIssues];
|
||||
|
||||
const handleValueChange = (changedField: SchemaField, nextValue: unknown): void => {
|
||||
setValues((current: unknown) => setAtPath(current, changedField.path, nextValue));
|
||||
const nextValues = setAtPath(values, changedField.path, nextValue);
|
||||
let nextSources = sources;
|
||||
setValues(nextValues);
|
||||
onDirtyChange?.(true);
|
||||
const currentSource = sources[sourceKey(changedField)];
|
||||
if (currentSource?.mode === "literal") {
|
||||
setSources((current) => ({
|
||||
...current,
|
||||
nextSources = {
|
||||
...sources,
|
||||
[sourceKey(changedField)]: { mode: "literal", value: nextValue },
|
||||
};
|
||||
setSources(() => ({
|
||||
...sources,
|
||||
[sourceKey(changedField)]: { mode: "literal", value: nextValue },
|
||||
}));
|
||||
}
|
||||
onValueChange?.(serializeSchemaValues(field, nextValues, nextSources));
|
||||
};
|
||||
|
||||
const handleSourceChange = (changedField: SchemaField, source: FieldSource): void => {
|
||||
onDirtyChange?.(true);
|
||||
const nextValues = source.mode === "literal"
|
||||
? setAtPath(values, changedField.path, source.value)
|
||||
: values;
|
||||
const nextSources = { ...sources, [sourceKey(changedField)]: source };
|
||||
if (source.mode === "literal") {
|
||||
setValues((current: unknown) => setAtPath(current, changedField.path, source.value));
|
||||
setValues(nextValues);
|
||||
}
|
||||
setSources((current) => ({ ...current, [sourceKey(changedField)]: source }));
|
||||
setSources(nextSources);
|
||||
onValueChange?.(serializeSchemaValues(field, nextValues, nextSources));
|
||||
};
|
||||
|
||||
const handleArrayItemRemove = (arrayField: SchemaField, index: number): void => {
|
||||
onDirtyChange?.(true);
|
||||
setValues((current: unknown) => {
|
||||
const arrayValue = readAtPath(current, arrayField.path);
|
||||
if (!Array.isArray(arrayValue)) return current;
|
||||
return setAtPath(
|
||||
current,
|
||||
arrayField.path,
|
||||
arrayValue.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
});
|
||||
setSources((current) =>
|
||||
rebaseFieldSourcesAfterArrayRemoval(current, arrayField.path, index),
|
||||
);
|
||||
setSubmitIssues((current) =>
|
||||
rebaseSchemaIssuesAfterArrayRemoval(current, arrayField.path, index),
|
||||
const arrayValue = readAtPath(values, arrayField.path);
|
||||
if (!Array.isArray(arrayValue)) return;
|
||||
const nextValues = setAtPath(
|
||||
values,
|
||||
arrayField.path,
|
||||
arrayValue.filter((_, itemIndex) => itemIndex !== index),
|
||||
);
|
||||
const nextSources = rebaseFieldSourcesAfterArrayRemoval(sources, arrayField.path, index);
|
||||
const nextIssues = rebaseSchemaIssuesAfterArrayRemoval(submitIssues, arrayField.path, index);
|
||||
setValues(nextValues);
|
||||
setSources(nextSources);
|
||||
setSubmitIssues(nextIssues);
|
||||
onValueChange?.(serializeSchemaValues(field, nextValues, nextSources));
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
|
||||
@@ -79,9 +79,38 @@ describe("serializeSchemaValues", () => {
|
||||
expect(result.bindings).toEqual([
|
||||
{ target: "profile.email", path: "input.user.email" },
|
||||
]);
|
||||
expect(result.literalBindings).toEqual([
|
||||
{ target: "title", value: "Report" },
|
||||
]);
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("lowers root, nested, array, and null literals without wrappers", () => {
|
||||
const objectResult = serializeSchemaValues(
|
||||
normalizeSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "object",
|
||||
properties: { display: { type: "string" } },
|
||||
},
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
note: { type: "string" },
|
||||
},
|
||||
}),
|
||||
{ profile: { display: "Ada" }, tags: ["one", "two"], note: null },
|
||||
);
|
||||
const rootResult = serializeSchemaValues(normalizeSchema({ type: "array", items: { type: "string" } }), ["one"]);
|
||||
|
||||
expect(objectResult.literalBindings).toEqual([
|
||||
{ target: "profile.display", value: "Ada" },
|
||||
{ target: "tags.0", value: "one" },
|
||||
{ target: "tags.1", value: "two" },
|
||||
{ target: "note", value: null },
|
||||
]);
|
||||
expect(rootResult.literalBindings).toEqual([{ target: "0", value: "one" }]);
|
||||
});
|
||||
|
||||
it("returns a field-local issue for malformed binding paths without throwing", () => {
|
||||
const field = normalizeSchema({
|
||||
type: "object",
|
||||
|
||||
@@ -18,9 +18,15 @@ export type SchemaBinding = {
|
||||
readonly path: string;
|
||||
};
|
||||
|
||||
export type SchemaLiteralBinding = {
|
||||
readonly target: string;
|
||||
readonly value: unknown;
|
||||
};
|
||||
|
||||
export type SchemaSerializationResult = {
|
||||
readonly value: unknown;
|
||||
readonly bindings: ReadonlyArray<SchemaBinding>;
|
||||
readonly literalBindings: ReadonlyArray<SchemaLiteralBinding>;
|
||||
readonly issues: ReadonlyArray<SchemaValueIssue>;
|
||||
};
|
||||
|
||||
@@ -30,6 +36,7 @@ type SerializedField = {
|
||||
readonly present: boolean;
|
||||
readonly value: unknown;
|
||||
readonly bindings: ReadonlyArray<SchemaBinding>;
|
||||
readonly literalBindings: ReadonlyArray<SchemaLiteralBinding>;
|
||||
readonly issues: ReadonlyArray<SchemaValueIssue>;
|
||||
};
|
||||
|
||||
@@ -199,6 +206,7 @@ const serializeField = (
|
||||
present: field.required,
|
||||
value: undefined,
|
||||
bindings: [],
|
||||
literalBindings: [],
|
||||
issues: [issue(field.path, "Binding path must start with input, state, or context.")],
|
||||
};
|
||||
}
|
||||
@@ -206,6 +214,7 @@ const serializeField = (
|
||||
present: true,
|
||||
value: undefined,
|
||||
bindings: [{ target: targetPath(field.path), path: source.sourcePath }],
|
||||
literalBindings: [],
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
@@ -223,7 +232,7 @@ const serializeField = (
|
||||
: sourceValue
|
||||
: sourceValue;
|
||||
if (!usingDefault && raw === undefined && !field.required && !hasNestedSource) {
|
||||
return { present: false, value: undefined, bindings: [], issues: [] };
|
||||
return { present: false, value: undefined, bindings: [], literalBindings: [], issues: [] };
|
||||
}
|
||||
if (
|
||||
!usingDefault &&
|
||||
@@ -232,7 +241,7 @@ const serializeField = (
|
||||
isEmptyValue(raw) &&
|
||||
!field.required
|
||||
) {
|
||||
return { present: false, value: undefined, bindings: [], issues: [] };
|
||||
return { present: false, value: undefined, bindings: [], literalBindings: [], issues: [] };
|
||||
}
|
||||
|
||||
if (field.kind === "object") {
|
||||
@@ -241,22 +250,28 @@ const serializeField = (
|
||||
present: field.required,
|
||||
value: raw,
|
||||
bindings: [],
|
||||
literalBindings: [],
|
||||
issues: [issue(field.path, "Enter an object value.")],
|
||||
};
|
||||
}
|
||||
const value: ValueRecord = {};
|
||||
const bindings: SchemaBinding[] = [];
|
||||
const literalBindings: SchemaLiteralBinding[] = [];
|
||||
const issues: SchemaValueIssue[] = [];
|
||||
for (const child of field.children) {
|
||||
const childValue = serializeField(child, raw[child.key], sources);
|
||||
if (childValue.present) value[child.key] = childValue.value;
|
||||
bindings.push(...childValue.bindings);
|
||||
literalBindings.push(...childValue.literalBindings);
|
||||
issues.push(...childValue.issues);
|
||||
}
|
||||
if (Object.keys(value).length === 0 && !field.required && bindings.length === 0 && !usingDefault) {
|
||||
return { present: false, value: undefined, bindings, issues };
|
||||
return { present: false, value: undefined, bindings, literalBindings, issues };
|
||||
}
|
||||
return { present: true, value, bindings, issues };
|
||||
if (field.children.length === 0) {
|
||||
literalBindings.push({ target: targetPath(field.path), value });
|
||||
}
|
||||
return { present: true, value, bindings, literalBindings, issues };
|
||||
}
|
||||
|
||||
if (field.kind === "array") {
|
||||
@@ -265,11 +280,13 @@ const serializeField = (
|
||||
present: field.required,
|
||||
value: raw,
|
||||
bindings: [],
|
||||
literalBindings: [],
|
||||
issues: [issue(field.path, "Enter an array value.")],
|
||||
};
|
||||
}
|
||||
const value: unknown[] = [];
|
||||
const bindings: SchemaBinding[] = [];
|
||||
const literalBindings: SchemaLiteralBinding[] = [];
|
||||
const issues: SchemaValueIssue[] = [];
|
||||
const item = field.item;
|
||||
if (item) {
|
||||
@@ -278,13 +295,17 @@ const serializeField = (
|
||||
const serialized = serializeField(itemField, itemValue, sources);
|
||||
if (serialized.present) value.push(serialized.value);
|
||||
bindings.push(...serialized.bindings);
|
||||
literalBindings.push(...serialized.literalBindings);
|
||||
issues.push(...serialized.issues);
|
||||
});
|
||||
}
|
||||
if (value.length === 0 && bindings.length === 0 && !field.required && !usingDefault) {
|
||||
return { present: false, value: undefined, bindings, issues };
|
||||
return { present: false, value: undefined, bindings, literalBindings, issues };
|
||||
}
|
||||
return { present: true, value, bindings, issues };
|
||||
if (item === null) {
|
||||
literalBindings.push({ target: targetPath(field.path), value });
|
||||
}
|
||||
return { present: true, value, bindings, literalBindings, issues };
|
||||
}
|
||||
|
||||
if (field.kind === "string") {
|
||||
@@ -293,6 +314,7 @@ const serializeField = (
|
||||
present: true,
|
||||
value: "",
|
||||
bindings: [],
|
||||
literalBindings: [{ target: targetPath(field.path), value: "" }],
|
||||
issues: [issue(field.path, "Required field is incomplete.")],
|
||||
};
|
||||
}
|
||||
@@ -300,6 +322,7 @@ const serializeField = (
|
||||
present: true,
|
||||
value: raw,
|
||||
bindings: [],
|
||||
literalBindings: [{ target: targetPath(field.path), value: raw }],
|
||||
issues: field.required && raw === "" ? [issue(field.path, "Required field is incomplete.")] : [],
|
||||
};
|
||||
}
|
||||
@@ -309,6 +332,7 @@ const serializeField = (
|
||||
present: true,
|
||||
value: "",
|
||||
bindings: [],
|
||||
literalBindings: [{ target: targetPath(field.path), value: "" }],
|
||||
issues: [issue(field.path, "Required field is incomplete.")],
|
||||
};
|
||||
}
|
||||
@@ -327,6 +351,10 @@ const serializeField = (
|
||||
present: true,
|
||||
value: parsed.value,
|
||||
bindings: [],
|
||||
literalBindings:
|
||||
parsed.value === undefined
|
||||
? []
|
||||
: [{ target: targetPath(field.path), value: parsed.value }],
|
||||
issues: parsed.message ? [issue(field.path, parsed.message)] : [],
|
||||
};
|
||||
};
|
||||
@@ -340,6 +368,7 @@ export const serializeSchemaValues = (
|
||||
return {
|
||||
value: serialized.value,
|
||||
bindings: serialized.bindings,
|
||||
literalBindings: serialized.literalBindings,
|
||||
issues: serialized.issues,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user