Skip to content

Commit b34cc14

Browse files
fix(plugin-detail): record:details edit mode edits a markdown field in a multi-line textarea (objectui#11541) (#11559)
Fixes #11541 Clause-②: no ## What changed `record:details` edit mode now gives a `markdown` field an editor: the fields package's multi-line `TextAreaField`. - `packages/plugin-detail/src/fieldEnrichment.ts`: `markdown` joins `DETAIL_ROUTED_INLINE_TYPES`, the detail page's carve-out from the fields package's shared `INLINE_EXCLUDED_FIELD_TYPES` (the carve-out the upload widgets already use). Both detail hosts (`DetailSection`, `HeaderHighlight`) read it through `isInlineExcludedDetailFieldType`, so both now open the editor. `INLINE_EXCLUDED_FIELD_TYPES` is untouched, so a grid cell still excludes `markdown`. - `packages/plugin-detail/src/InlineFieldInput.tsx`: a routed `markdown` branch renders `TextAreaField`, and `markdown` joins `INLINE_ROUTED_FIELD_TYPES`. The `INLINE_PLAIN_TEXT_FIELD_TYPES` docblock no longer says the hosts never open an editor for markdown. - `html` and `richtext`: unchanged, still excluded. - Drift guard `inlineEditTypeCoverage.test.tsx`: `markdown` moves from `excluded` to `routed`. - One `@object-ui/plugin-detail` patch changeset. ## objectui#4228's reason, read first (the ruling's stop condition) objectui#4228's blast-radius table records its detail-row reason for `markdown`: "heavy editor; a one-line text box is lossy". The shared set's own comment is "Heavy / full editors — better in the record form than a cell", which is an argument about a grid cell. Neither holds for a multi-line editor in a detail row, so the stop condition does not fire. ## Deviation from the ruling's wording: `textarea` has no multi-line editor in `record:details` The ruling says "`markdown` gets the multi-line editor `textarea` already uses in `record:details` edit mode". Measured on `origin/main` `c476be0`: `textarea` is a member of `INLINE_PLAIN_TEXT_FIELD_TYPES`, so in `record:details` edit mode it edits in the terminal plain-text input, an INPUT element of type text. That is a ONE-line box, the very "one-line text box" objectui#4228 kept markdown out of. - Chromium 141 (headless, `/opt/pw-browsers/chromium`): an INPUT of type text given the value `"# T\n\npara\n"` reads back `"# Tpara"`. A TEXTAREA reads back the bytes it was given. HTML value sanitization for a text input strips line breaks. - happy-dom 20.11.2 (this repo's test environment) answers the same: `"# Tpara"`. - Component level, at `c476be0`: `InlineFieldInput` with `type: 'textarea'` and a multi-line value renders that INPUT, its value reads `"# TitleFirst paragraph.- a- b"`, and one keystroke emits the flattened string. Routing markdown onto textarea's branch would therefore land it on the lossy box and fail the ruling's own pin ("its save reads back unchanged, newlines included"). Ablation leg A below measures exactly that. The ruling's intent, a multi-line editor that is lossless for a stored string, is met with `TextAreaField`: the fields package's multi-line widget, the one a `textarea` field edits with in the record form and in a grid cell (`FieldEditWidget` maps `textarea` to it). As a consequence `markdown` lands in the drift guard's `routed` bucket, not in `textarea`'s `benign` one. ## Pins New file `InlineFieldInput.markdownEditor-11541.test.tsx`. Its values carry blank lines and a trailing newline, and are compared byte for byte. - `InlineFieldInput`: a TEXTAREA seeded with the stored value, never the terminal input; typing emits the exact string; `autoFocus` lands on it. - `DetailSection`: the pencil shows on a markdown 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 notes field set to the edited string, the store holds it, and re-entering edit seeds the editor with it. - `html` / `richtext`: no pencil and no editor on either host. - Grid: `plugin-grid`'s `inline-edit-options.test.ts` gains one assertion, `isFieldInlineEditable({ type: 'markdown' })` is false, at the grid's own gate. ## Verification At `950c768` (the change itself): - `pnpm exec turbo run build --filter="@object-ui/plugin-detail^..." --filter="@object-ui/plugin-grid^..." --concurrency=2`: 13 of 13 tasks successful. - `pnpm --filter @object-ui/plugin-detail --filter @object-ui/plugin-grid run type-check`: both Done, exit 0. `tsc -p tsconfig.test.json --listFiles` confirms both test projects compile the touched test files. - `pnpm exec vitest run --maxWorkers=2 packages/plugin-detail/ packages/plugin-grid/src/inline-edit-options.test.ts`: Test Files 233 passed, 1 skipped (234); Tests 2318 passed, 8 skipped. Ablations, run after the change was committed. Each mutation went through `ablation-replace.mjs` (anchor must hit, blob must change) with an EXIT/INT/TERM restore; each restore is proven by the blob hash equal to HEAD's and an empty `git diff HEAD`. - Leg A, the ruling's literal route (markdown falls to textarea's terminal input): 8 failed, 159 passed. The first failure prints the text INPUT holding the value. - Leg B, no carve-out (`markdown` removed from `DETAIL_ROUTED_INLINE_TYPES`): 7 failed, 160 passed (both hosts, the round trip, the blast-radius row, the drift guard's partition). - Leg C, `markdown` removed from the shared `INLINE_EXCLUDED_FIELD_TYPES`: the grid pin goes red, 1 failed, 14 passed. At `c65a031` (final head; a typing-only follow-up that removes two `as any` casts and one `any` parameter annotation): - `pnpm --filter @object-ui/plugin-detail run type-check`: exit 0. - `pnpm exec vitest run --maxWorkers=2` over the six touched or adjacent test files: 6 files, 215 tests passed. - eslint on the touched files: 0 errors. The warnings are all `no-explicit-any`; one is on a line this PR adds, the new markdown branch's `field as any`, written the way its sibling branches are. - `check:control-bytes`, `check:new-line-citations` (0 new, HEAD `c65a031`), `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-presence`, `check-changeset-overwrite`, `check-changeset-no-major`: all exit 0. - Declared narrowing: the full `plugin-detail` suite ran at `950c768` and was not re-run at `c65a031`. The difference between them is type-only. CI runs the full farm. ## Acceptance notes - Out of scope, reported for the seat, not changed here: in `record:details` edit mode a `textarea` field edits in the one-line terminal input, so a stored multi-line value loses its line breaks on the first keystroke and the save writes the flattened text (measured above). This PR leaves `textarea` alone; the `INLINE_PLAIN_TEXT_FIELD_TYPES` docblock now says the line-break loss holds for it, instead of letting "nothing is lost" read as covering it. - The record form edits `markdown` with `RichTextField` (a Textarea plus a format header). This PR follows the ruling's "the multi-line editor `textarea` uses" and routes to `TextAreaField`. Moving the detail row to `RichTextField` would be a one-line change if the maintainer prefers the form's own markdown widget. - The brief expected existing textarea save pins in `InlineFieldInput.test.tsx`; there are none. The round-trip harness follows `InlineEditSaveBar.cascadePruneWire-10291.test.tsx`. - objectui#11512's `packages/plugin-detail/README.md` is not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b253c4e commit b34cc14

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