diff --git a/.changeset/record-details-sections-description-3807.md b/.changeset/record-details-sections-description-3807.md new file mode 100644 index 000000000..8fd00b1ef --- /dev/null +++ b/.changeset/record-details-sections-description-3807.md @@ -0,0 +1,32 @@ +--- +"@object-ui/plugin-detail": patch +--- + +`record:details` 的 `sections` 输入说明改为从 spec 形状派生的对象形,不再教已被退役的「Section IDs」 + +`inputs` 不是文档,而是发布出去的编写契约:`gen-manifest.ts` 把它序列化进 +`sdui.manifest.json`(保存门 + parser 白名单)和 `sdui-intrinsics.d.ts`。而 +`record:details.sections` 的说明写的是 `Section IDs to show (required when layout +is "custom")` —— 那是 17.x 以前的形状。pin 版 `@objectstack/spec@17.0.0-rc.5` 的 +`RecordDetailsProps.sections` 是对象数组 `{ name?, label?, columns?, fields }`, +objectstack#5611 把 `z.array(z.string())` 那条拼法**删掉**而不是 union 进来(既无 +producer 也无 consumer,一种形状而不是两套事实契约)。 + +照旧说明写 `sections: ['contact_info', 'address']` 的作者,在四层之间拿不到任何 +诊断:`['a','b']` 对 manifest 门是合法 `array`(门只看顶层键名 + 粗类型),上游 +`validateComponentProps` 是 advisory 级,spec 只在真的走 parse 的路径上才拒,而 +`RecordDetailsRenderer` 对每个条目读 `s.name` / `s.label` / `s.fields` —— 字符串上 +三者全 `undefined`,该 section 一个字段都不渲染。`layout: 'custom'` 时 sections 是 +详情页正文的唯一来源,所以结果是一张没有报错的空白详情页。 + +新说明逐键派生自 spec 各成员的 `.describe()` 与渲染器实读:`fields` 必填、按序渲染; +`label` 是标题(省略即无标题、无边框);`name` 是 snake_case 稳定标识与 i18n 锚点 +(标题走 `objects.{object}._sections.{name}.label`);`columns`(1-4)是本 section 的 +字段栅格宽度,省略则由渲染器推导;并明确写出字符串条目不被接受。渲染器另外还认的 +`title` / `showBorder` / `hideEmpty` **故意不写进说明** —— spec 的 section 对象没有 +声明它们,parse 时会被静默剥掉,发布它们等于教作者写契约丢弃的键。 + +同时新增 `recordDetailsInputs.spec-parity.test.ts`:两个方向的断言都在运行时从 spec +schema 派生(每个 spec 成员键都能从说明里发现;本 block 不声明 spec 不接受的顶层 +input),所以下一次 spec 变形会先让测试红,而不是又一次静默张开。仅说明文本变化,无 +运行时行为改动。 diff --git a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts new file mode 100644 index 000000000..f92ac0ab4 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -0,0 +1,197 @@ +/** + * 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. + * + * `record:details` — the published authoring surface stays in parity with + * `@objectstack/spec` `RecordDetailsProps` (objectui#3807, objectstack#5611). + * + * Sibling of `recordHighlightsInputs.spec-parity.test.ts` (objectui#3407 / + * PR #3795) and the same two directions, on the block where the drift was + * worse: there the `fields` description spelled an entry shape that was merely + * INCOMPLETE (`readonly` missing); here the `sections` description spelled an + * entry shape the spec had DELETED. Until 17.x `sections` was + * `z.array(z.string())` — "section IDs" — and objectstack#5611 replaced that + * with the object form outright (no producer, no consumer, so one shape rather + * than two de-facto contracts). The registry text kept teaching the ID list. + * + * WHY A DESCRIPTION IS WORTH A TEST. `inputs` is not documentation, it is the + * published contract: `gen-manifest.ts` serializes it into `sdui.manifest.json` + * (the save-gate + parser whitelist) and into `sdui-intrinsics.d.ts` (the JSX + * authoring surface), and for an array-of-objects input the ENTRY shape exists + * nowhere else — `ComponentInput` has no member-shape slot, which is why the + * repo-wide gate in `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` + * (objectui#3797 / PR #3806) can only see top-level keys and says so in its + * LIMIT note. An author following the retired spelling gets four silent layers: + * `['a','b']` is a valid `array` to the manifest gate, upstream + * `validateComponentProps` is advisory, the spec is only parsed on paths that + * parse, and `RecordDetailsRenderer` reads `s.name` / `s.label` / `s.fields` + * off each entry — all `undefined` on a string, so the section renders nothing. + * Under `layout: 'custom'` sections are the ONLY source of the body, so the + * page comes up blank with no diagnostic anywhere pointing at `sections`. + * + * Every expectation below is DERIVED from the spec schema at runtime rather + * than restating today's key list, so a spec change fails here instead of + * quietly reopening the gap. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { RecordDetailsProps } from '@objectstack/spec/ui'; +import '../index'; + +type ShapeCarrier = { shape?: unknown; _def?: { shape?: unknown } }; + +/** Resolve a Zod object's `.shape` through both spellings, lazy or plain. */ +function shapeKeys(schema: unknown): string[] { + const carrier = schema as ShapeCarrier | undefined; + const shape = carrier?.shape ?? carrier?._def?.shape; + const resolved = typeof shape === 'function' ? (shape as () => object)() : shape; + return resolved && typeof resolved === 'object' ? Object.keys(resolved) : []; +} + +/** One entry of `.shape`, unwrapped past `.optional()`. */ +function shapeMember(schema: unknown, key: string): unknown { + const carrier = schema as ShapeCarrier | undefined; + const shape = carrier?.shape ?? carrier?._def?.shape; + const resolved = (typeof shape === 'function' ? (shape as () => object)() : shape) as + | Record + | undefined; + const member = resolved?.[key] as { unwrap?: () => unknown } | undefined; + return typeof member?.unwrap === 'function' ? member.unwrap() : member; +} + +/** The element schema of a `z.array(...)`, through both spellings. */ +function arrayElement(schema: unknown): unknown { + const arr = schema as { + element?: unknown; + def?: { element?: unknown }; + _def?: { type?: unknown; element?: unknown }; + } | undefined; + return arr?.element ?? arr?.def?.element ?? arr?._def?.element ?? arr?._def?.type; +} + +/** Top-level keys of the spec's `RecordDetailsProps`. */ +const specTopLevelKeys = (): string[] => shapeKeys(RecordDetailsProps); + +/** Member keys of one `sections[]` entry, per the spec. */ +const specSectionKeys = (): string[] => + shapeKeys(arrayElement(shapeMember(RecordDetailsProps, 'sections'))); + +/** + * Section keys `RecordDetailsRenderer` honours beyond the spec's four. Read off + * `renderers/record-details.tsx` (`s.title ?? s.label`, `s.showBorder`, + * `s.hideEmpty`) — a hand-kept list, but the ASSERTION filters it through the + * spec at runtime, so the day upstream declares one of these it drops out of + * the forbidden set on its own instead of pinning a stale prohibition. + */ +const RENDERER_ONLY_SECTION_KEYS = ['title', 'showBorder', 'hideEmpty']; + +const config = () => ComponentRegistry.getConfig('record:details'); +const inputs = () => config()?.inputs ?? []; +const input = (name: string) => inputs().find((i) => i.name === name); +const sectionsDescription = () => input('sections')?.description ?? ''; + +describe('record:details — registry inputs vs @objectstack/spec', () => { + it('is registered with a non-empty `inputs` surface', () => { + expect(config()).toBeDefined(); + expect(inputs().map((i) => i.name)).toContain('sections'); + }); + + it('the spec really takes OBJECT sections — the id-list spelling is gone, not unioned in', () => { + // Guards the premise the rest of the file rests on. A `z.array(z.string())` + // arm coming back (or the object form moving) must fail here first, because + // the description below would then be documenting the wrong shape again. + expect(specSectionKeys().length).toBeGreaterThan(0); + + // A VALUE verdict, so the criterion is a full parse, not key recognition: + // the retired spelling has to be rejected on its value, and the object form + // has to survive intact. + const idList = RecordDetailsProps.safeParse({ + layout: 'custom', + sections: ['contact_info', 'address'], + }); + expect(idList.success).toBe(false); + expect(idList.error?.issues.map((i) => i.code)).toContain('invalid_type'); + + const objectForm = RecordDetailsProps.safeParse({ + layout: 'custom', + sections: [{ name: 'contact_info', label: 'Contact', columns: 2, fields: ['phone'] }], + }); + expect(objectForm.success).toBe(true); + expect(objectForm.data?.sections?.[0]).toMatchObject({ + name: 'contact_info', + columns: 2, + fields: ['phone'], + }); + }); + + it('every spec section member key is discoverable from the `sections` description', () => { + const description = sectionsDescription(); + expect(description).not.toBe(''); + const undocumented = specSectionKeys().filter((key) => !description.includes(key)); + expect(undocumented).toEqual([]); + }); + + it('the `sections` description no longer teaches the retired section-id spelling', () => { + // The regression this issue was filed for, named explicitly so it stays + // legible if the derived check above is ever loosened. The entry shape must + // be stated as an object, and the string form must be ruled out in the same + // breath — an author reading only "object form" would not know their + // existing `['contact_info']` page is now silently empty. + const description = sectionsDescription(); + expect(description).not.toMatch(/section ids/i); + expect(description).toMatch(/object/i); + expect(description).toMatch(/string/i); + }); + + it('publishes no section member key the spec strips on parse', () => { + // The renderer honours `title` / `showBorder` / `hideEmpty` per section, + // but the spec's section object does not declare them, so they are dropped + // with no error. Documenting them here would tell authors to write keys the + // contract discards — the member-level twin of publishing a top-level input + // the props schema rejects. + const stripped = RENDERER_ONLY_SECTION_KEYS.filter( + (key) => !specSectionKeys().includes(key), + ); + expect(stripped).not.toEqual([]); // the premise: these really are undeclared + + const parsed = RecordDetailsProps.safeParse({ + sections: [{ label: 'Contact', fields: ['phone'], title: 'T', showBorder: true, hideEmpty: false }], + }); + expect(parsed.success).toBe(true); + expect(Object.keys(parsed.data?.sections?.[0] ?? {}).sort()).toEqual(['fields', 'label']); + + // Word-boundary, not substring: this direction asks "does the text teach + // this KEY", and prose legitimately contains words that merely embed one + // ("untitled" embeds `title`). The forward check above can stay a substring + // test because a false positive there only ever accepts a description that + // does mention the key. + const description = sectionsDescription(); + const published = stripped.filter((key) => new RegExp(`\\b${key}\\b`).test(description)); + expect(published).toEqual([]); + }); + + it('declares no top-level input the spec does not accept', () => { + const allowed = new Set(specTopLevelKeys()); + const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name)); + expect(offSpec).toEqual([]); + }); + + it('`fields` documents no entry shape, because the spec accepts bare names only', () => { + // objectui#3807's fence check on the sibling input at the same call site. + // Top-level `fields` is `z.array(z.string())`: there is no member shape to + // publish, and the renderer's tolerance for `{name}` / `{field}` entries is + // not a second contract to advertise — the spec rejects those values. + const element = arrayElement(shapeMember(RecordDetailsProps, 'fields')); + expect(shapeKeys(element)).toEqual([]); + expect(RecordDetailsProps.safeParse({ fields: ['phone'] }).success).toBe(true); + expect(RecordDetailsProps.safeParse({ fields: [{ name: 'phone' }] }).success).toBe(false); + + const description = input('fields')?.description ?? ''; + expect(description).not.toBe(''); + expect(description).not.toContain('{'); + }); +}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 81cccfcad..8c7095ebf 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -243,10 +243,36 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { label: 'Record Details', icon: 'FileText', // Designer inputs mirror @objectstack/spec RecordDetailsProps (component.zod). + // + // `sections` publishes its ENTRY shape in prose, derived from the spec's own + // `.describe()` on each member key — `ComponentInput` is flat by design and + // has no slot for a member shape, so an array-of-objects input can only + // document its elements here (same as `record:highlights.fields`, + // `record:path.stages`, `record:alert.action`). It says "object, not string" + // out loud because the string spelling is exactly what this text used to + // teach: until 17.x the spec declared `sections: z.array(z.string())` and + // this description read "Section IDs to show". objectstack#5611 deleted that + // arm rather than unioning it in (no producer, no consumer — one shape, not + // two de-facto contracts), and nothing in the four layers between the + // manifest and the screen reports a leftover ID list: the manifest gate + // checks top-level prop names and coarse types only (`['a','b']` is a valid + // `array`), `validateComponentProps` upstream is advisory, and + // `RecordDetailsRenderer` maps every entry as an object (`s.name` / + // `s.label` / `s.fields`), so a string entry contributes no fields at all. + // With `layout: 'custom'` sections are the ONLY source of the body, so the + // author who trusted the old text got a blank detail page. objectui#3807. + // + // Documented member keys are exactly the spec's four (`name`, `label`, + // `columns`, `fields`) — deliberately NOT the extras `RecordDetailsRenderer` + // also honours on a section (`title`, `showBorder`, `hideEmpty`). Those are + // undeclared upstream, so the spec's section object STRIPS them on parse: + // publishing them here would advertise keys the contract throws away, the + // same trap as declaring a top-level `readonly` on `record:highlights` + // below. The renderer tolerating them is not a licence to teach them. inputs: [ { name: 'columns', type: 'enum', label: 'Columns', enum: ['1', '2', '3', '4'], defaultValue: '2', description: 'Number of columns for field layout (1-4)' }, { name: 'layout', type: 'enum', label: 'Layout', enum: ['auto', 'custom'], defaultValue: 'auto', description: 'auto uses the object highlightFields; custom uses explicit sections' }, - { name: 'sections', type: 'array', label: 'Sections', description: 'Section IDs to show (required when layout is "custom")' }, + { name: 'sections', type: 'array', label: 'Sections', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Required when layout is "custom", where sections are the only source of the detail body.' }, { name: 'fields', type: 'array', label: 'Fields', description: 'Explicit field list (overrides highlightFields)' }, ], });