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
15 changes: 15 additions & 0 deletions .changeset/richtext-spec-spelling-4250.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/plugin-form': patch
'@object-ui/app-shell': patch
---

`richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects

`@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.

For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.

The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.

`markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
19 changes: 17 additions & 2 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,24 @@ export function resolveActionUser(
* audit-by-name fields drop down.
*/
const SECONDARY_FIELD_NAME_HINTS = ['description', 'notes', 'note', 'remark', 'remarks', 'comments'];
const SECONDARY_FIELD_TYPES = new Set(['textarea', 'markdown', 'html', 'rich-text', 'json', 'code']);
/**
* Matched against the RAW `objectDef.fields[x].type`, so every member must be a
* `@objectstack/spec` `FieldType` name. `rich-text` was not one: the spec spells
* the type `richtext` and REJECTS `rich-text`, which survives only as a typo key
* in the spec's own `suggestFieldType` table — a spelling no producer can emit.
* The member therefore demoted nothing while a real `richtext` field stayed in
* the dense primary section, and the same field failed to read wide in the
* detail and form auto-layouts, which carried the identical hole (#4250).
*/
const SECONDARY_FIELD_TYPES = new Set(['textarea', 'markdown', 'html', 'richtext', 'json', 'code']);

function isSecondaryField(fieldName: string, fieldDef: any): boolean {
/**
* Exported for the cross-surface pin in `richtextSurfaceParity.test.ts` — the
* rule is the behaviour under test, not the set's string membership. Not
* re-exported from the package barrel (`views/index.ts` names `RecordDetailView`
* only), so this widens no public API.
*/
export function isSecondaryField(fieldName: string, fieldDef: any): boolean {
if (SECONDARY_FIELD_TYPES.has(fieldDef?.type)) return true;
const lc = fieldName.toLowerCase();
return SECONDARY_FIELD_NAME_HINTS.some((hint) => lc === hint || lc.endsWith(`_${hint}`));
Expand Down
96 changes: 96 additions & 0 deletions packages/app-shell/src/views/richtextSurfaceParity.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* 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#4250 — the CROSS-SURFACE control. Four sets across three packages
* decide how a long-form field is placed, each matched against the RAW type,
* and each spelled the rich-text type in a way `@objectstack/spec` rejects
* (`rich_text` / `rich-text` — typo keys in the spec's own `suggestFieldType`
* table, never a producer's output). The card's warning was that fixing only
* some of them is WORSE than the uniform gap, because then the detail page and
* the form disagree about the same field.
*
* So this file asks all three placement rules about ONE shared fixture field,
* from the package that depends on every one of them.
*
* READ THIS BEFORE TREATING A GREEN RUN AS PROOF OF THE FIX: the parity
* assertions here were green BEFORE the fix too — the four sets were uniformly
* wrong, which is agreement of a kind. Their job is to go red on a HALF fix,
* and that is how they were verified (reverting one set alone turns
* `agree on EVERY spec field type` red). The red-before-green-after pins for
* the fix itself live in each package: `autoLayout.wideSpelling.test.ts` in
* plugin-detail / plugin-form, and `RelatedList.longFormColumns.test.tsx` for
* the rendered column set.
*/
import { describe, it, expect } from 'vitest';
import { FieldType } from '@objectstack/spec/data';
import { isWideFieldType as detailIsWide } from '@object-ui/plugin-detail';
import { isWideFieldType as formIsWide } from '@object-ui/plugin-form';
import { isSecondaryField } from './RecordDetailView';

const SPEC_TYPES: readonly string[] = [...(FieldType as { options: readonly string[] }).options];

/**
* ONE fixture, read by all three rules. `body` is the field the card is about;
* `subject` is the narrow control that must stay primary and narrow everywhere.
*/
const OBJECT_FIELDS: Record<string, { type: string; label: string }> = {
subject: { type: 'text', label: 'Subject' },
body: { type: 'richtext', label: 'Body' },
notes: { type: 'markdown', label: 'Notes' },
};

describe('richtext placement — one field, three surfaces (#4250)', () => {
it('the fixture is spelled the way the spec spells it', () => {
// The derivation that makes everything below meaningful: if this type name
// is not in the spec vocabulary, no producer can emit it and every
// assertion underneath is vacuous — which is exactly how the previous
// `isWideFieldType('rich-text')` pin stayed green over a dead rule.
expect(SPEC_TYPES).toContain(OBJECT_FIELDS.body.type);
expect(SPEC_TYPES).toContain(OBJECT_FIELDS.notes.type);
});

it('the SAME richtext field is wide in the detail view and in the form', () => {
const type = OBJECT_FIELDS.body.type;
expect(detailIsWide(type)).toBe(true);
expect(formIsWide(type)).toBe(true);
expect(detailIsWide(type)).toBe(formIsWide(type));
});

it('the SAME richtext field is secondary on the record detail page', () => {
expect(isSecondaryField('body', OBJECT_FIELDS.body)).toBe(true);
// The narrow control: a plain text field stays primary…
expect(isSecondaryField('subject', OBJECT_FIELDS.subject)).toBe(false);
// …and is narrow on both layout surfaces.
expect(detailIsWide(OBJECT_FIELDS.subject.type)).toBe(false);
expect(formIsWide(OBJECT_FIELDS.subject.type)).toBe(false);
});

it('markdown — the sibling the card asked about — is placed identically', () => {
const type = OBJECT_FIELDS.notes.type;
expect(detailIsWide(type)).toBe(true);
expect(formIsWide(type)).toBe(true);
expect(isSecondaryField('notes', OBJECT_FIELDS.notes)).toBe(true);
});

it('the detail and form auto-layouts agree on EVERY spec field type', () => {
// The card's warning, mechanized. Green before the fix and green after;
// it fails the moment one of the two sets moves without the other.
const disagreements = SPEC_TYPES.filter((t) => detailIsWide(t) !== formIsWide(t));
expect(disagreements).toEqual([]);
});

it('no surface answers to a spelling the spec rejects', () => {
// Dropped, not carried alongside (the ruling on #4250) — asserted on all
// three surfaces at once so a "defensive" re-add cannot slip back into one.
for (const dead of ['rich-text', 'rich_text']) {
expect(SPEC_TYPES).not.toContain(dead);
expect(detailIsWide(dead)).toBe(false);
expect(formIsWide(dead)).toBe(false);
expect(isSecondaryField('body', { type: dead, label: 'Body' })).toBe(false);
}
});
});
21 changes: 20 additions & 1 deletion packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -976,7 +976,26 @@ export const RelatedList: React.FC<RelatedListProps> = ({
// receipt attachment (objectui#2360). Only types with no useful tabular
// rendering stay excluded. (`attachment` is intentionally absent — it is not
// a `@objectstack/spec` field type, so the renderer does not model it, #2655.)
const SKIP_TYPES = new Set(['rich_text', 'html', 'json']);
//
// SPELLING: matched against the RAW `def.type`, so every member must be a
// `@objectstack/spec` `FieldType` name. `rich_text` was not one. The spec
// spells the type `richtext` and REJECTS `rich_text` / `rich-text`
// outright — they survive only as typo keys in the spec's own
// `suggestFieldType` table, i.e. spellings no producer can emit. So the
// member excluded nothing while a real `richtext` field fell straight
// through into a derived column (#4250).
//
// `markdown` joins its siblings on MEASURED behaviour, not on the
// raw-markup story: markdown, richtext and html all render FORMATTED here
// (the first two via `MarkdownCellRenderer`, html via `HtmlCellRenderer`).
// What makes all three unusable in a table is that the formatted output is
// BLOCK-level (`<h1>` / `<p>` / `<ul>`) inside a `truncate` single-line
// cell, so a document renders as one clipped heading with the rest
// invisible. `textarea` stays OUT by the same measurement read the other
// way — it renders as plain truncated text, which is a useful cell.
// Author-declared columns are unaffected: this set only filters the
// zero-config auto-derive walk.
const SKIP_TYPES = new Set(['richtext', 'markdown', 'html', 'json']);
const PRIORITY_NAMES = [
'name',
'full_name',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* 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#4250 — the DOM half of the `richtext` spelling fix. `SKIP_TYPES` is
* matched against the raw `def.type`, and its member was `rich_text`: a
* spelling `@objectstack/spec` rejects outright (it exists only as a typo key in
* the spec's own suggestion table), so the set excluded nothing while a real
* spec-spelled `richtext` field was auto-derived into a related-list column.
*
* These pins assert the RENDERED column set, not the set's string membership —
* revert `richtext`/`markdown` out of `SKIP_TYPES` and the two "not derived"
* cases go red on a header that reappears.
*
* WHAT THE CELL ACTUALLY DID (measured on the pre-fix tree, and why the pin is
* phrased as "no document markup" rather than "no raw markup"): a `richtext`
* value did NOT render as raw markup. `getCellRenderer('richtext')` resolves to
* `MarkdownCellRenderer`, so the cell held FORMATTED, sanitized GFM. The harm is
* that the formatted output is BLOCK-level — `<h1>` / `<p>` / `<ul>` — inside a
* `truncate` single-line table cell, so a document rendered as one clipped
* heading with the rest invisible. `html` (already skipped) formats the same
* way through `HtmlCellRenderer`, which is why "has a formatting renderer" was
* never the discriminator this set used.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
// The markdown cell renderer is behind `React.lazy`; import the chunk at module
// scope so the factory resolves immediately and the assertions are not racing
// the module loader (AGENTS.md §测试纪律). Same specifier the component uses.
import '@object-ui/fields/widgets/MarkdownContent';
import { RelatedList } from '../RelatedList';

/** Multi-block markdown — the shape whose formatted render is `<h1>` + `<ul>`. */
const DOC = '# Heading\n\n**bold** and `code`\n\n- one\n- two';

const fields = {
subject: { type: 'text', label: 'Subject' },
amount: { type: 'currency', label: 'Amount' },
body_rt: { type: 'richtext', label: 'Body Rich' },
body_md: { type: 'markdown', label: 'Body Markdown' },
body_html: { type: 'html', label: 'Body Html' },
payload: { type: 'json', label: 'Payload' },
memo: { type: 'textarea', label: 'Memo' },
};

const row = {
id: 'n1',
subject: 'Note one',
amount: 42,
body_rt: DOC,
body_md: DOC,
body_html: '<b>htmlbold</b>',
payload: { a: 1 },
memo: 'a plain long-form memo',
};

const makeDS = (rows: any[]) => ({
find: vi.fn(async () => rows),
getObjectSchema: vi.fn(async () => ({ name: 'note_line', fields })),
});

function renderList(extra: Record<string, unknown> = {}) {
return render(
<RelatedList
title="Notes"
type="table"
api="note_line"
objectName="note_line"
referenceField="note"
parentId="N-1"
maxColumns={10}
dataSource={makeDS([row]) as any}
{...(extra as any)}
/>,
);
}

describe('RelatedList — long-form types stay out of auto-derived columns (#4250)', () => {
it('does not derive a column for a spec-spelled `richtext` field', async () => {
const { container } = renderList();
// Control: the walk ran and produced real columns.
await waitFor(() => expect(screen.getByText('Subject')).toBeTruthy());
expect(screen.getByText('Amount')).toBeTruthy();

expect(screen.queryByText('Body Rich')).toBeNull();
// …and nothing rendered the document into a cell.
expect(container.querySelector('td h1')).toBeNull();
expect(screen.queryByText('Heading')).toBeNull();
});

it('does not derive a column for a `markdown` field either', async () => {
renderList();
await waitFor(() => expect(screen.getByText('Subject')).toBeTruthy());
expect(screen.queryByText('Body Markdown')).toBeNull();
});

it('keeps the pre-existing `html` / `json` exclusions', async () => {
renderList();
await waitFor(() => expect(screen.getByText('Subject')).toBeTruthy());
expect(screen.queryByText('Body Html')).toBeNull();
expect(screen.queryByText('Payload')).toBeNull();
});

it('still derives `textarea` — plain truncated text is a useful cell', async () => {
renderList();
// The measurement read the other way: `textarea` has no block-level render,
// so excluding it would hide a business column for nothing (objectui#2360).
await waitFor(() => expect(screen.getByText('Memo')).toBeTruthy());
expect(screen.getByText('a plain long-form memo')).toBeTruthy();
});

it('an AUTHOR-DECLARED richtext column is still rendered', async () => {
// The set filters the zero-config auto-derive walk only. An explicit column
// is the author saying they want it — over-skipping that is the objectui#2360
// harm, and this is the control that says the fix did not reintroduce it.
renderList({ columns: ['subject', 'body_rt'] });
await waitFor(() => expect(screen.getByText('Body Rich')).toBeTruthy());
expect(await screen.findByText('Heading')).toBeTruthy();
});
});
Loading
Loading