Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/record-details-sections-description-3807.md
Original file line number Diff line number Diff line change
@@ -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 变形会先让测试红,而不是又一次静默张开。仅说明文本变化,无
运行时行为改动。
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
| 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('{');
});
});
28 changes: 27 additions & 1 deletion packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<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.' },
{ name: 'fields', type: 'array', label: 'Fields', description: 'Explicit field list (overrides highlightFields)' },
],
});
Expand Down
Loading