fix: repair composite input editor task 7
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user