fix(web): stop a schema default from reverting the edit in progress (#2026)

A field whose schema declares a `default` could not be edited. Unsendable
draft text reports `undefined` by design — an inspector must not report a value
it cannot send — and `resolveValue` turned that straight back into the default.
That is a real change from the previous value, so the draft/value re-sync
rewrote the box to the default and the keystroke was lost. Almost every edit
passes through an unsendable state, which left a defaulted array or object
argument uneditable in the JSON editor, and a defaulted number field unable to
be emptied.

Hand the two draft-holding fields the raw value and the schema default
separately, and compare only against the raw one. The default now seeds what
the field opens with, which is what a `default` means, rather than being
re-imposed while the user types. Every other field keeps resolving through
`resolveValue` unchanged.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 00:27:56 -04:00
commit a24413af6f
2 changed files with 143 additions and 6 deletions

View file

@ -1648,3 +1648,108 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
expect(jsonInput.value).not.toContain("\\");
});
});
// A schema `default` is what a field *opens* with, not a value re-imposed on
// every unsendable draft. Unsendable text reports `undefined` by design, and
// `resolveValue` used to turn that straight back into the default — a real
// change from the previous value, so the draft/value re-sync rewrote the box
// and reverted the keystroke. Almost every edit passes through an unsendable
// state, which left a defaulted array or object argument uneditable.
describe("SchemaForm defaulted fields (#2026)", () => {
function DefaultHarness({
schema,
initial = {},
}: {
schema: InspectorFormSchema;
initial?: Record<string, unknown>;
}) {
const [values, setValues] = useState<Record<string, unknown>>(initial);
return <SchemaForm schema={schema} values={values} onChange={setValues} />;
}
const arrayWithDefault: InspectorFormSchema = {
type: "object",
properties: { tags: { type: "array", title: "Tags", default: ["a"] } },
};
it("opens the JSON editor on the schema default", () => {
renderWithMantine(<DefaultHarness schema={arrayWithDefault} />);
expect((screen.getByLabelText(/Tags/) as HTMLTextAreaElement).value).toBe(
'[\n "a"\n]',
);
});
it("keeps a keystroke typed into a defaulted JSON field", async () => {
const user = userEvent.setup();
// Seeded the way the Tools tab seeds it — from the schema's defaults, as a
// separate value than the schema's own. Sharing one reference hid this.
renderWithMantine(
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
);
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
await user.click(jsonInput);
await user.keyboard("{End}x");
// The invalid draft is the user's, not the default reasserting itself.
expect(jsonInput.value).toBe('[\n "a"\n]x');
});
it("lets a defaulted JSON field be edited to a new value", async () => {
const user = userEvent.setup();
renderWithMantine(
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
);
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
await user.clear(jsonInput);
await user.type(jsonInput, '[["b"]');
expect(jsonInput.value).toBe('["b"]');
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
});
it("lets a defaulted number field be emptied", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: { n: { type: "integer", title: "N", default: 30 } },
};
renderWithMantine(<DefaultHarness schema={schema} initial={{ n: 30 }} />);
const n = screen.getByLabelText(/N/) as HTMLInputElement;
expect(n.value).toBe("30");
await user.click(n);
await user.keyboard("{End}{Backspace}{Backspace}");
// The box stays empty instead of refilling itself with the default.
expect(n.value).toBe("");
});
it("still re-syncs a defaulted field when the value changes externally", async () => {
function ExternalHarness() {
const [values, setValues] = useState<Record<string, unknown>>({
tags: ["a"],
});
return (
<>
<SchemaForm
schema={arrayWithDefault}
values={values}
onChange={setValues}
/>
<button type="button" onClick={() => setValues({ tags: ["z"] })}>
load example
</button>
</>
);
}
const user = userEvent.setup();
renderWithMantine(<ExternalHarness />);
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
await user.click(screen.getByRole("button", { name: "load example" }));
expect(jsonInput.value).toBe('[\n "z"\n]');
});
});

