diff --git a/.changeset/richtext-spec-spelling-4250.md b/.changeset/richtext-spec-spelling-4250.md new file mode 100644 index 0000000000..ade8feb456 --- /dev/null +++ b/.changeset/richtext-spec-spelling-4250.md @@ -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. diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 63f5cf6629..18f9e0dc76 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -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}`)); diff --git a/packages/app-shell/src/views/richtextSurfaceParity.test.tsx b/packages/app-shell/src/views/richtextSurfaceParity.test.tsx new file mode 100644 index 0000000000..65b9b6b842 --- /dev/null +++ b/packages/app-shell/src/views/richtextSurfaceParity.test.tsx @@ -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 = { + 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); + } + }); +}); diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index e849d7bff7..5d5104ad5f 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -976,7 +976,26 @@ export const RelatedList: React.FC = ({ // 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 (`

` / `

` / `

    `) 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', diff --git a/packages/plugin-detail/src/__tests__/RelatedList.longFormColumns.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.longFormColumns.test.tsx new file mode 100644 index 0000000000..ea4840ceb1 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.longFormColumns.test.tsx @@ -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 — `

    ` / `

    ` / `

      ` — 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 `

      ` + `
        `. */ +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: 'htmlbold', + 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 = {}) { + return render( + , + ); +} + +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(); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/autoLayout.wideSpelling.test.ts b/packages/plugin-detail/src/__tests__/autoLayout.wideSpelling.test.ts new file mode 100644 index 0000000000..90dbc3d3f9 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/autoLayout.wideSpelling.test.ts @@ -0,0 +1,98 @@ +/** + * 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 — `WIDE_FIELD_TYPES` is matched against the RAW field type, and + * its rich-text members were `rich-text` / `field:rich-text`: spellings + * `@objectstack/spec` REJECTS (they exist only as typo keys in the spec's own + * `suggestFieldType` table), so the set never widened a rich-text field while + * `markdown` and `html` beside it did. + * + * The pins below are DERIVED, not enumerated (the #4226/#4207 precedent): the + * bare members are checked against the spec's own `FieldType` vocabulary, and + * the `field:` half is read out of `mapFieldTypeToFormType` rather than retyped. + * So the next spelling drift fails BY NAME — "'rich-text' is not a spec + * FieldType" — instead of passing vacuously the way + * `isWideFieldType('rich-text')` did. + */ +import { describe, it, expect } from 'vitest'; +import { FieldType } from '@objectstack/spec/data'; +import { mapFieldTypeToFormType } from '@object-ui/fields'; +import { isWideFieldType, applyAutoSpan } from '../autoLayout'; + +const SPEC_TYPES: readonly string[] = [...(FieldType as { options: readonly string[] }).options]; + +/** + * The long-form / document family: the spec types whose value is a whole block + * of prose and therefore takes the full row. Spelled here as the SPEC spells + * them — the first assertion below is what makes that claim checkable. + */ +const WIDE_SPEC_TYPES = ['textarea', 'markdown', 'html', 'richtext'] as const; + +/** Spellings the spec rejects. Kept as data so the pin says what it rejects. */ +const REJECTED_SPELLINGS = ['rich-text', 'rich_text'] as const; + +describe('detail WIDE_FIELD_TYPES — keyed on the spec vocabulary (#4250)', () => { + it('every bare member this set relies on IS a spec FieldType name', () => { + // The derivation. If a spelling drifts out of the spec vocabulary — exactly + // what `rich-text` had done — this fails and NAMES the offender, without + // needing a field to reach the set for it to show. + for (const type of WIDE_SPEC_TYPES) { + expect(SPEC_TYPES).toContain(type); + } + }); + + it('widens every long-form spec type', () => { + for (const type of WIDE_SPEC_TYPES) { + expect(isWideFieldType(type)).toBe(true); + } + }); + + it('widens the widget id each long-form type maps to', () => { + // Derives the `field:*` half from the alias table instead of retyping it, + // so the two halves of the set cannot drift apart: `field:rich-text` was + // wrong precisely because `mapFieldTypeToFormType('richtext')` has always + // returned `field:richtext`. + for (const type of WIDE_SPEC_TYPES) { + expect(isWideFieldType(mapFieldTypeToFormType(type))).toBe(true); + } + }); + + it('does not answer to spellings the spec rejects', () => { + for (const dead of REJECTED_SPELLINGS) { + expect(SPEC_TYPES).not.toContain(dead); + // Dropped rather than carried alongside: the alias table is the one place + // aliases live (the ruling on #4250). + expect(isWideFieldType(dead)).toBe(false); + expect(isWideFieldType(`field:${dead}`)).toBe(false); + } + }); + + it('its spec-facing surface is EXACTLY the long-form family', () => { + // The complement, so a stray spec type cannot be added without saying so. + // `grid` is absent from this assertion by construction: it is not a spec + // FieldType but a sanctioned objectui-local key (`GridFieldMetadata` in + // `@object-ui/types`), so it is not part of the spec-facing surface. + // `repeater` — the spec type whose widget IS `field:grid` — is deliberately + // NOT wide here; that asymmetry is filed separately rather than fixed under + // this card. + const wideSpecTypes = SPEC_TYPES.filter((t) => isWideFieldType(t)); + expect([...wideSpecTypes].sort()).toEqual([...WIDE_SPEC_TYPES].sort()); + }); + + it('spans a richtext field across the section, like markdown beside it', () => { + // The behaviour, not the membership: the same call the detail layout makes. + const fields = [ + { name: 'subject', type: 'text' }, + { name: 'body', type: 'richtext' }, + { name: 'notes', type: 'markdown' }, + ] as any; + const out = applyAutoSpan(fields, 3); + expect(out[0].span).toBeUndefined(); + expect(out[1].span).toBe(3); + expect(out[2].span).toBe(3); + }); +}); diff --git a/packages/plugin-detail/src/autoLayout.ts b/packages/plugin-detail/src/autoLayout.ts index cfe52f739e..dcadff3bfb 100644 --- a/packages/plugin-detail/src/autoLayout.ts +++ b/packages/plugin-detail/src/autoLayout.ts @@ -29,18 +29,38 @@ import type { DetailViewField } from '@object-ui/types'; -/** Field types that should span full width in multi-column layouts */ +/** + * Field types that should span full width in multi-column layouts. + * + * Two spellings per type on purpose: the BARE entries are `@objectstack/spec` + * `FieldType` names (a raw `objectDef.fields[x].type` reaches here from the + * detail synth and from app-shell's form designer), the `field:` entries are + * the widget ids `mapFieldTypeToFormType` produces. Both are matched RAW, so a + * bare member that is not a spec type name — and a `field:` member that is not + * a registered widget id — silently matches nothing. + * + * That is what `rich-text` / `field:rich-text` were: the spec spells the type + * `richtext` (widget `field:richtext`) and REJECTS `rich-text`, which survives + * only as a typo key in the spec's own `suggestFieldType` table. The set + * therefore never widened a rich-text field, while `markdown` and `html` right + * beside it did (#4250). The dead spellings are dropped rather than kept + * alongside: the alias table is the one place aliases belong. + * + * `grid` is NOT a spec `FieldType` — it is a sanctioned objectui-local key + * (`GridFieldMetadata` in `@object-ui/types`), which is why it is exempt from + * the spec-name pin in the tests. + */ const WIDE_FIELD_TYPES = new Set([ 'textarea', 'markdown', 'html', 'grid', - 'rich-text', + 'richtext', 'field:textarea', 'field:markdown', 'field:html', 'field:grid', - 'field:rich-text', + 'field:richtext', ]); /** diff --git a/packages/plugin-form/src/__tests__/autoLayout.test.ts b/packages/plugin-form/src/__tests__/autoLayout.test.ts index c0e8f8801c..aafecd13c8 100644 --- a/packages/plugin-form/src/__tests__/autoLayout.test.ts +++ b/packages/plugin-form/src/__tests__/autoLayout.test.ts @@ -17,9 +17,14 @@ describe('autoLayout', () => { it('returns true for wide form field types', () => { expect(isWideFieldType('field:textarea')).toBe(true); expect(isWideFieldType('field:markdown')).toBe(true); - expect(isWideFieldType('field:html')).toBe(true); expect(isWideFieldType('field:grid')).toBe(true); - expect(isWideFieldType('field:rich-text')).toBe(true); + expect(isWideFieldType('field:html')).toBe(true); + // `field:richtext` — the widget id `mapFieldTypeToFormType('richtext')` + // actually returns. This line used to read `field:rich-text` and was + // green only because the set held that string; no field has ever reached + // it with that type (objectui#4250). The derived pin lives in + // `autoLayout.wideSpelling.test.ts`. + expect(isWideFieldType('field:richtext')).toBe(true); }); it('returns true for raw wide field types', () => { @@ -27,7 +32,9 @@ describe('autoLayout', () => { expect(isWideFieldType('markdown')).toBe(true); expect(isWideFieldType('html')).toBe(true); expect(isWideFieldType('grid')).toBe(true); - expect(isWideFieldType('rich-text')).toBe(true); + // The spec's spelling, replacing the vacuous `'rich-text'` assertion + // (objectui#4250) — the spec rejects `rich-text` outright. + expect(isWideFieldType('richtext')).toBe(true); }); it('returns false for narrow field types', () => { diff --git a/packages/plugin-form/src/__tests__/autoLayout.wideSpelling.test.ts b/packages/plugin-form/src/__tests__/autoLayout.wideSpelling.test.ts new file mode 100644 index 0000000000..9dc6345e5a --- /dev/null +++ b/packages/plugin-form/src/__tests__/autoLayout.wideSpelling.test.ts @@ -0,0 +1,79 @@ +/** + * 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 form twin of the detail view's `WIDE_FIELD_TYPES` pin. + * The set is matched against the RAW type, and its rich-text members were + * `rich-text` / `field:rich-text`: spellings `@objectstack/spec` REJECTS (typo + * keys in the spec's own `suggestFieldType` table, nothing a producer emits). + * The card's `isWideFieldType('rich-text')` assertion was green for exactly the + * wrong reason — the set held the string it asked about — so the rule could be + * dead for every real field while the pin stayed green. + * + * Derived, not enumerated (the #4226/#4207 precedent): bare members are checked + * against the spec's own `FieldType` vocabulary and the `field:` half is read + * out of `mapFieldTypeToFormType`, so the next drift fails BY NAME. + */ +import { describe, it, expect } from 'vitest'; +import { FieldType } from '@objectstack/spec/data'; +import { mapFieldTypeToFormType } from '@object-ui/fields'; +import { isWideFieldType, resolveColSpan } from '../autoLayout'; +import type { FormField } from '@object-ui/types'; + +const SPEC_TYPES: readonly string[] = [...(FieldType as { options: readonly string[] }).options]; + +/** The long-form / document family, spelled as the SPEC spells it. */ +const WIDE_SPEC_TYPES = ['textarea', 'markdown', 'html', 'richtext'] as const; + +/** Spellings the spec rejects. */ +const REJECTED_SPELLINGS = ['rich-text', 'rich_text'] as const; + +describe('form WIDE_FIELD_TYPES — keyed on the spec vocabulary (#4250)', () => { + it('every bare member this set relies on IS a spec FieldType name', () => { + for (const type of WIDE_SPEC_TYPES) { + expect(SPEC_TYPES).toContain(type); + } + }); + + it('widens every long-form spec type', () => { + for (const type of WIDE_SPEC_TYPES) { + expect(isWideFieldType(type)).toBe(true); + } + }); + + it('widens the widget id each long-form type maps to', () => { + for (const type of WIDE_SPEC_TYPES) { + expect(isWideFieldType(mapFieldTypeToFormType(type))).toBe(true); + } + }); + + it('does not answer to spellings the spec rejects', () => { + for (const dead of REJECTED_SPELLINGS) { + expect(SPEC_TYPES).not.toContain(dead); + expect(isWideFieldType(dead)).toBe(false); + expect(isWideFieldType(`field:${dead}`)).toBe(false); + } + }); + + it('its spec-facing surface is EXACTLY the long-form family', () => { + // `grid` is not in this comparison by construction — it is an objectui-local + // key (`GridFieldMetadata` in `@object-ui/types`), not a spec FieldType. + const wideSpecTypes = SPEC_TYPES.filter((t) => isWideFieldType(t)); + expect([...wideSpecTypes].sort()).toEqual([...WIDE_SPEC_TYPES].sort()); + }); + + it('gives a richtext field the whole row, in both spellings', () => { + // The behaviour, through the function the form grid actually calls. Both + // spellings reach it in production: `FormField.type` carries the widget id, + // while app-shell's `ObjectFormDesigner` passes the raw object field type. + const bare = { name: 'body', type: 'richtext' } as unknown as FormField; + const widget = { name: 'body', type: 'field:richtext' } as unknown as FormField; + const narrow = { name: 'subject', type: 'field:text' } as unknown as FormField; + expect(resolveColSpan(bare, 4)).toBe(4); + expect(resolveColSpan(widget, 4)).toBe(4); + expect(resolveColSpan(narrow, 4)).toBe(1); + }); +}); diff --git a/packages/plugin-form/src/autoLayout.ts b/packages/plugin-form/src/autoLayout.ts index 777f432cc6..d5c39cadd4 100644 --- a/packages/plugin-form/src/autoLayout.ts +++ b/packages/plugin-form/src/autoLayout.ts @@ -31,18 +31,40 @@ const AUTO_GENERATED_FORM_TYPES = new Set([ // via related-list views, not via the master-detail field type itself. ]); -/** Field types that should span full width in multi-column layouts */ +/** + * Field types that should span full width in multi-column layouts. + * + * Two spellings per type on purpose: the `field:` entries are the widget ids + * `mapFieldTypeToFormType` produces (what a resolved `FormField.type` carries), + * the BARE entries are `@objectstack/spec` `FieldType` names — app-shell's + * `ObjectFormDesigner` passes a raw `objectDef.fields[x].type` straight in. + * Both are matched RAW, so a bare member that is not a spec type name — and a + * `field:` member that is not a registered widget id — silently matches + * nothing. + * + * That is what `rich-text` / `field:rich-text` were: the spec spells the type + * `richtext` (widget `field:richtext`) and REJECTS `rich-text`, which survives + * only as a typo key in the spec's own `suggestFieldType` table. The set never + * widened a rich-text field while `markdown` and `html` beside it did, and the + * detail view's twin set had the identical hole — so the same field read wide + * in neither surface (#4250). The dead spellings are dropped rather than kept + * alongside: the alias table is the one place aliases belong. + * + * `grid` is NOT a spec `FieldType` — it is a sanctioned objectui-local key + * (`GridFieldMetadata` in `@object-ui/types`), which is why it is exempt from + * the spec-name pin in the tests. + */ const WIDE_FIELD_TYPES = new Set([ 'field:textarea', 'field:markdown', 'field:html', 'field:grid', - 'field:rich-text', + 'field:richtext', 'textarea', 'markdown', 'html', 'grid', - 'rich-text', + 'richtext', ]); /**