Skip to content

Commit 9063a4b

Browse files
committed
feat(role-manager): add error toast notification on dashboard refresh failure
- Update useDashboardData refetch to propagate errors using Promise.allSettled - Add handleRefresh wrapper in Dashboard to catch errors and show toast - Add 3 test cases for refetch error propagation behavior
1 parent e8a3874 commit 9063a4b

File tree

4 files changed

+124
-3
lines changed

4 files changed

+124
-3
lines changed

apps/role-manager/src/hooks/__tests__/useDashboardData.test.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,102 @@ describe('useDashboardData', () => {
295295
expect(mockRolesRefetch).toHaveBeenCalledTimes(1);
296296
expect(mockOwnershipRefetch).toHaveBeenCalledTimes(1);
297297
});
298+
299+
it('throws error when roles refetch fails', async () => {
300+
const mockRolesRefetch = vi.fn().mockRejectedValue(new Error('Network error'));
301+
const mockOwnershipRefetch = vi.fn().mockResolvedValue(undefined);
302+
303+
vi.mocked(useContractDataModule.useContractRoles).mockReturnValue({
304+
roles: [],
305+
isLoading: false,
306+
error: null,
307+
refetch: mockRolesRefetch,
308+
isEmpty: true,
309+
totalMemberCount: 0,
310+
canRetry: false,
311+
errorMessage: null,
312+
hasError: false,
313+
});
314+
315+
vi.mocked(useContractDataModule.useContractOwnership).mockReturnValue({
316+
ownership: null,
317+
isLoading: false,
318+
error: null,
319+
refetch: mockOwnershipRefetch,
320+
hasOwner: false,
321+
canRetry: false,
322+
errorMessage: null,
323+
hasError: false,
324+
});
325+
326+
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
327+
328+
await expect(result.current.refetch()).rejects.toThrow('Network error');
329+
});
330+
331+
it('throws error when ownership refetch fails', async () => {
332+
const mockRolesRefetch = vi.fn().mockResolvedValue(undefined);
333+
const mockOwnershipRefetch = vi.fn().mockRejectedValue(new Error('Failed to load ownership'));
334+
335+
vi.mocked(useContractDataModule.useContractRoles).mockReturnValue({
336+
roles: [],
337+
isLoading: false,
338+
error: null,
339+
refetch: mockRolesRefetch,
340+
isEmpty: true,
341+
totalMemberCount: 0,
342+
canRetry: false,
343+
errorMessage: null,
344+
hasError: false,
345+
});
346+
347+
vi.mocked(useContractDataModule.useContractOwnership).mockReturnValue({
348+
ownership: null,
349+
isLoading: false,
350+
error: null,
351+
refetch: mockOwnershipRefetch,
352+
hasOwner: false,
353+
canRetry: false,
354+
errorMessage: null,
355+
hasError: false,
356+
});
357+
358+
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
359+
360+
await expect(result.current.refetch()).rejects.toThrow('Failed to load ownership');
361+
});
362+
363+
it('throws with generic message when error is not an Error instance', async () => {
364+
const mockRolesRefetch = vi.fn().mockRejectedValue('string error');
365+
const mockOwnershipRefetch = vi.fn().mockResolvedValue(undefined);
366+
367+
vi.mocked(useContractDataModule.useContractRoles).mockReturnValue({
368+
roles: [],
369+
isLoading: false,
370+
error: null,
371+
refetch: mockRolesRefetch,
372+
isEmpty: true,
373+
totalMemberCount: 0,
374+
canRetry: false,
375+
errorMessage: null,
376+
hasError: false,
377+
});
378+
379+
vi.mocked(useContractDataModule.useContractOwnership).mockReturnValue({
380+
ownership: null,
381+
isLoading: false,
382+
error: null,
383+
refetch: mockOwnershipRefetch,
384+
hasOwner: false,
385+
canRetry: false,
386+
errorMessage: null,
387+
hasError: false,
388+
});
389+
390+
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
391+
392+
await expect(result.current.refetch()).rejects.toThrow('Failed to refresh data');
393+
});
298394
});
299395

300396
describe('capability detection', () => {

apps/role-manager/src/hooks/useDashboardData.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,23 @@ export function useDashboardData(
124124
const canRetry = rolesCanRetry || ownershipCanRetry;
125125

126126
// Combined refetch function - refetches both in parallel
127+
// Throws on error to allow caller to handle (e.g., show toast notification)
127128
const refetch = useCallback(async (): Promise<void> => {
128129
setIsRefreshing(true);
129130
try {
130-
await Promise.all([rolesRefetch(), ownershipRefetch()]);
131+
const results = await Promise.allSettled([rolesRefetch(), ownershipRefetch()]);
132+
// Check if any refetch failed and throw an aggregated error
133+
const failures = results.filter(
134+
(result): result is PromiseRejectedResult => result.status === 'rejected'
135+
);
136+
if (failures.length > 0) {
137+
// Throw the first error message to signal refresh failed
138+
const errorMessage =
139+
failures[0].reason instanceof Error
140+
? failures[0].reason.message
141+
: 'Failed to refresh data';
142+
throw new Error(errorMessage);
143+
}
131144
} finally {
132145
setIsRefreshing(false);
133146
}

apps/role-manager/src/pages/Dashboard.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
*/
1414

1515
import { Download, Loader2, RefreshCw, Shield, Users } from 'lucide-react';
16+
import { toast } from 'sonner';
17+
import { useCallback } from 'react';
1618
import { useNavigate } from 'react-router-dom';
1719

1820
import { Button } from '@openzeppelin/ui-builder-ui';
@@ -54,6 +56,16 @@ export function Dashboard() {
5456
// Determine if buttons should be disabled
5557
const actionsDisabled = !hasContract || isLoading || isRefreshing;
5658

59+
// Handle refresh with toast notification on error
60+
const handleRefresh = useCallback(async () => {
61+
try {
62+
await refetch();
63+
} catch (error) {
64+
const message = error instanceof Error ? error.message : 'Failed to refresh data';
65+
toast.error(`Refresh failed: ${message}`);
66+
}
67+
}, [refetch]);
68+
5769
// Combined loading state for stats cards (initial load OR manual refresh)
5870
const isDataLoading = isLoading || isRefreshing;
5971

@@ -80,7 +92,7 @@ export function Dashboard() {
8092
<Button
8193
variant="outline"
8294
size="sm"
83-
onClick={() => refetch()}
95+
onClick={handleRefresh}
8496
disabled={actionsDisabled}
8597
className="bg-white"
8698
>

specs/007-dashboard-real-data/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
- [x] T025 [US3] Add refetch function to useDashboardData hook: combine rolesRefetch and ownershipRefetch with Promise.all per research.md §7
110110
- [x] T026 [US3] Add isRefreshing state to useDashboardData to distinguish initial load from manual refresh
111111
- [x] T027 [US3] Update Dashboard.tsx Refresh Data button: connect onClick to refetch, show loading indicator when isRefreshing, disable during refresh per FR-012
112-
- [ ] T028 [US3] Add error toast notification when refresh fails per User Story 3 acceptance scenario 3
112+
- [x] T028 [US3] Add error toast notification when refresh fails per User Story 3 acceptance scenario 3
113113

114114
**Checkpoint**: Refresh Data button works, shows loading state, handles errors gracefully
115115

0 commit comments

Comments
 (0)