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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

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

### Changed

- **`action:bar` schema** now accepts `systemActions?: ActionSchema[]`
(`@object-ui/components`). System/chrome actions are always placed in the
overflow menu (never inline) and share the same `⋯` trigger with any
business-action overflow. A visual separator is automatically inserted
between business and system groups.
- **`ActionSchema`** (`@object-ui/types`) exposes an optional UI-local
`onClick?: () => void | Promise<void>` escape hatch. `action:menu`
short-circuits to `onClick` when present, bypassing the ActionEngine.
This is intended for chrome-level callbacks (e.g., opening the native
Share sheet, toggling inline-edit mode) that depend on React state and
are not part of the server-driven action protocol.

- **Console home page (`/home`)** now uses a top navigation bar (`HomeTopNav`)
instead of the left `UnifiedSidebar`. This visually separates the workspace
landing page from individual applications (which still use `AppShell` +
Expand Down
72 changes: 72 additions & 0 deletions packages/components/src/__tests__/action-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,78 @@ describe('ActionBar (action:bar)', () => {
});
});

describe('systemActions', () => {
it('renders a single overflow menu when only systemActions are provided', () => {
const { container } = renderComponent({
type: 'action:bar',
systemActions: [
{ name: 'sys_duplicate', label: 'Duplicate', type: 'script' },
{ name: 'sys_export', label: 'Export', type: 'script' },
],
});
const toolbar = container.querySelector('[role="toolbar"]');
expect(toolbar).toBeTruthy();
// 0 inline buttons + 1 overflow menu trigger
expect(toolbar!.children.length).toBe(1);
});

it('merges business overflow and systemActions into ONE overflow menu', () => {
const { container } = renderComponent({
type: 'action:bar',
maxVisible: 2,
actions: [
{ name: 'biz1', label: 'Biz 1', type: 'script' },
{ name: 'biz2', label: 'Biz 2', type: 'script' },
{ name: 'biz3', label: 'Biz 3', type: 'script' },
{ name: 'biz4', label: 'Biz 4', type: 'script' },
],
systemActions: [
{ name: 'sys_duplicate', label: 'Duplicate', type: 'script' },
{ name: 'sys_delete', label: 'Delete', type: 'script' },
],
});
const toolbar = container.querySelector('[role="toolbar"]');
// 2 inline buttons + exactly 1 overflow menu trigger — never two
expect(toolbar!.children.length).toBe(3);
// No business-action overflow was rendered as a separate menu
const menus = toolbar!.querySelectorAll('[aria-haspopup]');
expect(menus.length).toBe(1);
});

it('systemActions never appear inline regardless of maxVisible', () => {
const { container } = renderComponent({
type: 'action:bar',
maxVisible: 10,
actions: [
{ name: 'biz1', label: 'Biz 1', type: 'script' },
],
systemActions: [
{ name: 'sys_duplicate', label: 'Duplicate', type: 'script' },
],
});
const toolbar = container.querySelector('[role="toolbar"]');
// 1 inline business button + 1 overflow menu for the system action
expect(toolbar!.children.length).toBe(2);
// The system action label is not inline
const inlineButtons = toolbar!.querySelectorAll(':scope > button:not([aria-haspopup]), :scope > [role="button"]:not([aria-haspopup])');
const inlineText = Array.from(inlineButtons).map(b => b.textContent).join(' ');
expect(inlineText).not.toContain('Duplicate');
});

it('renders overflow menu when only systemActions exist even with empty actions', () => {
const { container } = renderComponent({
type: 'action:bar',
actions: [],
systemActions: [
{ name: 'sys_history', label: 'History', type: 'script' },
],
});
const toolbar = container.querySelector('[role="toolbar"]');
expect(toolbar).toBeTruthy();
expect(toolbar!.children.length).toBe(1);
});
});

