fix: repair composite input editor task 7

This commit is contained in:
lda
2026-08-14 02:11:17 +07:00 Verified
parent e01715e20c
commit 01b33c8cf3
7 changed files with 443 additions and 66 deletions
@@ -1,5 +1,6 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { normalizeSchema } from "../schema-form/schema-field.js";
import type { ExpressionEditorState } from "./input-expression-editor.js";
@@ -77,6 +78,108 @@ describe("InputExpressionControl", () => {
expect(screen.queryByRole("group", { name: "payload.count" })).toBeNull();
});
it("restores a missing required declared property without removing declared fields", async () => {
const user = userEvent.setup();
let state: ExpressionEditorState = { kind: "object", fields: [] };
const renderControl = (): void => {
render(
<InputExpressionControl
field={normalizeSchema({
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
})}
label="payload"
onChange={(next) => {
state = next;
cleanup();
renderControl();
}}
state={state}
/>,
);
};
renderControl();
expect(screen.getByRole("button", { name: "Add required property name to payload" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add required property name to payload" }));
expect(state).toMatchObject({ kind: "object", fields: [{ name: "name" }] });
expect(screen.getByRole("group", { name: "payload.name" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Remove payload.name" })).toBeNull();
});
it("keeps nested additional-name state with its logical array item after reorder", async () => {
const user = userEvent.setup();
const Harness = () => {
const [state, setState] = useState<ExpressionEditorState>({
kind: "array",
items: [
{ kind: "object", fields: [] },
{ kind: "object", fields: [] },
],
});
return (
<InputExpressionControl
field={normalizeSchema({
type: "array",
items: { type: "object", additionalProperties: true },
})}
label="items"
onChange={setState}
state={state}
/>
);
};
render(<Harness />);
const firstName = screen.getByRole("textbox", { name: "Additional property name for items item 1" });
await user.type(firstName, "kept");
await user.click(screen.getByRole("button", { name: "Move items item 2 up" }));
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("");
expect(screen.getByRole("textbox", { name: "Additional property name for items item 2" })).toHaveValue("kept");
});
it("gives repeated labels unique datalist and typed-leaf control ids", () => {
const field = normalizeSchema({ type: "string" });
render(
<>
<InputExpressionControl
field={field}
label="value"
onChange={() => undefined}
state={{ kind: "path", path: "state.first", touched: false }}
/>
<InputExpressionControl
field={field}
label="value"
onChange={() => undefined}
state={{ kind: "path", path: "state.second", touched: false }}
/>
<InputExpressionControl
field={field}
label="value"
onChange={() => undefined}
state={{ kind: "literal", value: "first", touched: false }}
/>
<InputExpressionControl
field={field}
label="value"
onChange={() => undefined}
state={{ kind: "literal", value: "second", touched: false }}
/>
</>,
);
const datalistIds = [...document.querySelectorAll("datalist")].map((element) => element.id);
const controlIds = [...document.querySelectorAll("textarea")].map((element) => element.id);
expect(datalistIds).toHaveLength(2);
expect(new Set(datalistIds).size).toBe(datalistIds.length);
expect(controlIds).toHaveLength(2);
expect(new Set(controlIds).size).toBe(controlIds.length);
});
it("exposes deferred path guidance and keeps construct unavailable for scalar schemas", async () => {
const user = userEvent.setup();
let state: ExpressionEditorState = { kind: "path", path: "context.profile", touched: false };
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useId, useRef, useState } from "react";
import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js";
import { rebaseSchemaField, type SchemaField } from "../schema-form/schema-field.js";
import type { FieldSources } from "../schema-form/schema-values.js";
import type { ExpressionEditorState } from "./input-expression-editor.js";
import {
defaultExpressionEditorState,
type ExpressionEditorState,
} from "./input-expression-editor.js";
export type InputExpressionControlProps = {
readonly field: SchemaField | null;
@@ -16,30 +19,6 @@ export type InputExpressionControlProps = {
const isConstructField = (field: SchemaField | null): boolean =>
field?.kind === "array" || field?.kind === "object";
const defaultLiteralValue = (field: SchemaField | null): unknown => {
if (field?.hasDefault) return field.defaultValue;
if (field?.kind === "boolean") return false;
if (field?.kind === "number" || field?.kind === "integer") return 0;
if (field?.kind === "enum") return field.enumValues[0] ?? null;
return "";
};
export const defaultExpressionEditorState = (
field: SchemaField | null,
): ExpressionEditorState => {
if (field?.kind === "array") return { kind: "array", items: [] };
if (field?.kind === "object") {
return {
kind: "object",
fields: field.children.map((child) => ({
name: child.key,
value: defaultExpressionEditorState(child),
})),
};
}
return { kind: "literal", value: defaultLiteralValue(field), touched: false };
};
const valueSourceFor = (state: ExpressionEditorState): "path" | "literal" | "construct" =>
state.kind === "array" || state.kind === "object" ? "construct" : state.kind;
@@ -56,7 +35,14 @@ const stateForSource = (
if (source === "literal") {
return current.kind === "literal"
? current
: { kind: "literal", value: defaultLiteralValue(field), touched: true };
: (() => {
const defaultState = defaultExpressionEditorState(field);
return {
kind: "literal" as const,
value: defaultState.kind === "literal" ? defaultState.value : null,
touched: true,
};
})();
}
return isConstructField(field) ? defaultExpressionEditorState(field) : current;
};
@@ -67,6 +53,20 @@ const fieldForName = (field: SchemaField | null, name: string): SchemaField | nu
(field.additionalPropertiesKind === "schema" ? field.additionalProperty : null);
};
const missingRequiredProperties = (
field: SchemaField | null,
fields: ReadonlyArray<{ readonly name: string }>,
): ReadonlyArray<SchemaField> => {
if (field?.kind !== "object") return [];
const presentNames = new Set<string>();
for (const entry of fields) presentNames.add(entry.name);
const missing: SchemaField[] = [];
for (const child of field.children) {
if (child.required && !presentNames.has(child.key)) missing.push(child);
}
return missing;
};
const labelForName = (label: string, name: string): string =>
`${label} field ${name}`;
@@ -75,33 +75,41 @@ const pathNeedsDeferredValidation = (field: SchemaField | null, path: string): b
field === null ||
field.fallbackReason === "The schema is unconstrained; edit JSON directly.";
const safeIdSuffix = (value: string): string =>
value.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
const EMPTY_FIELD_SOURCES: FieldSources = {};
const InputExpressionLeaf = ({
field,
label,
onChange,
sourceSuggestions,
state,
idPrefix,
}: {
readonly field: SchemaField | null;
readonly idPrefix: string;
readonly label: string;
readonly onChange: (state: ExpressionEditorState) => void;
readonly sourceSuggestions: ReadonlyArray<string>;
readonly state: ExpressionEditorState;
}) => {
if (state.kind === "path") {
const pathListId = `${idPrefix}-paths`;
return (
<div className="input-expression-control__leaf">
<label>
Path for {label}
<input
aria-label={`Path for ${label}`}
list={`${label.replaceAll(/[^a-zA-Z0-9]+/g, "-")}-paths`}
list={pathListId}
onChange={(event) => onChange({ ...state, path: event.target.value, touched: true })}
type="text"
value={state.path}
/>
</label>
<datalist id={`${label.replaceAll(/[^a-zA-Z0-9]+/g, "-")}-paths`}>
<datalist id={pathListId}>
{sourceSuggestions.map((suggestion) => <option key={suggestion} value={suggestion} />)}
</datalist>
{pathNeedsDeferredValidation(field, state.path) && (
@@ -136,7 +144,6 @@ const InputExpressionLeaf = ({
);
}
const sources: FieldSources = {};
return (
<div className="input-expression-control__leaf">
<SchemaFieldControl
@@ -146,7 +153,8 @@ const InputExpressionLeaf = ({
onSourceChange={() => undefined}
onValueChange={(_, value) => onChange({ ...literalState, value, touched: true })}
showSourceControl={false}
sources={sources}
idPrefix={`${idPrefix}-leaf`}
sources={EMPTY_FIELD_SOURCES}
value={literalState.value}
/>
</div>
@@ -161,7 +169,14 @@ export const InputExpressionControl = ({
state,
showModeControl = true,
}: InputExpressionControlProps) => {
const controlId = `input-expression-${safeIdSuffix(useId())}`;
const nextItemId = useRef(state.kind === "array" ? state.items.length : 0);
const [additionalName, setAdditionalName] = useState("");
const [arrayItemIds, setArrayItemIds] = useState<ReadonlyArray<string>>(() =>
state.kind === "array"
? state.items.map((_, index) => `${controlId}-item-${index}`)
: [],
);
const source = valueSourceFor(state);
const selectSource = (next: "path" | "literal" | "construct"): void =>
onChange(stateForSource(next, field, state));
@@ -175,11 +190,12 @@ export const InputExpressionControl = ({
? rebaseSchemaField(field.item, [...field.path, index])
: null;
const itemLabel = `${label} item ${index + 1}`;
const itemId = arrayItemIds[index]!;
return (
<fieldset
aria-label={itemLabel}
className="input-expression-control__item"
key={`${itemLabel}-${index}`}
key={itemId}
>
<InputExpressionControl
field={itemField}
@@ -196,7 +212,15 @@ export const InputExpressionControl = ({
aria-label={`Move ${itemLabel} up`}
className="schema-form__secondary-action"
disabled={index === 0}
onClick={() => onChange({
onClick={() => {
setArrayItemIds((current) => current.map((candidate, candidateIndex) =>
candidateIndex === index - 1
? current[index]!
: candidateIndex === index
? current[index - 1]!
: candidate,
));
onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) =>
candidateIndex === index - 1
@@ -205,7 +229,8 @@ export const InputExpressionControl = ({
? state.items[index - 1]!
: candidate,
),
})}
});
}}
type="button"
>
Move up
@@ -214,7 +239,15 @@ export const InputExpressionControl = ({
aria-label={`Move ${itemLabel} down`}
className="schema-form__secondary-action"
disabled={index === state.items.length - 1}
onClick={() => onChange({
onClick={() => {
setArrayItemIds((current) => current.map((candidate, candidateIndex) =>
candidateIndex === index
? current[index + 1]!
: candidateIndex === index + 1
? current[index]!
: candidate,
));
onChange({
kind: "array",
items: state.items.map((candidate, candidateIndex) =>
candidateIndex === index
@@ -223,7 +256,8 @@ export const InputExpressionControl = ({
? state.items[index]!
: candidate,
),
})}
});
}}
type="button"
>
Move down
@@ -231,7 +265,10 @@ export const InputExpressionControl = ({
<button
aria-label={`Remove ${itemLabel}`}
className="schema-form__secondary-action"
onClick={() => onChange({ kind: "array", items: state.items.filter((_, itemIndex) => itemIndex !== index) })}
onClick={() => {
setArrayItemIds((current) => current.filter((_, itemIndex) => itemIndex !== index));
onChange({ kind: "array", items: state.items.filter((_, itemIndex) => itemIndex !== index) });
}}
type="button"
>
Remove
@@ -242,7 +279,12 @@ export const InputExpressionControl = ({
})}
<button
className="schema-form__secondary-action"
onClick={() => onChange({ kind: "array", items: [...state.items, defaultExpressionEditorState(field?.item ?? null)] })}
onClick={() => {
const itemId = `${controlId}-item-${nextItemId.current}`;
nextItemId.current += 1;
setArrayItemIds((current) => [...current, itemId]);
onChange({ kind: "array", items: [...state.items, defaultExpressionEditorState(field?.item ?? null)] });
}}
type="button"
>
Add item to {label}
@@ -294,6 +336,23 @@ export const InputExpressionControl = ({
</fieldset>
);
})}
{missingRequiredProperties(field, state.fields).map((child) => (
<button
aria-label={`Add required property ${child.key} to ${label}`}
className="schema-form__secondary-action"
key={`required-${child.key}`}
onClick={() => onChange({
kind: "object",
fields: [...state.fields, {
name: child.key,
value: defaultExpressionEditorState(child),
}],
})}
type="button"
>
Add required property {child.key}
</button>
))}
{(field?.additionalPropertiesKind === "allowed" || field?.additionalPropertiesKind === "schema") && (
<div className="input-expression-control__additional">
<label>
@@ -330,6 +389,7 @@ export const InputExpressionControl = ({
) : (
<InputExpressionLeaf
field={field}
idPrefix={`${controlId}-leaf`}
label={label}
onChange={onChange}
sourceSuggestions={sourceSuggestions}
@@ -271,6 +271,61 @@ describe("SelectedCapabilityInspector", () => {
}
});
it("rehydrates the selected inspector from a returned canonical mutation", async () => {
const user = userEvent.setup();
const initial = draft("read", [{
target: "items",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "before" },
],
},
}], []);
const returned = draft("read", [{
target: "items",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.bar" },
{ kind: "literal", value: "after" },
],
},
}], []);
const controller = { ...controllerFor(initial), resetGeneration: 0 };
const { rerender } = render(
<SelectedCapabilityInspector
capabilityDetail={compositeDetail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={controller}
draft={initial}
nodeKind="use"
nodeRef="demo.concat"
stepId="read"
/>,
);
await user.click(screen.getByRole("tab", { name: "Inputs" }));
await user.click(screen.getByRole("button", { name: "Save inputs" }));
const returnedController = { ...controller, draft: returned, resetGeneration: 1 };
rerender(
<SelectedCapabilityInspector
capabilityDetail={compositeDetail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={returnedController}
draft={returned}
nodeKind="use"
nodeRef="demo.concat"
stepId="read"
/>,
);
expect(screen.getByRole("combobox", { name: "Path for items item 1" })).toHaveValue("state.bar");
expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("after");
});
it("keeps diagnostic ids unique across failing setup and hidden binding forms", async () => {
const user = userEvent.setup();
const workspace = draft(
@@ -199,7 +199,7 @@ export const SelectedCapabilityInspector = ({
</TabPanel>
<TabPanel activeTab={activeTab} tab="inputs">
<StepInputBindingsForm
key={`inputs:${stepId}:${controller.resetGeneration}`}
canonicalVersion={`${stepId}:${controller.resetGeneration}`}
initialRows={inputRows}
inputSchema={capabilityDetail.inputSchema}
workflowInputSchema={isRecord(draft.draft) ? draft.draft.input_schema : undefined}
@@ -337,6 +337,103 @@ describe("StepInputBindingsForm", () => {
)).toHaveLength(2);
});
it("recomputes duplicate-target errors after the edited target is repaired", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
initialRows={[
{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } },
{ kind: "canonical", index: 1, value: { path: "input.title", target: "nullable" } },
]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
const secondTarget = screen.getByRole("combobox", { name: "Target for row 2" });
await user.clear(secondTarget);
await user.type(secondTarget, "title");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([]);
await user.clear(secondTarget);
await user.type(secondTarget, "nullable");
expect(screen.getByRole("button", { name: "Save inputs" })).not.toBeDisabled();
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toHaveLength(1);
});
it("keeps backend diagnostics visible as provenance while allowing a dirty row to be repaired", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
initialRows={[{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } }]}
onSubmit={(value) => { submissions.push(value); }}
rowDiagnostics={{
0: [{ code: "invalid", path: "bindings[0].path", message: "Backend rejected this source path.", stepId: "read", repairHint: null, details: {} }],
}}
/>,
);
expect(screen.getByRole("button", { name: "Save inputs" })).toBeDisabled();
const source = screen.getByRole("combobox", { name: "Source path for input row 1" });
await user.clear(source);
await user.type(source, "context.profile");
expect(screen.getByText("Backend rejected this source path.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save inputs" })).not.toBeDisabled();
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([[{ path: "context.profile", target: "title" }]]);
});
it("rehydrates only when the canonical version changes, preserving dirty edits otherwise", async () => {
const user = userEvent.setup();
const { rerender } = render(
<StepInputBindingsForm
canonicalVersion={1}
inputSchema={schema}
initialRows={[{ kind: "canonical", index: 0, value: { path: "input.title", target: "title" } }]}
onSubmit={() => undefined}
/>,
);
const target = screen.getByRole("combobox", { name: "Target for row 1" });
rerender(
<StepInputBindingsForm
canonicalVersion={1}
inputSchema={schema}
initialRows={[{ kind: "canonical", index: 0, value: { path: "input.title", target: "nullable" } }]}
onSubmit={() => undefined}
/>,
);
expect(target).toHaveValue("title");
await user.clear(target);
await user.type(target, "edited");
rerender(
<StepInputBindingsForm
canonicalVersion={1}
inputSchema={schema}
initialRows={[{ kind: "canonical", index: 0, value: { path: "input.title", target: "nullable" } }]}
onSubmit={() => undefined}
/>,
);
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("edited");
rerender(
<StepInputBindingsForm
canonicalVersion={2}
inputSchema={schema}
initialRows={[{ kind: "canonical", index: 0, value: { path: "input.title", target: "nullable" } }]}
onSubmit={() => undefined}
/>,
);
expect(screen.getByRole("combobox", { name: "Target for row 1" })).toHaveValue("nullable");
});
it("shows row diagnostics at the row that owns them", () => {
render(
<StepInputBindingsForm
@@ -3,11 +3,12 @@ import type {
DraftDiagnostic,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import { InputExpressionControl, defaultExpressionEditorState } from "./InputExpressionControl.js";
import { InputExpressionControl } from "./InputExpressionControl.js";
import {
projectExpressionEditorState,
serializeExpressionEditorState,
validateExpressionEditorState,
defaultExpressionEditorState,
type ExpressionEditorState,
} from "./input-expression-editor.js";
import { SchemaFieldControl } from "../schema-form/SchemaFieldControl.js";
@@ -52,6 +53,8 @@ export type StepInputBindingsFormProps = {
readonly inputSchema: unknown;
readonly workflowInputSchema?: unknown;
readonly workflowStateSchema?: unknown;
/** Changes only when the parent has accepted a new canonical draft. */
readonly canonicalVersion?: string | number | null;
readonly initialRows?: ReadonlyArray<StepInputBindingRow>;
readonly initialBindings?: ReadonlyArray<StepInputBinding>;
readonly rowDiagnostics?: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
@@ -193,10 +196,11 @@ const relativePath = (
const rowIssueMessages = (
row: EditableRow,
rowDiagnostics: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>,
localIssues: Readonly<Record<string, ReadonlyArray<string>>>,
editedRowIds: ReadonlySet<string>,
): ReadonlyArray<string> => [
...(rowDiagnostics[row.rawIndex] ?? []).map((diagnostic) => diagnostic.message),
...(localIssues[row.id] ?? []),
...(editedRowIds.has(row.id)
? []
: (rowDiagnostics[row.rawIndex] ?? []).map((diagnostic) => diagnostic.message)),
];
const schemaFieldForTarget = (root: SchemaField, target: string): SchemaField | null => {
@@ -267,7 +271,28 @@ const bindingForRow = (
: { binding, issues: [] };
};
export const StepInputBindingsForm = ({
const duplicateIssuesForRows = (
rows: ReadonlyArray<FormRow>,
root: SchemaField,
): ReadonlyMap<string, ReadonlyArray<string>> => {
const rowsByTarget = new Map<string, string[]>();
for (const row of rows) {
if (row.kind !== "canonical") continue;
const result = bindingForRow(root, row);
if (result.binding === null) continue;
const target = displayLocalInputPath(result.binding.target);
rowsByTarget.set(target, [...(rowsByTarget.get(target) ?? []), row.id]);
}
const issues = new Map<string, ReadonlyArray<string>>();
for (const ids of rowsByTarget.values()) {
if (ids.length < 2) continue;
for (const id of ids) issues.set(id, ["Target is duplicated in another input row."]);
}
return issues;
};
const StepInputBindingsFormContent = ({
canonicalVersion = null,
inputSchema,
workflowInputSchema,
workflowStateSchema,
@@ -287,7 +312,7 @@ export const StepInputBindingsForm = ({
const [rows, setRows] = useState<ReadonlyArray<FormRow>>(() =>
rowsFrom(inputRows(initialRows, initialBindings), formId, root),
);
const [localIssues, setLocalIssues] = useState<Readonly<Record<string, ReadonlyArray<string>>>>({});
const [editedRowIds, setEditedRowIds] = useState<ReadonlySet<string>>(new Set());
const [formIssue, setFormIssue] = useState<string | null>(null);
const nextId = useRef(rows.length);
const formErrorId = `${formId}-form-error`;
@@ -296,6 +321,8 @@ export const StepInputBindingsForm = ({
const editRow = (id: string, update: (row: EditableRow) => EditableRow): void => {
setRows((current) => updateRow(current, id, update));
setEditedRowIds((current) => new Set(current).add(id));
setFormIssue(null);
markDirty();
};
@@ -316,9 +343,9 @@ export const StepInputBindingsForm = ({
const removeRow = (id: string): void => {
setRows((current) => current.filter((row) => row.id !== id));
setLocalIssues((current) => {
const next = { ...current };
delete next[id];
setEditedRowIds((current) => {
const next = new Set(current);
next.delete(id);
return next;
});
setFormIssue(null);
@@ -345,9 +372,11 @@ export const StepInputBindingsForm = ({
};
const unsupportedRows = rows.filter((row): row is UnsupportedRow => row.kind === "unsupported");
const duplicateIssues = duplicateIssuesForRows(rows, root);
const hasBlockingIssues = rows.some((row) => {
if (row.kind === "unsupported") return true;
return rowIssueMessages(row, rowDiagnostics, localIssues).length > 0 ||
return rowIssueMessages(row, rowDiagnostics, editedRowIds).length > 0 ||
(duplicateIssues.get(row.id)?.length ?? 0) > 0 ||
bindingForRow(root, row).issues.length > 0;
});
@@ -364,21 +393,9 @@ export const StepInputBindingsForm = ({
if (result.binding === null) nextIssues[row.id] = result.issues;
else completed.push({ id: row.id, binding: result.binding });
}
const duplicateRows = new Map<string, string[]>();
for (const item of completed) {
const target = displayLocalInputPath(item.binding.target);
duplicateRows.set(target, [...(duplicateRows.get(target) ?? []), item.id]);
for (const [id, issues] of duplicateIssuesForRows(rows, root)) {
nextIssues[id] = [...(nextIssues[id] ?? []), ...issues];
}
for (const ids of duplicateRows.values()) {
if (ids.length < 2) continue;
for (const id of ids) {
nextIssues[id] = [
...(nextIssues[id] ?? []),
"Target is duplicated in another input row.",
];
}
}
setLocalIssues(nextIssues);
setFormIssue(unsupportedRows.length > 0
? "Remove or repair every unsupported input row before saving."
: null);
@@ -394,7 +411,6 @@ export const StepInputBindingsForm = ({
markDirty();
return;
}
setLocalIssues({});
setFormIssue(null);
markDirty();
void Promise.resolve(onSubmit([]))
@@ -417,7 +433,6 @@ export const StepInputBindingsForm = ({
if (row.kind === "unsupported") {
const unsupportedIssues = [
...(rowDiagnostics[row.index] ?? []).map((diagnostic) => diagnostic.message),
...(localIssues[row.id] ?? []),
];
const errorId = `${row.id}-errors`;
return (
@@ -448,8 +463,10 @@ export const StepInputBindingsForm = ({
);
}
const field = schemaFieldForTarget(root, row.target.trim());
const backendIssues = (rowDiagnostics[row.rawIndex] ?? []).map((diagnostic) => diagnostic.message);
const issues = [...new Set([
...rowIssueMessages(row, rowDiagnostics, localIssues),
...rowIssueMessages(row, rowDiagnostics, editedRowIds),
...(duplicateIssues.get(row.id) ?? []),
...bindingForRow(root, row).issues,
])];
const targetId = `${row.id}-target`;
@@ -539,6 +556,7 @@ export const StepInputBindingsForm = ({
) : row.mode === "expression" ? (
<InputExpressionControl
field={field}
key={`${row.id}:${canonicalVersion ?? "initial"}`}
label={row.target.trim() || `input row ${rowNumber}`}
onChange={(next) => editRow(row.id, (current) => ({
...current,
@@ -588,6 +606,15 @@ export const StepInputBindingsForm = ({
{issues.map((issue) => <p key={issue}>{issue}</p>)}
</div>
)}
{editedRowIds.has(row.id) && backendIssues.length > 0 && (
<div
aria-label={`Backend diagnostics for input row ${rowNumber}`}
className="schema-form__diagnostics schema-form__diagnostics--provenance"
role="status"
>
{backendIssues.map((issue) => <p key={issue}>{issue}</p>)}
</div>
)}
<div className="schema-form__source-options">
<button
aria-label={`Move input row ${rowNumber} up`}
@@ -636,3 +663,14 @@ export const StepInputBindingsForm = ({
</form>
);
};
/**
* Remount only when the parent publishes a new canonical revision. Ordinary
* rerenders keep the editor's dirty rows and local validation state intact.
*/
export const StepInputBindingsForm = (props: StepInputBindingsFormProps) => {
const version = props.canonicalVersion === null || props.canonicalVersion === undefined
? "initial"
: String(props.canonicalVersion);
return <StepInputBindingsFormContent key={version} {...props} />;
};
@@ -20,6 +20,30 @@ export type ExpressionEditorState =
| { readonly kind: "array"; readonly items: ReadonlyArray<ExpressionEditorState> }
| { readonly kind: "object"; readonly fields: ReadonlyArray<{ readonly name: string; readonly value: ExpressionEditorState }> };
const defaultLiteralValue = (field: SchemaField | null): unknown => {
if (field?.hasDefault) return field.defaultValue;
if (field?.kind === "boolean") return false;
if (field?.kind === "number" || field?.kind === "integer") return 0;
if (field?.kind === "enum") return field.enumValues[0] ?? null;
return "";
};
export const defaultExpressionEditorState = (
field: SchemaField | null,
): ExpressionEditorState => {
if (field?.kind === "array") return { kind: "array", items: [] };
if (field?.kind === "object") {
return {
kind: "object",
fields: field.children.map((child) => ({
name: child.key,
value: defaultExpressionEditorState(child),
})),
};
}
return { kind: "literal", value: defaultLiteralValue(field), touched: false };
};
export type ExpressionProjection =
| { readonly kind: "editable"; readonly state: ExpressionEditorState }
| { readonly kind: "unsupported"; readonly raw: InputExpression; readonly reason: string };