diff --git a/CHANGELOG.md b/CHANGELOG.md index 13078b8c4..cab5f9cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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` 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` + diff --git a/packages/components/src/__tests__/action-bar.test.tsx b/packages/components/src/__tests__/action-bar.test.tsx index c432a9f13..6798cac44 100644 --- a/packages/components/src/__tests__/action-bar.test.tsx +++ b/packages/components/src/__tests__/action-bar.test.tsx @@ -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({ diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index f11a618d2..927c3d719 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -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) */ @@ -70,13 +81,29 @@ const ActionBarRenderer = forwardRef { const actions = schema.actions || []; const located = !schema.location @@ -94,8 +121,21 @@ const ActionBarRenderer = forwardRef { + const actions = schema.systemActions || []; + const seen = new Set(); + 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); @@ -109,19 +149,34 @@ const ActionBarRenderer = forwardRef(() => { + 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 ? ( 0 && overflowMenu} + {combinedOverflow.length > 0 && overflowMenu} ); }, @@ -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', diff --git a/packages/components/src/renderers/action/action-menu.tsx b/packages/components/src/renderers/action/action-menu.tsx index b03d903cd..18926120d 100644 --- a/packages/components/src/renderers/action/action-menu.tsx +++ b/packages/components/src/renderers/action/action-menu.tsx @@ -106,6 +106,11 @@ const ActionMenuRenderer = forwardRef { 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, diff --git a/packages/plugin-detail/src/DetailView.tsx b/packages/plugin-detail/src/DetailView.tsx index 2d5069873..6452a8f4d 100644 --- a/packages/plugin-detail/src/DetailView.tsx +++ b/packages/plugin-detail/src/DetailView.tsx @@ -12,11 +12,6 @@ import { Badge, Button, Skeleton, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, Tooltip, TooltipContent, TooltipProvider, @@ -29,12 +24,7 @@ import { import { ArrowLeft, Edit, - Trash2, - MoreHorizontal, Share2, - Copy, - Download, - History, Star, StarOff, Check, @@ -50,7 +40,7 @@ import { RecordComments } from './RecordComments'; import { ActivityTimeline } from './ActivityTimeline'; import { SchemaRenderer } from '@object-ui/react'; import { buildExpandFields } from '@object-ui/core'; -import type { DetailViewSchema, DataSource } from '@object-ui/types'; +import type { DetailViewSchema, DataSource, ActionSchema, SchemaNode } from '@object-ui/types'; import { useDetailTranslation } from './useDetailTranslation'; /** Default page size for related lists in the detail view */ @@ -340,6 +330,150 @@ export const DetailView: React.FC = ({ })); }, [schema.related, discoveredRelated]); + /** + * Chrome-level "system" actions (Duplicate, Export, View History, Delete, + * and mobile-only fallbacks for Share / Edit / Inline Edit) expressed as + * {@link ActionSchema} entries. These are funnelled into the *single* + * overflow menu of the record-header `action:bar` via its `systemActions` + * field, guaranteeing at most one "More" button on the header regardless + * of how many business actions the object metadata contributes. + * + * `onClick` is used as a UI-local escape hatch because these handlers + * depend on React state (e.g., `isInlineEditing`) and local DOM APIs + * (`navigator.share`, `navigator.clipboard`) that are not part of the + * server-driven action protocol. + */ + const systemActions = React.useMemo(() => { + const items: ActionSchema[] = []; + + // Mobile-only mirrors of the desktop inline chrome buttons. + items.push({ + name: 'sys_share_mobile', + label: t('detail.share'), + icon: 'share-2', + type: 'script', + className: 'sm:hidden', + onClick: handleShare, + }); + if (schema.showEdit) { + items.push({ + name: 'sys_edit_mobile', + label: t('detail.edit'), + icon: 'edit', + type: 'script', + className: 'sm:hidden', + onClick: handleEdit, + }); + } + if (inlineEdit) { + items.push({ + name: 'sys_toggle_inline_edit_mobile', + label: isInlineEditing ? t('detail.save') : t('detail.editInline'), + icon: 'edit', + type: 'script', + className: 'sm:hidden', + onClick: handleInlineEditToggle, + }); + } + + // Universal record-level utilities (desktop + mobile). + const firstUniversalTags = items.length > 0 ? ['separator-before'] : undefined; + items.push({ + name: 'sys_duplicate', + label: t('detail.duplicate'), + icon: 'copy', + type: 'script', + ...(firstUniversalTags && { tags: firstUniversalTags }), + onClick: handleDuplicate, + }); + items.push({ + name: 'sys_export', + label: t('detail.export'), + icon: 'download', + type: 'script', + onClick: handleExport, + }); + items.push({ + name: 'sys_view_history', + label: t('detail.viewHistory'), + icon: 'history', + type: 'script', + onClick: handleViewHistory, + }); + + // Destructive action — separated and styled via variant. + if (schema.showDelete) { + items.push({ + name: 'sys_delete', + label: t('detail.delete'), + icon: 'trash-2', + type: 'script', + variant: 'destructive', + tags: ['separator-before'], + onClick: handleDelete, + }); + } + + return items; + }, [ + t, + schema.showEdit, + schema.showDelete, + inlineEdit, + isInlineEditing, + handleShare, + handleEdit, + handleInlineEditToggle, + handleDuplicate, + handleExport, + handleViewHistory, + handleDelete, + ]); + + /** + * Inject `systemActions` into the record-header `action:bar` if one was + * provided via `schema.actions`; otherwise append a new header `action:bar` + * that carries only the system actions. The goal is to always render a + * single, unified overflow menu containing both business-action overflow + * and system actions. + */ + const headerActionNodes = React.useMemo(() => { + // `schema.actions` is typed as ActionSchema[] by DetailViewSchema, but + // in practice RecordDetailView (and consumers) pass through full UI + // schema nodes like `action:bar` so they can be rendered by + // SchemaRenderer. Treat each entry as an opaque SchemaNode here. + const actions = (schema.actions ?? []) as unknown as SchemaNode[]; + if (systemActions.length === 0) return actions; + let injected = false; + const mapped: SchemaNode[] = actions.map((node) => { + const record = node as Record | null; + if ( + record && + typeof record === 'object' && + record.type === 'action:bar' && + (!record.location || record.location === 'record_header') + ) { + injected = true; + const existingSystem = Array.isArray(record.systemActions) + ? (record.systemActions as ActionSchema[]) + : []; + return { + ...record, + systemActions: [...existingSystem, ...systemActions], + } as SchemaNode; + } + return node; + }); + if (!injected) { + mapped.push({ + type: 'action:bar', + location: 'record_header', + systemActions, + } as unknown as SchemaNode); + } + return mapped; + }, [schema.actions, systemActions]); + if (loading || schema.loading) { return (
@@ -475,16 +609,18 @@ export const DetailView: React.FC = ({
)} - {schema.actions?.map((action, index) => ( + {headerActionNodes.map((action, index) => ( ))} - {/* Inline Edit Toggle - hidden on mobile, accessible via more menu */} + {/* Inline Edit Toggle — desktop-only chrome. + Mobile fallback lives inside the unified action:bar overflow + menu as a `systemActions` entry with `sm:hidden`. */} {inlineEdit && ( - - - - {t('detail.moreActions')} - - - {/* Mobile-only: Share, Edit, Inline Edit */} - - - {t('detail.share')} - - {schema.showEdit && ( - - - {t('detail.edit')} - - )} - {inlineEdit && ( - - - {isInlineEditing ? t('detail.save') : t('detail.editInline')} - - )} - - - - {t('detail.duplicate')} - - - - {t('detail.export')} - - - - {t('detail.viewHistory')} - - {schema.showDelete && ( - <> - - - - {t('detail.delete')} - - - )} - -
diff --git a/packages/types/src/ui-action.ts b/packages/types/src/ui-action.ts index 055819e4b..11261cecf 100644 --- a/packages/types/src/ui-action.ts +++ b/packages/types/src/ui-action.ts @@ -212,6 +212,20 @@ export interface ActionSchema { /** Tags for categorization */ tags?: string[]; + + /** + * UI-local escape hatch: synchronous/async callback invoked directly by + * UI action renderers (e.g., `action:menu`) instead of routing through + * {@link ActionEngine}. Intended for chrome-level concerns such as + * toggling inline-edit mode, opening a native Share sheet, or copying the + * URL to the clipboard — UI side-effects that are not part of the domain + * action protocol and therefore need not be serialized over the wire. + * + * When present, `onClick` takes precedence over `type` / `target` / + * `execute`. Prefer {@link ActionEngine}-routed actions for anything that + * could originate from server-driven metadata. + */ + onClick?: () => void | Promise; } /**