Skip to content

Commit 0c84911

Browse files
Copilothotlong
andcommitted
feat: add ActionBar component (action:bar) and useActionEngine hook
- ActionBar: location-aware composite toolbar that renders ActionSchema[] filtered by location, with overflow into "More" menu - useActionEngine: React hook wrapping ActionEngine for location-based action retrieval, execution, and shortcut handling - 23 tests covering registration, rendering, filtering, overflow, styling - Updated ROADMAP.md with P2.10 milestone Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 27aa576 commit 0c84911

File tree

7 files changed

+650
-0
lines changed

7 files changed

+650
-0
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)
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)