diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index a11fe0b1..47de56d2 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2002,9 +2002,15 @@ describe("SchemaForm enlarge keyboard access (#2138)", () => { // Seeds a value and holds it in real state. A stub `onChange` would leave the // field's value frozen, which makes "the value did not change" assertions // pass whatever the component does. - function SeededHarness({ maxLength }: { maxLength?: number }) { + function SeededHarness({ + maxLength, + initial = "abc", + }: { + maxLength?: number; + initial?: unknown; + }) { const [values, setValues] = useState>({ - note: "abc", + note: initial, }); return ( { expect(noteField().value).toBe("a\n"); }); + // `values` is a Record fed by whatever a server declares, so + // a string field can arrive holding a number. It renders as text, and slicing + // it as a string would throw — turning one keystroke into a crashed panel + // (#2139 review). Reading the control's own value keeps it a string. + it("survives a non-string value on a string field", () => { + renderWithMantine(); + + expect(noteField().value).toBe("123"); + noteField().setSelectionRange(3, 3); + fireEvent.keyDown(noteField(), { key: "Enter" }); + + expect(noteField().tagName).toBe("TEXTAREA"); + expect(noteField().value).toBe("123\n"); + }); + + // Even when the newline does not fit, the keyboard had a real caret position + // and it is kept — otherwise the user editing the middle of a full field is + // thrown to the end on top of not getting their newline. + it("keeps the caret where it was when the newline does not fit", () => { + renderWithMantine(); + + noteField().setSelectionRange(1, 1); + fireEvent.keyDown(noteField(), { key: "Enter" }); + + expect(noteField().value).toBe("abc"); + expect(noteField().selectionStart).toBe(1); + }); + // The control: the same seeded field with no maxLength does take the newline, // so the test above is showing the constraint at work rather than a field // that never accepts one. diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 76603591..52daebbb 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -762,7 +762,14 @@ export function SchemaForm({ // A field with no room left for the newline is enlarged without one, // since the alternative is breaching a maxLength the schema states. const enlargeWithNewline = (input: HTMLInputElement) => { - const current = (rawValue as string) ?? ""; + // The control's own value, not the form's. Two reasons, and the second + // is the one that bites: it is the exact string the selection offsets + // below index into, and it is always a string. `values` is a + // `Record` fed by whatever a server's schema declares, + // so a non-string default on a string field (`default: 123`) arrives + // here as a number — it renders as text, and would then throw on + // `.slice`, turning a keystroke into a crashed panel. + const current = input.value; // A control that cannot report a selection (`selectionStart` is null // for some input types) is treated as a caret at the end. const start = input.selectionStart ?? current.length; @@ -771,13 +778,15 @@ export function SchemaForm({ const fits = fieldSchema.maxLength === undefined || next.length <= fieldSchema.maxLength; - if (fits) { - handleFieldChange(fieldName, next); - setEnlargeCarets((previous) => ({ - ...previous, - [fieldName]: start + 1, - })); - } + if (fits) handleFieldChange(fieldName, next); + // The caret is recorded either way. The keyboard route always has a + // real position, so dropping it when the newline does not fit would + // send the caret to the end of a field the user was editing the middle + // of — a second surprise on top of the newline they did not get. + setEnlargeCarets((previous) => ({ + ...previous, + [fieldName]: fits ? start + 1 : start, + })); enlarge(); };