Skip to content

Commit 4f4c3fb

Browse files
os-zhuangclaude
andauthored
feat(spec): RecordHighlightsField 声明 readonly —— 渲染器已强制的键不再被静默剥离 (#5176) (#5607)
`record:highlights` 条目的对象形式新增可选 `readonly: boolean`,用于把高亮 chip 标记为不可行内编辑(hook / 自动化维护的列)。 此前该键未被声明,而对象成员不是 `.strict()`,因此被**静默剥离**: input { fields: [ { name: 'supply_share', readonly: true, type: 'number' } ] } parsed { fields: [ { name: 'supply_share', type: 'number' } ] } 今天之所以端到端可用,只是因为逐组件 props 尚未在装载路径上解析 (`PageComponentSchema.properties` 是 `z.record(z.string(), z.unknown())`)。 一旦该闸门接通,授权的 `readonly` 要么静默丢失(机器维护列重新可编辑、 零诊断),要么硬解析失败。声明它使"授权的声明"与"被强制的行为"成为 同一事实 —— 落地即满足 ADR-0049 enforce-or-remove,而非声明即惰性。 纯增量:`readonly` 可选且不物化默认值,未授权该键的条目解析结果与此前 完全一致;bare-string 形式不变。对象级 `highlightFields: string[]` 不在 本次范围内。 不翻 `.strict()` —— 那属于 #5068 的闸门专项。 Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93a61be commit 4f4c3fb

4 files changed

Lines changed: 102 additions & 4 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `RecordHighlightsField` declares `readonly` (#5176)
6+
7+
The object form of a `record:highlights` entry now declares an optional
8+
`readonly: boolean`. It marks a highlight chip as non-editable — use it for
9+
columns a hook or automation maintains, which must not be hand-edited from the
10+
record header.
11+
12+
```ts
13+
{
14+
type: 'record:highlights',
15+
properties: {
16+
fields: [
17+
'name',
18+
{ name: 'supply_share', type: 'number', readonly: true },
19+
],
20+
},
21+
}
22+
```
23+
24+
**Why this is a spec change and not a renderer detail.** The renderer's
25+
`HeaderHighlight` gate already refuses inline editing on a chip carrying
26+
`readonly`, but the key was not declared here — and the object member is not
27+
`.strict()`, so `RecordHighlightsField` **silently stripped** it:
28+
29+
```
30+
input { fields: [ { name: 'supply_share', readonly: true, type: 'number' } ] }
31+
parsed { fields: [ { name: 'supply_share', type: 'number' } ] }
32+
```
33+
34+
That worked end to end only because per-component props are not parsed on the
35+
live load path today (`PageComponentSchema.properties` is
36+
`z.record(z.string(), z.unknown())`, so the bag rides through untouched). The
37+
moment that gate is wired up, an authored `readonly` becomes either a silent
38+
strip — a machine-owned column quietly editable again, with no diagnostic
39+
anywhere — or a hard parse error. Declaring the key makes the authored
40+
declaration and the enforced behaviour the same fact, which is what ADR-0049
41+
asks for: it is enforced on arrival, not declared-and-inert.
42+
43+
For authors — including AI authors — the key now appears in the generated
44+
component reference, and a misspelling (`readOnly`, `read_only`) is a wrong key
45+
rather than a second de-facto contract the renderer happens to honour.
46+
47+
Purely additive: `readonly` is optional and no default is materialized, so an
48+
entry that does not author it parses exactly as before, and the bare-string form
49+
of a highlight field is unchanged.

content/docs/references/ui/component.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ const result = AIChatWindowProps.parse(data);
300300

301301
## RecordHighlightsField
302302

303-
Highlight field: bare name, or `{name,label?,icon?,type?}`
303+
Highlight field: bare name, or `{name,label?,icon?,type?,readonly?}`
304304

305305
### Union Options
306306

@@ -322,6 +322,7 @@ Type: `string`
322322
| **label** | `string` | optional | Display label (overrides schema label) |
323323
| **icon** | `string` | optional | Icon name (lucide icon key) |
324324
| **type** | `string` | optional | Override cell renderer type (rare) |
325+
| **readonly** | `boolean` | optional | Render this chip read-only — suppresses inline editing on the highlight card. Use for hook/automation-maintained columns that must not be hand-edited from the record header. |
325326

326327
---
327328

@@ -334,7 +335,7 @@ Type: `string`
334335

335336
| Property | Type | Required | Description |
336337
| :--- | :--- | :--- | :--- |
337-
| **fields** | `string \| { name: string; label?: string; icon?: string; type?: string }[]` || Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, icon?, type?}` for inline overrides. |
338+
| **fields** | `string \| { name: string; label?: string; icon?: string; type?: string; … }[]` || Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or `{name, label?, icon?, type?, readonly?}` for inline overrides. |
338339
| **layout** | `Enum<'horizontal' \| 'vertical'>` || Layout orientation for highlight fields |
339340
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
340341

packages/spec/src/ui/component.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,47 @@ describe('RecordHighlightsProps', () => {
212212
it('should reject missing fields', () => {
213213
expect(() => RecordHighlightsProps.parse({})).toThrow();
214214
});
215+
216+
// #5176 — `readonly` is a declared key on the object member of
217+
// RecordHighlightsField. objectui's HeaderHighlight gate reads it to keep a
218+
// hook-maintained column non-editable; before it was declared the object
219+
// member (non-strict) silently stripped it, so the authored intent never
220+
// reached the renderer contract at all.
221+
it('should preserve an authored readonly on an object-form highlight field', () => {
222+
const props = {
223+
fields: [{ name: 'supply_share', readonly: true, type: 'number' }],
224+
};
225+
const result = RecordHighlightsProps.parse(props);
226+
const entry = result.fields[0] as { name: string; readonly?: boolean; type?: string };
227+
expect(entry.name).toBe('supply_share');
228+
expect(entry.type).toBe('number');
229+
expect(entry.readonly).toBe(true);
230+
});
231+
232+
it('should preserve readonly: false rather than dropping it', () => {
233+
const result = RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: false }] });
234+
const entry = result.fields[0] as { readonly?: boolean };
235+
expect(entry.readonly).toBe(false);
236+
});
237+
238+
it('should leave readonly undefined when it is not authored (no default materialized)', () => {
239+
const result = RecordHighlightsProps.parse({ fields: [{ name: 'amount' }] });
240+
const entry = result.fields[0] as { readonly?: boolean };
241+
expect(entry).not.toHaveProperty('readonly');
242+
expect(entry.readonly).toBeUndefined();
243+
});
244+
245+
it('should reject a non-boolean readonly instead of silently stripping it', () => {
246+
expect(() => RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: 'yes' }] })).toThrow();
247+
});
248+
249+
it('should still accept bare-string and other object-form highlight fields', () => {
250+
const result = RecordHighlightsProps.parse({
251+
fields: ['name', { name: 'status', label: 'State', icon: 'flag' }],
252+
});
253+
expect(result.fields[0]).toBe('name');
254+
expect(result.fields[1]).toEqual({ name: 'status', label: 'State', icon: 'flag' });
255+
});
215256
});
216257

217258
describe('ComponentPropsMap', () => {

packages/spec/src/ui/component.zod.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,18 @@ export const RecordHighlightsField = z.union([
223223
label: z.string().optional().describe('Display label (overrides schema label)'),
224224
icon: z.string().optional().describe('Icon name (lucide icon key)'),
225225
type: z.string().optional().describe('Override cell renderer type (rare)'),
226+
// #5176 — declared because it is already enforced: the renderer's
227+
// HeaderHighlight gate refuses inline editing on a chip carrying it. Kept
228+
// as a declared key (ADR-0049 enforce-or-remove, satisfied on arrival)
229+
// rather than an undeclared key the renderer happens to honour — an
230+
// undeclared key is silently stripped here, which turns a machine-owned
231+
// column editable again with no diagnostic anywhere.
232+
readonly: z.boolean().optional().describe('Render this chip read-only — suppresses inline editing on the highlight card. Use for hook/automation-maintained columns that must not be hand-edited from the record header.'),
226233
}),
227-
]).describe('Highlight field: bare name, or {name,label?,icon?,type?}');
234+
]).describe('Highlight field: bare name, or {name,label?,icon?,type?,readonly?}');
228235

229236
export const RecordHighlightsProps = z.object({
230-
fields: z.array(RecordHighlightsField).min(1).max(7).describe('Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?} for inline overrides.'),
237+
fields: z.array(RecordHighlightsField).min(1).max(7).describe('Key fields to highlight (1-7 fields max, typically displayed as prominent cards). Each item may be a bare field name or {name, label?, icon?, type?, readonly?} for inline overrides.'),
231238
layout: z.enum(['horizontal', 'vertical']).default('horizontal').describe('Layout orientation for highlight fields'),
232239
/** ARIA accessibility */
233240
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),

0 commit comments

Comments
 (0)