diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 9e573a9c..89ee31bb 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -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; + }) { + const [values, setValues] = useState>(initial); + return ; + } + + const arrayWithDefault: InspectorFormSchema = { + type: "object", + properties: { tags: { type: "array", title: "Tags", default: ["a"] } }, + }; + + it("opens the JSON editor on the schema default", () => { + renderWithMantine(); + 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( + , + ); + + 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( + , + ); + + 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(); + + 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>({ + tags: ["a"], + }); + return ( + <> + + + + ); + } + const user = userEvent.setup(); + renderWithMantine(); + + const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement; + await user.click(screen.getByRole("button", { name: "load example" })); + expect(jsonInput.value).toBe('[\n "z"\n]'); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 54e50b6e..a849ab50 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -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(value ?? ""); + const [draft, setDraft] = useState( + 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} />