Skip to content

Commit ed08a61

Browse files
committed
fix(plugin-detail): record:details 的 sections 说明改为 spec 的对象形,不再教已退役的 Section IDs (#3807)
`inputs` 是发布出去的编写契约(gen-manifest.ts → sdui.manifest.json 保存门 + 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']` 的作者四层都拿不到诊断: 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 锚点 (sectionLabel → objects.{object}._sections.{name}.label,useObjectLabel.ts:419); columns(1-4)是本 section 的字段栅格宽度(DetailSection 的 applyDetailAutoLayout(visibleFields, section.columns)),省略则由渲染器推导; 并明确写出字符串条目不被接受。渲染器另外还认的 title / showBorder / hideEmpty 故意不写进说明:spec 的 section 对象没声明它们,parse 时静默剥掉,发布它们等于 教作者写契约丢弃的键(与本文件下方"顶层 readonly 不声明"同一条理由)。 recordDetailsInputs.spec-parity.test.ts 是 PR #3795(record:highlights)那条 sibling,两个方向都在运行时从 spec schema 派生:每个 spec 成员键都能从说明里 发现;本 block 不声明 spec 不接受的顶层 input。另外三条把这次的判断钉住 —— 退役拼法真的被 spec 按值拒(safeParse 红)、对象形保留、渲染器独有的三个 section 键确实会被 parse 剥掉且不得出现在说明里(词边界匹配,因为 untitled 内含 title)。 围栏内核对结论:同处 fields 的说明与 spec 的 "Explicit field list to display (optional, overrides highlightFields)" 语义一致, 元素是纯字符串、没有成员形状可发布,故不改;渲染器对 {name}/{field} 条目的容忍 不是第二套契约(spec 按值拒),测试里也钉了这一点。hideFields 未声明属 #3808。 仅说明文本变化,无运行时行为改动。 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5147d93 commit ed08a61

3 files changed

Lines changed: 256 additions & 1 deletion

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@object-ui/plugin-detail": patch
3+
---
4+
5+
`record:details``sections` 输入说明改为从 spec 形状派生的对象形,不再教已被退役的「Section IDs」
6+
7+
`inputs` 不是文档,而是发布出去的编写契约:`gen-manifest.ts` 把它序列化进
8+
`sdui.manifest.json`(保存门 + parser 白名单)和 `sdui-intrinsics.d.ts`。而
9+
`record:details.sections` 的说明写的是 `Section IDs to show (required when layout
10+
is "custom")` —— 那是 17.x 以前的形状。pin 版 `@objectstack/spec@17.0.0-rc.5` 的
11+
`RecordDetailsProps.sections` 是对象数组 `{ name?, label?, columns?, fields }`,
12+
objectstack#5611`z.array(z.string())` 那条拼法**删掉**而不是 union 进来(既无
13+
producer 也无 consumer,一种形状而不是两套事实契约)。
14+
15+
照旧说明写 `sections: ['contact_info', 'address']` 的作者,在四层之间拿不到任何
16+
诊断:`['a','b']` 对 manifest 门是合法 `array`(门只看顶层键名 + 粗类型),上游
17+
`validateComponentProps` 是 advisory 级,spec 只在真的走 parse 的路径上才拒,而
18+
`RecordDetailsRenderer` 对每个条目读 `s.name` / `s.label` / `s.fields` —— 字符串上
19+
三者全 `undefined`,该 section 一个字段都不渲染。`layout: 'custom'` 时 sections 是
20+
详情页正文的唯一来源,所以结果是一张没有报错的空白详情页。
21+
22+
新说明逐键派生自 spec 各成员的 `.describe()` 与渲染器实读:`fields` 必填、按序渲染;
23+
`label` 是标题(省略即无标题、无边框);`name` 是 snake_case 稳定标识与 i18n 锚点
24+
(标题走 `objects.{object}._sections.{name}.label`);`columns`(1-4)是本 section 的
25+
字段栅格宽度,省略则由渲染器推导;并明确写出字符串条目不被接受。渲染器另外还认的
26+
`title` / `showBorder` / `hideEmpty` **故意不写进说明** —— spec 的 section 对象没有
27+
声明它们,parse 时会被静默剥掉,发布它们等于教作者写契约丢弃的键。
28+
29+
同时新增 `recordDetailsInputs.spec-parity.test.ts`:两个方向的断言都在运行时从 spec
30+
schema 派生(每个 spec 成员键都能从说明里发现;本 block 不声明 spec 不接受的顶层
31+
input),所以下一次 spec 变形会先让测试红,而不是又一次静默张开。仅说明文本变化,无
32+
运行时行为改动。
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* `record:details` — the published authoring surface stays in parity with
9+
* `@objectstack/spec` `RecordDetailsProps` (objectui#3807, objectstack#5611).
10+
*
11+
* Sibling of `recordHighlightsInputs.spec-parity.test.ts` (objectui#3407 /
12+
* PR #3795) and the same two directions, on the block where the drift was
13+
* worse: there the `fields` description spelled an entry shape that was merely
14+
* INCOMPLETE (`readonly` missing); here the `sections` description spelled an
15+
* entry shape the spec had DELETED. Until 17.x `sections` was
16+
* `z.array(z.string())` — "section IDs" — and objectstack#5611 replaced that
17+
* with the object form outright (no producer, no consumer, so one shape rather
18+
* than two de-facto contracts). The registry text kept teaching the ID list.
19+
*
20+
* WHY A DESCRIPTION IS WORTH A TEST. `inputs` is not documentation, it is the
21+
* published contract: `gen-manifest.ts` serializes it into `sdui.manifest.json`
22+
* (the save-gate + parser whitelist) and into `sdui-intrinsics.d.ts` (the JSX
23+
* authoring surface), and for an array-of-objects input the ENTRY shape exists
24+
* nowhere else — `ComponentInput` has no member-shape slot, which is why the
25+
* repo-wide gate in `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`
26+
* (objectui#3797 / PR #3806) can only see top-level keys and says so in its
27+
* LIMIT note. An author following the retired spelling gets four silent layers:
28+
* `['a','b']` is a valid `array` to the manifest gate, upstream
29+
* `validateComponentProps` is advisory, the spec is only parsed on paths that
30+
* parse, and `RecordDetailsRenderer` reads `s.name` / `s.label` / `s.fields`
31+
* off each entry — all `undefined` on a string, so the section renders nothing.
32+
* Under `layout: 'custom'` sections are the ONLY source of the body, so the
33+
* page comes up blank with no diagnostic anywhere pointing at `sections`.
34+
*
35+
* Every expectation below is DERIVED from the spec schema at runtime rather
36+
* than restating today's key list, so a spec change fails here instead of
37+
* quietly reopening the gap.
38+
*/
39+
40+
import { describe, it, expect } from 'vitest';
41+
import { ComponentRegistry } from '@object-ui/core';
42+
import { RecordDetailsProps } from '@objectstack/spec/ui';
43+
import '../index';
44+
45+
type ShapeCarrier = { shape?: unknown; _def?: { shape?: unknown } };
46+
47+
/** Resolve a Zod object's `.shape` through both spellings, lazy or plain. */
48+
function shapeKeys(schema: unknown): string[] {
49+
const carrier = schema as ShapeCarrier | undefined;
50+
const shape = carrier?.shape ?? carrier?._def?.shape;
51+
const resolved = typeof shape === 'function' ? (shape as () => object)() : shape;
52+
return resolved && typeof resolved === 'object' ? Object.keys(resolved) : [];
53+
}
54+
55+
/** One entry of `.shape`, unwrapped past `.optional()`. */
56+
function shapeMember(schema: unknown, key: string): unknown {
57+
const carrier = schema as ShapeCarrier | undefined;
58+
const shape = carrier?.shape ?? carrier?._def?.shape;
59+
const resolved = (typeof shape === 'function' ? (shape as () => object)() : shape) as
60+
| Record<string, unknown>
61+
| undefined;
62+
const member = resolved?.[key] as { unwrap?: () => unknown } | undefined;
63+
return typeof member?.unwrap === 'function' ? member.unwrap() : member;
64+
}
65+
66+
/** The element schema of a `z.array(...)`, through both spellings. */
67+
function arrayElement(schema: unknown): unknown {
68+
const arr = schema as {
69+
element?: unknown;
70+
def?: { element?: unknown };
71+
_def?: { type?: unknown; element?: unknown };
72+
} | undefined;
73+
return arr?.element ?? arr?.def?.element ?? arr?._def?.element ?? arr?._def?.type;
74+
}
75+
76+
/** Top-level keys of the spec's `RecordDetailsProps`. */
77+
const specTopLevelKeys = (): string[] => shapeKeys(RecordDetailsProps);
78+
79+
/** Member keys of one `sections[]` entry, per the spec. */
80+
const specSectionKeys = (): string[] =>
81+
shapeKeys(arrayElement(shapeMember(RecordDetailsProps, 'sections')));
82+
83+
/**
84+
* Section keys `RecordDetailsRenderer` honours beyond the spec's four. Read off
85+
* `renderers/record-details.tsx` (`s.title ?? s.label`, `s.showBorder`,
86+
* `s.hideEmpty`) — a hand-kept list, but the ASSERTION filters it through the
87+
* spec at runtime, so the day upstream declares one of these it drops out of
88+
* the forbidden set on its own instead of pinning a stale prohibition.
89+
*/
90+
const RENDERER_ONLY_SECTION_KEYS = ['title', 'showBorder', 'hideEmpty'];
91+
92+
const config = () => ComponentRegistry.getConfig('record:details');
93+
const inputs = () => config()?.inputs ?? [];
94+
const input = (name: string) => inputs().find((i) => i.name === name);
95+
const sectionsDescription = () => input('sections')?.description ?? '';
96+
97+
describe('record:details — registry inputs vs @objectstack/spec', () => {
98+
it('is registered with a non-empty `inputs` surface', () => {
99+
expect(config()).toBeDefined();
100+
expect(inputs().map((i) => i.name)).toContain('sections');
101+
});
102+
103+
it('the spec really takes OBJECT sections — the id-list spelling is gone, not unioned in', () => {
104+
// Guards the premise the rest of the file rests on. A `z.array(z.string())`
105+
// arm coming back (or the object form moving) must fail here first, because
106+
// the description below would then be documenting the wrong shape again.
107+
expect(specSectionKeys().length).toBeGreaterThan(0);
108+
109+
// A VALUE verdict, so the criterion is a full parse, not key recognition:
110+
// the retired spelling has to be rejected on its value, and the object form
111+
// has to survive intact.
112+
const idList = RecordDetailsProps.safeParse({
113+
layout: 'custom',
114+
sections: ['contact_info', 'address'],
115+
});
116+
expect(idList.success).toBe(false);
117+
expect(idList.error?.issues.map((i) => i.code)).toContain('invalid_type');
118+
119+
const objectForm = RecordDetailsProps.safeParse({
120+
layout: 'custom',
121+
sections: [{ name: 'contact_info', label: 'Contact', columns: 2, fields: ['phone'] }],
122+
});
123+
expect(objectForm.success).toBe(true);
124+
expect(objectForm.data?.sections?.[0]).toMatchObject({
125+
name: 'contact_info',
126+
columns: 2,
127+
fields: ['phone'],
128+
});
129+
});
130+
131+
it('every spec section member key is discoverable from the `sections` description', () => {
132+
const description = sectionsDescription();
133+
expect(description).not.toBe('');
134+
const undocumented = specSectionKeys().filter((key) => !description.includes(key));
135+
expect(undocumented).toEqual([]);
136+
});
137+
138+
it('the `sections` description no longer teaches the retired section-id spelling', () => {
139+
// The regression this issue was filed for, named explicitly so it stays
140+
// legible if the derived check above is ever loosened. The entry shape must
141+
// be stated as an object, and the string form must be ruled out in the same
142+
// breath — an author reading only "object form" would not know their
143+
// existing `['contact_info']` page is now silently empty.
144+
const description = sectionsDescription();
145+
expect(description).not.toMatch(/section ids/i);
146+
expect(description).toMatch(/object/i);
147+
expect(description).toMatch(/string/i);
148+
});
149+
150+
it('publishes no section member key the spec strips on parse', () => {
151+
// The renderer honours `title` / `showBorder` / `hideEmpty` per section,
152+
// but the spec's section object does not declare them, so they are dropped
153+
// with no error. Documenting them here would tell authors to write keys the
154+
// contract discards — the member-level twin of publishing a top-level input
155+
// the props schema rejects.
156+
const stripped = RENDERER_ONLY_SECTION_KEYS.filter(
157+
(key) => !specSectionKeys().includes(key),
158+
);
159+
expect(stripped).not.toEqual([]); // the premise: these really are undeclared
160+
161+
const parsed = RecordDetailsProps.safeParse({
162+
sections: [{ label: 'Contact', fields: ['phone'], title: 'T', showBorder: true, hideEmpty: false }],
163+
});
164+
expect(parsed.success).toBe(true);
165+
expect(Object.keys(parsed.data?.sections?.[0] ?? {}).sort()).toEqual(['fields', 'label']);
166+
167+
// Word-boundary, not substring: this direction asks "does the text teach
168+
// this KEY", and prose legitimately contains words that merely embed one
169+
// ("untitled" embeds `title`). The forward check above can stay a substring
170+
// test because a false positive there only ever accepts a description that
171+
// does mention the key.
172+
const description = sectionsDescription();
173+
const published = stripped.filter((key) => new RegExp(`\\b${key}\\b`).test(description));
174+
expect(published).toEqual([]);
175+
});
176+
177+
it('declares no top-level input the spec does not accept', () => {
178+
const allowed = new Set(specTopLevelKeys());
179+
const offSpec = inputs().map((i) => i.name).filter((name) => !allowed.has(name));
180+
expect(offSpec).toEqual([]);
181+
});
182+
183+
it('`fields` documents no entry shape, because the spec accepts bare names only', () => {
184+
// objectui#3807's fence check on the sibling input at the same call site.
185+
// Top-level `fields` is `z.array(z.string())`: there is no member shape to
186+
// publish, and the renderer's tolerance for `{name}` / `{field}` entries is
187+
// not a second contract to advertise — the spec rejects those values.
188+
const element = arrayElement(shapeMember(RecordDetailsProps, 'fields'));
189+
expect(shapeKeys(element)).toEqual([]);
190+
expect(RecordDetailsProps.safeParse({ fields: ['phone'] }).success).toBe(true);
191+
expect(RecordDetailsProps.safeParse({ fields: [{ name: 'phone' }] }).success).toBe(false);
192+
193+
const description = input('fields')?.description ?? '';
194+
expect(description).not.toBe('');
195+
expect(description).not.toContain('{');
196+
});
197+
});

packages/plugin-detail/src/index.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,36 @@ ComponentRegistry.register('details', RecordDetailsRenderer, {
243243
label: 'Record Details',
244244
icon: 'FileText',
245245
// Designer inputs mirror @objectstack/spec RecordDetailsProps (component.zod).
246+
//
247+
// `sections` publishes its ENTRY shape in prose, derived from the spec's own
248+
// `.describe()` on each member key — `ComponentInput` is flat by design and
249+
// has no slot for a member shape, so an array-of-objects input can only
250+
// document its elements here (same as `record:highlights.fields`,
251+
// `record:path.stages`, `record:alert.action`). It says "object, not string"
252+
// out loud because the string spelling is exactly what this text used to
253+
// teach: until 17.x the spec declared `sections: z.array(z.string())` and
254+
// this description read "Section IDs to show". objectstack#5611 deleted that
255+
// arm rather than unioning it in (no producer, no consumer — one shape, not
256+
// two de-facto contracts), and nothing in the four layers between the
257+
// manifest and the screen reports a leftover ID list: the manifest gate
258+
// checks top-level prop names and coarse types only (`['a','b']` is a valid
259+
// `array`), `validateComponentProps` upstream is advisory, and
260+
// `RecordDetailsRenderer` maps every entry as an object (`s.name` /
261+
// `s.label` / `s.fields`), so a string entry contributes no fields at all.
262+
// With `layout: 'custom'` sections are the ONLY source of the body, so the
263+
// author who trusted the old text got a blank detail page. objectui#3807.
264+
//
265+
// Documented member keys are exactly the spec's four (`name`, `label`,
266+
// `columns`, `fields`) — deliberately NOT the extras `RecordDetailsRenderer`
267+
// also honours on a section (`title`, `showBorder`, `hideEmpty`). Those are
268+
// undeclared upstream, so the spec's section object STRIPS them on parse:
269+
// publishing them here would advertise keys the contract throws away, the
270+
// same trap as declaring a top-level `readonly` on `record:highlights`
271+
// below. The renderer tolerating them is not a licence to teach them.
246272
inputs: [
247273
{ name: 'columns', type: 'enum', label: 'Columns', enum: ['1', '2', '3', '4'], defaultValue: '2', description: 'Number of columns for field layout (1-4)' },
248274
{ name: 'layout', type: 'enum', label: 'Layout', enum: ['auto', 'custom'], defaultValue: 'auto', description: 'auto uses the object highlightFields; custom uses explicit sections' },
249-
{ name: 'sections', type: 'array', label: 'Sections', description: 'Section IDs to show (required when layout is "custom")' },
275+
{ 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.<object>._sections.<name>.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.' },
250276
{ name: 'fields', type: 'array', label: 'Fields', description: 'Explicit field list (overrides highlightFields)' },
251277
],
252278
});

0 commit comments

Comments
 (0)