Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/11541-detail-markdown-editor.md
Original file line number Diff line number Diff line change
@@ -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`.
32 changes: 29 additions & 3 deletions packages/plugin-detail/src/InlineFieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
FileField,
AvatarField,
SignatureField,
TextAreaField,
AddressField,
LocationField,
GeolocationField,
Expand Down Expand Up @@ -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 `<input type="text">` 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<string>([
'text',
Expand Down Expand Up @@ -131,6 +142,9 @@ export const INLINE_ROUTED_FIELD_TYPES = new Set<string>([
// 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',
]);

/**
Expand Down Expand Up @@ -398,6 +412,18 @@ export const InlineFieldInput: React.FC<InlineFieldInputProps> = ({
if (editType === 'file' || editType === 'video' || editType === 'audio') {
return <FileField field={field as any} value={value} onChange={(v: any) => 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 <TextAreaField field={field as any} value={value} onChange={(v) => 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<input type="text">`), 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(
<InlineFieldInput field={{ name: 'notes', type: 'markdown' }} value={STORED} onChange={vi.fn()} />,
);
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(
<InlineFieldInput field={{ name: 'notes', type: 'markdown' }} value={STORED} onChange={onChange} />,
);
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(
<InlineFieldInput field={{ name: 'notes', type: 'markdown' }} value={STORED} onChange={vi.fn()} autoFocus />,
);
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(
<DetailSection
section={{ fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection}
data={{ notes: STORED }}
objectSchema={markdownSchema}
onEnterInlineEdit={vi.fn()}
/>,
);
expect(hasPencil()).toBe(true);
});

it('the details body renders one editor for the markdown row in edit mode', () => {
const { container } = render(
<DetailSection
section={{ fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection}
data={{ notes: STORED }}
objectSchema={markdownSchema}
isEditing
onEnterInlineEdit={vi.fn()}
/>,
);
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 <button type="button" onClick={() => inline!.enter('notes')}>enter-edit</button>;
}
const { container } = render(
<InlineEditProvider canEdit>
<HeaderHighlight fields={[{ name: 'notes', label: 'Notes' }]} data={{ notes: STORED }} objectSchema={markdownSchema} />
<EnterEdit />
</InlineEditProvider>,
);
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<string, unknown> = { id: 'r1', notes: STORED, updated_at: 'v1' };
const wire: string[] = [];
const update = vi.fn(async (_object: string, _id: string, patch: Record<string, unknown>) => {
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<string, unknown> }) {
const inline = useInlineEdit()!;
return (
<>
<button type="button" onClick={() => inline.enter()}>enter-edit</button>
<DetailSection
section={section}
data={{ ...record, ...inline.draft }}
objectSchema={markdownSchema}
isEditing={inline.editing}
onFieldChange={inline.setField}
/>
</>
);
}
function Page() {
const [record, setRecord] = React.useState<Record<string, unknown>>(() => ({ ...store }));
const refresh = React.useCallback(async () => setRecord({ ...store }), []);
return (
<InlineEditProvider canEdit>
<Body record={record} />
<InlineEditSaveBar dataSource={{ update }} objectName="deal" recordId="r1" data={record} refresh={refresh} />
</InlineEditProvider>
);
}

const { container } = render(<Page />);
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<string, string> = { html: '<p>a</p>\n<p>b</p>', richtext: '<p>a</p>' };

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(
<DetailSection section={section} data={{ body: MARKUP[type] }} objectSchema={schema} onEnterInlineEdit={vi.fn()} />,
);
expect(hasPencil()).toBe(false);
const { container } = render(
<DetailSection section={section} data={{ body: MARKUP[type] }} objectSchema={schema} isEditing onEnterInlineEdit={vi.fn()} />,
);
expect(editors(container)).toHaveLength(0);
});

it(`a \`${type}\` highlight opens no editor inside the edit session`, () => {
function EnterEdit() {
const inline = useInlineEdit();
return <button type="button" onClick={() => inline!.enter('body')}>enter-edit</button>;
}
const { container } = render(
<InlineEditProvider canEdit>
<HeaderHighlight
fields={[{ name: 'body', label: 'Body' }]}
data={{ body: MARKUP[type] }}
objectSchema={{ fields: { body: { type, label: 'Body' } } }}
/>
<EnterEdit />
</InlineEditProvider>,
);
fireEvent.click(screen.getByText('enter-edit'));
expect(editors(container)).toHaveLength(0);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand All @@ -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'],
Expand Down
15 changes: 15 additions & 0 deletions packages/plugin-detail/src/fieldEnrichment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -110,6 +124,7 @@ export const DETAIL_ROUTED_INLINE_TYPES = new Set<string>([
'file',
'video',
'audio',
'markdown',
]);

/**
Expand Down
Loading
Loading