From 156cf2ef27f7b4008da882888c619dc5d2b0f39f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 15:26:09 +0000 Subject: [PATCH] fix(types): declare record:details hideFields / inlineEdit / showHeader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RecordDetailsComponentProps`'s top level had never been reconciled against `@objectstack/spec` — objectui#8583 did `sections[]` member-for-member and objectui#8604 fixed the top-level `columns` type, but the top-level KEY SET diverged in both directions. Direction 1 (fixed here): `hideFields`, `inlineEdit` and `showHeader` are declared by the spec, read by `RecordDetailsRenderer`, and published as inputs by `@object-ui/plugin-detail`'s registry manifest. Every layer declared them except this published TypeScript face, so a spec-valid, renderer-honoured, registry-published document was refused with TS2353. Each key takes the contract's own authoring type — `hideFields` stays `string[]`, the bare-name dialect the spec declares, rather than the `{name}` objects the renderer also tolerates at its read site. Direction 2 (ledgered, not fixed): the retired `layout` is still declared here and the contract refuses it by name (ADR-0087 D2 tombstone). Removing it is a published-surface retirement that breaks an in-repo consumer, which triage scoped out of this card; the divergence is now signposted at the declaration and pinned so it cannot rot into a stale comment. `record-details-top-level-9040.test.ts` pins both directions on two instruments. Only the `tsc` legs discriminate direction 1 — the defect was a TypeScript-only refusal, so the `safeParse` legs are labelled PREMISE and are green in both worlds. Part of #9040 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...40-record-details-top-level-spec-parity.md | 37 +++ .../record-details-top-level-9040.test.ts | 279 ++++++++++++++++++ packages/types/src/record-components.ts | 80 ++++- 3 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 .changeset/9040-record-details-top-level-spec-parity.md create mode 100644 packages/types/src/__tests__/record-details-top-level-9040.test.ts diff --git a/.changeset/9040-record-details-top-level-spec-parity.md b/.changeset/9040-record-details-top-level-spec-parity.md new file mode 100644 index 0000000000..95ba828ac8 --- /dev/null +++ b/.changeset/9040-record-details-top-level-spec-parity.md @@ -0,0 +1,37 @@ +--- +'@object-ui/types': minor +--- + +`RecordDetailsComponentProps` now declares the three top-level keys +`@objectstack/spec` declares and `RecordDetailsRenderer` honours: `hideFields`, +`inlineEdit` and `showHeader`. + +```ts +// now compiles — and always parsed +const props: RecordDetailsComponentProps = { + hideFields: ['name'], // string[] — omit fields already shown elsewhere + inlineEdit: false, // boolean — force the inline-edit affordance off + showHeader: true, // boolean — draw the detail body's own heading +}; +``` + +A widening only: no key changes type and nothing is removed, so no authored +document that compiled before stops compiling. + +Every other layer already declared these. `@objectstack/spec` accepts all three +(measured on the installed pin, 17.4.0, against a control — an undeclared key is +refused with `unrecognized_keys` on the same instrument); `RecordDetailsRenderer` +reads all three; and `@object-ui/plugin-detail`'s registry manifest publishes +all three as inputs (objectui#3808, objectui#4668). This published TypeScript +face was the one layer that refused them, so a spec-valid, renderer-honoured, +registry-published document got `TS2353` — the same reverse-direction defect +objectui#8583 fixed on `sections[]`, one level up. + +⚠️ The retired `layout` on the same interface is NOT removed here. The contract +refuses it by name (ADR-0087 D2 tombstone, removed in `@objectstack/spec` +17.0.0), so it remains a published key `tsc` accepts and publish rejects. +Removing it is a retirement with its own obligations — it breaks an in-repo +consumer that triage scoped out of objectui#9040 — and it needs its own change +and its own FROM/TO changeset. The divergence is now signposted at the +declaration and pinned by `record-details-top-level-9040.test.ts` so it cannot +rot into a stale comment. diff --git a/packages/types/src/__tests__/record-details-top-level-9040.test.ts b/packages/types/src/__tests__/record-details-top-level-9040.test.ts new file mode 100644 index 0000000000..df10582f9e --- /dev/null +++ b/packages/types/src/__tests__/record-details-top-level-9040.test.ts @@ -0,0 +1,279 @@ +/** + * 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#9040 — `RecordDetailsComponentProps`'s TOP LEVEL against the + * contract, in both directions. objectui#8583 reconciled `sections[]` + * member-for-member and objectui#8604 fixed the top-level `columns` type; the + * top-level KEY SET had never been reconciled, and it diverged both ways. + * + * Direction 1 — OMISSIONS (fixed here). `hideFields`, `inlineEdit` and + * `showHeader` are declared by `@objectstack/spec`, read by + * `RecordDetailsRenderer`, and published as inputs by + * `@object-ui/plugin-detail`'s registry manifest (objectui#3808 for + * `hideFields`, objectui#4668 for the other two). Every layer declared them + * except this published TypeScript face, so a spec-valid, renderer-honoured, + * registry-published document was refused by `tsc` with `TS2353`. + * + * Direction 2 — the RETIRED `layout` (NOT fixed here; ledgered). The contract + * refuses it by name. Removing it from this interface is a published-surface + * retirement that breaks an in-repo consumer (`p1-spec-alignment.test.ts`), + * and triage on objectui#9040 ruled that consumer out of this card's scope. + * So this file pins the divergence as OPEN rather than pretending it is shut: + * the legs below fail when the key is removed, which is how the remover finds + * the rest of the work (move the consumer, ship the `minor` retirement + * changeset) instead of discovering it from CI. + * + * ── Two instruments, and only ONE of them can see direction 1 ─────────────── + * - `tsc` sees the `@ts-expect-error` legs and the `Equal` assertions. That + * is the ONLY half that discriminates the fix from the defect, because the + * defect was a TypeScript-only refusal. It means nothing unless + * `type-check` runs — vitest strips types. + * - vitest runs the `safeParse` legs against the INSTALLED published spec + * artifact (17.4.0 at the time of writing), each with a control that would + * have fired. ⚠️ Those legs read the SPEC ONLY: they were green before this + * change and are green after, so they are the PREMISE, never the evidence. + * They are labelled PREMISE below so nobody counts them as the fix. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { RecordDetailsProps } from '@objectstack/spec/ui'; +import type { RecordDetailsComponentProps } from '../record-components'; + +/** What an author writes for the spec's `record:details` props bag. */ +type SpecProps = z.input; + +/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +/** The only assertion form used here — its constraint is what refuses `false`. */ +type Expect = T; + +/* ── Direction proofs: a broken instrument makes THIS file red ─────────────── */ + +// @ts-expect-error objectui#9040 — `Expect` must refuse `false`. Widen its constraint and this directive goes unused (TS2578). +type _ExpectRefusesFalse = Expect; + +// @ts-expect-error objectui#9040 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through. +type _EqualRefusesNever = Expect>; + +// @ts-expect-error objectui#9040 — `any` must NOT read as equal to `true`, for the same reason. +type _EqualRefusesAny = Expect>; + +/* ── Direction 1: the three omitted keys carry the contract's own types ────── */ + +/** RED before objectui#9040 (the key did not exist here), green after. */ +type _HideFields = Expect< + Equal +>; +type _InlineEdit = Expect< + Equal +>; +type _ShowHeader = Expect< + Equal +>; + +/** + * Spelled out as well as derived. `Equal` against `SpecProps` would also be + * satisfied if BOTH faces drifted to the same wrong type; these say what the + * type actually is, so a spec that widened `hideFields` to accept the `{name}` + * dialect the renderer tolerates cannot pull this declaration along silently. + */ +type _HideFieldsIsBareNames = Expect< + Equal +>; +type _InlineEditIsBoolean = Expect< + Equal +>; +type _ShowHeaderIsBoolean = Expect< + Equal +>; + +/** + * The card's repro, as a literal. Every key here is spec-valid and honoured by + * `RecordDetailsRenderer`; before objectui#9040 this object did not compile + * (`TS2353`, three times over) while the contract accepted the document. + */ +const omittedKeysAccepted: RecordDetailsComponentProps = { + hideFields: ['name'], + inlineEdit: false, + showHeader: true, +}; + +/** + * The value fences, in the direction the contract holds. + * + * ⚠️ NOT evidence for direction 1: an unknown key is `TS2353` too, so these + * directives were "used" before the fix as well and cannot tell the two worlds + * apart. The `Equal` assertions above are what do that. These exist so the new + * keys cannot later be loosened to `any` / `unknown` without a red line. + */ +const hideFieldsRefusesTheTolerantDialect: RecordDetailsComponentProps = { + // @ts-expect-error objectui#9040 — `hideFields` is `z.array(z.string())`: bare field names, never the `{name}` objects the renderer tolerates at its read site. + hideFields: [{ name: 'amount' }], +}; + +const inlineEditRefusesStrings: RecordDetailsComponentProps = { + // @ts-expect-error objectui#9040 — `inlineEdit` is a boolean on both faces; the string is refused here and with `invalid_type` at publish. + inlineEdit: 'yes', +}; + +/* ── Direction 2: the retired `layout`, ledgered as an OPEN divergence ─────── */ + +/** + * The TS face still declares `layout`; the contract's face accepts nothing + * there (`z.never()` under its optional wrapper, so the authoring type is + * `undefined`). The two faces DISAGREE, and that is the defect this asserts — + * it is a ledger entry, ⛔ not an endorsement. + * + * Remove the key and this line stops compiling (`TS2339`), which is the point: + * the removal is a retirement with its own obligations, and this is where its + * checklist lives. + */ +type _LayoutFacesDisagree = Expect< + Equal, false> +>; + +/** What each face actually says, so "disagree" is not satisfied by two unknowns. */ +type _LayoutOnTheTsFace = Expect< + Equal +>; +type _LayoutOnTheContractFace = Expect>; + +/** + * The trap, as a literal: `tsc` is green on a document the contract refuses. + * ⛔ Do not author this. When the retirement lands, this `const` is deleted + * together with the key. + */ +const layoutCompilesButIsRefusedAtPublish: RecordDetailsComponentProps = { + layout: 'stacked', +}; + +describe('objectui#9040 — record:details top level, against the installed spec', () => { + it('PREMISE (spec-only): the three keys are live members that judge their values', () => { + // Spec-only: green before and after objectui#9040. It is here so a spec + // that retires one of the three fails HERE, naming the key, instead of + // leaving the declarations above asserting a shape the contract dropped. + for (const [payload, expected] of [ + [{ hideFields: ['secret'] }, { hideFields: ['secret'] }], + [{ inlineEdit: true }, { inlineEdit: true }], + [{ inlineEdit: false }, { inlineEdit: false }], + [{ showHeader: true }, { showHeader: true }], + ] as const) { + const parsed = RecordDetailsProps.safeParse(payload); + // A FULL green parse, not merely "no unrecognized_keys": these legs are + // about the key being real AND its value being judged. + expect(parsed.success, `the contract refused ${JSON.stringify(payload)}`).toBe(true); + expect(parsed.data).toMatchObject(expected); + } + + // The other half: each key judges VALUES, so "accepted" above is not the + // same reading an open bag would produce. + for (const [key, bad] of [ + ['hideFields', 'notAnArray'], + ['inlineEdit', 'yes'], + ['showHeader', 'yes'], + ] as const) { + const refused = RecordDetailsProps.safeParse({ [key]: bad }); + expect(refused.success, `${key} accepted a wrongly-typed value`).toBe(false); + const issue = refused.error?.issues.find((i) => i.path.join('.') === key); + expect(issue?.code, `${key} was refused, but not at its own path`).toBe('invalid_type'); + } + + // THE CONTROL, on the same instrument: an undeclared key is refused with a + // DIFFERENT code, so the greens above are about these three keys and not + // about a schema that accepts anything. + const nonsense = RecordDetailsProps.safeParse({ zzzNonsenseKey: 'whatever' }); + expect(nonsense.success).toBe(false); + expect(nonsense.error?.issues[0]?.code).toBe('unrecognized_keys'); + }); + + it('PREMISE (spec-only): `layout` is refused BY NAME, not swept up as an unknown key', () => { + // The distinction this leg exists for: a tombstone is still a declared + // member typed `never`, so it fails with `invalid_type` at its own path + // and carries the removal prescription. An undeclared key fails with + // `unrecognized_keys` at the root. Reading them as the same failure is how + // a retired key gets mistaken for a typo. + for (const value of ['stacked', 'inline', 'compact', 'auto', 'custom']) { + const refused = RecordDetailsProps.safeParse({ layout: value }); + expect(refused.success, `the contract accepted layout: ${value}`).toBe(false); + const issue = refused.error?.issues.find((i) => i.path.join('.') === 'layout'); + expect(issue?.code).toBe('invalid_type'); + expect(issue?.message).toContain('was removed in @objectstack/spec 17.0.0'); + } + + // CONTROL A — a near-miss typo of the same key gets the OTHER code, which + // is what makes "by name" a reading rather than a claim. + const typo = RecordDetailsProps.safeParse({ layoutt: 'compact' }); + expect(typo.success).toBe(false); + expect(typo.error?.issues[0]?.code).toBe('unrecognized_keys'); + + // CONTROL B — a live key on the same instrument parses green, so the + // refusals above are not a schema refusing everything. + expect(RecordDetailsProps.safeParse({ columns: '2' }).success).toBe(true); + }); + + it('CONTROL: the keys that were ALREADY correct stay correct on both faces', () => { + // The live control for the ablation of this change: `columns`, `fields`, + // `sections` and `aria` are green whether or not objectui#9040 is applied. + // A leg that cannot tell the two worlds apart is named as such and is not + // counted as evidence — this one exists to catch an over-broad revert that + // takes the whole interface with it. + const alreadyCorrect: RecordDetailsComponentProps = { + columns: '2', + fields: ['name'], + sections: [{ label: 'Info', fields: ['name'] }], + aria: { ariaLabel: 'Account Details' }, + }; + expect(alreadyCorrect.columns).toBe('2'); + + const parsed = RecordDetailsProps.safeParse({ + columns: '2', + fields: ['name'], + sections: [{ label: 'Info', fields: ['name'] }], + aria: { ariaLabel: 'Account Details' }, + }); + expect(parsed.success).toBe(true); + }); + + it('the literals above are real values, not type-only decoration', () => { + // vitest strips types, so these expectations are NOT the assertion — the + // annotations are. They exist so the file also fails visibly if the + // literals are ever silently emptied out. + expect(omittedKeysAccepted.hideFields).toEqual(['name']); + expect(omittedKeysAccepted.inlineEdit).toBe(false); + expect(omittedKeysAccepted.showHeader).toBe(true); + expect(hideFieldsRefusesTheTolerantDialect.hideFields as unknown).toEqual([{ name: 'amount' }]); + expect(inlineEditRefusesStrings.inlineEdit as unknown).toBe('yes'); + expect(layoutCompilesButIsRefusedAtPublish.layout).toBe('stacked'); + }); + + it('the whole document the three keys make possible is accepted by the contract', () => { + // End to end, in the shape a page actually authors: the omitted-keys fix is + // only worth anything if the keys compose with the ones already declared. + const authored: RecordDetailsComponentProps = { + columns: '2', + fields: ['name', 'amount', 'stage'], + hideFields: ['name'], + inlineEdit: false, + showHeader: true, + sections: [{ name: 'info', label: 'Info', fields: ['amount'], columns: 2 }], + aria: { ariaLabel: 'Opportunity Details' }, + }; + + const parsed = RecordDetailsProps.safeParse(authored); + expect( + parsed.success, + `the contract refused a document this type now accepts: ${JSON.stringify( + parsed.success ? [] : parsed.error.issues, + )}`, + ).toBe(true); + }); +}); diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index f7ab7b3459..ec71d3c387 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -60,7 +60,40 @@ export interface RecordDetailsComponentProps { * against the installed spec. */ columns?: '1' | '2' | '3' | '4'; - /** Detail layout mode */ + /** + * ⛔ RETIRED UPSTREAM — the contract REFUSES this key by name. Do not author + * it; `tsc` accepting it here is the defect, not permission. + * + * `@objectstack/spec` declares the same top-level key as an ADR-0087 D2 + * tombstone: removed in 17.0.0 (objectstack#6946) because the published + * `auto` | `custom` semantics were never implemented. Measured on the + * installed pin (17.4.0): `RecordDetailsProps.safeParse({ layout: 'compact' })` + * is RED with `invalid_type` at `layout`, and the message is the removal + * prescription itself. The control on the same instrument fired as it should + * — every other top-level key here accepts a plausible value, and an + * undeclared key is refused with a DIFFERENT code (`unrecognized_keys`), so + * the refusal is about this key by name rather than a schema that refuses + * everything. + * + * ⚠️ Note the third spelling: this face offers `stacked` | `inline` | + * `compact`, which is not even the `auto` | `custom` the spec published + * before removing it. No value of either set parses. + * + * Every other layer has already withdrawn it — objectui#3818 removed the + * renderer's dead branch, and `@object-ui/plugin-detail`'s registry manifest + * deliberately publishes no `layout` input and says so at the site. This + * declaration is the last live holdout of the spelling. + * + * ⚠️ It is still here ON PURPOSE, and this is a ledger of an OPEN divergence, + * not an endorsement: removing it is a published-surface RETIREMENT that + * breaks an in-repo consumer (`__tests__/p1-spec-alignment.test.ts` declares + * `layout: 'stacked'` on this interface and reads it back), and triage on + * objectui#9040 ruled that consumer out of that card's scope. The removal + * needs its own change: delete the key, move that consumer, and ship the + * `minor` retirement changeset — `.changeset/retire-record-details-section-collapsed.md` + * is the in-repo shape to copy. `__tests__/record-details-top-level-9040.test.ts` + * pins both halves of this paragraph so it cannot rot into a stale comment. + */ layout?: 'stacked' | 'inline' | 'compact'; /** Sections to organize fields */ sections?: Array<{ @@ -148,6 +181,51 @@ export interface RecordDetailsComponentProps { }>; /** Specific fields to display (overrides auto-detection from object) */ fields?: string[]; + /** + * Field names to OMIT from the body — applied to `fields` above and to every + * section's `fields` (`@objectstack/spec` `RecordDetailsProps.hideFields`, + * `z.array(z.string())`). It is how a page stops repeating the fields already + * shown in `record:highlights` or as the page title. + * + * Bare field NAMES only, deliberately. `RecordDetailsRenderer` also tolerates + * `{name}` / `{field}` entries at its read site, but the contract declares + * `z.array(z.string())` and refuses those values on parse — declaring them + * here would publish a second dialect the contract rejects (Commandment + * #0.1). The registry manifest holds the same fence. + * + * Declared here since objectui#9040. Every other layer already declared it — + * the spec, `RecordDetailsRenderer` (`renderers/record-details.tsx`, in the + * highlight-dedup path) and `@object-ui/plugin-detail`'s registry manifest + * (objectui#3808) — so this published TypeScript face was the one layer that + * gave a spec-valid, renderer-honoured, registry-published document `TS2353`. + */ + hideFields?: string[]; + /** + * Allow inline field editing in the detail body + * (`@objectstack/spec` `RecordDetailsProps.inlineEdit`, `z.boolean()`). + * + * There is no schema default: the RENDERER's default is on, ANDed with the + * object's own editability and with the server's effective `apiOperations`, + * so `undefined` is not the same fact as `false`. `false` force-disables the + * affordance whatever the object permits (`schema.inlineEdit ?? true` at + * `renderers/record-details.tsx`). + * + * Declared here since objectui#9040, with `showHeader` below — the two keys + * `@objectstack/spec` 17.0.0 GA added to this block, already declared by the + * registry manifest under objectui#4668. + */ + inlineEdit?: boolean; + /** + * Render the detail body's OWN heading + * (`@objectstack/spec` `RecordDetailsProps.showHeader`, `z.boolean()`). + * + * Renderer default off (`schema.showHeader ?? false`), because a + * `record:details` composed under a `page:header` would otherwise draw a + * second title/star/copy chip beside the page's own. + * + * Declared here since objectui#9040 (see `inlineEdit` above). + */ + showHeader?: boolean; /** ARIA accessibility attributes */ aria?: RecordComponentAriaProps; }