Skip to content

Commit a191944

Browse files
fix: changing how the feedback message is displayed for adding team members
1 parent 3a1cafa commit a191944

File tree

7 files changed

+243
-66
lines changed

7 files changed

+243
-66
lines changed

src/authz-module/data/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export type PermissionsByRole = {
4040
userCount: number;
4141
};
4242
export interface PutAssignTeamMembersRoleResponse {
43-
completed: { user: string; status: string }[];
43+
completed: { userIdentifier: string; status: string }[];
4444
errors: { userIdentifier: string; error: string }[];
4545
}
4646

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,80 @@ describe('ToastManagerContext', () => {
137137
expect(logError).toHaveBeenCalled();
138138
expect(retryFn).toHaveBeenCalled();
139139
});
140+
141+
it('respects custom delay when provided', async () => {
142+
const user = userEvent.setup();
143+
144+
const DelayTestComponent = () => {
145+
const { showToast } = useToastManager();
146+
147+
const handleShowToastWithDelay = () => showToast({
148+
message: 'Custom delay toast',
149+
type: 'success',
150+
delay: 1000, // Custom 1 second delay
151+
});
152+
153+
return (
154+
<button type="button" onClick={handleShowToastWithDelay}>Show Toast With Custom Delay</button>
155+
);
156+
};
157+
158+
renderWrapper(
159+
<ToastManagerProvider>
160+
<DelayTestComponent />
161+
</ToastManagerProvider>,
162+
);
163+
164+
const showButton = screen.getByText('Show Toast With Custom Delay');
165+
await user.click(showButton);
166+
167+
await waitFor(() => {
168+
expect(screen.getByText('Custom delay toast')).toBeInTheDocument();
169+
});
170+
171+
await waitFor(() => {
172+
expect(screen.getByText('Custom delay toast')).toBeInTheDocument();
173+
}, { timeout: 600 });
174+
175+
// Toast should disappear after the custom delay (1000ms)
176+
await waitFor(() => {
177+
expect(screen.queryByText('Custom delay toast')).not.toBeInTheDocument();
178+
}, { timeout: 1200 });
179+
});
180+
181+
it('uses default delay when delay prop is not provided', async () => {
182+
const user = userEvent.setup();
183+
184+
const DefaultDelayTestComponent = () => {
185+
const { showToast } = useToastManager();
186+
187+
const handleShowToastWithoutDelay = () => showToast({
188+
message: 'Default delay toast',
189+
type: 'success',
190+
// No delay prop provided
191+
});
192+
193+
return (
194+
<button type="button" onClick={handleShowToastWithoutDelay}>Show Toast With Default Delay</button>
195+
);
196+
};
197+
198+
renderWrapper(
199+
<ToastManagerProvider>
200+
<DefaultDelayTestComponent />
201+
</ToastManagerProvider>,
202+
);
203+
204+
const showButton = screen.getByText('Show Toast With Default Delay');
205+
await user.click(showButton);
206+
207+
await waitFor(() => {
208+
expect(screen.getByText('Default delay toast')).toBeInTheDocument();
209+
});
210+
211+
// DEFAULT_TOAST_DELAY is 5000ms
212+
await waitFor(() => {
213+
expect(screen.queryByText('Default delay toast')).not.toBeInTheDocument();
214+
}, { timeout: 5050 });
215+
}, 5100);
140216
});

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { logError } from '@edx/frontend-platform/logging';
55
import { useIntl } from '@edx/frontend-platform/i18n';
66
import { Toast } from '@openedx/paragon';
77
import messages from './messages';
8+
import { DEFAULT_TOAST_DELAY } from './constants';
89

910
type ToastType = 'success' | 'error' | 'error-retry';
1011

@@ -19,11 +20,12 @@ export const ERROR_TOAST_MAP: Record<number | string, { type: ToastType; message
1920
DEFAULT: { type: 'error-retry', messageId: 'library.authz.team.toast.default.error.message' },
2021
};
2122

22-
interface AppToast {
23+
export interface AppToast {
2324
id: string;
2425
message: string;
2526
type: ToastType;
2627
onRetry?: () => void;
28+
delay?: number;
2729
}
2830

2931
const Bold = (chunk: string) => <b>{chunk}</b>;
@@ -47,7 +49,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
4749
const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]);
4850

