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
56 changes: 56 additions & 0 deletions .changeset/7083-richtext-field-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@object-ui/types': minor
---

`RichtextFieldMetadata` — the third registry key of `RichTextField` becomes declarable
(objectui#7083, maintainer ruling 2026-09-07, director decision batch #71).

`markdown`, `html` and `richtext` are one widget (objectui#5498). Two of the three
already had an exported metadata type; `richtext` had none, so the runtime served it by
structure while an author could not write its metadata under an annotation at all. The
only way to write one was `as unknown as MarkdownFieldMetadata`, and that deliberate
cast — in this repo's own pin test — was the gap's sole evidence. The state was neither
a union member nor a recorded alias, which is why it had to be rediscovered to be seen.

**New.** `RichtextFieldMetadata` is exported from `@object-ui/types` and joins the
`FieldMetadata` union, so a richtext field's metadata can be written as a typed literal
and narrowed out of the union on `type`:

```ts
import type { RichtextFieldMetadata } from '@object-ui/types';

const doc: RichtextFieldMetadata = {
type: 'richtext',
name: 'doc',
label: 'Release notes',
rows: 10,
placeholder: 'Write the release notes…',
};
```

**Additive only.** Nothing is removed or narrowed: `richtext` field metadata that was
previously written through a cast keeps compiling, and every other member of the union
is untouched. The one behavioural surface — `RichTextField` — is unchanged; it already
served all three keys and this release only gives the third one a face.

**The member's shape was derived, not copied from its two siblings.** `type`, `rows`,
`placeholder`, `mobile_fullscreen` and `label` are the keys `RichTextField` actually
reads on the `richtext` path (the last three already sit on `BaseFieldMetadata`, so the
member declares `type` and `rows`); the readonly branch hands the metadata to a cell
renderer that reads `value` only and contributes no key.

`max_length` is the one declared key the widget itself does not read, and it is declared
because a live reader outside the widget does: `buildValidationRules` compiles
`maxLength ?? max_length` into a react-hook-form rule for **every** field it is handed —
it is generic, with no field-type gate — and both form producers call it on every field
they build. So `max_length` on a `richtext` field is enforced when the form is
submitted. (It is not, however, forwarded to the editor's HTML `maxlength` attribute,
and it gets no default cap in `EmbeddableForm`: both of those enumerate field types and
omit `richtext`. That predates this release and is unchanged by it.) Omitting the key
would have left `richtext` the one type of the three whose ceiling cannot be authored
under an annotation while the submit-time rule enforcing it stayed live — a fresh
instance of the asymmetry this member exists to end.

Docs: `content/docs/fields/rich-text.mdx` teaches all three metadata types and carries a
`RichtextFieldMetadata` snippet; before this release the page stated there was no third
type.
49 changes: 40 additions & 9 deletions content/docs/fields/rich-text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@ The Rich Text Field component edits formatted text content with markdown or HTML
<SchemaExample id="fields-rich-text/html-editor" />
## Field Schema

`markdown` and `html` are two field types served by one widget, and each has its own
exported metadata type — `MarkdownFieldMetadata` and `HtmlFieldMetadata`
(`@object-ui/types`). Both extend `BaseFieldMetadata` and add a length bound and an
inline-editor height; there is no combined "rich text" metadata type.
`markdown`, `html` and `richtext` are three field types served by one widget, and each
has its own exported metadata type — `MarkdownFieldMetadata`, `HtmlFieldMetadata` and
`RichtextFieldMetadata` (`@object-ui/types`). All three extend `BaseFieldMetadata` and
add a length bound and an inline-editor height; there is no combined "rich text"
metadata type.

```ts
import type { HtmlFieldMetadata, MarkdownFieldMetadata } from '@object-ui/types';
import type {
HtmlFieldMetadata,
MarkdownFieldMetadata,
RichtextFieldMetadata,
} from '@object-ui/types';

const releaseNotes: MarkdownFieldMetadata = {
type: 'markdown',
Expand All @@ -39,15 +44,36 @@ const emailBody: HtmlFieldMetadata = {
label: 'Email Body',
max_length: 50000,
};

const articleBody: RichtextFieldMetadata = {
type: 'richtext',
name: 'article_body',
label: 'Article Body',
rows: 10,
max_length: 50000,
};
```

`rows` sizes the inline editor, in text rows — declared on both types (and on
`richtext` gained its exported type in
[objectui#7083](https://github.com/objectstack-ai/objectui/issues/7083); until then it
was the one key of the three with no member to annotate against, so its metadata could
only be written through a cast to one of its siblings. It stores **HTML** — it reads
through the same display pipeline as `html`, not the markdown one
([objectui#5452](https://github.com/objectstack-ai/objectui/issues/5452)) — and that
pipeline is the only thing `type` changes between the three.

`max_length` is a bound on the stored text. The widget itself does not read it; the
form does — `buildValidationRules` compiles it into the submit-time validation rules
for every field, whatever its type — so it is enforced when the form is submitted, not
as a `maxlength` attribute on the rich-text editor.

`rows` sizes the inline editor, in text rows — declared on all three types (and on
`@objectstack/spec`'s `FieldSchema` for the multiline editor types), matching the
`textarea` field's key of the same name ([objectui#6140](https://github.com/objectstack-ai/objectui/issues/6140)).
The editor is a plain textarea today — there is no formatting toolbar, no preview pane
and no pixel height to configure, so neither metadata type declares one: `toolbar`,
`preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing them
does nothing.
and no pixel height to configure, so none of the three metadata types declares one:
`toolbar`, `preview`, `minHeight` and `maxHeight` are **not** metadata keys, and writing
them does nothing.

The value being edited, and the `className` / `disabled` a host supplies, are **not**
metadata keys — they are runtime widget props. See [Field Widget Props](/docs/fields/widget-props).
Expand Down Expand Up @@ -116,3 +142,8 @@ The rich text field can operate in different modes:
- Full HTML editing
- Advanced formatting
- Embedded content support

3. **Rich Text Mode** (`type: 'richtext'`)
- Stores HTML, and reads through the same display pipeline as `html`
- Editable under `RichtextFieldMetadata`
- Same plain-textarea editing surface as the other two
17 changes: 10 additions & 7 deletions packages/fields/src/widgets/RichTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,13 +280,16 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
return <Display value={value} field={field} />;
}

// The declared metadata face for this widget's registry keys. `markdown` and
// `html` each have an exported type; the third key, `richtext`, has no union
// member of its own and structurally matches the same three optional reads
// below — every key this widget consumes (`rows`, `mobile_fullscreen`,
// `placeholder`, `label`) is DECLARED on both members, `rows` since the
// objectui#6140 Option A ruling (which is what retired the `as any` that
// used to launder this carrier).
// The declared metadata face for this widget's registry keys. All three of
// them have an exported type: `markdown` and `html` always did, and the third
// key, `richtext`, gained `RichtextFieldMetadata` in objectui#7083 — which is
// what retired the deliberate `as unknown as MarkdownFieldMetadata` its pin
// test needed for as long as the union had no branch to write it against.
// The cast below names two of the three because it does not have to
// discriminate: every key this widget consumes (`rows`, `mobile_fullscreen`,
// `placeholder`, `label`) is DECLARED on all three, so the two named already
// admit every read below — `rows` since the objectui#6140 Option A ruling
// (which is what retired the `as any` that used to launder this carrier).
const richField = field as MarkdownFieldMetadata | HtmlFieldMetadata;
const rows = richField?.rows || 8;
// The stored syntax, DERIVED from the type's display pipeline rather than
Expand Down
18 changes: 12 additions & 6 deletions packages/fields/src/widgets/__tests__/RichTextField.rows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
* 2026-08-25, Option A, aligning the `TextareaFieldMetadata` precedent), so
* the field literals below carry it under the excess-property check rather
* than through a cast. The `richtext` registry key resolves to the same
* widget (objectui#5498) with no union member of its own, so its case is the
* one deliberate `as` in this file.
* widget (objectui#5498) and now has a union member of its own too
* (`RichtextFieldMetadata`, objectui#7083), so all three literals here are
* annotated and this file holds no `as` at all — the deliberate cast that used
* to sit on the richtext case WAS the only evidence that the third key had no
* declarable face, and it went with the gap it recorded.
*
* Direction of the DOM assertion: `rows` lands on the HTML `rows` attribute of
* the inline `<Textarea>`; the fullscreen dialog deliberately ignores it
Expand All @@ -26,7 +29,12 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { HtmlFieldMetadata, MarkdownFieldMetadata, TextareaFieldMetadata } from '@object-ui/types';
import type {
HtmlFieldMetadata,
MarkdownFieldMetadata,
RichtextFieldMetadata,
TextareaFieldMetadata,
} from '@object-ui/types';

import { RichTextField } from '../RichTextField';
import { TextAreaField } from '../TextAreaField';
Expand All @@ -45,9 +53,7 @@ describe('RichTextField — declared `rows` sizes the inline editor (#6140)', ()
});

it('richtext: the third registry key of the same widget honours rows too', () => {
// No `RichtextFieldMetadata` exists in the union — the runtime shape is
// structural. Cast, deliberately, at the one seam that has no declared type.
const field = { type: 'richtext', name: 'doc', rows: 10 } as unknown as MarkdownFieldMetadata;
const field: RichtextFieldMetadata = { type: 'richtext', name: 'doc', rows: 10 };
render(<RichTextField value="<p>hi</p>" onChange={() => {}} field={field} />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '10');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* 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.
*/

/**
* `RichtextFieldMetadata` — the third registry key of one widget becomes
* DECLARABLE (objectui#7083, maintainer ruling of 2026-09-07, batch #71).
*
* ## What was missing, and why a cast was the only evidence
*
* `markdown`, `html` and `richtext` are one widget (objectui#5498). Two of the
* three carried an exported metadata type; `richtext` carried none, so the
* runtime served it happily by structure while an author could not write its
* metadata under an annotation at all. The state was neither a union member
* nor a recorded alias — a silent gap whose ONLY trace was a deliberate
* `as unknown as MarkdownFieldMetadata` in `RichTextField.rows.test.tsx`. That
* cast is gone; this file is what replaces it, so the gap cannot be
* rediscovered by the next reader who trips over a cast.
*
* ## The pin has two halves and needs both
*
* The ruling asks that "a typed `richtext` literal compiles AND the widget
* renders it", and the compile half is not decoration: a pin that only rendered
* would still pass with the cast in place, which is the state this card ends.
*
* - COMPILE — {@link richtextField} below is an annotated literal, so TypeScript's
* excess-property check judges every key in it, and it is assigned to
* `FieldMetadata` to pin the UNION membership the ruling actually granted.
* On the pre-change tree this file does not compile at all: there is no
* member to annotate against, which is what makes this leg non-vacuous.
* - RENDER — every key of the derived read set is then asserted at the DOM,
* each against a control that changes only that key, so a green here reads
* "the widget consumed the declared key" and never "the default happened to
* match".
*
* ## The read set is DERIVED from `RichTextField.tsx`, not copied
*
* `type` (`resolveRichTextFieldType`, the discriminator), `rows`
* (`richField?.rows || 8`), `placeholder`, `mobile_fullscreen` and `label` are
* the five keys the widget reads off this carrier on the `richtext` path; the
* readonly branch hands `field` to a `RICH_TEXT_CELL_RENDERERS` entry, and both
* renderers there read `value` only. The sixth key, `max_length`, is on the
* member even though this widget does not read it, because the FORM does:
* `buildValidationRules` is generic and compiles it into a submit-time rule for
* every field. The last group below pins only the spec-boundary reading that
* accompanies it — see the member's own docblock in
* `packages/types/src/field-types.ts` for which of the two is the reason.
*/

import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { FieldSchema } from '@objectstack/spec/data';
import type { FieldMetadata, RichtextFieldMetadata } from '@object-ui/types';

import { RichTextField } from '../RichTextField';

/**
* The whole derived read set in ONE annotated literal.
*
* Annotated, never `as`: the annotation is what makes TypeScript judge each
* key, and judging each key is the half of this pin that a cast would have
* silently satisfied.
*/
const richtextField: RichtextFieldMetadata = {
type: 'richtext',
name: 'doc',
label: 'Release notes',
rows: 10,
placeholder: 'Write the release notes…',
mobile_fullscreen: true,
max_length: 5000,
};

describe('RichtextFieldMetadata — the declarable face of the `richtext` key', () => {
it('is a member of the `FieldMetadata` union and discriminates on `type`', () => {
const asUnion: FieldMetadata = richtextField;

// The runtime half of the same statement, so the assertion is not purely a
// compile-time artefact that a `// @ts-expect-error` sweep could hide.
expect(asUnion.type).toBe('richtext');

// …and the union NARROWS on it: reaching `rows` through the union without a
// cast is the authoring capability the ruling granted. Before this member
// existed there was no branch of the union this literal could inhabit.
if (asUnion.type !== 'richtext') throw new Error('unreachable: literal is a richtext field');
expect(asUnion.rows).toBe(10);
expect(asUnion.max_length).toBe(5000);
});

it('renders the declared `rows` on the inline editor', () => {
render(<RichTextField value="<p>hi</p>" onChange={() => {}} field={richtextField} />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '10');
});

it('renders the declared `placeholder`', () => {
render(<RichTextField value="" onChange={() => {}} field={richtextField} />);
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'Write the release notes…');
});

it('renders the expand affordance for `mobile_fullscreen`, titled with `label`', () => {
render(<RichTextField value="<p>hi</p>" onChange={() => {}} field={richtextField} />);

const toggle = screen.getByTestId('richtext-fullscreen-toggle');
expect(toggle).toBeInTheDocument();

// `label` reaches the dialog title, which is a direct render of the key
// rather than an interpolated sentence — so this assertion cannot be
// satisfied by a fallback string.
fireEvent.click(toggle);
expect(screen.getByTestId('richtext-fullscreen-dialog')).toBeInTheDocument();
expect(screen.getByText('Release notes')).toBeInTheDocument();
});

it('control: the same widget, same type, with the optional keys omitted', () => {
// One key different per assertion above; here they are all absent at once,
// and the widget answers with its own defaults. Without this, a green above
// would be compatible with the widget ignoring the metadata entirely.
const bare: RichtextFieldMetadata = { type: 'richtext', name: 'doc' };
render(<RichTextField value="" onChange={() => {}} field={bare} />);

expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
expect(screen.getByRole('textbox')).not.toHaveAttribute('placeholder', 'Write the release notes…');
expect(screen.queryByTestId('richtext-fullscreen-toggle')).not.toBeInTheDocument();
});
});

/**
* The spec-boundary reading for the ONE key on the member that `RichTextField`
* does not read.
*
* ⛔ This group is NOT the reason `max_length` is declared. That reason is
* `buildValidationRules` — generic, no field-type gate, called on every field
* both form producers build — which makes the key enforceable at submit on a
* `richtext` field. This reading is NON-DISCRIMINATING for it: at
* `@objectstack/spec` 17.3.0 the authoring boundary answers IDENTICALLY for
* every field type, `text` included, so it says nothing about `richtext`
* versus its two siblings.
*
* It is pinned anyway, for the three types this one widget serves, so the
* member's docblock cannot rot into a false canonical claim about the spelling
* — the failure mode objectui#7014 was opened for.
*/
describe('spec boundary — `richtext` is symmetric with `markdown`/`html` on the ceiling key', () => {
const base = (type: string) => ({ name: 'body', type, label: 'Body' });

/**
* Pull the `unrecognized_keys` issue naming `key`, or undefined.
*
* Typed off `safeParse`'s own return rather than through `any`: narrowing on
* the issue's `code` is what makes `keys` reachable, and it is also what
* keeps this helper honest — an issue of a different code can never satisfy
* it by carrying a same-named field.
*/
const refusedByName = (result: ReturnType<typeof FieldSchema.safeParse>, key: string) =>
result.success
? undefined
: result.error.issues.find((i) => i.code === 'unrecognized_keys' && i.keys.includes(key));

for (const type of ['markdown', 'html', 'richtext'] as const) {
it(`control: \`${type}\` with no ceiling key is accepted`, () => {
expect(FieldSchema.safeParse(base(type)).success).toBe(true);
});

it(`\`${type}\` ADMITS the spec's own \`maxLength\``, () => {
expect(FieldSchema.safeParse({ ...base(type), maxLength: 5000 }).success).toBe(true);
});

it(`\`${type}\` refuses the objectui legacy spelling \`max_length\` BY NAME`, () => {
const res = FieldSchema.safeParse({ ...base(type), max_length: 5000 });
expect(res.success).toBe(false);
expect(refusedByName(res, 'max_length'), `expected unrecognized_keys naming 'max_length' on ${type}`).toBeDefined();
// Control, per fixture: the key is the only difference from the accepted
// payload above, so "refused" is about the key and not about the field.
expect(FieldSchema.safeParse(base(type)).success).toBe(true);
});
}
});
Loading
Loading