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'],
});
});
});