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:
parent
fcf0a6309b
commit
a24413af6f
2 changed files with 143 additions and 6 deletions
|
|
@ -1648,3 +1648,108 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
|
||||||
expect(jsonInput.value).not.toContain("\\");
|
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]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,14 @@ interface SchemaJsonFieldProps {
|
||||||
description?: string;
|
description?: string;
|
||||||
withAsterisk: boolean;
|
withAsterisk: boolean;
|
||||||
disabled: 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;
|
value: unknown;
|
||||||
|
/** The schema `default`, used to seed the draft only. See `defaultValue`. */
|
||||||
|
defaultValue: unknown;
|
||||||
onChange: (value: unknown) => void;
|
onChange: (value: unknown) => void;
|
||||||
onValidityChange: DraftValidityReporter;
|
onValidityChange: DraftValidityReporter;
|
||||||
}
|
}
|
||||||
|
|
@ -244,18 +251,29 @@ interface SchemaJsonFieldProps {
|
||||||
function SchemaJsonField({
|
function SchemaJsonField({
|
||||||
fieldName,
|
fieldName,
|
||||||
value,
|
value,
|
||||||
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
...inputProps
|
...inputProps
|
||||||
}: SchemaJsonFieldProps) {
|
}: SchemaJsonFieldProps) {
|
||||||
const [draft, setDraft] = useState(() =>
|
const [draft, setDraft] = useState(() => {
|
||||||
value === undefined ? "" : serializeJson(value),
|
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
|
// 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
|
// parses to, which leaves an external reset (a cleared form, a loaded
|
||||||
// example) working while an in-progress `[` — whose parse is `undefined`,
|
// example) working while an in-progress `[` — whose parse is `undefined`,
|
||||||
// matching the `undefined` we just emitted — is left alone.
|
// 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) => {
|
useValueChange(value, (next) => {
|
||||||
if (!isSameJson(parseJsonDraft(draft), next)) {
|
if (!isSameJson(parseJsonDraft(draft), next)) {
|
||||||
setDraft(next === undefined ? "" : serializeJson(next));
|
setDraft(next === undefined ? "" : serializeJson(next));
|
||||||
|
|
@ -309,7 +327,10 @@ interface SchemaNumberInputProps {
|
||||||
description?: string;
|
description?: string;
|
||||||
withAsterisk: boolean;
|
withAsterisk: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
/** Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. */
|
||||||
value: number | undefined;
|
value: number | undefined;
|
||||||
|
/** The schema `default`, used to seed the draft only (#2026). */
|
||||||
|
defaultValue: number | undefined;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
allowDecimal: boolean;
|
allowDecimal: boolean;
|
||||||
|
|
@ -343,12 +364,19 @@ interface SchemaNumberInputProps {
|
||||||
function SchemaNumberInput({
|
function SchemaNumberInput({
|
||||||
fieldName,
|
fieldName,
|
||||||
value,
|
value,
|
||||||
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
...inputProps
|
...inputProps
|
||||||
}: SchemaNumberInputProps) {
|
}: 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) => {
|
useValueChange(value, (next) => {
|
||||||
if (!Object.is(toNumericValue(draft), next)) {
|
if (!Object.is(toNumericValue(draft), next)) {
|
||||||
setDraft(next ?? "");
|
setDraft(next ?? "");
|
||||||
|
|
@ -584,7 +612,9 @@ export function SchemaForm({
|
||||||
description={description}
|
description={description}
|
||||||
withAsterisk={isRequired}
|
withAsterisk={isRequired}
|
||||||
disabled={disabled}
|
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}
|
min={fieldSchema.minimum}
|
||||||
max={fieldSchema.maximum}
|
max={fieldSchema.maximum}
|
||||||
// An `integer` field rejects the decimal point outright rather than
|
// An `integer` field rejects the decimal point outright rather than
|
||||||
|
|
@ -692,7 +722,9 @@ export function SchemaForm({
|
||||||
description={description}
|
description={description}
|
||||||
withAsterisk={isRequired}
|
withAsterisk={isRequired}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={rawValue}
|
// Raw and default kept apart, not pre-resolved (#2026).
|
||||||
|
value={values[fieldName]}
|
||||||
|
defaultValue={getDefaultValue(fieldSchema)}
|
||||||
onChange={(val) => handleFieldChange(fieldName, val)}
|
onChange={(val) => handleFieldChange(fieldName, val)}
|
||||||
onValidityChange={reportFieldValidity}
|
onValidityChange={reportFieldValidity}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue