Skip to content

Commit 6c0135c

Browse files
os-zhuangclaude
andauthored
feat(page-header): metadata-driven multi-button record header (#2361) (#2574)
The record detail page header hardcoded INLINE_MAX=1 — only one action could ever render as an inline button; everything else collapsed into the ⋯ overflow menu, so multi-action objects (Lead: Convert / Assign / Return) could not surface their common operations side by side. The header now renders up to maxVisible inline buttons (default 3 desktop / 1 mobile, overridable via maxVisible / mobileMaxVisible on the page:header schema) and decides which actions claim the inline slots from metadata, mirroring action:bar's #2339 contract: - order ascending (unset = 0; lower = more prominent), stable sort - variant: 'primary' as a tie-break within equal order (and mapped to the Shadcn default Button variant instead of leaking through) - component: 'action:menu' pins an action inside the ⋯ menu regardless of the action count The synthesized system actions declare their placement accordingly: sys_edit gets order: 100 (behind every authored business action, still inline when slots remain); sys_share / sys_delete are pinned into the ⋯ menu via component: 'action:menu' so Delete never surfaces as an inline red button just because an object has few actions. Closes #2361 Claude-Session: https://claude.ai/code/session_01AvDRpozQS6m3UaWpGBtdG2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 092bd85 commit 6c0135c

5 files changed

Lines changed: 230 additions & 16 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@object-ui/components": minor
3+
"@object-ui/app-shell": minor
4+
---
5+
6+
feat(page-header): metadata-driven multi-button record header (#2361)
7+
8+
The record detail page header no longer hardcodes a single inline primary
9+
button (`INLINE_MAX = 1`). It now renders up to `maxVisible` actions
10+
side-by-side (default 3 desktop / 1 mobile, overridable via
11+
`maxVisible` / `mobileMaxVisible` on the `page:header` schema) — the same
12+
contract as `action:bar` — so multi-action objects (e.g. Lead: Convert /
13+
Assign / Return) can surface several primary buttons at once.
14+
15+
Which actions claim the inline slots is declared in metadata, mirroring the
16+
`action:bar` #2339 rules:
17+
18+
- `order` ascending (unset = 0; lower = more prominent), stable sort;
19+
- `variant: 'primary'` as a tie-break within equal order (also mapped to the
20+
Shadcn `default` Button variant instead of leaking through);
21+
- `component: 'action:menu'` pins an action inside the `` overflow menu
22+
regardless of the action count.
23+
24+
The synthesized system actions declare their placement accordingly:
25+
`sys_edit` gets `order: 100` (behind every authored business action, but
26+
still inline when slots remain), while `sys_share` / `sys_delete` are pinned
27+
into the `` menu via `component: 'action:menu'` — Delete never surfaces as
28+
an inline red button just because an object has few actions.

content/docs/guide/slotted-pages.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,41 @@ renderer suppresses itself the same way on hand-authored pages). The server
8181
always enforced data access — this keeps the UI from rendering an empty
8282
grid with a "New" button that would be rejected on save.
8383

84+
## Header actions: inline vs. overflow
85+
86+
`page:header` renders the record's `record_header` actions (authored
87+
business actions plus the host-injected system set — Edit / Share /
88+
Delete) as a button row. Up to **`maxVisible`** actions render inline,
89+
side by side (default **3** on desktop, **`mobileMaxVisible`**, default
90+
**1**, on mobile); the rest collapse into a `` "More actions" menu.
91+
92+
Which actions claim the inline slots is declared in metadata, using the
93+
same rules as `action:bar`:
94+
95+
- **`order`** (number, default `0`) — actions are stable-sorted
96+
ascending before the split, so a lower `order` promotes an action
97+
into the inline slots and a higher `order` pushes it toward the ``
98+
menu. The synthesized system actions use `order: 100+`, so authored
99+
business actions outrank them by default.
100+
- **`variant: 'primary'`** — tie-break within equal `order`: a primary
101+
action outranks its unordered siblings without needing an explicit
102+
`order`.
103+
- **`component: 'action:menu'`** — pins the action inside the `` menu
104+
regardless of how few actions exist (the synthesized Share / Delete
105+
use this).
106+
107+
```ts
108+
slots: {
109+
header: {
110+
type: 'page:header',
111+
properties: {
112+
title: '{name}',
113+
maxVisible: 4, // allow four inline buttons on this page
114+
},
115+
},
116+
},
117+
```
118+
84119
## Composing default + custom
85120

