Skip to content

Commit 7a20eaf

Browse files
authored
Merge pull request #1098 from objectstack-ai/copilot/fix-action-button-no-response
2 parents 4244501 + 703647b commit 7a20eaf

File tree

3 files changed

+298
-1
lines changed

3 files changed

+298
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- **List Toolbar Action Buttons Not Responding** (`apps/console`): Fixed a bug where schema-driven action buttons in the upper-right corner of the list view (rendered via `action:bar` with `locations: ['list_toolbar']`) did not respond to clicks. The root cause: the console `ObjectView` component rendered these action buttons via `SchemaRenderer` without wrapping them in an `ActionProvider`. This caused `useAction()` to fall back to a local `ActionRunner` with no handlers, toast, confirmation, or navigation capabilities — so clicks executed silently with no visible effect. Added `ActionProvider` with proper handlers (toast via Sonner, confirmation dialog, navigation via React Router, param collection dialog, and a generic API handler) to the console `ObjectView`, following the same pattern already used in `RecordDetailView`. Includes 4 new integration tests validating the full action chain (render, confirm, execute, cancel).
13+
1214
- **CRM Seed Data Lookup References** (`examples/crm`): Fixed all CRM seed data files to use natural key (`name`) references for lookup fields instead of raw `id` values. The `SeedLoaderService` resolves foreign key references by the target object's `name` field (the default `externalId`), so seed data values like `order: "o1"` or `account: "2"` could not be resolved and were set to `null`. Updated all 8 affected data files (`account`, `contact`, `opportunity`, `order`, `order_item`, `opportunity_contact`, `project_task`, `event`) to use human-readable name references (e.g., `order: "ORD-2024-001"`, `product: "Workstation Pro Laptop"`, `account: "Salesforce Tower"`). This fixes the issue where Order and Product columns displayed as empty in the Order Item grid view.
1315

