@object-ui/plugin-detail
v18.3.0
Published
DetailView plugin for ObjectUI - comprehensive detail page with sections, tabs, and related lists
Maintainers
Readme
@object-ui/plugin-detail
DetailView plugin for ObjectUI - A comprehensive detail page component with field grouping, tabs, related lists, and action buttons.
Features
- Field Grouping/Sections: Organize fields into logical sections with titles
- Collapsible Sections: Make sections collapsible to save space
- Tab Navigation: Organize content into tabs for better UX
- Related Lists: Display related records (e.g., contacts for an account) —
authored as a
record:related_listblock, ⛔ not as adetail-viewrelatedarray, which is retired (objectui#7997; see RelatedList below) - Action Buttons: Edit, Delete, and custom action buttons
- Readonly/Edit Mode: Toggle between view and edit modes
- Back Navigation: Built-in back button with customizable behavior
- Loading States: Skeleton loading for async data
- Custom Headers/Footers: Flexible customization options
Installation
pnpm add @object-ui/plugin-detailUsage
Two ways to reach a data source
detail-view needs a data source to load a record, and there are two ways to
give it one. They are equivalent, and an explicit prop wins when both are
present:
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { DetailView } from '@object-ui/plugin-detail';
import type { DataSource } from '@object-ui/types';
declare const dataSource: DataSource;
// 1. Injected by an ancestor — the pattern the rest of the family uses, and the
// one to reach for when a page renders several data-bound blocks.
const viaProvider = <SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={{ type: 'detail-view', objectName: 'account', resourceId: '42' }} />
</SchemaRendererProvider>;
// 2. Handed to one placement — what `<DetailView dataSource={…} />` has always
// taken, and what the examples below use.
const viaProp = <DetailView schema={{ type: 'detail-view', objectName: 'account', resourceId: '42' }} dataSource={dataSource} />;Route 1 is new as of objectui#5378: this block used to read the adapter from its
prop ONLY, while its siblings object-grid and object-form read it from
context only, so a page could satisfy one of them or the other and never both.
Nothing that worked before changed — route 2 is unaffected — and a block that
resolves neither now renders a No data source resolved panel naming itself
and the object it was about to read, instead of an empty shell.
Note the record id is resourceId here. object-form's is recordId; the two
blocks do not share that key.
The provenance footer belongs to the record page
RecordMetaFooter is the one-line "Created … Updated …" summary of a record's
audit fields. A standalone DetailView draws it under its body. On a
type: 'record' page the footer is the PAGE's: the page host draws it once for
the record, whatever the number of record:details nodes the page carries, and a
record:details node that reads the page's record draws none of its own. So a
page with two nodes shows the line once, and a node whose sections are all
hidden draws nothing. The console's record page (RecordDetailView in
@object-ui/app-shell) draws it at the page foot.
A host that mounts RecordContextProvider itself renders RecordMetaFooter
once, where its page ends, with the record as data and the object definition
as objectSchema. A host that draws a highlight strip also passes
drawnHighlightFields, the fields that strip actually draws: the meta line
leaves the owner out only when that list carries owner_id, so a shorter strip
(a phone's record header) never hides the owner from both places. Without it,
the footer takes the object's declared highlightFields as the strip. A record:details node with a dataSource binding (below)
shows a record the page does not, so it keeps its own footer.
Record blocks without a record page (dataSource)
record:details, record:highlights and record:quick_actions show the record
their record context carries: on a type: 'record' page, the page's record. On
any other page, the spec's per-element dataSource binding supplies it — the
block reads the FIRST row of the bound set by the binding's sort, under the
caller's own identity:
{
"type": "record:highlights",
"properties": { "fields": ["name", "environment_type"] },
"dataSource": { "object": "sys_environment", "sort": [{ "field": "created_at", "order": "desc" }], "limit": 1 }
}No row draws the block's authored emptyState when it declares one, and its
no-record state when it does not. record:quick_actions runs the object's
declared actions against the bound row's id. The binding resolves as on
every bound block (a named view, an AND-combined filter, its sort), and the
row is re-read when a write to its object is announced. Declare limit: 1:
objectui validate warns on a record block whose binding does not. See
Per-element data binding on a page
in the data-source guide. record:related_list's dataSource is
different: it binds the rows the list shows.
emptyState is the spec's empty-state shape, the one object-grid takes:
{ title, message, icon, link }. It goes in properties, beside the block's
other keys:
{
"type": "record:details",
"properties": {
"fields": ["region"],
"emptyState": {
"title": "No production environment",
"message": "Create one from the environments page.",
"icon": "server",
"link": { "label": "Open environments", "url": "/environments" }
}
},
"dataSource": { "object": "sys_environment", "limit": 1 }
}It is drawn in the block's place only when the binding's read settles with no
row. A read the server refuses as forbidden counts as no row. It is not drawn
while the read loads, in place of the error a failed read shows, when a row is
there, or on a block with no dataSource. icon is a Lucide name. title and
message are plain strings or inline locale maps, resolved against the display
locale. link is followed as a url action. Leave title out and the heading
reads "Record not found". Leave emptyState out and the block keeps its own
no-record state: "Record not found" on record:details, nothing on
record:highlights, and the empty bar on record:quick_actions.
A related list's PARENT record has a binding of its own, parentDataSource in
its properties (the spec row's member). It is read by the same one-record
rule — the first row by sort, re-read when its object is written — and the
list draws that record's children:
{
"type": "record:related_list",
"properties": {
"objectName": "sys_member",
"relationshipField": "environment_id",
"parentDataSource": { "object": "sys_environment", "sort": [{ "field": "created_at", "order": "desc" }], "limit": 1 }
}
}No row draws a notice in the block's place, never an empty list. Without the key, the parent is the record page's record.
A section heading that counts a field's values (count)
A record:details section can show how many values a multi-value field holds
on the current record. count.field names the field, and the heading writes the
{{ count }} hole where the number goes (@objectstack/spec 18,
RecordDetailsProps.sections[].count):
{
"type": "record:details",
"properties": {
"sections": [
{
"name": "tagging",
"label": "{{ count }} tags",
"count": { "field": "tags" },
"fields": ["tags"],
"collapsible": true,
"defaultCollapsed": true
}
]
}
}With three tags selected the heading reads 3 tags, collapsed or not. An absent
or empty value counts 0, and a field the viewer may not read counts as absent.
The hole is filled after the heading is translated, so each locale of an inline
label map and a translated objects.<object>._sections.<name>.label write the
same {{ count }}. count changes the heading only: with hideEmpty on (the
default), a section whose fields are all empty is still absent. The spec refuses
count beside group, on a section without label, and on a label with no
hole. A section without count prints its heading as written.
Empty fields and empty sections (hideEmpty)
A section that still has a filled field and enough empty ones folds the empty rows behind a Show N empty fields row; the reader can reveal them and fold them back. No authored value changes this.
A section whose fields are ALL empty is decided by hideEmpty. On
record:details it defaults to on, and such a section renders nothing at all.
hideEmpty: false keeps it, which a brand-new record needs:
{ "group": "narrative", "hideEmpty": false }In read-only mode a kept all-empty section draws its heading plus one
Show N empty fields row, the same fold a partly-filled section uses, and
not one placeholder row per field. In edit mode it draws every field as an
editable row. On detail-view and the detail-section node no default is
resolved, so an omitted key keeps the section the same way, and only true
hides it.
When every section of a body is empty and at least two are kept, the body draws ONE Show N empty fields row for all of them. Revealing it draws each section folded on its own. On a print page every declared row prints, empty ones included, with no toggle.
Basic Example
import { DetailView } from '@object-ui/plugin-detail';
function ContactDetail() {
return (
<DetailView
schema={{
type: 'detail-view',
title: 'Contact Details',
data: {
name: 'John Doe',
email: '[email protected]',
phone: '+1234567890',
company: 'Acme Corp',
},
fields: [
{ name: 'name', label: 'Full Name' },
{ name: 'email', label: 'Email' },
{ name: 'phone', label: 'Phone' },
{ name: 'company', label: 'Company' },
],
showBack: true,
showEdit: true,
showDelete: true,
}}
/>
);
}With Sections
import { DetailView } from '@object-ui/plugin-detail';
declare const accountData: Record<string, unknown>;
const accountDetail = <DetailView
schema={{
type: 'detail-view',
title: 'Account Details',
sections: [
{
title: 'Basic Information',
icon: '📋',
fields: [
{ name: 'name', label: 'Account Name' },
{ name: 'industry', label: 'Industry' },
{ name: 'website', label: 'Website' },
],
columns: 2,
},
{
title: 'Address',
collapsible: true,
defaultCollapsed: false,
fields: [
{ name: 'street', label: 'Street' },
{ name: 'city', label: 'City' },
{ name: 'state', label: 'State' },
{ name: 'zipcode', label: 'Zip Code' },
],
columns: 2,
},
],
data: accountData,
}}
/>;With Tabs
import { DetailView } from '@object-ui/plugin-detail';
declare const navigate: (url: string) => void;
declare const deleteAccount: (id: string) => void;
const accountDetail = <DetailView
schema={{
type: 'detail-view',
title: 'Account: Acme Corp',
objectName: 'accounts',
resourceId: '12345',
fields: [
{ name: 'name', label: 'Account Name' },
{ name: 'industry', label: 'Industry' },
],
tabs: [
{
key: 'details',
label: 'Details',
icon: '📄',
content: {
type: 'detail-section',
fields: [
{ name: 'description', label: 'Description' },
{ name: 'employees', label: 'Employee Count' },
],
},
},
{
key: 'activity',
label: 'Activity',
badge: '12',
// `record:activity` — the registered Activity Timeline block. Reachable
// under that exact key and no other; see the note below the block.
// Its inputs go in `properties`, as the spec declares them.
content: {
type: 'record:activity',
properties: { limit: 20, showCompleted: false },
},
},
],
showEdit: true,
showDelete: true,
}}
onEdit={() => navigate('/accounts/12345/edit')}
onDelete={() => deleteAccount('12345')}
onBack={() => navigate('/accounts')}
/>;A tab's content is an SDUI node, not a private vocabulary: DetailTabs
renders it with <SchemaRenderer schema={toRenderableSchema(tab.content)} />,
so content.type is resolved by the component registry and an unregistered
name paints the Unknown component type panel (OBJUI-001) instead of a
component. Author only types this repository registers.
Two things about the Activity tab above are worth copying rather than guessing
(objectui#8114 — it previously taught type: 'activity-timeline', which
nothing registers):
- The key is
record:activity, with the namespace spelled out. The registration insrc/index.tsxpasses the bare name under{ namespace: 'record', skipFallback: true }, andskipFallbackis what stops the bare name from also being claimed globally — sorecord:activityresolves andactivityresolves to nothing.activityis additionally a tab key in the example above; the two are unrelated. - The node declares the feed's filters, not the feed. Its inputs (
limit,showCompleted,types,filterMode, …) go in itspropertiesbag, and the block finds its own feed: a mounted discussion context, or a self-fetch fromsys_activityscoped to the recorduseRecordContextbinds. Both need a record host; a bare<DetailView>like the one above mounts neither, so there the tab draws the block's empty state ("No activity recorded"). Under a record host, such as the console's record page, the same node draws that record's activity.items(with itsloadingflag) is the host's feed slot, not a key of this node:@object-ui/typesrefuses it by name, anddatais not a key this block reads.
See The record:activity block in the plugin-detail guide for its declared
inputs (types, limit, filterMode, showCompleted, …).
Schema
The DetailView component accepts a DetailViewSchema, declared in
@object-ui/types. Import it rather than re-typing it — every key below is
checked against that declaration, so this example cannot drift from the package:
import type { DetailViewSchema } from '@object-ui/types';
const schema: DetailViewSchema = {
type: 'detail-view',
title: 'Account Details',
objectName: 'accounts',
resourceId: '12345',
api: '/api/accounts/12345',
data: undefined,
// Field name whose value becomes the header title; `title` is the fallback.
primaryField: 'name',
// Field values rendered as badges beside the header title.
summaryFields: ['industry', 'website'],
layout: 'vertical',
columns: 2,
// See the examples above for the shapes these three carry.
sections: [],
fields: [],
tabs: [],
actions: [],
// The view's own heading (title, follow-star, copy-id chip); drawn unless
// `false`. `record:details` sets it from its own `showHeader`.
showHeader: true,
showBack: true,
backUrl: '/accounts',
showEdit: true,
editUrl: '/accounts/12345/edit',
showDelete: true,
deleteConfirmation: 'Delete this account?',
loading: false,
header: { type: 'text', label: 'Custom header' },
footer: { type: 'text', label: 'Custom footer' },
};summaryFields and non-scalar values
A summary chip is a single-line pill, and it formats currency, date,
datetime, percent and the option families itself. Any other value it can
render as text it renders as text.
A percent chip is formatted by @object-ui/fields' formatPercent — the
same call the list cell makes — so one stored value reads the same beside the H1
as it does in a list: scaled at the storage the field declares (the spec's
percentScaleOf: a fraction unless the field declares a max above 1, read
through percentCellScale in @object-ui/fields, never guessed from the
value), rounded to the width resolveFieldScale (@objectstack/spec/data)
resolves for the field — its declared scale, or the protocol's own width for
a percent that declares none, which every percent face reads (objectui#9843) —
and rendered through the display locale's own percent affix rather than an
appended sign. A stored 1234.5 on a field declaring max: 100 therefore
reads 1,235% in an en session, and a stored 1 on a field declaring no
max reads 100%; both carry the locale's own affix and marks elsewhere. Which stored values that convention moves, and
what each reads in both places, is re-derived by
src/__tests__/summaryChip.percentConvention-9167.test.tsx rather than listed
here. The chip's bar keeps the unrounded magnitude, as the list cell's bar does.
An object value — an expanded lookup payload, an address, a location, a file — is drawn by that field's own cell renderer, so the chip shows what the same value shows everywhere else on the page: the referenced record's name, the formatted postal line, the coordinates, the file name.
Fifteen field kinds are the exception, because their renderer does not fit a
pill: the option families draw a badge (which would nest inside the chip's own),
user draws an avatar, image / avatar / signature draw an image and no
text at all, and boolean / toggle / date / datetime / repeater draw a
"No value" face for a value the chip only exists because the page called
filled. Those keep a text chip, carrying @object-ui/fields' shared value
coercion — the record's name where the object has one, and [Object] where it
does not. The set and the measurement behind it are
src/summaryChipRenderers.ts.
Components
RecordDetailPanel
The record overlay's payload, with no shell of its own: one
InlineEditProvider session wrapping DetailView plus the record-level
InlineEditSaveBar, and the objectSchema → typed-field derivation that feeds
them.
Every list-type renderer (ObjectGrid, ObjectTree, ObjectGantt,
ObjectKanban, ObjectCalendar, and since objectui#13458 ListView,
ObjectGallery, ObjectMap, ObjectTimeline) mounts this component through
NavigationOverlay from @object-ui/components, so an authored
navigation.mode: 'drawer' means the same thing on every view type
(objectui#9299; modal, split and popover were retired by
objectstack#26677). ⛔ Do not re-wrap it in a shell of
your own — that is exactly the fork this component exists to remove.
import { NavigationOverlay } from '@object-ui/components';
import { RecordDetailPanel } from '@object-ui/plugin-detail';
import { useNavigationOverlay } from '@object-ui/react';
import type { DataSource } from '@object-ui/types';
declare const dataSource: DataSource;
declare const objectSchema: { fields?: Record<string, unknown> };
declare function saveField(field: string, value: unknown): Promise<void>;
declare function deleteRecord(): Promise<void>;
function ContactsOverlay() {
const navigation = useNavigationOverlay({
navigation: { mode: 'drawer' },
objectName: 'contacts',
});
return (
<NavigationOverlay
{...navigation}
title="Record Detail"
>
{(record) => (
<RecordDetailPanel
record={record}
objectName="contacts"
recordId={String(record.id)}
dataSource={dataSource}
objectSchema={objectSchema}
onFieldSave={saveField}
onDelete={deleteRecord}
onClose={navigation.close}
/>
)}
</NavigationOverlay>
);
}Omit onFieldSave for a strictly read-only panel; omit onDelete for no
delete action.
Capability is handler presence, not a boolean. A caller that omits
onFieldSave gets a read-only panel and one that omits onDelete gets no
delete action — which is what lets a row locked through a renderer's lockField
open read-only without the panel knowing what a lock is.
…and a handler is a wiring, not a grant. The inline edit is the recordEdit
row of the affordance-to-grant map in @object-ui/core (objectui#12082), the row
the record page reads for the same in-place edit: the object's policy (read off
the objectSchema you pass), the effective API operation set and the caller's
update grant. When it is closed the panel opens no inline-edit session, as if
onFieldSave were absent; each field still asks its own field question. The
delete action is DetailView's own recordDelete read. With no permission
provider mounted both read open.
RECORD_OVERLAY_DEFAULT_WIDTH is exported alongside it and is the single code
home of the default overlay width repo-wide. ⛔ Do not re-spell the literal at a
call site.
RecordDetailDrawer
The same payload in the drawer shell — a published convenience wrapper that
delegates to NavigationOverlay in mode="drawer". It no longer owns a
Sheet, a drag-resize implementation, or a chrome header of its own
(objectui#9299).
A width a user had already dragged is persisted by the shell under
ov:drawer-width:<objectName>; a width left by the retired implementation under
objectui.drawerWidth.<objectName> is read once, moved forward and removed, so
existing widths carry over rather than reset.
DetailSection
Renders a group of fields with optional collapsing.
DetailTabs
Tab navigation for organizing content into different views.
RelatedList
Displays related records in list, grid, or table format.
Retired:
DetailViewSchema.related(objectui#7997, ADR-0049 enforce-or-remove). Author arecord:related_listblock instead.Until objectui#7997 a
detail-viewnode could carry its ownrelatedarray, and this README taught it. That array is retired under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-10). It was a second entry to a capability@objectstack/specalready governs — the protocol declares noDetailViewschema at all — so it mirrored nothing and drifted from the renderer it fed: it typedcolumnsasTableColumnobjects while the renderer also accepted bare field names. Authoring it is now refused by name on both the TypeScript and the JSON face, ⛔ not silently ignored.Nothing about the rendered result changed: both entries always went through the
RelatedListcomponent documented here. Only the second door closed.import { SchemaRenderer } from '@object-ui/react'; const contacts = ( <SchemaRenderer schema={{ type: 'record:related_list', properties: { objectName: 'contact', relationshipField: 'account_id', title: 'Contacts', columns: ['name', 'email', 'phone'], }, }} /> );⚠️
columnsis an array of field-name strings, which is what the protocol declares (RecordRelatedListProps.columns). The header comes from the related object's fieldlabeland the cell from the field's type, so a label rename reaches the list for free — the hand-spelled{ accessorKey, header }form the retired array taught froze both.relationshipFieldnames the field on the related object pointing back at this record, and replaces the retired form'sapiendpoint.⚠️ The block reads the parent record from the record page's
RecordContext, so author it on a record page. Placed anywhere it cannot resolve a parent id it scopes to nothing and renders an empty list — which is also what the retiredrelatedform did whenever it declaredapiwithout areferenceField.
Related lists are paged by default: the record:related_list renderer
applies the spec default limit of 5 when the node doesn't declare one
(@objectstack/spec RecordRelatedListProps.limit, "Number of records to
display initially"), so a child collection of any size renders as pages with
Previous/Next controls instead of one flat dump. On the auto-fetch path the
component asks the server for one page at a time ($top/$skip) and
reads the collection size from QueryResult.total (falling back to a
hasMore estimate), so large child tables never ship wholesale to the
browser. A user column sort becomes a server $orderby (ordering stays
global across pages), and the node's sort prop seeds the initial order.
Because that $orderby names a flat field, two kinds of column cannot survive
the trip. A relational column (lookup / master_detail / user / tree)
would order the collection by the stored foreign-key id while its cells show
related-record names (objectui#3096); a formula column has no
materialized column to order by at all — silently unordered rows under a 200
before objectstack#6994, a 400 INVALID_SORT after it (objectui#3950). So on
the windowed path neither offers a sort affordance, at either of this card's two
sort entry points: the embedded table's column headers, and the button row a
list card keeps because it has no headers. In client mode the sort key is the
value the cell shows — the label this list already resolved, the formula result
the server hydrated — so the affordance stays and orders by that.
Passing data directly keeps the historical client-side slicing, and typing
in the opt-in filter box temporarily falls back to the full-fetch client
pipeline (the contains-filter sweeps the columns the list shows, which no
generic server filter can express).
A password / secret column draws ••••••, and the list flags it
masked on the table it renders (objectui#10657), so the table never copies it
on Ctrl+C / Cmd+C, shows it as a tooltip, sorts by it from its header, or sizes
it by its value. The rule is isMaskedFieldType() from @object-ui/fields,
read over the column's authored type and the object's field type, so an
authored type: 'text' over a secret field keeps the flag. While the object
definition is still loading, or after its read failed, the list cannot tell
which columns are masked, so it flags every one and draws every cell it would
draw from that definition as the mask, never as text; it keeps doing so when
the read failed (objectui#10657). A column with a cell of your own draws what
your cell returns.
The list's own filter box and sort read the same flag (objectui#10728). The
opt-in filter box matches a term against the columns the list shows that are
not masked, so a masked value, and a field no column shows, never keeps a row.
A masked column gets no sort button on a list card, and a sort by it is
refused (including one the embedded table asks for, which a server-paged list
would send as $orderby). In a list that sorts in the browser, a sort set on a
column before it was flagged also stops ordering the rows. While the definition
is loading, or after its read failed, every column is flagged: the filter box
keeps no row and no sort button is offered. Not covered: on a server-paged
list, an authored sort naming a masked field is still sent to the server as
$orderby, and so is a sort the user set on a column before that column was
flagged, with each page the list fetches, until the user sorts by another
column. The column is flagged after the sort only when the columns or the
object of a list already on screen change; record:related_list sets both
once per block.
A list that authors columns sends a $select projection on its
auto-fetch (objectui#10186), the one ListView and ObjectGrid already send
(objectui#6898). It asks for the authored columns that pass the same gates the
column layer draws through (redaction, the parent key, field-level security),
plus id, the $expand roots, and, once the child schema has loaded, the
fields the row predicates read (the child object's userActions Edit/Delete
overrides, its actions, and the host's row actions), plus the mobile card
gallery's cover field when declared and readable. A column the principal
cannot read is dropped once the permission answer has loaded; a request sent
before that answer can still carry it, the same deferral ObjectGrid has.
If every authored column is denied, the list asks for id and its predicate
operands, not for everything. On a backend that honours $select, rows then
carry only the projected fields, so the opt-in filter box above sweeps those.
A list without authored columns derives them from highlightFields or
the field walk, and both choose by the emptiness of the rows fetched. That list sends no projection, and its column layer alone keeps a
denied field off screen. Against ObjectStack's server the projection is defence
in depth, because the server already strips denied fields from every row.
The node's filter (spec RecordRelatedListProps.filter, "additional filter
criteria") narrows the list beyond the parent relationship: it is
AND-combined with the parent-relationship condition, never substituted for
it, so a related list stays scoped to the record it appears on and an additional
criterion can only ever narrow that set. That condition is not one fixed
spelling — it is compiled to match the relationship field's ARITY on the
child object (objectui#7299). A single-valued relationship gets the equality
form { [relationshipField]: parentId }; a multi-valued one gets the membership
form { [relationshipField]: { $contains: parentId } }, because the stored
value is an array of ids and equality would ask whether the whole array is one
id. The arity verdict is @objectstack/spec/data's own isMultiValueField,
never a rule this package keeps locally. Authors write filter in the spec's own
vocabulary ([{ field, operator, value }]); a dataSource binding's composed
filter (component AND saved view AND binding) lands on the same key. Both are
lowered to ObjectQL through the repo's single filter sink, so no second dialect
appears.
A third producer writes that same key: the relationship itself. A lookup /
master_detail field may declare relatedListFilter (added to @objectstack/spec
in objectstack#8704 / PR #8955 — see this package's own @objectstack/spec
dependency in package.json for the version in use), a canonical Query-DSL
FilterCondition such as
{ status: { $ne: 'deleted' } }. RecordDetailView derives the record page's
related lists from the relationship graph and carries that value onto this node's
filter, so an auto-derived page honours it without anyone authoring a page
(objectui#4664). It is an authored constraint, not a user-editable suggestion —
it never becomes a filter-bar row — and it lands on the existing key rather than
a new one, so all three producers share one read site and one lowering.
The tab badge honours the same composed filter. page:tabs auto-derives a
Related tab's count from the record:related_list nodes underneath it, and that
probe reads this node's filter and composes it with the parent scope through
the same mergeFilterNodes sink the row query uses — one value, sent twice. That
is what keeps the badge from saying 7 above a list showing 3; it is normative in
the spec key's own contract text, not a nicety. Counts are cached per
(object, relationship, parent, scope), so a filtered and an unfiltered probe
over the same relationship never badge each other. On the legacy raw-URL fallback path (no dataSource adapter, where the
query language is filter[<field>]=<value> and cannot carry an operator) a
declared filter is refused with a console explanation rather than dropped —
answering with more rows than the metadata asked for is the failure this key's
wiring exists to remove.
The Add affordance renders only where every link in its chain is available:
a spec-valid add.picker.object and a dataSource. The picker dialog and the
add callback both required the adapter already, so without it the button used to
render and do nothing at all when clicked — visible in hosts that supply no
RecordContext (Studio designer previews, context-free embeds).
…and only where the caller may make the write it ends in (objectui#12082). With
a linkField the picked records become link rows of the list's own object: the
create grant there, the relatedNew row of the affordance-to-grant map in
@object-ui/core. Without one the picked record is re-parented: the update
grant on add.picker.object, the relatedRowEdit row. The remove that the
record:related_list renderer falls back to when an add config is present and
no host handed over a delete reads the list object's relatedRowDelete row, the
row the host's own delete reads, so it never puts back a delete the host
withheld. With no permission provider mounted these read open.
The record:related_list renderer is automatically gated on the current
user's object-level read permission for the child object: when the
permission system (@object-ui/permissions) is loaded and denies read,
the whole section renders nothing — no header, no empty grid, no "New"
button that would be rejected server-side. With no PermissionProvider
mounted (Studio designer, standalone embeds) the gate stays open.
A page author withdraws New from one related list by authoring
actions: [] on its record:related_list node — the author's choice of no
actions on that list, the generic create form included. Use it where the
children must be created through a process (a renewal through its flow, a
version through upload). It holds per list, whatever the child object's
userActions.create says; every list that does not author [] keeps the
host's verdict. A non-empty actions list leaves New as it was, and the row
affordances (View, Edit, Delete) are not governed by this key.
{
"type": "record:related_list",
"properties": { "objectName": "contract", "relationshipField": "renewed_from", "actions": [] }
}InlineEditSaveBar & InlineFieldInput
The record-level inline-edit session (#2407): double-clicking a field (or its
hover pencil) in the highlights strip or details body stages edits into ONE
shared draft (InlineEditProvider from @object-ui/react), committed by
<InlineEditSaveBar> as a single atomic OCC-guarded update. Polish shipped in
#2572:
- Editors:
InlineFieldInputroutes every field type to the same widget the form uses — includingnumber/currency/percent(numeric keyboard,min/max/stepfrom metadata, fraction↔percent conversion) and reference pickers, which receive$expand-ed record objects as-is so the display name renders without a hydration re-fetch. - Keyboard shortcuts: while the session is active, Esc cancels (open
popovers/dialogs keep owning Escape for "close") and Cmd/Ctrl+Enter
saves; both respect the in-flight
savingandlockedstates. - Approval lock: hosts pass
locked/lockedHintto the save bar and gateInlineEditProvider.canEditwhen the record is approval-locked, so a locked record hides its edit affordances instead of rejecting at Save. - Approval band & recall (#6464): the band reads
approvalPending/approvalProgressfrom the same provider, and offers its Recall button only where the click can succeed. Recall is the submitter's lever — the server authorizes it on submitter identity and refuses everyone else — so hosts that resolve approvals passInlineEditProvider.approvalIsSubmitter. It is tri-state on purpose:falsewithdraws the button from a resolved non-submitter,truekeeps it, and omitting it leaves the button exactly as it was — a host that resolves no approval identity is unchanged rather than losing its submitter's only unlock lever. It gates the affordance only; the recall endpoint remains the authority on who may actually recall. - Answered approvals read (#12220): a host whose approvals read answered
(a 2xx) passes
InlineEditProvider.approvalReadAnswered. Itslocked/approvalPendingare then the whole answer, and the band stops falling back to the record'sapproval_statuscolumn: a composition without the approvals service answers the read with an empty list and enforces no lock, so a stalependingin the column no longer shows a lock with nothing to recall. Omitting it keeps the column fallback exactly as before. Hosts and the band decide through one rule,resolveApprovalLockfrom@object-ui/react.
Links
One record page, laid out by its width
The synthesized default record page (buildDefaultPageSchema, and a slotted
page that keeps the default tab strip) is laid out by the width of the view it
occupies — the full page, the split pane, the overlay drawer, the maximized
pane, a phone — by one rule, never by the viewport:
| Container width | Layout | |---|---| | 1120 px and up | Details · the right column (380 px). A details section takes up to three field columns; the 动态 panel shows five entries | | 760 – 1119 px | Details · the right column (320 px). A details section takes up to two field columns; the 动态 panel shows three entries | | below 760 px | One column: the details sections, then the right column's panels in their order |
The right column holds the panels, each the same shape (a title and a count;
the header row opens the panel's whole face, the host's ?related=): first
动态 (Activity) — one stream of the discussion, the activity and, with
enable.trackHistory, the field changes, shown as a one-line composer, the
newest entries and 「查看全部 N 条 →」, which opens the stream in full with its
All / Activity / Discussion / Changes chips and paging (?related=timeline) —
then each related list in the reader's arrangement, then Approvals and
Attachments. The reader's ⊟ (rightColumn) takes the right column out of the
two-column tiers; there is no tabbed tier.
A tab row closes the record header in every tier (RecordTabRow): 「概览」
(Overview), then 动态, then one tab per related panel in the reader's
arrangement, each with its count. A tab is its panel's whole face: choosing it
draws the full list (every column, paging, sort, inline edit) or the full
stream below the row, with the header unchanged, and the URL carries it
(?related=<object>.<field>, ?related=timeline); 「概览」 or Esc returns. The
row shows as many tabs as its own width fits, measured on every resize, and
folds the rest into 「+N ▾」, which lists them with their counts and ends with
the hidden panels' tabs. 「概览」, at least one more tab and the active tab are
always on the row.
The related panels load counts first and rows on demand (RelatedCardStack).
Every panel's count is asked in one request set, and each panel is a
placeholder until the set has answered. A panel counted at 0 reads no rows. A
panel above 0 reads its first three rows, projected to the name field and one
status or date field, once it is in view (IntersectionObserver). The details
column never waits for any of it. RecordPageColumns takes relatedFieldsOf,
the child objects' field definitions the host already holds, so the counts
read no child schema first; prefetchRelatedCounts and
relatedCountTargetsIn warm another record's counts, which the console's
drawer does for the record ↑ / ↓ would show next.
One header (title, actions, highlights, stage path) serves every tier, and
the page description stays the tabbed one: its blocks are read off it by each
tab's stable value (buildDefaultPageColumns), so Studio's page-create
seed, a slotted page and the running page never disagree, and no metadata key
or node type says "columns". An authored record page (kind: 'full') is drawn
as authored at every width, and so is a page whose strip the partition cannot
read; ?tab= addresses those strips. A slotted page keeps the synthesized
strip, so it takes the tiers.
A host wires it with three exports:
import * as React from 'react';
import { SchemaRenderer } from '@object-ui/react';
import { RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, useRecordPageTier } from '@object-ui/plugin-detail';
import type { ObjectDefLike } from '@object-ui/plugin-detail';
declare const objectDef: ObjectDefLike;
export function RecordPage() {
// The element the record page occupies: its width picks the tier.
const [root, setRoot] = React.useState<HTMLDivElement | null>(null);
const page = buildDefaultPageSchema(objectDef);
// `null` until the first reading lands, so nothing is drawn for that one commit.
// Pass `enabled: false` for an authored page, which is drawn as authored.
const tier = useRecordPageTier(root, { enabled: true });
const columns = buildDefaultPageColumns(page);
return (
<div ref={setRoot}>
{columns ? (
tier === null ? null : <RecordPageColumns tier={tier} columns={columns} />
) : (
<SchemaRenderer schema={page} />
)}
</div>
);
}Reference Rail on an authored page
The "Reference Rail" is a right-hand column of summary cards for related
collections, drawn by the record:reference_rail component. The synthesized
default page never emits it: its right column already shows every related
list as a compact panel (see One record page, laid out by its width above). The
buildDefaultPageSchema options that used to opt into the rail
(showReferenceRail, hideReferenceRail, hideRelatedTab) and the rightRail
slot were removed with the other surface hints ADR-0085 does not admit
(objectstack#27055), so the rail's one route is an authored node.
An authored Page places it in an aside region after the main region:
const asideRegion = {
name: 'aside',
width: 'small',
components: [
{
type: 'record:reference_rail',
id: 'opp_reference_rail',
properties: {
entries: [
{ objectName: 'quote', relationshipField: 'opportunity', title: 'Quotes', limit: 3 },
{ objectName: 'opportunity_line_item', relationshipField: 'opportunity', title: 'Products', limit: 3 },
{ objectName: 'task', relationshipField: 'related_to_opportunity', title: 'Open Tasks', limit: 3 },
],
},
},
],
};The entries go in the node's properties bag, as the example shows: that
bag is the contract, and objectui validate refuses a flat entries written
on the node itself, naming properties.entries.
License
MIT — see LICENSE.