View file

@ -207,7 +207,14 @@ interface SchemaJsonFieldProps {
description?: string;
withAsterisk: boolean;
disabled: boolean;
/**
* The field's own entry in the form's values — **not** run through
* `resolveValue`. A draft-holding field is handed the raw value and the
* schema default separately; see `defaultValue` for why.
*/
value: unknown;
/** The schema `default`, used to seed the draft only. See `defaultValue`. */
defaultValue: unknown;
onChange: (value: unknown) => void;
onValidityChange: DraftValidityReporter;
}
@ -244,18 +251,29 @@ interface SchemaJsonFieldProps {
function SchemaJsonField({
fieldName,
value,
defaultValue,
onChange,
onValidityChange,
...inputProps
}: SchemaJsonFieldProps) {
const [draft, setDraft] = useState(() =>
value === undefined ? "" : serializeJson(value),
);
const [draft, setDraft] = useState(() => {
const initial = value === undefined ? defaultValue : value;
return initial === undefined ? "" : serializeJson(initial);
});
// Re-sync only when the parent's value genuinely diverges from what the draft
// parses to, which leaves an external reset (a cleared form, a loaded
// example) working while an in-progress `[` — whose parse is `undefined`,
// matching the `undefined` we just emitted — is left alone.
//
// The value compared here is the field's *raw* entry, never `resolveValue`'s
// default substitution (#2026). Unsendable text reports `undefined`, and a
// defaulted field resolves that straight back to its default — a real change
// from the previous value, so this would rewrite the draft to the default and
// revert the keystroke. Almost every edit passes through an unsendable state,
// which made a defaulted array or object argument uneditable. The default
// belongs to what the field *opens* with, seeded above, not to what is
// re-imposed while it is being typed into.
useValueChange(value, (next) => {
if (!isSameJson(parseJsonDraft(draft), next)) {
setDraft(next === undefined ? "" : serializeJson(next));
@ -309,7 +327,10 @@ interface SchemaNumberInputProps {
description?: string;
withAsterisk: boolean;
disabled: boolean;
/** Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. */
value: number | undefined;
/** The schema `default`, used to seed the draft only (#2026). */
defaultValue: number | undefined;
min?: number;
max?: number;
allowDecimal: boolean;
@ -343,12 +364,19 @@ interface SchemaNumberInputProps {
function SchemaNumberInput({
fieldName,
value,
defaultValue,
onChange,
onValidityChange,
...inputProps
}: SchemaNumberInputProps) {
const [draft, setDraft] = useState<string | number>(value ?? "");
const [draft, setDraft] = useState<string | number>(
value ?? defaultValue ?? "",
);
// Compared against the raw value, never the default-substituted one, for the
// reason spelled out on `SchemaJsonField` (#2026): clearing this box reports
// no value, which a defaulted field resolved straight back to its default —
// so the box refilled itself and the argument could not be emptied.
useValueChange(value, (next) => {
if (!Object.is(toNumericValue(draft), next)) {
setDraft(next ?? "");
@ -584,7 +612,9 @@ export function SchemaForm({
description={description}
withAsterisk={isRequired}
disabled={disabled}
value={rawValue as number | undefined}
// Raw and default kept apart, not pre-resolved (#2026).
value={values[fieldName] as number | undefined}
defaultValue={getDefaultValue(fieldSchema) as number | undefined}
min={fieldSchema.minimum}
max={fieldSchema.maximum}
// An `integer` field rejects the decimal point outright rather than
@ -692,7 +722,9 @@ export function SchemaForm({
description={description}
withAsterisk={isRequired}
disabled={disabled}
value={rawValue}
// Raw and default kept apart, not pre-resolved (#2026).
value={values[fieldName]}
defaultValue={getDefaultValue(fieldSchema)}
onChange={(val) => handleFieldChange(fieldName, val)}
onValidityChange={reportFieldValidity}
/>