1416
### Added
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* ObjectView — List toolbar action button integration tests
3+
*
4+
* Validates that action buttons in the list toolbar header are wired to
5+
* ActionProvider and respond to clicks:
6+
* - Actions with confirmText show a confirmation dialog
7+
* - Actions with params show a param collection dialog
8+
* - Toast notifications are displayed on success
9+
*/
10+
11+
import { describe, it, expect, vi, beforeEach } from 'vitest';
12+
import { render, screen, waitFor } from '@testing-library/react';
13+
import userEvent from '@testing-library/user-event';
14+
import '@testing-library/jest-dom';
15+
import { ObjectView } from '../components/ObjectView';
16+
import { ComponentRegistry } from '@object-ui/core';
17+
18+
// Mock sonner toast
19+
const mockToastSuccess = vi.fn();
20+
const mockToastError = vi.fn();
21+
vi.mock('sonner', () => ({
22+
toast: Object.assign(vi.fn(), {
23+
success: (...args: any[]) => mockToastSuccess(...args),
24+
error: (...args: any[]) => mockToastError(...args),
25+
}),
26+
}));
27+
28+
// Mock child plugins to isolate ObjectView logic
29+
vi.mock('@object-ui/plugin-grid', () => ({
30+
ObjectGrid: (props: any) => <div data-testid="object-grid">Grid View: {props.schema.objectName}</div>
31+
}));
32+
33+
vi.mock('@object-ui/plugin-kanban', () => ({
34+
ObjectKanban: (props: any) => <div data-testid="object-kanban">Kanban View</div>
35+
}));
36+
37+
vi.mock('@object-ui/plugin-calendar', () => ({
38+
ObjectCalendar: (props: any) => <div data-testid="object-calendar">Calendar View</div>
39+
}));
40+
41+
vi.mock('@object-ui/plugin-list', () => ({
42+
ListView: (props: any) => (
43+
<div data-testid="list-view">
44+
<div data-testid="object-grid">Grid View: {props.schema?.objectName}</div>
45+
<button data-testid="list-row-click" onClick={() => props.onRowClick?.({ id: 'rec-1' })}>Click Row</button>
46+
</div>
47+
),
48+
}));
49+
50+
vi.mock('@object-ui/components', async (importOriginal) => {
51+
const actual = await importOriginal<any>();
52+
return {
53+
...actual,
54+
cn: (...inputs: any[]) => inputs.filter(Boolean).join(' '),
55+
Button: ({ children, onClick, title, ...rest }: any) => <button onClick={onClick} title={title} {...rest}>{children}</button>,
56+
Empty: ({ children }: any) => <div data-testid="empty">{children}</div>,
57+
EmptyTitle: ({ children }: any) => <div>{children}</div>,
58+
EmptyDescription: ({ children }: any) => <div>{children}</div>,
59+
DropdownMenu: ({ children }: any) => <div>{children}</div>,
60+
DropdownMenuTrigger: ({ children }: any) => <>{children}</>,
61+
DropdownMenuContent: ({ children }: any) => <div>{children}</div>,
62+
DropdownMenuItem: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
63+
DropdownMenuSeparator: () => <hr />,
64+
};
65+
});
66+
67+
// Mock React Router
68+
const mockNavigate = vi.fn();
69+
vi.mock('react-router-dom', () => ({
70+
useParams: () => ({ objectName: 'account', appName: 'crm' }),
71+
useSearchParams: () => [new URLSearchParams(), vi.fn()],
72+
useNavigate: () => mockNavigate,
73+
}));
74+
75+
// Mock auth — default admin so Create button is visible
76+
vi.mock('@object-ui/auth', () => ({
77+
useAuth: () => ({ user: { id: 'u1', name: 'Admin', role: 'admin' } }),
78+
}));
79+
80+
// ─── Test Data ───────────────────────────────────────────────────────────────
81+
82+
const listToolbarActions = [
83+
{
84+
name: 'bulk_import',
85+
label: 'Import Records',
86+
icon: 'upload',
87+
type: 'api' as const,
88+
target: 'bulk_import',
89+
locations: ['list_toolbar' as const],
90+
confirmText: 'This will import records from CSV. Continue?',
91+
refreshAfter: true,
92+
successMessage: 'Records imported successfully',
93+
},
94+
{
95+
name: 'export_all',
96+
label: 'Export All',
97+
icon: 'download',
98+
type: 'url' as const,
99+
target: '/api/export/accounts',
100+
locations: ['list_toolbar' as const],
101+
},
102+
];
103+
104+
const mockObjects = [
105+
{
106+
name: 'account',
107+
label: 'Account',
108+
fields: {
109+
name: { name: 'name', label: 'Name', type: 'text' },
110+
industry: { name: 'industry', label: 'Industry', type: 'text' },
111+
},
112+
actions: listToolbarActions,
113+
listViews: {
114+
all: { label: 'All Accounts', type: 'grid', columns: ['name', 'industry'] },
115+
},
116+
},
117+
];
118+
119+
function createMockDataSource() {
120+
return {
121+
find: vi.fn().mockResolvedValue({ data: [], total: 0 }),
122+
findOne: vi.fn().mockResolvedValue(null),
123+
create: vi.fn().mockResolvedValue({ id: '1' }),
124+
update: vi.fn().mockResolvedValue({ id: '1' }),
125+
delete: vi.fn().mockResolvedValue(true),
126+
execute: vi.fn().mockResolvedValue({ success: true }),
127+
} as any;
128+
}
129+
130+
// ─── Tests ───────────────────────────────────────────────────────────────────
131+
132+
describe('ObjectView — List toolbar action buttons', () => {
133+
beforeEach(() => {
134+
vi.clearAllMocks();
135+
// Register mock components for SchemaRenderer to find
136+
ComponentRegistry.register('object-grid', (props: any) => <div data-testid="object-grid">Grid View: {props.schema.objectName}</div>);
137+
ComponentRegistry.register('list-view', (_props: any) => <div data-testid="list-view">List View</div>);
138+
});
139+
140+
it('renders action buttons in the list toolbar', async () => {
141+
const ds = createMockDataSource();
142+
render(<ObjectView dataSource={ds} objects={mockObjects} onEdit={vi.fn()} />);
143+
144+
// Action buttons should be visible
145+
await waitFor(() => {
146+
expect(screen.getByRole('button', { name: /Import Records/i })).toBeInTheDocument();
147+
});
148+
});
149+
150+
it('shows confirmation dialog when action button with confirmText is clicked', async () => {
151+
const user = userEvent.setup();
152+
const ds = createMockDataSource();
153+
render(<ObjectView dataSource={ds} objects={mockObjects} onEdit={vi.fn()} />);
154+
155+
// Wait for the Import Records button to render
156+
await waitFor(() => {
157+
expect(screen.getByRole('button', { name: /Import Records/i })).toBeInTheDocument();
158+
});
159+
160+
// Click Import Records (has confirmText)
161+
await user.click(screen.getByRole('button', { name: /Import Records/i }));
162+
163+
// Confirmation dialog should appear
164+
await waitFor(() => {
165+
expect(screen.getByText('This will import records from CSV. Continue?')).toBeInTheDocument();
166+
});
167+
});
168+
169+
it('calls dataSource.execute when confirmation is accepted', async () => {
170+
const user = userEvent.setup();
171+
const ds = createMockDataSource();
172+
render(<ObjectView dataSource={ds} objects={mockObjects} onEdit={vi.fn()} />);
173+
174+
// Wait for button to render
175+
await waitFor(() => {
176+
expect(screen.getByRole('button', { name: /Import Records/i })).toBeInTheDocument();
177+
});
178+
179+
// Click action button
180+
await user.click(screen.getByRole('button', { name: /Import Records/i }));
181+
182+
// Wait for confirmation dialog
183+
await waitFor(() => {
184+
expect(screen.getByText('This will import records from CSV. Continue?')).toBeInTheDocument();
185+
});
186+
187+
// Click Continue to confirm
188+
await user.click(screen.getByRole('button', { name: /Continue/i }));
189+
190+
// dataSource.execute should be called
191+
await waitFor(() => {
192+
expect(ds.execute).toHaveBeenCalledWith('account', 'bulk_import', {});
193+
}, { timeout: 3000 });
194+
});
195+
196+
it('does not execute action when confirmation is cancelled', async () => {
197+
const user = userEvent.setup();
198+
const ds = createMockDataSource();
199+
render(<ObjectView dataSource={ds} objects={mockObjects} onEdit={vi.fn()} />);
200+
201+
// Wait for button
202+
await waitFor(() => {
203+
expect(screen.getByRole('button', { name: /Import Records/i })).toBeInTheDocument();
204+
});
205+
206+
// Click action button
207+
await user.click(screen.getByRole('button', { name: /Import Records/i }));
208+
209+
// Wait for confirmation dialog
210+
await waitFor(() => {
211+
expect(screen.getByText('This will import records from CSV. Continue?')).toBeInTheDocument();
212+
});
213+
214+
// Click Cancel
215+
await user.click(screen.getByRole('button', { name: /Cancel/i }));
216+
217+
// dataSource.execute should NOT be called
218+
await new Promise(r => setTimeout(r, 100));
219+
expect(ds.execute).not.toHaveBeenCalled();
220+
});
221+
});

