Skip to content

Commit f4ef82a

Browse files
committed
refactor: Modify ToastManager for general feedback
Exposes a showToast for managing action errors and the showErrorToast for managing general server errors
1 parent b387aba commit f4ef82a

File tree

15 files changed

+389
-269
lines changed

15 files changed

+389
-269
lines changed

jest.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ module.exports = createConfig('jest', {
44
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
55
// If you want to add config BEFORE jest loads, use setupFiles instead.
66
setupFilesAfterEnv: [
7-
'<rootDir>/src/setupTest.jsx',
7+
'<rootDir>/src/setupTest.tsx',
88
],
99
moduleNameMapper: {
1010
'^@src/(.*)$': '<rootDir>/src/$1',
1111
},
1212
coveragePathIgnorePatterns: [
13-
'src/setupTest.jsx',
13+
'src/setupTest.tsx',
1414
'src/i18n',
1515
],
1616
});

src/authz-module/libraries-manager/LibrariesUserManager.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const LibrariesUserManager = () => {
4747

4848
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null);
4949
const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false);
50-
const { handleShowToast, handleDiscardToast } = useToastManager();
50+
const { showToast, Bold, Br } = useToastManager();
5151

5252
const {
5353
data: teamMember, isLoading: isLoadingTeamMember, isFetching: isFetchingMember,
@@ -78,7 +78,6 @@ const LibrariesUserManager = () => {
7878
const handleShowConfirmDeletionModal = (role: Role) => {
7979
if (isRevokingUserRole) { return; }
8080

81-
handleDiscardToast();
8281
setRoleToDelete(role);
8382
setShowConfirmDeletionModal(true);
8483
};
@@ -95,19 +94,26 @@ const LibrariesUserManager = () => {
9594
revokeUserRoles({ data }, {
9695
onSuccess: () => {
9796
const remainingRolesCount = userRoles.length - 1;
98-
handleShowToast(intl.formatMessage(
99-
messages['library.authz.team.remove.user.toast.success.description'],
100-
{
101-
role: roleToDelete.name,
102-
rolesCount: remainingRolesCount,
103-
},
104-
));
97+
showToast({
98+
message: intl.formatMessage(
99+
messages['library.authz.team.remove.user.toast.success.description'],
100+
{
101+
role: roleToDelete.name,
102+
rolesCount: remainingRolesCount,
103+
},
104+
),
105+
type: 'success',
106+
});
107+
105108
handleCloseConfirmDeletionModal();
106109
},
107110
onError: (error) => {
108111
logError(error);
109-
// eslint-disable-next-line react/no-unstable-nested-components
110-
handleShowToast(intl.formatMessage(messages['library.authz.team.default.error.toast.message'], { b: chunk => <b>{chunk}</b>, br: () => <br /> }));
112+
113+
showToast({
114+
type: 'error',
115+
message: intl.formatMessage(messages['library.authz.team.toast.default.error.message'], { Bold, Br }),
116+
});
111117
handleCloseConfirmDeletionModal();
112118
},
113119
});

src/authz-module/libraries-manager/ToastManagerContext.test.tsx

Lines changed: 20 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,26 @@
1-
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
1+
import { screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import { IntlProvider } from '@edx/frontend-platform/i18n';
3+
import { renderWrapper } from '@src/setupTest';
44
import { ToastManagerProvider, useToastManager } from './ToastManagerContext';
55

6-
const render = (ui: React.ReactElement) => rtlRender(
7-
<IntlProvider locale="en">
8-
{ui}
9-
</IntlProvider>,
10-
);
11-
126
const TestComponent = () => {
13-
const { handleShowToast, handleDiscardToast } = useToastManager();
7+
const { showToast } = useToastManager();
8+
9+
const handleShowToast = () => showToast({ message: 'Test toast message', type: 'error' });
10+
const handleShowAnotherToast = () => showToast({ message: 'Another message', type: 'success' });
1411

1512
return (
1613
<div>
17-
<button type="button" onClick={() => handleShowToast('Test toast message')}>
18-
Show Toast
19-
</button>
20-
<button type="button" onClick={() => handleShowToast('Another message')}>
21-
Show Another Toast
22-
</button>
23-
<button type="button" onClick={handleDiscardToast}>
24-
Discard Toast
25-
</button>
14+
<button type="button" onClick={handleShowToast}>Show Toast</button>
15+
<button type="button" onClick={handleShowAnotherToast}>Show Another Toast</button>
2616
</div>
2717
);
2818
};
2919

3020
describe('ToastManagerContext', () => {
3121
describe('ToastManagerProvider', () => {
3222
it('does not show toast initially', () => {
33-
render(
23+
renderWrapper(
3424
<ToastManagerProvider>
3525
<TestComponent />
3626
</ToastManagerProvider>,
@@ -39,15 +29,14 @@ describe('ToastManagerContext', () => {
3929
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
4030
});
4131

42-
it('shows toast when handleShowToast is called', async () => {
32+
it('shows toast when showToast is called', async () => {
4333
const user = userEvent.setup();
44-
render(
34+
renderWrapper(
4535
<ToastManagerProvider>
4636
<TestComponent />
4737
</ToastManagerProvider>,
4838
);
4939

50-
// handleShowToast is called on button click
5140
const showButton = screen.getByText('Show Toast');
5241
await user.click(showButton);
5342

@@ -57,59 +46,29 @@ describe('ToastManagerContext', () => {
5746
});
5847
});
5948

60-
it('updates toast message when handleShowToast is called with different message', async () => {
49+
it('adds multiple toasts when showToast is called multiple times', async () => {
6150
const user = userEvent.setup();
62-
render(
51+
renderWrapper(
6352
<ToastManagerProvider>
6453
<TestComponent />
6554
</ToastManagerProvider>,
6655
);
6756

68-
// Show first toast
6957
const showButton = screen.getByText('Show Toast');
70-
await user.click(showButton);
71-
72-
await waitFor(() => {
73-
expect(screen.getByText('Test toast message')).toBeInTheDocument();
74-
});
75-
76-
// Show another toast
7758
const showAnotherButton = screen.getByText('Show Another Toast');
78-
await user.click(showAnotherButton);
7959

80-
await waitFor(() => {
81-
expect(screen.getByText('Another message')).toBeInTheDocument();
82-
expect(screen.queryByText('Test toast message')).not.toBeInTheDocument();
83-
});
84-
});
85-
86-
it('hides toast when handleDiscardToast is called', async () => {
87-
const user = userEvent.setup();
88-
render(
89-
<ToastManagerProvider>
90-
<TestComponent />
91-
</ToastManagerProvider>,
92-
);
93-
94-
const showButton = screen.getByText('Show Toast');
9560
await user.click(showButton);
61+
await user.click(showAnotherButton);
9662

9763
await waitFor(() => {
9864
expect(screen.getByText('Test toast message')).toBeInTheDocument();
99-
});
100-
101-
// handleDiscardToast is called on button click
102-
const discardButton = screen.getByText('Discard Toast');
103-
await user.click(discardButton);
104-
105-
await waitFor(() => {
106-
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
65+
expect(screen.getByText('Another message')).toBeInTheDocument();
10766
});
10867
});
10968

11069
it('hides toast when close button is clicked', async () => {
11170
const user = userEvent.setup();
112-
render(
71+
renderWrapper(
11372
<ToastManagerProvider>
11473
<TestComponent />
11574
</ToastManagerProvider>,
@@ -127,48 +86,22 @@ describe('ToastManagerContext', () => {
12786

12887
await waitFor(() => {
12988
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
130-
});
131-
});
132-
133-
it('calls handleClose callback when toast is closed', async () => {
134-
const user = userEvent.setup();
135-
const mockHandleClose = jest.fn();
136-
137-
render(
138-
<ToastManagerProvider handleClose={mockHandleClose}>
139-
<TestComponent />
140-
</ToastManagerProvider>,
141-
);
142-
143-
const showButton = screen.getByText('Show Toast');
144-
await user.click(showButton);
145-
146-
await waitFor(() => {
147-
expect(screen.getByText('Test toast message')).toBeInTheDocument();
148-
});
149-
150-
const closeButton = screen.getByLabelText('Close');
151-
await user.click(closeButton);
152-
153-
await waitFor(() => {
154-
expect(mockHandleClose).toHaveBeenCalledTimes(1);
155-
});
89+
}, { timeout: 500 });
15690
});
15791
});
15892

15993
describe('useToastManager hook', () => {
16094
it('throws error when used outside ToastManagerProvider', () => {
161-
// Suppress console.error for this test
162-
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
95+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
16396

16497
const TestComponentWithoutProvider = () => {
16598
useToastManager();
16699
return <div>Test</div>;
167100
};
168101

169102
expect(() => {
170-
render(<TestComponentWithoutProvider />);
171-
}).toThrow('useToastManager must be used within an ToastManagerProvider');
103+
renderWrapper(<TestComponentWithoutProvider />);
104+
}).toThrow('useToastManager must be used within a ToastManagerProvider');
172105

173106
consoleSpy.mockRestore();
174107
});
Lines changed: 85 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,117 @@
11
import {
2-
createContext, useContext, useMemo, useState,
2+
createContext, useContext, useState, useMemo,
33
} from 'react';
4+
import { logError } from '@edx/frontend-platform/logging';
5+
import { useIntl } from '@edx/frontend-platform/i18n';
46
import { Toast } from '@openedx/paragon';
7+
import messages from './messages';
8+
9+
type ToastType = 'success' | 'error' | 'error-retry';
10+
11+
export const ERROR_TOAST_MAP: Record<number | string, { type: ToastType; messageId: string }> = {
12+
// Transient (retryable) server errors
13+
500: { type: 'error-retry', messageId: 'library.authz.team.toast.500.error.message' },
14+
502: { type: 'error-retry', messageId: 'library.authz.team.toast.502.error.message' },
15+
503: { type: 'error-retry', messageId: 'library.authz.team.toast.503.error.message' },
16+
408: { type: 'error-retry', messageId: 'library.authz.team.toast.408.error.message' },
17+
18+
// Generic fallback error
19+
DEFAULT: { type: 'error-retry', messageId: 'library.authz.team.toast.default.error.message' },
20+
};
21+
22+
interface AppToast {
23+
id: string;
24+
message: string;
25+
type: ToastType;
26+
onRetry?: () => void;
27+
}
28+
29+
const Bold = (chunk: string) => <b>{chunk}</b>;
30+
const Br = () => <br />;
531

632
type ToastManagerContextType = {
7-
handleShowToast: (message: string) => void;
8-
handleDiscardToast: () => void;
33+
showToast: (toast: Omit<AppToast, 'id'>) => void;
34+
showErrorToast: (error, retryFn?: () => void) => void;
35+
Bold: (chunk: string) => JSX.Element;
36+
Br: () => JSX.Element;
937
};
1038

1139
const ToastManagerContext = createContext<ToastManagerContextType | undefined>(undefined);
1240

1341
interface ToastManagerProviderProps {
14-
handleClose?: () => void
1542
children: React.ReactNode | React.ReactNode[];
1643
}
1744

18-
export const ToastManagerProvider = ({ handleClose, children }: ToastManagerProviderProps) => {
19-
const [toastMessage, setToastMessage] = useState<string | null>(null);
45+
export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) => {
46+
const intl = useIntl();
47+
const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]);
2048

21-
const handleShowToast = (message: string) => {
22-
setToastMessage(message);
49+
const showToast = (toast: Omit<AppToast, 'id'>) => {
50+
const id = `toast-notification-${Date.now()}`;
51+
const newToast = { ...toast, id, visible: true };
52+
setToasts(prev => [...prev, newToast]);
2353
};
2454

25-
const handleDiscardToast = () => {
26-
setToastMessage(null);
55+
const discardToast = (id: string) => {
56+
setToasts(prev => prev.map(t => (t.id === id ? { ...t, visible: false } : t)));
57+
58+
setTimeout(() => {
59+
setToasts(prev => prev.filter(t => t.id !== id));
60+
}, 5000);
2761
};
2862

29-
const value = useMemo((): ToastManagerContextType => ({
30-
handleShowToast,
31-
handleDiscardToast,
32-
}), []);
63+
const value = useMemo<ToastManagerContextType>(() => {
64+
const showErrorToast = (error, retryFn?: () => void) => {
65+
logError(error);
66+
const errorStatus = error?.customAttributes?.httpErrorStatus;
67+
const toastConfig = ERROR_TOAST_MAP[errorStatus] || ERROR_TOAST_MAP.DEFAULT;
68+
const message = intl.formatMessage(messages[toastConfig.messageId], { Bold, Br });
69+
70+
showToast({
71+
message,
72+
type: toastConfig.type,
73+
onRetry: toastConfig.type === 'error-retry' && retryFn ? retryFn : undefined,
74+
});
75+
};
76+
77+
return ({
78+
showToast,
79+
showErrorToast,
80+
Bold,
81+
Br,
82+
});
83+
}, [intl]);
3384

3485
return (
3586
<ToastManagerContext.Provider value={value}>
3687
{children}
3788

38-
<Toast
39-
onClose={() => {
40-
if (handleClose) { handleClose(); }
41-
setToastMessage(null);
42-
}}
43-
show={!!toastMessage}
44-
>
45-
{toastMessage ?? ''}
46-
</Toast>
89+
<div className="toast-container">
90+
{toasts.map(toast => (
91+
<Toast
92+
key={toast.id}
93+
show={toast.visible}
94+
onClose={() => discardToast(toast.id)}
95+
action={toast.onRetry ? {
96+
onClick: () => {
97+
discardToast(toast.id);
98+
toast.onRetry?.();
99+
},
100+
label: intl.formatMessage(messages['library.authz.team.toast.retry.label']),
101+
} : undefined}
102+
>
103+
{toast.message}
104+
</Toast>
105+
))}
106+
</div>
47107
</ToastManagerContext.Provider>
48108
);
49109
};
50110

51111
export const useToastManager = (): ToastManagerContextType => {
52112
const context = useContext(ToastManagerContext);
53-
if (context === undefined) {
54-
throw new Error('useToastManager must be used within an ToastManagerProvider');
113+
if (!context) {
114+
throw new Error('useToastManager must be used within a ToastManagerProvider');
55115
}
56116
return context;
57117
};

0 commit comments

Comments
 (0)