Skip to content

Commit a649dbe

Browse files
Merge pull request #1247 from objectstack-ai/copilot/fix-more-buttons-issue
fix(plugin-detail): collapse duplicate record-header overflow menus into one
2 parents da8544c + 4393464 commit a649dbe

6 files changed

Lines changed: 335 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Record detail header** no longer renders two separate "More" (⋯) overflow
13+
menus when an object defines more `record_header` actions than
14+
`maxVisible`. The hardcoded `<DropdownMenu>` inside
15+
`@object-ui/plugin-detail`'s `DetailView` has been removed; its contents
16+
(Duplicate, Export, View History, Delete, plus mobile-only Share / Edit /
17+
Inline Edit fallbacks) are now emitted as `ActionSchema` entries and
18+
funnelled through the record-header `action:bar` via its new
19+
`systemActions` field. At most **one** overflow menu is rendered per bar,
20+
regardless of how many business actions the object metadata contributes.
21+
1022
### Changed
1123

24+
- **`action:bar` schema** now accepts `systemActions?: ActionSchema[]`
25+
(`@object-ui/components`). System/chrome actions are always placed in the
26+
overflow menu (never inline) and share the same `` trigger with any
27+
business-action overflow. A visual separator is automatically inserted
28+
between business and system groups.
29+
- **`ActionSchema`** (`@object-ui/types`) exposes an optional UI-local
30+
`onClick?: () => void | Promise<void>` escape hatch. `action:menu`
31+
short-circuits to `onClick` when present, bypassing the ActionEngine.
32+
This is intended for chrome-level callbacks (e.g., opening the native
33+
Share sheet, toggling inline-edit mode) that depend on React state and
34+
are not part of the server-driven action protocol.
35+
1236
- **Console home page (`/home`)** now uses a top navigation bar (`HomeTopNav`)
1337
instead of the left `UnifiedSidebar`. This visually separates the workspace
1438
landing page from individual applications (which still use `AppShell` +

packages/components/src/__tests__/action-bar.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,78 @@ describe('ActionBar (action:bar)', () => {
167167
});
168168
});
169169

