Skip to content

Commit 2531d91

Browse files
Copilothotlong
andcommitted
fix: preserve original action type in action-bar → action-button pipeline
The action:bar component was overriding action.type ('api') with the component type ('action:button'), causing ActionRunner to miss the registered 'api' handler. Now stores the original type as 'actionType' and action-button uses it for execution. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent a1e523f commit 2531d91

3 files changed

Lines changed: 266 additions & 1 deletion

File tree

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* RecordDetailView — Action button integration tests
3+
*
4+
* Validates that action buttons in the detail page header are wired to
5+
* ActionProvider and that the full action chain works:
6+
* - API actions use dataSource.update() via the registered handler
7+
* - Actions with params show a param collection dialog
8+
* - Actions with confirmText show a confirmation dialog
9+
* - Toast notifications are displayed on success/error
10+
* - Data refreshes after successful action execution
11+
*/
12+
13+
import { describe, it, expect, vi, beforeEach } from 'vitest';
14+
import { render, screen, waitFor, within } from '@testing-library/react';
15+
import userEvent from '@testing-library/user-event';
16+
import '@testing-library/jest-dom';
17+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
18+
import { RecordDetailView } from '../components/RecordDetailView';
19+
20+
// Mock sonner toast
21+
const mockToastSuccess = vi.fn();
22+
const mockToastError = vi.fn();
23+
vi.mock('sonner', () => ({
24+
toast: Object.assign(vi.fn(), {
25+
success: (...args: any[]) => mockToastSuccess(...args),
26+
error: (...args: any[]) => mockToastError(...args),
27+
}),
28+
}));
29+
30+
// ─── Test Data ───────────────────────────────────────────────────────────────
31+
32+
function createMockDataSource() {
33+
return {
34+
async getObjectSchema() {
35+
return {
36+
name: 'opportunity',
37+
label: 'Opportunity',
38+
fields: {
39+
name: { name: 'name', label: 'Name', type: 'text' },
40+
stage: { name: 'stage', label: 'Stage', type: 'select' },
41+
amount: { name: 'amount', label: 'Amount', type: 'currency' },
42+
},
43+
};
44+
},
45+
findOne: vi.fn().mockResolvedValue({
46+
id: 'opp-1',
47+
name: 'Acme Deal',
48+
stage: 'proposal',
49+
amount: 50000,
50+
}),
51+
find: vi.fn().mockResolvedValue({ data: [] }),
52+
create: vi.fn().mockResolvedValue({ id: '1' }),
53+
update: vi.fn().mockResolvedValue({ id: 'opp-1' }),
54+
delete: vi.fn().mockResolvedValue(true),
55+
} as any;
56+
}
57+
58+
const opportunityActions = [
59+
{
60+
name: 'opportunity_change_stage',
61+
label: 'Change Stage',
62+
icon: 'arrow-right-circle',
63+
type: 'api' as const,
64+
target: 'opportunity_change_stage',
65+
locations: ['record_header' as const],
66+
params: [
67+
{
68+
name: 'new_stage', label: 'New Stage', type: 'select' as const, required: true,
69+
options: [
70+
{ label: 'Prospecting', value: 'prospecting' },
71+
{ label: 'Qualification', value: 'qualification' },
72+
{ label: 'Proposal', value: 'proposal' },
73+
{ label: 'Negotiation', value: 'negotiation' },
74+
{ label: 'Closed Won', value: 'closed_won' },
75+
{ label: 'Closed Lost', value: 'closed_lost' },
76+
],
77+
},
78+
],
79+
refreshAfter: true,
80+
successMessage: 'Stage updated successfully',
81+
},
82+
{
83+
name: 'opportunity_mark_won',
84+
label: 'Mark as Won',
85+
icon: 'trophy',
86+
type: 'api' as const,
87+
target: 'opportunity_mark_won',
88+
locations: ['record_header' as const],
89+
variant: 'primary' as const,
90+
confirmText: 'Mark this opportunity as Closed Won?',
91+
refreshAfter: true,
92+
successMessage: 'Opportunity marked as won!',
93+
},
94+
];
95+
96+
const mockObjectsWithActions = [
97+
{
98+
name: 'opportunity',
99+
label: 'Opportunity',
100+
fields: {
101+
name: { name: 'name', label: 'Name', type: 'text' },
102+
stage: { name: 'stage', label: 'Stage', type: 'select' },
103+
amount: { name: 'amount', label: 'Amount', type: 'currency' },
104+
},
105+
actions: opportunityActions,
106+
},
107+
];
108+
109+
function renderDetailWithActions(ds?: any) {
110+
const dataSource = ds ?? createMockDataSource();
111+
const onEdit = vi.fn();
112+
return {
113+
dataSource,
114+
onEdit,
115+
...render(
116+
<MemoryRouter initialEntries={['/opportunity/record/opp-1']}>
117+
<Routes>
118+
<Route
119+
path="/:objectName/record/:recordId"
120+
element={
121+
<RecordDetailView
122+
dataSource={dataSource}
123+
objects={mockObjectsWithActions}
124+
onEdit={onEdit}
125+
/>
126+
}
127+
/>
128+
</Routes>
129+
</MemoryRouter>,
130+
),
131+
};
132+
}
133+
134+
// ─── Tests ───────────────────────────────────────────────────────────────────
135+
136+
describe('RecordDetailView — Action buttons', () => {
137+
beforeEach(() => {
138+
vi.clearAllMocks();
139+
});
140+
141+
it('renders action buttons in the detail header', async () => {
142+
renderDetailWithActions();
143+
144+
// Wait for record to load
145+
await waitFor(() => {
146+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Acme Deal');
147+
});
148+
149+
// Action buttons should be visible
150+
expect(screen.getByRole('button', { name: /Change Stage/i })).toBeInTheDocument();
151+
expect(screen.getByRole('button', { name: /Mark as Won/i })).toBeInTheDocument();
152+
});
153+
154+
it('shows confirmation dialog for Mark as Won action', async () => {
155+
const user = userEvent.setup();
156+
renderDetailWithActions();
157+
158+
await waitFor(() => {
159+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Acme Deal');
160+
});
161+
162+
// Click Mark as Won
163+
await user.click(screen.getByRole('button', { name: /Mark as Won/i }));
164+
165+
// Confirmation dialog should appear
166+
await waitFor(() => {
167+
expect(screen.getByText('Mark this opportunity as Closed Won?')).toBeInTheDocument();
168+
});
169+
});
170+
171+
it('calls dataSource.update when confirmation is accepted', async () => {
172+
const user = userEvent.setup();
173+
const ds = createMockDataSource();
174+
renderDetailWithActions(ds);
175+
176+
await waitFor(() => {
177+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Acme Deal');
178+
});
179+
180+
// Click Mark as Won
181+
await user.click(screen.getByRole('button', { name: /Mark as Won/i }));
182+
183+
// Wait for confirmation dialog
184+
await waitFor(() => {
185+
expect(screen.getByText('Mark this opportunity as Closed Won?')).toBeInTheDocument();
186+
});
187+
188+
// Click Continue (confirm)
189+
await user.click(screen.getByRole('button', { name: /Continue/i }));
190+
191+
// dataSource.update should be called with the correct params
192+
await waitFor(() => {
193+
expect(ds.update).toHaveBeenCalledWith('opportunity', 'opp-1', { stage: 'closed_won' });
194+
}, { timeout: 3000 });
195+
});
196+
197+
it('does not call dataSource.update when confirmation is cancelled', async () => {
198+
const user = userEvent.setup();
199+
const ds = createMockDataSource();
200+
renderDetailWithActions(ds);
201+
202+
await waitFor(() => {
203+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Acme Deal');
204+
});
205+
206+
// Click Mark as Won
207+
await user.click(screen.getByRole('button', { name: /Mark as Won/i }));
208+
209+
// Wait for confirmation dialog
210+
await waitFor(() => {
211+
expect(screen.getByText('Mark this opportunity as Closed Won?')).toBeInTheDocument();
212+
});
213+
214+
// Click Cancel
215+
await user.click(screen.getByRole('button', { name: /Cancel/i }));
216+
217+
// dataSource.update should NOT be called
218+
// (wait a tick to ensure no async calls happen)
219+
await new Promise(r => setTimeout(r, 100));
220+
expect(ds.update).not.toHaveBeenCalledWith('opportunity', 'opp-1', expect.anything());
221+
});
222+
223+
it('shows param collection dialog for Change Stage action', async () => {
224+
const user = userEvent.setup();
225+
renderDetailWithActions();
226+
227+
await waitFor(() => {
228+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Acme Deal');
229+
});
230+
231+
// Click Change Stage
232+
await user.click(screen.getByRole('button', { name: /Change Stage/i }));
233+
234+
// Param dialog should appear with the select field
235+
await waitFor(() => {
236+
expect(screen.getByText('Action Parameters')).toBeInTheDocument();
237+
expect(screen.getByText('New Stage')).toBeInTheDocument();
238+
});
239+
});
240+
241+
it('shows toast on successful action execution', async () => {
242+
const user = userEvent.setup();
243+
const ds = createMockDataSource();
244+
renderDetailWithActions(ds);
245+
246+
await waitFor(() => {
247+
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Acme Deal');
248+
});
249+
250+
// Click Mark as Won
251+
await user.click(screen.getByRole('button', { name: /Mark as Won/i }));
252+
253+
// Confirm
254+
await waitFor(() => {
255+
expect(screen.getByText('Mark this opportunity as Closed Won?')).toBeInTheDocument();
256+
});
257+
await user.click(screen.getByRole('button', { name: /Continue/i }));
258+
259+
// Toast should be called with success message
260+
await waitFor(() => {
261+
expect(mockToastSuccess).toHaveBeenCalledWith('Opportunity marked as won!');
262+
});
263+
});
264+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ const ActionBarRenderer = forwardRef<HTMLDivElement, { schema: ActionBarSchema;
137137
schema={{
138138
...action,
139139
type: componentType,
140+
actionType: action.type,
140141
variant: action.variant || schema.variant,
141142
size: action.size || schema.size,
142143
}}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const ActionButtonRenderer = forwardRef<HTMLButtonElement, ActionButtonProps>(
7272
typeof schema.params[0] === 'object' && 'name' in schema.params[0] && 'type' in schema.params[0];
7373

7474
await execute({
75-
type: schema.type,
75+
type: (schema as any).actionType || schema.type,
7676
name: schema.name,
7777
target: schema.target,
7878
execute: schema.execute,

0 commit comments

Comments
 (0)