fix: reconcile controlled composite input identities
This commit is contained in:
@@ -141,6 +141,130 @@ describe("InputExpressionControl", () => {
|
||||
expect(screen.getByRole("textbox", { name: "Additional property name for items item 2" })).toHaveValue("kept");
|
||||
});
|
||||
|
||||
it("follows logical item identity across an external remove", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = { kind: "object", fields: [] } satisfies ExpressionEditorState;
|
||||
const second = { kind: "object", fields: [] } satisfies ExpressionEditorState;
|
||||
const Harness = () => {
|
||||
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [first, second] });
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setState({ kind: "array", items: state.kind === "array" ? state.items.slice(1) : [] })} type="button">
|
||||
External remove first
|
||||
</button>
|
||||
<InputExpressionControl
|
||||
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
|
||||
label="items"
|
||||
onChange={setState}
|
||||
state={state}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Harness />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Additional property name for items item 2" }), "second-local");
|
||||
await user.click(screen.getByRole("button", { name: "External remove first" }));
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("second-local");
|
||||
});
|
||||
|
||||
it("follows logical item identity across an external reorder", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = { kind: "object", fields: [] } satisfies ExpressionEditorState;
|
||||
const second = { kind: "object", fields: [] } satisfies ExpressionEditorState;
|
||||
const Harness = () => {
|
||||
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [first, second] });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setState({ kind: "array", items: state.kind === "array" ? [...state.items].reverse() : [] })}
|
||||
type="button"
|
||||
>
|
||||
External reorder
|
||||
</button>
|
||||
<InputExpressionControl
|
||||
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
|
||||
label="items"
|
||||
onChange={setState}
|
||||
state={state}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Harness />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Additional property name for items item 1" }), "first-local");
|
||||
await user.type(screen.getByRole("textbox", { name: "Additional property name for items item 2" }), "second-local");
|
||||
await user.click(screen.getByRole("button", { name: "External reorder" }));
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("second-local");
|
||||
expect(screen.getByRole("textbox", { name: "Additional property name for items item 2" })).toHaveValue("first-local");
|
||||
});
|
||||
|
||||
it("allocates an identity for an externally added item without a missing key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = { kind: "object", fields: [] } satisfies ExpressionEditorState;
|
||||
const Harness = () => {
|
||||
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [first] });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setState({
|
||||
kind: "array",
|
||||
items: state.kind === "array" ? [...state.items, { kind: "object", fields: [] }] : [],
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
External add
|
||||
</button>
|
||||
<InputExpressionControl
|
||||
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
|
||||
label="items"
|
||||
onChange={setState}
|
||||
state={state}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Harness />);
|
||||
await user.click(screen.getByRole("button", { name: "External add" }));
|
||||
|
||||
expect(screen.getAllByRole("group", { name: "items item 2" })).not.toHaveLength(0);
|
||||
expect(screen.getAllByRole("textbox", { name: /Additional property name for items item/ })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not carry local state into a reconstructed semantically equal item", async () => {
|
||||
const user = userEvent.setup();
|
||||
const Harness = () => {
|
||||
const [state, setState] = useState<ExpressionEditorState>({ kind: "array", items: [{ kind: "object", fields: [] }] });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setState({ kind: "array", items: [{ kind: "object", fields: [] }] })}
|
||||
type="button"
|
||||
>
|
||||
External replace with equal value
|
||||
</button>
|
||||
<InputExpressionControl
|
||||
field={normalizeSchema({ type: "array", items: { type: "object", additionalProperties: true } })}
|
||||
label="items"
|
||||
onChange={setState}
|
||||
state={state}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Harness />);
|
||||
const name = screen.getByRole("textbox", { name: "Additional property name for items item 1" });
|
||||
await user.type(name, "stale-local");
|
||||
await user.click(screen.getByRole("button", { name: "External replace with equal value" }));
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Additional property name for items item 1" })).toHaveValue("");
|
||||
});
|
||||
|
||||
it("gives repeated labels unique datalist and typed-leaf control ids", () => {
|
||||
const field = normalizeSchema({ type: "string" });
|
||||
render(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useId, 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";
|
||||
@@ -170,13 +170,25 @@ export const InputExpressionControl = ({
|
||||
showModeControl = true,
|
||||
}: InputExpressionControlProps) => {
|
||||
const controlId = `input-expression-${safeIdSuffix(useId())}`;
|
||||
const nextItemId = useRef(state.kind === "array" ? state.items.length : 0);
|
||||
const [itemIdentity] = useState(() => ({
|
||||
byState: new WeakMap<ExpressionEditorState, string>(),
|
||||
nextId: 0,
|
||||
}));
|
||||
const [additionalName, setAdditionalName] = useState("");
|
||||
const [arrayItemIds, setArrayItemIds] = useState<ReadonlyArray<string>>(() =>
|
||||
state.kind === "array"
|
||||
? state.items.map((_, index) => `${controlId}-item-${index}`)
|
||||
: [],
|
||||
);
|
||||
const identityForItem = (item: ExpressionEditorState): string => {
|
||||
const existing = itemIdentity.byState.get(item);
|
||||
if (existing !== undefined) return existing;
|
||||
const id = `${controlId}-item-${itemIdentity.nextId}`;
|
||||
itemIdentity.nextId += 1;
|
||||
itemIdentity.byState.set(item, id);
|
||||
return id;
|
||||
};
|
||||
const arrayItemIds = state.kind === "array"
|
||||
? state.items.map(identityForItem)
|
||||
: [];
|
||||
const rememberItemIdentity = (item: ExpressionEditorState, id: string | undefined): void => {
|
||||
if (id !== undefined) itemIdentity.byState.set(item, id);
|
||||
};
|
||||
const source = valueSourceFor(state);
|
||||
const selectSource = (next: "path" | "literal" | "construct"): void =>
|
||||
onChange(stateForSource(next, field, state));
|
||||
@@ -200,10 +212,13 @@ export const InputExpressionControl = ({
|
||||
<InputExpressionControl
|
||||
field={itemField}
|
||||
label={itemLabel}
|
||||
onChange={(next) => onChange({
|
||||
kind: "array",
|
||||
items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate),
|
||||
})}
|
||||
onChange={(next) => {
|
||||
rememberItemIdentity(next, itemId);
|
||||
onChange({
|
||||
kind: "array",
|
||||
items: state.items.map((candidate, candidateIndex) => candidateIndex === index ? next : candidate),
|
||||
});
|
||||
}}
|
||||
sourceSuggestions={sourceSuggestions}
|
||||
state={item}
|
||||
/>
|
||||
@@ -213,13 +228,6 @@ export const InputExpressionControl = ({
|
||||
className="schema-form__secondary-action"
|
||||
disabled={index === 0}
|
||||
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) =>
|
||||
@@ -240,13 +248,6 @@ export const InputExpressionControl = ({
|
||||
className="schema-form__secondary-action"
|
||||
disabled={index === state.items.length - 1}
|
||||
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) =>
|
||||
@@ -266,7 +267,6 @@ export const InputExpressionControl = ({
|
||||
aria-label={`Remove ${itemLabel}`}
|
||||
className="schema-form__secondary-action"
|
||||
onClick={() => {
|
||||
setArrayItemIds((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
onChange({ kind: "array", items: state.items.filter((_, itemIndex) => itemIndex !== index) });
|
||||
}}
|
||||
type="button"
|
||||
@@ -280,10 +280,11 @@ export const InputExpressionControl = ({
|
||||
<button
|
||||
className="schema-form__secondary-action"
|
||||
onClick={() => {
|
||||
const itemId = `${controlId}-item-${nextItemId.current}`;
|
||||
nextItemId.current += 1;
|
||||
setArrayItemIds((current) => [...current, itemId]);
|
||||
onChange({ kind: "array", items: [...state.items, defaultExpressionEditorState(field?.item ?? null)] });
|
||||
const item = defaultExpressionEditorState(field?.item ?? null);
|
||||
const itemId = `${controlId}-item-${itemIdentity.nextId}`;
|
||||
itemIdentity.nextId += 1;
|
||||
itemIdentity.byState.set(item, itemId);
|
||||
onChange({ kind: "array", items: [...state.items, item] });
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user