fix: preserve canonical authoring state

This commit is contained in:
lda
2026-08-09 07:52:31 +07:00 Verified
parent 71f997f221
commit a485ca8a6b
17 changed files with 830 additions and 113 deletions
@@ -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,
};
};