npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@object-ui/plugin-detail

v18.3.0

Published

DetailView plugin for ObjectUI - comprehensive detail page with sections, tabs, and related lists

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_list block, ⛔ not as a detail-view related array, 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-detail

Usage

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 in src/index.tsx passes the bare name under { namespace: 'record', skipFallback: true }, and skipFallback is what stops the bare name from also being claimed globally — so record:activity resolves and activity resolves to nothing. activity is 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 its properties bag, and the block finds its own feed: a mounted discussion context, or a self-fetch from sys_activity scoped to the record useRecordContext binds. 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 its loading flag) is the host's feed slot, not a key of this node: @object-ui/types refuses it by name, and data is 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 a record:related_list block instead.

Until objectui#7997 a detail-view node could carry its own related array, 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/spec already governs — the protocol declares no DetailView schema at all — so it mirrored nothing and drifted from the renderer it fed: it typed columns as TableColumn objects 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 RelatedList component 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'],
      },
    }}
  />
);

⚠️ columns is an array of field-name strings, which is what the protocol declares (RecordRelatedListProps.columns). The header comes from the related object's field label and 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. relationshipField names the field on the related object pointing back at this record, and replaces the retired form's api endpoint.

⚠️ 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 retired related form did whenever it declared api without a referenceField.

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: InlineFieldInput routes every field type to the same widget the form uses — including number / currency / percent (numeric keyboard, min/max/step from 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 saving and locked states.
  • Approval lock: hosts pass locked / lockedHint to the save bar and gate InlineEditProvider.canEdit when 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 / approvalProgress from 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 pass InlineEditProvider.approvalIsSubmitter. It is tri-state on purpose: false withdraws the button from a resolved non-submitter, true keeps 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. Its locked / approvalPending are then the whole answer, and the band stops falling back to the record's approval_status column: a composition without the approvals service answers the read with an empty list and enforces no lock, so a stale pending in 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, resolveApprovalLock from @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.