Skip to content

Commit 5af2bb9

Browse files
committed
fix(plugin-detail): record:details edit mode edits a textarea field in a multi-line textarea (objectui#11562)
`textarea` leaves INLINE_PLAIN_TEXT_FIELD_TYPES (class D) and joins INLINE_ROUTED_FIELD_TYPES; InlineFieldInput routes it to the fields package's multi-line TextAreaField, on markdown's branch (objectui#11541). The terminal input is a one-line INPUT of type text, and the browser strips line breaks from the value it is seeded with, so a stored multi-line note was saved flattened after one keystroke. The hosts' gate is unchanged: textarea is not in the shared INLINE_EXCLUDED_FIELD_TYPES, so DETAIL_ROUTED_INLINE_TYPES does not need it. Drift guard: textarea moves from benign to routed. Class-D pins for text, email, url and phone stay. New pins in InlineFieldInput.textareaEditor-11562.test.tsx. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 072b7e8 commit 5af2bb9

5 files changed

Lines changed: 295 additions & 28 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
`record:details` edit mode edits a `textarea` field in a multi-line textarea,
6+
so saving it keeps its line breaks (objectui#11562).
7+
8+
Before, a `textarea` row on the record page edited in a one-line text input.
9+
The browser strips line breaks from the value such an input is given, so a
10+
stored note with blank lines or a trailing newline was shown flattened, and one
11+
keystroke made the flattened text the value the Save wrote. Nothing warned.
12+
13+
The detail row now routes `textarea` to `TextAreaField`, the fields package's
14+
multi-line widget: the one a `textarea` field already edits with in the record
15+
form and in a data-grid cell, and the one a `markdown` row got in
16+
objectui#11541. It hands the text back exactly as typed, blank lines and
17+
trailing newline included. A single-line value saves the same as before.
18+
19+
Unchanged: `text`, `email`, `phone` and `url` rows still edit in the one-line
20+
input, and a data-grid cell still edits `textarea` with the same widget it used
21+
before.

‎packages/plugin-detail/src/InlineFieldInput.tsx‎

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,18 @@ export const INLINE_PLAIN_TEXT_INPUT_TESTID = 'inline-plain-text-input';
7575
* only its degenerate options-less SINGLE-value form reaches the terminal
7676
* input — see {@link usesInlinePlainTextInput}.
7777
*
78-
* `markdown` is absent although its value is a string (objectui#11541): it is
79-
* ROUTED, to the multi-line `TextAreaField`, because this terminal input is a
80-
* single-line `<input type="text">` and the browser strips line breaks from the
81-
* value it is seeded with — one keystroke and the flattened text is what gets
82-
* written. `html` / `richtext` are absent because the hosts never open an
83-
* editor for them at all (they are in the fields package's shared exclusion,
84-
* consulted since #4228).
85-
*
86-
* ⚠️ That line-break loss holds for `textarea`, which IS a member, whenever its
87-
* stored value spans lines. Recorded here so the "nothing is lost" above is not
88-
* read as covering it; objectui#11541 does not change `textarea`.
78+
* `markdown` (objectui#11541) and `textarea` (objectui#11562) are absent
79+
* although their values are strings: they are multi-line by type, so they are
80+
* ROUTED, to the multi-line `TextAreaField`. This terminal input is a
81+
* single-line `<input type="text">`, and the browser strips line breaks from
82+
* the value it is seeded with — one keystroke and the flattened text is what
83+
* gets written. The members left here are single-line types, which is what
84+
* makes "nothing is lost" true of them. `html` / `richtext` are absent because
85+
* the hosts never open an editor for them at all (they are in the fields
86+
* package's shared exclusion, consulted since #4228).
8987
*/
9088
export const INLINE_PLAIN_TEXT_FIELD_TYPES = new Set<string>([
9189
'text',
92-
'textarea',
9390
'email',
9491
'phone',
9592
'url',
@@ -145,6 +142,10 @@ export const INLINE_ROUTED_FIELD_TYPES = new Set<string>([
145142
// Long-form markup source — the same exemption (objectui#11541): a row can
146143
// host a multi-line editor, and the terminal input is one line.
147144
'markdown',
145+
// Multi-line plain text (objectui#11562). Not in the shared exclusion, so
146+
// the hosts always opened an editor for it; it was class D until the
147+
// terminal input's one line was measured to drop its line breaks.
148+
'textarea',
148149
]);
149150

150151
/**
@@ -412,16 +413,18 @@ export const InlineFieldInput: React.FC<InlineFieldInputProps> = ({
412413
if (editType === 'file' || editType === 'video' || editType === 'audio') {
413414
return <FileField field={field as any} value={value} onChange={(v: any) => onChange(v)} error={error} />;
414415
}
415-
// Markdown → `TextAreaField`, the fields package's multi-line widget, the one
416-
// a `textarea` field edits with in the record form and in a grid cell
417-
// (objectui#11541). The value is the markdown SOURCE, a plain string, and the
418-
// textarea hands it back exactly as typed, blank lines and trailing newline
419-
// included. The terminal input below cannot: it is one line, and the browser
420-
// strips line breaks from the value it is seeded with — "a one-line text box
421-
// is lossy", the reason #4228 recorded for keeping markdown out of this row.
422-
// `html` / `richtext` never reach this component: the hosts' gate still
423-
// excludes them.
424-
if (editType === 'markdown') {
416+
// Markdown and textarea → `TextAreaField`, the fields package's multi-line
417+
// widget, the one a `textarea` field edits with in the record form and in a
418+
// grid cell (objectui#11541 for markdown, objectui#11562 for textarea). The
419+
// value is a plain string — for markdown, its SOURCE — and the textarea hands
420+
// it back exactly as typed, blank lines and trailing newline included. The
421+
// terminal input below cannot: it is one line, and the browser strips line
422+
// breaks from the value it is seeded with — "a one-line text box is lossy",
423+
// the reason #4228 recorded for keeping markdown out of this row, and the
424+
// reason a stored multi-line note lost its line breaks on the first keystroke
425+
// while `textarea` still edited there. `html` / `richtext` never reach this
426+
// component: the hosts' gate still excludes them.
427+
if (editType === 'markdown' || editType === 'textarea') {
425428
return <TextAreaField field={field as any} value={value} onChange={(v) => onChange(v)} autoFocus={autoFocus} error={error} />;
426429
}
427430
// Reference fields (lookup / master_detail / tree / user / owner) store an id

‎packages/plugin-detail/src/__tests__/InlineFieldInput.delegation.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,10 @@ describe('types the fields package edits inline but the switch had no branch for
361361
// ---------------------------------------------------------------------------
362362

363363
describe('class D — benign string types keep the terminal text input (#4220)', () => {
364-
for (const type of ['text', 'textarea', 'email', 'url', 'phone']) {
364+
// `textarea` stood in this list until objectui#11562 routed it to the
365+
// multi-line `TextAreaField`; its pins are
366+
// `InlineFieldInput.textareaEditor-11562.test.tsx`.
367+
for (const type of ['text', 'email', 'url', 'phone']) {
365368
it(`\`${type}\` still edits in the plain input, and emits the typed string`, () => {
366369
const onChange = vi.fn();
367370
render(
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11562 — `record:details` edit mode edits a `textarea` field in a
11+
* MULTI-line editor, so a save writes back the text that was stored, line
12+
* breaks included.
13+
*
14+
* Before, `textarea` was a member of `INLINE_PLAIN_TEXT_FIELD_TYPES` (class D,
15+
* objectui#4220: "the stored value is already a string, nothing is lost"), so
16+
* the row edited in this component's terminal input, an `<input type="text">`.
17+
* That holds for a single-line value only. The browser strips every line break
18+
* from the value a text input is seeded with, so a stored note with blank lines
19+
* and a trailing newline was shown flattened, and one keystroke made the
20+
* flattened string the value the Save batch wrote. Nothing warned.
21+
*
22+
* The row now routes `textarea` to the fields package's `TextAreaField`, the
23+
* widget `markdown` got in objectui#11541 and the one a `textarea` field
24+
* already edits with in the record form and in a grid cell. The pins below
25+
* compare values BYTE for byte with blank lines and a trailing newline in them:
26+
* a single-line editor cannot pass them. A single-line value is pinned beside
27+
* them, so the move is shown to change nothing for it.
28+
*
29+
* The hosts' gate needed no change: `textarea` is not in the fields package's
30+
* shared `INLINE_EXCLUDED_FIELD_TYPES`, so both detail hosts already opened an
31+
* editor for it. It was the editor that was wrong.
32+
*/
33+
34+
import * as React from 'react';
35+
import { describe, it, expect, vi, beforeAll } from 'vitest';
36+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
37+
import { InlineEditProvider, useInlineEdit } from '@object-ui/react';
38+
import type { DetailViewSection } from '@object-ui/types';
39+
import { FieldEditWidget } from '@object-ui/fields';
40+
import { DetailSection } from '../DetailSection';
41+
import { HeaderHighlight } from '../HeaderHighlight';
42+
import { InlineEditSaveBar } from '../InlineEditSaveBar';
43+
import { InlineFieldInput, INLINE_PLAIN_TEXT_INPUT_TESTID } from '../InlineFieldInput';
44+
45+
/** A blank line inside and a trailing newline: the bytes a one-line box drops. */
46+
const MULTI_LINE = 'Call back Monday.\n\nBudget approved, needs legal review.\n';
47+
/** The control: a value a one-line box already kept intact. */
48+
const SINGLE_LINE = 'Call back Monday.';
49+
50+
const plainInput = (root: ParentNode = document) =>
51+
root.querySelector(`[data-testid="${INLINE_PLAIN_TEXT_INPUT_TESTID}"]`);
52+
const editors = (root: ParentNode) => root.querySelectorAll('input, textarea');
53+
const textareaIn = (root: ParentNode) => root.querySelector('textarea') as HTMLTextAreaElement | null;
54+
const hasPencil = () => screen.queryAllByLabelText('Double-click to edit').length > 0;
55+
56+
beforeAll(() => {
57+
// Desktop layout — the mobile branch renders its own read-only row shape.
58+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
59+
});
60+
61+
// ---------------------------------------------------------------------------
62+
// The editor itself
63+
// ---------------------------------------------------------------------------
64+
65+
describe('objectui#11562 — InlineFieldInput edits textarea in a multi-line textarea', () => {
66+
for (const [label, stored] of [['multi-line', MULTI_LINE], ['single-line', SINGLE_LINE]] as const) {
67+
it(`a ${label} value seeds a textarea byte for byte, never the terminal input`, () => {
68+
const { container } = render(
69+
<InlineFieldInput field={{ name: 'notes', type: 'textarea' }} value={stored} onChange={vi.fn()} />,
70+
);
71+
expect(plainInput(container)).toBeNull();
72+
expect(textareaIn(container)).not.toBeNull();
73+
expect(textareaIn(container)!.value).toBe(stored);
74+
});
75+
76+
it(`one keystroke on a ${label} value emits the stored text plus that keystroke, exactly`, () => {
77+
const onChange = vi.fn();
78+
const { container } = render(
79+
<InlineFieldInput field={{ name: 'notes', type: 'textarea' }} value={stored} onChange={onChange} />,
80+
);
81+
// Typed at the end of whatever the editor shows: the issue's repro. A
82+
// one-line box shows the flattened text, so it would emit that.
83+
const editor = container.querySelector('input, textarea') as HTMLTextAreaElement;
84+
fireEvent.change(editor, { target: { value: `${editor.value}!` } });
85+
expect(onChange).toHaveBeenCalledTimes(1);
86+
expect(onChange.mock.calls[0][0]).toBe(`${stored}!`);
87+
});
88+
}
89+
90+
it('takes the entered field’s focus, like the other routed editors', () => {
91+
const { container } = render(
92+
<InlineFieldInput field={{ name: 'notes', type: 'textarea' }} value={MULTI_LINE} onChange={vi.fn()} autoFocus />,
93+
);
94+
expect(textareaIn(container)).toBe(document.activeElement);
95+
});
96+
});
97+
98+
// ---------------------------------------------------------------------------
99+
// The two detail hosts open it
100+
// ---------------------------------------------------------------------------
101+
102+
const textareaSchema = { fields: { notes: { type: 'textarea', label: 'Notes' } } };
103+
const notesSection = { fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection;
104+
105+
describe('objectui#11562 — both detail hosts open the multi-line editor', () => {
106+
it('the details body shows the inline-edit affordance on a textarea row', () => {
107+
render(
108+
<DetailSection section={notesSection} data={{ notes: MULTI_LINE }} objectSchema={textareaSchema} onEnterInlineEdit={vi.fn()} />,
109+
);
110+
expect(hasPencil()).toBe(true);
111+
});
112+
113+
it('the details body renders one editor for the textarea row in edit mode, seeded byte for byte', () => {
114+
const { container } = render(
115+
<DetailSection
116+
section={notesSection}
117+
data={{ notes: MULTI_LINE }}
118+
objectSchema={textareaSchema}
119+
isEditing
120+
onEnterInlineEdit={vi.fn()}
121+
/>,
122+
);
123+
expect(editors(container)).toHaveLength(1);
124+
expect(plainInput(container)).toBeNull();
125+
expect(textareaIn(container)!.value).toBe(MULTI_LINE);
126+
});
127+
128+
it('the highlights strip opens the same editor inside the edit session', () => {
129+
function EnterEdit() {
130+
const inline = useInlineEdit();
131+
return <button type="button" onClick={() => inline!.enter('notes')}>enter-edit</button>;
132+
}
133+
const { container } = render(
134+
<InlineEditProvider canEdit>
135+
<HeaderHighlight fields={[{ name: 'notes', label: 'Notes' }]} data={{ notes: MULTI_LINE }} objectSchema={textareaSchema} />
136+
<EnterEdit />
137+
</InlineEditProvider>,
138+
);
139+
fireEvent.click(screen.getByText('enter-edit'));
140+
expect(plainInput(container)).toBeNull();
141+
expect(textareaIn(container)!.value).toBe(MULTI_LINE);
142+
});
143+
});
144+
145+
// ---------------------------------------------------------------------------
146+
// The save reads back unchanged
147+
// ---------------------------------------------------------------------------
148+
149+
describe('objectui#11562 — a textarea save reads back unchanged, line breaks included', () => {
150+
// The multi-line edit appends a line that ends in a newline too; the
151+
// single-line control appends words only, so it stays a one-line value.
152+
const CASES = [
153+
['multi-line', MULTI_LINE, 'Follow-up booked.\n'],
154+
['single-line', SINGLE_LINE, ' Follow-up booked.'],
155+
] as const;
156+
for (const [label, stored, typed] of CASES) {
157+
it(`a ${label} value: the PATCH body carries the edit byte for byte, and the refetched record seeds it back`, async () => {
158+
/** The server's copy of the record; `update` writes through the wire's JSON. */
159+
const store: Record<string, unknown> = { id: 'r1', notes: stored, updated_at: 'v1' };
160+
const wire: string[] = [];
161+
const update = vi.fn(async (_object: string, _id: string, patch: Record<string, unknown>) => {
162+
const body = JSON.stringify(patch);
163+
wire.push(body);
164+
Object.assign(store, JSON.parse(body), { updated_at: 'v2' });
165+
return {};
166+
});
167+
const edited = `${stored}${typed}`;
168+
169+
/** Stand-in for `DetailView`: the fetched record overlaid with the draft. */
170+
function Body({ record }: { record: Record<string, unknown> }) {
171+
const inline = useInlineEdit()!;
172+
return (
173+
<>
174+
<button type="button" onClick={() => inline.enter()}>enter-edit</button>
175+
<DetailSection
176+
section={notesSection}
177+
data={{ ...record, ...inline.draft }}
178+
objectSchema={textareaSchema}
179+
isEditing={inline.editing}
180+
onFieldChange={inline.setField}
181+
/>
182+
</>
183+
);
184+
}
185+
function Page() {
186+
const [record, setRecord] = React.useState<Record<string, unknown>>(() => ({ ...store }));
187+
const refresh = React.useCallback(async () => setRecord({ ...store }), []);
188+
return (
189+
<InlineEditProvider canEdit>
190+
<Body record={record} />
191+
<InlineEditSaveBar dataSource={{ update }} objectName="lead" recordId="r1" data={record} refresh={refresh} />
192+
</InlineEditProvider>
193+
);
194+
}
195+
196+
const { container } = render(<Page />);
197+
fireEvent.click(screen.getByText('enter-edit'));
198+
// Whatever editor the row renders, the user types onto what it SHOWS.
199+
const editor = container.querySelector('input, textarea') as HTMLTextAreaElement;
200+
expect(editor.value).toBe(stored);
201+
fireEvent.change(editor, { target: { value: `${editor.value}${typed}` } });
202+
203+
await act(async () => {
204+
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
205+
});
206+
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
207+
expect(wire).toHaveLength(1);
208+
expect(JSON.parse(wire[0])).toEqual({ notes: edited });
209+
expect(store.notes).toBe(edited);
210+
// Save leaves edit mode; re-entering seeds the editor from the refetched
211+
// record. Editor-agnostic on purpose, like the reads above: the editor's
212+
// KIND is pinned by the first describe, and this case is about bytes, so
213+
// the single-line control reads the same before the fix and after it.
214+
await waitFor(() => expect(container.querySelector('input, textarea')).toBeNull());
215+
fireEvent.click(screen.getByText('enter-edit'));
216+
expect((container.querySelector('input, textarea') as HTMLTextAreaElement).value).toBe(edited);
217+
});
218+
}
219+
});
220+
221+
// ---------------------------------------------------------------------------
222+
// The grid cell: unchanged, and now the same editor as the row
223+
// ---------------------------------------------------------------------------
224+
225+
describe('objectui#11562 — the grid cell editor is the same TextAreaField, unchanged', () => {
226+
it('`FieldEditWidget`, which the grid cell editor delegates to, seeds a textarea byte for byte', () => {
227+
// `ObjectGrid`'s `renderCellEditor` hands a `textarea` cell to
228+
// `FieldEditWidget`; this file's change does not touch that path. The pin
229+
// records that the row and the cell now edit with one widget.
230+
const { container } = render(
231+
<FieldEditWidget field={{ name: 'notes', type: 'textarea' }} value={MULTI_LINE} onChange={vi.fn()} />,
232+
);
233+
expect(textareaIn(container)!.value).toBe(MULTI_LINE);
234+
});
235+
});

0 commit comments

Comments
 (0)