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
24 changes: 24 additions & 0 deletions .changeset/8464-summary-chip-object-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@object-ui/plugin-detail': patch
---

The record page's summary chips beside the H1 no longer render an object-valued
field as the literal text `[object Object]`.

`effectiveSummaryFields`' chip displayed `String(val)` with only currency, date,
datetime, percent and the option families formatted, so an expanded lookup
payload, a location or an address printed the placeholder next to the page
title — and, because the chip's accessible name is built from that same string,
in its accessible name too.

An object value is now drawn by the field's own cell renderer, the way the
highlights strip one band below already reads it: a lookup chip shows the
referenced record's name, an address chip its formatted postal line, a location
chip its coordinates. Fifteen field kinds whose renderer does not fit a pill —
the option families (a badge inside a badge), `user` (an avatar), the image
family (no text at all), and the kinds that draw a "No value" face for a value
the page has just called filled — keep a text chip and take
`@object-ui/fields`' shared value coercion instead.

Values that already rendered are untouched, and the chip's emptiness
classification is unchanged.
21 changes: 21 additions & 0 deletions packages/plugin-detail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,27 @@ const schema: DetailViewSchema = {
};
```

### `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.

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

### DetailSection
Expand Down
77 changes: 77 additions & 0 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ import { useLocalization, resolveFieldCurrency } from '@object-ui/i18n';
import type { DetailViewSchema, DataSource, ActionSchema, SchemaNode } from '@object-ui/types';
import { useDetailTranslation } from './useDetailTranslation';
import { useRecordEditable } from './useRecordEditable';
import { getCellRenderer, resolveCellRendererType, coerceToSafeValue } from '@object-ui/fields';
import { hasCellValue } from './emptiness';
import { enrichDetailField } from './fieldEnrichment';
import { chipTakesCellRenderer } from './summaryChipRenderers';

/** Default page size for related lists in the detail view */
const DEFAULT_RELATED_PAGE_SIZE = 5;
Expand Down Expand Up @@ -1108,13 +1111,86 @@ export const DetailView: React.FC<DetailViewProps> = ({
} catch {
/* fall back to String(val) */
}

// ── The chip's STRING path cannot express an object ───────
//
// `String({…})` is the literal `[object Object]`, and it
// reached the reader twice: as the chip's text beside the H1
// and, because the accessible name is built from the same
// string, as the chip's accessible name (objectui#8464).
// Every branch above lands here too — `Number({})` is `NaN`,
// `new Date({})` is Invalid, and the option lookup falls back
// to `String(val)` — so the four formatted families are
// caught by this one test rather than by four of their own.
//
// The test is the DEFECT'S OWN SIGNATURE, not a type guess:
// it fires exactly where the placeholder was produced, so a
// value the string path already renders (`['a','b']` →
// `a,b`, every scalar) is byte-for-byte untouched.
//
// ⭐ Which side this chip is on was MEASURED, not argued.
// objectui#8395 established on this page that "render what
// the user sees" and "render the underlying value" give
// different answers per kind. This chip already answers the
// FIRST question for every family it formats — it prints
// `$1,235` for a stored `1234.5`, `Mar 4, 2026` for
// `'2026-03-04'`, `Closed Won` for `'won'` — so the display
// authority is the field's own cell renderer, exactly as
// `HeaderHighlight` reads it one band below.
//
// ⚠️ …but only where a pill can host it. A Badge is a much
// smaller surface than a cell: 15 of the 53 registered types
// draw a nested pill, an avatar composite, a bare `<img>`
// with no text, or a "No value" face for a value
// `hasCellValue` just called FILLED. Those kinds are named,
// with the measurement, in `./summaryChipRenderers`, and they
// take `coerceToSafeValue` — this package's single answer to
// the same question, and byte-equal to what seven of them
// print in their own cell (objectui#8596).
const chipField = enrichDetailField(
{ name: fieldName, label: sectionField?.label, type: ftype || 'text' },
objField,
);
const chipRendererType =
resolveCellRendererType(chipField as any) || ftype || 'text';
const stringPathFailed = display.includes('[object Object]');
const ChipCellRenderer =
stringPathFailed && chipTakesCellRenderer(chipRendererType)
? getCellRenderer(chipRendererType)
: null;
if (stringPathFailed && !ChipCellRenderer) {
display = String(coerceToSafeValue(val) ?? '');
}

if (ChipCellRenderer) {
return (
<Badge
key={fieldName}
variant="secondary"
className="text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15"
data-summary-chip={fieldName}
>
{/* The chip carries no visible label, so the field name
reached the reader only through the `aria-label`
that the string branches below still set. A renderer
draws an ELEMENT, and an `aria-label` would override
it — hiding the very value this branch exists to
show. Same accessible name, `field: value`, composed
from content instead. */}
<span className="sr-only">{`${fieldName}: `}</span>
<ChipCellRenderer value={val} field={chipField as any} />
</Badge>
);
}

if (percentValue !== null) {
return (
<Badge
key={fieldName}
variant="secondary"
className="text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15 gap-1.5 pl-2 pr-2"
aria-label={`${fieldName}: ${display}`}
data-summary-chip={fieldName}
>
<span
className="relative inline-block h-1.5 w-12 rounded-full bg-primary/20 overflow-hidden"
Expand All @@ -1135,6 +1211,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
variant="secondary"
className="text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15"
aria-label={`${fieldName}: ${display}`}
data-summary-chip={fieldName}
>
{display}
</Badge>
Expand Down
Loading
Loading