Skip to content

Commit cd7d17b

Browse files
authored
Merge pull request #926 from objectstack-ai/copilot/fix-action-bar-rendering
2 parents e318006 + 4320986 commit cd7d17b

File tree

9 files changed

+672
-12
lines changed

9 files changed

+672
-12
lines changed

ROADMAP.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,26 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
10881088
- [x] 11 AppSidebar tests passing
10891089
- [x] 32 i18n tests passing
10901090

1091+
### P2.10 ActionBar Rendering & useActionEngine Integration ✅
1092+
1093+
> Location-aware action toolbar component and React hook for ActionEngine.
1094+
> Bridges ActionSchema metadata → visible, clickable action buttons at all defined locations.
1095+
1096+
- [x] `action:bar` component — Composite toolbar renderer that accepts `ActionSchema[]` + `location` filter
1097+
- Filters actions by `ActionLocation` (list_toolbar, list_item, record_header, record_more, record_related, global_nav)
1098+
- Resolves each action's `component` type (action:button, action:icon, action:menu, action:group) via ComponentRegistry
1099+
- Overflow support: actions beyond `maxVisible` threshold grouped into "More" dropdown (action:menu)
1100+
- Supports horizontal/vertical direction, gap, variant/size defaults, custom className
1101+
- WCAG: `role="toolbar"` + `aria-label="Actions"`
1102+
- Registered in ComponentRegistry with inputs/defaultProps for Designer/Studio integration
1103+
- [x] `useActionEngine` hook — React wrapper around `ActionEngine`
1104+
- `getActionsForLocation(location)` — returns filtered, priority-sorted actions
1105+
- `getBulkActions()` — returns bulk-enabled actions
1106+
- `executeAction(name)` — executes by name with optional context override
1107+
- `handleShortcut(keys)` — keyboard shortcut dispatch
1108+
- Memoized engine instance, stable callbacks
1109+
- [x] Tests: 23 tests (12 ActionBar, 11 useActionEngine) covering registration, rendering, location filtering, overflow, styling, execution, shortcuts
1110+
10911111
### P2.5 PWA & Offline (Real Sync)
10921112

10931113
- [ ] Background sync queue → real server sync (replace simulation)

apps/console/src/components/ObjectView.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
3030
import { usePermissions } from '@object-ui/permissions';
3131
import { useAuth } from '@object-ui/auth';
3232
import { useRealtimeSubscription, useConflictResolution } from '@object-ui/collaboration';
33-
import { useNavigationOverlay } from '@object-ui/react';
33+
import { useNavigationOverlay, SchemaRenderer } from '@object-ui/react';
3434

3535
/** Map view types to Lucide icons (Airtable-style) */
3636
const VIEW_TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
@@ -719,17 +719,15 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
719719
)}
720720

721721
{/* Schema-driven toolbar actions */}
722-
{objectDef.actions?.filter((a: any) => a.location === 'list_toolbar').map((action: any) => (
723-
<Button
724-
key={action.name || action.label}
725-
size="sm"
726-
variant={action.variant || "outline"}
727-
className="shadow-none h-8 sm:h-9"
728-
onClick={() => actions.execute(action)}
729-
>
730-
{action.label || action.name}
731-
</Button>
732-
))}
722+
{objectDef.actions?.some((a: any) => a.locations?.includes('list_toolbar')) && (
723+
<SchemaRenderer schema={{
724+
type: 'action:bar',
725+
location: 'list_toolbar',
726+
actions: objectDef.actions,
727+
size: 'sm',
728+
variant: 'outline',
729+
}} />
730+
)}
733731

734732
{/* Design tools menu — visible only to admin users */}
735733
{isAdmin && (

apps/console/src/components/RecordDetailView.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
247247
},
248248
];
249249

250+
// Filter actions for record_header location
251+
const recordHeaderActions = (objectDef.actions || []).filter(
252+
(a: any) => a.locations?.includes('record_header'),
253+
);
254+
250255
const detailSchema: DetailViewSchema = {
251256
type: 'detail-view',
252257
objectName: objectDef.name,
@@ -257,6 +262,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
257262
title: objectDef.label,
258263
primaryField,
259264
sections,
265+
...(recordHeaderActions.length > 0 && {
266+
actions: [{
267+
type: 'action:bar',
268+
location: 'record_header',
269+
actions: recordHeaderActions,
270+
} as any],
271+
}),
260272
};
261273

