From 3d0fb781eb55fb1b5b690a81d96f1ece1baf39de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 13:59:08 +0000 Subject: [PATCH 1/5] fix(types): `DetailViewSchema.related[].columns` accepts a bare field name (objectui#7997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The member was declared `TableColumn[]`. `RelatedList.normalizeColumn` has a dedicated bare-string branch — it resolves the name against the related object's schema, derives the header from the field's `label` and attaches a type-aware cell renderer — and `RelatedList.columnIdentityAccessor.test.tsx` has pinned that branch for cards. So the renderer accepted two arms and this declaration named one, and only on the typed authoring path: `DetailView` reaches the renderer through `columns={related.columns as any}`, so the cast — not the type — was carrying the string arm at runtime. The member becomes `Array`. Additive: the object arm is untouched and the README's own related block is type-checked unchanged. Two pins, in different programs and for different reasons: `packages/types` — the DECLARATION. Mutual-assignability `Eq` on the member (fails on the revert, on the `any[]` caricature and on over-rotating to `string[]` alike), three `@ts-expect-error` refusals that hold the union against `any[]`, a cross-face row asserting one `string[]` value assigns to BOTH this member and `RecordRelatedListComponentProps['columns']`, and the zod mirror's current reading with two live controls. `packages/plugin-detail` — the BEHAVIOUR, authored through the real view type, so a revert stops the file compiling rather than merely changing its meaning. Its firing control was measured by ablating the string branch on disk, not asserted. Scope note: `@objectstack/spec` declares no `DetailView` schema — this is objectui's own host-facing view schema. The spec-bound twin (`record:related_list`, spec `z.array(z.string())`, objectui `string[]`) was already aligned and is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- ...997-detail-view-related-columns-strings.md | 38 ++ ...List.detailViewStringColumns-7997.test.tsx | 189 +++++++++ .../detail-view-related-columns-7997.test.ts | 390 ++++++++++++++++++ packages/types/src/views.ts | 26 +- 4 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 .changeset/7997-detail-view-related-columns-strings.md create mode 100644 packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx create mode 100644 packages/types/src/__tests__/detail-view-related-columns-7997.test.ts diff --git a/.changeset/7997-detail-view-related-columns-strings.md b/.changeset/7997-detail-view-related-columns-strings.md new file mode 100644 index 0000000000..16db8919d7 --- /dev/null +++ b/.changeset/7997-detail-view-related-columns-strings.md @@ -0,0 +1,38 @@ +--- +'@object-ui/types': minor +--- + +`DetailViewSchema.related[].columns` accepts a bare field name (objectui#7997). + +The member was declared `TableColumn[]`. `RelatedList.normalizeColumn` has a +dedicated bare-string branch — it resolves the name against the related object's +schema, derives the header from the field's `label` and attaches a type-aware cell +renderer — and that branch is pinned by +`RelatedList.columnIdentityAccessor.test.tsx`. So the renderer accepted two arms and +the declaration named one, and the shorter form (`columns: ['status', 'amount']`) did +not type-check for a TypeScript host even though it renders *better* than the +hand-spelled equivalent: a hand-written `header` stops following a field's label +rename, a derived one does not. + +The member is now `Array`. + +**Additive. Nothing that compiled before stops compiling.** The object arm is +untouched — `TableColumn` entries, including the optional members (`width`, `align`, +`className`, …), still assign, and the `packages/plugin-detail/README.md` example is +type-checked unchanged in the new pin. Graded **minor** rather than patch on that +basis: it moves the published type surface of `@object-ui/types` and lets authors +write a shape that previously failed to compile, which is a feature-level change to +what the package accepts; and rather than **major**, because the widened member is in +*input* position for authors, so no existing author is broken by it. + +⚠️ Read `columns?: Array` as *what this renderer accepts*, not +as a protocol shape. `@objectstack/spec` declares no `DetailView` schema at all — +`DetailViewSchema` is objectui's own host-facing React view schema. The spec-bound +twin is the `record:related_list` page block, whose `columns` is `z.array(z.string())` +in the spec and `string[]` in `packages/types/src/record-components.ts`; those two were +already aligned and are **unchanged** here. + +**Not changed, deliberately, and pinned so it cannot drift:** the zod mirror +(`views.zod.ts` spells this member `z.array(z.any())`, so the JSON authoring path never +refused strings — the typed path was the only one that did) and the object arm itself +(retiring it is a separate, non-additive decision). diff --git a/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx new file mode 100644 index 0000000000..6026108012 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx @@ -0,0 +1,189 @@ +/** + * 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#7997 — the BEHAVIOUR half of the declaration widening: a + * `DetailViewSchema` authored with bare field names renders the columns the + * string branch resolves, and this file is typed so that the declaration and + * the behaviour cannot drift apart again. + * + * ## What this file adds that the neighbouring pins do not + * + * `RelatedList.columnIdentityAccessor.test.tsx` already pins the string branch + * ("keeps bare-string entries hydrating as before"), and it is the FIRING + * CONTROL for that branch — measured, see below. What no test covered is the + * seam this card is actually about: the columns arriving as a member of a + * `DetailViewSchema`, which is where the TypeScript author gets stopped. + * + * So `RELATED` below is annotated with the real view type. Revert + * `DetailViewSchema.related[].columns` to `TableColumn[]` and this file stops + * COMPILING (`tsc -p tsconfig.test.json`, TS2322 on the string entries) — the + * declaration is load-bearing here, not decorative. `DetailView` itself reaches + * the renderer through `columns={related.columns as any}`, so the cast — not + * the type — was carrying this arm at runtime; that is why the runtime needed + * no change and why a behaviour-only pin would have proved nothing about the + * card. + * + * ## The firing control, measured rather than asserted + * + * A green test against an arm that cannot break is not a measurement. The + * string branch of `RelatedList.normalizeColumn` was ablated on disk (the + * `typeof c !== 'string'` guard inverted, so bare strings fall through + * unresolved), the ablation was proved present by blob hash, and this file was + * run against it: + * + * - `renders the object-schema label as the header` — FAILED (the header cell + * read `status`, the raw field name, instead of `SchemaLabel`). + * - `renders type-aware cells` — FAILED (cells read `planned` / `running`, + * the raw stored values, instead of the option labels). + * + * The exact runs are quoted in the PR body. The file was restored from its + * `HEAD` blob and `git diff HEAD` proved empty before anything else was read. + * + * ## Desktop, pinned rather than inherited (objectui#8399) + * + * `RelatedList` reads `useIsMobile` (breakpoint 768): above it a `type="table"` + * list renders a real `data-table` with header cells; below it a card layout + * with no headers and no cells at all. Every assertion here reads a rendered + * header or cell, so the width is set explicitly rather than inherited from + * happy-dom's ambient 1024. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { DetailViewSchema } from '@object-ui/types'; +import { RelatedList } from '../RelatedList'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +/** + * A select field whose stored values and display labels differ, so "the string + * branch resolved the field def" and "it did not" produce different TEXT rather + * than the same text twice. + */ +const fields = { + status: { + type: 'select', + label: 'SchemaLabel', + options: [ + { value: 'planned', label: 'Planned' }, + { value: 'running', label: 'Running' }, + ], + }, +}; + +const rows = [ + { id: 't1', status: 'planned' }, + { id: 't2', status: 'running' }, +]; + +/** + * THE SUBJECT OF THIS CARD, authored the way a TypeScript host authors it. + * + * The annotation is the gate: before objectui#7997 the `columns: ['status']` + * entry was TS2322 against `TableColumn[]`, and the only way to write this was + * to drop the annotation or cast it away — which is precisely the report. + */ +const RELATED: NonNullable = [ + { title: 'Tasks', type: 'table', api: 'task', columns: ['status'] }, +]; + +const makeDS = (label = 'SchemaLabel') => ({ + find: vi.fn(async () => rows), + getObjectSchema: vi.fn(async () => ({ + name: 'task', + fields: { ...fields, status: { ...fields.status, label } }, + })), +}); + +/** + * Hand the renderer the member itself — no cast, no re-literal. If the two + * halves of this card ever disagree again, they disagree HERE. + */ +function renderFromView(entry = RELATED[0], schemaLabel = 'SchemaLabel') { + return render( + , + ); +} + +/** Every rendered header cell's text, in order. */ +const headers = () => + Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); + +/** Every rendered body cell's text. */ +const cellTexts = () => + Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim()); + +describe('objectui#7997 — a DetailViewSchema authored with field names renders', () => { + it('renders the object-schema label as the header, not the raw field name', async () => { + // The whole argument for the string arm: the header FOLLOWS the field's + // label, so a rename in the object schema reaches this list for free. The + // hand-spelled `{ accessorKey, header }` equivalent freezes the text. + renderFromView(); + + await waitFor(() => expect(screen.getByText('SchemaLabel')).toBeInTheDocument()); + expect(headers()).toEqual(['SchemaLabel']); + // The negative half, so the row cannot pass on a header that merely EXISTS. + expect(headers()).not.toContain('status'); + }); + + it('renders type-aware cells — option labels, not stored values', async () => { + // The second thing the string branch buys: a cell renderer resolved from + // the field def. Without it the column paints `planned` / `running`. + renderFromView(); + + await waitFor(() => + expect(cellTexts()).toEqual(expect.arrayContaining(['Planned', 'Running'])), + ); + expect(cellTexts()).not.toContain('planned'); + expect(cellTexts()).not.toContain('running'); + }); + + it('CONTROL — the harness renders the SAME column spelled as a TableColumn', async () => { + // Says the failures above would be about the string branch specifically, + // not about a list that cannot render this field at all. The object arm + // addresses the same field and reaches the same cells; only the header + // differs, because the author spelled it themselves. + renderFromView({ + title: 'Tasks', + type: 'table', + api: 'task', + columns: [{ accessorKey: 'status', header: 'Hand-spelled' }], + }); + + await waitFor(() => expect(screen.getByText('Hand-spelled')).toBeInTheDocument()); + expect(headers()).toEqual(['Hand-spelled']); + await waitFor(() => + expect(cellTexts()).toEqual(expect.arrayContaining(['Planned', 'Running'])), + ); + }); + + it('CONTROL — the header FOLLOWS the object schema, it is not echoed text', async () => { + // The complement of row one, and the assertion that makes row one mean what + // it claims. Same authored `columns: ['status']`, one thing changed — the + // field's `label` in the object schema — and the header moves with it. A + // branch that echoed the field name, or any fixed string, is red here while + // staying green on row one's positive alone. + renderFromView(RELATED[0], 'RenamedInSchema'); + + await waitFor(() => expect(screen.getByText('RenamedInSchema')).toBeInTheDocument()); + expect(headers()).toEqual(['RenamedInSchema']); + expect(headers()).not.toContain('SchemaLabel'); + }); +}); diff --git a/packages/types/src/__tests__/detail-view-related-columns-7997.test.ts b/packages/types/src/__tests__/detail-view-related-columns-7997.test.ts new file mode 100644 index 0000000000..2f9fad8f11 --- /dev/null +++ b/packages/types/src/__tests__/detail-view-related-columns-7997.test.ts @@ -0,0 +1,390 @@ +/** + * 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#7997 — `DetailViewSchema.related[].columns` admits a bare FIELD + * NAME, which is the arm `@objectstack/spec` declares. + * + * ## What was wrong + * + * The member was declared `TableColumn[]`. `RelatedList.normalizeColumn` has a + * dedicated bare-string branch that resolves the name against the related + * object's schema — header from the field's `label`, plus a type-aware cell + * renderer — and `RelatedList.columnIdentityAccessor.test.tsx` has pinned that + * behaviour ("keeps bare-string entries hydrating as before") for cards. So the + * renderer and its behaviour pins already agreed on two arms; only this + * declaration said one. Measured on the base commit: + * + * ``` + * const view: DetailViewSchema = { + * type: 'detail-view', + * related: [{ title: 'Tasks', type: 'table', columns: ['status'] }], + * }; // TS2322 + * ``` + * + * The direction was settled by the maintainer principle in force on this card, + * quoted verbatim and untranslated because a paraphrase is a different ruling: + * + * > 我们的项目以 objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。 + * + * `@objectstack/spec` `packages/spec/src/ui/component.zod.ts` declares + * `RecordRelatedListProps.columns` as `z.array(z.string())` — "Fields to + * display in the related list" — so the string arm is the protocol's arm, and + * the narrow declaration was the face out of line. + * + * ## Why the assertions below are shaped the way they are + * + * The caricature of a widening is `any[]`: it satisfies every "the string form + * now compiles" assertion while deleting the whole authoring contract. So the + * positives are never load-bearing alone. The division of labour between the + * constructs below was MEASURED by mutating the member three ways and reading + * `tsc -p tsconfig.test.json` each time — not reasoned about: + * + * | member mutated to | what catches it, in this file | + * | :-------------------------- | :---------------------------- | + * | `TableColumn[]` (the bug) | `_columnsShape`, `_stringArmReachesBothFaces`, and the four positive string rows | + * | `any[]` (the caricature) | `_columnsShape` + all three `@ts-expect-error`s go TS2578 "unused" | + * | `string[]` (over-rotation) | `_columnsShape` + the object-arm positives | + * + * ⚠️ The refusals do NOT catch the revert: `[42]` and `[{ header }]` are + * refused by `TableColumn[]` too, so those three rows stay green under the very + * bug this card fixes. `_columnsShape` is what holds that line — mutual + * assignability, so it fails in both directions where a one-way `extends` + * would pass for two of the three mutations above. + * + * ⚠️ Each `@ts-expect-error` sits on the `columns:` MEMBER, not on the `const`. + * The error is reported at the offending member inside the object literal, so a + * directive on the declaration suppresses nothing and `tsc` reports it twice + * over: TS2578 for the unused directive AND the unsuppressed TS2322 below it. + * Measured here, on the first run of this file — not a style choice. + * + * ## Which program checks this file + * + * `packages/types`' `type-check` runs THREE programs; this file is in the third + * (`tsconfig.test.json` — `tsc --noEmit` builds `tsconfig.json`, which excludes + * `__tests__/` by directory). The subject is imported as a sibling SOURCE + * module (`../views`), so that program reads the declaration directly and no + * `dist` staleness sits between this file and what it pins. + * + * ## What this card deliberately did NOT change + * + * Two things, both held visible below rather than left to rot into assumptions: + * + * 1. **The renderer.** Nothing in `RelatedList` moved. This is a declaration + * change, and the last block measures the runtime accept set it is catching + * up with. + * 2. **The object arm.** The spec's `columns` is strings ONLY, so objectui is + * WIDER than the protocol here. The principle quoted above forbids being + * narrower; it does not, in its own text, settle a wider arm — and + * `DetailViewSchema` is objectui's own host-facing React view schema, not a + * spec-bound block (`packages/spec/src/ui/view.zod.ts` declares no + * `DetailView` at all). Retiring the object arm is a separate, non-additive + * change that needs its own answer; the block below pins it as PRESENT so + * that answer is taken deliberately rather than by drift. + */ + +import { describe, it, expect } from 'vitest'; +import type { DetailViewSchema } from '../views'; +import type { TableColumn } from '../data-display'; +import type { RecordRelatedListComponentProps } from '../record-components'; +import { DetailViewSchema as DetailViewZodMirror } from '../zod/views.zod'; + +/** Mutual assignability, the standard invariant `Eq` — not `extends`. */ +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false; + +/** One entry of the `related` array — the object this card is about. */ +type RelatedEntry = NonNullable[number]; + +/* ── (a) the member's type, pinned whole ──────────────────────────────────── */ + +describe('objectui#7997 — DetailViewSchema.related[].columns declares both arms', () => { + it('the member type is exactly `TableColumn | string` entries, optional', () => { + // Mutual assignability, so this fails in BOTH directions: reverted to + // `TableColumn[]` it fails, and widened to `any[]` or over-rotated to + // `string[]` it also fails. A one-way `extends` check would pass for two of + // those three. This is the ONLY assertion in the file that catches the + // revert — see the table in the header. + const _columnsShape: Eq | undefined> = + true; + expect(_columnsShape).toBe(true); + }); + + it('the member is still optional — omitting `columns` is still authoring', () => { + const noColumns: DetailViewSchema = { + type: 'detail-view', + related: [{ title: 'Tasks', type: 'table', api: 'task' }], + }; + expect(noColumns.related?.[0].columns).toBeUndefined(); + }); +}); + +/* ── (b) the arm this card exists for ─────────────────────────────────────── */ + +describe('objectui#7997 — the protocol-shaped string form type-checks', () => { + it("the card's own reproduction compiles", () => { + const view: DetailViewSchema = { + type: 'detail-view', + related: [{ title: 'Tasks', type: 'table', api: 'task', columns: ['status'] }], + }; + expect(view.related?.[0].columns).toEqual(['status']); + }); + + it('the multi-field form the issue was filed with compiles', () => { + const view: DetailViewSchema = { + type: 'detail-view', + related: [{ title: 'Contacts', type: 'table', api: 'contact', columns: ['name', 'email'] }], + }; + expect(view.related?.[0].columns).toEqual(['name', 'email']); + }); + + it('a `string[]` built elsewhere assigns in — not only a fresh literal', () => { + // Producers hand `columns` a computed list (a picked subset of an object's + // fields, say). A widening that only admitted literals would not help them. + const picked: string[] = ['status', 'amount']; + const view: DetailViewSchema = { + type: 'detail-view', + related: [{ title: 'Tasks', type: 'table', api: 'task', columns: picked }], + }; + expect(view.related?.[0].columns).toBe(picked); + }); + + it('the string and object arms mix in one list', () => { + // `normalizeColumn` maps per entry, so a mixed list is a real authoring + // shape rather than a curiosity: name the fields you want defaults for, + // spell out the one you are overriding. + const view: DetailViewSchema = { + type: 'detail-view', + related: [ + { + title: 'Tasks', + type: 'table', + api: 'task', + columns: ['status', { accessorKey: 'amount', header: 'Value (USD)' }], + }, + ], + }; + expect(view.related?.[0].columns).toHaveLength(2); + }); +}); + +/* ── (c) non-regression: the object arm is untouched ──────────────────────── */ + +describe('objectui#7997 — every shape that compiled before still compiles', () => { + it("the README's own related block still type-checks", () => { + // `packages/plugin-detail/README.md` teaches this exact shape. A widening + // that broke it would be a breaking change wearing an additive label. + const view: DetailViewSchema = { + type: 'detail-view', + related: [ + { + title: 'Contacts', + type: 'table', + api: '/api/accounts/12345/contacts', + columns: [ + { accessorKey: 'name', header: 'Name' }, + { accessorKey: 'email', header: 'Email' }, + ], + }, + ], + }; + expect(view.related?.[0].columns).toHaveLength(2); + }); + + it('a `TableColumn[]` built elsewhere still assigns in', () => { + const built: TableColumn[] = [{ accessorKey: 'name', header: 'Name' }]; + const view: DetailViewSchema = { + type: 'detail-view', + related: [{ title: 'Contacts', type: 'table', api: 'contact', columns: built }], + }; + expect(view.related?.[0].columns).toBe(built); + }); + + it('the optional `TableColumn` members still reach the entry', () => { + // The object arm is the WHOLE `TableColumn`, not a narrowed stand-in. + const view: DetailViewSchema = { + type: 'detail-view', + related: [ + { + title: 'Contacts', + type: 'table', + api: 'contact', + columns: [{ accessorKey: 'name', header: 'Name', width: 240, align: 'right' }], + }, + ], + }; + const col = view.related?.[0].columns?.[0] as TableColumn; + expect(col.align).toBe('right'); + }); +}); + +/* ── (d) the refusals — the `any[]` caricature guard ──────────────────────── */ + +describe('objectui#7997 — the widening is a union, not `any[]`', () => { + it('a number entry is refused', () => { + const view: DetailViewSchema = { + type: 'detail-view', + related: [ + { + title: 'Tasks', + type: 'table', + api: 'task', + // @ts-expect-error a column is a field NAME or a `TableColumn`, never a number (objectui#7997) + columns: [42], + }, + ], + }; + expect(view).toBeDefined(); + }); + + it('an object entry with no `accessorKey` is refused — `TableColumn` requires it', () => { + const view: DetailViewSchema = { + type: 'detail-view', + related: [ + { + title: 'Tasks', + type: 'table', + api: 'task', + // @ts-expect-error `TableColumn.accessorKey` is required; a header alone addresses no field (objectui#7997) + columns: [{ header: 'Status' }], + }, + ], + }; + expect(view).toBeDefined(); + }); + + it('a bare string in place of the ARRAY is refused', () => { + // The member widened by one element type, not into "anything at all". + const view: DetailViewSchema = { + type: 'detail-view', + related: [ + { + title: 'Tasks', + type: 'table', + api: 'task', + // @ts-expect-error `columns` is a list of columns, not a single field name (objectui#7997) + columns: 'status', + }, + ], + }; + expect(view).toBeDefined(); + }); +}); + +/* ── (e) the two faces, measured against each other ───────────────────────── */ + +describe('objectui#7997 — the view schema and the `record:related_list` block agree', () => { + it('one `string[]` assigns to BOTH faces — the protocol shape reaches each', () => { + // The card is a DISAGREEMENT between two declarations of the same slot, so + // fixing one and leaving the other out of line would not close it. This is + // that check, made with a single value rather than two look-alike literals: + // `specShaped` is what `@objectstack/spec`'s `z.array(z.string())` admits, + // and both objectui faces now take it. + const specShaped: string[] = ['name', 'email']; + + const viaViewSchema: RelatedEntry['columns'] = specShaped; + const viaSpecBlock: RecordRelatedListComponentProps['columns'] = specShaped; + + expect(viaViewSchema).toBe(specShaped); + expect(viaSpecBlock).toBe(specShaped); + }); + + it('the `record:related_list` face is STILL strings-only — it was already right', () => { + // Held visible on purpose. That block mirrors a spec `strictObject`, and + // spec says strings; it needed no change on this card, and a future widening + // of it would be a protocol question, not a repo question. This assertion + // is what turns such a change into a red test rather than a quiet drift. + const _blockIsStringsOnly: Eq< + RecordRelatedListComponentProps['columns'], + string[] | undefined + > = true; + expect(_blockIsStringsOnly).toBe(true); + }); + + it('the object arm is objectui-only — the asymmetry this card did NOT resolve', () => { + // `DetailViewSchema` admits `TableColumn`; the spec-mirroring block does + // not. Pinned as PRESENT so that retiring the object arm (a separate, + // non-additive decision) reddens here and gets taken deliberately. + const objectArm: RelatedEntry['columns'] = [{ accessorKey: 'name', header: 'Name' }]; + expect(objectArm).toHaveLength(1); + + const _specBlockRefusesObjects: Eq< + NonNullable[number], + string + > = true; + expect(_specBlockRefusesObjects).toBe(true); + }); +}); + +/* ── (f) the other authoring face — the JSON mirror, held visible ─────────── */ + +describe('objectui#7997 — the zod mirror already admitted the string form', () => { + it('the JSON face accepts a bare-string column list', () => { + // THE HANDOFF READING. `views.zod.ts` spells this member + // `z.array(z.any())`, so the JSON authoring path never refused strings — + // the TypeScript path was the only one that did, which is exactly why the + // card reported the defect as reachable "only through the typed authoring + // path". No mirror change was needed, and this block records the reading so + // it cannot rot into an assumption that both faces moved together. + const r = DetailViewZodMirror.safeParse({ + type: 'detail-view', + related: [{ title: 'Tasks', type: 'table', api: 'task', columns: ['status'] }], + }); + expect(r.success).toBe(true); + }); + + it('the JSON face still accepts the object form', () => { + const r = DetailViewZodMirror.safeParse({ + type: 'detail-view', + related: [ + { + title: 'Contacts', + type: 'table', + api: 'contact', + columns: [{ accessorKey: 'name', header: 'Name' }], + }, + ], + }); + expect(r.success).toBe(true); + }); + + it('CONTROL — the mirror is live: it refuses a non-array `columns`', () => { + // Without this, both readings above would be equally green against a mirror + // that validates nothing at all. `z.array(z.any())` still refuses a scalar, + // so this is the assertion that says the member is being checked. + const r = DetailViewZodMirror.safeParse({ + type: 'detail-view', + related: [{ title: 'Tasks', type: 'table', api: 'task', columns: 'status' }], + }); + expect(r.success).toBe(false); + }); + + it('CONTROL — and it refuses a related entry missing its required `title`', () => { + // The second live control, one level up: it says the `related` entry shape + // itself is parsed, not waved through as `z.any()`. + const r = DetailViewZodMirror.safeParse({ + type: 'detail-view', + related: [{ type: 'table', api: 'task', columns: ['status'] }], + }); + expect(r.success).toBe(false); + }); + + it('the mirror is WIDER than the declaration — stated, not papered over', () => { + // `z.array(z.any())` admits a number column, which the TypeScript face now + // refuses (block (d)). Narrowing the mirror to match would be a change to + // what already-shipped JSON is allowed to say — non-additive, and not what + // this card was dispatched for. Reported rather than done; this row is the + // record of the gap. + const r = DetailViewZodMirror.safeParse({ + type: 'detail-view', + related: [{ title: 'Tasks', type: 'table', api: 'task', columns: [42] }], + }); + expect(r.success).toBe(true); + }); +}); diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index f674e671a8..0251797370 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -792,9 +792,31 @@ export interface DetailViewSchema extends BaseSchema { */ data?: any[]; /** - * Columns for table view + * Columns for the table view — either a bare FIELD NAME or a fully + * spelled adapter column. + * + * The bare-string arm is the one `@objectstack/spec` declares: its + * `RecordRelatedListProps.columns` is `z.array(z.string())` ("Fields to + * display in the related list"), and the objectui mirror of that block, + * `RecordRelatedListComponentProps.columns`, is `string[]` to match. + * `RelatedList.normalizeColumn` has always resolved a bare string against + * the related object's schema — deriving the header from the field's label + * and attaching a type-aware cell renderer — so `columns: ['status']` + * renders BETTER than the hand-spelled equivalent, whose header stops + * following a label rename. + * + * Only this declaration disagreed, and only on the typed authoring path: + * `DetailView` reaches the renderer through `columns={related.columns as + * any}`, so the cast — not the type — was carrying the string arm. + * Widened under the maintainer principle that the objectstack protocol is + * the reference and the documentation follows the real implementation + * (objectui#7997). + * + * The object arm is kept: this is objectui's own host-facing view schema, + * not a spec surface, and `TableColumn` entries are what the renderer's + * non-string branch consumes. */ - columns?: TableColumn[]; + columns?: Array; /** * Fields for list view */ From 73a7d5fda4ff3135bca4208e95378193833325b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:26:23 +0000 Subject: [PATCH 2/5] test(plugin-detail): record what the ablation actually fired, not what it was expected to (objectui#7997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firing control was run: the string branch of `RelatedList.normalizeColumn` was ablated on disk (guard widened so bare strings fall into the object branch), proved present by blob hash, and the pin was run against it — 2 failed | 2 passed, restored from the HEAD blob with `git diff HEAD` empty. Two of the three behaviour rows fired, and NOT the pair the header first claimed. The header derivation rows failed (`Unable to find an element with the text: SchemaLabel` / `…RenamedInSchema`); the type-aware CELL row stayed green, because the object branch attaches `makeCell` on the same terms and `columnIdentity` resolves a bare string. The header now names the measured split and the cell row is labelled for what it is — a non-regression control that the header assertions are read off a list that really rendered, not a firing row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- ...List.detailViewStringColumns-7997.test.tsx | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx index 6026108012..b5b27d7152 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx @@ -32,18 +32,32 @@ * ## The firing control, measured rather than asserted * * A green test against an arm that cannot break is not a measurement. The - * string branch of `RelatedList.normalizeColumn` was ablated on disk (the - * `typeof c !== 'string'` guard inverted, so bare strings fall through - * unresolved), the ablation was proved present by blob hash, and this file was - * run against it: + * string branch of `RelatedList.normalizeColumn` was ablated ON DISK — its + * `typeof c !== 'string'` guard widened so that bare strings fall into the + * OBJECT branch instead — the mutation was proved present by blob hash before + * anything was read, and this file was run against it. Result: **2 failed | 2 + * passed**. * - * - `renders the object-schema label as the header` — FAILED (the header cell - * read `status`, the raw field name, instead of `SchemaLabel`). - * - `renders type-aware cells` — FAILED (cells read `planned` / `running`, - * the raw stored values, instead of the option labels). + * WHAT FIRED, and it is not what you would guess from the row names: * - * The exact runs are quoted in the PR body. The file was restored from its - * `HEAD` blob and `git diff HEAD` proved empty before anything else was read. + * - `renders the object-schema label as the header` — FAILED, and the failure + * names the mechanism: `Unable to find an element with the text: + * SchemaLabel`. Header derivation from the field def is string-branch-only. + * - `CONTROL — the header FOLLOWS the object schema` — FAILED the same way + * (`…with the text: RenamedInSchema`). The pair is what carries this file. + * + * WHAT DID NOT FIRE, recorded because a reader would otherwise assume it did: + * + * - `renders type-aware cells` — still PASSED under the ablation. The object + * branch attaches `makeCell` too (`if (!c.cell && !c.render)`), and + * `columnIdentity('status')` resolves a bare string, so the cells keep + * rendering option labels with the string branch gone. That row is a + * NON-REGRESSION row, not a firing row, and it is kept as one: it says the + * header assertions above are not passing on a list that failed to render. + * + * ⇒ The header pair is the measurement; the cell row is the control that the + * measurement is being taken on a live list. The file was restored from its + * `HEAD` blob and `git diff HEAD` proved empty before the result was read. * * ## Desktop, pinned rather than inherited (objectui#8399) * @@ -144,8 +158,13 @@ describe('objectui#7997 — a DetailViewSchema authored with field names renders }); it('renders type-aware cells — option labels, not stored values', async () => { - // The second thing the string branch buys: a cell renderer resolved from - // the field def. Without it the column paints `planned` / `running`. + // A cell renderer resolved from the field def; without one the column + // paints the stored `planned` / `running`. + // + // ⚠️ NOT string-branch-exclusive, measured: this row stayed GREEN under the + // ablation described in the header, because the object branch attaches + // `makeCell` on the same terms. Kept as the row that says the header + // assertions above are read off a list that really rendered. renderFromView(); await waitFor(() => From f57dc39f3d0e70a354780b196fdfa1d82004c173 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 15:32:35 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(types)!:=20retire=20`DetailViewSchema.r?= =?UTF-8?q?elated`=20=E2=80=94=20author=20`record:related=5Flist`=20(objec?= =?UTF-8?q?tui#7997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling 2026-09-10, quoted verbatim and untranslated: 关掉详情页那个入口(推荐) This SUPERSEDES the widening this branch carried as Route A. `related` on a `detail-view` node was objectui's own second entry to a capability the protocol already governs, and `@objectstack/spec` declares no `DetailView` schema at all — every `DetailView` occurrence in `packages/spec/src` is prose about this repo's own `RecordDetailView.tsx`. So the array mirrored nothing and drifted: it typed `columns` as `TableColumn[]` while the renderer it fed also accepted bare field names and the `{ field, label }` / `{ name, label }` spellings. The axis that carried the ruling was measured ZERO PULL, not the alias argument the dispatch offered — that argument was refuted on this branch and is recorded as refuted in the pins so it is not repeated. A NAMED REFUSAL, not a bare delete. `BaseSchemaCore` ends `.passthrough()` and the TS `BaseSchema` closes with an any-valued index signature, so a dropped member key is KEPT, not refused — deleting the declaration would have left the silent accept exactly as it was (the mechanism objectui#7963 measured). `?: never` on the TypeScript face, `retirementTombstone()` on the mirror, one guidance string feeding both the parse-time message and `.describe()`. A pin authors an UNDECLARED sibling key through the same parse and watches it survive, so that reasoning is a reading rather than a claim. The entry closes at the renderer too, which is what the ruling asked for: `DetailView` no longer reads `schema.related`, and the flat Related section, the `autoTabs` Related tab, its trigger and its count badge are gone, as is `related` from the `detail-view` registry `inputs` / `defaultProps`. `RelatedList` itself, the `related-list` / `related_list` registrations and `record:related_list` are untouched — both entries always rendered through the same component, so nothing about the rendered result is lost. Two shipped documents stop teaching the retired array and gain a migration block each. The docs page had been teaching `{ name, label }` columns, a shape the retired declaration itself never admitted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- ...997-detail-view-related-columns-strings.md | 38 -- .../7997-detail-view-related-retired.md | 63 +++ content/docs/api/schema-reference.md | 44 +- packages/plugin-detail/README.md | 72 ++-- packages/plugin-detail/src/DetailView.tsx | 103 +---- ...tailView.relatedEntryRetired-7997.test.tsx | 200 +++++++++ .../src/__tests__/DetailView.test.tsx | 54 ++- ...List.detailViewStringColumns-7997.test.tsx | 208 ---------- packages/plugin-detail/src/index.tsx | 2 - .../detail-view-related-columns-7997.test.ts | 390 ------------------ .../detail-view-related-retired-7997.test.ts | 215 ++++++++++ packages/types/src/views.ts | 96 ++--- packages/types/src/zod/views.zod.ts | 63 ++- 13 files changed, 679 insertions(+), 869 deletions(-) delete mode 100644 .changeset/7997-detail-view-related-columns-strings.md create mode 100644 .changeset/7997-detail-view-related-retired.md create mode 100644 packages/plugin-detail/src/__tests__/DetailView.relatedEntryRetired-7997.test.tsx delete mode 100644 packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx delete mode 100644 packages/types/src/__tests__/detail-view-related-columns-7997.test.ts create mode 100644 packages/types/src/__tests__/detail-view-related-retired-7997.test.ts diff --git a/.changeset/7997-detail-view-related-columns-strings.md b/.changeset/7997-detail-view-related-columns-strings.md deleted file mode 100644 index 16db8919d7..0000000000 --- a/.changeset/7997-detail-view-related-columns-strings.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@object-ui/types': minor ---- - -`DetailViewSchema.related[].columns` accepts a bare field name (objectui#7997). - -The member was declared `TableColumn[]`. `RelatedList.normalizeColumn` has a -dedicated bare-string branch — it resolves the name against the related object's -schema, derives the header from the field's `label` and attaches a type-aware cell -renderer — and that branch is pinned by -`RelatedList.columnIdentityAccessor.test.tsx`. So the renderer accepted two arms and -the declaration named one, and the shorter form (`columns: ['status', 'amount']`) did -not type-check for a TypeScript host even though it renders *better* than the -hand-spelled equivalent: a hand-written `header` stops following a field's label -rename, a derived one does not. - -The member is now `Array`. - -**Additive. Nothing that compiled before stops compiling.** The object arm is -untouched — `TableColumn` entries, including the optional members (`width`, `align`, -`className`, …), still assign, and the `packages/plugin-detail/README.md` example is -type-checked unchanged in the new pin. Graded **minor** rather than patch on that -basis: it moves the published type surface of `@object-ui/types` and lets authors -write a shape that previously failed to compile, which is a feature-level change to -what the package accepts; and rather than **major**, because the widened member is in -*input* position for authors, so no existing author is broken by it. - -⚠️ Read `columns?: Array` as *what this renderer accepts*, not -as a protocol shape. `@objectstack/spec` declares no `DetailView` schema at all — -`DetailViewSchema` is objectui's own host-facing React view schema. The spec-bound -twin is the `record:related_list` page block, whose `columns` is `z.array(z.string())` -in the spec and `string[]` in `packages/types/src/record-components.ts`; those two were -already aligned and are **unchanged** here. - -**Not changed, deliberately, and pinned so it cannot drift:** the zod mirror -(`views.zod.ts` spells this member `z.array(z.any())`, so the JSON authoring path never -refused strings — the typed path was the only one that did) and the object arm itself -(retiring it is a separate, non-additive decision). diff --git a/.changeset/7997-detail-view-related-retired.md b/.changeset/7997-detail-view-related-retired.md new file mode 100644 index 0000000000..f632e7c06d --- /dev/null +++ b/.changeset/7997-detail-view-related-retired.md @@ -0,0 +1,63 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-detail': minor +--- + +`DetailViewSchema.related` is retired — author a `record:related_list` block +(objectui#7997, ADR-0049 enforce-or-remove; maintainer ruling 2026-09-10). + +**Breaking, and graded `minor` by this repo's convention** — a `major` would drag +the whole 39-package fixed group off `@objectstack`'s cadence. A `detail-view` +node authoring `related` used to parse **green** and render a Related section; it +now reds at that key on both faces, and the renderer draws nothing from it. + +**What retired is a DOOR, not the capability.** `record:related_list` is +unchanged and is now the only entry. It is the protocol-governed one +(`@objectstack/spec` `RecordRelatedListProps`), and it has always rendered +through the same `RelatedList` component the retired array fed — so nothing +about the rendered result is lost. + +| before, on a `detail-view` node | after | +| --- | --- | +| `related: [{ title, type, api, columns: [{ accessorKey, header }] }]` | a `record:related_list` node: `{ objectName, relationshipField, title, columns: ['name', 'email'] }` | + +⚠️ `columns` on the surviving entry is an array of **field-name strings**, which +is what the protocol declares. The header is derived from the related object's +field `label` and the cell from the field's type, so a label rename reaches the +list for free — the hand-spelled `{ accessorKey, header }` form froze both. +`relationshipField` names the field on the related object that points back at +this record, and replaces the retired form's `api` endpoint. + +**Why it retired.** `@objectstack/spec` declares no `DetailView` schema at all — +every `DetailView` occurrence in `packages/spec/src` is prose about this repo's +own `RecordDetailView.tsx` — so this array mirrored no protocol schema and +drifted freely: it declared `columns` as `TableColumn[]` while the renderer it +fed also accepted bare field names, `{ field, label }` and legacy +`{ name, label }` spellings. The axis that carried the ruling was measured **zero +pull**: no application code authored the member, both internal producers of a +`detail-view` node (`RecordDetailDrawer`, `renderers/record-details.tsx`) +synthesize it without `related`, and the only in-tree authorings carrying real +columns were two documents — both rewritten here. + +**A named refusal, not a deletion.** `BaseSchemaCore` ends `.passthrough()` and +the TypeScript `BaseSchema` closes with an any-valued index signature, so a +*dropped* member key is kept, not refused — deleting the declaration would have +left the silent accept exactly as it was. The key stays declared and unwritable: +`retirementTombstone()` on the Zod face, `?: never` on the TypeScript face, one +guidance string feeding both the parse-time message and `.describe()`. A pin +authors an undeclared sibling key through the same parse and watches it survive, +so "a bare delete would not have refused it" is a reading rather than a claim. + +**What moved in `@object-ui/plugin-detail`.** `DetailView` no longer reads +`schema.related`: the flat Related section, the `autoTabs` Related tab, its +trigger and its count badge are gone, and `related` is off the `detail-view` +registry's `inputs` and `defaultProps`. `RelatedList` itself, the +`related-list` / `related_list` registrations and `record:related_list` are +untouched. + +**Documentation.** `packages/plugin-detail/README.md` and +`content/docs/api/schema-reference.md` stop teaching the retired array and gain a +migration block each. ⚠️ The docs page had been teaching `{ name, label }` +columns, a shape the retired declaration itself never admitted — so the +documentation was already wrong about this member before it retired, in a +direction no route would have fixed on its own. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 327e7e2714..5dfdede106 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1083,7 +1083,7 @@ objectui#5667: nothing read them on the authored-node path. ### DetailViewSchema -An enhanced detail view for a single record with sections, tabs, related records, and navigation. +An enhanced detail view for a single record with sections, tabs and navigation. ```json { @@ -1122,18 +1122,6 @@ An enhanced detail view for a single record with sections, tabs, related records "content": { "type": "timeline", "events": [] } } ], - "related": [ - { - "title": "Recent Orders", - "type": "table", - "api": "/api/contacts/contact-123/orders", - "columns": [ - { "name": "id", "label": "Order #" }, - { "name": "total", "label": "Total" }, - { "name": "status", "label": "Status" } - ] - } - ], "actions": [ { "type": "action", "label": "Send Email", "icon": "Mail", "level": "primary" } ] @@ -1152,13 +1140,41 @@ An enhanced detail view for a single record with sections, tabs, related records | `sections` | `DetailViewSection[]` | Field groups with `title`, `icon`, `fields`, `collapsible`. | | `fields` | `DetailViewField[]` | Direct fields (without sections). | | `tabs` | `DetailViewTab[]` | Tabbed content with `key`, `label`, `icon`, `badge`, `content`. | -| `related` | `array` | Related record sections with `title`, `type`, `api`, `columns`. | +| `related` | ⛔ **RETIRED** | Retired in objectui#7997 (ADR-0049 enforce-or-remove). Authoring it is now refused by name on both faces. Author a `record:related_list` block instead — see below. | | `actions` | `ActionSchema[]` | Available actions. | | `showBack` / `backUrl` | `boolean` / `string` | Back navigation. | | `showEdit` / `editUrl` | `boolean` / `string` | Edit navigation. | | `showDelete` / `deleteConfirmation` | `boolean` / `string` | Delete with confirmation message. | | `header` / `footer` | `SchemaNode` | Custom header/footer content. | +> #### ⛔ `related` is retired — author `record:related_list` instead +> +> Until objectui#7997 this block carried its own `related` array, and this page +> taught it with `{ "name": ..., "label": ... }` columns. That array is retired +> under ADR-0049 enforce-or-remove: it was a second entry to a capability +> `@objectstack/spec` already governs, it mirrored no protocol schema, and it +> drifted from the renderer it fed. Authoring it is now **refused by name** on +> both the TypeScript and the JSON face — it is not silently ignored. +> +> Related lists have ONE entry now, and it renders through the same component: +> +> ```json +> { +> "type": "record:related_list", +> "objectName": "order", +> "relationshipField": "contact_id", +> "title": "Recent Orders", +> "columns": ["id", "total", "status"] +> } +> ``` +> +> ⚠️ `columns` here is an array of **field-name strings**, not column objects — +> that is what the protocol declares (`RecordRelatedListProps.columns`), and the +> header and cell formatting are derived from the related object's schema, so a +> field label rename reaches the list for free. `relationshipField` names the +> field on the RELATED object that points back at this record, and replaces the +> retired form's `api` endpoint. + **Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema) --- diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index ac9fca29eb..0a1df2f5df 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -7,7 +7,9 @@ DetailView plugin for ObjectUI - A comprehensive detail page component with fiel - **Field Grouping/Sections**: Organize fields into logical sections with titles - **Collapsible Sections**: Make sections collapsible to save space - **Tab Navigation**: Organize content into tabs for better UX -- **Related Lists**: Display related records (e.g., contacts for an account) +- **Related Lists**: Display related records (e.g., contacts for an account) — + authored as a `record:related_list` block, ⛔ not as a `detail-view` `related` + array, which is retired (objectui#7997; see **RelatedList** below) - **Action Buttons**: Edit, Delete, and custom action buttons - **Readonly/Edit Mode**: Toggle between view and edit modes - **Back Navigation**: Built-in back button with customizable behavior @@ -128,7 +130,7 @@ const accountDetail = ; ``` -### With Tabs and Related Lists +### With Tabs ```tsx import { DetailView } from '@object-ui/plugin-detail'; @@ -170,30 +172,6 @@ const accountDetail = #### ⛔ `DetailViewSchema.related` is retired — author `record:related_list` +> +> Until objectui#7997 a `detail-view` node could carry its own `related` array, +> and this README taught it. That array is retired under ADR-0049 +> enforce-or-remove (maintainer ruling 2026-09-10). It was a second entry to a +> capability `@objectstack/spec` already governs — the protocol declares no +> `DetailView` schema at all — so it mirrored nothing and drifted from the +> renderer it fed: it typed `columns` as `TableColumn` objects while the +> renderer also accepted bare field names. Authoring it is now **refused by +> name** on both the TypeScript and the JSON face, ⛔ not silently ignored. +> +> Nothing about the rendered result changed: both entries always went through +> the `RelatedList` component documented here. Only the second door closed. +> +> ```tsx +> import { SchemaRenderer } from '@object-ui/react'; +> +> const contacts = ( +> schema={{ +> type: 'record:related_list', +> objectName: 'contact', +> relationshipField: 'account_id', +> title: 'Contacts', +> columns: ['name', 'email', 'phone'], +> }} +> /> +> ); +> ``` +> +> ⚠️ `columns` is an array of **field-name strings**, which is what the protocol +> declares (`RecordRelatedListProps.columns`). The header comes from the related +> object's field `label` and the cell from the field's type, so a label rename +> reaches the list for free — the hand-spelled `{ accessorKey, header }` form the +> retired array taught froze both. `relationshipField` names the field on the +> related object pointing back at this record, and replaces the retired form's +> `api` endpoint. + + Related lists are **paged by default**: the `record:related_list` renderer applies the spec default `limit` of **5** when the node doesn't declare one (`@objectstack/spec` `RecordRelatedListProps.limit`, "Number of records to diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index 6e2fdb7215..afc442e881 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -37,7 +37,6 @@ import { } from 'lucide-react'; import { DetailSection } from './DetailSection'; import { DetailTabs } from './DetailTabs'; -import { RelatedList } from './RelatedList'; import { SectionGroup } from './SectionGroup'; import { HeaderHighlight } from './HeaderHighlight'; import { RecordComments } from './RecordComments'; @@ -56,8 +55,6 @@ import { hasCellValue } from './emptiness'; import { enrichDetailField } from './fieldEnrichment'; import { chipTakesCellRenderer } from './summaryChipRenderers'; -/** Default page size for related lists in the detail view */ -const DEFAULT_RELATED_PAGE_SIZE = 5; /** Stable empty draft so the section `data`-merge identity is preserved when * no is mounted (bare / read-only DetailView). */ @@ -845,18 +842,22 @@ export const DetailView: React.FC = ({ return () => document.removeEventListener('keydown', handler); }, [schema.recordNavigation]); + // objectui#7997 — the `related` ENTRY on this node is RETIRED (ADR-0049 + // enforce-or-remove, maintainer ruling 2026-09-10: 「关掉详情页那个入口(推荐)」). + // `DetailViewSchema.related` is a `?: never` tombstone on the TypeScript face + // and a `retirementTombstone()` arm on the zod mirror, so this component no + // longer reads it and no longer renders a Related tab or a Related section. + // + // ⛔ The capability did not retire, only this door: author a + // `record:related_list` block, which is the protocol-governed entry + // (@objectstack/spec `RecordRelatedListProps`) and which has always rendered + // through the same `RelatedList` component this branch used — see + // `renderers/record-related-list.tsx`. + // // Auto-discovery of related panels via INVERSE references (other objects - // whose FK points to the current record) is the responsibility of the - // page layer (e.g. RecordDetailView), which has access to the registry of - // all objects. We deliberately do NOT auto-derive related panels from the - // current object's *forward* lookups (account, owner, …) — those are - // parent references already surfaced as detail fields, and listing them - // here always produces empty 0-count panels with no usable "+ New" CTA - // (the new child wouldn't have an FK to back-fill). Leaving them out - // avoids the misleading "为什么有的能新建有的不能" experience. - const effectiveRelated: NonNullable = React.useMemo(() => { - return schema.related ?? []; - }, [schema.related]); + // whose FK points at the current record) was never this component's job + // either; it belongs to the page layer (e.g. RecordDetailView), which has the + // registry of all objects. /** * Chrome-level "system" actions (Duplicate, Export, View History, Delete, @@ -1592,7 +1593,6 @@ export const DetailView: React.FC = ({ When only the Details tab would render (no related, no activity, no discussion), skip the Tabs strip entirely — it's pure visual noise. */} {schema.autoTabs && !schema.tabs?.length ? (() => { - const hasRelated = effectiveRelated.length > 0; const hasActivity = !!schema.activities && schema.activities.length > 0; const hasDiscussion = !!discussionSlot; const hasHistory = !!schema.history; @@ -1603,7 +1603,6 @@ export const DetailView: React.FC = ({ // in Radix's uncontrolled state. const tabValues = [ 'details', - ...(hasRelated ? ['related'] : []), ...(hasActivity ? ['activity'] : []), ...(hasDiscussion ? ['discussion'] : []), ...(hasHistory ? ['history'] : []), @@ -1673,7 +1672,7 @@ export const DetailView: React.FC = ({ ); - if (!hasRelated && !hasActivity && !hasDiscussion && !hasHistory) { + if (!hasActivity && !hasDiscussion && !hasHistory) { // Single-tab case: render just the details content without a tab strip. return
{detailsContent}
; } @@ -1687,17 +1686,6 @@ export const DetailView: React.FC = ({ > {t('detail.details')} - {hasRelated && ( - - - {t('detail.related')} - {effectiveRelated.length} - - - )} {hasActivity && ( = ({ {detailsContent} - {/* Related Tab Content */} - {hasRelated && ( - -
- {effectiveRelated.map((related, index) => ( - 0} - pageSize={DEFAULT_RELATED_PAGE_SIZE} - /> - ))} -
-
- )} - {/* Activity Tab Content */} {hasActivity && ( @@ -1861,35 +1819,6 @@ export const DetailView: React.FC = ({ )} - {/* Related Lists */} - {effectiveRelated.length > 0 && ( -
-

{t('detail.related')}

- {effectiveRelated.map((related, index) => ( - 0} - pageSize={DEFAULT_RELATED_PAGE_SIZE} - /> - ))} -
- )} - {/* Comments */} {schema.comments && ( 关掉详情页那个入口(推荐) + * + * So absence alone is the wrong shape of evidence. Every absence row here is + * paired with a positive on the SAME `RelatedList` component the retired branch + * used to feed — `renderers/record-related-list.tsx` renders it — which is what + * makes "the door closed" separable from "the room is gone". + * + * ## The casts are load-bearing + * + * `DetailViewSchema.related` is a `?: never` tombstone, so these fixtures + * cannot be authored without a cast — that IS the TypeScript half of the + * refusal, pinned in `packages/types`. They are cast anyway so the RUNTIME half + * is read: a host that ignores `tsc`, or a plain JSON document that reaches the + * renderer without passing the zod mirror, still gets nothing rendered rather + * than a silently honoured second door. + * + * ## The firing control, MEASURED — this file reddens when the entry is open + * + * An absence pin that cannot fail is decoration. The retirement was ablated ON + * DISK by restoring `DetailView.tsx` from the pre-retirement commit — the entry + * open again, everything else on the branch unchanged — proved present by blob + * hash and by an on-disk marker count (`effectiveRelated`: 0 -> 6), and this + * file was run against it. Result: **3 failed | 2 passed**. + * + * WHAT FIRED — every absence row, and only those: + * + * - `renders no related section, no heading and no rows` — + * `expected document not to contain element, found SPAN` + * - `grows no Related TAB either, under autoTabs` — + * `expected document not to contain element, found BUTTON` (the tab trigger) + * - `with ONLY the retired key authored, autoTabs renders no tab strip at all` — + * `expected [ BUTTON, …(1) ] to have a length of +0 but got 2` + * + * WHAT STAYED GREEN, correctly — the two CONTROL rows. "the rest of the node + * renders" and "the SAME RelatedList component still renders a related list" + * are true on both sides of the ablation, which is exactly what makes them + * controls rather than firing rows: they separate "the door closed" from "the + * component broke" and from "the room is gone". + * + * The file was restored from the saved retired blob and the restoration was + * verified by HASH (⛔ not by an exit code, and ⛔ not by `git diff HEAD`, which + * is non-empty here by construction — the retirement is the diff). + * + * ## Desktop, pinned rather than inherited (objectui#8399) + * + * `RelatedList` reads `useIsMobile` (breakpoint 768): above it a `type="table"` + * list renders a real `data-table` with header cells, below it a card layout + * with neither. The positive rows read rendered text either way, but the width + * is set explicitly rather than inherited from happy-dom's ambient 1024. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { DetailViewSchema } from '@object-ui/types'; +import { DetailView } from '../DetailView'; +import { RelatedList } from '../RelatedList'; + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +const RELATED_ROWS = [ + { id: 'c1', name: 'Ada Lovelace', status: 'planned' }, + { id: 'c2', name: 'Alan Turing', status: 'running' }, +]; + +const fields = { + name: { type: 'text', label: 'Full Name' }, + status: { + type: 'select', + label: 'Stage', + options: [ + { value: 'planned', label: 'Planned' }, + { value: 'running', label: 'Running' }, + ], + }, +}; + +const makeDS = () => ({ + find: vi.fn(async () => RELATED_ROWS), + getObjectSchema: vi.fn(async () => ({ name: 'contact', fields })), +}); + +/** The retired authoring shape, exactly as the README and the docs taught it. */ +const withRelated = { + type: 'detail-view', + title: 'Account Details', + data: { name: 'Acme Corp' }, + fields: [{ name: 'name', label: 'Name' }], + related: [ + { + title: 'Contacts', + type: 'table', + data: RELATED_ROWS, + columns: [{ accessorKey: 'name', header: 'Full Name' }], + }, + ], +} as unknown as DetailViewSchema; + +describe('objectui#7997 — the detail-view `related` entry is closed', () => { + it('renders no related section, no heading and no rows', () => { + render(); + + expect(screen.queryByText('Contacts')).not.toBeInTheDocument(); + expect(screen.queryByText('Related')).not.toBeInTheDocument(); + expect(screen.queryByText('Ada Lovelace')).not.toBeInTheDocument(); + expect(screen.queryByText('Full Name')).not.toBeInTheDocument(); + }); + + it('CONTROL — the rest of the node renders, so the absences are readings', () => { + // Without this, every assertion above would be equally green against a + // component that threw during mount. + render(); + expect(screen.getByText('Account Details')).toBeInTheDocument(); + }); + + it('grows no Related TAB either, under `autoTabs`', () => { + // The retired branch had TWO render sites: the flat section covered above, + // and an `autoTabs` tab with its own trigger and count badge. Retiring one + // and leaving the other is the half-landing this row exists to catch. + const autoTabbed = { + ...(withRelated as unknown as Record), + autoTabs: true, + } as unknown as DetailViewSchema; + + render(); + + expect(screen.queryByRole('tab', { name: /Related/i })).not.toBeInTheDocument(); + expect(screen.queryByText('Contacts')).not.toBeInTheDocument(); + }); + + it('with ONLY the retired key authored, `autoTabs` renders no tab strip at all', () => { + // The tab strip is skipped when Details would be the only tab. Before this + // card `related` alone was enough to raise a strip; it must not be now, or + // the retirement would leave an empty second tab behind. + const onlyRelated = { + type: 'detail-view', + title: 'Account Details', + data: { name: 'Acme Corp' }, + fields: [{ name: 'name', label: 'Name' }], + autoTabs: true, + related: [{ title: 'Contacts', type: 'table', data: RELATED_ROWS }], + } as unknown as DetailViewSchema; + + render(); + + expect(screen.queryAllByRole('tab')).toHaveLength(0); + expect(screen.getByText('Account Details')).toBeInTheDocument(); + }); +}); + +describe('objectui#7997 — the capability survives on the entry that kept it', () => { + it('the SAME RelatedList component still renders a related list', () => { + // THE ROW THAT MAKES THE ABSENCES MEAN "the door closed" RATHER THAN "the + // room is gone". `renderers/record-related-list.tsx` renders exactly this + // component, with `columns` spelled the protocol way — an array of field + // names — which is what an author migrating off the retired key writes. + render( + , + ); + + return waitFor(() => { + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + // Header derived from the object schema's field label, and the cell + // rendered through the field's type — the behaviour the surviving entry + // keeps, read here so the migration advice in the tombstone is testable + // rather than aspirational. + expect(screen.getByText('Stage')).toBeInTheDocument(); + expect(screen.getByText('Planned')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/DetailView.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.test.tsx index 8c22fd0f3e..de0c2384bb 100644 --- a/packages/plugin-detail/src/__tests__/DetailView.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailView.test.tsx @@ -259,24 +259,35 @@ describe('DetailView', () => { expect(screen.getByText('Activity')).toBeInTheDocument(); }); - it('should render related lists when provided', () => { - const schema: DetailViewSchema = { + it('no longer renders a related list — the entry is retired (objectui#7997)', () => { + // WAS `should render related lists when provided`, and its inversion is the + // point of objectui#7997: `DetailViewSchema.related` retired under ADR-0049 + // enforce-or-remove (maintainer ruling 2026-09-10). The capability moved to + // its one protocol-governed entry, `record:related_list`. + // + // The cast is load-bearing, not laziness: the member is a `?: never` + // tombstone, so this object cannot be authored without one — which is the + // TypeScript half of the refusal. It is cast anyway so the RUNTIME half is + // read too: a host that ignores `tsc`, or a plain JSON document, still gets + // nothing rendered rather than a silently honoured second door. + const schema = { type: 'detail-view', title: 'Account Details', data: { name: 'Acme Corp' }, fields: [{ name: 'name', label: 'Name' }], - related: [ - { - title: 'Contacts', - type: 'table', - data: [], - }, - ], - }; + related: [{ title: 'Contacts', type: 'table', data: [] }], + } as unknown as DetailViewSchema; render(); - - expect(screen.getByText('Contacts')).toBeInTheDocument(); + + expect(screen.queryByText('Contacts')).not.toBeInTheDocument(); + // The section heading went with it — this row absorbed the former + // `should use i18n fallback for related section heading` test, whose whole + // subject was that heading. + expect(screen.queryByText('Related')).not.toBeInTheDocument(); + // CONTROL: the rest of the node still renders, so the two absences above + // are readings and not a component that failed to mount. + expect(screen.getByText('Account Details')).toBeInTheDocument(); }); it('should show loading skeleton when loading is true', () => { @@ -762,23 +773,4 @@ describe('DetailView', () => { ); }); - it('should use i18n fallback for related section heading', () => { - const schema: DetailViewSchema = { - type: 'detail-view', - title: 'Account Details', - data: { name: 'Acme Corp' }, - fields: [{ name: 'name', label: 'Name' }], - related: [ - { - title: 'Contacts', - type: 'table', - data: [], - }, - ], - }; - - render(); - // The "Related" heading uses t('detail.related') - expect(screen.getByText('Related')).toBeInTheDocument(); - }); }); diff --git a/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx deleted file mode 100644 index b5b27d7152..0000000000 --- a/packages/plugin-detail/src/__tests__/RelatedList.detailViewStringColumns-7997.test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -/** - * 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#7997 — the BEHAVIOUR half of the declaration widening: a - * `DetailViewSchema` authored with bare field names renders the columns the - * string branch resolves, and this file is typed so that the declaration and - * the behaviour cannot drift apart again. - * - * ## What this file adds that the neighbouring pins do not - * - * `RelatedList.columnIdentityAccessor.test.tsx` already pins the string branch - * ("keeps bare-string entries hydrating as before"), and it is the FIRING - * CONTROL for that branch — measured, see below. What no test covered is the - * seam this card is actually about: the columns arriving as a member of a - * `DetailViewSchema`, which is where the TypeScript author gets stopped. - * - * So `RELATED` below is annotated with the real view type. Revert - * `DetailViewSchema.related[].columns` to `TableColumn[]` and this file stops - * COMPILING (`tsc -p tsconfig.test.json`, TS2322 on the string entries) — the - * declaration is load-bearing here, not decorative. `DetailView` itself reaches - * the renderer through `columns={related.columns as any}`, so the cast — not - * the type — was carrying this arm at runtime; that is why the runtime needed - * no change and why a behaviour-only pin would have proved nothing about the - * card. - * - * ## The firing control, measured rather than asserted - * - * A green test against an arm that cannot break is not a measurement. The - * string branch of `RelatedList.normalizeColumn` was ablated ON DISK — its - * `typeof c !== 'string'` guard widened so that bare strings fall into the - * OBJECT branch instead — the mutation was proved present by blob hash before - * anything was read, and this file was run against it. Result: **2 failed | 2 - * passed**. - * - * WHAT FIRED, and it is not what you would guess from the row names: - * - * - `renders the object-schema label as the header` — FAILED, and the failure - * names the mechanism: `Unable to find an element with the text: - * SchemaLabel`. Header derivation from the field def is string-branch-only. - * - `CONTROL — the header FOLLOWS the object schema` — FAILED the same way - * (`…with the text: RenamedInSchema`). The pair is what carries this file. - * - * WHAT DID NOT FIRE, recorded because a reader would otherwise assume it did: - * - * - `renders type-aware cells` — still PASSED under the ablation. The object - * branch attaches `makeCell` too (`if (!c.cell && !c.render)`), and - * `columnIdentity('status')` resolves a bare string, so the cells keep - * rendering option labels with the string branch gone. That row is a - * NON-REGRESSION row, not a firing row, and it is kept as one: it says the - * header assertions above are not passing on a list that failed to render. - * - * ⇒ The header pair is the measurement; the cell row is the control that the - * measurement is being taken on a live list. The file was restored from its - * `HEAD` blob and `git diff HEAD` proved empty before the result was read. - * - * ## Desktop, pinned rather than inherited (objectui#8399) - * - * `RelatedList` reads `useIsMobile` (breakpoint 768): above it a `type="table"` - * list renders a real `data-table` with header cells; below it a card layout - * with no headers and no cells at all. Every assertion here reads a rendered - * header or cell, so the width is set explicitly rather than inherited from - * happy-dom's ambient 1024. - */ -import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; -import type { DetailViewSchema } from '@object-ui/types'; -import { RelatedList } from '../RelatedList'; - -beforeAll(() => { - Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); -}); - -/** - * A select field whose stored values and display labels differ, so "the string - * branch resolved the field def" and "it did not" produce different TEXT rather - * than the same text twice. - */ -const fields = { - status: { - type: 'select', - label: 'SchemaLabel', - options: [ - { value: 'planned', label: 'Planned' }, - { value: 'running', label: 'Running' }, - ], - }, -}; - -const rows = [ - { id: 't1', status: 'planned' }, - { id: 't2', status: 'running' }, -]; - -/** - * THE SUBJECT OF THIS CARD, authored the way a TypeScript host authors it. - * - * The annotation is the gate: before objectui#7997 the `columns: ['status']` - * entry was TS2322 against `TableColumn[]`, and the only way to write this was - * to drop the annotation or cast it away — which is precisely the report. - */ -const RELATED: NonNullable = [ - { title: 'Tasks', type: 'table', api: 'task', columns: ['status'] }, -]; - -const makeDS = (label = 'SchemaLabel') => ({ - find: vi.fn(async () => rows), - getObjectSchema: vi.fn(async () => ({ - name: 'task', - fields: { ...fields, status: { ...fields.status, label } }, - })), -}); - -/** - * Hand the renderer the member itself — no cast, no re-literal. If the two - * halves of this card ever disagree again, they disagree HERE. - */ -function renderFromView(entry = RELATED[0], schemaLabel = 'SchemaLabel') { - return render( - , - ); -} - -/** Every rendered header cell's text, in order. */ -const headers = () => - Array.from(document.querySelectorAll('thead th')).map((th) => (th.textContent ?? '').trim()); - -/** Every rendered body cell's text. */ -const cellTexts = () => - Array.from(document.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim()); - -describe('objectui#7997 — a DetailViewSchema authored with field names renders', () => { - it('renders the object-schema label as the header, not the raw field name', async () => { - // The whole argument for the string arm: the header FOLLOWS the field's - // label, so a rename in the object schema reaches this list for free. The - // hand-spelled `{ accessorKey, header }` equivalent freezes the text. - renderFromView(); - - await waitFor(() => expect(screen.getByText('SchemaLabel')).toBeInTheDocument()); - expect(headers()).toEqual(['SchemaLabel']); - // The negative half, so the row cannot pass on a header that merely EXISTS. - expect(headers()).not.toContain('status'); - }); - - it('renders type-aware cells — option labels, not stored values', async () => { - // A cell renderer resolved from the field def; without one the column - // paints the stored `planned` / `running`. - // - // ⚠️ NOT string-branch-exclusive, measured: this row stayed GREEN under the - // ablation described in the header, because the object branch attaches - // `makeCell` on the same terms. Kept as the row that says the header - // assertions above are read off a list that really rendered. - renderFromView(); - - await waitFor(() => - expect(cellTexts()).toEqual(expect.arrayContaining(['Planned', 'Running'])), - ); - expect(cellTexts()).not.toContain('planned'); - expect(cellTexts()).not.toContain('running'); - }); - - it('CONTROL — the harness renders the SAME column spelled as a TableColumn', async () => { - // Says the failures above would be about the string branch specifically, - // not about a list that cannot render this field at all. The object arm - // addresses the same field and reaches the same cells; only the header - // differs, because the author spelled it themselves. - renderFromView({ - title: 'Tasks', - type: 'table', - api: 'task', - columns: [{ accessorKey: 'status', header: 'Hand-spelled' }], - }); - - await waitFor(() => expect(screen.getByText('Hand-spelled')).toBeInTheDocument()); - expect(headers()).toEqual(['Hand-spelled']); - await waitFor(() => - expect(cellTexts()).toEqual(expect.arrayContaining(['Planned', 'Running'])), - ); - }); - - it('CONTROL — the header FOLLOWS the object schema, it is not echoed text', async () => { - // The complement of row one, and the assertion that makes row one mean what - // it claims. Same authored `columns: ['status']`, one thing changed — the - // field's `label` in the object schema — and the header moves with it. A - // branch that echoed the field name, or any fixed string, is red here while - // staying green on row one's positive alone. - renderFromView(RELATED[0], 'RenamedInSchema'); - - await waitFor(() => expect(screen.getByText('RenamedInSchema')).toBeInTheDocument()); - expect(headers()).toEqual(['RenamedInSchema']); - expect(headers()).not.toContain('SchemaLabel'); - }); -}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 3f7867383c..7aa588f5df 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -297,7 +297,6 @@ ComponentRegistry.register('detail-view', DetailViewRenderer, { { name: 'sections', type: 'array' }, { name: 'fields', type: 'array' }, { name: 'tabs', type: 'array' }, - { name: 'related', type: 'array' }, { name: 'actions', type: 'array' }, { name: 'showBack', type: 'boolean' }, { name: 'backUrl', type: 'string' }, @@ -317,7 +316,6 @@ ComponentRegistry.register('detail-view', DetailViewRenderer, { sections: [], fields: [], tabs: [], - related: [], } }); diff --git a/packages/types/src/__tests__/detail-view-related-columns-7997.test.ts b/packages/types/src/__tests__/detail-view-related-columns-7997.test.ts deleted file mode 100644 index 2f9fad8f11..0000000000 --- a/packages/types/src/__tests__/detail-view-related-columns-7997.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * 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#7997 — `DetailViewSchema.related[].columns` admits a bare FIELD - * NAME, which is the arm `@objectstack/spec` declares. - * - * ## What was wrong - * - * The member was declared `TableColumn[]`. `RelatedList.normalizeColumn` has a - * dedicated bare-string branch that resolves the name against the related - * object's schema — header from the field's `label`, plus a type-aware cell - * renderer — and `RelatedList.columnIdentityAccessor.test.tsx` has pinned that - * behaviour ("keeps bare-string entries hydrating as before") for cards. So the - * renderer and its behaviour pins already agreed on two arms; only this - * declaration said one. Measured on the base commit: - * - * ``` - * const view: DetailViewSchema = { - * type: 'detail-view', - * related: [{ title: 'Tasks', type: 'table', columns: ['status'] }], - * }; // TS2322 - * ``` - * - * The direction was settled by the maintainer principle in force on this card, - * quoted verbatim and untranslated because a paraphrase is a different ruling: - * - * > 我们的项目以 objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。 - * - * `@objectstack/spec` `packages/spec/src/ui/component.zod.ts` declares - * `RecordRelatedListProps.columns` as `z.array(z.string())` — "Fields to - * display in the related list" — so the string arm is the protocol's arm, and - * the narrow declaration was the face out of line. - * - * ## Why the assertions below are shaped the way they are - * - * The caricature of a widening is `any[]`: it satisfies every "the string form - * now compiles" assertion while deleting the whole authoring contract. So the - * positives are never load-bearing alone. The division of labour between the - * constructs below was MEASURED by mutating the member three ways and reading - * `tsc -p tsconfig.test.json` each time — not reasoned about: - * - * | member mutated to | what catches it, in this file | - * | :-------------------------- | :---------------------------- | - * | `TableColumn[]` (the bug) | `_columnsShape`, `_stringArmReachesBothFaces`, and the four positive string rows | - * | `any[]` (the caricature) | `_columnsShape` + all three `@ts-expect-error`s go TS2578 "unused" | - * | `string[]` (over-rotation) | `_columnsShape` + the object-arm positives | - * - * ⚠️ The refusals do NOT catch the revert: `[42]` and `[{ header }]` are - * refused by `TableColumn[]` too, so those three rows stay green under the very - * bug this card fixes. `_columnsShape` is what holds that line — mutual - * assignability, so it fails in both directions where a one-way `extends` - * would pass for two of the three mutations above. - * - * ⚠️ Each `@ts-expect-error` sits on the `columns:` MEMBER, not on the `const`. - * The error is reported at the offending member inside the object literal, so a - * directive on the declaration suppresses nothing and `tsc` reports it twice - * over: TS2578 for the unused directive AND the unsuppressed TS2322 below it. - * Measured here, on the first run of this file — not a style choice. - * - * ## Which program checks this file - * - * `packages/types`' `type-check` runs THREE programs; this file is in the third - * (`tsconfig.test.json` — `tsc --noEmit` builds `tsconfig.json`, which excludes - * `__tests__/` by directory). The subject is imported as a sibling SOURCE - * module (`../views`), so that program reads the declaration directly and no - * `dist` staleness sits between this file and what it pins. - * - * ## What this card deliberately did NOT change - * - * Two things, both held visible below rather than left to rot into assumptions: - * - * 1. **The renderer.** Nothing in `RelatedList` moved. This is a declaration - * change, and the last block measures the runtime accept set it is catching - * up with. - * 2. **The object arm.** The spec's `columns` is strings ONLY, so objectui is - * WIDER than the protocol here. The principle quoted above forbids being - * narrower; it does not, in its own text, settle a wider arm — and - * `DetailViewSchema` is objectui's own host-facing React view schema, not a - * spec-bound block (`packages/spec/src/ui/view.zod.ts` declares no - * `DetailView` at all). Retiring the object arm is a separate, non-additive - * change that needs its own answer; the block below pins it as PRESENT so - * that answer is taken deliberately rather than by drift. - */ - -import { describe, it, expect } from 'vitest'; -import type { DetailViewSchema } from '../views'; -import type { TableColumn } from '../data-display'; -import type { RecordRelatedListComponentProps } from '../record-components'; -import { DetailViewSchema as DetailViewZodMirror } from '../zod/views.zod'; - -/** Mutual assignability, the standard invariant `Eq` — not `extends`. */ -type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) - ? true - : false; - -/** One entry of the `related` array — the object this card is about. */ -type RelatedEntry = NonNullable[number]; - -/* ── (a) the member's type, pinned whole ──────────────────────────────────── */ - -describe('objectui#7997 — DetailViewSchema.related[].columns declares both arms', () => { - it('the member type is exactly `TableColumn | string` entries, optional', () => { - // Mutual assignability, so this fails in BOTH directions: reverted to - // `TableColumn[]` it fails, and widened to `any[]` or over-rotated to - // `string[]` it also fails. A one-way `extends` check would pass for two of - // those three. This is the ONLY assertion in the file that catches the - // revert — see the table in the header. - const _columnsShape: Eq | undefined> = - true; - expect(_columnsShape).toBe(true); - }); - - it('the member is still optional — omitting `columns` is still authoring', () => { - const noColumns: DetailViewSchema = { - type: 'detail-view', - related: [{ title: 'Tasks', type: 'table', api: 'task' }], - }; - expect(noColumns.related?.[0].columns).toBeUndefined(); - }); -}); - -/* ── (b) the arm this card exists for ─────────────────────────────────────── */ - -describe('objectui#7997 — the protocol-shaped string form type-checks', () => { - it("the card's own reproduction compiles", () => { - const view: DetailViewSchema = { - type: 'detail-view', - related: [{ title: 'Tasks', type: 'table', api: 'task', columns: ['status'] }], - }; - expect(view.related?.[0].columns).toEqual(['status']); - }); - - it('the multi-field form the issue was filed with compiles', () => { - const view: DetailViewSchema = { - type: 'detail-view', - related: [{ title: 'Contacts', type: 'table', api: 'contact', columns: ['name', 'email'] }], - }; - expect(view.related?.[0].columns).toEqual(['name', 'email']); - }); - - it('a `string[]` built elsewhere assigns in — not only a fresh literal', () => { - // Producers hand `columns` a computed list (a picked subset of an object's - // fields, say). A widening that only admitted literals would not help them. - const picked: string[] = ['status', 'amount']; - const view: DetailViewSchema = { - type: 'detail-view', - related: [{ title: 'Tasks', type: 'table', api: 'task', columns: picked }], - }; - expect(view.related?.[0].columns).toBe(picked); - }); - - it('the string and object arms mix in one list', () => { - // `normalizeColumn` maps per entry, so a mixed list is a real authoring - // shape rather than a curiosity: name the fields you want defaults for, - // spell out the one you are overriding. - const view: DetailViewSchema = { - type: 'detail-view', - related: [ - { - title: 'Tasks', - type: 'table', - api: 'task', - columns: ['status', { accessorKey: 'amount', header: 'Value (USD)' }], - }, - ], - }; - expect(view.related?.[0].columns).toHaveLength(2); - }); -}); - -/* ── (c) non-regression: the object arm is untouched ──────────────────────── */ - -describe('objectui#7997 — every shape that compiled before still compiles', () => { - it("the README's own related block still type-checks", () => { - // `packages/plugin-detail/README.md` teaches this exact shape. A widening - // that broke it would be a breaking change wearing an additive label. - const view: DetailViewSchema = { - type: 'detail-view', - related: [ - { - title: 'Contacts', - type: 'table', - api: '/api/accounts/12345/contacts', - columns: [ - { accessorKey: 'name', header: 'Name' }, - { accessorKey: 'email', header: 'Email' }, - ], - }, - ], - }; - expect(view.related?.[0].columns).toHaveLength(2); - }); - - it('a `TableColumn[]` built elsewhere still assigns in', () => { - const built: TableColumn[] = [{ accessorKey: 'name', header: 'Name' }]; - const view: DetailViewSchema = { - type: 'detail-view', - related: [{ title: 'Contacts', type: 'table', api: 'contact', columns: built }], - }; - expect(view.related?.[0].columns).toBe(built); - }); - - it('the optional `TableColumn` members still reach the entry', () => { - // The object arm is the WHOLE `TableColumn`, not a narrowed stand-in. - const view: DetailViewSchema = { - type: 'detail-view', - related: [ - { - title: 'Contacts', - type: 'table', - api: 'contact', - columns: [{ accessorKey: 'name', header: 'Name', width: 240, align: 'right' }], - }, - ], - }; - const col = view.related?.[0].columns?.[0] as TableColumn; - expect(col.align).toBe('right'); - }); -}); - -/* ── (d) the refusals — the `any[]` caricature guard ──────────────────────── */ - -describe('objectui#7997 — the widening is a union, not `any[]`', () => { - it('a number entry is refused', () => { - const view: DetailViewSchema = { - type: 'detail-view', - related: [ - { - title: 'Tasks', - type: 'table', - api: 'task', - // @ts-expect-error a column is a field NAME or a `TableColumn`, never a number (objectui#7997) - columns: [42], - }, - ], - }; - expect(view).toBeDefined(); - }); - - it('an object entry with no `accessorKey` is refused — `TableColumn` requires it', () => { - const view: DetailViewSchema = { - type: 'detail-view', - related: [ - { - title: 'Tasks', - type: 'table', - api: 'task', - // @ts-expect-error `TableColumn.accessorKey` is required; a header alone addresses no field (objectui#7997) - columns: [{ header: 'Status' }], - }, - ], - }; - expect(view).toBeDefined(); - }); - - it('a bare string in place of the ARRAY is refused', () => { - // The member widened by one element type, not into "anything at all". - const view: DetailViewSchema = { - type: 'detail-view', - related: [ - { - title: 'Tasks', - type: 'table', - api: 'task', - // @ts-expect-error `columns` is a list of columns, not a single field name (objectui#7997) - columns: 'status', - }, - ], - }; - expect(view).toBeDefined(); - }); -}); - -/* ── (e) the two faces, measured against each other ───────────────────────── */ - -describe('objectui#7997 — the view schema and the `record:related_list` block agree', () => { - it('one `string[]` assigns to BOTH faces — the protocol shape reaches each', () => { - // The card is a DISAGREEMENT between two declarations of the same slot, so - // fixing one and leaving the other out of line would not close it. This is - // that check, made with a single value rather than two look-alike literals: - // `specShaped` is what `@objectstack/spec`'s `z.array(z.string())` admits, - // and both objectui faces now take it. - const specShaped: string[] = ['name', 'email']; - - const viaViewSchema: RelatedEntry['columns'] = specShaped; - const viaSpecBlock: RecordRelatedListComponentProps['columns'] = specShaped; - - expect(viaViewSchema).toBe(specShaped); - expect(viaSpecBlock).toBe(specShaped); - }); - - it('the `record:related_list` face is STILL strings-only — it was already right', () => { - // Held visible on purpose. That block mirrors a spec `strictObject`, and - // spec says strings; it needed no change on this card, and a future widening - // of it would be a protocol question, not a repo question. This assertion - // is what turns such a change into a red test rather than a quiet drift. - const _blockIsStringsOnly: Eq< - RecordRelatedListComponentProps['columns'], - string[] | undefined - > = true; - expect(_blockIsStringsOnly).toBe(true); - }); - - it('the object arm is objectui-only — the asymmetry this card did NOT resolve', () => { - // `DetailViewSchema` admits `TableColumn`; the spec-mirroring block does - // not. Pinned as PRESENT so that retiring the object arm (a separate, - // non-additive decision) reddens here and gets taken deliberately. - const objectArm: RelatedEntry['columns'] = [{ accessorKey: 'name', header: 'Name' }]; - expect(objectArm).toHaveLength(1); - - const _specBlockRefusesObjects: Eq< - NonNullable[number], - string - > = true; - expect(_specBlockRefusesObjects).toBe(true); - }); -}); - -/* ── (f) the other authoring face — the JSON mirror, held visible ─────────── */ - -describe('objectui#7997 — the zod mirror already admitted the string form', () => { - it('the JSON face accepts a bare-string column list', () => { - // THE HANDOFF READING. `views.zod.ts` spells this member - // `z.array(z.any())`, so the JSON authoring path never refused strings — - // the TypeScript path was the only one that did, which is exactly why the - // card reported the defect as reachable "only through the typed authoring - // path". No mirror change was needed, and this block records the reading so - // it cannot rot into an assumption that both faces moved together. - const r = DetailViewZodMirror.safeParse({ - type: 'detail-view', - related: [{ title: 'Tasks', type: 'table', api: 'task', columns: ['status'] }], - }); - expect(r.success).toBe(true); - }); - - it('the JSON face still accepts the object form', () => { - const r = DetailViewZodMirror.safeParse({ - type: 'detail-view', - related: [ - { - title: 'Contacts', - type: 'table', - api: 'contact', - columns: [{ accessorKey: 'name', header: 'Name' }], - }, - ], - }); - expect(r.success).toBe(true); - }); - - it('CONTROL — the mirror is live: it refuses a non-array `columns`', () => { - // Without this, both readings above would be equally green against a mirror - // that validates nothing at all. `z.array(z.any())` still refuses a scalar, - // so this is the assertion that says the member is being checked. - const r = DetailViewZodMirror.safeParse({ - type: 'detail-view', - related: [{ title: 'Tasks', type: 'table', api: 'task', columns: 'status' }], - }); - expect(r.success).toBe(false); - }); - - it('CONTROL — and it refuses a related entry missing its required `title`', () => { - // The second live control, one level up: it says the `related` entry shape - // itself is parsed, not waved through as `z.any()`. - const r = DetailViewZodMirror.safeParse({ - type: 'detail-view', - related: [{ type: 'table', api: 'task', columns: ['status'] }], - }); - expect(r.success).toBe(false); - }); - - it('the mirror is WIDER than the declaration — stated, not papered over', () => { - // `z.array(z.any())` admits a number column, which the TypeScript face now - // refuses (block (d)). Narrowing the mirror to match would be a change to - // what already-shipped JSON is allowed to say — non-additive, and not what - // this card was dispatched for. Reported rather than done; this row is the - // record of the gap. - const r = DetailViewZodMirror.safeParse({ - type: 'detail-view', - related: [{ title: 'Tasks', type: 'table', api: 'task', columns: [42] }], - }); - expect(r.success).toBe(true); - }); -}); diff --git a/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts b/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts new file mode 100644 index 0000000000..d55cd98467 --- /dev/null +++ b/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts @@ -0,0 +1,215 @@ +/** + * 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#7997 — `DetailViewSchema.related` is RETIRED on BOTH faces, as a + * NAMED REFUSAL rather than a deletion. + * + * ## The ruling, and what carried it + * + * Maintainer, 2026-09-10, quoted verbatim and untranslated because a paraphrase + * is a different ruling: + * + * > 关掉详情页那个入口(推荐) + * + * ("close that entry point on the detail page (recommended)".) The axis that + * carried it was measured ZERO PULL: no application code authored the member, + * both internal producers of a `detail-view` node (`RecordDetailDrawer`, + * `renderers/record-details.tsx`) synthesize it without `related`, and the only + * in-tree authorings carrying real columns were two documents, both rewritten + * by the same change. + * + * ⚠️ An argument that did NOT carry it, recorded so it is not repeated: the + * protocol's `related: 'tabs'` alias. That alias lives in + * `RecordPageSchema.slots` (`page.zod.ts`), a SLOT-NAME map whose vocabulary is + * header | actions | alerts | highlights | details | tabs | discussion. It says + * "if you name a SLOT `related`, we mean the `tabs` slot" — it matched the + * WORD, and says nothing about an array of related-list configs, which the + * protocol never had. + * + * ## Why a refusal and not a deletion — the mechanism, not a preference + * + * `BaseSchema` closes with an any-valued index signature and `BaseSchemaCore` + * ends `.passthrough()`. A DROPPED member key is therefore KEPT, not refused: + * deleting the declaration would have left the silent accept exactly as it was + * and thrown the diagnostic away with it (the mechanism objectui#7963 + * measured). Declared-and-unwritable is what makes the refusal loud — + * `?: never` on the TypeScript face, `retirementTombstone()` on the mirror. + * + * ⭐ The block that proves this is not a stylistic claim is (c) below: it + * authors an UNDECLARED sibling key through the very same parse and shows it + * surviving. Without that row, "a bare delete would not have refused it" is an + * assertion; with it, it is a reading taken on this schema. + * + * ## Which program checks this file + * + * `packages/types`' `type-check` runs THREE programs; this file is in the third + * (`tsconfig.test.json` — `tsc --noEmit` builds `tsconfig.json`, which excludes + * `__tests__/` by directory). The subject is imported as a sibling SOURCE + * module, so that program reads the declaration directly with no `dist` + * staleness in between. + * + * ## What did NOT retire + * + * The capability. `record:related_list` is the protocol-governed entry + * (`@objectstack/spec` `RecordRelatedListProps`, `columns: z.array(z.string())`), + * its objectui mirror `RecordRelatedListComponentProps` is unchanged, and both + * entries always rendered through the same `RelatedList` component. Block (d) + * pins that survivor, because a retirement pin that only proves absence is + * equally green against a tree where the whole feature was deleted. + */ + +import { describe, it, expect } from 'vitest'; +import type { DetailViewSchema } from '../views'; +import type { RecordRelatedListComponentProps } from '../record-components'; +import { DetailViewSchema as DetailViewZodMirror } from '../zod/views.zod'; + +/** Mutual assignability, the standard invariant `Eq` — not `extends`. */ +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false; + +/* ── (a) the TypeScript face ──────────────────────────────────────────────── */ + +describe('objectui#7997 — the TypeScript face refuses `related`', () => { + it('the member is a `never` tombstone, not a deleted key and not a value type', () => { + // Mutual assignability, so this fails in BOTH directions: restore any value + // type (the retired array, or the `Array of (TableColumn OR string)` this + // branch briefly carried as Route A) and it fails; DELETE the member + // outright and it also fails, because `BaseSchema`'s index signature would + // then type the key `any` rather than `undefined`. That second direction is + // the one worth having — it is the caricature this whole card warns about. + const _tombstoned: Eq = true; + expect(_tombstoned).toBe(true); + }); + + it('authoring a related array no longer compiles', () => { + const view: DetailViewSchema = { + type: 'detail-view', + // @ts-expect-error `related` is retired — author a `record:related_list` block (objectui#7997) + related: [{ title: 'Contacts', type: 'table', api: 'contact', columns: ['name'] }], + }; + expect(view).toBeDefined(); + }); + + it('even an EMPTY related array no longer compiles', () => { + // The shape a producer reaches for when it has nothing to show. Retiring a + // key that still admits its own empty value would be a half-retirement. + const view: DetailViewSchema = { + type: 'detail-view', + // @ts-expect-error `related` is retired in every form, empty included (objectui#7997) + related: [], + }; + expect(view).toBeDefined(); + }); + + it('the REST of the node still type-checks — this is a member retirement', () => { + // The control for the three rows above: they are about one key, not about a + // declaration that stopped accepting anything. + const view: DetailViewSchema = { + type: 'detail-view', + title: 'Account', + objectName: 'account', + autoTabs: true, + tabs: [{ key: 'notes', label: 'Notes', content: { type: 'text' } }], + }; + expect(view.tabs).toHaveLength(1); + }); +}); + +/* ── (b) the zod mirror ───────────────────────────────────────────────────── */ + +describe('objectui#7997 — the JSON face refuses `related` by name', () => { + const authored = { + type: 'detail-view', + related: [{ title: 'Contacts', type: 'table', api: 'contact', columns: ['name'] }], + }; + + it('a document authoring `related` is refused', () => { + // It parsed GREEN before this card. That is the accept-set change the + // changeset declares. + expect(DetailViewZodMirror.safeParse(authored).success).toBe(false); + }); + + it('the issue is addressed to `related` and names the surviving entry', () => { + // A refusal an author cannot act on is half a refusal. `retirementTombstone` + // feeds ONE guidance string into both the parse-time message and + // `.describe()`, so what the author reads and what generated docs publish + // cannot drift apart. + const r = DetailViewZodMirror.safeParse(authored); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === 'related'); + expect(issue, 'no issue was addressed to `related`').toBeDefined(); + expect(issue!.message).toContain('record:related_list'); + expect(issue!.message).toContain('objectui#7997'); + }); + + it('CONTROL — the same document parses green with `related` removed', () => { + // Says the refusal above is about this member and not about the fixture. + const { related: _dropped, ...withoutRelated } = authored; + expect(DetailViewZodMirror.safeParse(withoutRelated).success).toBe(true); + }); + + it('CONTROL — the mirror still refuses a genuinely malformed node', () => { + // And says the green above is a reading, not a mirror that accepts + // anything: `type` is a literal. + expect(DetailViewZodMirror.safeParse({ type: 'not-a-detail-view' }).success).toBe(false); + }); +}); + +/* ── (c) why it had to be a refusal — the passthrough, MEASURED ───────────── */ + +describe('objectui#7997 — a bare delete would have KEPT the key, not refused it', () => { + it('an UNDECLARED sibling key survives the same parse untouched', () => { + // THE LOAD-BEARING ROW of this file. The claim "deleting the member would + // not have refused it" is otherwise unfalsifiable prose. Here it is a + // reading taken on this very schema: `relatedPanels` is declared nowhere, + // and it comes back out of a successful parse with its value intact. + // That is exactly what `related` would have done had it simply been + // deleted — silently accepted, silently ignored, no diagnostic anywhere. + const r = DetailViewZodMirror.safeParse({ + type: 'detail-view', + relatedPanels: [{ title: 'Contacts' }], + }); + expect(r.success).toBe(true); + if (!r.success) return; + expect((r.data as Record).relatedPanels).toEqual([{ title: 'Contacts' }]); + }); + + it('and the retired key does NOT survive it — the pair is the whole argument', () => { + // The same parse, one key swapped. Undeclared: kept. Tombstoned: refused. + expect( + DetailViewZodMirror.safeParse({ type: 'detail-view', related: [{ title: 'Contacts' }] }) + .success, + ).toBe(false); + }); +}); + +/* ── (d) the survivor — retiring a DOOR, not the capability ───────────────── */ + +describe('objectui#7997 — `record:related_list` is untouched and is now the only entry', () => { + it('its `columns` is still the protocol shape: an array of field-name strings', () => { + // `@objectstack/spec` declares `RecordRelatedListProps.columns` as + // `z.array(z.string())`. This face already mirrored it before the card and + // is deliberately unchanged by it — pinned so "we retired related lists" can + // never become a true description of this change. + const _specShape: Eq = true; + expect(_specShape).toBe(true); + }); + + it('the surviving entry still accepts the columns an author would have written', () => { + const block: RecordRelatedListComponentProps = { + objectName: 'contact', + relationshipField: 'account', + columns: ['name', 'email'], + title: 'Contacts', + }; + expect(block.columns).toEqual(['name', 'email']); + }); +}); diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 0251797370..e9cbc34114 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -18,7 +18,6 @@ import type { BaseSchema, SchemaNode } from './base.js'; import type { ActionSchema } from './crud.js'; -import type { TableColumn } from './data-display.js'; import type { SelectOptionMetadata } from './field-types.js'; import type { ListView as SpecListView } from '@objectstack/spec/ui'; @@ -772,66 +771,43 @@ export interface DetailViewSchema extends BaseSchema { */ onNavigate?: (url: string, options?: { replace?: boolean; newTab?: boolean }) => void; /** - * Related records section + * RETIRED (objectui#7997, ADR-0049 enforce-or-remove; maintainer ruling + * 2026-09-10, quoted verbatim and untranslated because a paraphrase is a + * different ruling: 「关掉详情页那个入口(推荐)」 — "close that entry point on + * the detail page (recommended)"). + * + * This was objectui's own second entry to a capability the protocol already + * governs. `@objectstack/spec` declares NO `DetailView` schema at all — every + * `DetailView` occurrence in `packages/spec/src` is prose about this repo's + * own `RecordDetailView.tsx` — so this array mirrored nothing and drifted + * freely: it declared `columns` as `TableColumn[]` while the renderer it fed + * also accepted bare field names, `{ field, label }` and the legacy + * `{ name, label }` spellings. + * + * ⛔ Do NOT read the retirement as "related lists are gone". The capability + * moves to its ONE protocol-governed entry, `record:related_list` + * (`RecordRelatedListComponentProps`, mirroring `@objectstack/spec` + * `RecordRelatedListProps`), whose `columns` is an array of FIELD-NAME + * strings. Both entries always rendered through the same `RelatedList` + * component, so nothing about the rendered result is lost — only the second + * door. + * + * What was measured, and what carried the ruling: ZERO pull. No application + * code authored this member; both internal producers of a `detail-view` node + * (`RecordDetailDrawer`, `renderers/record-details.tsx`) pass no `related`; + * the only in-tree authorings carrying real columns were two documents, both + * rewritten by the same change. + * + * `?: never` is the twin of `zod/views.zod.ts`'s `retirementTombstone` arm, + * and the pair is deliberate: a BARE DELETE would not refuse this key, it + * would KEEP it. `BaseSchema` closes with an any-valued index signature and + * `BaseSchemaCore` ends `.passthrough()`, so an undeclared member is passed + * through silently — the mechanism objectui#7963 measured. Declared-and- + * unwritable is what makes the refusal loud. + * + * @deprecated Not part of this contract. Author a `record:related_list` block. */ - related?: Array<{ - /** - * Relation title - */ - title: string; - /** - * Relation type - */ - type: 'list' | 'grid' | 'table'; - /** - * API endpoint for related data - */ - api?: string; - /** - * Static data - */ - data?: any[]; - /** - * Columns for the table view — either a bare FIELD NAME or a fully - * spelled adapter column. - * - * The bare-string arm is the one `@objectstack/spec` declares: its - * `RecordRelatedListProps.columns` is `z.array(z.string())` ("Fields to - * display in the related list"), and the objectui mirror of that block, - * `RecordRelatedListComponentProps.columns`, is `string[]` to match. - * `RelatedList.normalizeColumn` has always resolved a bare string against - * the related object's schema — deriving the header from the field's label - * and attaching a type-aware cell renderer — so `columns: ['status']` - * renders BETTER than the hand-spelled equivalent, whose header stops - * following a label rename. - * - * Only this declaration disagreed, and only on the typed authoring path: - * `DetailView` reaches the renderer through `columns={related.columns as - * any}`, so the cast — not the type — was carrying the string arm. - * Widened under the maintainer principle that the objectstack protocol is - * the reference and the documentation follows the real implementation - * (objectui#7997). - * - * The object arm is kept: this is objectui's own host-facing view schema, - * not a spec surface, and `TableColumn` entries are what the renderer's - * non-string branch consumes. - */ - columns?: Array; - /** - * Fields for list view - */ - fields?: string[]; - /** - * Optional foreign-key field on the child records that points back to the - * parent record. When provided, the renderer hides this column from the - * default related-list table because the parent is implicit context. - */ - referenceField?: string; - /** - * Optional Lucide-style icon name to render next to the section title. - */ - icon?: string; - }>; + related?: never; /** * Optional audit history feed for this record. When provided, a "History" tab * is rendered alongside Details/Related. The renderer treats the data as diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index cce7acb267..5c60dbf8b8 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -18,7 +18,7 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; -import { handlerKeyRefusal } from './tombstone.zod.js'; +import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; /** @@ -160,16 +160,57 @@ export const DetailViewSchema = BaseSchema.extend({ loading: z.boolean().optional().describe('Whether to show loading state'), header: SchemaNodeSchema.optional().describe('Custom header content'), footer: SchemaNodeSchema.optional().describe('Custom footer content'), - related: z.array(z.object({ - title: z.string().describe('Relation title'), - type: z.enum(['list', 'grid', 'table']).describe('Relation type'), - api: z.string().optional().describe('API endpoint for related data'), - data: z.array(z.any()).optional().describe('Static data'), - columns: z.array(z.any()).optional().describe('Columns for table view'), - fields: z.array(z.string()).optional().describe('Fields for list view'), - referenceField: z.string().optional().describe('Foreign-key field on the child object pointing back to the parent record. The renderer hides this column from the related-list table by default since the parent is implicit context.'), - icon: z.string().optional().describe('Optional Lucide-style icon name to render next to the section title'), - })).optional().describe('Related records section'), + /** + * The DETAIL-VIEW RELATED-LIST REFUSAL (objectui#7997) — `related` retires + * from `DetailViewSchema` on BOTH faces under ADR-0049 enforce-or-remove + * (maintainer ruling 2026-09-10; the direction is not re-opened by a later + * card). + * + * ## Why a REFUSAL and not a deletion + * + * `BaseSchemaCore` ends `.passthrough()` and the TypeScript `BaseSchema` + * closes with an any-valued index signature, so a dropped MEMBER key is + * KEPT, not refused — deleting this declaration would have left the silent + * accept exactly as it was and thrown the diagnostic away with it. + * `retirementTombstone` keeps the key DECLARED and unwritable, which is what + * makes the refusal loud. Same mechanism and same reasoning as the + * alert-dialog footer refusals (`./overlay.zod.ts`, objectui#7963) and the + * `PageNodeSchema` arms (`./layout.zod.ts`, objectui#7926 / objectui#8871). + * + * ## What was measured — the frame is BASE `efead6c60`, stated out loud + * + * ZERO PULL, which is the axis that carried the ruling. No application code + * authored this member. Both internal producers of a `detail-view` node — + * `RecordDetailDrawer` and `renderers/record-details.tsx` in + * `@object-ui/plugin-detail` — synthesize the node WITHOUT `related`. The + * only in-tree authorings carrying real columns were `packages/plugin-detail`'s + * README and `content/docs/api/schema-reference.md`, both rewritten by the + * same change to teach `record:related_list`. + * + * ⛔ The bare word `related` is worthless as a probe here and fails towards + * "live": `relatedListColumns`, `autoDiscoverRelated`, `RelatedList`, + * `record:related_list` and `RelatedRecordActionsContext` are all live and + * all untouched. The reading is a MEMBER-ACCESS one, and it is the two + * producers above that make the zero a measurement rather than a miss. + * + * ## What did NOT retire + * + * The capability. `record:related_list` is the protocol-governed entry + * (`@objectstack/spec` `RecordRelatedListProps`), it always rendered through + * the SAME `RelatedList` component this member fed, and it is unchanged here. + * One capability, one entry. + */ + related: retirementTombstone( + '`related` is RETIRED on `detail-view` (objectui#7997, ADR-0049 ' + + 'enforce-or-remove). It was a second, unmirrored entry to a capability the ' + + 'protocol already governs: @objectstack/spec declares no DetailView schema, ' + + 'so this array mirrored nothing and drifted — it declared `columns` as ' + + 'TableColumn objects while the renderer it fed also took bare field names. ' + + 'Author a `record:related_list` block instead: it is the protocol-governed ' + + 'entry (RecordRelatedListProps), its `columns` is an array of field-name ' + + 'strings, and it renders through the same component, so nothing about the ' + + 'result is lost — only the second door.', + ), }); /** From 9835365e1e21ae888357e9be4952efbf8d4a907d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:52:51 +0000 Subject: [PATCH 4/5] fix(docs): unblockquote the two retirement headings, and correct two claims the review caught (objectui#7997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review VERDICT: REWORK on PR #8984. Route C, the blast radius, the ablation, the grading and the scope split were all reproduced and upheld; this commit is the docs/prose surface it asked for. R1, the decisive one — CI was RED at the previous head on this PR's own diff and my suite selection could not see it. `scripts/__tests__/check-doc-links.test.ts` failed two rows because the two call-outs I wrote were `> #### …` headings INSIDE blockquotes. fumadocs' TOC counts a blockquoted heading; the gate's ATX scan (`/^#{1,6}\s+\S/`) cannot, so the two files read "39 headings, 38 ATX" and "22 headings, 21 ATX". They were the only `> #` headings in the whole md/mdx surface. Both now use this tree's idiom for exactly this call-out, `> **Retired: KEY** (objectui#NNNN, ADR-0049 …)`, which has 39 instances under `content/docs`. ⛔ The gate was not touched. Local: 121/121, rc 0 — the same count the reviewer measured at the merge base. R2, five precision fixes: 1. The changeset said `{ name, label }` was "a shape the retired declaration itself never admitted". True of the TypeScript face only. The retired ZOD face spelled the member `z.array(z.any())`, so the JSON document the docs page taught parsed green and rendered. The page was wrong for a TYPED author and right for a JSON author — a sharper defect than one bad example, and the changeset becomes CHANGELOG, so it now says so. 2. "the only entry" ignored `related-list`, still registered at `plugin-detail/src/index.tsx` against the same component with untyped `columns`. Every site now says "the only DECLARED / protocol-governed entry" — changeset, zod tombstone, TS tombstone, the types pin's describe, and the docs page. 3. `detail.related` is newly `[needs-review]` in the i18n report. The pack key is genuinely live through `containers.tsx`, so it stays; what was left dangling is `useDetailTranslation.ts`'s fallback row. Kept DELIBERATELY, with the reason in place: that map mirrors the `en` PACK, not this package's call sites, and dropping the row would leave the built-in Related page tab untranslated in exactly the no-I18nProvider case the map exists for. 4. `DetailView.tsx` no longer declares `isMobile` (its only consumer was the removed `defaultCollapsed`), and the now-unused `useIsMobile` import goes with it. 5. `deriveRelatedLists.ts`'s comment no longer calls `DetailView.related` "legacy" as though it were live. Both migration blocks also gain the sentence that makes them runnable rather than merely compilable: `record:related_list` reads the parent record from the record page's `RecordContext`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../7997-detail-view-related-retired.md | 27 +++++++++++++------ content/docs/api/schema-reference.md | 9 +++++-- .../app-shell/src/utils/deriveRelatedLists.ts | 5 +++- packages/plugin-detail/README.md | 9 ++++++- packages/plugin-detail/src/DetailView.tsx | 2 -- .../plugin-detail/src/useDetailTranslation.ts | 10 +++++++ .../detail-view-related-retired-7997.test.ts | 2 +- packages/types/src/views.ts | 2 +- packages/types/src/zod/views.zod.ts | 6 ++++- 9 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.changeset/7997-detail-view-related-retired.md b/.changeset/7997-detail-view-related-retired.md index f632e7c06d..c8437716cf 100644 --- a/.changeset/7997-detail-view-related-retired.md +++ b/.changeset/7997-detail-view-related-retired.md @@ -12,10 +12,13 @@ node authoring `related` used to parse **green** and render a Related section; i now reds at that key on both faces, and the renderer draws nothing from it. **What retired is a DOOR, not the capability.** `record:related_list` is -unchanged and is now the only entry. It is the protocol-governed one -(`@objectstack/spec` `RecordRelatedListProps`), and it has always rendered -through the same `RelatedList` component the retired array fed — so nothing -about the rendered result is lost. +unchanged and is now the only **declared, protocol-governed** entry +(`@objectstack/spec` `RecordRelatedListProps`); it has always rendered through +the same `RelatedList` component the retired array fed, so nothing about the +rendered result is lost. ⚠️ Not the only entry full stop — `plugin-detail` +still registers a bare `related-list` node against the same component with +untyped `columns`, and that registration is out of this card's scope and +untouched. | before, on a `detail-view` node | after | | --- | --- | @@ -57,7 +60,15 @@ untouched. **Documentation.** `packages/plugin-detail/README.md` and `content/docs/api/schema-reference.md` stop teaching the retired array and gain a -migration block each. ⚠️ The docs page had been teaching `{ name, label }` -columns, a shape the retired declaration itself never admitted — so the -documentation was already wrong about this member before it retired, in a -direction no route would have fixed on its own. +migration block each. + +⚠️ The docs page had been teaching `{ name, label }` columns, and the two faces +disagreed about that shape: the retired **TypeScript** declaration never +admitted it (`TableColumn` requires `header` **and** `accessorKey`), while the +retired **zod mirror** did — it spelled the member `z.array(z.any())` — so the +JSON document that page taught parsed green and rendered. The page was wrong for +a **typed** author and right for a **JSON** author, which is a sharper defect +than a single wrong example: the two authoring faces of one member disagreed +about what a column is. Retiring the member closes that split at the source, and +the page now teaches `record:related_list`, whose `columns` is +`z.array(z.string())` on both faces. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 5dfdede106..f80dd425fa 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -1147,7 +1147,8 @@ An enhanced detail view for a single record with sections, tabs and navigation. | `showDelete` / `deleteConfirmation` | `boolean` / `string` | Delete with confirmation message. | | `header` / `footer` | `SchemaNode` | Custom header/footer content. | -> #### ⛔ `related` is retired — author `record:related_list` instead +> **Retired: `related`** (objectui#7997, ADR-0049 enforce-or-remove). +> Author a `record:related_list` block instead. > > Until objectui#7997 this block carried its own `related` array, and this page > taught it with `{ "name": ..., "label": ... }` columns. That array is retired @@ -1156,7 +1157,7 @@ An enhanced detail view for a single record with sections, tabs and navigation. > drifted from the renderer it fed. Authoring it is now **refused by name** on > both the TypeScript and the JSON face — it is not silently ignored. > -> Related lists have ONE entry now, and it renders through the same component: +> Related lists have one declared entry now, and it renders through the same component: > > ```json > { @@ -1174,6 +1175,10 @@ An enhanced detail view for a single record with sections, tabs and navigation. > field label rename reaches the list for free. `relationshipField` names the > field on the RELATED object that points back at this record, and replaces the > retired form's `api` endpoint. +> +> ⚠️ The block reads the parent record from the record page's `RecordContext`, +> so author it on a record page. Placed anywhere it cannot resolve a parent id +> it scopes to nothing and renders an empty list. **Related:** [DetailSchema](#detailschema), [ObjectViewSchema](#objectviewschema) diff --git a/packages/app-shell/src/utils/deriveRelatedLists.ts b/packages/app-shell/src/utils/deriveRelatedLists.ts index c669a444fd..f8f0e88ad5 100644 --- a/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -10,7 +10,10 @@ * This helper scans every object for fields whose `reference`/`reference_to` * points back at the parent object and produces one related-list descriptor per * eligible FK. The detail page (`RecordDetailView`) feeds these into the - * `record:related_list` renderers (and the legacy `DetailView.related`). + * `record:related_list` renderers. (It also fed `DetailView.related`, which is + * RETIRED as of objectui#7997 — that entry is a `?: never` tombstone on both + * faces now, and this helper's output reaches the page only as + * `record:related_list` nodes.) * * Rules (kept in lockstep with the relationship-level `relatedList` spec flag): * - Owned children (`master_detail`) and `lookup` children are SHOWN by diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index 0a1df2f5df..c0f4bc64c9 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -255,7 +255,8 @@ Tab navigation for organizing content into different views. Displays related records in list, grid, or table format. -> #### ⛔ `DetailViewSchema.related` is retired — author `record:related_list` +> **Retired: `DetailViewSchema.related`** (objectui#7997, ADR-0049 +> enforce-or-remove). Author a `record:related_list` block instead. > > Until objectui#7997 a `detail-view` node could carry its own `related` array, > and this README taught it. That array is retired under ADR-0049 @@ -292,6 +293,12 @@ Displays related records in list, grid, or table format. > retired array taught froze both. `relationshipField` names the field on the > related object pointing back at this record, and replaces the retired form's > `api` endpoint. +> +> ⚠️ The block reads the parent record from the record page's `RecordContext`, +> so author it on a record page. Placed anywhere it cannot resolve a parent id +> it scopes to nothing and renders an empty list — which is also what the +> retired `related` form did whenever it declared `api` without a +> `referenceField`. Related lists are **paged by default**: the `record:related_list` renderer diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index afc442e881..bd87d7fe14 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -20,7 +20,6 @@ import { TabsList, TabsTrigger, TabsContent, - useIsMobile, } from '@object-ui/components'; import { ArrowLeft, @@ -295,7 +294,6 @@ export const DetailView: React.FC = ({ // Tenant default currency (ADR-0053) for summary metrics whose field omits one. const { currency: tenantCurrency } = useLocalization(); const { fieldOptionLabel } = useSafeFieldLabel(); - const isMobile = useIsMobile(); // Field-level permission gate. Filter section.fields and top-level // fields based on the current user's read permissions BEFORE any diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index 25c744faf2..ecb66d97d4 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -87,6 +87,16 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.recordNotFoundDescription': 'The record you are looking for does not exist or may have been deleted.', 'detail.goBack': 'Go back', 'detail.details': 'Details', + // KEPT DELIBERATELY after objectui#7997 retired `DetailViewSchema.related`. + // This map is a fallback for the `en` PACK, not an index of this package's + // `t()` call sites, and the key is still live: `@object-ui/components` + // `renderers/layout/containers.tsx` maps the built-in page tab + // `Related` to it. Dropping the row would leave that tab untranslated + // whenever no I18nProvider is mounted — which is the only situation this + // map exists for. `check-i18n-dead-keys` newly lists it as + // `[needs-review]` because this package's own `t('detail.related')` call + // site went with the retired render branch; that report row is expected + // and is not a dead key. 'detail.related': 'Related', 'detail.relatedRecords': '{{count}} records', 'detail.relatedRecordOne': '{{count}} record', diff --git a/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts b/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts index d55cd98467..0c8e120c3e 100644 --- a/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts +++ b/packages/types/src/__tests__/detail-view-related-retired-7997.test.ts @@ -193,7 +193,7 @@ describe('objectui#7997 — a bare delete would have KEPT the key, not refused i /* ── (d) the survivor — retiring a DOOR, not the capability ───────────────── */ -describe('objectui#7997 — `record:related_list` is untouched and is now the only entry', () => { +describe('objectui#7997 — `record:related_list` is untouched and is the declared entry', () => { it('its `columns` is still the protocol shape: an array of field-name strings', () => { // `@objectstack/spec` declares `RecordRelatedListProps.columns` as // `z.array(z.string())`. This face already mirrored it before the card and diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index e9cbc34114..544cb074e9 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -785,7 +785,7 @@ export interface DetailViewSchema extends BaseSchema { * `{ name, label }` spellings. * * ⛔ Do NOT read the retirement as "related lists are gone". The capability - * moves to its ONE protocol-governed entry, `record:related_list` + * moves to its one DECLARED, protocol-governed entry — `record:related_list` * (`RecordRelatedListComponentProps`, mirroring `@objectstack/spec` * `RecordRelatedListProps`), whose `columns` is an array of FIELD-NAME * strings. Both entries always rendered through the same `RelatedList` diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 5c60dbf8b8..fadeaa9f71 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -198,7 +198,11 @@ export const DetailViewSchema = BaseSchema.extend({ * The capability. `record:related_list` is the protocol-governed entry * (`@objectstack/spec` `RecordRelatedListProps`), it always rendered through * the SAME `RelatedList` component this member fed, and it is unchanged here. - * One capability, one entry. + * + * ⚠️ It is the only DECLARED / protocol-governed entry, ⛔ not the only entry + * full stop: `plugin-detail/src/index.tsx` still registers a bare + * `related-list` node against the same component, with untyped `columns`. + * That registration is out of this card's scope and is untouched. */ related: retirementTombstone( '`related` is RETIRED on `detail-view` (objectui#7997, ADR-0049 ' From 8348568f9a60b1d0b2be7b2edf949c10237c5420 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 18:01:56 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(plugin-detail):=20drop=20the=20dead=20`?= =?UTF-8?q?detail.related`=20fallback=20row=20=E2=80=94=20the=20reason=20I?= =?UTF-8?q?=20wrote=20for=20keeping=20it=20was=20false=20(objectui#7997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incremental contract review returned REWORK on one item, and it overturned me correctly. Keeping the row was a defensible CALL; the justification I committed next to it was not TRUE, and this whole family of cards is about source that states something false about itself. What I had written: that `@object-ui/components` `containers.tsx` resolves the built-in Related page tab through this map, so dropping the row would leave it untranslated with no I18nProvider mounted. Re-measured on my own clone, all three legs, and every one refutes it: - `DETAIL_DEFAULT_TRANSLATIONS` has exactly ONE runtime consumer, the `createSafeTranslation(...)` call in this same file. Every other tree-wide occurrence is a test, a comment, or the barrel re-export. - `@object-ui/components` declares NO dependency on `@object-ui/plugin-detail` — not in `dependencies`, `peerDependencies` or `devDependencies` — so it could not reach this map even if it wanted to. - `containers.tsx:491` resolves that label with `useSafeTranslate()` from `@object-ui/i18n`, whose contract (`useSafeTranslation.ts`) is: try each key, and with no provider return the POSITIONAL English fallback. The token `Related` is itself that fallback, stated in the call site's own comment. And the decisive one: after this card removed the render branch, NOTHING in `plugin-detail` resolves `detail.related` any more — the only textual occurrences left were the row and my own comment about it. So the row is genuinely unreachable from its only consumer, and it goes. The PACK key stays live through `containers.tsx`, which is a different surface; the i18n report confirms the split — `detail.related` still lists `components/renderers/layout/containers.tsx` and no longer lists `useDetailTranslation.ts`, while its neighbour `detail.relatedRecordOne` still does. Deletion was checked against the contract that actually governs this map before committing: `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` is 15/15 green with the row gone — its byte-identity case judges rows the map HAS, so removing one is permitted, and its sentinel rows are the `detail.showEmptyRelated*` trio, not this key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- packages/plugin-detail/src/useDetailTranslation.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index ecb66d97d4..06a01b6db4 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -87,17 +87,6 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.recordNotFoundDescription': 'The record you are looking for does not exist or may have been deleted.', 'detail.goBack': 'Go back', 'detail.details': 'Details', - // KEPT DELIBERATELY after objectui#7997 retired `DetailViewSchema.related`. - // This map is a fallback for the `en` PACK, not an index of this package's - // `t()` call sites, and the key is still live: `@object-ui/components` - // `renderers/layout/containers.tsx` maps the built-in page tab - // `Related` to it. Dropping the row would leave that tab untranslated - // whenever no I18nProvider is mounted — which is the only situation this - // map exists for. `check-i18n-dead-keys` newly lists it as - // `[needs-review]` because this package's own `t('detail.related')` call - // site went with the retired render branch; that report row is expected - // and is not a dead key. - 'detail.related': 'Related', 'detail.relatedRecords': '{{count}} records', 'detail.relatedRecordOne': '{{count}} record', 'detail.noRelatedRecords': 'No related records found',