170+
describe('systemActions', () => {
171+
it('renders a single overflow menu when only systemActions are provided', () => {
172+
const { container } = renderComponent({
173+
type: 'action:bar',
174+
systemActions: [
175+
{ name: 'sys_duplicate', label: 'Duplicate', type: 'script' },
176+
{ name: 'sys_export', label: 'Export', type: 'script' },
177+
],
178+
});
179+
const toolbar = container.querySelector('[role="toolbar"]');
180+
expect(toolbar).toBeTruthy();
181+
// 0 inline buttons + 1 overflow menu trigger
182+
expect(toolbar!.children.length).toBe(1);
183+
});
184+
185+
it('merges business overflow and systemActions into ONE overflow menu', () => {
186+
const { container } = renderComponent({
187+
type: 'action:bar',
188+
maxVisible: 2,
189+
actions: [
190+
{ name: 'biz1', label: 'Biz 1', type: 'script' },
191+
{ name: 'biz2', label: 'Biz 2', type: 'script' },
192+
{ name: 'biz3', label: 'Biz 3', type: 'script' },
193+
{ name: 'biz4', label: 'Biz 4', type: 'script' },
194+
],
195+
systemActions: [
196+
{ name: 'sys_duplicate', label: 'Duplicate', type: 'script' },
197+
{ name: 'sys_delete', label: 'Delete', type: 'script' },
198+
],
199+
});
200+
const toolbar = container.querySelector('[role="toolbar"]');
201+
// 2 inline buttons + exactly 1 overflow menu trigger — never two
202+
expect(toolbar!.children.length).toBe(3);
203+
// No business-action overflow was rendered as a separate menu
204+
const menus = toolbar!.querySelectorAll('[aria-haspopup]');
205+
expect(menus.length).toBe(1);
206+
});
207+
208+
it('systemActions never appear inline regardless of maxVisible', () => {
209+
const { container } = renderComponent({
210+
type: 'action:bar',
211+
maxVisible: 10,
212+
actions: [
213+
{ name: 'biz1', label: 'Biz 1', type: 'script' },
214+
],
215+
systemActions: [
216+
{ name: 'sys_duplicate', label: 'Duplicate', type: 'script' },
217+
],
218+
});
219+
const toolbar = container.querySelector('[role="toolbar"]');
220+
// 1 inline business button + 1 overflow menu for the system action
221+
expect(toolbar!.children.length).toBe(2);
222+
// The system action label is not inline
223+
const inlineButtons = toolbar!.querySelectorAll(':scope > button:not([aria-haspopup]), :scope > [role="button"]:not([aria-haspopup])');
224+
const inlineText = Array.from(inlineButtons).map(b => b.textContent).join(' ');
225+
expect(inlineText).not.toContain('Duplicate');
226+
});
227+
228+
it('renders overflow menu when only systemActions exist even with empty actions', () => {
229+
const { container } = renderComponent({
230+
type: 'action:bar',
231+
actions: [],
232+
systemActions: [
233+
{ name: 'sys_history', label: 'History', type: 'script' },
234+
],
235+
});
236+
const toolbar = container.querySelector('[role="toolbar"]');
237+
expect(toolbar).toBeTruthy();
238+
expect(toolbar!.children.length).toBe(1);
239+
});
240+
});
241+
170242
describe('styling', () => {
171243
it('applies custom className', () => {
172244
const { container } = renderComponent({

packages/components/src/renderers/action/action-bar.tsx

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,19 @@ import { useIsMobile } from '../../hooks/use-mobile';
4040

4141
export interface ActionBarSchema {
4242
type: 'action:bar';
43-
/** Actions to render */
43+
/** Business actions to render — subject to inline/overflow split via {@link maxVisible} */
4444
actions?: ActionSchema[];
45+
/**
46+
* System/chrome actions (Duplicate, Export, View History, Delete, etc.) that
47+
* are *always* placed in the overflow menu — never inline — regardless of
48+
* {@link maxVisible}. They share a single overflow button with any business
49+
* actions that spilled past {@link maxVisible}, guaranteeing at most one
50+
* "More" menu per bar.
51+
*
52+
* The first system action is automatically separated from business-overflow
53+
* entries by a menu separator.
54+
*/
55+
systemActions?: ActionSchema[];
4556
/** Filter actions by this location */
4657
location?: ActionLocation;
4758
/** Maximum visible inline actions before overflow into "More" menu (default: 3) */
@@ -70,13 +81,29 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
7081
'data-obj-type': dataObjType,
7182
style,
7283
data,
84+
// Strip schema metadata props that are consumed via `schema.*` and
85+
// must NOT be spread onto the underlying DOM element (avoids React
86+
// "unknown DOM attribute" warnings — especially for camelCase keys
87+
// like `systemActions`, `mobileMaxVisible`).
88+
/* eslint-disable @typescript-eslint/no-unused-vars */
89+
actions: _schemaActions,
90+
systemActions: _schemaSystemActions,
91+
location: _schemaLocation,
92+
maxVisible: _schemaMaxVisible,
93+
mobileMaxVisible: _schemaMobileMaxVisible,
94+
direction: _schemaDirection,
95+
gap: _schemaGap,
96+
variant: _schemaVariant,
97+
size: _schemaSize,
98+
visible: _schemaVisible,
99+
/* eslint-enable @typescript-eslint/no-unused-vars */
73100
...rest
74101
} = props;
75102

76103
const isVisible = useCondition(schema.visible ? `\${${schema.visible}}` : undefined);
77104
const isMobile = useIsMobile();
78105

79-
// Filter actions by location and deduplicate by name
106+
// Filter business actions by location and deduplicate by name
80107
const filteredActions = useMemo(() => {
81108
const actions = schema.actions || [];
82109
const located = !schema.location
@@ -94,8 +121,21 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
94121
});
95122
}, [schema.actions, schema.location]);
96123

97-
// Split into visible inline actions and overflow
98-
// On mobile, show fewer actions inline (default: 1)
124+
// System actions: always go into the overflow menu, deduped by name,
125+
// never filtered by location (they're chrome, not business logic).
126+
const systemActions = useMemo(() => {
127+
const actions = schema.systemActions || [];
128+
const seen = new Set<string>();
129+
return actions.filter(a => {
130+
if (!a.name) return true;
131+
if (seen.has(a.name)) return false;
132+
seen.add(a.name);
133+
return true;
134+
});
135+
}, [schema.systemActions]);
136+
137+
// Split business actions into visible inline and overflow.
138+
// On mobile, show fewer actions inline (default: 1).
99139
const maxVisible = isMobile
100140
? (schema.mobileMaxVisible ?? 1)
101141
: (schema.maxVisible ?? 3);
@@ -109,19 +149,34 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
109149
};
110150
}, [filteredActions, maxVisible]);
111151

152+
// Merge business overflow with system actions into a single overflow list.
153+
// Insert a visual separator before the first system action when both
154+
// groups coexist, so users can distinguish domain vs. chrome actions.
155+
const combinedOverflow = useMemo<ActionSchema[]>(() => {
156+
if (systemActions.length === 0) return overflowActions;
157+
if (overflowActions.length === 0) return systemActions;
158+
const [firstSys, ...restSys] = systemActions;
159+
const firstWithSeparator: ActionSchema = {
160+
...firstSys,
161+
tags: [...(firstSys.tags || []), 'separator-before'],
162+
};
163+
return [...overflowActions, firstWithSeparator, ...restSys];
164+
}, [overflowActions, systemActions]);
165+
112166
if (schema.visible && !isVisible) return null;
113-
if (filteredActions.length === 0) return null;
167+
if (filteredActions.length === 0 && systemActions.length === 0) return null;
114168

115169
const direction = schema.direction || 'horizontal';
116170
const gap = schema.gap || 'gap-2';
117171

118-
// Render overflow menu for excess actions
119-
const MenuRenderer = overflowActions.length > 0 ? ComponentRegistry.get('action:menu') : null;
172+
// Render a single overflow menu for any combination of business-overflow
173+
// + system actions. This guarantees at most ONE "More" button per bar.
174+
const MenuRenderer = combinedOverflow.length > 0 ? ComponentRegistry.get('action:menu') : null;
120175
const overflowMenu = MenuRenderer ? (
121176
<MenuRenderer
122177
schema={{
123178
type: 'action:menu' as const,
124-
actions: overflowActions,
179+
actions: combinedOverflow,
125180
variant: schema.variant || 'ghost',
126181
size: schema.size || 'sm',
127182
}}
@@ -163,7 +218,7 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
163218
);
164219
})}
165220

166-
{overflowActions.length > 0 && overflowMenu}
221+
{combinedOverflow.length > 0 && overflowMenu}
167222
</div>
168223
);
169224
},
@@ -176,6 +231,7 @@ ComponentRegistry.register('action:bar', ActionBarRenderer, {
176231
label: 'Action Bar',
177232
inputs: [
178233
{ name: 'actions', type: 'object', label: 'Actions' },
234+
{ name: 'systemActions', type: 'object', label: 'System Actions (always in overflow)' },
179235
{
180236
name: 'location',
181237
type: 'enum',

packages/components/src/renderers/action/action-menu.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
106106
async (action: ActionSchema) => {
107107
setLoading(true);
108108
try {
109+
// UI-local escape hatch: direct callback, bypass ActionEngine
110+
if (typeof action.onClick === 'function') {
111+
await action.onClick();
112+
return;
113+
}
109114
await execute({
110115
type: action.type,
111116
name: action.name,

0 commit comments

Comments
 (0)