Skip to content

Commit 390c071

Browse files
os-zhuangclaude
andauthored
feat(record): declare inputs for the seven configurable record:* blocks, and curate six (#3027)
Seven record:* blocks shipped with renderers that read props but declared no inputs. That combination is the worst of both: the renderer honours `limit`, `severity`, `location` …, while every authoring surface — the designer panel, the AI vocabulary, the generated manifest — reports the block takes no configuration. #3013 recorded them as deliberately uncurated for exactly that reason. The declarations mirror what each renderer actually reads: record:activity 11 from RecordActivityComponentProps record:chatter 5 from RecordChatterComponentProps record:discussion 5 (same renderer, same inputs) record:alert 8 severity, title, body, visible, icon, action, dismissible, dismissKey record:quick_actions 7 actionNames, requiredPermissions, location, align, inline, variant, size record:history 3 limit, emptyText, unknownUserText record:reference_rail 1 hideEmpty inputs describe what an AUTHOR writes, which is a subset of what the renderer reads. `entries`, `loading` and resolved `actions` are injected by the host shell off RecordContext; declaring them would invite a model to hand-write the data the page is supposed to fetch. `aria` is omitted for the reason it is omitted on record:details — an accessibility escape hatch, not a layout choice. `location` takes its enum from the spec's ACTION_LOCATIONS rather than restating it, per #3019. Six of the seven join PUBLIC_BLOCKS: configurable and absent from the contract is the state #3006 was about. The contract goes 36 -> 42 tags, all resolving. record:chatter stays out — same renderer as record:discussion under a Salesforce-familiar name, kept for schemas already in the wild. Two spellings of one block is ambiguity an authoring model cannot resolve, so the vocabulary carries the spec's name. A test compares the two input lists, so the day they diverge the exclusion stops being justified and fails. A companion assertion requires every curated record:* tag to declare inputs. A curated tag with none reads as "takes no configuration" when the renderer in fact reads props — the same gap #3006 opened, pointed the other way. Claude-Session: https://claude.ai/code/session_01N4mrr1ihhwnfEHFSWmGoMp Co-authored-by: Claude <noreply@anthropic.com>
1 parent b234b4f commit 390c071

4 files changed

Lines changed: 173 additions & 27 deletions

File tree

.changeset/record-blocks-inputs.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@object-ui/plugin-detail": minor
3+
"@object-ui/core": minor
4+
"@object-ui/console": patch
5+
---
6+
7+
feat(record): declare inputs for the seven configurable record:\* blocks, and curate six
8+
9+
Seven `record:*` blocks shipped with renderers that read props but declared no
10+
`inputs`. That combination is the worst of both: the renderer honours
11+
`limit`, `severity`, `location` …, while every authoring surface — the designer
12+
panel, the AI vocabulary, the generated manifest — reports the block takes no
13+
configuration. objectui#3013 recorded them as deliberately uncurated for
14+
exactly that reason.
15+
16+
The declarations mirror what each renderer actually reads:
17+
18+
| block | inputs |
19+
|---|---|
20+
| `record:activity` | 11 — from `RecordActivityComponentProps` |
21+
| `record:chatter` / `record:discussion` | 5 — from `RecordChatterComponentProps` |
22+
| `record:alert` | 8 — severity, title, body, visible, icon, action, dismissible, dismissKey |
23+
| `record:quick_actions` | 7 — actionNames, requiredPermissions, location, align, inline, variant, size |
24+
| `record:history` | 3 — limit, emptyText, unknownUserText |
25+
| `record:reference_rail` | 1 — hideEmpty |
26+
27+
`inputs` describe what an AUTHOR writes, which is a subset of what the renderer
28+
reads. `entries`, `loading` and resolved `actions` are injected by the host
29+
shell off RecordContext; declaring them would invite a model to hand-write the
30+
data the page is supposed to fetch. `aria` is omitted for the reason it is
31+
omitted on `record:details` — an accessibility escape hatch, not a layout
32+
choice. `location` takes its enum from the spec's `ACTION_LOCATIONS` rather
33+
than restating it, per objectui#3019.
34+
35+
Six of the seven are now in `PUBLIC_BLOCKS`: configurable and absent from the
36+
contract is the state objectui#3006 was about. The contract goes 36 → 42 tags,
37+
all resolving.
38+
39+
`record:chatter` stays out — it is the same renderer as `record:discussion`
40+
under a Salesforce-familiar name, kept for schemas already in the wild. Two
41+
spellings of one block is ambiguity an authoring model cannot resolve, so the
42+
vocabulary carries the spec's name. A test compares the two input lists, so the
43+
day they diverge the exclusion stops being justified and fails.
44+
45+
A companion assertion requires every curated `record:*` tag to declare inputs.
46+
A curated tag with none reads as "takes no configuration" when the renderer in
47+
fact reads props — the same gap objectui#3006 opened, pointed the other way.

apps/console/src/__tests__/public-contract.test.ts

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ const EXPECTED_COVERED = [
6767
'record:related_list',
6868
'record:path',
6969
'record:line_items',
70+
'record:activity',
71+
'record:discussion',
72+
'record:history',
73+
'record:quick_actions',
74+
'record:reference_rail',
75+
'record:alert',
7076
'flex',
7177
'grid',
7278
'stack',
@@ -161,25 +167,23 @@ describe('console ↔ PUBLIC_BLOCKS coverage', () => {
161167

162168
/**
163169
* `record:*` blocks that ship but are deliberately NOT in the AI vocabulary,
164-
* each with the reason it stays out. Every one of these currently declares
165-
* ZERO `inputs` — there is nothing for an author or a model to configure, so
166-
* putting them in the contract would advertise a block that can only be
167-
* emitted bare.
170+
* each with the reason it stays out.
168171
*
169172
* This list exists to force a decision, not to park problems: registering a
170173
* new `record:*` block fails the test below until it is either curated or
171174
* added here with a reason. That is the property objectui#3006 needed and did
172175
* not have — `record:line_items` shipped fully configurable (5 inputs) and
173176
* simply never reached the contract, because nothing looked in this direction.
177+
*
178+
* It held seven entries until the blocks behind them declared `inputs`; six
179+
* became authorable and moved into the contract. The one that stayed is here
180+
* on its own merits rather than for want of a configuration surface.
174181
*/
175182
const DELIBERATELY_UNCURATED: Record<string, string> = {
176-
'record:activity': 'no declared inputs — feed is derived from the record, nothing to author',
177-
'record:alert': 'no declared inputs — banner content comes from record state',
178-
'record:chatter': 'no declared inputs — feed is derived from the record',
179-
'record:discussion': 'no declared inputs — shares the chatter renderer',
180-
'record:history': 'no declared inputs — audit trail is derived from the record',
181-
'record:quick_actions': 'no declared inputs — actions come from object metadata',
182-
'record:reference_rail': 'no declared inputs — rail is derived from the record',
183+
'record:chatter':
184+
'same renderer as record:discussion under a Salesforce-familiar name, kept for ' +
185+
'schemas already in the wild — the vocabulary carries the spec name, since two ' +
186+
'spellings of one block is ambiguity an authoring model cannot resolve',
183187
};
184188

185189
describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => {
@@ -220,17 +224,34 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => {
220224
}
221225
});
222226

223-
it('keeps the deliberately-uncurated blocks unconfigurable', () => {
224-
// The stated reason for every exclusion is "nothing to author". If one of
225-
// these grows `inputs`, it became authorable and the exclusion needs
226-
// re-deciding rather than inheriting.
227-
for (const tag of Object.keys(DELIBERATELY_UNCURATED)) {
228-
// A pending lazy stub reports `inputs: undefined` meaning "not known
229-
// yet", which would make the assertion below vacuous. These are eager
230-
// registrations in plugin-detail; pin that, so the day one goes lazy this
231-
// fails loudly instead of silently passing.
227+
it('keeps the chatter alias identical to the block it aliases', () => {
228+
// `record:chatter` is excluded because it duplicates `record:discussion`,
229+
// not because it is lesser. The moment the two configuration surfaces
230+
// diverge, that reasoning stops holding: `chatter` would be its own block
231+
// kept out of the vocabulary, which is the state this whole file exists to
232+
// catch. Comparing inputs is what makes the exclusion falsifiable.
233+
//
234+
// Both are eager registrations, asserted first — a pending lazy stub
235+
// reports `inputs: undefined` meaning "not known yet", which would make the
236+
// comparison below pass vacuously on two blanks.
237+
for (const tag of ['record:chatter', 'record:discussion']) {
232238
expect(ComponentRegistry.getConfig(tag)).toBeDefined();
233-
expect(ComponentRegistry.getMeta(tag)?.inputs ?? []).toEqual([]);
239+
}
240+
const chatter = ComponentRegistry.getMeta('record:chatter')?.inputs;
241+
expect(chatter?.length).toBeGreaterThan(0);
242+
expect(chatter).toEqual(ComponentRegistry.getMeta('record:discussion')?.inputs);
243+
});
244+
245+
it('declares inputs for every curated record:* block', () => {
246+
// What objectui#3006 cost was a configurable block sitting outside the
247+
// contract. The inverse is just as bad for an authoring model: a curated
248+
// tag with no declared inputs can only be emitted bare, so it reads as
249+
// "this block takes no configuration" when the renderer in fact reads
250+
// props. Curation and a configuration surface travel together.
251+
const curatedRecordBlocks = PUBLIC_BLOCKS.filter((tag) => tag.startsWith(`${NS}:`));
252+
expect(curatedRecordBlocks.length).toBeGreaterThan(0);
253+
for (const tag of curatedRecordBlocks) {
254+
expect(contract.get(tag)?.inputs ?? []).not.toEqual([]);
234255
}
235256
});
236257

packages/core/src/registry/public-blocks.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ export const PUBLIC_BLOCKS: readonly string[] = [
4848
// it was listed under here never existed — the block has shipped all along
4949
// but could never resolve through the contract (objectui#2953 follow-up).
5050
'record:line_items',
51+
// Configurable as of objectui#3023 follow-up: these shipped with renderers
52+
// but no declared `inputs`, so a model could only emit them bare — which is
53+
// why they sat outside the contract. Their inputs now mirror what the
54+
// renderers actually read, so they are authorable and belong here.
55+
//
56+
// `record:chatter` is deliberately NOT in this list: it is the same renderer
57+
// as `record:discussion` under a Salesforce-familiar name, kept for schemas
58+
// already in the wild. Two spellings of one block is ambiguity an authoring
59+
// model has no way to resolve, so the vocabulary carries the spec's name.
60+
'record:activity',
61+
'record:discussion',
62+
'record:history',
63+
'record:quick_actions',
64+
'record:reference_rail',
65+
'record:alert',
5166
// ── Tier B — layout / content primitives ──────────────────────────────────
5267
'flex',
5368
'grid',

packages/plugin-detail/src/index.tsx

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { ComponentRegistry } from '@object-ui/core';
9+
import { ComponentRegistry, type ComponentInput } from '@object-ui/core';
1010
import { DetailView } from './DetailView';
1111
import { DetailSection } from './DetailSection';
1212
import { DetailTabs } from './DetailTabs';
@@ -23,6 +23,7 @@ import { RecordReferenceRailRenderer } from './renderers/record-reference-rail';
2323
import { RecordAlertRenderer } from './renderers/record-alert';
2424
import { PermissionFacetLink } from './renderers/PermissionFacetLink';
2525
import type { DetailViewSchema } from '@object-ui/types';
26+
import { ACTION_LOCATIONS } from '@object-ui/types';
2627

2728
export { DetailView, DetailSection, DetailTabs, RelatedList };
2829
export {
@@ -282,33 +283,67 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, {
282283
],
283284
});
284285

286+
// `inputs` on the blocks below describe what an AUTHOR writes, which is a
287+
// subset of what the renderer reads. `entries`, `loading` and resolved
288+
// `actions` are injected by the host shell (RecordDetailView and friends) off
289+
// RecordContext — declaring those would invite a model to hand-write data the
290+
// page is supposed to fetch. `aria` is omitted for the same reason it is
291+
// omitted on `record:details` above: it is an accessibility escape hatch, not
292+
// a layout choice.
293+
285294
ComponentRegistry.register('activity', RecordActivityRenderer, {
286295
namespace: 'record',
287296
skipFallback: true,
288297
category: 'record',
289298
label: 'Activity Timeline',
290299
icon: 'Activity',
300+
// Mirrors RecordActivityComponentProps (@object-ui/types), itself aligned
301+
// with @objectstack/spec RecordActivityProps.
302+
inputs: [
303+
{ name: 'types', type: 'array', label: 'Activity Types', description: 'Activity types to display (e.g. task, event, call, comment)' },
304+
{ name: 'filterMode', type: 'string', label: 'Filter Mode', description: 'How the type filter combines with the feed query' },
305+
{ name: 'showFilterToggle', type: 'boolean', label: 'Show Filter Toggle', description: 'Expose the activity-type filter UI' },
306+
{ name: 'limit', type: 'number', label: 'Limit', description: 'Maximum activities to display' },
307+
{ name: 'showCompleted', type: 'boolean', label: 'Show Completed', description: 'Include completed/resolved activities' },
308+
{ name: 'unifiedTimeline', type: 'boolean', label: 'Unified Timeline', description: 'Merge all activity types into one timeline' },
309+
{ name: 'showCommentInput', type: 'boolean', label: 'Show Comment Input' },
310+
{ name: 'enableMentions', type: 'boolean', label: 'Enable @mentions' },
311+
{ name: 'enableReactions', type: 'boolean', label: 'Enable Reactions' },
312+
{ name: 'enableThreading', type: 'boolean', label: 'Enable Threaded Replies' },
313+
{ name: 'showSubscriptionToggle', type: 'boolean', label: 'Show Subscribe Toggle' },
314+
],
291315
});
292316

317+
// `record:chatter` and `record:discussion` are the same renderer under two
318+
// names. The spec prefers `discussion` for new Lightning-style record pages;
319+
// `chatter` stays for Salesforce-familiar authors and for schemas already in
320+
// the wild. Both carry the same inputs — an author who reaches for either gets
321+
// the same configuration surface.
322+
const CHATTER_INPUTS: ComponentInput[] = [
323+
{ name: 'position', type: 'enum', label: 'Position', enum: ['bottom', 'right', 'left'], defaultValue: 'bottom', description: 'Where the panel docks relative to the record body' },
324+
{ name: 'width', type: 'string', label: 'Width', description: 'Panel width as a CSS value (side positions only)' },
325+
{ name: 'collapsible', type: 'boolean', label: 'Collapsible', defaultValue: false },
326+
{ name: 'defaultCollapsed', type: 'boolean', label: 'Start Collapsed' },
327+
{ name: 'feed', type: 'object', label: 'Feed Options', description: 'Activity-feed config nested inside the panel — same shape as record:activity' },
328+
];
329+
293330
ComponentRegistry.register('chatter', RecordChatterRenderer, {
294331
namespace: 'record',
295332
skipFallback: true,
296333
category: 'record',
297334
label: 'Chatter Feed',
298335
icon: 'MessageSquare',
336+
// Mirrors RecordChatterComponentProps (@object-ui/types).
337+
inputs: CHATTER_INPUTS,
299338
});
300339

301-
// `record:discussion` is the spec-compliant alias preferred for new
302-
// Lightning-style record pages. The two names render identically and
303-
// share the same DiscussionContext wiring; we keep `record:chatter`
304-
// for Salesforce-familiar authors and for backward compatibility with
305-
// schemas already in the wild.
306340
ComponentRegistry.register('discussion', RecordChatterRenderer, {
307341
namespace: 'record',
308342
skipFallback: true,
309343
category: 'record',
310344
label: 'Discussion',
311345
icon: 'MessageSquare',
346+
inputs: CHATTER_INPUTS,
312347
});
313348

314349
ComponentRegistry.register('path', RecordPathRenderer, {
@@ -330,6 +365,16 @@ ComponentRegistry.register('quick_actions', RecordQuickActionsRenderer, {
330365
category: 'record',
331366
label: 'Quick Actions',
332367
icon: 'Zap',
368+
inputs: [
369+
{ name: 'actionNames', type: 'array', label: 'Actions', description: 'Action names to expose, in order (else every action declared for the object at this location)' },
370+
{ name: 'requiredPermissions', type: 'array', label: 'Required Permissions', description: 'Hide the whole bar unless the user holds these permissions' },
371+
// Derived from the spec's own vocabulary rather than restated — #3019.
372+
{ name: 'location', type: 'enum', label: 'Location', enum: [...ACTION_LOCATIONS], defaultValue: 'record_header', description: 'Which declared action location this bar renders' },
373+
{ name: 'align', type: 'enum', label: 'Align', enum: ['start', 'center', 'end'], defaultValue: 'end' },
374+
{ name: 'inline', type: 'boolean', label: 'Inline', description: 'Render in the flow instead of folding into the record header' },
375+
{ name: 'variant', type: 'string', label: 'Button Variant', defaultValue: 'default', description: 'Passed to the Button primitive; a per-action variant overrides it' },
376+
{ name: 'size', type: 'string', label: 'Button Size', defaultValue: 'sm', description: 'Passed to the Button primitive; a per-action size overrides it' },
377+
],
333378
});
334379

335380
ComponentRegistry.register('history', RecordHistoryRenderer, {
@@ -338,6 +383,11 @@ ComponentRegistry.register('history', RecordHistoryRenderer, {
338383
category: 'record',
339384
label: 'History Timeline',
340385
icon: 'Clock',
386+
inputs: [
387+
{ name: 'limit', type: 'number', label: 'Limit', defaultValue: 50, description: 'Maximum history entries to display' },
388+
{ name: 'emptyText', type: 'string', label: 'Empty Text', description: 'Copy shown when the record has no history' },
389+
{ name: 'unknownUserText', type: 'string', label: 'Unknown User Text', description: 'Copy substituted when an entry has no resolvable actor' },
390+
],
341391
});
342392

343393
ComponentRegistry.register('reference_rail', RecordReferenceRailRenderer, {
@@ -346,6 +396,9 @@ ComponentRegistry.register('reference_rail', RecordReferenceRailRenderer, {
346396
category: 'record',
347397
label: 'Reference Rail',
348398
icon: 'PanelRight',
399+
inputs: [
400+
{ name: 'hideEmpty', type: 'boolean', label: 'Hide When Empty', defaultValue: true, description: 'Drop the rail entirely when no entries resolve' },
401+
],
349402
});
350403

351404
ComponentRegistry.register('alert', RecordAlertRenderer, {
@@ -354,6 +407,16 @@ ComponentRegistry.register('alert', RecordAlertRenderer, {
354407
category: 'record',
355408
label: 'Alert Banner',
356409
icon: 'AlertTriangle',
410+
inputs: [
411+
{ name: 'severity', type: 'enum', label: 'Severity', enum: ['info', 'warning', 'error', 'success'], defaultValue: 'info' },
412+
{ name: 'title', type: 'string', label: 'Title', description: 'Accepts an inline translation map ({ en, "zh-CN", … })' },
413+
{ name: 'body', type: 'string', label: 'Body', description: 'Accepts an inline translation map ({ en, "zh-CN", … })' },
414+
{ name: 'visible', type: 'string', label: 'Visible When', description: 'Expression gating the banner against the current record' },
415+
{ name: 'icon', type: 'string', label: 'Icon', description: 'Lucide icon name; defaults to the severity icon' },
416+
{ name: 'action', type: 'object', label: 'Call to Action', description: '{ actionName, label?, variant? } — the action the banner offers' },
417+
{ name: 'dismissible', type: 'boolean', label: 'Dismissible' },
418+
{ name: 'dismissKey', type: 'string', label: 'Dismiss Key', description: 'Stable key the dismissal is remembered under' },
419+
],
357420
});
358421

359422
// ADR-0056 P1 — the `permission-facet-link` field widget renders a

0 commit comments

Comments
 (0)