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
11 changes: 11 additions & 0 deletions .changeset/12348-details-sections-mirror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/types': minor
---

`RecordDetailsComponentProps.sections[]` declares `hideEmpty` (objectui#12348).

`@objectstack/spec` declares `hideEmpty` on the `record:details` section (since 17.3.0), and `RecordDetailsRenderer` already reads it (on unless authored `false`). The published TypeScript type omitted it, so a spec-valid section such as `{ fields: ['phone'], hideEmpty: false }` failed to compile with TS2353 on `hideEmpty`. It now compiles, typed `boolean` like the spec. Nothing changes at runtime.

The section entry's member set now equals the installed spec's: `name`, `label`, `columns`, `group`, `fields`, `hideEmpty`, `collapsible`, `showBorder`, `defaultCollapsed`, `icon`, `description`, `headerColor`, `count`. A key the spec does not declare on the section is still refused on a typed literal.

The `object-grid` `emptyState` describe in the zod mirror now names all four members of the spec's `EmptyStateSchema`, `link` included. The schema itself was already the spec's by reference.
Original file line number Diff line number Diff line change
Expand Up @@ -3015,7 +3015,7 @@ const MEMBER_PINS: Record<string, MemberPin> = {
},
'object-grid.emptyState': {
file: 'packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx',
pins: 'All three members the spec\'s strict `EmptyStateSchema` declares, at the empty state the grid draws in place of an empty table, through the real `SchemaRenderer` and the `{ type, properties }` document (objectui#11227). `title` and `message` are I18nLabel: a map paints its `zh-CN` entry under `zh` and its `en` entry under `en`, with `en` listed FIRST so a fallback fails the `zh` row, and the two members resolve independently (a map title beside a string message). A map with no usable entry keeps that member\'s default: the table\'s own "No results found" heading, and no message line. `icon` is a plain Lucide name, read off the same object. The render-failure signature is asserted absent on every row, because the pre-fix renderer handed a map to `DataEmptyState` as an object child, which React refuses. The string arm, the empty-search case and the not-drawn-with-rows case are `ObjectGrid.declaredKeys-11068.test.tsx`\'s rows. New file.',
pins: 'Three of the four members the spec\'s strict `EmptyStateSchema` declares (`title`, `message`, `icon`), at the empty state the grid draws in place of an empty table, through the real `SchemaRenderer` and the `{ type, properties }` document (objectui#11227). `title` and `message` are I18nLabel: a map paints its `zh-CN` entry under `zh` and its `en` entry under `en`, with `en` listed FIRST so a fallback fails the `zh` row, and the two members resolve independently (a map title beside a string message). A map with no usable entry keeps that member\'s default: the table\'s own "No results found" heading, and no message line. `icon` is a plain Lucide name, read off the same object. The render-failure signature is asserted absent on every row, because the pre-fix renderer handed a map to `DataEmptyState` as an object child, which React refuses. The string arm, the empty-search case and the not-drawn-with-rows case are `ObjectGrid.declaredKeys-11068.test.tsx`\'s rows. The fourth member, `link`, is pinned by `ObjectGrid.emptyStateLink-12270.test.tsx` in the same directory (objectui#12270). New file.',
},
'object-kanban.cardFields': {
file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
* window (director seat, decision batch #101, applying batch #87 — this repo
* does not declare a key the spec refuses by name — and the 2026-08-27
* retirement-pacing ruling). `defaultCollapsed` is the surviving spelling.
* - **objectui#12348** — this type declares `hideEmpty`, which the spec
* declares and the renderer reads, and the pin now holds the WHOLE member
* set to the installed spec in both directions (key set and each member's
* type), so the next spec member cannot go missing unnoticed.
*
* ## Why this pin is compile-time, and why a runtime pin alone measures nothing
*
Expand Down Expand Up @@ -109,6 +113,45 @@ type _DefaultCollapsed = Expect<Equal<Section['defaultCollapsed'], SpecSection['
/** RED before objectui#8583, green after — the closed six-token vocabulary, not `string`. */
type _HeaderColor = Expect<Equal<Section['headerColor'], SpecSection['headerColor']>>;

/* ── objectui#12348: `hideEmpty`, and the WHOLE member set, both directions ─── */

/**
* RED before objectui#12348 (`Property 'hideEmpty' does not exist on type …`),
* green after. Spec 17.3.0+ declares it on the section; `RecordDetailsRenderer`
* reads it (`s.hideEmpty ?? true`).
*/
type _HideEmpty = Expect<Equal<Section['hideEmpty'], SpecSection['hideEmpty']>>;

/**
* The drift guard objectui#12348 adds, so a member cannot go missing again one
* key at a time: the section entry's KEY SET equals the installed spec's, in
* both directions at once. A member the spec gains and this type omits, or a
* member this type declares and the spec does not, turns `Equal` false and
* this line red under `type-check`. The per-key `Equal` pins above say WHICH
* type each member carries; this line says no member is missing or extra.
*/
type _KeySetMirrorsSpec = Expect<Equal<keyof Section, keyof SpecSection>>;

/**
* Every member, each with the spec's own authoring type — the mapped form of
* the per-key pins above, so a member added on both sides later is held to the
* spec's type without anyone remembering to write its line.
*/
type MemberTypeMirrors = { [K in keyof SpecSection]-?: Equal<Section[K & keyof Section], SpecSection[K]> };
type _EveryMemberTypeMirrorsSpec = Expect<Equal<MemberTypeMirrors[keyof MemberTypeMirrors], true>>;

/**
* The same member set as a runtime list, for the vitest half (vitest strips
* types, so the line above is invisible to it). The annotation below ties the
* list to the TYPE in both directions; the `it` below ties it to the INSTALLED
* spec's shape in both directions. Together: type == list == spec.
*/
const MIRRORED_SECTION_KEYS = [
'name', 'label', 'columns', 'group', 'fields', 'hideEmpty', 'collapsible',
'showBorder', 'defaultCollapsed', 'icon', 'description', 'headerColor', 'count',
] as const;
type _ListIsTheTypesKeySet = Expect<Equal<(typeof MIRRORED_SECTION_KEYS)[number], keyof Section>>;

/* ── Literals: what an author can now write, and what they still cannot ────── */

const SIX = ['columns', 'icon', 'description', 'showBorder', 'defaultCollapsed', 'headerColor'] as const;
Expand Down Expand Up @@ -288,6 +331,37 @@ describe('record:details `sections[]` mirrors the spec member set, both directio
expect(refusedKeys(parsed.error?.issues)).toEqual(['collapsed']);
});

it('objectui#12348 — the section member set equals the installed spec\'s, both directions', () => {
// Both directions in one equality: a key the spec gains that the list (and,
// through `_ListIsTheTypesKeySet`, the type) lacks fails it, and so does a
// key the list keeps after the spec drops it. The 'all six' check above is
// one-directional (`arrayContaining`); this one is not.
expect([...specSectionKeys()].sort()).toEqual([...MIRRORED_SECTION_KEYS].sort());
});

it('objectui#12348 — `hideEmpty` is accepted by the spec, survives the parse, and is permitted beside `group`', () => {
const enumerated: Section = { ...canonicalSection, hideEmpty: false };
const parsed = RecordDetailsProps.safeParse({ sections: [enumerated] });
expect(parsed.error?.issues ?? []).toEqual([]);
expect(parsed.data?.sections?.[0]).toMatchObject({ hideEmpty: false });

// The card's shape against a group reference: `hideEmpty` is not one of
// the keys a group declares, so the spec admits it beside `group`.
const grouped: RecordDetailsComponentProps = { sections: [{ group: 'terms', hideEmpty: false }] };
const groupedParsed = RecordDetailsProps.safeParse(grouped);
expect(groupedParsed.error?.issues ?? []).toEqual([]);
expect(groupedParsed.data?.sections?.[0]).toMatchObject({ group: 'terms', hideEmpty: false });

// Control: the same position refuses a VALUE of the wrong type, so the
// acceptance above is about `hideEmpty` being declared as a boolean.
const wrongValue = RecordDetailsProps.safeParse({ sections: [{ ...canonicalSection, hideEmpty: 'no' }] });
expect(wrongValue.success).toBe(false);
expect(wrongValue.error?.issues.map((i) => [i.code, i.path.join('.')])).toContainEqual([
'invalid_type',
'sections.0.hideEmpty',
]);
});

it('keeps the compile-time literals alive at runtime', () => {
// Runtime shape is unaffected by the type-level assertions above; these
// exist so the file also fails visibly if a literal is ever emptied.
Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/record-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,19 @@ export interface RecordDetailsComponentProps {
| 'primary/10'
| 'secondary/10'
| 'destructive/10';
/**
* Hide this section's empty fields (`sections[].hideEmpty`, `z.boolean()`
* on the spec row since 17.3.0). Unauthored, `RecordDetailsRenderer` reads
* it as on (`s.hideEmpty ?? true`), and a section whose fields are ALL
* empty then renders nothing; `false` keeps the empty rows and the
* section's heading skeleton. Permitted beside `group`.
*
* Missing here until objectui#12348: the spec declared it and the renderer
* honoured it, so this published face was the one layer that gave a
* spec-valid section TS2353. objectui#7129's refusal premise was superseded
* by the objectui#8603 ruling — objectui follows the protocol.
*/
hideEmpty?: boolean;
collapsible?: boolean;
/**
* Show how many values a multi-value field holds in this section's heading
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/objectql.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ export const ObjectGridSchema = BaseSchema.extend({
// `icon` is a string. Mirrored by the interface's `SpecEmptyState`.
emptyState: stripImportedDefaults(SpecEmptyStateSchema)
.optional()
.describe('What the grid draws instead of an empty table: `{ title, message, icon }`, the spec EmptyState by reference'),
.describe('What the grid draws instead of an empty table: `{ title, message, icon, link }`, the spec EmptyState by reference'),
editable: z.boolean().optional(),
keyboardNavigation: z.boolean().optional(),
frozenColumns: z.number().optional(),
Expand Down
Loading