diff --git a/.changeset/12348-details-sections-mirror.md b/.changeset/12348-details-sections-mirror.md new file mode 100644 index 0000000000..f9bc541c57 --- /dev/null +++ b/.changeset/12348-details-sections-mirror.md @@ -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. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 6e670807eb..f4d06accc1 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3015,7 +3015,7 @@ const MEMBER_PINS: Record = { }, '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', diff --git a/packages/types/src/__tests__/record-details-section-members-8583.test.ts b/packages/types/src/__tests__/record-details-section-members-8583.test.ts index 243342fcd4..42d635ccdf 100644 --- a/packages/types/src/__tests__/record-details-section-members-8583.test.ts +++ b/packages/types/src/__tests__/record-details-section-members-8583.test.ts @@ -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 * @@ -109,6 +113,45 @@ type _DefaultCollapsed = Expect>; +/* ── 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>; + +/** + * 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>; + +/** + * 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 }; +type _EveryMemberTypeMirrorsSpec = Expect>; + +/** + * 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>; + /* ── Literals: what an author can now write, and what they still cannot ────── */ const SIX = ['columns', 'icon', 'description', 'showBorder', 'defaultCollapsed', 'headerColor'] as const; @@ -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. diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index 360597fa0e..9ebe666a59 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -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 diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 59068e1583..3ab40585e7 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -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(),