apps/console/src/components/ObjectView.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ import { useObjectTranslation, useObjectLabel } 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, SchemaRenderer } from '@object-ui/react';
33+
import { ActionProvider, useNavigationOverlay, SchemaRenderer } from '@object-ui/react';
34+
import { toast } from 'sonner';
35+
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog';
36+
import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog';
37+
import type { ActionDef, ActionParamDef } from '@object-ui/core';
3438

3539
/** Map view types to Lucide icons (Airtable-style) */
3640
const VIEW_TYPE_ICONS: Record<string, ComponentType<{ className?: string }>> = {
@@ -386,6 +390,61 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
386390
onRefresh: () => setRefreshKey(k => k + 1),
387391
});
388392

393+
// ─── ActionProvider handlers for schema-driven toolbar actions ──────
394+
const currentUser = user
395+
? { id: user.id, name: user.name, avatar: user.image }
396+
: FALLBACK_USER;
397+
398+
const [confirmState, setConfirmState] = useState<ConfirmDialogState>({ open: false, message: '' });
399+
const [paramState, setParamState] = useState<ParamDialogState>({ open: false, params: [] });
400+
401+
const confirmHandler = useCallback((message: string, options?: { title?: string; confirmText?: string; cancelText?: string }) => {
402+
return new Promise<boolean>((resolve) => {
403+
setConfirmState({ open: true, message, options, resolve });
404+
});
405+
}, []);
406+
407+
const paramCollectionHandler = useCallback((params: ActionParamDef[]) => {
408+
return new Promise<Record<string, any> | null>((resolve) => {
409+
setParamState({ open: true, params, resolve });
410+
});
411+
}, []);
412+
413+
const toastHandler = useCallback((message: string, options?: { type?: string }) => {
414+
if (options?.type === 'error') toast.error(message);
415+
else toast.success(message);
416+
}, []);
417+
418+
const navigateHandler = useCallback((url: string, options?: { external?: boolean; newTab?: boolean }) => {
419+
if (options?.external || options?.newTab) {
420+
window.open(url, '_blank', 'noopener,noreferrer');
421+
} else {
422+
navigate(url);
423+
}
424+
}, [navigate]);
425+
426+
const apiHandler = useCallback(async (action: ActionDef) => {
427+
try {
428+
const target = action.target || action.name;
429+
const params = action.params || {};
430+
431+
// Generic list-level API handler: update/execute via dataSource
432+
if (typeof dataSource.execute === 'function') {
433+
await dataSource.execute(objectDef.name, target, params);
434+
} else if (params.recordId && Object.keys(params).length > 1 && typeof dataSource.update === 'function') {
435+
await dataSource.update(objectDef.name, params.recordId, params);
436+
}
437+
438+
const shouldRefresh = action.refreshAfter !== false;
439+
if (shouldRefresh) {
440+
setRefreshKey(k => k + 1);
441+
}
442+
return { success: true, reload: shouldRefresh };
443+
} catch (error) {
444+
return { success: false, error: (error as Error).message };
445+
}
446+
}, [dataSource, objectDef.name]);
447+
389448
// Real-time: auto-refresh when server reports data changes
390449
const { lastMessage: realtimeMessage } = useRealtimeSubscription({
391450
channel: `object:${objectDef.name}`,
@@ -678,6 +737,14 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
678737
}), [objectDef.name, onEdit, activeView?.showSearch, activeView?.showFilters, activeView?.showSort, navigate, viewId, isAdmin]);
679738

680739
return (
740+
<ActionProvider
741+
context={{ objectName: objectDef.name, user: currentUser }}
742+
onConfirm={confirmHandler}
743+
onToast={toastHandler}
744+
onNavigate={navigateHandler}
745+
onParamCollection={paramCollectionHandler}
746+
handlers={{ api: apiHandler }}
747+
>
681748
<div className="h-full flex flex-col bg-background min-w-0 overflow-hidden">
682749
{/* 1. Header with breadcrumb + description */}
683750
<div className="flex justify-between items-center py-2.5 sm:py-3 px-3 sm:px-4 border-b shrink-0 bg-background z-10">
@@ -882,5 +949,12 @@ export function ObjectView({ dataSource, objects, onEdit }: any) {
882949
</NavigationOverlay>
883950
)}
884951
</div>
952+
<ActionConfirmDialog state={confirmState} onOpenChange={(open) => {
953+
if (!open) setConfirmState({ open: false, message: '' });
954+
}} />
955+
<ActionParamDialog state={paramState} onOpenChange={(open) => {
956+
if (!open) setParamState({ open: false, params: [] });
957+
}} />
958+
</ActionProvider>
885959
);
886960
}

0 commit comments

Comments
 (0)