262274
return (
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Tests for ActionBar (action:bar) renderer
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { render, screen } from '@testing-library/react';
7+
import { ComponentRegistry } from '@object-ui/core';
8+
import { renderComponent, validateComponentRegistration } from './test-utils';
9+
10+
// Ensure action renderers are loaded (side-effect imports via vitest.setup.tsx)
11+
12+
describe('ActionBar (action:bar)', () => {
13+
describe('registration', () => {
14+
it('is registered in ComponentRegistry', () => {
15+
const reg = validateComponentRegistration('action:bar');
16+
expect(reg.isRegistered).toBe(true);
17+
expect(reg.hasRenderer).toBe(true);
18+
expect(reg.hasLabel).toBe(true);
19+
expect(reg.hasInputs).toBe(true);
20+
expect(reg.hasDefaultProps).toBe(true);
21+
});
22+
});
23+
24+
describe('rendering', () => {
25+
it('renders nothing when actions array is empty', () => {
26+
const { container } = renderComponent({
27+
type: 'action:bar',
28+
actions: [],
29+
});
30+
expect(container.innerHTML).toBe('');
31+
});
32+
33+
it('renders action buttons for provided actions', () => {
34+
const { container } = renderComponent({
35+
type: 'action:bar',
36+
actions: [
37+
{ name: 'save', label: 'Save', type: 'script', component: 'action:button' },
38+
{ name: 'cancel', label: 'Cancel', type: 'script', component: 'action:button' },
39+
],
40+
});
41+
expect(container.textContent).toContain('Save');
42+
expect(container.textContent).toContain('Cancel');
43+
});
44+
45+
it('renders with role="toolbar" and aria-label', () => {
46+
const { container } = renderComponent({
47+
type: 'action:bar',
48+
actions: [
49+
{ name: 'test', label: 'Test', type: 'script' },
50+
],
51+
});
52+
const toolbar = container.querySelector('[role="toolbar"]');
53+
expect(toolbar).toBeTruthy();
54+
expect(toolbar?.getAttribute('aria-label')).toBe('Actions');
55+
});
56+
57+
it('filters actions by location', () => {
58+
const { container } = renderComponent({
59+
type: 'action:bar',
60+
location: 'list_toolbar',
61+
actions: [
62+
{ name: 'toolbar_action', label: 'Toolbar Action', type: 'script', locations: ['list_toolbar'] },
63+
{ name: 'header_action', label: 'Header Action', type: 'script', locations: ['record_header'] },
64+
{ name: 'both_action', label: 'Both Action', type: 'script', locations: ['list_toolbar', 'record_header'] },
65+
],
66+
});
67+
expect(container.textContent).toContain('Toolbar Action');
68+
expect(container.textContent).not.toContain('Header Action');
69+
expect(container.textContent).toContain('Both Action');
70+
});
71+
72+
it('shows actions without locations when filtering by location', () => {
73+
const { container } = renderComponent({
74+
type: 'action:bar',
75+
location: 'record_header',
76+
actions: [
77+
{ name: 'no_loc', label: 'No Location', type: 'script' },
78+
{ name: 'has_loc', label: 'Has Location', type: 'script', locations: ['list_toolbar'] },
79+
],
80+
});
81+
// Action without locations should show in any location
82+
expect(container.textContent).toContain('No Location');
83+
expect(container.textContent).not.toContain('Has Location');
84+
});
85+
86+
it('renders all actions when no location filter is set', () => {
87+
const { container } = renderComponent({
88+
type: 'action:bar',
89+
actions: [
90+
{ name: 'a1', label: 'Action 1', type: 'script', locations: ['list_toolbar'] },
91+
{ name: 'a2', label: 'Action 2', type: 'script', locations: ['record_header'] },
92+
],
93+
});
94+
expect(container.textContent).toContain('Action 1');
95+
expect(container.textContent).toContain('Action 2');
96+
});
97+
});
98+
99+
describe('overflow', () => {
100+
it('groups excess actions into overflow menu when maxVisible is exceeded', () => {
101+
const { container } = renderComponent({
102+
type: 'action:bar',
103+
maxVisible: 2,
104+
actions: [
105+
{ name: 'a1', label: 'Action 1', type: 'script' },
106+
{ name: 'a2', label: 'Action 2', type: 'script' },
107+
{ name: 'a3', label: 'Action 3', type: 'script' },
108+
{ name: 'a4', label: 'Action 4', type: 'script' },
109+
],
110+
});
111+
// First 2 should be visible as buttons
112+
expect(container.textContent).toContain('Action 1');
113+
expect(container.textContent).toContain('Action 2');
114+
// Remaining 2 should be in a dropdown (rendered as action:menu trigger)
115+
const toolbar = container.querySelector('[role="toolbar"]');
116+
expect(toolbar).toBeTruthy();
117+
// There should be 3 children: 2 inline buttons + 1 menu trigger
118+
const children = toolbar!.children;
119+
expect(children.length).toBe(3);
120+
});
121+
122+
it('does not show overflow when actions fit within maxVisible', () => {
123+
const { container } = renderComponent({
124+
type: 'action:bar',
125+
maxVisible: 5,
126+
actions: [
127+
{ name: 'a1', label: 'Action 1', type: 'script' },
128+
{ name: 'a2', label: 'Action 2', type: 'script' },
129+
],
130+
});
131+
const toolbar = container.querySelector('[role="toolbar"]');
132+
expect(toolbar!.children.length).toBe(2);
133+
});
134+
});
135+
136+
describe('styling', () => {
137+
it('applies custom className', () => {
138+
const { container } = renderComponent({
139+
type: 'action:bar',
140+
className: 'my-custom-bar',
141+
actions: [
142+
{ name: 'test', label: 'Test', type: 'script' },
143+
],
144+
});
145+
const toolbar = container.querySelector('[role="toolbar"]');
146+
expect(toolbar?.className).toContain('my-custom-bar');
147+
});
148+
149+
it('supports vertical direction', () => {
150+
const { container } = renderComponent({
151+
type: 'action:bar',
152+
direction: 'vertical',
153+
actions: [
154+
{ name: 'test', label: 'Test', type: 'script' },
155+
],
156+
});
157+
const toolbar = container.querySelector('[role="toolbar"]');
158+
expect(toolbar?.className).toContain('flex-col');
159+
});
160+
161+
it('defaults to horizontal direction', () => {
162+
const { container } = renderComponent({
163+
type: 'action:bar',
164+
actions: [
165+
{ name: 'test', label: 'Test', type: 'script' },
166+
],
167+
});
168+
const toolbar = container.querySelector('[role="toolbar"]');
169+
expect(toolbar?.className).toContain('flex-row');
170+
});
171+
});
172+
});

0 commit comments

Comments
 (0)