Skip to content

Commit ab18797

Browse files
fix(plugin-detail): record:details edit mode edits a textarea field in a multi-line textarea (objectui#11562) (#11567)
Fixes #11562 Clause-②: no ## What changed `record:details` edit mode now edits a `textarea` field in the fields package's multi-line `TextAreaField`, so a save keeps the line breaks the value was stored with. - `packages/plugin-detail/src/InlineFieldInput.tsx`: `textarea` leaves `INLINE_PLAIN_TEXT_FIELD_TYPES` (class D, objectui#4220) and joins `INLINE_ROUTED_FIELD_TYPES`. It takes the branch `markdown` got in objectui#11541, which renders `TextAreaField`. The set's docblock drops the warning objectui#11541 wrote about `textarea`. Its "nothing is lost" sentence now covers only the single-line members left: `text`, `email`, `phone`, `url`. - Drift guard `inlineEditTypeCoverage.test.tsx`: `textarea` moves from `benign` to `routed`. The benign bucket's wording now says single-line. - `InlineFieldInput.delegation.test.tsx`: `textarea` leaves the class-D loop. The class-D pins for `text`, `email`, `url` and `phone` are unchanged and green. - New pin file `InlineFieldInput.textareaEditor-11562.test.tsx`. - One `@object-ui/plugin-detail` patch changeset. ## Deviation from the ruling's mechanism words: `DETAIL_ROUTED_INLINE_TYPES` is untouched The ruling reads "Route `textarea` through `DETAIL_ROUTED_INLINE_TYPES` to the fields package's multi-line `TextAreaField`". The PR does not add `textarea` to that set. Measured, it is the wrong lever for this type: - `DETAIL_ROUTED_INLINE_TYPES` exists only to exempt members of the fields package's shared `INLINE_EXCLUDED_FIELD_TYPES` from the detail hosts' gate. `markdown` needed it because `markdown` is in that set. `textarea` is not (`EDIT_WIDGETS` maps it to `TextAreaField`), so both hosts already opened an editor for it. On `origin/main` the pin "the details body shows the inline-edit affordance on a textarea row" is green, and the row in edit mode renders exactly one editor: the one-line terminal input. - Adding it anyway is refused by an existing pin. Probe leg D below: with `textarea` in `DETAIL_ROUTED_INLINE_TYPES`, `inlineCredentialGate.test.tsx` fails "every exempted type is a member of the shared exclusion (no free-floating entries)" with "textarea must be in the shared set". Nothing else changes. The substance of the ruling lands as written. The row routes `textarea` to `TextAreaField`, and the drift guard moves it from "benign" to "routed". The route lives where `markdown`'s editor lives: `INLINE_ROUTED_FIELD_TYPES` and the editor branch. ## The defect, measured On `origin/main` `072b7e8`, `textarea` edited in the terminal input, an INPUT element of type text. The browser strips line breaks from the value such an input is given. A stored note `"Call back Monday.\n\nBudget approved, needs legal review.\n"` showed as `"Call back Monday.Budget approved, needs legal review."`. One keystroke made that flattened string the value the Save batch wrote. ## Pins New file `InlineFieldInput.textareaEditor-11562.test.tsx`. The multi-line value has a blank line inside and a trailing newline, and every comparison is byte for byte. - `InlineFieldInput`: a TEXTAREA seeded with the stored value, never the terminal input. One keystroke appended to what the editor shows emits the stored text plus that keystroke. `autoFocus` lands on it. - `DetailSection`: the pencil shows on a textarea row. Edit mode renders one editor, seeded byte-identical. - `HeaderHighlight`: the same editor inside the edit session. - Save round trip: `InlineEditProvider` + `DetailSection` + `InlineEditSaveBar` over a store-backed `update`. The PATCH body is exactly the edited string, the store holds it, and re-entering edit seeds the editor with it. - Single-line control: the keystroke pin and the round trip also run with `"Call back Monday."`. Both are green on `origin/main` and after the fix, so a single-line value saves exactly as before. - Grid cell: `FieldEditWidget`, which `ObjectGrid`'s cell editor delegates to, seeds a TEXTAREA with the multi-line value byte for byte. This PR does not touch that path. The pin records that the row and the cell now edit with one widget. ## Verification All at `5af2bb9` (the only commit), from the worktree. - `pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-detail^..." build`: exit 0. 12 projects in scope, all 11 with a build script printed Done (`test-support` has none). - `pnpm exec vitest run --maxWorkers=2 packages/plugin-detail/`: Test Files 233 passed, 1 skipped (234). Tests 2313 passed, 8 skipped (2321). - `pnpm --filter @object-ui/plugin-detail run type-check`: exit 0. `tsc -p tsconfig.test.json --listFiles` lists all three touched test files. - eslint over the 4 touched source and test files (`--no-inline-config --format json`): 4 files, 0 errors. The three edited files carry the same warning counts as at `072b7e8` (42, 1 and 0, all `no-explicit-any` or `react-refresh`), and the new file carries none. `eslint.config.js` sets no `parserOptions` project, so linting is not type-aware and this diff cannot change the result for an untouched file. - `check:control-bytes`, `check:new-line-citations` (0 new), `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-presence`, `check-changeset-overwrite`, `check-changeset-no-major`, `check-changeset-fixed`, `check-test-path-roots`, the three `check-vi-mock-*` checks, `check-unreferenced-sources`, `check-type-check-coverage`, `check-lint-coverage`: all exit 0. Ablations, after the commit. Each mutation went through objectstack's `ablation-replace.mjs`, or a trap-armed whole-file swap for leg C. Each restore is proven by the blob hash equal to HEAD's and an empty `git diff HEAD`. Each leg ran the new pin file, the drift guard and the delegation suite. - Leg A, `textarea` put back into `INLINE_PLAIN_TEXT_FIELD_TYPES`: 2 failed, 113 passed. The drift guard's "no type is claimed twice" and its benign-list pin fail. The new pins stay green because the routed branch runs before the class-D test. A first attempt at this leg did not run: the tool refused an anchor that its own replacement contained, and nothing was measured. - Leg B, only the branch's `textarea` disjunct deleted: green, 115 passed. This direction is the expected one. The type is no longer class D, so it falls to the delegation tail, and `FieldEditWidget` renders the same `TextAreaField`. See the acceptance notes. - Leg C, `InlineFieldInput.tsx` swapped whole to `072b7e8` (byte-identical to `origin/main`): 9 failed, 106 passed. 7 are in the new pin file: every multi-line pin and every pin on the editor's kind. The 2 single-line controls stay green. The other 2 are in the drift guard. - Leg D, the ruling's mechanism words: `textarea` added to `DETAIL_ROUTED_INLINE_TYPES` (with `inlineCredentialGate.test.tsx` in the run): 1 failed, 151 passed, as described above. CI runs the full farm. Locally only `plugin-detail` was run. The plugin-detail sets this PR edits are not exported from the package index, and outside `plugin-detail` no test imports them or queries the terminal input on a `textarea` row. ## Acceptance notes - Leg B shows that for `textarea` the explicit branch and the delegation tail reach the same widget. So the drift guard cannot tell a routed `textarea` from a delegated one: deleting the branch alone stays green, and the user still gets `TextAreaField`. The branch follows the ruling's "routed" bucket and `markdown`'s precedent. What the pins do guard is the actual regression, `textarea` landing back on the one-line input (leg C, and leg A at the guard). - The grid's record overlay (`RecordDetailPanel`, which `plugin-grid` imports from this package) renders the same detail hosts, so it gets the multi-line editor too. The grid CELL still edits with `FieldEditWidget`, unchanged. - `packages/plugin-detail/README.md` is not touched. objectui#11512 edits it, and it does not describe per-type inline editors. No `content/docs` page lists which editor a detail row uses per type, so no guide page changes. - Reach, from the card: `notes: Field.textarea` on objectstack `examples/app-crm`'s lead and `examples/app-showcase`'s task. --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent dbd1081 commit ab18797

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)