fix: close workflow console review remediation
This commit is contained in:
@@ -17,6 +17,7 @@ export type SchemaFieldControlProps = {
|
||||
readonly onValueChange: (field: SchemaField, value: unknown) => void;
|
||||
readonly onSourceChange: (field: SchemaField, source: FieldSource) => void;
|
||||
readonly onArrayItemRemove: (field: SchemaField, index: number) => void;
|
||||
readonly arrayItemKey?: ((field: SchemaField, index: number) => string) | undefined;
|
||||
readonly sourceSuggestions?: ReadonlyArray<string>;
|
||||
readonly showSourceControl?: boolean;
|
||||
readonly idPrefix?: string;
|
||||
@@ -216,6 +217,7 @@ const LeafControl = ({
|
||||
};
|
||||
|
||||
export const SchemaFieldControl = ({
|
||||
arrayItemKey,
|
||||
field,
|
||||
value,
|
||||
sources,
|
||||
@@ -246,8 +248,9 @@ export const SchemaFieldControl = ({
|
||||
{field.children.map((child) => (
|
||||
<SchemaFieldControl
|
||||
diagnostics={diagnostics.filter((diagnostic) => isPathPrefix(child.path, diagnostic.path))}
|
||||
arrayItemKey={arrayItemKey}
|
||||
field={child}
|
||||
key={pathKey(child)}
|
||||
key={String(child.key)}
|
||||
onArrayItemRemove={onArrayItemRemove}
|
||||
onSourceChange={onSourceChange}
|
||||
onValueChange={onValueChange}
|
||||
@@ -282,8 +285,12 @@ export const SchemaFieldControl = ({
|
||||
: null;
|
||||
if (!itemField) return null;
|
||||
return (
|
||||
<div className="schema-form__array-item" key={pathKey(itemField)}>
|
||||
<div
|
||||
className="schema-form__array-item"
|
||||
key={arrayItemKey?.(field, index) ?? pathKey(itemField)}
|
||||
>
|
||||
<SchemaFieldControl
|
||||
arrayItemKey={arrayItemKey}
|
||||
diagnostics={diagnostics.filter((diagnostic) => isPathPrefix(itemField.path, diagnostic.path))}
|
||||
field={itemField}
|
||||
onSourceChange={onSourceChange}
|
||||
|
||||
@@ -57,6 +57,42 @@ describe("SchemaForm", () => {
|
||||
expect(screen.getByRole("button", { name: "Call capability" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("isolates control ids and radio groups across form instances", () => {
|
||||
render(
|
||||
<>
|
||||
<SchemaForm schema={{ type: "object", properties: { value: { type: "string" } } }} />
|
||||
<SchemaForm schema={{ type: "object", properties: { value: { type: "string" } } }} />
|
||||
</>,
|
||||
);
|
||||
|
||||
const literalRadios = screen.getAllByRole("radio", { name: /Literal/ });
|
||||
expect(literalRadios[0]).not.toHaveAttribute("name", literalRadios[1]?.getAttribute("name"));
|
||||
const valueInputs = screen.getAllByRole("textbox", { name: "Value" });
|
||||
expect(valueInputs[0]?.id).not.toBe(valueInputs[1]?.id);
|
||||
});
|
||||
|
||||
it("resets values when the represented schema changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<SchemaForm
|
||||
initialValue={{ first: "initial" }}
|
||||
schema={{ type: "object", properties: { first: { type: "string" } } }}
|
||||
/>,
|
||||
);
|
||||
await user.clear(screen.getByRole("textbox", { name: "First" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "First" }), "edited");
|
||||
|
||||
rerender(
|
||||
<SchemaForm
|
||||
initialValue={{ second: "replacement" }}
|
||||
schema={{ type: "object", properties: { second: { type: "string" } } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("textbox", { name: "First" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Second" })).toHaveValue("replacement");
|
||||
});
|
||||
|
||||
it("renders unsupported fields as JSON editors with their exact fallback reason", () => {
|
||||
render(
|
||||
<SchemaForm
|
||||
@@ -152,6 +188,29 @@ describe("SchemaForm", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps later array controls mounted when an earlier row is removed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SchemaForm
|
||||
initialValue={{ items: [{ name: "first" }, { name: "second" }] }}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const secondControl = screen.getAllByRole("textbox", { name: "Name" })[1];
|
||||
await user.click(screen.getByRole("button", { name: "Remove item 1" }));
|
||||
|
||||
expect(screen.getByRole("textbox", { name: "Name" })).toBe(secondControl);
|
||||
expect(secondControl).toHaveValue("second");
|
||||
});
|
||||
|
||||
it("omits an untouched optional boolean but preserves explicit false", async () => {
|
||||
const user = userEvent.setup();
|
||||
const untouched: SchemaSerializationResult[] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { useId, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { normalizeSchema, type FieldSource, type SchemaField } from "./schema-field.js";
|
||||
import { SchemaFieldControl } from "./SchemaFieldControl.js";
|
||||
import {
|
||||
@@ -93,7 +93,7 @@ const rawSchemaText = (schema: unknown): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const SchemaForm = ({
|
||||
const SchemaFormState = ({
|
||||
schema,
|
||||
initialValue,
|
||||
initialSources = EMPTY_SOURCES,
|
||||
@@ -106,6 +106,9 @@ export const SchemaForm = ({
|
||||
sourceSuggestions = EMPTY_SUGGESTIONS,
|
||||
showSourceControls = true,
|
||||
}: SchemaFormProps) => {
|
||||
const formId = useId();
|
||||
const nextArrayRowId = useRef(0);
|
||||
const arrayRowIds = useRef(new Map<string, string[]>());
|
||||
const field = normalizeSchema(schema);
|
||||
const [values, setValues] = useState<unknown>(() =>
|
||||
initialValue !== undefined ? initialValue : emptyValueFor(field),
|
||||
@@ -147,6 +150,7 @@ export const SchemaForm = ({
|
||||
onDirtyChange?.(true);
|
||||
const arrayValue = readAtPath(values, arrayField.path);
|
||||
if (!Array.isArray(arrayValue)) return;
|
||||
arrayRowIds.current.get(sourceKey(arrayField))?.splice(index, 1);
|
||||
const nextValues = setAtPath(
|
||||
values,
|
||||
arrayField.path,
|
||||
@@ -160,6 +164,16 @@ export const SchemaForm = ({
|
||||
onValueChange?.(serializeSchemaValues(field, nextValues, nextSources));
|
||||
};
|
||||
|
||||
const arrayItemKey = (arrayField: SchemaField, index: number): string => {
|
||||
const path = sourceKey(arrayField);
|
||||
const ids = arrayRowIds.current.get(path) ?? [];
|
||||
while (ids.length <= index) {
|
||||
ids.push(`${formId}-array-row-${nextArrayRowId.current++}`);
|
||||
}
|
||||
arrayRowIds.current.set(path, ids);
|
||||
return ids[index] ?? `${formId}-array-row-missing`;
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const result = serializeSchemaValues(field, values, sources);
|
||||
@@ -171,8 +185,10 @@ export const SchemaForm = ({
|
||||
<form className="schema-form" noValidate onSubmit={handleSubmit}>
|
||||
{renderBeforeFields}
|
||||
<SchemaFieldControl
|
||||
arrayItemKey={arrayItemKey}
|
||||
diagnostics={allDiagnostics}
|
||||
field={field}
|
||||
idPrefix={`schema-form-${formId}`}
|
||||
onArrayItemRemove={handleArrayItemRemove}
|
||||
onSourceChange={handleSourceChange}
|
||||
onValueChange={handleValueChange}
|
||||
@@ -189,3 +205,8 @@ export const SchemaForm = ({
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/** Remounts form-local state only when the represented schema changes. */
|
||||
export const SchemaForm = (props: SchemaFormProps) => (
|
||||
<SchemaFormState key={rawSchemaText(props.schema)} {...props} />
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user