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);