4951
const showToast = (toast: Omit<AppToast, 'id'>) => {
50-
const id = `toast-notification-${Date.now()}`;
52+
const id = `toast-notification-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
5153
const newToast = { ...toast, id, visible: true };
5254
setToasts(prev => [...prev, newToast]);
5355
};
@@ -92,6 +94,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
9294
key={toast.id}
9395
show={toast.visible}
9496
onClose={() => discardToast(toast.id)}
97+
delay={toast.delay ?? DEFAULT_TOAST_DELAY}
9598
action={toast.onRetry ? {
9699
onClick: () => {
97100
discardToast(toast.id);

src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ describe('AddNewTeamMemberTrigger', () => {
146146
expect(screen.queryByTestId('add-team-member-modal')).not.toBeInTheDocument();
147147
});
148148

149-
expect(screen.getByText('2 team members added successfully.')).toBeInTheDocument();
149+
expect(screen.getByText(/2 team members added successfully/)).toBeInTheDocument();
150150
});
151151

152152
it('displays mixed success and error toast on partial success', async () => {
@@ -179,6 +179,50 @@ describe('AddNewTeamMemberTrigger', () => {
179179
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
180180
});
181181

182+
it('filters out successfully added users from error users list', async () => {
183+
const user = userEvent.setup();
184+
185+
const mockPartialResponse = {
186+
completed: [
187+
{ userIdentifier: '[email protected]' },
188+
],
189+
errors: [
190+
{ userIdentifier: '[email protected]', error: 'USER_NOT_FOUND' },
191+
{ userIdentifier: '[email protected]', error: 'USER_NOT_FOUND' },
192+
],
193+
};
194+
195+
(useAssignTeamMembersRole as jest.Mock).mockReturnValue({
196+
mutate: jest.fn((_variables, { onSuccess }) => {
197+
onSuccess(mockPartialResponse);
198+
}),
199+
isPending: false,
200+
});
201+
202+
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
203+
204+
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
205+
await user.click(triggerButton);
206+
207+
const usersInput = screen.getByRole('textbox', { name: /Enter user emails or usernames/i });
208+
const roleSelect = screen.getByRole('combobox', { name: /Select role/i });
209+
const saveButton = screen.getByRole('button', { name: 'Save team member' });
210+
211+
await user.type(usersInput, '[email protected], [email protected], [email protected]');
212+
await user.selectOptions(roleSelect, 'editor');
213+
await user.click(saveButton);
214+
215+
await waitFor(() => {
216+
expect(usersInput).toHaveValue('[email protected], [email protected]');
217+
});
218+
219+
await user.type(usersInput, ', [email protected]');
220+
221+
await waitFor(() => {
222+
expect(usersInput).toHaveValue('[email protected], [email protected], [email protected]');
223+
});
224+
});
225+
182226
it('displays only error toast when all additions fail', async () => {
183227
const user = userEvent.setup();
184228
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
@@ -207,6 +251,33 @@ describe('AddNewTeamMemberTrigger', () => {
207251
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
208252
});
209253

254+
it('displays different error toast when different errors happen', async () => {
255+
const user = userEvent.setup();
256+
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
257+
258+
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
259+
await user.click(triggerButton);
260+
261+
const saveButton = screen.getByRole('button', { name: 'Save team member' });
262+
await user.click(saveButton);
263+
264+
const [, { onSuccess }] = mockMutate.mock.calls[0];
265+
onSuccess({
266+
completed: [],
267+
errors: [
268+
{ userIdentifier: '[email protected]', error: 'user_not_found' },
269+
{ userIdentifier: '[email protected]', error: 'user_already_has_role' },
270+
],
271+
});
272+
273+
await waitFor(() => {
274+
expect(screen.getByText(/We couldn't find a user for 1 email address or username \(unknown@example.com\)/)).toBeInTheDocument();
275+
expect(screen.getByText(/The user already has the role \(already@example.com\)/)).toBeInTheDocument();
276+
});
277+
278+
expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
279+
});
280+
210281
it('resets form values after successful addition with no errors', async () => {
211282
const user = userEvent.setup();
212283
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
@@ -258,7 +329,7 @@ describe('AddNewTeamMemberTrigger', () => {
258329

259330
// Toast should be visible
260331
await waitFor(() => {
261-
expect(screen.getByText('1 team member added successfully.')).toBeInTheDocument();
332+
expect(screen.getByText(/1 team member added successfully/)).toBeInTheDocument();
262333
});
263334

264335
// Find and close the toast

src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx

Lines changed: 76 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { Plus } from '@openedx/paragon/icons';
66
import { PutAssignTeamMembersRoleResponse } from 'authz-module/data/api';
77
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
88
import { RoleOperationErrorStatus } from '@src/authz-module/constants';
9-
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
9+
import { AppToast, useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
10+
import { DEFAULT_TOAST_DELAY } from '@src/authz-module/libraries-manager/constants';
1011
import AddNewTeamMemberModal from './AddNewTeamMemberModal';
1112
import messages from './messages';
1213

14+
type AppToastOmitIdType = Omit<AppToast, 'id'>;
1315
interface AddNewTeamMemberTriggerProps {
1416
libraryId: string;
1517
}
@@ -58,53 +60,64 @@ const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({ libraryId }
5860
setFormValues((prev) => ({ ...prev, [name]: value }));
5961
};
6062

61-
const handleErrors = (
63+
const buildErrorMessages = (
6264
errors: PutAssignTeamMembersRoleResponse['errors'],
63-
successfulCount: number,
64-
) => {
65+
): Array<AppToastOmitIdType> => {
6566
const notFoundUsers = errors
6667
.filter((err) => err.error === RoleOperationErrorStatus.USER_NOT_FOUND)
6768
.map((err) => err.userIdentifier.trim());
6869

69-
const alreadyHasRole = errors.some(
70-
(err) => err.error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE,
70+
const alreadyHasRole = errors
71+
.filter((err) => err.error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE)
72+
.map((err) => err.userIdentifier.trim());
73+
74+
const otherErrors = errors.filter(
75+
(err) => err.error !== RoleOperationErrorStatus.USER_NOT_FOUND
76+
&& err.error !== RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE,
7177
);
7278

73-
if (alreadyHasRole && errors.length === 1 && !successfulCount) {
74-
showToast({
75-
message: intl.formatMessage(messages['libraries.authz.manage.assign.role.existing']),
76-
type: 'error',
77-
});
78-
handleClose();
79-
return;
80-
}
81-
82-
if (notFoundUsers.length) {
83-
setErrorUsers(notFoundUsers);
84-
setIsError(true);
85-
setFormValues((prev) => ({
86-
...prev,
87-
users: notFoundUsers.join(', '),
88-
}));
89-
90-
const toastMessage = successfulCount
91-
? intl.formatMessage(messages['libraries.authz.manage.add.member.partial'], {
92-
countSuccess: successfulCount,
93-
countFailure: notFoundUsers.length,
94-
Bold,
95-
Br,
96-
})
97-
: intl.formatMessage(messages['libraries.authz.manage.add.member.failure'], {
98-
count: notFoundUsers.length,
99-
Bold,
100-
Br,
101-
});
102-
103-
showToast({
104-
message: toastMessage,
105-
type: 'error',
79+
const result: Array<AppToastOmitIdType> = [];
80+
81+
const errorTypes = [
82+
{
83+
errorMessageId: 'libraries.authz.manage.assign.role.existing',
84+
users: alreadyHasRole,
85+
},
86+
{
87+
errorMessageId: 'libraries.authz.manage.add.member.failure.not.found',
88+
users: notFoundUsers,
89+
},
90+
{
91+
errorMessageId: 'libraries.authz.manage.add.member.failure.generic',
92+
users: otherErrors,
93+
},
94+
];
95+
96+
errorTypes.forEach(({ errorMessageId, users }) => {
97+
if (users.length === 0) { return; }
98+
const errorMessage = intl.formatMessage(messages[errorMessageId], {
99+
count: users.length,
100+
userIds: users.join(', '),
101+
Bold,
102+
Br,
106103
});
107-
}
104+
result.push({ message: errorMessage, type: 'error' });
105+
});
106+
107+
return result;
108+
};
109+
110+
const buildSuccessMessage = (completed: PutAssignTeamMembersRoleResponse['completed']): AppToastOmitIdType => {
111+
const userIds = completed.map((user) => user.userIdentifier).join(', ');
112+
const successMessage = intl.formatMessage(messages['libraries.authz.manage.add.member.success'], {
113+
count: completed.length,
114+
userIds,
115+
});
116+
117+
return {
118+
message: successMessage,
119+
type: 'success',
120+
};
108121
};
109122

110123
const handleAddTeamMember = () => {
@@ -125,20 +138,32 @@ const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({ libraryId }
125138
assignTeamMembersRole(variables, {
126139
onSuccess: (response) => {
127140
const { completed, errors } = response;
141+
const feedbackMessages: Array<AppToastOmitIdType> = [];
128142

129-
if (completed.length && !errors.length) {
130-
showToast({
131-
message: intl.formatMessage(messages['libraries.authz.manage.add.member.success'], {
132-
count: completed.length,
133-
}),
134-
type: 'success',
135-
});
136-
handleClose();
137-
return;
143+
if (completed.length) {
144+
feedbackMessages.push(buildSuccessMessage(completed));
138145
}
139-
140146
if (errors.length) {
141-
handleErrors(errors, completed.length);
147+
const errorMessages = buildErrorMessages(errors);
148+
feedbackMessages.push(...errorMessages);
149+
150+
const errorUserIds = normalizedUsers.filter((user) => !completed.map(c => c.userIdentifier).includes(user));
151+
setErrorUsers(errorUserIds);
152+
setIsError(true);
153+
setFormValues((prev) => ({
154+
...prev,
155+
users: errorUserIds.join(', '),
156+
}));
157+
}
158+
159+
// Calculate delay based on the number of feedback messages, 5 seconds per message
160+
const delay = DEFAULT_TOAST_DELAY * feedbackMessages.length;
161+
feedbackMessages.forEach(({ message, type }) => {
162+
showToast({ message, type, delay });
163+
});
164+
165+
if (!errors.length) {
166+
handleClose();
142167
}
143168
},
144169
onError: (error, retryVariables) => {

0 commit comments

Comments
 (0)