fix: harden capability input forms
This commit is contained in:
@@ -23,6 +23,7 @@ describe("CapabilityNodeForm", () => {
|
||||
await user.type(screen.getByRole("textbox", { name: "Step id" }), "enrich");
|
||||
await user.type(screen.getByRole("textbox", { name: "Description" }), "Enrich report");
|
||||
await user.type(screen.getByRole("textbox", { name: "Title" }), "Quarterly report");
|
||||
expect(screen.getByRole("spinbutton", { name: "Timeout seconds" })).toHaveAttribute("inputmode", "decimal");
|
||||
await user.click(screen.getByRole("button", { name: "Add node" }));
|
||||
|
||||
expect(submissions[0]).toMatchObject({
|
||||
|
||||
@@ -233,7 +233,7 @@ export const CapabilityNodeForm = ({
|
||||
? ""
|
||||
: String(initialValue.timeoutSeconds)
|
||||
}
|
||||
inputMode="numeric"
|
||||
inputMode="decimal"
|
||||
min="0.000001"
|
||||
ref={timeoutSecondsRef}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -75,4 +75,26 @@ describe("CapabilitySetupForm", () => {
|
||||
|
||||
expect(submissions).toEqual([{ timeoutSeconds: 2.5 }]);
|
||||
});
|
||||
|
||||
it("associates local retry errors with the retry control and avoids duplicate ids", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<CapabilitySetupForm onSubmit={() => undefined} />
|
||||
<CapabilitySetupForm onSubmit={() => undefined} />
|
||||
</>,
|
||||
);
|
||||
|
||||
const retryInputs = screen.getAllByRole("spinbutton", { name: "Retry" });
|
||||
expect(new Set(retryInputs.map((input) => input.id)).size).toBe(2);
|
||||
expect(new Set(retryInputs.map((input) => input.getAttribute("aria-describedby"))).size).toBe(1);
|
||||
|
||||
await user.type(retryInputs[0]!, "-1");
|
||||
await user.click(screen.getAllByRole("button", { name: "Save setup" })[0]!);
|
||||
|
||||
const describedBy = retryInputs[0]!.getAttribute("aria-describedby");
|
||||
expect(retryInputs[0]).toHaveAttribute("aria-invalid", "true");
|
||||
expect(describedBy).toBeTruthy();
|
||||
expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Retry must be at least 0.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useId, useRef, 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";
|
||||
@@ -38,33 +38,41 @@ export const CapabilitySetupForm = ({
|
||||
onDirtyChange,
|
||||
submitLabel = "Save setup",
|
||||
}: CapabilitySetupFormProps) => {
|
||||
const [description, setDescription] = useState(initialText(initialValue.description));
|
||||
const [retry, setRetry] = useState(
|
||||
const [description, setDescription] = useState(() => initialText(initialValue.description));
|
||||
const [retry, setRetry] = useState(() =>
|
||||
initialValue.retry === null || initialValue.retry === undefined ? "" : String(initialValue.retry),
|
||||
);
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState(
|
||||
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 touchedRef = useRef<ReadonlySet<SetupField>>(new Set());
|
||||
const [issues, setIssues] = useState<Readonly<Partial<Record<SetupField, string>>>>({});
|
||||
const formId = useId();
|
||||
|
||||
const controlId = (field: SetupField): string => `${formId}-${field}`;
|
||||
const errorId = (field: SetupField): string => `${controlId(field)}-error`;
|
||||
const diagnosticFor = (field: SetupField): string | null =>
|
||||
issues[field] ?? issueMessage(diagnostics, field);
|
||||
|
||||
const touch = (field: SetupField): void => {
|
||||
setTouched((current) => current.has(field) ? current : new Set([...current, field]));
|
||||
if (!touchedRef.current.has(field)) {
|
||||
touchedRef.current = new Set([...touchedRef.current, field]);
|
||||
}
|
||||
onDirtyChange?.(true);
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const nextIssues: string[] = [];
|
||||
const nextIssues: Partial<Record<SetupField, string>> = {};
|
||||
const patch: {
|
||||
description?: string | null;
|
||||
retry?: number | null;
|
||||
timeoutSeconds?: number | null;
|
||||
} = {};
|
||||
|
||||
if (touched.has("description")) {
|
||||
if (touchedRef.current.has("description")) {
|
||||
if (description.trim() === "") {
|
||||
if (initialValue.description !== undefined && initialValue.description !== null) {
|
||||
patch.description = null;
|
||||
@@ -74,30 +82,29 @@ export const CapabilitySetupForm = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.has("retry")) {
|
||||
if (touchedRef.current.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(
|
||||
nextIssues.retry =
|
||||
!Number.isInteger(parsed) && Number.isFinite(parsed)
|
||||
? "Retry must be a whole number."
|
||||
: "Retry must be at least 0.",
|
||||
);
|
||||
: "Retry must be at least 0.";
|
||||
} else {
|
||||
patch.retry = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched.has("timeoutSeconds")) {
|
||||
if (touchedRef.current.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.");
|
||||
nextIssues.timeoutSeconds = "Timeout must be greater than 0.";
|
||||
} else {
|
||||
patch.timeoutSeconds = parsed;
|
||||
}
|
||||
@@ -105,7 +112,7 @@ export const CapabilitySetupForm = ({
|
||||
}
|
||||
|
||||
setIssues(nextIssues);
|
||||
if (nextIssues.length > 0) return;
|
||||
if (Object.keys(nextIssues).length > 0) return;
|
||||
void Promise.resolve(onSubmit(patch)).catch(() => undefined);
|
||||
};
|
||||
|
||||
@@ -116,21 +123,25 @@ export const CapabilitySetupForm = ({
|
||||
<label>
|
||||
Description
|
||||
<input
|
||||
aria-describedby={issueMessage(diagnostics, "description") ? "setup-description-diagnostic" : undefined}
|
||||
aria-describedby={diagnosticFor("description") ? errorId("description") : undefined}
|
||||
aria-label="Description"
|
||||
aria-invalid={diagnosticFor("description") !== null}
|
||||
id={controlId("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>
|
||||
{diagnosticFor("description") && (
|
||||
<p id={errorId("description")} role="alert">{diagnosticFor("description")}</p>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
Retry
|
||||
<input
|
||||
aria-describedby={issueMessage(diagnostics, "retry") ? "setup-retry-diagnostic" : undefined}
|
||||
aria-describedby={diagnosticFor("retry") ? errorId("retry") : undefined}
|
||||
aria-label="Retry"
|
||||
aria-invalid={diagnosticFor("retry") !== null}
|
||||
id={controlId("retry")}
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
onChange={(event) => { touch("retry"); setRetry(event.target.value); }}
|
||||
@@ -138,15 +149,17 @@ export const CapabilitySetupForm = ({
|
||||
type="number"
|
||||
value={retry}
|
||||
/>
|
||||
{issueMessage(diagnostics, "retry") && (
|
||||
<p id="setup-retry-diagnostic" role="alert">{issueMessage(diagnostics, "retry")}</p>
|
||||
{diagnosticFor("retry") && (
|
||||
<p id={errorId("retry")} role="alert">{diagnosticFor("retry")}</p>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
Timeout seconds
|
||||
<input
|
||||
aria-describedby={issueMessage(diagnostics, "timeoutSeconds") ? "setup-timeout-diagnostic" : undefined}
|
||||
aria-describedby={diagnosticFor("timeoutSeconds") ? errorId("timeoutSeconds") : undefined}
|
||||
aria-label="Timeout seconds"
|
||||
aria-invalid={diagnosticFor("timeoutSeconds") !== null}
|
||||
id={controlId("timeoutSeconds")}
|
||||
inputMode="decimal"
|
||||
min="0.000001"
|
||||
onChange={(event) => { touch("timeoutSeconds"); setTimeoutSeconds(event.target.value); }}
|
||||
@@ -154,15 +167,10 @@ export const CapabilitySetupForm = ({
|
||||
type="number"
|
||||
value={timeoutSeconds}
|
||||
/>
|
||||
{issueMessage(diagnostics, "timeoutSeconds") && (
|
||||
<p id="setup-timeout-diagnostic" role="alert">{issueMessage(diagnostics, "timeoutSeconds")}</p>
|
||||
{diagnosticFor("timeoutSeconds") && (
|
||||
<p id={errorId("timeoutSeconds")} role="alert">{diagnosticFor("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>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
import { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
@@ -19,6 +20,13 @@ const schema = {
|
||||
};
|
||||
|
||||
describe("StepInputBindingsForm", () => {
|
||||
it("formats local and graph path objects at whole and nested paths", () => {
|
||||
expect(displayLocalInputPath({ root: "local", parts: [] })).toBe(".");
|
||||
expect(displayLocalInputPath({ root: "local", parts: ["payload", "item"] })).toBe("payload.item");
|
||||
expect(displayGraphInputPath({ root: "input", parts: ["payload", "item"] })).toBe("input.payload.item");
|
||||
expect(displayGraphInputPath({ root: "state", parts: [] })).toBe("state");
|
||||
});
|
||||
|
||||
it("renders ordered path, null literal, nested, and unsupported rows with repair controls", () => {
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
@@ -35,8 +43,8 @@ describe("StepInputBindingsForm", () => {
|
||||
|
||||
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("radio", { name: "Path for input row 1" })).toBeChecked();
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 1" })).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();
|
||||
@@ -91,6 +99,155 @@ describe("StepInputBindingsForm", () => {
|
||||
expect(submissions).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("blocks clear until every unsupported row is explicitly removed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[{
|
||||
kind: "unsupported",
|
||||
field: "input",
|
||||
index: 0,
|
||||
raw: { target: "broken" },
|
||||
reason: "Unsupported input binding.",
|
||||
}]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clear = screen.getByRole("button", { name: "Clear inputs" });
|
||||
await user.click(clear);
|
||||
|
||||
expect(submissions).toEqual([]);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Remove or repair this unsupported input row before clearing inputs.",
|
||||
);
|
||||
expect(clear.getAttribute("aria-describedby")).toBe(screen.getByRole("alert").id);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove unsupported input row 1" }));
|
||||
await user.click(clear);
|
||||
expect(submissions).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("round-trips whole and nested local paths without adding the local root", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[
|
||||
{
|
||||
kind: "canonical",
|
||||
index: 0,
|
||||
value: {
|
||||
target: { root: "local", parts: ["payload", "item"] },
|
||||
path: { root: "input", parts: ["source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "canonical",
|
||||
index: 1,
|
||||
value: {
|
||||
target: { root: "local", parts: [] },
|
||||
path: { root: "state", parts: ["audit", "latest"] },
|
||||
},
|
||||
},
|
||||
]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 1" })).toHaveValue("payload.item");
|
||||
expect(screen.getByRole("textbox", { name: "Target for row 2" })).toHaveValue(".");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 1" })).toHaveValue("input.source");
|
||||
expect(screen.getByRole("textbox", { name: "Source path for input row 2" })).toHaveValue("state.audit.latest");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
expect(submissions).toEqual([[
|
||||
{ target: "payload.item", path: "input.source" },
|
||||
{ target: ".", path: "state.audit.latest" },
|
||||
]]);
|
||||
});
|
||||
|
||||
it("uses explicit row modes and immutably edits a whole object literal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
nested: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" }, count: { type: "integer" } },
|
||||
},
|
||||
},
|
||||
}}
|
||||
initialRows={[{
|
||||
kind: "canonical",
|
||||
index: 0,
|
||||
value: { target: "nested", value: { name: "before", count: 2 } },
|
||||
}]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Literal value for input row 1" })).toBeChecked();
|
||||
await user.clear(screen.getByRole("textbox", { name: "Name" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "after");
|
||||
await user.click(screen.getByRole("radio", { name: "Path for input row 1" }));
|
||||
await user.clear(screen.getByRole("textbox", { name: "Source path for input row 1" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Source path for input row 1" }), "input.nested");
|
||||
await user.click(screen.getByRole("radio", { name: "Literal value for input row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
expect(submissions).toEqual([[{ target: "nested", value: { name: "after", count: 2 } }]]);
|
||||
});
|
||||
|
||||
it("preserves array shape while editing a whole array literal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: ReadonlyArray<InputBinding>[] = [];
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={{ type: "object", properties: { items: { type: "array", items: { type: "string" } } } }}
|
||||
initialRows={[{
|
||||
kind: "canonical",
|
||||
index: 0,
|
||||
value: { target: "items", value: ["first", "second"] },
|
||||
}]}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.clear(screen.getByRole("textbox", { name: "Item 1" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Item 1" }), "updated");
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
expect(submissions).toEqual([[{ target: "items", value: ["updated", "second"] }]]);
|
||||
});
|
||||
|
||||
it("associates local row errors with the target control", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
inputSchema={schema}
|
||||
initialRows={[{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } }]}
|
||||
onSubmit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.clear(screen.getByRole("textbox", { name: "Target for row 1" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save inputs" }));
|
||||
|
||||
const target = screen.getByRole("textbox", { name: "Target for row 1" });
|
||||
const describedBy = target.getAttribute("aria-describedby");
|
||||
expect(target).toHaveAttribute("aria-invalid", "true");
|
||||
expect(describedBy).toBeTruthy();
|
||||
expect(document.getElementById(describedBy ?? "")).toHaveTextContent("Target is required.");
|
||||
});
|
||||
|
||||
it("shows row diagnostics at the row that owns them", () => {
|
||||
render(
|
||||
<StepInputBindingsForm
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import type { DraftDiagnostic, InputBinding } from "../domain/draft-workspace-models.js";
|
||||
import { useId, 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 { displayGraphInputPath, displayLocalInputPath } from "./input-binding-paths.js";
|
||||
import {
|
||||
inputBindingRows,
|
||||
isJsonValue,
|
||||
@@ -47,34 +50,33 @@ export type StepInputBindingsFormProps = {
|
||||
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}` };
|
||||
const rowsFrom = (
|
||||
rows: ReadonlyArray<InputBindingRow>,
|
||||
formId: string,
|
||||
): ReadonlyArray<FormRow> => rows.map((row, index) => {
|
||||
if (row.kind === "unsupported") return { ...row, id: `${formId}-input-row-${index}` };
|
||||
if ("path" in row.value) {
|
||||
return {
|
||||
kind: "canonical",
|
||||
id: `input-row-${index}`,
|
||||
id: `${formId}-input-row-${index}`,
|
||||
rawIndex: row.index,
|
||||
target: pathText(row.value.target),
|
||||
target: displayLocalInputPath(row.value.target),
|
||||
mode: "path",
|
||||
sourcePath: pathText(row.value.path),
|
||||
sourcePath: displayGraphInputPath(row.value.path),
|
||||
value: null,
|
||||
jsonText: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "canonical",
|
||||
id: `input-row-${index}`,
|
||||
id: `${formId}-input-row-${index}`,
|
||||
rawIndex: row.index,
|
||||
target: pathText(row.value.target),
|
||||
target: displayLocalInputPath(row.value.target),
|
||||
mode: "literal",
|
||||
sourcePath: "input.",
|
||||
value: row.value.value,
|
||||
@@ -98,6 +100,52 @@ const updateRow = (
|
||||
row.kind === "canonical" && row.id === id ? update(row) : row,
|
||||
);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const setAtPath = (
|
||||
current: unknown,
|
||||
path: ReadonlyArray<string | number>,
|
||||
value: unknown,
|
||||
): unknown => {
|
||||
if (path.length === 0) return value;
|
||||
const head = path[0];
|
||||
if (head === undefined) return current;
|
||||
const tail = path.slice(1);
|
||||
if (typeof head === "number") {
|
||||
const next = Array.isArray(current) ? [...current] : [];
|
||||
next[head] = setAtPath(next[head], tail, value);
|
||||
return next;
|
||||
}
|
||||
const next = isRecord(current) ? { ...current } : {};
|
||||
next[head] = setAtPath(next[head], tail, value);
|
||||
return next;
|
||||
};
|
||||
|
||||
const removeAtPath = (
|
||||
current: unknown,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): unknown => {
|
||||
if (path.length === 0) return current;
|
||||
const head = path[0];
|
||||
if (head === undefined) return current;
|
||||
const tail = path.slice(1);
|
||||
if (typeof head === "number") {
|
||||
if (!Array.isArray(current)) return current;
|
||||
if (tail.length === 0) return current.filter((_, index) => index !== head);
|
||||
const next = [...current];
|
||||
next[head] = removeAtPath(next[head], tail);
|
||||
return next;
|
||||
}
|
||||
if (!isRecord(current)) return current;
|
||||
return { ...current, [head]: removeAtPath(current[head], tail) };
|
||||
};
|
||||
|
||||
const relativePath = (
|
||||
rootPath: ReadonlyArray<string | number>,
|
||||
changedPath: ReadonlyArray<string | number>,
|
||||
): ReadonlyArray<string | number> => changedPath.slice(rootPath.length);
|
||||
|
||||
const rowIssueMessages = (
|
||||
row: EditableRow,
|
||||
rowDiagnostics: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>,
|
||||
@@ -107,6 +155,13 @@ const rowIssueMessages = (
|
||||
...(localIssues[row.id] ?? []),
|
||||
];
|
||||
|
||||
const schemaFieldForTarget = (root: SchemaField, target: string): SchemaField | null => {
|
||||
const targetParts = parseTOMLPath(target);
|
||||
return targetParts === null
|
||||
? null
|
||||
: schemaFieldAtPath(root, targetParts.map((part) => /^\d+$/.test(part) ? Number(part) : part));
|
||||
};
|
||||
|
||||
const literalValueFor = (
|
||||
field: SchemaField | null,
|
||||
row: EditableRow,
|
||||
@@ -146,10 +201,7 @@ const bindingForRow = (
|
||||
? { 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 field = schemaFieldForTarget(root, target);
|
||||
const literal = literalValueFor(field, row);
|
||||
if (literal.issues.length > 0) return { binding: null, issues: literal.issues };
|
||||
const binding = serializeInputBindingRow({ target, value: literal.value });
|
||||
@@ -158,12 +210,6 @@ const bindingForRow = (
|
||||
: { 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,
|
||||
@@ -173,12 +219,15 @@ export const StepInputBindingsForm = ({
|
||||
onDirtyChange,
|
||||
submitLabel = "Save inputs",
|
||||
}: StepInputBindingsFormProps) => {
|
||||
const formId = useId();
|
||||
const root = normalizeSchema(inputSchema);
|
||||
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
|
||||
rowsFrom(inputRows(initialRows, initialBindings)),
|
||||
rowsFrom(inputRows(initialRows, initialBindings), formId),
|
||||
);
|
||||
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
|
||||
const [formIssue, setFormIssue] = useState<string | null>(null);
|
||||
const nextId = useRef(rows.length);
|
||||
const formErrorId = `${formId}-form-error`;
|
||||
|
||||
const markDirty = (): void => onDirtyChange?.(true);
|
||||
|
||||
@@ -204,11 +253,17 @@ export const StepInputBindingsForm = ({
|
||||
|
||||
const removeRow = (id: string): void => {
|
||||
setRows((current) => current.filter((row) => row.id !== id));
|
||||
setLocalIssues((current) => {
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setFormIssue(null);
|
||||
markDirty();
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
const id = `input-row-${nextId.current++}`;
|
||||
const id = `${formId}-input-row-${nextId.current++}`;
|
||||
setRows((current) => [
|
||||
...current,
|
||||
{
|
||||
@@ -225,6 +280,8 @@ export const StepInputBindingsForm = ({
|
||||
markDirty();
|
||||
};
|
||||
|
||||
const unsupportedRows = rows.filter((row): row is UnsupportedRow => row.kind === "unsupported");
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const nextIssues: Record<string, ReadonlyArray<string>> = {};
|
||||
@@ -239,18 +296,29 @@ export const StepInputBindingsForm = ({
|
||||
else bindings.push(result.binding);
|
||||
}
|
||||
setLocalIssues(nextIssues);
|
||||
setFormIssue(unsupportedRows.length > 0
|
||||
? "Remove or repair every unsupported input row before saving."
|
||||
: null);
|
||||
if (Object.keys(nextIssues).length > 0) return;
|
||||
void Promise.resolve(onSubmit(bindings)).catch(() => undefined);
|
||||
};
|
||||
|
||||
const clear = (): void => {
|
||||
if (unsupportedRows.length > 0) {
|
||||
const message = "Remove or repair this unsupported input row before clearing inputs.";
|
||||
setFormIssue(message);
|
||||
markDirty();
|
||||
return;
|
||||
}
|
||||
setLocalIssues({});
|
||||
setFormIssue(null);
|
||||
markDirty();
|
||||
void Promise.resolve(onSubmit([])).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="schema-form authoring-form" noValidate onSubmit={submit}>
|
||||
{formIssue !== null && <p id={formErrorId} role="alert">{formIssue}</p>}
|
||||
<div className="schema-form__group">
|
||||
{rows.length === 0 && <p>No input bindings configured.</p>}
|
||||
{rows.map((row, index) => {
|
||||
@@ -260,6 +328,7 @@ export const StepInputBindingsForm = ({
|
||||
...(rowDiagnostics[row.index] ?? []).map((diagnostic) => diagnostic.message),
|
||||
...(localIssues[row.id] ?? []),
|
||||
];
|
||||
const errorId = `${row.id}-errors`;
|
||||
return (
|
||||
<fieldset aria-label={`Unsupported input row ${rowNumber}`} className="schema-form__group" key={row.id}>
|
||||
<legend>Input row {rowNumber}: unsupported</legend>
|
||||
@@ -271,11 +340,12 @@ export const StepInputBindingsForm = ({
|
||||
</pre>
|
||||
</details>
|
||||
{unsupportedIssues.length > 0 && (
|
||||
<div className="schema-form__diagnostics" role="alert">
|
||||
<div className="schema-form__diagnostics" id={errorId} role="alert">
|
||||
{unsupportedIssues.map((issue) => <p key={issue}>{issue}</p>)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
aria-describedby={unsupportedIssues.length > 0 ? errorId : undefined}
|
||||
aria-label={`Remove unsupported input row ${rowNumber}`}
|
||||
className="schema-form__secondary-action"
|
||||
onClick={() => removeRow(row.id)}
|
||||
@@ -286,91 +356,108 @@ export const StepInputBindingsForm = ({
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
const targetParts = parseTOMLPath(row.target.trim());
|
||||
const field = targetParts === null
|
||||
? null
|
||||
: schemaFieldAtPath(root, targetParts.map((part) => /^\d+$/.test(part) ? Number(part) : part));
|
||||
const field = schemaFieldForTarget(root, row.target.trim());
|
||||
const issues = rowIssueMessages(row, rowDiagnostics, localIssues);
|
||||
const source = field === null ? null : sourceForRow(field, row);
|
||||
const targetId = `${row.id}-target`;
|
||||
const errorId = `${row.id}-errors`;
|
||||
const pathId = `${row.id}-source-path`;
|
||||
const literalId = `${row.id}-literal`;
|
||||
const pathModeId = `${row.id}-path-mode`;
|
||||
const literalModeId = `${row.id}-literal-mode`;
|
||||
const hasIssues = issues.length > 0;
|
||||
const literalSources: FieldSources = field === null
|
||||
? {}
|
||||
: { [formatTOMLPath(field.path)]: { mode: "literal", value: row.value } };
|
||||
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 ? (
|
||||
<label htmlFor={targetId}>Target</label>
|
||||
<input
|
||||
aria-describedby={hasIssues ? errorId : undefined}
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Target for row ${rowNumber}`}
|
||||
id={targetId}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, target: event.target.value }))}
|
||||
type="text"
|
||||
value={row.target}
|
||||
/>
|
||||
<fieldset aria-label={`Source mode for input row ${rowNumber}`} className="schema-form__source">
|
||||
<legend>Value source</legend>
|
||||
<div className="schema-form__source-options">
|
||||
<label htmlFor={pathModeId}>
|
||||
<input
|
||||
aria-label={`Path for input row ${rowNumber}`}
|
||||
checked={row.mode === "path"}
|
||||
id={pathModeId}
|
||||
name={`${row.id}-mode`}
|
||||
onChange={() => editRow(row.id, (current) => ({ ...current, mode: "path" }))}
|
||||
type="radio"
|
||||
/>
|
||||
Path
|
||||
</label>
|
||||
<label htmlFor={literalModeId}>
|
||||
<input
|
||||
aria-label={`Literal value for input row ${rowNumber}`}
|
||||
checked={row.mode === "literal"}
|
||||
id={literalModeId}
|
||||
name={`${row.id}-mode`}
|
||||
onChange={() => editRow(row.id, (current) => ({ ...current, mode: "literal" }))}
|
||||
type="radio"
|
||||
/>
|
||||
Literal value
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
{row.mode === "path" ? (
|
||||
<label htmlFor={pathId}>
|
||||
Source path for input row {rowNumber}
|
||||
<input
|
||||
aria-describedby={hasIssues ? errorId : undefined}
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Source path for input row ${rowNumber}`}
|
||||
id={pathId}
|
||||
onChange={(event) => editRow(row.id, (current) => ({ ...current, sourcePath: event.target.value }))}
|
||||
type="text"
|
||||
value={row.sourcePath}
|
||||
/>
|
||||
</label>
|
||||
) : field !== 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}
|
||||
idPrefix={`${row.id}-schema`}
|
||||
onArrayItemRemove={(arrayField, itemIndex) => editRow(row.id, (current) => ({
|
||||
...current,
|
||||
value: removeAtPath(current.value, [...relativePath(field.path, arrayField.path), itemIndex]),
|
||||
}))}
|
||||
onSourceChange={() => undefined}
|
||||
onValueChange={(changedField, value) => editRow(row.id, (current) => ({
|
||||
...current,
|
||||
value: setAtPath(current.value, relativePath(field.path, changedField.path), value),
|
||||
}))}
|
||||
showSourceControl={false}
|
||||
sources={literalSources}
|
||||
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>
|
||||
</>
|
||||
<label htmlFor={literalId}>
|
||||
Literal JSON value for input row {rowNumber}
|
||||
<textarea
|
||||
aria-describedby={hasIssues ? errorId : undefined}
|
||||
aria-invalid={hasIssues}
|
||||
aria-label={`Literal JSON value for input row ${rowNumber}`}
|
||||
id={literalId}
|
||||
onChange={(event) => editRow(row.id, (current) => ({
|
||||
...current,
|
||||
jsonText: event.target.value,
|
||||
value: event.target.value,
|
||||
}))}
|
||||
value={row.jsonText ?? jsonText(row.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{issues.length > 0 && (
|
||||
<div className="schema-form__diagnostics" role="alert">
|
||||
{hasIssues && (
|
||||
<div className="schema-form__diagnostics" id={errorId} role="alert">
|
||||
{issues.map((issue) => <p key={issue}>{issue}</p>)}
|
||||
</div>
|
||||
)}
|
||||
@@ -411,7 +498,13 @@ export const StepInputBindingsForm = ({
|
||||
</div>
|
||||
<div className="schema-form__source-options">
|
||||
<button type="submit">{submitLabel}</button>
|
||||
<button onClick={clear} type="button">Clear inputs</button>
|
||||
<button
|
||||
aria-describedby={formIssue !== null ? formErrorId : undefined}
|
||||
onClick={clear}
|
||||
type="button"
|
||||
>
|
||||
Clear inputs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { InputPath, LocalInputPath } from "../domain/draft-workspace-models.js";
|
||||
import { formatTOMLPath } from "../schema-form/schema-paths.js";
|
||||
|
||||
/** Display a local target without exposing its transport-only root marker. */
|
||||
export const displayLocalInputPath = (value: LocalInputPath): string =>
|
||||
typeof value === "string" ? value : formatTOMLPath(value.parts);
|
||||
|
||||
/** Display a graph source path while retaining its input/state/context root. */
|
||||
export const displayGraphInputPath = (value: InputPath): string =>
|
||||
typeof value === "string" ? value : formatTOMLPath([value.root, ...value.parts]);
|
||||
@@ -9,6 +9,7 @@ export type BindingSourceControlProps = {
|
||||
readonly literalValue: unknown;
|
||||
readonly onChange: (source: FieldSource) => void;
|
||||
readonly suggestions?: ReadonlyArray<string>;
|
||||
readonly idPrefix?: string;
|
||||
};
|
||||
|
||||
const fieldKey = (field: SchemaField): string =>
|
||||
@@ -23,8 +24,9 @@ export const BindingSourceControl = ({
|
||||
literalValue,
|
||||
onChange,
|
||||
suggestions = EMPTY_SUGGESTIONS,
|
||||
idPrefix,
|
||||
}: BindingSourceControlProps) => {
|
||||
const key = fieldKey(field);
|
||||
const key = idPrefix === undefined ? fieldKey(field) : `${idPrefix}-${fieldKey(field)}`;
|
||||
const literalId = `${key}-literal`;
|
||||
const bindId = `${key}-bind`;
|
||||
const sourceId = `${key}-source-path`;
|
||||
|
||||
@@ -18,6 +18,8 @@ export type SchemaFieldControlProps = {
|
||||
readonly onSourceChange: (field: SchemaField, source: FieldSource) => void;
|
||||
readonly onArrayItemRemove: (field: SchemaField, index: number) => void;
|
||||
readonly sourceSuggestions?: ReadonlyArray<string>;
|
||||
readonly showSourceControl?: boolean;
|
||||
readonly idPrefix?: string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -31,8 +33,8 @@ const samePath = (
|
||||
right: ReadonlyArray<string | number>,
|
||||
): boolean => left.length === right.length && left.every((part, index) => part === right[index]);
|
||||
|
||||
const fieldId = (field: SchemaField): string =>
|
||||
`schema-field-${encodeSchemaPath(field.path)}`;
|
||||
const fieldId = (field: SchemaField, idPrefix: string): string =>
|
||||
`${idPrefix}-${encodeSchemaPath(field.path)}`;
|
||||
|
||||
const displayTitle = (title: string): string =>
|
||||
title.length === 0 ? "Value" : `${title.slice(0, 1).toUpperCase()}${title.slice(1)}`;
|
||||
@@ -63,13 +65,15 @@ const defaultArrayItemValue = (field: SchemaField): unknown => {
|
||||
const FieldDiagnostics = ({
|
||||
field,
|
||||
diagnostics,
|
||||
idPrefix,
|
||||
}: {
|
||||
readonly field: SchemaField;
|
||||
readonly diagnostics: ReadonlyArray<SchemaValueIssue>;
|
||||
readonly idPrefix: string;
|
||||
}) => {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return (
|
||||
<div className="schema-form__diagnostics" id={`${fieldId(field)}-diagnostics`} role="alert">
|
||||
<div className="schema-form__diagnostics" id={`${fieldId(field, idPrefix)}-diagnostics`} role="alert">
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<p key={`${formatTOMLPath(diagnostic.path)}-${diagnostic.message}`}>
|
||||
{diagnostic.message}
|
||||
@@ -110,14 +114,16 @@ const LeafControl = ({
|
||||
onValueChange,
|
||||
describedBy,
|
||||
invalid,
|
||||
idPrefix,
|
||||
}: {
|
||||
readonly field: SchemaField;
|
||||
readonly value: unknown;
|
||||
readonly onValueChange: (value: unknown) => void;
|
||||
readonly describedBy: string | undefined;
|
||||
readonly invalid: boolean;
|
||||
readonly idPrefix: string;
|
||||
}) => {
|
||||
const id = fieldId(field);
|
||||
const id = fieldId(field, idPrefix);
|
||||
const label = displayTitle(field.title);
|
||||
const common = {
|
||||
"aria-describedby": describedBy,
|
||||
@@ -218,8 +224,10 @@ export const SchemaFieldControl = ({
|
||||
onSourceChange,
|
||||
onArrayItemRemove,
|
||||
sourceSuggestions = EMPTY_SUGGESTIONS,
|
||||
showSourceControl = true,
|
||||
idPrefix = "schema-field",
|
||||
}: SchemaFieldControlProps) => {
|
||||
const id = fieldId(field);
|
||||
const id = fieldId(field, idPrefix);
|
||||
const descriptionId = field.description ? `${id}-description` : undefined;
|
||||
const ownDiagnostics = diagnostics.filter((diagnostic) => samePath(diagnostic.path, field.path));
|
||||
const diagnosticsId = ownDiagnostics.length > 0 ? `${id}-diagnostics` : undefined;
|
||||
@@ -245,12 +253,15 @@ export const SchemaFieldControl = ({
|
||||
onValueChange={onValueChange}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
sources={sources}
|
||||
showSourceControl={showSourceControl}
|
||||
idPrefix={idPrefix}
|
||||
value={objectValue[child.key]}
|
||||
/>
|
||||
))}
|
||||
<FieldDiagnostics
|
||||
diagnostics={ownDiagnostics}
|
||||
field={field}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
@@ -280,6 +291,8 @@ export const SchemaFieldControl = ({
|
||||
onValueChange={onValueChange}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
sources={sources}
|
||||
showSourceControl={showSourceControl}
|
||||
idPrefix={idPrefix}
|
||||
value={itemValue}
|
||||
/>
|
||||
<button
|
||||
@@ -303,28 +316,35 @@ export const SchemaFieldControl = ({
|
||||
<FieldDiagnostics
|
||||
diagnostics={ownDiagnostics}
|
||||
field={field}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
const source = sources[pathKey(field)] ?? { mode: "literal", value };
|
||||
const source: FieldSource = showSourceControl
|
||||
? sources[pathKey(field)] ?? { mode: "literal", value }
|
||||
: { mode: "literal", value };
|
||||
return (
|
||||
<div className="schema-form__field">
|
||||
<FieldLabel field={field} id={id} />
|
||||
{field.description && <p id={descriptionId}>{field.description}</p>}
|
||||
<BindingSourceControl
|
||||
field={field}
|
||||
literalValue={value}
|
||||
onChange={(nextSource) => onSourceChange(field, nextSource)}
|
||||
source={source}
|
||||
suggestions={sourceSuggestions}
|
||||
/>
|
||||
{showSourceControl && (
|
||||
<BindingSourceControl
|
||||
field={field}
|
||||
idPrefix={idPrefix}
|
||||
literalValue={value}
|
||||
onChange={(nextSource) => onSourceChange(field, nextSource)}
|
||||
source={source}
|
||||
suggestions={sourceSuggestions}
|
||||
/>
|
||||
)}
|
||||
{source.mode === "literal" && (
|
||||
<LeafControl
|
||||
describedBy={describedBy}
|
||||
field={field}
|
||||
invalid={ownDiagnostics.length > 0}
|
||||
idPrefix={idPrefix}
|
||||
onValueChange={(nextValue) => onValueChange(field, nextValue)}
|
||||
value={value}
|
||||
/>
|
||||
@@ -334,7 +354,7 @@ export const SchemaFieldControl = ({
|
||||
{field.fallbackReason}
|
||||
</p>
|
||||
)}
|
||||
<FieldDiagnostics diagnostics={ownDiagnostics} field={field} />
|
||||
<FieldDiagnostics diagnostics={ownDiagnostics} field={field} idPrefix={idPrefix} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user