describe('styling', () => {
it('applies custom className', () => {
const { container } = renderComponent({
Expand Down
74 changes: 65 additions & 9 deletions packages/components/src/renderers/action/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,19 @@ import { useIsMobile } from '../../hooks/use-mobile';

export interface ActionBarSchema {
type: 'action:bar';
/** Actions to render */
/** Business actions to render — subject to inline/overflow split via {@link maxVisible} */
actions?: ActionSchema[];
/**
* System/chrome actions (Duplicate, Export, View History, Delete, etc.) that
* are *always* placed in the overflow menu — never inline — regardless of
* {@link maxVisible}. They share a single overflow button with any business
* actions that spilled past {@link maxVisible}, guaranteeing at most one
* "More" menu per bar.
*
* The first system action is automatically separated from business-overflow
* entries by a menu separator.
*/
systemActions?: ActionSchema[];
/** Filter actions by this location */
location?: ActionLocation;
/** Maximum visible inline actions before overflow into "More" menu (default: 3) */
Expand Down Expand Up @@ -70,13 +81,29 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
'data-obj-type': dataObjType,
style,
data,
// Strip schema metadata props that are consumed via `schema.*` and
// must NOT be spread onto the underlying DOM element (avoids React
// "unknown DOM attribute" warnings — especially for camelCase keys
// like `systemActions`, `mobileMaxVisible`).
/* eslint-disable @typescript-eslint/no-unused-vars */
actions: _schemaActions,
systemActions: _schemaSystemActions,
location: _schemaLocation,
maxVisible: _schemaMaxVisible,
mobileMaxVisible: _schemaMobileMaxVisible,
direction: _schemaDirection,
gap: _schemaGap,
variant: _schemaVariant,
size: _schemaSize,
visible: _schemaVisible,
/* eslint-enable @typescript-eslint/no-unused-vars */
...rest
} = props;

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

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

// Split into visible inline actions and overflow
// On mobile, show fewer actions inline (default: 1)
// System actions: always go into the overflow menu, deduped by name,
// never filtered by location (they're chrome, not business logic).
const systemActions = useMemo(() => {
const actions = schema.systemActions || [];
const seen = new Set<string>();
return actions.filter(a => {
if (!a.name) return true;
if (seen.has(a.name)) return false;
seen.add(a.name);
return true;
});
}, [schema.systemActions]);

// Split business actions into visible inline and overflow.
// On mobile, show fewer actions inline (default: 1).
const maxVisible = isMobile
? (schema.mobileMaxVisible ?? 1)
: (schema.maxVisible ?? 3);
Expand All @@ -109,19 +149,34 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
};
}, [filteredActions, maxVisible]);

// Merge business overflow with system actions into a single overflow list.
// Insert a visual separator before the first system action when both
// groups coexist, so users can distinguish domain vs. chrome actions.
const combinedOverflow = useMemo<ActionSchema[]>(() => {
if (systemActions.length === 0) return overflowActions;
if (overflowActions.length === 0) return systemActions;
const [firstSys, ...restSys] = systemActions;
const firstWithSeparator: ActionSchema = {
...firstSys,
tags: [...(firstSys.tags || []), 'separator-before'],
};
return [...overflowActions, firstWithSeparator, ...restSys];
}, [overflowActions, systemActions]);

if (schema.visible && !isVisible) return null;
if (filteredActions.length === 0) return null;
if (filteredActions.length === 0 && systemActions.length === 0) return null;

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

// Render overflow menu for excess actions
const MenuRenderer = overflowActions.length > 0 ? ComponentRegistry.get('action:menu') : null;
// Render a single overflow menu for any combination of business-overflow
// + system actions. This guarantees at most ONE "More" button per bar.
const MenuRenderer = combinedOverflow.length > 0 ? ComponentRegistry.get('action:menu') : null;
const overflowMenu = MenuRenderer ? (
<MenuRenderer
schema={{
type: 'action:menu' as const,
actions: overflowActions,
actions: combinedOverflow,
variant: schema.variant || 'ghost',
size: schema.size || 'sm',
}}
Expand Down Expand Up @@ -163,7 +218,7 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
);
})}

{overflowActions.length > 0 && overflowMenu}
{combinedOverflow.length > 0 && overflowMenu}
</div>
);
},
Expand All @@ -176,6 +231,7 @@ ComponentRegistry.register('action:bar', ActionBarRenderer, {
label: 'Action Bar',
inputs: [
{ name: 'actions', type: 'object', label: 'Actions' },
{ name: 'systemActions', type: 'object', label: 'System Actions (always in overflow)' },
{
name: 'location',
type: 'enum',
Expand Down
5 changes: 5 additions & 0 deletions packages/components/src/renderers/action/action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
async (action: ActionSchema) => {
setLoading(true);
try {
// UI-local escape hatch: direct callback, bypass ActionEngine
if (typeof action.onClick === 'function') {
await action.onClick();
return;
}
await execute({
type: action.type,
name: action.name,
Expand Down
Loading