Skip to content

Commit a9f8680

Browse files
authored
feat(authz): [FC-0099] Manage action feedback using the Toast component. (#26)
Surface errors via toasts while keeping the modal open. This ensures they can retry or correct input without losing context, improving usability and reducing frustration.
1 parent 9fef270 commit a9f8680

20 files changed

+669
-302
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/index.scss

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,10 @@
6565
// Move toast to the right
6666
left: auto;
6767
right: var(--pgn-spacing-toast-container-gutter-lg);
68-
}
68+
}
69+
70+
// Fix a bug with a toast on edit tags sheet component: can't click on close toast button
71+
// https://github.com/openedx/frontend-app-authoring/issues/1898
72+
#toast-root[data-focus-on-hidden] {
73+
pointer-events: initial !important;
74+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jest.mock('./context', () => {
1515
LibraryAuthZProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
1616
};
1717
});
18+
1819
const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
1920

2021
jest.mock('@src/authz-module/data/hooks', () => ({
@@ -165,6 +166,10 @@ describe('LibrariesTeamManager', () => {
165166
onSuccess: expect.any(Function),
166167
}),
167168
);
169+
const { onSuccess } = (mutate as jest.Mock).mock.calls[0][1];
170+
onSuccess?.();
171+
172+
expect(await screen.findByText(/updated successfully/i)).toBeInTheDocument();
168173
});
169174

170175
it('should not render the toggle if the user can not manage team and the Library Public Read is disabled', () => {

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

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ describe('LibrariesUserManager', () => {
241241
await user.click(removeButton);
242242

243243
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
244-
onSuccessCallback();
244+
onSuccessCallback({ errors: [] });
245245

246246
await waitFor(() => {
247247
expect(screen.getByText(/The Admin role has been successfully removed/)).toBeInTheDocument();
@@ -278,14 +278,14 @@ describe('LibrariesUserManager', () => {
278278
await user.click(removeButton);
279279

280280
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
281-
onSuccessCallback();
281+
onSuccessCallback({ errors: [] });
282282

283283
await waitFor(() => {
284284
expect(screen.getByText(/The user no longer has access to this library/)).toBeInTheDocument();
285285
});
286286
});
287287

288-
it('shows error toast when role revocation fails', async () => {
288+
it('shows error toast when role revocation fails with server error', async () => {
289289
const user = userEvent.setup();
290290
renderComponent();
291291

@@ -302,8 +302,50 @@ describe('LibrariesUserManager', () => {
302302
const onErrorCallback = mockMutate.mock.calls[0][1].onError;
303303
onErrorCallback(new Error('Network error'));
304304

305+
// Wait for the error toast to appear with a retry button
305306
await waitFor(() => {
306-
expect(screen.getByText(/Something went wrong on our end/)).toBeInTheDocument();
307+
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
308+
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
309+
});
310+
311+
// Second call to mutate also fails
312+
mockMutate.mockImplementationOnce((_vars, { onError }) => {
313+
onError(new Error('Network error'), _vars);
314+
});
315+
316+
// Click retry button
317+
const retryButton = screen.getByRole('button', { name: /retry/i });
318+
await user.click(retryButton);
319+
320+
// The retry toast should appear again
321+
await waitFor(() => {
322+
expect(screen.getAllByText(/Something went wrong/i).length).toBeGreaterThanOrEqual(1);
323+
});
324+
325+
// Ensure mutate was called twice (original + retry)
326+
expect(mockMutate).toHaveBeenCalledTimes(2);
327+
});
328+
329+
it('shows error toast when API fails to remove a role', async () => {
330+
const user = userEvent.setup();
331+
332+
renderComponent();
333+
334+
const deleteButton = screen.getByText('delete-role-Admin');
335+
await user.click(deleteButton);
336+
337+
await waitFor(() => {
338+
expect(screen.getByText('Remove role?')).toBeInTheDocument();
339+
});
340+
341+
const removeButton = screen.getByText('Remove');
342+
await user.click(removeButton);
343+
344+
const { onSuccess } = mockMutate.mock.calls[0][1];
345+
onSuccess({ errors: [{ error: 'role_removal_error' }] });
346+
347+
await waitFor(() => {
348+
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
307349
});
308350
});
309351

@@ -322,11 +364,12 @@ describe('LibrariesUserManager', () => {
322364
await user.click(removeButton);
323365

324366
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
325-
onSuccessCallback();
367+
onSuccessCallback({ errors: [] });
326368

327369
await waitFor(() => {
328370
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
329371
});
372+
expect(await screen.findByText(/role has been successfully removed/i)).toBeInTheDocument();
330373
});
331374

332375
it('disables delete action when revocation is in progress', async () => {

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

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useEffect, useMemo, useState } from 'react';
22
import { useNavigate, useParams } from 'react-router-dom';
33
import { useIntl } from '@edx/frontend-platform/i18n';
4-
import { logError } from '@edx/frontend-platform/logging';
54
import { Container, Skeleton } from '@openedx/paragon';
65
import { ROUTES } from '@src/authz-module/constants';
76
import { Role } from 'types';
@@ -47,7 +46,9 @@ const LibrariesUserManager = () => {
4746

4847
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null);
4948
const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false);
50-
const { handleShowToast, handleDiscardToast } = useToastManager();
49+
const {
50+
showToast, showErrorToast, Bold, Br,
51+
} = useToastManager();
5152

5253
const {
5354
data: teamMember, isLoading: isLoadingTeamMember, isFetching: isFetchingMember,
@@ -78,7 +79,6 @@ const LibrariesUserManager = () => {
7879
const handleShowConfirmDeletionModal = (role: Role) => {
7980
if (isRevokingUserRole) { return; }
8081

81-
handleDiscardToast();
8282
setRoleToDelete(role);
8383
setShowConfirmDeletionModal(true);
8484
};
@@ -92,25 +92,42 @@ const LibrariesUserManager = () => {
9292
scope: libraryId,
9393
};
9494

95-
revokeUserRoles({ data }, {
96-
onSuccess: () => {
97-
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-
));
105-
handleCloseConfirmDeletionModal();
106-
},
107-
onError: (error) => {
108-
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 /> }));
111-
handleCloseConfirmDeletionModal();
112-
},
113-
});
95+
const runRevokeRole = (variables = { data }) => {
96+
revokeUserRoles(variables, {
97+
onSuccess: (response) => {
98+
const { errors } = response;
99+
100+
if (errors.length) {
101+
showToast({
102+
type: 'error',
103+
message: intl.formatMessage(
104+
messages['library.authz.team.toast.default.error.message'],
105+
{ Bold, Br },
106+
),
107+
});
108+
return;
109+
}
110+
111+
const remainingRolesCount = userRoles.length - 1;
112+
showToast({
113+
message: intl.formatMessage(
114+
messages['library.authz.team.remove.user.toast.success.description'],
115+
{
116+
role: roleToDelete.name,
117+
rolesCount: remainingRolesCount,
118+
},
119+
),
120+
type: 'success',
121+
});
122+
},
123+
onError: (error, retryVariables) => {
124+
showErrorToast(error, () => runRevokeRole(retryVariables));
125+
},
126+
});
127+
};
128+
129+
handleCloseConfirmDeletionModal();
130+
runRevokeRole();
114131
};
115132

116133
return (

0 commit comments

Comments
 (0)