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
28 changes: 28 additions & 0 deletions .changeset/record-header-multi-primary-buttons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@object-ui/components": minor
"@object-ui/app-shell": minor
---

feat(page-header): metadata-driven multi-button record header (#2361)

The record detail page header no longer hardcodes a single inline primary
button (`INLINE_MAX = 1`). It now renders up to `maxVisible` actions
side-by-side (default 3 desktop / 1 mobile, overridable via
`maxVisible` / `mobileMaxVisible` on the `page:header` schema) — the same
contract as `action:bar` — so multi-action objects (e.g. Lead: Convert /
Assign / Return) can surface several primary buttons at once.

Which actions claim the inline slots is declared in metadata, mirroring the
`action:bar` #2339 rules:

- `order` ascending (unset = 0; lower = more prominent), stable sort;
- `variant: 'primary'` as a tie-break within equal order (also mapped to the
Shadcn `default` Button variant instead of leaking through);
- `component: 'action:menu'` pins an action inside the `⋯` overflow menu
regardless of the action count.

The synthesized system actions declare their placement accordingly:
`sys_edit` gets `order: 100` (behind every authored business action, but
still inline when slots remain), while `sys_share` / `sys_delete` are pinned
into the `⋯` menu via `component: 'action:menu'` — Delete never surfaces as
an inline red button just because an object has few actions.
35 changes: 35 additions & 0 deletions content/docs/guide/slotted-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,41 @@ renderer suppresses itself the same way on hand-authored pages). The server
always enforced data access — this keeps the UI from rendering an empty
grid with a "New" button that would be rejected on save.

## Header actions: inline vs. overflow

`page:header` renders the record's `record_header` actions (authored
business actions plus the host-injected system set — Edit / Share /
Delete) as a button row. Up to **`maxVisible`** actions render inline,
side by side (default **3** on desktop, **`mobileMaxVisible`**, default
**1**, on mobile); the rest collapse into a `⋯` "More actions" menu.

Which actions claim the inline slots is declared in metadata, using the
same rules as `action:bar`:

- **`order`** (number, default `0`) — actions are stable-sorted
ascending before the split, so a lower `order` promotes an action
into the inline slots and a higher `order` pushes it toward the `⋯`
menu. The synthesized system actions use `order: 100+`, so authored
business actions outrank them by default.
- **`variant: 'primary'`** — tie-break within equal `order`: a primary
action outranks its unordered siblings without needing an explicit
`order`.
- **`component: 'action:menu'`** — pins the action inside the `⋯` menu
regardless of how few actions exist (the synthesized Share / Delete
use this).

```ts
slots: {
header: {
type: 'page:header',
properties: {
title: '{name}',
maxVisible: 4, // allow four inline buttons on this page
},
},
},
```

## Composing default + custom

