Skip to content

Commit 9acf631

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 9acf631

File tree

15 files changed

+420
-269
lines changed

15 files changed

+420
-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
});
Lines changed: 51 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,28 @@
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';
4+
import { logError } from '@edx/frontend-platform/logging';
45
import { ToastManagerProvider, useToastManager } from './ToastManagerContext';
56

6-
const render = (ui: React.ReactElement) => rtlRender(
7-
<IntlProvider locale="en">
8-
{ui}
9-
</IntlProvider>,
10-
);
11-
7+
jest.mock('@edx/frontend-platform/logging');
128
const TestComponent = () => {
13-
const { handleShowToast, handleDiscardToast } = useToastManager();
9+
const { showToast } = useToastManager();
10+
11+
const handleShowToast = () => showToast({ message: 'Test toast message', type: 'error' });
12+
const handleShowAnotherToast = () => showToast({ message: 'Another message', type: 'success' });
1413

1514
return (
1615
<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>
16+
<button type="button" onClick={handleShowToast}>Show Toast</button>
17+
<button type="button" onClick={handleShowAnotherToast}>Show Another Toast</button>
2618
</div>
2719
);
2820
};
2921

3022
describe('ToastManagerContext', () => {
3123
describe('ToastManagerProvider', () => {
3224
it('does not show toast initially', () => {
33-
render(
25+
renderWrapper(
3426
<ToastManagerProvider>
3527
<TestComponent />
3628
</ToastManagerProvider>,
@@ -39,15 +31,14 @@ describe('ToastManagerContext', () => {
3931
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
4032
});
4133

42-
it('shows toast when handleShowToast is called', async () => {
34+
it('shows toast when showToast is called', async () => {
4335
const user = userEvent.setup();
44-
render(
36+
renderWrapper(
4537
<ToastManagerProvider>
4638
<TestComponent />
4739
</ToastManagerProvider>,
4840
);
4941

50-
// handleShowToast is called on button click
5142
const showButton = screen.getByText('Show Toast');
5243
await user.click(showButton);
5344

@@ -57,59 +48,29 @@ describe('ToastManagerContext', () => {
5748
});
5849
});
5950

60-
it('updates toast message when handleShowToast is called with different message', async () => {
51+
it('adds multiple toasts when showToast is called multiple times', async () => {
6152
const user = userEvent.setup();
62-
render(
53+
renderWrapper(
6354
<ToastManagerProvider>
6455
<TestComponent />
6556
</ToastManagerProvider>,
6657
);
6758

68-
// Show first toast
6959
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
7760
const showAnotherButton = screen.getByText('Show Another Toast');
78-
await user.click(showAnotherButton);
79-
80-
await waitFor(() => {
81-
expect(screen.getByText('Another message')).toBeInTheDocument();
82-
expect(screen.queryByText('Test toast message')).not.toBeInTheDocument();
83-
});
84-
});
8561

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');
9562
await user.click(showButton);
63+
await user.click(showAnotherButton);
9664

9765
await waitFor(() => {
9866
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();
67+
expect(screen.getByText('Another message')).toBeInTheDocument();
10768
});
10869
});
10970

11071
it('hides toast when close button is clicked', async () => {
11172
const user = userEvent.setup();
112-
render(
73+
renderWrapper(
11374
<ToastManagerProvider>
11475
<TestComponent />
11576
</ToastManagerProvider>,
@@ -127,50 +88,53 @@ describe('ToastManagerContext', () => {
12788

12889
await waitFor(() => {
12990
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-
});
91+
}, { timeout: 500 });
15692
});
15793
});
15894

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

16499
const TestComponentWithoutProvider = () => {
165100
useToastManager();
166101
return <div>Test</div>;
167102
};
168103

169104
expect(() => {
170-
render(<TestComponentWithoutProvider />);
171-
}).toThrow('useToastManager must be used within an ToastManagerProvider');
105+
renderWrapper(<TestComponentWithoutProvider />);
106+
}).toThrow('useToastManager must be used within a ToastManagerProvider');
172107

173108
consoleSpy.mockRestore();
174109
});
175110
});
111+
112+
it('calls retry function when retry button is clicked', async () => {
113+
const user = userEvent.setup();
114+
const retryFn = jest.fn();
115+
116+
const ErrorTestComponent = () => {
117+
const { showErrorToast } = useToastManager();
118+
return (
119+
<button
120+
type="button"
121+
onClick={() => showErrorToast({ customAttributes: { httpErrorStatus: 500 } }, retryFn)}
122+
>Retry Error
123+
</button>
124+
);
125+
};
126+
127+
renderWrapper(
128+
<ToastManagerProvider>
129+
<ErrorTestComponent />
130+
</ToastManagerProvider>,
131+
);
132+
133+
await user.click(screen.getByText('Retry Error'));
134+
const retryButton = await screen.findByText('Retry');
135+
await user.click(retryButton);
136+
137+
expect(logError).toHaveBeenCalled();
138+
expect(retryFn).toHaveBeenCalled();
139+
});
176140
});

0 commit comments

Comments
 (0)