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
49 changes: 49 additions & 0 deletions .changeset/record-highlights-field-readonly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/spec": minor
---

feat(spec): `RecordHighlightsField` declares `readonly` (#5176)

The object form of a `record:highlights` entry now declares an optional
`readonly: boolean`. It marks a highlight chip as non-editable — use it for
columns a hook or automation maintains, which must not be hand-edited from the
record header.

```ts
{
type: 'record:highlights',
properties: {
fields: [
'name',
{ name: 'supply_share', type: 'number', readonly: true },
],
},
}
```

**Why this is a spec change and not a renderer detail.** The renderer's
`HeaderHighlight` gate already refuses inline editing on a chip carrying
`readonly`, but the key was not declared here — and the object member is not
`.strict()`, so `RecordHighlightsField` **silently stripped** it:

```
input { fields: [ { name: 'supply_share', readonly: true, type: 'number' } ] }
parsed { fields: [ { name: 'supply_share', type: 'number' } ] }
```

That worked end to end only because per-component props are not parsed on the
live load path today (`PageComponentSchema.properties` is
`z.record(z.string(), z.unknown())`, so the bag rides through untouched). The
moment that gate is wired up, an authored `readonly` becomes either a silent
strip — a machine-owned column quietly editable again, with no diagnostic
anywhere — or a hard parse error. Declaring the key makes the authored
declaration and the enforced behaviour the same fact, which is what ADR-0049
asks for: it is enforced on arrival, not declared-and-inert.

For authors — including AI authors — the key now appears in the generated
component reference, and a misspelling (`readOnly`, `read_only`) is a wrong key
rather than a second de-facto contract the renderer happens to honour.

Purely additive: `readonly` is optional and no default is materialized, so an
entry that does not author it parses exactly as before, and the bare-string form
of a highlight field is unchanged.
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ const result = AIChatWindowProps.parse(data);

## RecordHighlightsField

Highlight field: bare name, or `{name,label?,icon?,type?}`
Highlight field: bare name, or `{name,label?,icon?,type?,readonly?}`

### Union Options

Expand All @@ -322,6 +322,7 @@ Type: `string`
| **label** | `string` | optional | Display label (overrides schema label) |
| **icon** | `string` | optional | Icon name (lucide icon key) |
| **type** | `string` | optional | Override cell renderer type (rare) |
| **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. |

---

Expand All @@ -334,7 +335,7 @@ Type: `string`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **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. |
| **layout** | `Enum<'horizontal' \| 'vertical'>` | ✅ | Layout orientation for highlight fields |
| **aria** | `{ ariaLabel?: string; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

Expand Down
41 changes: 41 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,47 @@ describe('RecordHighlightsProps', () => {
it('should reject missing fields', () => {
expect(() => RecordHighlightsProps.parse({})).toThrow();
});

// #5176 — `readonly` is a declared key on the object member of
// RecordHighlightsField. objectui's HeaderHighlight gate reads it to keep a
// hook-maintained column non-editable; before it was declared the object
// member (non-strict) silently stripped it, so the authored intent never
// reached the renderer contract at all.
it('should preserve an authored readonly on an object-form highlight field', () => {
const props = {
fields: [{ name: 'supply_share', readonly: true, type: 'number' }],
};
const result = RecordHighlightsProps.parse(props);
const entry = result.fields[0] as { name: string; readonly?: boolean; type?: string };
expect(entry.name).toBe('supply_share');
expect(entry.type).toBe('number');
expect(entry.readonly).toBe(true);
});

it('should preserve readonly: false rather than dropping it', () => {
const result = RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: false }] });
const entry = result.fields[0] as { readonly?: boolean };
expect(entry.readonly).toBe(false);
});

it('should leave readonly undefined when it is not authored (no default materialized)', () => {
const result = RecordHighlightsProps.parse({ fields: [{ name: 'amount' }] });
const entry = result.fields[0] as { readonly?: boolean };
expect(entry).not.toHaveProperty('readonly');
expect(entry.readonly).toBeUndefined();
});

it('should reject a non-boolean readonly instead of silently stripping it', () => {
expect(() => RecordHighlightsProps.parse({ fields: [{ name: 'amount', readonly: 'yes' }] })).toThrow();
});

it('should still accept bare-string and other object-form highlight fields', () => {
const result = RecordHighlightsProps.parse({
fields: ['name', { name: 'status', label: 'State', icon: 'flag' }],
});
expect(result.fields[0]).toBe('name');
expect(result.fields[1]).toEqual({ name: 'status', label: 'State', icon: 'flag' });
});
});

describe('ComponentPropsMap', () => {
Expand Down
11 changes: 9 additions & 2 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,18 @@ export const RecordHighlightsField = z.union([
label: z.string().optional().describe('Display label (overrides schema label)'),
icon: z.string().optional().describe('Icon name (lucide icon key)'),
type: z.string().optional().describe('Override cell renderer type (rare)'),
// #5176 — declared because it is already enforced: the renderer's
// HeaderHighlight gate refuses inline editing on a chip carrying it. Kept
// as a declared key (ADR-0049 enforce-or-remove, satisfied on arrival)
// rather than an undeclared key the renderer happens to honour — an
// undeclared key is silently stripped here, which turns a machine-owned
// column editable again with no diagnostic anywhere.
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.'),
}),
]).describe('Highlight field: bare name, or {name,label?,icon?,type?}');
]).describe('Highlight field: bare name, or {name,label?,icon?,type?,readonly?}');

export const RecordHighlightsProps = z.object({
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.'),
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.'),
layout: z.enum(['horizontal', 'vertical']).default('horizontal').describe('Layout orientation for highlight fields'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
Expand Down
Loading