fix: harden capability input forms

This commit is contained in:
lda
2026-08-09 23:59:13 +07:00 Verified
parent 8f64277754
commit d0e9c0cabc
9 changed files with 463 additions and 150 deletions
@@ -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>
);
};