Skip to content

Commit 950c768

Browse files
committed
fix(plugin-detail): record:details edit mode edits a markdown field in a multi-line textarea (objectui#11541)
A markdown row offered no pencil and no editor in edit mode: the detail hosts consult the fields package's shared inline-edit exclusion, and markdown is in it. #4228 recorded the detail-row reason as "heavy editor; a one-line text box is lossy". The row now routes markdown to the fields package's multi-line TextAreaField through DETAIL_ROUTED_INLINE_TYPES, the carve-out the upload widgets already use, so neither reason applies. The grid cell keeps the exclusion; html and richtext stay excluded. The INLINE_PLAIN_TEXT_FIELD_TYPES docblock no longer says the hosts never open an editor for markdown, and records that the one-line terminal input strips line breaks, which also holds for a multi-line textarea value. Pins: a markdown editor seeded and emitted byte for byte with blank lines and a trailing newline, both detail hosts, a save that reads back unchanged, html/richtext still open none, and the grid gate still refuses markdown. The type-coverage drift guard moves markdown to routed. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c476be0 commit 950c768

6 files changed

Lines changed: 328 additions & 7 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
`record:details` edit mode gives a `markdown` field an editor: a multi-line
6+
textarea (objectui#11541).
7+
8+
Before, a markdown row on the record page showed no pencil, and with the
9+
section in edit mode it stayed a read-only cell with no editor at all. The
10+
detail hosts consult the fields package's shared inline-edit exclusion, and
11+
`markdown` is in it. A markdown field that no form names, such as a
12+
`description` that is on no form, therefore had no editing surface anywhere in
13+
the UI.
14+
15+
The detail row now routes `markdown` to `TextAreaField`, the fields package's
16+
multi-line widget (the one a `textarea` field edits with in the record form),
17+
through the same detail-side carve-out the upload widgets use
18+
(`DETAIL_ROUTED_INLINE_TYPES`). The value is the markdown source, a plain
19+
string. The textarea hands it back exactly as typed, blank lines and trailing
20+
newline included, so a save writes back what was typed, byte for byte. It is a
21+
plain textarea, with no rendered preview.
22+
23+
Unchanged: `html` and `richtext` still open no inline editor on the record
24+
page, and a data-grid cell still opens none for `markdown`, `html` or
25+
`richtext`.

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
FileField,
2121
AvatarField,
2222
SignatureField,
23+
TextAreaField,
2324
AddressField,
2425
LocationField,
2526
GeolocationField,
@@ -72,9 +73,19 @@ export const INLINE_PLAIN_TEXT_INPUT_TESTID = 'inline-plain-text-input';
7273
*
7374
* `select` is deliberately absent: it has a routed branch (the picklist), and
7475
* only its degenerate options-less SINGLE-value form reaches the terminal
75-
* input — see {@link usesInlinePlainTextInput}. `markdown` / `html` /
76-
* `richtext` are absent because the hosts never open an editor for them at all
77-
* (they are in the fields package's shared exclusion, consulted since #4228).
76+
* input — see {@link usesInlinePlainTextInput}.
77+
*
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`.
7889
*/
7990
export const INLINE_PLAIN_TEXT_FIELD_TYPES = new Set<string>([
8091
'text',
@@ -131,6 +142,9 @@ export const INLINE_ROUTED_FIELD_TYPES = new Set<string>([
131142
// Binary / attachment — the detail page's exemption from the shared
132143
// exclusion (#4228): a row can host an upload widget, a grid cell cannot.
133144
'image', 'avatar', 'signature', 'file', 'video', 'audio',
145+
// Long-form markup source — the same exemption (objectui#11541): a row can
146+
// host a multi-line editor, and the terminal input is one line.
147+
'markdown',
134148
]);
135149

136150
/**
@@ -398,6 +412,18 @@ export const InlineFieldInput: React.FC<InlineFieldInputProps> = ({
398412
if (editType === 'file' || editType === 'video' || editType === 'audio') {
399413
return <FileField field={field as any} value={value} onChange={(v: any) => onChange(v)} error={error} />;
400414
}
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') {
425+
return <TextAreaField field={field as any} value={value} onChange={(v: any) => onChange(v)} autoFocus={autoFocus} error={error} />;
426+
}
401427
// Reference fields (lookup / master_detail / tree / user / owner) store an id
402428
// but may arrive `$expand`-ed as a record object. A plain text input would
403429
// stringify that to "[object Object]", so render the real picker. The value
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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#11541 — `record:details` edit mode gives a `markdown` field an
11+
* editor, and that editor is MULTI-line.
12+
*
13+
* Before, a markdown row offered no pencil and, with the section in edit mode,
14+
* no editor at all: `markdown` is in the fields package's shared exclusion, and
15+
* the detail hosts consult it since #4228. A markdown field that no form names
16+
* therefore had no editing surface anywhere.
17+
*
18+
* #4228 recorded its reason for excluding markdown from the detail row: "heavy
19+
* editor; a one-line text box is lossy". The one-line text box is this
20+
* component's terminal input (an `<input type="text">`), and the loss is
21+
* concrete: the browser strips every line break from the value such an input
22+
* is seeded with, so one keystroke writes the flattened text back. That is why
23+
* the detail row routes markdown to the fields package's multi-line
24+
* `TextAreaField` instead, and why the pins below compare values BYTE for byte with blank
25+
* lines and a trailing newline in them: a single-line editor cannot pass them.
26+
*
27+
* `html` and `richtext` stay excluded, unchanged; the grid cell keeps the
28+
* exclusion for `markdown` too (pinned in `plugin-grid`'s
29+
* `inline-edit-options.test.ts`, at the grid's own gate).
30+
*/
31+
32+
import * as React from 'react';
33+
import { describe, it, expect, vi, beforeAll } from 'vitest';
34+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
35+
import { InlineEditProvider, useInlineEdit } from '@object-ui/react';
36+
import type { DetailViewSection } from '@object-ui/types';
37+
import { DetailSection } from '../DetailSection';
38+
import { HeaderHighlight } from '../HeaderHighlight';
39+
import { InlineEditSaveBar } from '../InlineEditSaveBar';
40+
import { InlineFieldInput, INLINE_PLAIN_TEXT_INPUT_TESTID } from '../InlineFieldInput';
41+
42+
/** Blank lines inside, and a trailing newline: the bytes a one-line box drops. */
43+
const STORED = '# Deal notes\n\nFirst paragraph.\n\n- one\n- two\n';
44+
/** The edit appends a section that ends in a newline too. */
45+
const EDITED = `${STORED}\n## Follow-up\n\nSecond paragraph.\n`;
46+
47+
const plainInput = (root: ParentNode = document) =>
48+
root.querySelector(`[data-testid="${INLINE_PLAIN_TEXT_INPUT_TESTID}"]`);
49+
const editors = (root: ParentNode) => root.querySelectorAll('input, textarea');
50+
const hasPencil = () => screen.queryAllByLabelText('Double-click to edit').length > 0;
51+
52+
beforeAll(() => {
53+
// Desktop layout — the mobile branch renders its own read-only row shape.
54+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
55+
});
56+
57+
// ---------------------------------------------------------------------------
58+
// The editor itself
59+
// ---------------------------------------------------------------------------
60+
61+
describe('objectui#11541 — InlineFieldInput edits markdown in a multi-line textarea', () => {
62+
it('renders a textarea seeded with the stored value, byte for byte, never the terminal input', () => {
63+
const { container } = render(
64+
<InlineFieldInput field={{ name: 'notes', type: 'markdown' }} value={STORED} onChange={vi.fn()} />,
65+
);
66+
expect(plainInput(container)).toBeNull();
67+
const textarea = container.querySelector('textarea');
68+
expect(textarea).not.toBeNull();
69+
expect((textarea as HTMLTextAreaElement).value).toBe(STORED);
70+
});
71+
72+
it('emits the typed string exactly, newlines included', () => {
73+
const onChange = vi.fn();
74+
const { container } = render(
75+
<InlineFieldInput field={{ name: 'notes', type: 'markdown' }} value={STORED} onChange={onChange} />,
76+
);
77+
fireEvent.change(container.querySelector('textarea') as HTMLTextAreaElement, {
78+
target: { value: EDITED },
79+
});
80+
expect(onChange).toHaveBeenCalledTimes(1);
81+
expect(onChange.mock.calls[0][0]).toBe(EDITED);
82+
});
83+
84+
it('takes the entered field’s focus, like the other routed editors', () => {
85+
const { container } = render(
86+
<InlineFieldInput field={{ name: 'notes', type: 'markdown' }} value={STORED} onChange={vi.fn()} autoFocus />,
87+
);
88+
expect(container.querySelector('textarea')).toBe(document.activeElement);
89+
});
90+
});
91+
92+
// ---------------------------------------------------------------------------
93+
// The two detail hosts open it
94+
// ---------------------------------------------------------------------------
95+
96+
const markdownSchema = { fields: { notes: { type: 'markdown', label: 'Notes' } } };
97+
98+
describe('objectui#11541 — both detail hosts offer the markdown editor', () => {
99+
it('the details body shows the inline-edit affordance on a markdown row', () => {
100+
render(
101+
<DetailSection
102+
section={{ fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection}
103+
data={{ notes: STORED }}
104+
objectSchema={markdownSchema}
105+
onEnterInlineEdit={vi.fn()}
106+
/>,
107+
);
108+
expect(hasPencil()).toBe(true);
109+
});
110+
111+
it('the details body renders one editor for the markdown row in edit mode', () => {
112+
const { container } = render(
113+
<DetailSection
114+
section={{ fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection}
115+
data={{ notes: STORED }}
116+
objectSchema={markdownSchema}
117+
isEditing
118+
onEnterInlineEdit={vi.fn()}
119+
/>,
120+
);
121+
expect(editors(container)).toHaveLength(1);
122+
expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe(STORED);
123+
});
124+
125+
it('the highlights strip opens the same editor inside the edit session', () => {
126+
function EnterEdit() {
127+
const inline = useInlineEdit();
128+
return <button type="button" onClick={() => inline!.enter('notes')}>enter-edit</button>;
129+
}
130+
const { container } = render(
131+
<InlineEditProvider canEdit>
132+
<HeaderHighlight fields={[{ name: 'notes', label: 'Notes' }] as any} data={{ notes: STORED }} objectSchema={markdownSchema} />
133+
<EnterEdit />
134+
</InlineEditProvider>,
135+
);
136+
fireEvent.click(screen.getByText('enter-edit'));
137+
expect(plainInput(container)).toBeNull();
138+
expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe(STORED);
139+
});
140+
});
141+
142+
// ---------------------------------------------------------------------------
143+
// The save reads back unchanged
144+
// ---------------------------------------------------------------------------
145+
146+
describe('objectui#11541 — a markdown save reads back unchanged, newlines included', () => {
147+
it('the PATCH body carries the edited string byte for byte, and the refetched record seeds it back', async () => {
148+
/** The server's copy of the record; `update` writes through the wire's JSON. */
149+
const store: Record<string, unknown> = { id: 'r1', notes: STORED, updated_at: 'v1' };
150+
const wire: string[] = [];
151+
const update = vi.fn(async (_object: string, _id: string, patch: Record<string, unknown>) => {
152+
const body = JSON.stringify(patch);
153+
wire.push(body);
154+
Object.assign(store, JSON.parse(body), { updated_at: 'v2' });
155+
return {};
156+
});
157+
const section = { fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection;
158+
159+
/** Stand-in for `DetailView`: the fetched record overlaid with the draft. */
160+
function Body({ record }: { record: Record<string, unknown> }) {
161+
const inline = useInlineEdit()!;
162+
return (
163+
<>
164+
<button type="button" onClick={() => inline.enter()}>enter-edit</button>
165+
<DetailSection
166+
section={section}
167+
data={{ ...record, ...inline.draft }}
168+
objectSchema={markdownSchema}
169+
isEditing={inline.editing}
170+
onFieldChange={inline.setField}
171+
/>
172+
</>
173+
);
174+
}
175+
function Page() {
176+
const [record, setRecord] = React.useState<Record<string, unknown>>(() => ({ ...store }));
177+
const refresh = React.useCallback(async () => setRecord({ ...store }), []);
178+
return (
179+
<InlineEditProvider canEdit>
180+
<Body record={record} />
181+
<InlineEditSaveBar dataSource={{ update }} objectName="deal" recordId="r1" data={record} refresh={refresh} />
182+
</InlineEditProvider>
183+
);
184+
}
185+
186+
const { container } = render(<Page />);
187+
fireEvent.click(screen.getByText('enter-edit'));
188+
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
189+
expect(textarea.value).toBe(STORED);
190+
fireEvent.change(textarea, { target: { value: EDITED } });
191+
192+
await act(async () => {
193+
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
194+
});
195+
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
196+
expect(wire).toHaveLength(1);
197+
expect(JSON.parse(wire[0])).toEqual({ notes: EDITED });
198+
expect(store.notes).toBe(EDITED);
199+
// Save leaves edit mode; re-entering seeds the editor from the refetched record.
200+
await waitFor(() => expect(container.querySelector('textarea')).toBeNull());
201+
fireEvent.click(screen.getByText('enter-edit'));
202+
expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe(EDITED);
203+
});
204+
});
205+
206+
// ---------------------------------------------------------------------------
207+
// html / richtext — unchanged: still no editor
208+
// ---------------------------------------------------------------------------
209+
210+
describe('objectui#11541 — `html` and `richtext` still open no editor', () => {
211+
const MARKUP: Record<string, string> = { html: '<p>a</p>\n<p>b</p>', richtext: '<p>a</p>' };
212+
213+
for (const type of ['html', 'richtext']) {
214+
it(`a \`${type}\` row has no affordance, and no editor in edit mode`, () => {
215+
const schema = { fields: { body: { type, label: 'Body' } } };
216+
const section = { fields: [{ name: 'body', label: 'Body' }] } as unknown as DetailViewSection;
217+
render(
218+
<DetailSection section={section} data={{ body: MARKUP[type] }} objectSchema={schema} onEnterInlineEdit={vi.fn()} />,
219+
);
220+
expect(hasPencil()).toBe(false);
221+
const { container } = render(
222+
<DetailSection section={section} data={{ body: MARKUP[type] }} objectSchema={schema} isEditing onEnterInlineEdit={vi.fn()} />,
223+
);
224+
expect(editors(container)).toHaveLength(0);
225+
});
226+
227+
it(`a \`${type}\` highlight opens no editor inside the edit session`, () => {
228+
function EnterEdit() {
229+
const inline = useInlineEdit();
230+
return <button type="button" onClick={() => inline!.enter('body')}>enter-edit</button>;
231+
}
232+
const { container } = render(
233+
<InlineEditProvider canEdit>
234+
<HeaderHighlight
235+
fields={[{ name: 'body', label: 'Body' }] as any}
236+
data={{ body: MARKUP[type] }}
237+
objectSchema={{ fields: { body: { type, label: 'Body' } } }}
238+
/>
239+
<EnterEdit />
240+
</InlineEditProvider>,
241+
);
242+
fireEvent.click(screen.getByText('enter-edit'));
243+
expect(editors(container)).toHaveLength(0);
244+
});
245+
}
246+
});

‎packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ describe('inline-edit type coverage — every type has exactly one decision (#42
228228
expect(byBucket).toEqual({
229229
excluded: [
230230
'auto_number', 'autonumber', 'composite', 'filter-condition', 'formula',
231-
'grid', 'html', 'markdown', 'object', 'object-ref', 'password',
231+
'grid', 'html', 'object', 'object-ref', 'password',
232232
'recipient-picker', 'record', 'repeater', 'richtext', 'secret',
233233
'summary', 'vector',
234234
],
@@ -246,11 +246,14 @@ describe('inline-edit type coverage — every type has exactly one decision (#42
246246
// loud refusal the form gives; that disposition is pinned by
247247
// `InlineFieldInput.retiredFieldType.test.tsx`, which also asserts no
248248
// retired spelling can re-enter this set.
249+
// `markdown` sat in `excluded` until objectui#11541: the detail row now
250+
// routes it to the multi-line `TextAreaField`, and the grid cell keeps
251+
// the shared exclusion. `html` / `richtext` stay excluded, unchanged.
249252
routed: [
250253
'address', 'audio', 'avatar', 'boolean', 'currency', 'date', 'datetime',
251-
'file', 'geolocation', 'image', 'location', 'lookup', 'master_detail',
252-
'multiselect', 'number', 'percent', 'select', 'signature',
253-
'tree', 'user', 'video',
254+
'file', 'geolocation', 'image', 'location', 'lookup', 'markdown',
255+
'master_detail', 'multiselect', 'number', 'percent', 'select',
256+
'signature', 'tree', 'user', 'video',
254257
],
255258
delegated: ['checkboxes', 'code', 'color', 'json', 'progress', 'qrcode', 'radio', 'rating', 'slider', 'tags', 'time', 'toggle'],
256259
benign: ['email', 'phone', 'text', 'textarea', 'url'],

‎packages/plugin-detail/src/fieldEnrichment.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ export function isComputedFieldType(
9292
* widgets the record form uses, added deliberately so inline edit could preview,
9393
* replace and remove files instead of showing a bare storage URL.
9494
*
95+
* `markdown` is exempt on the same footing (objectui#11541). The shared set
96+
* excludes it among the "Heavy / full editors — better in the record form than
97+
* a cell", which is again an argument about a CELL. #4228 kept it out of the
98+
* detail row for the reason it recorded there: "a one-line text box is lossy".
99+
* That box was the only editor the row then had for it — the terminal text
100+
* input, which is one line, so the browser strips every line break from the
101+
* value it is seeded with and the next keystroke writes the flattened text
102+
* back. A markdown value is a plain string, so a MULTI-line editor loses
103+
* nothing, and `InlineFieldInput` routes it to `TextAreaField`, the fields
104+
* package's multi-line widget (the one a `textarea` field edits with in the
105+
* record form and in a grid cell), never to that terminal input. The grid
106+
* keeps the exclusion for `markdown`. `html` and
107+
* `richtext` are not exempt: they stay excluded, unchanged.
108+
*
95109
* The credential and container members are excluded for a VALUE reason — masked
96110
* on read, or object-shaped — and that argument transfers to the detail page
97111
* verbatim, because the detail fallback is the same plain text input the grid's
@@ -110,6 +124,7 @@ export const DETAIL_ROUTED_INLINE_TYPES = new Set<string>([
110124
'file',
111125
'video',
112126
'audio',
127+
'markdown',
113128
]);
114129

115130
/**

0 commit comments

Comments
 (0)