feat: edit capability setup and inputs
This commit is contained in:
@@ -29,8 +29,30 @@ describe("CapabilityNodeForm", () => {
|
||||
stepId: "enrich",
|
||||
capabilityName: "demo.enrich",
|
||||
description: "Enrich report",
|
||||
inputBindings: [{ target: "title", value: "Quarterly report" }],
|
||||
});
|
||||
inputBindings: [{ target: "title", value: "Quarterly report" }],
|
||||
});
|
||||
expect(submissions[0]).not.toHaveProperty("retry");
|
||||
expect(submissions[0]).not.toHaveProperty("timeoutSeconds");
|
||||
});
|
||||
|
||||
it("preserves an explicit zero retry while omitting untouched blank metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(
|
||||
<CapabilityNodeForm
|
||||
capabilityName="demo.enrich"
|
||||
inputSchema={{ type: "object", properties: {} }}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("textbox", { name: "Step id" }), "enrich");
|
||||
await user.type(screen.getByRole("spinbutton", { name: "Retry" }), "0");
|
||||
await user.click(screen.getByRole("button", { name: "Add node" }));
|
||||
|
||||
expect(submissions[0]).toHaveProperty("retry", 0);
|
||||
expect(submissions[0]).not.toHaveProperty("timeoutSeconds");
|
||||
expect(submissions[0]).not.toHaveProperty("description");
|
||||
});
|
||||
|
||||
it("reports local edits as dirty and keeps them when submission fails", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import type { InputBinding } from "../domain/draft-workspace-models.js";
|
||||
import { SchemaForm } from "../schema-form/SchemaForm.js";
|
||||
import { normalizeSchema } from "../schema-form/schema-field.js";
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
export type CapabilityNodeFormValue = {
|
||||
readonly stepId: string;
|
||||
readonly capabilityName: string;
|
||||
readonly description: string | null;
|
||||
readonly retry: number | null;
|
||||
readonly timeoutSeconds: number | null;
|
||||
readonly description?: string | null;
|
||||
readonly retry?: number | null;
|
||||
readonly timeoutSeconds?: number | null;
|
||||
readonly inputBindings: ReadonlyArray<InputBinding> | null;
|
||||
readonly inputMap?: Record<string, string> | null;
|
||||
readonly routes?: Record<string, string> | null;
|
||||
@@ -38,6 +38,16 @@ export type CapabilityNodeFormProps = {
|
||||
readonly hidden?: boolean;
|
||||
};
|
||||
|
||||
const optionalNumber = (
|
||||
ref: RefObject<HTMLInputElement | null>,
|
||||
initial: number | null | undefined,
|
||||
touched: boolean,
|
||||
): number | null | undefined => {
|
||||
const raw = ref.current?.value.trim() ?? "";
|
||||
if (raw !== "") return Number(raw);
|
||||
return touched && typeof initial === "number" ? null : undefined;
|
||||
};
|
||||
|
||||
export const CapabilityNodeForm = ({
|
||||
capabilityName,
|
||||
inputSchema,
|
||||
@@ -60,6 +70,10 @@ export const CapabilityNodeForm = ({
|
||||
const timeoutSecondsRef = useRef<HTMLInputElement>(null);
|
||||
const routeTargetRefs = useRef(new Map<string, HTMLInputElement>());
|
||||
const dirtyRef = useRef(false);
|
||||
const descriptionTouchedRef = useRef(false);
|
||||
const retryTouchedRef = useRef(false);
|
||||
const timeoutTouchedRef = useRef(false);
|
||||
const [metadataIssues, setMetadataIssues] = useState<ReadonlyArray<string>>([]);
|
||||
const initialSchemaResult = useMemo(
|
||||
() => serializeSchemaValues(
|
||||
normalizeSchema(inputSchema),
|
||||
@@ -73,33 +87,59 @@ export const CapabilityNodeForm = ({
|
||||
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,
|
||||
],
|
||||
...(routeOutcomes.length > 0
|
||||
? {
|
||||
routes: Object.fromEntries(
|
||||
routeOutcomes.map((outcome) => [
|
||||
outcome,
|
||||
routeTargetRefs.current.get(outcome)?.value.trim() || "__end__",
|
||||
]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const valueFor = (result: SchemaSerializationResult): CapabilityNodeFormValue => {
|
||||
const description = descriptionRef.current?.value.trim() ?? "";
|
||||
const retry = optionalNumber(retryRef, initialValue?.retry, retryTouchedRef.current);
|
||||
const timeoutSeconds = optionalNumber(
|
||||
timeoutSecondsRef,
|
||||
initialValue?.timeoutSeconds,
|
||||
timeoutTouchedRef.current,
|
||||
);
|
||||
return {
|
||||
stepId: stepIdRef.current?.value ?? "",
|
||||
capabilityName,
|
||||
...(description !== ""
|
||||
? { description }
|
||||
: descriptionTouchedRef.current && typeof initialValue?.description === "string"
|
||||
? { description: null }
|
||||
: {}),
|
||||
...(retry === undefined ? {} : { retry }),
|
||||
...(timeoutSeconds === undefined ? {} : { timeoutSeconds }),
|
||||
inputBindings: [
|
||||
...result.bindings,
|
||||
...result.literalBindings,
|
||||
],
|
||||
...(routeOutcomes.length > 0
|
||||
? {
|
||||
routes: Object.fromEntries(
|
||||
routeOutcomes.map((outcome) => [
|
||||
outcome,
|
||||
routeTargetRefs.current.get(outcome)?.value.trim() || "__end__",
|
||||
]),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const validateMetadata = (): ReadonlyArray<string> => {
|
||||
const issues: string[] = [];
|
||||
const retry = retryRef.current?.value.trim() ?? "";
|
||||
if (retry !== "") {
|
||||
const value = Number(retry);
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
||||
issues.push("Retry must be a whole number at least 0.");
|
||||
}
|
||||
}
|
||||
const timeout = timeoutSecondsRef.current?.value.trim() ?? "";
|
||||
if (timeout !== "") {
|
||||
const value = Number(timeout);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
issues.push("Timeout must be greater than 0.");
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
};
|
||||
|
||||
const notifyValueChange = (result: SchemaSerializationResult): void => {
|
||||
schemaResultRef.current = result;
|
||||
@@ -129,7 +169,9 @@ export const CapabilityNodeForm = ({
|
||||
onDirtyChange={markDirty}
|
||||
onValueChange={notifyValueChange}
|
||||
onSubmit={(result) => {
|
||||
if (result.issues.length > 0) return;
|
||||
const nextMetadataIssues = validateMetadata();
|
||||
setMetadataIssues(nextMetadataIssues);
|
||||
if (result.issues.length > 0 || nextMetadataIssues.length > 0) return;
|
||||
notifyValueChange(result);
|
||||
void Promise.resolve(onSubmit(valueFor(result))).catch(() => undefined);
|
||||
}}
|
||||
@@ -155,6 +197,7 @@ export const CapabilityNodeForm = ({
|
||||
defaultValue={initialValue?.description ?? ""}
|
||||
ref={descriptionRef}
|
||||
onChange={(event) => {
|
||||
descriptionTouchedRef.current = true;
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
/>
|
||||
@@ -170,10 +213,13 @@ export const CapabilityNodeForm = ({
|
||||
: String(initialValue.retry)
|
||||
}
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
ref={retryRef}
|
||||
onChange={(event) => {
|
||||
retryTouchedRef.current = true;
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
step={1}
|
||||
type="number"
|
||||
/>
|
||||
{metadataMessage("retry") && <p role="alert">{metadataMessage("retry")}</p>}
|
||||
@@ -188,10 +234,13 @@ export const CapabilityNodeForm = ({
|
||||
: String(initialValue.timeoutSeconds)
|
||||
}
|
||||
inputMode="numeric"
|
||||
min="0.000001"
|
||||
ref={timeoutSecondsRef}
|
||||
onChange={(event) => {
|
||||
timeoutTouchedRef.current = true;
|
||||
notifyMetadataChange();
|
||||
}}
|
||||
step="any"
|
||||
type="number"
|
||||
/>
|
||||
{metadataMessage("timeout_seconds") && <p role="alert">{metadataMessage("timeout_seconds")}</p>}
|
||||
@@ -221,6 +270,11 @@ export const CapabilityNodeForm = ({
|
||||
submitLabel={submitLabel}
|
||||
schema={inputSchema}
|
||||
/>
|
||||
{metadataIssues.length > 0 && (
|
||||
<div className="schema-form__diagnostics" role="alert">
|
||||
{metadataIssues.map((issue) => <p key={issue}>{issue}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CapabilitySetupForm } from "./CapabilitySetupForm.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("CapabilitySetupForm", () => {
|
||||
it("omits untouched blank optional metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(<CapabilitySetupForm onSubmit={(value) => { submissions.push(value); }} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
|
||||
expect(submissions).toEqual([{}]);
|
||||
});
|
||||
|
||||
it("submits null only when clearing an existing value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(
|
||||
<CapabilitySetupForm
|
||||
initialValue={{ retry: 3, timeoutSeconds: 12 }}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.clear(screen.getByRole("spinbutton", { name: "Retry" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
|
||||
expect(submissions[0]).toEqual({ retry: null });
|
||||
expect(submissions[0]).not.toHaveProperty("timeoutSeconds");
|
||||
});
|
||||
|
||||
it("accepts retry zero", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(<CapabilitySetupForm onSubmit={(value) => { submissions.push(value); }} />);
|
||||
|
||||
await user.type(screen.getByRole("spinbutton", { name: "Retry" }), "0");
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
|
||||
expect(submissions).toEqual([{ retry: 0 }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["-1", "Retry must be at least 0."],
|
||||
["1.5", "Retry must be a whole number."],
|
||||
])("rejects retry %s", async (value, message) => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(<CapabilitySetupForm onSubmit={(input) => { submissions.push(input); }} />);
|
||||
|
||||
await user.type(screen.getByRole("spinbutton", { name: "Retry" }), value);
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(message);
|
||||
expect(submissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects timeout zero and accepts a positive timeout", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
render(<CapabilitySetupForm onSubmit={(value) => { submissions.push(value); }} />);
|
||||
|
||||
await user.type(screen.getByRole("spinbutton", { name: "Timeout seconds" }), "0");
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Timeout must be greater than 0.");
|
||||
expect(submissions).toEqual([]);
|
||||
|
||||
await user.clear(screen.getByRole("spinbutton", { name: "Timeout seconds" }));
|
||||
await user.type(screen.getByRole("spinbutton", { name: "Timeout seconds" }), "2.5");
|
||||
await user.click(screen.getByRole("button", { name: "Save setup" }));
|
||||
|
||||
expect(submissions).toEqual([{ timeoutSeconds: 2.5 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { DraftDiagnostic } from "../domain/draft-workspace-models.js";
|
||||
import type { SchemaValueIssue } from "../schema-form/schema-values.js";
|
||||
import type { CapabilitySetupPatch } from "./selected-step-dataflow.js";
|
||||
|
||||
export type CapabilitySetupFormValue = CapabilitySetupPatch;
|
||||
|
||||
export type CapabilitySetupFormProps = {
|
||||
readonly initialValue?: Partial<CapabilitySetupPatch>;
|
||||
readonly diagnostics?: ReadonlyArray<SchemaValueIssue | DraftDiagnostic>;
|
||||
readonly onSubmit: (value: CapabilitySetupFormValue) => void | Promise<void>;
|
||||
readonly onDirtyChange?: (dirty: boolean) => void;
|
||||
readonly submitLabel?: string;
|
||||
};
|
||||
|
||||
type SetupField = "description" | "retry" | "timeoutSeconds";
|
||||
|
||||
const fieldName = (field: SetupField): string =>
|
||||
field === "timeoutSeconds" ? "timeout_seconds" : field === "description" ? "desc" : "retry";
|
||||
|
||||
const issueMessage = (
|
||||
diagnostics: ReadonlyArray<SchemaValueIssue | DraftDiagnostic>,
|
||||
field: SetupField,
|
||||
): string | null => diagnostics.find((diagnostic) => {
|
||||
const path = diagnostic.path;
|
||||
const last = typeof path === "string" ? path.split(".").at(-1) : path.at(-1);
|
||||
return last === fieldName(field) || last === field;
|
||||
})?.message ?? null;
|
||||
|
||||
const initialText = (value: string | null | undefined): string => value ?? "";
|
||||
|
||||
const existingNumber = (value: number | null | undefined): boolean => typeof value === "number";
|
||||
|
||||
export const CapabilitySetupForm = ({
|
||||
initialValue = {},
|
||||
diagnostics = [],
|
||||
onSubmit,
|
||||
onDirtyChange,
|
||||
submitLabel = "Save setup",
|
||||
}: CapabilitySetupFormProps) => {
|
||||
const [description, setDescription] = useState(initialText(initialValue.description));
|
||||
const [retry, setRetry] = useState(
|
||||
initialValue.retry === null || initialValue.retry === undefined ? "" : String(initialValue.retry),
|
||||
);
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState(
|
||||
initialValue.timeoutSeconds === null || initialValue.timeoutSeconds === undefined
|
||||
? ""
|
||||
: String(initialValue.timeoutSeconds),
|
||||
);
|
||||
const [touched, setTouched] = useState<ReadonlySet<SetupField>>(() => new Set());
|
||||
const [issues, setIssues] = useState<ReadonlyArray<string>>([]);
|
||||
|
||||
const touch = (field: SetupField): void => {
|
||||
setTouched((current) => current.has(field) ? current : new Set([...current, field]));
|
||||
onDirtyChange?.(true);
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const nextIssues: string[] = [];
|
||||
const patch: {
|
||||
description?: string | null;
|
||||
retry?: number | null;
|
||||
timeoutSeconds?: number | null;
|
||||
} = {};
|
||||
|
||||
if (touched.has("description")) {
|
||||
if (description.trim() === "") {
|
||||
if (initialValue.description !== undefined && initialValue.description !== null) {
|
||||
patch.description = null;
|
||||
}
|
||||
} else {
|
||||
patch.description = description.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.has("retry")) {
|
||||
if (retry.trim() === "") {
|
||||
if (existingNumber(initialValue.retry)) patch.retry = null;
|
||||
} else {
|
||||
const parsed = Number(retry);
|
||||
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
|
||||
nextIssues.push(
|
||||
!Number.isInteger(parsed) && Number.isFinite(parsed)
|
||||
? "Retry must be a whole number."
|
||||
: "Retry must be at least 0.",
|
||||
);
|
||||
} else {
|
||||
patch.retry = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.has("timeoutSeconds")) {
|
||||
if (timeoutSeconds.trim() === "") {
|
||||
if (existingNumber(initialValue.timeoutSeconds)) patch.timeoutSeconds = null;
|
||||
} else {
|
||||
const parsed = Number(timeoutSeconds);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
nextIssues.push("Timeout must be greater than 0.");
|
||||
} else {
|
||||
patch.timeoutSeconds = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIssues(nextIssues);
|
||||
if (nextIssues.length > 0) return;
|
||||
void Promise.resolve(onSubmit(patch)).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="schema-form authoring-form" noValidate onSubmit={submit}>
|
||||
<fieldset className="schema-form__group">
|
||||
<legend>Setup</legend>
|
||||
<label>
|
||||
Description
|
||||
<input
|
||||
aria-describedby={issueMessage(diagnostics, "description") ? "setup-description-diagnostic" : undefined}
|
||||
aria-label="Description"
|
||||
onChange={(event) => { touch("description"); setDescription(event.target.value); }}
|
||||
type="text"
|
||||
value={description}
|
||||
/>
|
||||
{issueMessage(diagnostics, "description") && (
|
||||
<p id="setup-description-diagnostic" role="alert">{issueMessage(diagnostics, "description")}</p>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
Retry
|
||||
<input
|
||||
aria-describedby={issueMessage(diagnostics, "retry") ? "setup-retry-diagnostic" : undefined}
|
||||
aria-label="Retry"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
onChange={(event) => { touch("retry"); setRetry(event.target.value); }}
|
||||
step={1}
|
||||
type="number"
|
||||
value={retry}
|
||||
/>
|
||||
{issueMessage(diagnostics, "retry") && (
|
||||
<p id="setup-retry-diagnostic" role="alert">{issueMessage(diagnostics, "retry")}</p>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
Timeout seconds
|
||||
<input
|
||||
aria-describedby={issueMessage(diagnostics, "timeoutSeconds") ? "setup-timeout-diagnostic" : undefined}
|
||||
aria-label="Timeout seconds"
|
||||
inputMode="decimal"
|
||||
min="0.000001"
|
||||
onChange={(event) => { touch("timeoutSeconds"); setTimeoutSeconds(event.target.value); }}
|
||||
step="any"
|
||||
type="number"
|
||||
value={timeoutSeconds}
|
||||
/>
|
||||
{issueMessage(diagnostics, "timeoutSeconds") && (
|
||||
<p id="setup-timeout-diagnostic" role="alert">{issueMessage(diagnostics, "timeoutSeconds")}</p>
|
||||
)}
|
||||
</label>
|
||||
{issues.length > 0 && (
|
||||
<div className="schema-form__diagnostics" role="alert">
|
||||
{issues.map((issue) => <p key={issue}>{issue}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
<button type="submit">{submitLabel}</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { InputBinding } from "../domain/draft-workspace-models.js";
|
||||
import { StepInputBindingsForm } from "./StepInputBindingsForm.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
nullable: { enum: [null, "ready"] },
|
||||
nested: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("StepInputBindingsForm", () => {
|
||||
it("renders ordered path, null literal, nested, and unsupported rows with repair controls", () => {
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[
|
||||
{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } },
|
||||
{ kind: "canonical", index: 1, value: { target: "nullable", value: null } },
|
||||
{ kind: "canonical", index: 2, value: { path: "context.profile.name", target: "nested.name" } },
|
||||
{ kind: "unsupported", field: "input", index: 3, raw: { target: "broken" }, reason: "Unsupported input binding." },
|
||||
]}
|
||||
onSubmit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: "Input row 1" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("title");
|
||||
expect(screen.getAllByRole("radio", { name: "Bind" })[0]).toBeChecked();
|
||||
expect(screen.getByRole("textbox", { name: "Source path for Title" })).toHaveValue("input.title");
|
||||
expect(screen.getByRole("combobox", { name: "Nullable" })).toHaveValue("0:null");
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 3" })).toHaveValue("nested.name");
|
||||
expect(screen.getByText("Unsupported input binding.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Raw unsupported input row 4" })).toHaveTextContent('"target"');
|
||||
expect(screen.getByRole("button", { name: "Remove unsupported input row 4" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Move input row 1 down" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Move input row 2 up" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Remove input row 3" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add input row" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits canonical rows in exact reordered order and preserves fan-out", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[
|
||||
{ kind: "canonical", index: 0, value: { path: "input.title", target: "first" } },
|
||||
{ kind: "canonical", index: 1, value: { path: "input.title", target: "second" } },
|
||||
]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Move input row 1 down" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
expect(submissions[0]).toEqual([
|
||||
{ path: "input.title", target: "second" },
|
||||
{ path: "input.title", target: "first" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports explicit clear and removes unsupported rows before saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[
|
||||
{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } },
|
||||
{ kind: "unsupported", field: "input", index: 1, raw: { target: "broken" }, reason: "Unsupported input binding." },
|
||||
]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove unsupported input row 2" }));
|
||||
await user.click(screen.getByRole("button", { name: "Clear inputs" }));
|
||||
|
||||
expect(submissions).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("shows row diagnostics at the row that owns them", () => {
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[{ kind: "canonical", index: 4, value: { path: "input.title", target: "title" } }]}
|
||||
onSubmit={() => undefined}
|
||||
rowDiagnostics={{
|
||||
4: [{ code: "invalid", path: "bindings[4].target", message: "Target does not exist.", stepId: "step", repairHint: null, details: {} }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Target does not exist.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import type { DraftDiagnostic, InputBinding } from "../domain/draft-workspace-models.js";
|
||||
import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js";
|
||||
import { formatTOMLPath, parseGraphSourcePath, parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
import {
|
||||
normalizeSchema,
|
||||
schemaFieldAtPath,
|
||||
type FieldSource,
|
||||
type SchemaField,
|
||||
} from "../schema-form/schema-field.js";
|
||||
import { serializeSchemaValues, type FieldSources } from "../schema-form/schema-values.js";
|
||||
import { formatBoundedJson } from "./format-bounded-json.js";
|
||||
import {
|
||||
inputBindingRows,
|
||||
isJsonValue,
|
||||
serializeInputBindingRow,
|
||||
type InputBindingRow,
|
||||
} from "./selected-step-dataflow.js";
|
||||
|
||||
type EditableRow = {
|
||||
readonly kind: "canonical";
|
||||
readonly id: string;
|
||||
readonly rawIndex: number;
|
||||
readonly target: string;
|
||||
readonly mode: "path" | "literal";
|
||||
readonly sourcePath: string;
|
||||
readonly value: unknown;
|
||||
readonly jsonText: string | null;
|
||||
};
|
||||
|
||||
type UnsupportedRow = Extract<InputBindingRow, { readonly kind: "unsupported" }> & {
|
||||
readonly id: string;
|
||||
};
|
||||
|
||||
type FormRow = EditableRow | UnsupportedRow;
|
||||
|
||||
export type StepInputBindingsFormProps = {
|
||||
readonly inputSchema: unknown;
|
||||
readonly initialRows?: ReadonlyArray<InputBindingRow>;
|
||||
readonly initialBindings?: ReadonlyArray<InputBinding>;
|
||||
readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
|
||||
readonly onSubmit: (bindings: ReadonlyArray<InputBinding>) => void | Promise<void>;
|
||||
readonly onDirtyChange?: (dirty: boolean) => void;
|
||||
readonly submitLabel?: string;
|
||||
};
|
||||
|
||||
const EMPTY_ROWS: ReadonlyArray<InputBindingRow> = [];
|
||||
const EMPTY_DIAGNOSTICS: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>> = {};
|
||||
|
||||
const pathText = (
|
||||
value: string | { readonly parts: ReadonlyArray<string>; readonly root: string },
|
||||
): string => typeof value === "string" ? value : formatTOMLPath([value.root, ...value.parts]);
|
||||
|
||||
const jsonText = (value: unknown): string => {
|
||||
const encoded = JSON.stringify(value, null, 2);
|
||||
return encoded ?? "";
|
||||
};
|
||||
|
||||
const rowsFrom = (rows: ReadonlyArray<InputBindingRow>): ReadonlyArray<FormRow> => rows.map((row, index) => {
|
||||
if (row.kind === "unsupported") return { ...row, id: `input-row-${index}` };
|
||||
if ("path" in row.value) {
|
||||
return {
|
||||
kind: "canonical",
|
||||
id: `input-row-${index}`,
|
||||
rawIndex: row.index,
|
||||
target: pathText(row.value.target),
|
||||
mode: "path",
|
||||
sourcePath: pathText(row.value.path),
|
||||
value: null,
|
||||
jsonText: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "canonical",
|
||||
id: `input-row-${index}`,
|
||||
rawIndex: row.index,
|
||||
target: pathText(row.value.target),
|
||||
mode: "literal",
|
||||
sourcePath: "input.",
|
||||
value: row.value.value,
|
||||
jsonText: null,
|
||||
};
|
||||
});
|
||||
|
||||
const inputRows = (
|
||||
initialRows: ReadonlyArray<InputBindingRow> | undefined,
|
||||
initialBindings: ReadonlyArray<InputBinding> | undefined,
|
||||
): ReadonlyArray<InputBindingRow> => {
|
||||
if (initialRows !== undefined) return initialRows;
|
||||
return initialBindings === undefined ? EMPTY_ROWS : inputBindingRows(initialBindings);
|
||||
};
|
||||
|
||||
const updateRow = (
|
||||
rows: ReadonlyArray<FormRow>,
|
||||
id: string,
|
||||
update: (row: EditableRow) => EditableRow,
|
||||
): ReadonlyArray<FormRow> => rows.map((row) =>
|
||||
row.kind === "canonical" && row.id === id ? update(row) : row,
|
||||
);
|
||||
|
||||
const rowIssueMessages = (
|
||||
row: EditableRow,
|
||||
rowDiagnostics: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>,
|
||||
localIssues: Readonly<Record<string, ReadonlyArray<string>>>,
|
||||
): ReadonlyArray<string> => [
|
||||
...(rowDiagnostics[row.rawIndex] ?? []).map((diagnostic) => diagnostic.message),
|
||||
...(localIssues[row.id] ?? []),
|
||||
];
|
||||
|
||||
const literalValueFor = (
|
||||
field: SchemaField | null,
|
||||
row: EditableRow,
|
||||
): { readonly value: unknown; readonly issues: ReadonlyArray<string> } => {
|
||||
if (field === null) {
|
||||
const raw = row.jsonText ?? jsonText(row.value);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isJsonValue(parsed)
|
||||
? { value: parsed, issues: [] }
|
||||
: { value: parsed, issues: ["Literal value must be valid JSON."] };
|
||||
} catch {
|
||||
return { value: raw, issues: ["Literal value must be valid JSON."] };
|
||||
}
|
||||
}
|
||||
const result = serializeSchemaValues(field, row.value, {
|
||||
[formatTOMLPath(field.path)]: { mode: "literal", value: row.value },
|
||||
});
|
||||
return {
|
||||
value: result.value,
|
||||
issues: result.issues.map((issue) => issue.message),
|
||||
};
|
||||
};
|
||||
|
||||
const bindingForRow = (
|
||||
root: SchemaField,
|
||||
row: EditableRow,
|
||||
): { readonly binding: InputBinding | null; readonly issues: ReadonlyArray<string> } => {
|
||||
const target = row.target.trim();
|
||||
if (target === "") return { binding: null, issues: ["Target is required."] };
|
||||
if (row.mode === "path") {
|
||||
if (parseGraphSourcePath(row.sourcePath) === null) {
|
||||
return { binding: null, issues: ["Source path must start with input., state., or context."] };
|
||||
}
|
||||
const binding = serializeInputBindingRow({ target, path: row.sourcePath });
|
||||
return binding === null
|
||||
? { binding: null, issues: ["Enter a valid target and source path."] }
|
||||
: { binding, issues: [] };
|
||||
}
|
||||
const targetParts = parseTOMLPath(target);
|
||||
const field = targetParts === null
|
||||
? null
|
||||
: schemaFieldAtPath(root, targetParts.map((part) => /^\d+$/.test(part) ? Number(part) : part));
|
||||
const literal = literalValueFor(field, row);
|
||||
if (literal.issues.length > 0) return { binding: null, issues: literal.issues };
|
||||
const binding = serializeInputBindingRow({ target, value: literal.value });
|
||||
return binding === null
|
||||
? { binding: null, issues: ["Enter a valid target and JSON literal."] }
|
||||
: { binding, issues: [] };
|
||||
};
|
||||
|
||||
const sourceForRow = (field: SchemaField, row: EditableRow): FieldSources => ({
|
||||
[formatTOMLPath(field.path)]: row.mode === "path"
|
||||
? { mode: "bind", sourcePath: row.sourcePath }
|
||||
: { mode: "literal", value: row.value },
|
||||
});
|
||||
|
||||
export const StepInputBindingsForm = ({
|
||||
inputSchema,
|
||||
initialRows,
|
||||
initialBindings,
|
||||
rowDiagnostics = EMPTY_DIAGNOSTICS,
|
||||
onSubmit,
|
||||
onDirtyChange,
|
||||
submitLabel = "Save inputs",
|
||||
}: StepInputBindingsFormProps) => {
|
||||
const root = normalizeSchema(inputSchema);
|
||||
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
|
||||
rowsFrom(inputRows(initialRows, initialBindings)),
|
||||
);
|
||||
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
|
||||
const nextId = useRef(rows.length);
|
||||
|
||||
const markDirty = (): void => onDirtyChange?.(true);
|
||||
|
||||
const editRow = (id: string, update: (row: EditableRow) => EditableRow): void => {
|
||||
setRows((current) => updateRow(current, id, update));
|
||||
markDirty();
|
||||
};
|
||||
|
||||
const moveRow = (index: number, direction: -1 | 1): void => {
|
||||
setRows((current) => {
|
||||
const nextIndex = index + direction;
|
||||
if (nextIndex < 0 || nextIndex >= current.length) return current;
|
||||
const next = [...current];
|
||||
const currentRow = next[index];
|
||||
const replacement = next[nextIndex];
|
||||
if (currentRow === undefined || replacement === undefined) return current;
|
||||
next[index] = replacement;
|
||||
next[nextIndex] = currentRow;
|
||||
return next;
|
||||
});
|
||||
markDirty();
|
||||
};
|
||||
|
||||
const removeRow = (id: string): void => {
|
||||
setRows((current) => current.filter((row) => row.id !== id));
|
||||
markDirty();
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
const id = `input-row-${nextId.current++}`;
|
||||
setRows((current) => [
|
||||
...current,
|
||||
{
|
||||
kind: "canonical",
|
||||
id,
|
||||
rawIndex: -1,
|
||||
target: "",
|
||||
mode: "path",
|
||||
sourcePath: "input.",
|
||||
value: null,
|
||||
jsonText: null,
|
||||
},
|
||||
]);
|
||||
markDirty();
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const nextIssues: Record<string, ReadonlyArray<string>> = {};
|
||||
const bindings: InputBinding[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "unsupported") {
|
||||
nextIssues[row.id] = ["Remove or repair this unsupported input row before saving."];
|
||||
continue;
|
||||
}
|
||||
const result = bindingForRow(root, row);
|
||||
if (result.binding === null) nextIssues[row.id] = result.issues;
|
||||
else bindings.push(result.binding);
|
||||
}
|
||||
setLocalIssues(nextIssues);
|
||||
if (Object.keys(nextIssues).length > 0) return;
|
||||
void Promise.resolve(onSubmit(bindings)).catch(() => undefined);
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
setLocalIssues({});
|
||||
markDirty();
|
||||
void Promise.resolve(onSubmit([])).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="schema-form authoring-form" noValidate onSubmit={submit}>
|
||||
<div className="schema-form__group">
|
||||
{rows.length === 0 && <p>No input bindings configured.</p>}
|
||||
{rows.map((row, index) => {
|
||||
const rowNumber = index + 1;
|
||||
if (row.kind === "unsupported") {
|
||||
const unsupportedIssues = [
|
||||
...(rowDiagnostics[row.index] ?? []).map((diagnostic) => diagnostic.message),
|
||||
...(localIssues[row.id] ?? []),
|
||||
];
|
||||
return (
|
||||
<fieldset aria-label={`Unsupported input row ${rowNumber}`} className="schema-form__group" key={row.id}>
|
||||
<legend>Input row {rowNumber}: unsupported</legend>
|
||||
<p className="schema-form__fallback-reason">{row.reason}</p>
|
||||
<details className="schema-form__raw" open>
|
||||
<summary>Raw unsupported input</summary>
|
||||
<pre aria-label={`Raw unsupported input row ${rowNumber}`} role="region" tabIndex={0}>
|
||||
{formatBoundedJson(row.raw)}
|
||||
</pre>
|
||||
</details>
|
||||
{unsupportedIssues.length > 0 && (
|
||||
<div className="schema-form__diagnostics" role="alert">
|
||||
{unsupportedIssues.map((issue) => <p key={issue}>{issue}</p>)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Remove unsupported input row ${rowNumber}`}
|
||||
className="schema-form__secondary-action"
|
||||
onClick={() => removeRow(row.id)}
|
||||
type="button"
|
||||
>
|
||||
Remove to repair
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
const targetParts = parseTOMLPath(row.target.trim());
|
||||
const field = targetParts === null
|
||||
? null
|
||||
: schemaFieldAtPath(root, targetParts.map((part) => /^\d+$/.test(part) ? Number(part) : part));
|
||||
const issues = rowIssueMessages(row, rowDiagnostics, localIssues);
|
||||
const source = field === null ? null : sourceForRow(field, row);
|
||||
return (
|
||||
<fieldset aria-label={`Input row ${rowNumber}`} className="schema-form__group" key={row.id}>
|
||||
<legend>Input row {rowNumber}</legend>
|
||||
<label>
|
||||
Target
|
||||
<input
|
||||
aria-label={`Target for row ${rowNumber}`}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, target: event.target.value }))}
|
||||
type="text"
|
||||
value={row.target}
|
||||
/>
|
||||
</label>
|
||||
{field !== null && source !== null ? (
|
||||
<SchemaFieldControl
|
||||
diagnostics={[]}
|
||||
field={field}
|
||||
onArrayItemRemove={() => undefined}
|
||||
onSourceChange={(_changedField, nextSource: FieldSource) => editRow(row.id, (current) =>
|
||||
nextSource.mode === "bind"
|
||||
? { ...current, mode: "path", sourcePath: nextSource.sourcePath }
|
||||
: { ...current, mode: "literal", value: nextSource.value, jsonText: null },
|
||||
)}
|
||||
onValueChange={(_changedField, value) => editRow(row.id, (current) => ({ ...current, value, jsonText: null }))}
|
||||
sourceSuggestions={[]}
|
||||
sources={source}
|
||||
value={row.value}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<fieldset className="schema-form__source">
|
||||
<legend>Value source</legend>
|
||||
<div className="schema-form__source-options">
|
||||
<label>
|
||||
<input
|
||||
checked={row.mode === "literal"}
|
||||
name={`${row.id}-mode`}
|
||||
onChange={() => editRow(row.id, (current) => ({ ...current, mode: "literal" }))}
|
||||
type="radio"
|
||||
/>
|
||||
Literal
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
checked={row.mode === "path"}
|
||||
name={`${row.id}-mode`}
|
||||
onChange={() => editRow(row.id, (current) => ({ ...current, mode: "path" }))}
|
||||
type="radio"
|
||||
/>
|
||||
Bind
|
||||
</label>
|
||||
</div>
|
||||
{row.mode === "path" ? (
|
||||
<label>
|
||||
Source path
|
||||
<input
|
||||
aria-label={`Source path for row ${rowNumber}`}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, sourcePath: event.target.value }))}
|
||||
type="text"
|
||||
value={row.sourcePath}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label>
|
||||
Literal JSON value
|
||||
<textarea
|
||||
aria-label={`Literal JSON value for row ${rowNumber}`}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, jsonText: event.target.value, value: event.target.value }))}
|
||||
value={row.jsonText ?? jsonText(row.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
<p className="schema-form__fallback-reason">
|
||||
No matching schema field. Edit the binding as raw JSON.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
<div className="schema-form__diagnostics" role="alert">
|
||||
{issues.map((issue) => <p key={issue}>{issue}</p>)}
|
||||
</div>
|
||||
)}
|
||||
<div className="schema-form__source-options">
|
||||
<button
|
||||
aria-label={`Move input row ${rowNumber} up`}
|
||||
className="schema-form__secondary-action"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveRow(index, -1)}
|
||||
type="button"
|
||||
>
|
||||
Move up
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Move input row ${rowNumber} down`}
|
||||
className="schema-form__secondary-action"
|
||||
disabled={index === rows.length - 1}
|
||||
onClick={() => moveRow(index, 1)}
|
||||
type="button"
|
||||
>
|
||||
Move down
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Remove input row ${rowNumber}`}
|
||||
className="schema-form__secondary-action"
|
||||
onClick={() => removeRow(row.id)}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
<button className="schema-form__secondary-action" onClick={addRow} type="button">
|
||||
Add input row
|
||||
</button>
|
||||
</div>
|
||||
<div className="schema-form__source-options">
|
||||
<button type="submit">{submitLabel}</button>
|
||||
<button onClick={clear} type="button">Clear inputs</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -424,9 +424,9 @@ export const useDraftAuthoring = ({
|
||||
...(input.inputMap !== undefined ? { inputMap: input.inputMap } : {}),
|
||||
...(input.inputBindings !== undefined ? { inputBindings: input.inputBindings } : {}),
|
||||
...(input.bindOutputs !== undefined ? { bindOutputs: input.bindOutputs } : {}),
|
||||
description: input.description,
|
||||
retry: input.retry,
|
||||
timeoutSeconds: input.timeoutSeconds,
|
||||
...(input.description === undefined ? {} : { description: input.description }),
|
||||
...(input.retry === undefined ? {} : { retry: input.retry }),
|
||||
...(input.timeoutSeconds === undefined ? {} : { timeoutSeconds: input.timeoutSeconds }),
|
||||
}),
|
||||
{
|
||||
nextSelection: { kind: "node", nodeId: input.stepId },
|
||||
@@ -450,10 +450,10 @@ export const useDraftAuthoring = ({
|
||||
revision: requestDraft.revision,
|
||||
stepId: targetStepId,
|
||||
update: {
|
||||
description: input.description,
|
||||
input: input.inputBindings,
|
||||
retry: input.retry,
|
||||
timeoutSeconds: input.timeoutSeconds,
|
||||
...(input.description === undefined ? {} : { description: input.description }),
|
||||
...(input.retry === undefined ? {} : { retry: input.retry }),
|
||||
...(input.timeoutSeconds === undefined ? {} : { timeoutSeconds: input.timeoutSeconds }),
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -169,7 +169,7 @@ export const SchemaForm = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="schema-form" onSubmit={handleSubmit}>
|
||||
<form className="schema-form" noValidate onSubmit={handleSubmit}>
|
||||
{renderBeforeFields}
|
||||
<SchemaFieldControl
|
||||
diagnostics={allDiagnostics}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeSchema, rebaseSchemaField } from "./schema-field.js";
|
||||
import { normalizeSchema, rebaseSchemaField, schemaFieldAtPath } from "./schema-field.js";
|
||||
|
||||
describe("normalizeSchema", () => {
|
||||
it("normalizes primitive, multiline, boolean, and enum fields", () => {
|
||||
@@ -134,4 +134,30 @@ describe("normalizeSchema", () => {
|
||||
"name",
|
||||
]);
|
||||
});
|
||||
|
||||
it("looks up nested object, array, root, and missing schema paths", () => {
|
||||
const root = normalizeSchema({
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
items: { type: "array", items: { type: "integer" } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(schemaFieldAtPath(root, [])).toBe(root);
|
||||
expect(schemaFieldAtPath(root, ["profile", "name"])).toMatchObject({
|
||||
key: "name",
|
||||
kind: "string",
|
||||
path: ["profile", "name"],
|
||||
});
|
||||
expect(schemaFieldAtPath(root, ["items", 2])).toMatchObject({
|
||||
key: "item",
|
||||
kind: "integer",
|
||||
path: ["items", 2],
|
||||
});
|
||||
expect(schemaFieldAtPath(root, ["missing"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,3 +222,30 @@ export const normalizeSchemaField = (
|
||||
|
||||
export const normalizeSchema = (schema: unknown): SchemaField =>
|
||||
normalizeSchemaField(schema);
|
||||
|
||||
/** Find a normalized field without losing the array index in the returned path. */
|
||||
export const schemaFieldAtPath = (
|
||||
root: SchemaField,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): SchemaField | null => {
|
||||
let current = root;
|
||||
const actualPath: Array<string | number> = [];
|
||||
for (const part of path) {
|
||||
if (current.kind === "object") {
|
||||
const child = current.children.find((candidate) => candidate.key === String(part));
|
||||
if (child === undefined) return null;
|
||||
current = child;
|
||||
actualPath.push(child.key);
|
||||
continue;
|
||||
}
|
||||
if (current.kind === "array" && current.item !== null) {
|
||||
const index = typeof part === "number" ? part : Number(part);
|
||||
if (!Number.isSafeInteger(index) || index < 0) return null;
|
||||
current = rebaseSchemaField(current.item, [...actualPath, index]);
|
||||
actualPath.push(index);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user