When you want "the default actions plus one custom button," you have
Expand Down
13 changes: 13 additions & 0 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1626,6 +1626,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
// System actions (Edit / Share / Delete) — synthesized for every record
// page so objects without authored record_header actions still surface
// the basic affordances.
//
// Placement metadata (objectui#2361): the page header now renders up to
// `maxVisible` inline buttons sorted by `order` (lower = more prominent).
// Authored business actions default to `order: 0`, so giving the system
// set high orders (100+) keeps them behind every business action, and
// `component: 'action:menu'` pins Share/Delete inside the `⋯` overflow
// menu permanently — Delete must never surface as an inline red button
// just because an object has few actions.
const synthSystemActions: ActionDef[] = (() => {
const affordances = resolveCrudAffordances(objectDef as any);
const items: ActionDef[] = [];
Expand All @@ -1641,6 +1649,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
type: 'script',
locations: ['record_header'],
variant: 'default',
order: 100,
onClick: () => onEdit({ id: pureRecordId }),
} as any);
}
Expand All @@ -1650,6 +1659,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
type: 'script',
locations: ['record_header'],
variant: 'outline',
order: 110,
component: 'action:menu',
onClick: async () => {
try {
if ((navigator as any).share) {
Expand Down Expand Up @@ -1684,6 +1695,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
type: 'script',
locations: ['record_header'],
variant: 'destructive',
order: 120,
component: 'action:menu',
onClick: async () => {
const msg = t('detail.deleteConfirmation', {
defaultValue: 'Are you sure you want to delete this record?',
Expand Down
111 changes: 107 additions & 4 deletions packages/components/src/__tests__/page-header-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,11 @@ describe('PageHeaderRenderer — actions slot', () => {
],
},
);
// With 4 actions, the first stays inline; the rest collapse into a `⋯`
// overflow menu. We assert the primary is rendered as a button and the
// overflow trigger is present (full menu contents are exercised by
// separate dropdown interaction tests).
// With 4 actions and the default maxVisible of 3, the first three stay
// inline and the rest collapse into a `⋯` overflow menu. We assert the
// authored action is rendered as a button and the overflow trigger is
// present (full menu contents are exercised by separate dropdown
// interaction tests).
expect(screen.getByRole('button', { name: /Convert Lead/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
});
Expand Down Expand Up @@ -220,3 +221,105 @@ describe('PageHeaderRenderer — actions slot', () => {
expect(screen.getByRole('button', { name: /编辑/i })).toBeTruthy();
});
});

describe('PageHeaderRenderer — inline/overflow split (objectui#2361)', () => {
it('renders up to three actions side-by-side with no overflow menu', () => {
renderHeader({
type: 'page:header',
actions: [
{ name: 'convert', label: 'Convert Lead' },
{ name: 'assign', label: 'Assign' },
{ name: 'return', label: 'Return' },
],
});
expect(screen.getByRole('button', { name: /Convert Lead/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Assign/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Return/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /More actions/i })).toBeNull();
});

it('overflows past the default maxVisible of 3', () => {
renderHeader({
type: 'page:header',
actions: [
{ name: 'a', label: 'Action A' },
{ name: 'b', label: 'Action B' },
{ name: 'c', label: 'Action C' },
{ name: 'd', label: 'Action D' },
],
});
expect(screen.getByRole('button', { name: /Action C/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /Action D/i })).toBeNull();
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
});

it('honors a schema-level maxVisible override', () => {
renderHeader({
type: 'page:header',
maxVisible: 1,
actions: [
{ name: 'a', label: 'Action A' },
{ name: 'b', label: 'Action B' },
],
});
expect(screen.getByRole('button', { name: /Action A/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /Action B/i })).toBeNull();
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
});

it('reads maxVisible from properties (spec bridge variant)', () => {
renderHeader({
type: 'page:header',
properties: {
maxVisible: 4,
actions: [
{ name: 'a', label: 'Action A' },
{ name: 'b', label: 'Action B' },
{ name: 'c', label: 'Action C' },
{ name: 'd', label: 'Action D' },
],
},
});
expect(screen.getByRole('button', { name: /Action D/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /More actions/i })).toBeNull();
});

it('promotes a lower `order` into the inline slots (#2339 rule)', () => {
renderHeader({
type: 'page:header',
maxVisible: 1,
actions: [
{ name: 'a', label: 'Action A' },
{ name: 'b', label: 'Action B', order: -1 },
],
});
expect(screen.getByRole('button', { name: /Action B/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /Action A/i })).toBeNull();
});

it('prefers variant: primary as a tie-break within equal order', () => {
renderHeader({
type: 'page:header',
maxVisible: 1,
actions: [
{ name: 'a', label: 'Action A' },
{ name: 'b', label: 'Action B', variant: 'primary' },
],
});
expect(screen.getByRole('button', { name: /Action B/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /Action A/i })).toBeNull();
});

it('pins component: action:menu actions into the overflow menu even below maxVisible', () => {
renderHeader({
type: 'page:header',
actions: [
{ name: 'convert', label: 'Convert Lead' },
{ name: 'sys_delete', label: 'Delete', component: 'action:menu' },
],
});
expect(screen.getByRole('button', { name: /Convert Lead/i })).toBeTruthy();
expect(screen.queryByRole('button', { name: /^Delete$/i })).toBeNull();
expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy();
});
});
59 changes: 47 additions & 12 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useRecordContext, useAction, usePredicateScope, usePageVariables } from
import { renderChildren, cn } from '../../lib/utils';
import { LazyIcon } from '../../lib/lazy-icon';
import { RelatedCountStore, useRelatedCountVersion } from '../../hooks/related-count-store';
import { useIsMobile } from '../../hooks/use-mobile';
import {
Tabs,
TabsList,
Expand Down Expand Up @@ -755,6 +756,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
// action was silently filtered out.
const predicateScope = usePredicateScope();
const { execute } = useAction();
const isMobile = useIsMobile();
const { objectLabel: tObjectLabel, actionLabel: tActionLabel } = useObjectLabel();
const { fieldOptionLabel } = useSafeFieldLabel();
const { language } = useObjectTranslation();
Expand Down Expand Up @@ -897,7 +899,24 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
if (key) seen.add(key);
out.push(a);
}
return out;
// Order the merged list before the inline/overflow split — the same rule
// action:bar applies (objectui#2339):
// 1. `order` ascending (unset = 0; lower = more prominent)
// 2. `variant === 'primary'` as a tie-break within equal order
// 3. original registration order (stable) for the remaining ties
// This is what lets metadata declare which actions claim the inline
// button slots vs. the `⋯` overflow menu (objectui#2361).
const needsOrdering = out.some(
(a) => a?.order !== undefined || a?.variant === 'primary',
);
if (!needsOrdering) return out;
return [...out].sort((a, b) => {
const byOrder = (a?.order ?? 0) - (b?.order ?? 0);
if (byOrder !== 0) return byOrder;
const ap = a?.variant === 'primary' ? 0 : 1;
const bp = b?.variant === 'primary' ? 0 : 1;
return ap - bp; // equal → stable sort preserves registration order
});
}, [rawHeaderActions, hostSystemActions, ctx?.data, predicateScope]);

const renderHeaderActions = () => {
Expand All @@ -911,15 +930,29 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
if (!key) return fallback;
return tActionLabel(ctx?.objectName, key, fallback);
};
// Collapse secondary actions into a `⋯` overflow menu when more than 2
// actions are present. The first 1 action (typically the primary
// business action, e.g. "克隆商机") stays inline; everything else
// becomes a dropdown item. This keeps the header from drowning in 4–5
// buttons (Clone + Edit + Share + Delete + …) on every record page.
const INLINE_MAX = 1;
const useOverflow = headerActions.length > INLINE_MAX + 1;
const inlineActions = useOverflow ? headerActions.slice(0, INLINE_MAX) : headerActions;
const overflowActions = useOverflow ? headerActions.slice(INLINE_MAX) : [];
// Inline/overflow split (objectui#2361) — up to `maxVisible` actions
// render side-by-side as buttons; the rest collapse into a `⋯` overflow
// menu. Which actions stay inline is metadata-driven:
// 1. `component: 'action:menu'` forces an action into the `⋯` menu
// regardless of the count (chrome actions like Share/Delete);
// 2. the remainder is pre-sorted by `order` / `variant: 'primary'`
// (see the headerActions memo above), so the first `maxVisible`
// claim the inline slots.
// `maxVisible` / `mobileMaxVisible` are overridable on the page:header
// schema and default to 3 / 1 — the same contract as action:bar. This
// still keeps the header from drowning in 4–5 buttons on every record
// page, while letting multi-action objects surface several primary
// buttons at once.
const readMax = (v: any): number | undefined =>
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
const maxVisible = isMobile
? (readMax(schema?.mobileMaxVisible ?? schema?.properties?.mobileMaxVisible) ?? 1)
: (readMax(schema?.maxVisible ?? schema?.properties?.maxVisible) ?? 3);
const menuOnly = headerActions.filter((a: any) => a?.component === 'action:menu');
const buttonable = headerActions.filter((a: any) => a?.component !== 'action:menu');
const inlineActions = buttonable.slice(0, maxVisible);
const overflowActions = [...buttonable.slice(maxVisible), ...menuOnly];
const useOverflow = overflowActions.length > 0;
// Resolve a `disabled` predicate against the record. Mirrors the `visible`
// evaluation above — a boolean OR a CEL expression (`'record.status == …'`
// or the `{ dialect, source }` envelope). Without this a CEL `disabled`
Expand All @@ -941,7 +974,9 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
};
const renderButton = (action: any, idx: number) => {
const label = resolveLabel(action, idx);
const variant = action.variant || 'default';
// `variant: 'primary'` is valid ActionSchema but not a Shadcn Button
// variant — map it to `default` (same as action-button.tsx).
const variant = action.variant === 'primary' ? 'default' : (action.variant || 'default');
const size = action.size || 'sm';
const disabled = resolveDisabled(action.disabled);
const icon = typeof action.icon === 'string' ? action.icon : null;
Expand Down Expand Up @@ -986,7 +1021,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{overflowActions.map((action, idx) => {
const label = resolveLabel(action, idx + INLINE_MAX);
const label = resolveLabel(action, idx + inlineActions.length);
const disabled = resolveDisabled(action.disabled);
const icon = typeof action.icon === 'string' ? action.icon : null;
const isDestructive =
Expand Down
Loading