fix: return canonical draft mutations
This commit is contained in:
@@ -73,6 +73,7 @@ const controller = {
|
||||
markDirty: vi.fn(),
|
||||
rememberCapabilityForm: vi.fn(),
|
||||
rememberRouteForm: vi.fn(),
|
||||
preservedCapabilityForm: null,
|
||||
} satisfies DraftAuthoringController;
|
||||
|
||||
describe("ContextInspector", () => {
|
||||
@@ -117,6 +118,14 @@ describe("ContextInspector", () => {
|
||||
repairHint: null,
|
||||
details: {},
|
||||
},
|
||||
{
|
||||
code: "invalid_node_input_field",
|
||||
path: "nodes[0].input[0].target",
|
||||
message: "Destination field is not declared.",
|
||||
stepId: "read",
|
||||
repairHint: null,
|
||||
details: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -134,5 +143,54 @@ describe("ContextInspector", () => {
|
||||
|
||||
expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("Destination field is not declared.")).not.toHaveLength(0);
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveAttribute(
|
||||
"aria-invalid",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the editable update form when a conflict has no canonical draft", () => {
|
||||
const conflictDraft: DraftWorkspace = {
|
||||
...draft,
|
||||
status: "conflict",
|
||||
diagnostics: [],
|
||||
summary: { ...draft.summary, steps: [] },
|
||||
draft: null,
|
||||
};
|
||||
const preservedController = {
|
||||
...controller,
|
||||
draft: conflictDraft,
|
||||
preservedCapabilityForm: {
|
||||
kind: "update" as const,
|
||||
input: {
|
||||
stepId: "read",
|
||||
capabilityName: "demo.read",
|
||||
description: "Locally edited description",
|
||||
retry: 3,
|
||||
timeoutSeconds: 60,
|
||||
inputBindings: [{ target: "title", value: "Locally edited title" }],
|
||||
},
|
||||
},
|
||||
} satisfies DraftAuthoringController;
|
||||
|
||||
render(
|
||||
<ContextInspector
|
||||
capabilities={[]}
|
||||
capabilityDetail={detail}
|
||||
capabilityDetailMessage={null}
|
||||
capabilityDetailPhase="ready"
|
||||
controller={preservedController}
|
||||
draft={conflictDraft}
|
||||
selection={{ kind: "node", nodeId: "read" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue(
|
||||
"Locally edited description",
|
||||
);
|
||||
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue(
|
||||
"Locally edited title",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ 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";
|
||||
import {
|
||||
canonicalCapabilityFormData,
|
||||
capabilityFormDataFromValue,
|
||||
} from "./canonical-capability-form.js";
|
||||
import { parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
|
||||
type ContextInspectorProps = {
|
||||
readonly draft: DraftWorkspace;
|
||||
@@ -36,27 +40,91 @@ const diagnosticParts = (path: string): string[] => {
|
||||
return normalized.split(".").filter((part) => part.length > 0);
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const schemaPathParts = (value: unknown): ReadonlyArray<string | number> | null => {
|
||||
if (typeof value === "string") {
|
||||
const parts = parseTOMLPath(value);
|
||||
return parts?.map((part) => /^\d+$/.test(part) ? Number(part) : part) ?? null;
|
||||
}
|
||||
if (!isRecord(value) || value.root !== "local" || !Array.isArray(value.parts)) return null;
|
||||
return value.parts.every((part): part is string => typeof part === "string")
|
||||
? value.parts.map((part) => /^\d+$/.test(part) ? Number(part) : part)
|
||||
: null;
|
||||
};
|
||||
|
||||
const nodeIdForDiagnostic = (
|
||||
draft: DraftWorkspace,
|
||||
parts: ReadonlyArray<string>,
|
||||
): string | null => {
|
||||
const nodeIndexPart = parts[parts.indexOf("nodes") + 1];
|
||||
const nodeIndex = nodeIndexPart === undefined ? NaN : Number(nodeIndexPart);
|
||||
if (!Number.isInteger(nodeIndex) || nodeIndex < 0 || !isRecord(draft.draft)) return null;
|
||||
const nodes = draft.draft.nodes;
|
||||
if (Array.isArray(nodes)) {
|
||||
const node = nodes[nodeIndex];
|
||||
return isRecord(node) && typeof node.id === "string" ? node.id : null;
|
||||
}
|
||||
const steps = draft.draft.steps;
|
||||
if (!isRecord(steps)) return null;
|
||||
const stepIds = Object.keys(steps).toSorted();
|
||||
return stepIds[nodeIndex] ?? null;
|
||||
};
|
||||
|
||||
const inputBindingsForStep = (
|
||||
draft: DraftWorkspace,
|
||||
stepId: string,
|
||||
): ReadonlyArray<unknown> | null => {
|
||||
if (!isRecord(draft.draft)) return null;
|
||||
const steps = draft.draft.steps;
|
||||
if (isRecord(steps) && isRecord(steps[stepId]) && Array.isArray(steps[stepId].input)) {
|
||||
return steps[stepId].input;
|
||||
}
|
||||
const nodes = draft.draft.nodes;
|
||||
if (!Array.isArray(nodes)) return null;
|
||||
const node = nodes.find((candidate) => isRecord(candidate) && candidate.id === stepId);
|
||||
return isRecord(node) && Array.isArray(node.input) ? node.input : null;
|
||||
};
|
||||
|
||||
const fieldDiagnostic = (
|
||||
diagnostic: DraftDiagnostic,
|
||||
stepId: string,
|
||||
draft: DraftWorkspace,
|
||||
): 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 nodeIndex = parts.indexOf("nodes");
|
||||
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
|
||||
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return null;
|
||||
const inputIndex = parts.indexOf("input");
|
||||
if (inputIndex < 0) return null;
|
||||
return { path: parts.slice(inputIndex + 1), message: diagnostic.message };
|
||||
const inputPath = parts.slice(inputIndex + 1);
|
||||
const bindingIndex = inputPath[0] === undefined ? NaN : Number(inputPath[0]);
|
||||
if (Number.isInteger(bindingIndex) && bindingIndex >= 0) {
|
||||
const bindings = inputBindingsForStep(draft, stepId);
|
||||
const binding = bindings?.[bindingIndex];
|
||||
if (!isRecord(binding)) return null;
|
||||
const target = schemaPathParts(binding.target);
|
||||
return target === null ? null : { path: target, message: diagnostic.message };
|
||||
}
|
||||
return { path: inputPath, message: diagnostic.message };
|
||||
};
|
||||
|
||||
const metadataDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
draft: DraftWorkspace,
|
||||
): 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 [];
|
||||
const nodeIndex = parts.indexOf("nodes");
|
||||
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
|
||||
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return [];
|
||||
if (parts.includes("input")) return [];
|
||||
const field = parts.at(-1);
|
||||
return field === "desc" || field === "retry" || field === "timeout_seconds"
|
||||
@@ -67,8 +135,9 @@ const metadataDiagnostics = (
|
||||
const inputDiagnostics = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
draft: DraftWorkspace,
|
||||
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
|
||||
const issue = fieldDiagnostic(diagnostic, stepId);
|
||||
const issue = fieldDiagnostic(diagnostic, stepId, draft);
|
||||
return issue === null ? [] : [issue];
|
||||
});
|
||||
|
||||
@@ -236,6 +305,12 @@ export const ContextInspector = ({
|
||||
);
|
||||
} else {
|
||||
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
|
||||
const preservedForm =
|
||||
controller.preservedCapabilityForm?.kind === "update" &&
|
||||
controller.preservedCapabilityForm.input.stepId === selection.nodeId
|
||||
? capabilityFormDataFromValue(controller.preservedCapabilityForm.input)
|
||||
: null;
|
||||
const formData = canonicalCapabilityFormData(draft, selection.nodeId) ?? preservedForm;
|
||||
const unsupported = node?.data.kind === "unsupported";
|
||||
content = (
|
||||
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
|
||||
@@ -243,7 +318,7 @@ export const ContextInspector = ({
|
||||
<h2 id="node-selection-heading">{selection.nodeId}</h2>
|
||||
<dl className="authoring-inspector__facts">
|
||||
<Fact label="Kind" value={node?.data.kind ?? "unknown"} />
|
||||
<Fact label="Reference" value={node?.data.nodeRef ?? "none"} />
|
||||
<Fact label="Reference" value={node?.data.nodeRef ?? formData?.capabilityName ?? "none"} />
|
||||
</dl>
|
||||
{unsupported && <p role="status">Read-only: unsupported step kind.</p>}
|
||||
{!unsupported && capabilityDetailPhase === "loading" && (
|
||||
@@ -253,18 +328,17 @@ export const ContextInspector = ({
|
||||
<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)}
|
||||
diagnostics={inputDiagnostics(draft.diagnostics, selection.nodeId, draft)}
|
||||
initialInputSources={formData.initialInputSources}
|
||||
initialInputValue={formData.initialInputValue}
|
||||
initialValue={formData.initialValue}
|
||||
inputSchema={capabilityDetail.inputSchema}
|
||||
metadataDiagnostics={metadataDiagnostics(draft.diagnostics, selection.nodeId)}
|
||||
metadataDiagnostics={metadataDiagnostics(draft.diagnostics, selection.nodeId, draft)}
|
||||
onDirtyChange={controller.markDirty}
|
||||
onSubmit={controller.updateCapability}
|
||||
onValueChange={(value) => controller.rememberCapabilityForm("update", value)}
|
||||
|
||||
@@ -33,7 +33,12 @@ export const DraftWorkbench = ({
|
||||
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;
|
||||
capabilityName =
|
||||
graph.nodes.find((node) => node.id === nodeId)?.data.nodeRef ??
|
||||
(controller.preservedCapabilityForm?.kind === "update" &&
|
||||
controller.preservedCapabilityForm.input.stepId === nodeId
|
||||
? controller.preservedCapabilityForm.input.capabilityName
|
||||
: null);
|
||||
}
|
||||
const capabilityDetail = useAuthoringCapabilityDetail(capabilityName);
|
||||
const select = useCallback(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
CapabilityNodeFormValue,
|
||||
} from "./CapabilityNodeForm.js";
|
||||
import type { InputBinding, InputPath, LocalInputPath } from "../domain/draft-workspace-models.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";
|
||||
@@ -67,6 +68,58 @@ const metadataValue = (step: JsonRecord, key: string): string | number | null |
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const localInputPath = (value: unknown): value is LocalInputPath =>
|
||||
typeof value === "string" || (
|
||||
isRecord(value) &&
|
||||
value.root === "local" &&
|
||||
Array.isArray(value.parts) &&
|
||||
value.parts.every((part): part is string => typeof part === "string")
|
||||
);
|
||||
|
||||
const inputPath = (value: unknown): value is InputPath =>
|
||||
typeof value === "string" || (
|
||||
isRecord(value) &&
|
||||
(value.root === "input" || value.root === "state" || value.root === "context") &&
|
||||
Array.isArray(value.parts) &&
|
||||
value.parts.every((part): part is string => typeof part === "string")
|
||||
);
|
||||
|
||||
const inputBinding = (value: JsonRecord): InputBinding | null => {
|
||||
if (!localInputPath(value.target)) return null;
|
||||
if (inputPath(value.path)) return { target: value.target, path: value.path };
|
||||
if ("value" in value) return { target: value.target, value: value.value };
|
||||
return null;
|
||||
};
|
||||
|
||||
const formDataFromValue = (
|
||||
input: CapabilityNodeFormValue,
|
||||
): CanonicalCapabilityFormData => {
|
||||
const initialInputSources: Record<string, FieldSources[string]> = {};
|
||||
let initialInputValue: unknown = undefined;
|
||||
for (const rawBinding of input.inputBindings ?? []) {
|
||||
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: input.capabilityName,
|
||||
initialValue: input,
|
||||
initialInputValue,
|
||||
initialInputSources,
|
||||
};
|
||||
};
|
||||
|
||||
export const capabilityFormDataFromValue = (
|
||||
input: CapabilityNodeFormValue,
|
||||
): CanonicalCapabilityFormData => formDataFromValue(input);
|
||||
|
||||
export const canonicalCapabilityFormData = (
|
||||
draft: DraftWorkspace,
|
||||
stepId: string,
|
||||
@@ -88,29 +141,23 @@ export const canonicalCapabilityFormData = (
|
||||
? { timeoutSeconds }
|
||||
: {}),
|
||||
} satisfies Partial<CapabilityNodeFormValue>;
|
||||
const initialInputSources: Record<string, { readonly mode: "bind"; readonly sourcePath: string }> = {};
|
||||
let initialInputValue: unknown = undefined;
|
||||
const inputBindings: InputBinding[] = [];
|
||||
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);
|
||||
}
|
||||
const binding = inputBinding(rawBinding);
|
||||
if (binding !== null) inputBindings.push(binding);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
return formDataFromValue({
|
||||
...initialValue,
|
||||
stepId,
|
||||
capabilityName,
|
||||
initialValue,
|
||||
initialInputValue,
|
||||
initialInputSources,
|
||||
};
|
||||
description: initialValue.description ?? null,
|
||||
retry: initialValue.retry ?? null,
|
||||
timeoutSeconds: initialValue.timeoutSeconds ?? null,
|
||||
inputBindings,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,6 +233,30 @@ describe("useDraftAuthoring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an update form when a conflict response has no draft", async () => {
|
||||
const initial = workspace({
|
||||
draft: { steps: { read: { use: "demo.read" } }, routes: {} },
|
||||
});
|
||||
const conflict = workspace({
|
||||
revision: 4,
|
||||
status: "conflict",
|
||||
draft: null,
|
||||
summary: { ...initial.summary, steps: [] },
|
||||
});
|
||||
authoringClient.updateCapabilityStep.mockResolvedValue(conflict);
|
||||
const { result } = renderHook(() => useDraftAuthoring({
|
||||
draft: initial,
|
||||
initialSelection: { kind: "node", nodeId: "read" },
|
||||
}));
|
||||
const input = { ...capabilityInput, stepId: "read", capabilityName: "demo.read" };
|
||||
|
||||
await act(async () => result.current.updateCapability(input));
|
||||
|
||||
expect(result.current.draft).toBe(conflict);
|
||||
expect(result.current.selection).toEqual({ kind: "node", nodeId: "read" });
|
||||
expect(result.current.preservedCapabilityForm).toEqual({ kind: "update", input });
|
||||
});
|
||||
|
||||
it("does not send different writes or validation concurrently for one revision", async () => {
|
||||
const initial = workspace({ revision: 6 });
|
||||
let resolveAdd: ((value: DraftWorkspace) => void) | undefined;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface DraftAuthoringController {
|
||||
readonly phase: DraftAuthoringPhase;
|
||||
readonly message: string | null;
|
||||
readonly resetGeneration: number;
|
||||
readonly preservedCapabilityForm: PreservedCapabilityForm;
|
||||
readonly addCapability: (input: CapabilityNodeFormValue) => Promise<void>;
|
||||
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
|
||||
readonly setRoute: (input: RouteFormValue) => Promise<void>;
|
||||
@@ -71,6 +72,11 @@ type LastSubmission =
|
||||
| { readonly kind: "route"; readonly input: RouteFormValue }
|
||||
| null;
|
||||
|
||||
export type PreservedCapabilityForm =
|
||||
| { readonly kind: "add"; readonly input: CapabilityNodeFormValue }
|
||||
| { readonly kind: "update"; readonly input: CapabilityNodeFormValue }
|
||||
| null;
|
||||
|
||||
const canvasSelection: WorkbenchSelection = { kind: "canvas" };
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
@@ -449,6 +455,10 @@ export const useDraftAuthoring = ({
|
||||
validate,
|
||||
reload,
|
||||
reapply,
|
||||
preservedCapabilityForm:
|
||||
lastSubmissionRef.current?.kind === "add" || lastSubmissionRef.current?.kind === "update"
|
||||
? lastSubmissionRef.current
|
||||
: null,
|
||||
rememberCapabilityForm,
|
||||
rememberRouteForm,
|
||||
select,
|
||||
|
||||
Reference in New Issue
Block a user