diff --git a/.changeset/11541-detail-markdown-editor.md b/.changeset/11541-detail-markdown-editor.md
new file mode 100644
index 0000000000..f1e21eec63
--- /dev/null
+++ b/.changeset/11541-detail-markdown-editor.md
@@ -0,0 +1,25 @@
+---
+'@object-ui/plugin-detail': patch
+---
+
+`record:details` edit mode gives a `markdown` field an editor: a multi-line
+textarea (objectui#11541).
+
+Before, a markdown row on the record page showed no pencil, and with the
+section in edit mode it stayed a read-only cell with no editor at all. The
+detail hosts consult the fields package's shared inline-edit exclusion, and
+`markdown` is in it. A markdown field that no form names, such as a
+`description` that is on no form, therefore had no editing surface anywhere in
+the UI.
+
+The detail row now routes `markdown` to `TextAreaField`, the fields package's
+multi-line widget (the one a `textarea` field edits with in the record form),
+through the same detail-side carve-out the upload widgets use
+(`DETAIL_ROUTED_INLINE_TYPES`). The value is the markdown source, a plain
+string. The textarea hands it back exactly as typed, blank lines and trailing
+newline included, so a save writes back what was typed, byte for byte. It is a
+plain textarea, with no rendered preview.
+
+Unchanged: `html` and `richtext` still open no inline editor on the record
+page, and a data-grid cell still opens none for `markdown`, `html` or
+`richtext`.
diff --git a/packages/plugin-detail/src/InlineFieldInput.tsx b/packages/plugin-detail/src/InlineFieldInput.tsx
index 638cc82be2..af87f9c4a4 100644
--- a/packages/plugin-detail/src/InlineFieldInput.tsx
+++ b/packages/plugin-detail/src/InlineFieldInput.tsx
@@ -20,6 +20,7 @@ import {
FileField,
AvatarField,
SignatureField,
+ TextAreaField,
AddressField,
LocationField,
GeolocationField,
@@ -72,9 +73,19 @@ export const INLINE_PLAIN_TEXT_INPUT_TESTID = 'inline-plain-text-input';
*
* `select` is deliberately absent: it has a routed branch (the picklist), and
* only its degenerate options-less SINGLE-value form reaches the terminal
- * input — see {@link usesInlinePlainTextInput}. `markdown` / `html` /
- * `richtext` are absent because the hosts never open an editor for them at all
- * (they are in the fields package's shared exclusion, consulted since #4228).
+ * input — see {@link usesInlinePlainTextInput}.
+ *
+ * `markdown` is absent although its value is a string (objectui#11541): it is
+ * ROUTED, to the multi-line `TextAreaField`, because this terminal input is a
+ * single-line `` and the browser strips line breaks from the
+ * value it is seeded with — one keystroke and the flattened text is what gets
+ * written. `html` / `richtext` are absent because the hosts never open an
+ * editor for them at all (they are in the fields package's shared exclusion,
+ * consulted since #4228).
+ *
+ * ⚠️ That line-break loss holds for `textarea`, which IS a member, whenever its
+ * stored value spans lines. Recorded here so the "nothing is lost" above is not
+ * read as covering it; objectui#11541 does not change `textarea`.
*/
export const INLINE_PLAIN_TEXT_FIELD_TYPES = new Set([
'text',
@@ -131,6 +142,9 @@ export const INLINE_ROUTED_FIELD_TYPES = new Set([
// Binary / attachment — the detail page's exemption from the shared
// exclusion (#4228): a row can host an upload widget, a grid cell cannot.
'image', 'avatar', 'signature', 'file', 'video', 'audio',
+ // Long-form markup source — the same exemption (objectui#11541): a row can
+ // host a multi-line editor, and the terminal input is one line.
+ 'markdown',
]);
/**
@@ -398,6 +412,18 @@ export const InlineFieldInput: React.FC = ({
if (editType === 'file' || editType === 'video' || editType === 'audio') {
return onChange(v)} error={error} />;
}
+ // Markdown → `TextAreaField`, the fields package's multi-line widget, the one
+ // a `textarea` field edits with in the record form and in a grid cell
+ // (objectui#11541). The value is the markdown SOURCE, a plain string, and the
+ // textarea hands it back exactly as typed, blank lines and trailing newline
+ // included. The terminal input below cannot: it is one line, and the browser
+ // strips line breaks from the value it is seeded with — "a one-line text box
+ // is lossy", the reason #4228 recorded for keeping markdown out of this row.
+ // `html` / `richtext` never reach this component: the hosts' gate still
+ // excludes them.
+ if (editType === 'markdown') {
+ return onChange(v)} autoFocus={autoFocus} error={error} />;
+ }
// Reference fields (lookup / master_detail / tree / user / owner) store an id
// but may arrive `$expand`-ed as a record object. A plain text input would
// stringify that to "[object Object]", so render the real picker. The value
diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.markdownEditor-11541.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.markdownEditor-11541.test.tsx
new file mode 100644
index 0000000000..0578cb2007
--- /dev/null
+++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.markdownEditor-11541.test.tsx
@@ -0,0 +1,246 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#11541 — `record:details` edit mode gives a `markdown` field an
+ * editor, and that editor is MULTI-line.
+ *
+ * Before, a markdown row offered no pencil and, with the section in edit mode,
+ * no editor at all: `markdown` is in the fields package's shared exclusion, and
+ * the detail hosts consult it since #4228. A markdown field that no form names
+ * therefore had no editing surface anywhere.
+ *
+ * #4228 recorded its reason for excluding markdown from the detail row: "heavy
+ * editor; a one-line text box is lossy". The one-line text box is this
+ * component's terminal input (an ``), and the loss is
+ * concrete: the browser strips every line break from the value such an input
+ * is seeded with, so one keystroke writes the flattened text back. That is why
+ * the detail row routes markdown to the fields package's multi-line
+ * `TextAreaField` instead, and why the pins below compare values BYTE for byte with blank
+ * lines and a trailing newline in them: a single-line editor cannot pass them.
+ *
+ * `html` and `richtext` stay excluded, unchanged; the grid cell keeps the
+ * exclusion for `markdown` too (pinned in `plugin-grid`'s
+ * `inline-edit-options.test.ts`, at the grid's own gate).
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, beforeAll } from 'vitest';
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { InlineEditProvider, useInlineEdit } from '@object-ui/react';
+import type { DetailViewSection } from '@object-ui/types';
+import { DetailSection } from '../DetailSection';
+import { HeaderHighlight } from '../HeaderHighlight';
+import { InlineEditSaveBar } from '../InlineEditSaveBar';
+import { InlineFieldInput, INLINE_PLAIN_TEXT_INPUT_TESTID } from '../InlineFieldInput';
+
+/** Blank lines inside, and a trailing newline: the bytes a one-line box drops. */
+const STORED = '# Deal notes\n\nFirst paragraph.\n\n- one\n- two\n';
+/** The edit appends a section that ends in a newline too. */
+const EDITED = `${STORED}\n## Follow-up\n\nSecond paragraph.\n`;
+
+const plainInput = (root: ParentNode = document) =>
+ root.querySelector(`[data-testid="${INLINE_PLAIN_TEXT_INPUT_TESTID}"]`);
+const editors = (root: ParentNode) => root.querySelectorAll('input, textarea');
+const hasPencil = () => screen.queryAllByLabelText('Double-click to edit').length > 0;
+
+beforeAll(() => {
+ // Desktop layout — the mobile branch renders its own read-only row shape.
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
+});
+
+// ---------------------------------------------------------------------------
+// The editor itself
+// ---------------------------------------------------------------------------
+
+describe('objectui#11541 — InlineFieldInput edits markdown in a multi-line textarea', () => {
+ it('renders a textarea seeded with the stored value, byte for byte, never the terminal input', () => {
+ const { container } = render(
+ ,
+ );
+ expect(plainInput(container)).toBeNull();
+ const textarea = container.querySelector('textarea');
+ expect(textarea).not.toBeNull();
+ expect((textarea as HTMLTextAreaElement).value).toBe(STORED);
+ });
+
+ it('emits the typed string exactly, newlines included', () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ fireEvent.change(container.querySelector('textarea') as HTMLTextAreaElement, {
+ target: { value: EDITED },
+ });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange.mock.calls[0][0]).toBe(EDITED);
+ });
+
+ it('takes the entered field’s focus, like the other routed editors', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('textarea')).toBe(document.activeElement);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The two detail hosts open it
+// ---------------------------------------------------------------------------
+
+const markdownSchema = { fields: { notes: { type: 'markdown', label: 'Notes' } } };
+
+describe('objectui#11541 — both detail hosts offer the markdown editor', () => {
+ it('the details body shows the inline-edit affordance on a markdown row', () => {
+ render(
+ ,
+ );
+ expect(hasPencil()).toBe(true);
+ });
+
+ it('the details body renders one editor for the markdown row in edit mode', () => {
+ const { container } = render(
+ ,
+ );
+ expect(editors(container)).toHaveLength(1);
+ expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe(STORED);
+ });
+
+ it('the highlights strip opens the same editor inside the edit session', () => {
+ function EnterEdit() {
+ const inline = useInlineEdit();
+ return ;
+ }
+ const { container } = render(
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('enter-edit'));
+ expect(plainInput(container)).toBeNull();
+ expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe(STORED);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The save reads back unchanged
+// ---------------------------------------------------------------------------
+
+describe('objectui#11541 — a markdown save reads back unchanged, newlines included', () => {
+ it('the PATCH body carries the edited string byte for byte, and the refetched record seeds it back', async () => {
+ /** The server's copy of the record; `update` writes through the wire's JSON. */
+ const store: Record = { id: 'r1', notes: STORED, updated_at: 'v1' };
+ const wire: string[] = [];
+ const update = vi.fn(async (_object: string, _id: string, patch: Record) => {
+ const body = JSON.stringify(patch);
+ wire.push(body);
+ Object.assign(store, JSON.parse(body), { updated_at: 'v2' });
+ return {};
+ });
+ const section = { fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection;
+
+ /** Stand-in for `DetailView`: the fetched record overlaid with the draft. */
+ function Body({ record }: { record: Record }) {
+ const inline = useInlineEdit()!;
+ return (
+ <>
+
+
+ >
+ );
+ }
+ function Page() {
+ const [record, setRecord] = React.useState>(() => ({ ...store }));
+ const refresh = React.useCallback(async () => setRecord({ ...store }), []);
+ return (
+
+
+
+
+ );
+ }
+
+ const { container } = render();
+ fireEvent.click(screen.getByText('enter-edit'));
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
+ expect(textarea.value).toBe(STORED);
+ fireEvent.change(textarea, { target: { value: EDITED } });
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+ });
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
+ expect(wire).toHaveLength(1);
+ expect(JSON.parse(wire[0])).toEqual({ notes: EDITED });
+ expect(store.notes).toBe(EDITED);
+ // Save leaves edit mode; re-entering seeds the editor from the refetched record.
+ await waitFor(() => expect(container.querySelector('textarea')).toBeNull());
+ fireEvent.click(screen.getByText('enter-edit'));
+ expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe(EDITED);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// html / richtext — unchanged: still no editor
+// ---------------------------------------------------------------------------
+
+describe('objectui#11541 — `html` and `richtext` still open no editor', () => {
+ const MARKUP: Record = { html: '
a
\n
b
', richtext: '
a
' };
+
+ for (const type of ['html', 'richtext']) {
+ it(`a \`${type}\` row has no affordance, and no editor in edit mode`, () => {
+ const schema = { fields: { body: { type, label: 'Body' } } };
+ const section = { fields: [{ name: 'body', label: 'Body' }] } as unknown as DetailViewSection;
+ render(
+ ,
+ );
+ expect(hasPencil()).toBe(false);
+ const { container } = render(
+ ,
+ );
+ expect(editors(container)).toHaveLength(0);
+ });
+
+ it(`a \`${type}\` highlight opens no editor inside the edit session`, () => {
+ function EnterEdit() {
+ const inline = useInlineEdit();
+ return ;
+ }
+ const { container } = render(
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('enter-edit'));
+ expect(editors(container)).toHaveLength(0);
+ });
+ }
+});
diff --git a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx
index ca6b12c0f9..a18d5669b4 100644
--- a/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx
+++ b/packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx
@@ -228,7 +228,7 @@ describe('inline-edit type coverage — every type has exactly one decision (#42
expect(byBucket).toEqual({
excluded: [
'auto_number', 'autonumber', 'composite', 'filter-condition', 'formula',
- 'grid', 'html', 'markdown', 'object', 'object-ref', 'password',
+ 'grid', 'html', 'object', 'object-ref', 'password',
'recipient-picker', 'record', 'repeater', 'richtext', 'secret',
'summary', 'vector',
],
@@ -246,11 +246,14 @@ describe('inline-edit type coverage — every type has exactly one decision (#42
// loud refusal the form gives; that disposition is pinned by
// `InlineFieldInput.retiredFieldType.test.tsx`, which also asserts no
// retired spelling can re-enter this set.
+ // `markdown` sat in `excluded` until objectui#11541: the detail row now
+ // routes it to the multi-line `TextAreaField`, and the grid cell keeps
+ // the shared exclusion. `html` / `richtext` stay excluded, unchanged.
routed: [
'address', 'audio', 'avatar', 'boolean', 'currency', 'date', 'datetime',
- 'file', 'geolocation', 'image', 'location', 'lookup', 'master_detail',
- 'multiselect', 'number', 'percent', 'select', 'signature',
- 'tree', 'user', 'video',
+ 'file', 'geolocation', 'image', 'location', 'lookup', 'markdown',
+ 'master_detail', 'multiselect', 'number', 'percent', 'select',
+ 'signature', 'tree', 'user', 'video',
],
delegated: ['checkboxes', 'code', 'color', 'json', 'progress', 'qrcode', 'radio', 'rating', 'slider', 'tags', 'time', 'toggle'],
benign: ['email', 'phone', 'text', 'textarea', 'url'],
diff --git a/packages/plugin-detail/src/fieldEnrichment.ts b/packages/plugin-detail/src/fieldEnrichment.ts
index 91f8ada6ea..40fd9935e0 100644
--- a/packages/plugin-detail/src/fieldEnrichment.ts
+++ b/packages/plugin-detail/src/fieldEnrichment.ts
@@ -92,6 +92,20 @@ export function isComputedFieldType(
* widgets the record form uses, added deliberately so inline edit could preview,
* replace and remove files instead of showing a bare storage URL.
*
+ * `markdown` is exempt on the same footing (objectui#11541). The shared set
+ * excludes it among the "Heavy / full editors — better in the record form than
+ * a cell", which is again an argument about a CELL. #4228 kept it out of the
+ * detail row for the reason it recorded there: "a one-line text box is lossy".
+ * That box was the only editor the row then had for it — the terminal text
+ * input, which is one line, so the browser strips every line break from the
+ * value it is seeded with and the next keystroke writes the flattened text
+ * back. A markdown value is a plain string, so a MULTI-line editor loses
+ * nothing, and `InlineFieldInput` routes it to `TextAreaField`, the fields
+ * package's multi-line widget (the one a `textarea` field edits with in the
+ * record form and in a grid cell), never to that terminal input. The grid
+ * keeps the exclusion for `markdown`. `html` and
+ * `richtext` are not exempt: they stay excluded, unchanged.
+ *
* The credential and container members are excluded for a VALUE reason — masked
* on read, or object-shaped — and that argument transfers to the detail page
* 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([
'file',
'video',
'audio',
+ 'markdown',
]);
/**
diff --git a/packages/plugin-grid/src/inline-edit-options.test.ts b/packages/plugin-grid/src/inline-edit-options.test.ts
index 0f8260bd5c..baee4f8932 100644
--- a/packages/plugin-grid/src/inline-edit-options.test.ts
+++ b/packages/plugin-grid/src/inline-edit-options.test.ts
@@ -92,6 +92,12 @@ describe('isFieldInlineEditable', () => {
}
});
+ it('keeps a markdown cell read-only — the detail row editor (objectui#11541) is not a grid one', () => {
+ // The detail page routes `markdown` to a multi-line textarea through its own
+ // carve-out from the shared exclusion; the cell has no room for one.
+ expect(isFieldInlineEditable({ type: 'markdown' })).toBe(false);
+ });
+
it('blocks an explicitly readonly field regardless of type', () => {
expect(isFieldInlineEditable({ type: 'text', readonly: true })).toBe(false);
expect(isFieldInlineEditable({ type: 'select', readonly: true })).toBe(false);