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
47 changes: 47 additions & 0 deletions .changeset/record-blocks-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@object-ui/plugin-detail": minor
"@object-ui/core": minor
"@object-ui/console": patch
---

feat(record): declare inputs for the seven configurable record:\* blocks, and curate six

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. objectui#3013 recorded them as deliberately uncurated for
exactly that reason.

The declarations mirror what each renderer actually reads:

| block | inputs |
|---|---|
| `record:activity` | 11 — from `RecordActivityComponentProps` |
| `record:chatter` / `record:discussion` | 5 — from `RecordChatterComponentProps` |
| `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 objectui#3019.

Six of the seven are now in `PUBLIC_BLOCKS`: configurable and absent from the
contract is the state objectui#3006 was about. The contract goes 36 → 42 tags,
all resolving.

`record:chatter` stays out — it is the 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 objectui#3006 opened, pointed the other way.
63 changes: 42 additions & 21 deletions apps/console/src/__tests__/public-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ const EXPECTED_COVERED = [
'record:related_list',
'record:path',
'record:line_items',
'record:activity',
'record:discussion',
'record:history',
'record:quick_actions',
'record:reference_rail',
'record:alert',
'flex',
'grid',
'stack',
Expand Down Expand Up @@ -161,25 +167,23 @@ describe('console ↔ PUBLIC_BLOCKS coverage', () => {

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

describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => {
Expand Down Expand Up @@ -220,17 +224,34 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => {
}
});

it('keeps the deliberately-uncurated blocks unconfigurable', () => {
// The stated reason for every exclusion is "nothing to author". If one of
// these grows `inputs`, it became authorable and the exclusion needs
// re-deciding rather than inheriting.
for (const tag of Object.keys(DELIBERATELY_UNCURATED)) {
// A pending lazy stub reports `inputs: undefined` meaning "not known
// yet", which would make the assertion below vacuous. These are eager
// registrations in plugin-detail; pin that, so the day one goes lazy this
// fails loudly instead of silently passing.
it('keeps the chatter alias identical to the block it aliases', () => {
// `record:chatter` is excluded because it duplicates `record:discussion`,
// not because it is lesser. The moment the two configuration surfaces
// diverge, that reasoning stops holding: `chatter` would be its own block
// kept out of the vocabulary, which is the state this whole file exists to
// catch. Comparing inputs is what makes the exclusion falsifiable.
//
// Both are eager registrations, asserted first — a pending lazy stub
// reports `inputs: undefined` meaning "not known yet", which would make the
// comparison below pass vacuously on two blanks.
for (const tag of ['record:chatter', 'record:discussion']) {
expect(ComponentRegistry.getConfig(tag)).toBeDefined();
expect(ComponentRegistry.getMeta(tag)?.inputs ?? []).toEqual([]);
}
const chatter = ComponentRegistry.getMeta('record:chatter')?.inputs;
expect(chatter?.length).toBeGreaterThan(0);
expect(chatter).toEqual(ComponentRegistry.getMeta('record:discussion')?.inputs);
});

it('declares inputs for every curated record:* block', () => {
// What objectui#3006 cost was a configurable block sitting outside the
// contract. The inverse is just as bad for an authoring model: a curated
// tag with no declared inputs can only be emitted bare, so it reads as
// "this block takes no configuration" when the renderer in fact reads
// props. Curation and a configuration surface travel together.
const curatedRecordBlocks = PUBLIC_BLOCKS.filter((tag) => tag.startsWith(`${NS}:`));
expect(curatedRecordBlocks.length).toBeGreaterThan(0);
for (const tag of curatedRecordBlocks) {
expect(contract.get(tag)?.inputs ?? []).not.toEqual([]);
}
});

Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/registry/public-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ export const PUBLIC_BLOCKS: readonly string[] = [
// it was listed under here never existed — the block has shipped all along
// but could never resolve through the contract (objectui#2953 follow-up).
'record:line_items',
// Configurable as of objectui#3023 follow-up: these shipped with renderers
// but no declared `inputs`, so a model could only emit them bare — which is
// why they sat outside the contract. Their inputs now mirror what the
// renderers actually read, so they are authorable and belong here.
//
// `record:chatter` is deliberately NOT in this list: it is the 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 has no way to resolve, so the vocabulary carries the spec's name.
'record:activity',
'record:discussion',
'record:history',
'record:quick_actions',
'record:reference_rail',
'record:alert',
// ── Tier B — layout / content primitives ──────────────────────────────────
'flex',
'grid',
Expand Down
75 changes: 69 additions & 6 deletions packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/

import { ComponentRegistry } from '@object-ui/core';
import { ComponentRegistry, type ComponentInput } from '@object-ui/core';
import { DetailView } from './DetailView';
import { DetailSection } from './DetailSection';
import { DetailTabs } from './DetailTabs';
Expand All @@ -23,6 +23,7 @@ import { RecordReferenceRailRenderer } from './renderers/record-reference-rail';
import { RecordAlertRenderer } from './renderers/record-alert';
import { PermissionFacetLink } from './renderers/PermissionFacetLink';
import type { DetailViewSchema } from '@object-ui/types';
import { ACTION_LOCATIONS } from '@object-ui/types';

export { DetailView, DetailSection, DetailTabs, RelatedList };
export {
Expand Down Expand Up @@ -282,33 +283,67 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, {
],
});

// `inputs` on the blocks below 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 (RecordDetailView and friends) off
// RecordContext — declaring those would invite a model to hand-write data the
// page is supposed to fetch. `aria` is omitted for the same reason it is
// omitted on `record:details` above: it is an accessibility escape hatch, not
// a layout choice.

ComponentRegistry.register('activity', RecordActivityRenderer, {
namespace: 'record',
skipFallback: true,
category: 'record',
label: 'Activity Timeline',
icon: 'Activity',
// Mirrors RecordActivityComponentProps (@object-ui/types), itself aligned
// with @objectstack/spec RecordActivityProps.
inputs: [
{ name: 'types', type: 'array', label: 'Activity Types', description: 'Activity types to display (e.g. task, event, call, comment)' },
{ name: 'filterMode', type: 'string', label: 'Filter Mode', description: 'How the type filter combines with the feed query' },
{ name: 'showFilterToggle', type: 'boolean', label: 'Show Filter Toggle', description: 'Expose the activity-type filter UI' },
{ name: 'limit', type: 'number', label: 'Limit', description: 'Maximum activities to display' },
{ name: 'showCompleted', type: 'boolean', label: 'Show Completed', description: 'Include completed/resolved activities' },
{ name: 'unifiedTimeline', type: 'boolean', label: 'Unified Timeline', description: 'Merge all activity types into one timeline' },
{ name: 'showCommentInput', type: 'boolean', label: 'Show Comment Input' },
{ name: 'enableMentions', type: 'boolean', label: 'Enable @mentions' },
{ name: 'enableReactions', type: 'boolean', label: 'Enable Reactions' },
{ name: 'enableThreading', type: 'boolean', label: 'Enable Threaded Replies' },
{ name: 'showSubscriptionToggle', type: 'boolean', label: 'Show Subscribe Toggle' },
],
});

// `record:chatter` and `record:discussion` are the same renderer under two
// names. The spec prefers `discussion` for new Lightning-style record pages;
// `chatter` stays for Salesforce-familiar authors and for schemas already in
// the wild. Both carry the same inputs — an author who reaches for either gets
// the same configuration surface.
const CHATTER_INPUTS: ComponentInput[] = [
{ name: 'position', type: 'enum', label: 'Position', enum: ['bottom', 'right', 'left'], defaultValue: 'bottom', description: 'Where the panel docks relative to the record body' },
{ name: 'width', type: 'string', label: 'Width', description: 'Panel width as a CSS value (side positions only)' },
{ name: 'collapsible', type: 'boolean', label: 'Collapsible', defaultValue: false },
{ name: 'defaultCollapsed', type: 'boolean', label: 'Start Collapsed' },
{ name: 'feed', type: 'object', label: 'Feed Options', description: 'Activity-feed config nested inside the panel — same shape as record:activity' },
];

ComponentRegistry.register('chatter', RecordChatterRenderer, {
namespace: 'record',
skipFallback: true,
category: 'record',
label: 'Chatter Feed',
icon: 'MessageSquare',
// Mirrors RecordChatterComponentProps (@object-ui/types).
inputs: CHATTER_INPUTS,
});

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

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

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

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

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

// ADR-0056 P1 — the `permission-facet-link` field widget renders a
Expand Down
Loading