diff --git a/.changeset/11562-detail-textarea-multiline.md b/.changeset/11562-detail-textarea-multiline.md new file mode 100644 index 0000000000..d13a25a733 --- /dev/null +++ b/.changeset/11562-detail-textarea-multiline.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-detail': patch +--- + +`record:details` edit mode edits a `textarea` field in a multi-line textarea, +so saving it keeps its line breaks (objectui#11562). + +Before, a `textarea` row on the record page edited in a one-line text input. +The browser strips line breaks from the value such an input is given, so a +stored note with blank lines or a trailing newline was shown flattened, and one +keystroke made the flattened text the value the Save wrote. Nothing warned. + +The detail row now routes `textarea` to `TextAreaField`, the fields package's +multi-line widget: the one a `textarea` field already edits with in the record +form and in a data-grid cell, and the one a `markdown` row got in +objectui#11541. It hands the text back exactly as typed, blank lines and +trailing newline included. A single-line value saves the same as before. + +Unchanged: `text`, `email`, `phone` and `url` rows still edit in the one-line +input, and a data-grid cell still edits `textarea` with the same widget it used +before. diff --git a/packages/plugin-detail/src/InlineFieldInput.tsx b/packages/plugin-detail/src/InlineFieldInput.tsx index af87f9c4a4..ebc4b7e75c 100644 --- a/packages/plugin-detail/src/InlineFieldInput.tsx +++ b/packages/plugin-detail/src/InlineFieldInput.tsx @@ -75,21 +75,18 @@ export const INLINE_PLAIN_TEXT_INPUT_TESTID = 'inline-plain-text-input'; * only its degenerate options-less SINGLE-value form reaches the terminal * input — see {@link usesInlinePlainTextInput}. * - * `markdown` is absent although its value is a string (objectui#11541): it is - * ROUTED, to the multi-line `TextAreaField`, because this terminal input is a - * single-line `` and the browser strips line breaks from the - * value it is seeded with — one keystroke and the flattened text is what gets - * written. `html` / `richtext` are absent because the hosts never open an - * editor for them at all (they are in the fields package's shared exclusion, - * consulted since #4228). - * - * ⚠️ That line-break loss holds for `textarea`, which IS a member, whenever its - * stored value spans lines. Recorded here so the "nothing is lost" above is not - * read as covering it; objectui#11541 does not change `textarea`. + * `markdown` (objectui#11541) and `textarea` (objectui#11562) are absent + * although their values are strings: they are multi-line by type, so they are + * ROUTED, to the multi-line `TextAreaField`. This terminal input is a + * single-line ``, and the browser strips line breaks from + * the value it is seeded with — one keystroke and the flattened text is what + * gets written. The members left here are single-line types, which is what + * makes "nothing is lost" true of them. `html` / `richtext` are absent because + * the hosts never open an editor for them at all (they are in the fields + * package's shared exclusion, consulted since #4228). */ export const INLINE_PLAIN_TEXT_FIELD_TYPES = new Set([ 'text', - 'textarea', 'email', 'phone', 'url', @@ -145,6 +142,10 @@ export const INLINE_ROUTED_FIELD_TYPES = new Set([ // Long-form markup source — the same exemption (objectui#11541): a row can // host a multi-line editor, and the terminal input is one line. 'markdown', + // Multi-line plain text (objectui#11562). Not in the shared exclusion, so + // the hosts always opened an editor for it; it was class D until the + // terminal input's one line was measured to drop its line breaks. + 'textarea', ]); /** @@ -412,16 +413,18 @@ export const InlineFieldInput: React.FC = ({ if (editType === 'file' || editType === 'video' || editType === 'audio') { return onChange(v)} error={error} />; } - // Markdown → `TextAreaField`, the fields package's multi-line widget, the one - // a `textarea` field edits with in the record form and in a grid cell - // (objectui#11541). The value is the markdown SOURCE, a plain string, and the - // textarea hands it back exactly as typed, blank lines and trailing newline - // included. The terminal input below cannot: it is one line, and the browser - // strips line breaks from the value it is seeded with — "a one-line text box - // is lossy", the reason #4228 recorded for keeping markdown out of this row. - // `html` / `richtext` never reach this component: the hosts' gate still - // excludes them. - if (editType === 'markdown') { + // Markdown and textarea → `TextAreaField`, the fields package's multi-line + // widget, the one a `textarea` field edits with in the record form and in a + // grid cell (objectui#11541 for markdown, objectui#11562 for textarea). The + // value is a plain string — for markdown, its SOURCE — and the textarea hands + // it back exactly as typed, blank lines and trailing newline included. The + // terminal input below cannot: it is one line, and the browser strips line + // breaks from the value it is seeded with — "a one-line text box is lossy", + // the reason #4228 recorded for keeping markdown out of this row, and the + // reason a stored multi-line note lost its line breaks on the first keystroke + // while `textarea` still edited there. `html` / `richtext` never reach this + // component: the hosts' gate still excludes them. + if (editType === 'markdown' || editType === 'textarea') { return onChange(v)} autoFocus={autoFocus} error={error} />; } // Reference fields (lookup / master_detail / tree / user / owner) store an id diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx index 519ef72133..12625d30d1 100644 --- a/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx +++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx @@ -361,7 +361,10 @@ describe('types the fields package edits inline but the switch had no branch for // --------------------------------------------------------------------------- describe('class D — benign string types keep the terminal text input (#4220)', () => { - for (const type of ['text', 'textarea', 'email', 'url', 'phone']) { + // `textarea` stood in this list until objectui#11562 routed it to the + // multi-line `TextAreaField`; its pins are + // `InlineFieldInput.textareaEditor-11562.test.tsx`. + for (const type of ['text', 'email', 'url', 'phone']) { it(`\`${type}\` still edits in the plain input, and emits the typed string`, () => { const onChange = vi.fn(); render( diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.textareaEditor-11562.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.textareaEditor-11562.test.tsx new file mode 100644 index 0000000000..4fe056b8a2 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.textareaEditor-11562.test.tsx @@ -0,0 +1,235 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11562 — `record:details` edit mode edits a `textarea` field in a + * MULTI-line editor, so a save writes back the text that was stored, line + * breaks included. + * + * Before, `textarea` was a member of `INLINE_PLAIN_TEXT_FIELD_TYPES` (class D, + * objectui#4220: "the stored value is already a string, nothing is lost"), so + * the row edited in this component's terminal input, an ``. + * That holds for a single-line value only. The browser strips every line break + * from the value a text input is seeded with, so a stored note with blank lines + * and a trailing newline was shown flattened, and one keystroke made the + * flattened string the value the Save batch wrote. Nothing warned. + * + * The row now routes `textarea` to the fields package's `TextAreaField`, the + * widget `markdown` got in objectui#11541 and the one a `textarea` field + * already edits with in the record form and in a grid cell. The pins below + * compare values BYTE for byte with blank lines and a trailing newline in them: + * a single-line editor cannot pass them. A single-line value is pinned beside + * them, so the move is shown to change nothing for it. + * + * The hosts' gate needed no change: `textarea` is not in the fields package's + * shared `INLINE_EXCLUDED_FIELD_TYPES`, so both detail hosts already opened an + * editor for it. It was the editor that was wrong. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { InlineEditProvider, useInlineEdit } from '@object-ui/react'; +import type { DetailViewSection } from '@object-ui/types'; +import { FieldEditWidget } from '@object-ui/fields'; +import { DetailSection } from '../DetailSection'; +import { HeaderHighlight } from '../HeaderHighlight'; +import { InlineEditSaveBar } from '../InlineEditSaveBar'; +import { InlineFieldInput, INLINE_PLAIN_TEXT_INPUT_TESTID } from '../InlineFieldInput'; + +/** A blank line inside and a trailing newline: the bytes a one-line box drops. */ +const MULTI_LINE = 'Call back Monday.\n\nBudget approved, needs legal review.\n'; +/** The control: a value a one-line box already kept intact. */ +const SINGLE_LINE = 'Call back Monday.'; + +const plainInput = (root: ParentNode = document) => + root.querySelector(`[data-testid="${INLINE_PLAIN_TEXT_INPUT_TESTID}"]`); +const editors = (root: ParentNode) => root.querySelectorAll('input, textarea'); +const textareaIn = (root: ParentNode) => root.querySelector('textarea') as HTMLTextAreaElement | null; +const hasPencil = () => screen.queryAllByLabelText('Double-click to edit').length > 0; + +beforeAll(() => { + // Desktop layout — the mobile branch renders its own read-only row shape. + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +// --------------------------------------------------------------------------- +// The editor itself +// --------------------------------------------------------------------------- + +describe('objectui#11562 — InlineFieldInput edits textarea in a multi-line textarea', () => { + for (const [label, stored] of [['multi-line', MULTI_LINE], ['single-line', SINGLE_LINE]] as const) { + it(`a ${label} value seeds a textarea byte for byte, never the terminal input`, () => { + const { container } = render( + , + ); + expect(plainInput(container)).toBeNull(); + expect(textareaIn(container)).not.toBeNull(); + expect(textareaIn(container)!.value).toBe(stored); + }); + + it(`one keystroke on a ${label} value emits the stored text plus that keystroke, exactly`, () => { + const onChange = vi.fn(); + const { container } = render( + , + ); + // Typed at the end of whatever the editor shows: the issue's repro. A + // one-line box shows the flattened text, so it would emit that. + const editor = container.querySelector('input, textarea') as HTMLTextAreaElement; + fireEvent.change(editor, { target: { value: `${editor.value}!` } }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toBe(`${stored}!`); + }); + } + + it('takes the entered field’s focus, like the other routed editors', () => { + const { container } = render( + , + ); + expect(textareaIn(container)).toBe(document.activeElement); + }); +}); + +// --------------------------------------------------------------------------- +// The two detail hosts open it +// --------------------------------------------------------------------------- + +const textareaSchema = { fields: { notes: { type: 'textarea', label: 'Notes' } } }; +const notesSection = { fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection; + +describe('objectui#11562 — both detail hosts open the multi-line editor', () => { + it('the details body shows the inline-edit affordance on a textarea row', () => { + render( + , + ); + expect(hasPencil()).toBe(true); + }); + + it('the details body renders one editor for the textarea row in edit mode, seeded byte for byte', () => { + const { container } = render( + , + ); + expect(editors(container)).toHaveLength(1); + expect(plainInput(container)).toBeNull(); + expect(textareaIn(container)!.value).toBe(MULTI_LINE); + }); + + it('the highlights strip opens the same editor inside the edit session', () => { + function EnterEdit() { + const inline = useInlineEdit(); + return ; + } + const { container } = render( + + + + , + ); + fireEvent.click(screen.getByText('enter-edit')); + expect(plainInput(container)).toBeNull(); + expect(textareaIn(container)!.value).toBe(MULTI_LINE); + }); +}); + +// --------------------------------------------------------------------------- +// The save reads back unchanged +// --------------------------------------------------------------------------- + +describe('objectui#11562 — a textarea save reads back unchanged, line breaks included', () => { + // The multi-line edit appends a line that ends in a newline too; the + // single-line control appends words only, so it stays a one-line value. + const CASES = [ + ['multi-line', MULTI_LINE, 'Follow-up booked.\n'], + ['single-line', SINGLE_LINE, ' Follow-up booked.'], + ] as const; + for (const [label, stored, typed] of CASES) { + it(`a ${label} value: the PATCH body carries the edit byte for byte, and the refetched record seeds it back`, async () => { + /** The server's copy of the record; `update` writes through the wire's JSON. */ + const store: Record = { id: 'r1', notes: stored, updated_at: 'v1' }; + const wire: string[] = []; + const update = vi.fn(async (_object: string, _id: string, patch: Record) => { + const body = JSON.stringify(patch); + wire.push(body); + Object.assign(store, JSON.parse(body), { updated_at: 'v2' }); + return {}; + }); + const edited = `${stored}${typed}`; + + /** Stand-in for `DetailView`: the fetched record overlaid with the draft. */ + function Body({ record }: { record: Record }) { + const inline = useInlineEdit()!; + return ( + <> + + + + ); + } + function Page() { + const [record, setRecord] = React.useState>(() => ({ ...store })); + const refresh = React.useCallback(async () => setRecord({ ...store }), []); + return ( + + + + + ); + } + + const { container } = render(); + fireEvent.click(screen.getByText('enter-edit')); + // Whatever editor the row renders, the user types onto what it SHOWS. + const editor = container.querySelector('input, textarea') as HTMLTextAreaElement; + expect(editor.value).toBe(stored); + fireEvent.change(editor, { target: { value: `${editor.value}${typed}` } }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + }); + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(wire).toHaveLength(1); + expect(JSON.parse(wire[0])).toEqual({ notes: edited }); + expect(store.notes).toBe(edited); + // Save leaves edit mode; re-entering seeds the editor from the refetched + // record. Editor-agnostic on purpose, like the reads above: the editor's + // KIND is pinned by the first describe, and this case is about bytes, so + // the single-line control reads the same before the fix and after it. + await waitFor(() => expect(container.querySelector('input, textarea')).toBeNull()); + fireEvent.click(screen.getByText('enter-edit')); + expect((container.querySelector('input, textarea') as HTMLTextAreaElement).value).toBe(edited); + }); + } +}); + +// --------------------------------------------------------------------------- +// The grid cell: unchanged, and now the same editor as the row +// --------------------------------------------------------------------------- + +describe('objectui#11562 — the grid cell editor is the same TextAreaField, unchanged', () => { + it('`FieldEditWidget`, which the grid cell editor delegates to, seeds a textarea byte for byte', () => { + // `ObjectGrid`'s `renderCellEditor` hands a `textarea` cell to + // `FieldEditWidget`; this file's change does not touch that path. The pin + // records that the row and the cell now edit with one widget. + const { container } = render( + , + ); + expect(textareaIn(container)!.value).toBe(MULTI_LINE); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx index a18d5669b4..0871aa11f1 100644 --- a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx +++ b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx @@ -34,7 +34,7 @@ * | **excluded** | the hosts' gates (`isInlineExcludedDetailFieldType` / `isComputedFieldType`) | no inline editor at all — containers, credentials, computed | * | **routed** | `InlineFieldInput`'s own switch (`INLINE_ROUTED_FIELD_TYPES`) | the dedicated editor that type already had | * | **delegated** | `FieldEditWidget` | the SAME widget the form uses | - * | **benign** | `INLINE_PLAIN_TEXT_FIELD_TYPES` | the terminal text input, losslessly (the value is already a string) | + * | **benign** | `INLINE_PLAIN_TEXT_FIELD_TYPES` | the terminal text input, losslessly (the value is already a single-line string) | * * A type in NONE of the four is red — that is a new type nobody decided about, * and the default it would otherwise inherit is the value-destroying one. A @@ -197,7 +197,8 @@ describe('inline-edit type coverage — every type has exactly one decision (#42 // Decide it: give it a branch (INLINE_ROUTED_FIELD_TYPES), let it delegate // (a widget in the fields package's EDIT_WIDGETS), gate it out (the shared // INLINE_EXCLUDED_FIELD_TYPES), or declare it benign - // (INLINE_PLAIN_TEXT_FIELD_TYPES — only if its stored value is a string). + // (INLINE_PLAIN_TEXT_FIELD_TYPES — only if its stored value is a string + // that cannot span lines: the terminal input is one line, objectui#11562). expect(undecided).toEqual([]); }); @@ -210,11 +211,13 @@ describe('inline-edit type coverage — every type has exactly one decision (#42 it('the benign list is explicit, and every member really is string-valued', () => { // Enumerated, never "everything else" — an open tail is the drift itself. + // `textarea` left this list in objectui#11562: its value is a string, but + // a MULTI-line one, and the terminal input is one line, so a stored value + // with line breaks was saved flattened. It is routed now (see below). expect([...INLINE_PLAIN_TEXT_FIELD_TYPES].sort()).toEqual([ 'email', 'phone', 'text', - 'textarea', 'url', ]); }); @@ -249,14 +252,16 @@ describe('inline-edit type coverage — every type has exactly one decision (#42 // `markdown` sat in `excluded` until objectui#11541: the detail row now // routes it to the multi-line `TextAreaField`, and the grid cell keeps // the shared exclusion. `html` / `richtext` stay excluded, unchanged. + // `textarea` sat in `benign` until objectui#11562 and is routed to the + // same `TextAreaField`; the grid cell already edited it with that widget. routed: [ 'address', 'audio', 'avatar', 'boolean', 'currency', 'date', 'datetime', 'file', 'geolocation', 'image', 'location', 'lookup', 'markdown', 'master_detail', 'multiselect', 'number', 'percent', 'select', - 'signature', 'tree', 'user', 'video', + 'signature', 'textarea', 'tree', 'user', 'video', ], delegated: ['checkboxes', 'code', 'color', 'json', 'progress', 'qrcode', 'radio', 'rating', 'slider', 'tags', 'time', 'toggle'], - benign: ['email', 'phone', 'text', 'textarea', 'url'], + benign: ['email', 'phone', 'text', 'url'], }); }); });