86121
When you want "the default actions plus one custom button," you have

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,6 +1626,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
16261626
// System actions (Edit / Share / Delete) — synthesized for every record
16271627
// page so objects without authored record_header actions still surface
16281628
// the basic affordances.
1629+
//
1630+
// Placement metadata (objectui#2361): the page header now renders up to
1631+
// `maxVisible` inline buttons sorted by `order` (lower = more prominent).
1632+
// Authored business actions default to `order: 0`, so giving the system
1633+
// set high orders (100+) keeps them behind every business action, and
1634+
// `component: 'action:menu'` pins Share/Delete inside the `⋯` overflow
1635+
// menu permanently — Delete must never surface as an inline red button
1636+
// just because an object has few actions.
16291637
const synthSystemActions: ActionDef[] = (() => {
16301638
const affordances = resolveCrudAffordances(objectDef as any);
16311639
const items: ActionDef[] = [];
@@ -1641,6 +1649,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
16411649
type: 'script',
16421650
locations: ['record_header'],
16431651
variant: 'default',
1652+
order: 100,
16441653
onClick: () => onEdit({ id: pureRecordId }),
16451654
} as any);
16461655
}
@@ -1650,6 +1659,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
16501659
type: 'script',
16511660
locations: ['record_header'],
16521661
variant: 'outline',
1662+
order: 110,
1663+
component: 'action:menu',
16531664
onClick: async () => {
16541665
try {
16551666
if ((navigator as any).share) {
@@ -1684,6 +1695,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
16841695
type: 'script',
16851696
locations: ['record_header'],
16861697
variant: 'destructive',
1698+
order: 120,
1699+
component: 'action:menu',
16871700
onClick: async () => {
16881701
const msg = t('detail.deleteConfirmation', {
16891702
defaultValue: 'Are you sure you want to delete this record?',

packages/components/src/__tests__/page-header-actions.test.tsx

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,11 @@ describe('PageHeaderRenderer — actions slot', () => {
186186
],
187187
},
188188
);
189-
// With 4 actions, the first stays inline; the rest collapse into a `⋯`
190-
// overflow menu. We assert the primary is rendered as a button and the
191-
// overflow trigger is present (full menu contents are exercised by
192-
// separate dropdown interaction tests).
189+
// With 4 actions and the default maxVisible of 3, the first three stay
190+
// inline and the rest collapse into a `⋯` overflow menu. We assert the
191+
// authored action is rendered as a button and the overflow trigger is
192+
// present (full menu contents are exercised by separate dropdown
193+
// interaction tests).
193194
expect(screen.getByRole('button', { name: /Convert Lead/i })).toBeTruthy();
194195
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
195196
});
@@ -220,3 +221,105 @@ describe('PageHeaderRenderer — actions slot', () => {
220221
expect(screen.getByRole('button', { name: //i })).toBeTruthy();
221222
});
222223
});
224+
225+
describe('PageHeaderRenderer — inline/overflow split (objectui#2361)', () => {
226+
it('renders up to three actions side-by-side with no overflow menu', () => {
227+
renderHeader({
228+
type: 'page:header',
229+
actions: [
230+
{ name: 'convert', label: 'Convert Lead' },
231+
{ name: 'assign', label: 'Assign' },
232+
{ name: 'return', label: 'Return' },
233+
],
234+
});
235+
expect(screen.getByRole('button', { name: /Convert Lead/i })).toBeTruthy();
236+
expect(screen.getByRole('button', { name: /Assign/i })).toBeTruthy();
237+
expect(screen.getByRole('button', { name: /Return/i })).toBeTruthy();
238+
expect(screen.queryByRole('button', { name: /More actions/i })).toBeNull();
239+
});
240+
241+
it('overflows past the default maxVisible of 3', () => {
242+
renderHeader({
243+
type: 'page:header',
244+
actions: [
245+
{ name: 'a', label: 'Action A' },
246+
{ name: 'b', label: 'Action B' },
247+
{ name: 'c', label: 'Action C' },
248+
{ name: 'd', label: 'Action D' },
249+
],
250+
});
251+
expect(screen.getByRole('button', { name: /Action C/i })).toBeTruthy();
252+
expect(screen.queryByRole('button', { name: /Action D/i })).toBeNull();
253+
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
254+
});
255+
256+
it('honors a schema-level maxVisible override', () => {
257+
renderHeader({
258+
type: 'page:header',
259+
maxVisible: 1,
260+
actions: [
261+
{ name: 'a', label: 'Action A' },
262+
{ name: 'b', label: 'Action B' },
263+
],
264+
});
265+
expect(screen.getByRole('button', { name: /Action A/i })).toBeTruthy();
266+
expect(screen.queryByRole('button', { name: /Action B/i })).toBeNull();
267+
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
268+
});
269+
270+
it('reads maxVisible from properties (spec bridge variant)', () => {
271+
renderHeader({
272+
type: 'page:header',
273+
properties: {
274+
maxVisible: 4,
275+
actions: [
276+
{ name: 'a', label: 'Action A' },
277+
{ name: 'b', label: 'Action B' },
278+
{ name: 'c', label: 'Action C' },
279+
{ name: 'd', label: 'Action D' },
280+
],
281+
},
282+
});
283+
expect(screen.getByRole('button', { name: /Action D/i })).toBeTruthy();
284+
expect(screen.queryByRole('button', { name: /More actions/i })).toBeNull();
285+
});
286+
287+
it('promotes a lower `order` into the inline slots (#2339 rule)', () => {
288+
renderHeader({
289+
type: 'page:header',
290+
maxVisible: 1,
291+
actions: [
292+
{ name: 'a', label: 'Action A' },
293+
{ name: 'b', label: 'Action B', order: -1 },
294+
],
295+
});
296+
expect(screen.getByRole('button', { name: /Action B/i })).toBeTruthy();
297+
expect(screen.queryByRole('button', { name: /Action A/i })).toBeNull();
298+
});
299+
300+
it('prefers variant: primary as a tie-break within equal order', () => {
301+
renderHeader({
302+
type: 'page:header',
303+
maxVisible: 1,
304+
actions: [
305+
{ name: 'a', label: 'Action A' },
306+
{ name: 'b', label: 'Action B', variant: 'primary' },
307+
],
308+
});
309+
expect(screen.getByRole('button', { name: /Action B/i })).toBeTruthy();
310+
expect(screen.queryByRole('button', { name: /Action A/i })).toBeNull();
311+
});
312+
313+
it('pins component: action:menu actions into the overflow menu even below maxVisible', () => {
314+
renderHeader({
315+
type: 'page:header',
316+
actions: [
317+
{ name: 'convert', label: 'Convert Lead' },
318+
{ name: 'sys_delete', label: 'Delete', component: 'action:menu' },
319+
],
320+
});
321+
expect(screen.getByRole('button', { name: /Convert Lead/i })).toBeTruthy();
322+
expect(screen.queryByRole('button', { name: /^Delete$/i })).toBeNull();
323+
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
324+
});
325+
});

packages/components/src/renderers/layout/containers.tsx

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useRecordContext, useAction, usePredicateScope, usePageVariables } from
2323
import { renderChildren, cn } from '../../lib/utils';
2424
import { LazyIcon } from '../../lib/lazy-icon';
2525
import { RelatedCountStore, useRelatedCountVersion } from '../../hooks/related-count-store';
26+
import { useIsMobile } from '../../hooks/use-mobile';
2627
import {
2728
Tabs,
2829
TabsList,
@@ -755,6 +756,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
755756
// action was silently filtered out.
756757
const predicateScope = usePredicateScope();
757758
const { execute } = useAction();
759+
const isMobile = useIsMobile();
758760
const { objectLabel: tObjectLabel, actionLabel: tActionLabel } = useObjectLabel();
759761
const { fieldOptionLabel } = useSafeFieldLabel();
760762
const { language } = useObjectTranslation();
@@ -897,7 +899,24 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
897899
if (key) seen.add(key);
898900
out.push(a);
899901
}
900-
return out;
902+
// Order the merged list before the inline/overflow split — the same rule
903+
// action:bar applies (objectui#2339):
904+
// 1. `order` ascending (unset = 0; lower = more prominent)
905+
// 2. `variant === 'primary'` as a tie-break within equal order
906+
// 3. original registration order (stable) for the remaining ties
907+
// This is what lets metadata declare which actions claim the inline
908+
// button slots vs. the `⋯` overflow menu (objectui#2361).
909+
const needsOrdering = out.some(
910+
(a) => a?.order !== undefined || a?.variant === 'primary',
911+
);
912+
if (!needsOrdering) return out;
913+
return [...out].sort((a, b) => {
914+
const byOrder = (a?.order ?? 0) - (b?.order ?? 0);
915+
if (byOrder !== 0) return byOrder;
916+
const ap = a?.variant === 'primary' ? 0 : 1;
917+
const bp = b?.variant === 'primary' ? 0 : 1;
918+
return ap - bp; // equal → stable sort preserves registration order
919+
});
901920
}, [rawHeaderActions, hostSystemActions, ctx?.data, predicateScope]);
902921

903922
const renderHeaderActions = () => {
@@ -911,15 +930,29 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
911930
if (!key) return fallback;
912931
return tActionLabel(ctx?.objectName, key, fallback);
913932
};
914-
// Collapse secondary actions into a `⋯` overflow menu when more than 2
915-
// actions are present. The first 1 action (typically the primary
916-
// business action, e.g. "克隆商机") stays inline; everything else
917-
// becomes a dropdown item. This keeps the header from drowning in 4–5
918-
// buttons (Clone + Edit + Share + Delete + …) on every record page.
919-
const INLINE_MAX = 1;
920-
const useOverflow = headerActions.length > INLINE_MAX + 1;
921-
const inlineActions = useOverflow ? headerActions.slice(0, INLINE_MAX) : headerActions;
922-
const overflowActions = useOverflow ? headerActions.slice(INLINE_MAX) : [];
933+
// Inline/overflow split (objectui#2361) — up to `maxVisible` actions
934+
// render side-by-side as buttons; the rest collapse into a `⋯` overflow
935+
// menu. Which actions stay inline is metadata-driven:
936+
// 1. `component: 'action:menu'` forces an action into the `⋯` menu
937+
// regardless of the count (chrome actions like Share/Delete);
938+
// 2. the remainder is pre-sorted by `order` / `variant: 'primary'`
939+
// (see the headerActions memo above), so the first `maxVisible`
940+
// claim the inline slots.
941+
// `maxVisible` / `mobileMaxVisible` are overridable on the page:header
942+
// schema and default to 3 / 1 — the same contract as action:bar. This
943+
// still keeps the header from drowning in 4–5 buttons on every record
944+
// page, while letting multi-action objects surface several primary
945+
// buttons at once.
946+
const readMax = (v: any): number | undefined =>
947+
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
948+
const maxVisible = isMobile
949+
? (readMax(schema?.mobileMaxVisible ?? schema?.properties?.mobileMaxVisible) ?? 1)
950+
: (readMax(schema?.maxVisible ?? schema?.properties?.maxVisible) ?? 3);
951+
const menuOnly = headerActions.filter((a: any) => a?.component === 'action:menu');
952+
const buttonable = headerActions.filter((a: any) => a?.component !== 'action:menu');
953+
const inlineActions = buttonable.slice(0, maxVisible);
954+
const overflowActions = [...buttonable.slice(maxVisible), ...menuOnly];
955+
const useOverflow = overflowActions.length > 0;
923956
// Resolve a `disabled` predicate against the record. Mirrors the `visible`
924957
// evaluation above — a boolean OR a CEL expression (`'record.status == …'`
925958
// or the `{ dialect, source }` envelope). Without this a CEL `disabled`
@@ -941,7 +974,9 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
941974
};
942975
const renderButton = (action: any, idx: number) => {
943976
const label = resolveLabel(action, idx);
944-
const variant = action.variant || 'default';
977+
// `variant: 'primary'` is valid ActionSchema but not a Shadcn Button
978+
// variant — map it to `default` (same as action-button.tsx).
979+
const variant = action.variant === 'primary' ? 'default' : (action.variant || 'default');
945980
const size = action.size || 'sm';
946981
const disabled = resolveDisabled(action.disabled);
947982
const icon = typeof action.icon === 'string' ? action.icon : null;
@@ -986,7 +1021,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
9861021
</DropdownMenuTrigger>
9871022
<DropdownMenuContent align="end" className="w-44">
9881023
{overflowActions.map((action, idx) => {
989-
const label = resolveLabel(action, idx + INLINE_MAX);
1024+
const label = resolveLabel(action, idx + inlineActions.length);
9901025
const disabled = resolveDisabled(action.disabled);
9911026
const icon = typeof action.icon === 'string' ? action.icon : null;
9921027
const isDestructive =

0 commit comments

Comments
 (0)