diff --git a/.changeset/7997-detail-view-related-retired.md b/.changeset/7997-detail-view-related-retired.md new file mode 100644 index 0000000000..c8437716cf --- /dev/null +++ b/.changeset/7997-detail-view-related-retired.md @@ -0,0 +1,74 @@ +--- +'@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 **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 | +| --- | --- | +| `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, 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 327e7e2714..f80dd425fa 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,46 @@ 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. | +> **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 +> 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 declared 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. +> +> ⚠️ 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 ac9fca29eb..c0f4bc64c9 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 = **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 +> 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. +> +> ⚠️ 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 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..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, @@ -37,7 +36,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 +54,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). */ @@ -298,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 @@ -845,18 +840,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 +1591,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 +1601,6 @@ export const DetailView: React.FC = ({ // in Radix's uncontrolled state. const tabValues = [ 'details', - ...(hasRelated ? ['related'] : []), ...(hasActivity ? ['activity'] : []), ...(hasDiscussion ? ['discussion'] : []), ...(hasHistory ? ['history'] : []), @@ -1673,7 +1670,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 +1684,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 +1817,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/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/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index 25c744faf2..06a01b6db4 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -87,7 +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', - 'detail.related': 'Related', 'detail.relatedRecords': '{{count}} records', 'detail.relatedRecordOne': '{{count}} record', 'detail.noRelatedRecords': 'No related records found', 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..0c8e120c3e --- /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 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 + // 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 f674e671a8..544cb074e9 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,44 +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 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` + * 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 table view - */ - columns?: TableColumn[]; - /** - * 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..fadeaa9f71 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,61 @@ 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. + * + * ⚠️ 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 ' + + '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.', + ), }); /**