diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts
index 2ff3724..1821956 100644
--- a/src/authz-module/data/api.ts
+++ b/src/authz-module/data/api.ts
@@ -40,7 +40,7 @@ export type PermissionsByRole = {
userCount: number;
};
export interface PutAssignTeamMembersRoleResponse {
- completed: { user: string; status: string }[];
+ completed: { userIdentifier: string; status: string }[];
errors: { userIdentifier: string; error: string }[];
}
diff --git a/src/authz-module/libraries-manager/ToastManagerContext.test.tsx b/src/authz-module/libraries-manager/ToastManagerContext.test.tsx
index fe67c41..23bfc9a 100644
--- a/src/authz-module/libraries-manager/ToastManagerContext.test.tsx
+++ b/src/authz-module/libraries-manager/ToastManagerContext.test.tsx
@@ -137,4 +137,80 @@ describe('ToastManagerContext', () => {
expect(logError).toHaveBeenCalled();
expect(retryFn).toHaveBeenCalled();
});
+
+ it('respects custom delay when provided', async () => {
+ const user = userEvent.setup();
+
+ const DelayTestComponent = () => {
+ const { showToast } = useToastManager();
+
+ const handleShowToastWithDelay = () => showToast({
+ message: 'Custom delay toast',
+ type: 'success',
+ delay: 1000, // Custom 1 second delay
+ });
+
+ return (
+
+ );
+ };
+
+ renderWrapper(
+
+
+ ,
+ );
+
+ const showButton = screen.getByText('Show Toast With Custom Delay');
+ await user.click(showButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Custom delay toast')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Custom delay toast')).toBeInTheDocument();
+ }, { timeout: 600 });
+
+ // Toast should disappear after the custom delay (1000ms)
+ await waitFor(() => {
+ expect(screen.queryByText('Custom delay toast')).not.toBeInTheDocument();
+ }, { timeout: 1200 });
+ });
+
+ it('uses default delay when delay prop is not provided', async () => {
+ const user = userEvent.setup();
+
+ const DefaultDelayTestComponent = () => {
+ const { showToast } = useToastManager();
+
+ const handleShowToastWithoutDelay = () => showToast({
+ message: 'Default delay toast',
+ type: 'success',
+ // No delay prop provided
+ });
+
+ return (
+
+ );
+ };
+
+ renderWrapper(
+
+
+ ,
+ );
+
+ const showButton = screen.getByText('Show Toast With Default Delay');
+ await user.click(showButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Default delay toast')).toBeInTheDocument();
+ });
+
+ // DEFAULT_TOAST_DELAY is 5000ms
+ await waitFor(() => {
+ expect(screen.queryByText('Default delay toast')).not.toBeInTheDocument();
+ }, { timeout: 5050 });
+ }, 5100);
});
diff --git a/src/authz-module/libraries-manager/ToastManagerContext.tsx b/src/authz-module/libraries-manager/ToastManagerContext.tsx
index 12089f4..7f0d9b1 100644
--- a/src/authz-module/libraries-manager/ToastManagerContext.tsx
+++ b/src/authz-module/libraries-manager/ToastManagerContext.tsx
@@ -5,6 +5,7 @@ import { logError } from '@edx/frontend-platform/logging';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Toast } from '@openedx/paragon';
import messages from './messages';
+import { DEFAULT_TOAST_DELAY } from './constants';
type ToastType = 'success' | 'error' | 'error-retry';
@@ -19,11 +20,12 @@ export const ERROR_TOAST_MAP: Record void;
+ delay?: number;
}
const Bold = (chunk: string) => {chunk};
@@ -47,7 +49,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]);
const showToast = (toast: Omit) => {
- const id = `toast-notification-${Date.now()}`;
+ const id = `toast-notification-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
const newToast = { ...toast, id, visible: true };
setToasts(prev => [...prev, newToast]);
};
@@ -92,6 +94,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
key={toast.id}
show={toast.visible}
onClose={() => discardToast(toast.id)}
+ delay={toast.delay ?? DEFAULT_TOAST_DELAY}
action={toast.onRetry ? {
onClick: () => {
discardToast(toast.id);
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx
index 141026e..a50d7e7 100644
--- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx
@@ -147,7 +147,7 @@ describe('AddNewTeamMemberTrigger', () => {
expect(screen.queryByRole('dialog', { name: 'Add New Team Member' })).not.toBeInTheDocument();
});
- expect(screen.getByText('2 team members added successfully.')).toBeInTheDocument();
+ expect(screen.getByText(/2 team members added successfully/)).toBeInTheDocument();
});
it('displays mixed success and error toast on partial success', async () => {
@@ -180,6 +180,50 @@ describe('AddNewTeamMemberTrigger', () => {
expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
});
+ it('filters out successfully added users from error users list', async () => {
+ const user = userEvent.setup();
+
+ const mockPartialResponse = {
+ completed: [
+ { userIdentifier: 'alice@example.com' },
+ ],
+ errors: [
+ { userIdentifier: 'bob@example.com', error: 'USER_NOT_FOUND' },
+ { userIdentifier: 'charlie@example.com', error: 'USER_NOT_FOUND' },
+ ],
+ };
+
+ (useAssignTeamMembersRole as jest.Mock).mockReturnValue({
+ mutate: jest.fn((_variables, { onSuccess }) => {
+ onSuccess(mockPartialResponse);
+ }),
+ isPending: false,
+ });
+
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const usersInput = screen.getByRole('textbox', { name: /Enter user emails or usernames/i });
+ const roleSelect = screen.getByRole('combobox', { name: /Select role/i });
+ const saveButton = screen.getByRole('button', { name: 'Save team member' });
+
+ await user.type(usersInput, 'alice@example.com, bob@example.com, charlie@example.com');
+ await user.selectOptions(roleSelect, 'editor');
+ await user.click(saveButton);
+
+ await waitFor(() => {
+ expect(usersInput).toHaveValue('bob@example.com, charlie@example.com');
+ });
+
+ await user.type(usersInput, ', new@example.com');
+
+ await waitFor(() => {
+ expect(usersInput).toHaveValue('bob@example.com, charlie@example.com, new@example.com');
+ });
+ });
+
it('displays only error toast when all additions fail', async () => {
const user = userEvent.setup();
renderWrapper();
@@ -208,6 +252,33 @@ describe('AddNewTeamMemberTrigger', () => {
expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
});
+ it('displays different error toast when different errors happen', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const triggerButton = screen.getByRole('button', { name: /add new team member/i });
+ await user.click(triggerButton);
+
+ const saveButton = screen.getByRole('button', { name: 'Save team member' });
+ await user.click(saveButton);
+
+ const [, { onSuccess }] = mockMutate.mock.calls[0];
+ onSuccess({
+ completed: [],
+ errors: [
+ { userIdentifier: 'unknown@example.com', error: 'user_not_found' },
+ { userIdentifier: 'already@example.com', error: 'user_already_has_role' },
+ ],
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/We couldn't find a user for 1 email address or username/)).toBeInTheDocument();
+ expect(screen.getByText(/The user already has the role/)).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('dialog', { name: 'Add New Team Member' })).toBeInTheDocument();
+ });
+
it('resets form values after successful addition with no errors', async () => {
const user = userEvent.setup();
renderWrapper();
@@ -259,7 +330,7 @@ describe('AddNewTeamMemberTrigger', () => {
// Toast should be visible
await waitFor(() => {
- expect(screen.getByText('1 team member added successfully.')).toBeInTheDocument();
+ expect(screen.getByText(/1 team member added successfully/)).toBeInTheDocument();
});
// Find and close the toast
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
index f58d691..981cc07 100644
--- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
@@ -6,10 +6,12 @@ import { Plus } from '@openedx/paragon/icons';
import { PutAssignTeamMembersRoleResponse } from 'authz-module/data/api';
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
import { RoleOperationErrorStatus } from '@src/authz-module/constants';
-import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
+import { AppToast, useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
+import { DEFAULT_TOAST_DELAY } from '@src/authz-module/libraries-manager/constants';
import AddNewTeamMemberModal from './AddNewTeamMemberModal';
import messages from './messages';
+type AppToastOmitIdType = Omit;
interface AddNewTeamMemberTriggerProps {
libraryId: string;
}
@@ -58,53 +60,72 @@ const AddNewTeamMemberTrigger: FC = ({ libraryId }
setFormValues((prev) => ({ ...prev, [name]: value }));
};
- const handleErrors = (
+ const buildErrorMessages = (
errors: PutAssignTeamMembersRoleResponse['errors'],
- successfulCount: number,
- ) => {
+ ): Array => {
const notFoundUsers = errors
.filter((err) => err.error === RoleOperationErrorStatus.USER_NOT_FOUND)
.map((err) => err.userIdentifier.trim());
- const alreadyHasRole = errors.some(
- (err) => err.error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE,
+ const otherErrors = errors.filter(
+ (err) => err.error !== RoleOperationErrorStatus.USER_NOT_FOUND
+ && err.error !== RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE,
);
- if (alreadyHasRole && errors.length === 1 && !successfulCount) {
- showToast({
- message: intl.formatMessage(messages['libraries.authz.manage.assign.role.existing']),
- type: 'error',
- });
- handleClose();
- return;
- }
-
- if (notFoundUsers.length) {
- setErrorUsers(notFoundUsers);
- setIsError(true);
- setFormValues((prev) => ({
- ...prev,
- users: notFoundUsers.join(', '),
- }));
-
- const toastMessage = successfulCount
- ? intl.formatMessage(messages['libraries.authz.manage.add.member.partial'], {
- countSuccess: successfulCount,
- countFailure: notFoundUsers.length,
- Bold,
- Br,
- })
- : intl.formatMessage(messages['libraries.authz.manage.add.member.failure'], {
- count: notFoundUsers.length,
- Bold,
- Br,
- });
-
- showToast({
- message: toastMessage,
- type: 'error',
+ const result: Array = [];
+
+ const errorTypes = [
+ {
+ errorMessageId: 'libraries.authz.manage.add.member.failure.not.found',
+ users: notFoundUsers,
+ },
+ {
+ errorMessageId: 'libraries.authz.manage.add.member.failure.generic',
+ users: otherErrors,
+ },
+ ];
+
+ errorTypes.forEach(({ errorMessageId, users }) => {
+ if (users.length === 0) { return; }
+ const errorMessage = intl.formatMessage(messages[errorMessageId], {
+ count: users.length,
+ userIds: users.join(', '),
+ Bold,
+ Br,
});
- }
+ result.push({ message: errorMessage, type: 'error' });
+ });
+
+ return result;
+ };
+
+ const buildSuccessMessage = (completed: PutAssignTeamMembersRoleResponse['completed']): AppToastOmitIdType => {
+ const userIds = completed.map((user) => user.userIdentifier).join(', ');
+ const successMessage = intl.formatMessage(messages['libraries.authz.manage.add.member.success'], {
+ count: completed.length,
+ userIds,
+ });
+
+ return {
+ message: successMessage,
+ type: 'success',
+ };
+ };
+
+ const buildRoleAlreadyAssignedMessage = (
+ roleAlreadyAsignedUsers: PutAssignTeamMembersRoleResponse['errors'],
+ ): AppToastOmitIdType => {
+ const roleAlreadyAssignedUserIds = roleAlreadyAsignedUsers.map((err) => err.userIdentifier.trim());
+ const roleAlreadyAssignedMessage = intl.formatMessage(messages['libraries.authz.manage.assign.role.existing'], {
+ count: roleAlreadyAssignedUserIds.length,
+ userIds: roleAlreadyAssignedUserIds.join(', '),
+ Bold,
+ Br,
+ });
+ return {
+ message: roleAlreadyAssignedMessage,
+ type: 'success',
+ };
};
const handleAddTeamMember = () => {
@@ -125,20 +146,44 @@ const AddNewTeamMemberTrigger: FC = ({ libraryId }
assignTeamMembersRole(variables, {
onSuccess: (response) => {
const { completed, errors } = response;
-
- if (completed.length && !errors.length) {
- showToast({
- message: intl.formatMessage(messages['libraries.authz.manage.add.member.success'], {
- count: completed.length,
- }),
- type: 'success',
- });
- handleClose();
- return;
+ const feedbackMessages: Array = [];
+ const { USER_ALREADY_HAS_ROLE } = RoleOperationErrorStatus;
+ // Users who already have the role assigned are not considered errors
+ const roleAlreadyAssignedUsers = errors.filter((error) => error.error === USER_ALREADY_HAS_ROLE);
+ const cleanErrors = errors.filter((error) => error.error !== USER_ALREADY_HAS_ROLE);
+
+ if (completed.length) {
+ feedbackMessages.push(buildSuccessMessage(completed));
+ }
+ if (roleAlreadyAssignedUsers.length) {
+ feedbackMessages.push(buildRoleAlreadyAssignedMessage(roleAlreadyAssignedUsers));
+ }
+ if (cleanErrors.length) {
+ const errorMessages = buildErrorMessages(cleanErrors);
+ feedbackMessages.push(...errorMessages);
+
+ const successUserIds = [
+ ...completed.map(c => c.userIdentifier),
+ ...roleAlreadyAssignedUsers.map(r => r.userIdentifier),
+ ];
+
+ const errorUserIds = normalizedUsers.filter((user) => !successUserIds.includes(user));
+ setErrorUsers(errorUserIds);
+ setIsError(true);
+ setFormValues((prev) => ({
+ ...prev,
+ users: errorUserIds.join(', '),
+ }));
}
- if (errors.length) {
- handleErrors(errors, completed.length);
+ // Calculate delay based on the number of feedback messages, 5 seconds per message
+ const delay = DEFAULT_TOAST_DELAY * feedbackMessages.length;
+ feedbackMessages.forEach(({ message, type }) => {
+ showToast({ message, type, delay });
+ });
+
+ if (!cleanErrors.length) {
+ handleClose();
}
},
onError: (error, retryVariables) => {
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts
index c9daa33..05e28d5 100644
--- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts
@@ -53,22 +53,22 @@ const messages = defineMessages({
},
'libraries.authz.manage.add.member.success': {
id: 'libraries.authz.manage.add.member.success',
- defaultMessage: '{count, plural, one {# team member added successfully.} other {# team members added successfully.}}',
+ defaultMessage: '{count, plural, one {# team member added successfully} other {# team members added successfully}} ({userIds})',
description: 'Success message when adding new team members',
},
- 'libraries.authz.manage.add.member.failure': {
- id: 'libraries.authz.manage.add.member.failure',
- defaultMessage: 'We couldn\'t find a user for {count, plural, one {# email address or username.} other {# email addresses or usernames.}}
Please check the values and try again, or invite them to join your organization first.',
- description: 'Error message when adding new team members',
+ 'libraries.authz.manage.add.member.failure.not.found': {
+ id: 'libraries.authz.manage.add.member.failure.not.found',
+ defaultMessage: 'We couldn\'t find a user for {count, plural, one {# email address or username} other {# email addresses or usernames}}.
Please check the values ({userIds}) and try again, or invite them to join your organization first.',
+ description: 'Error message in case of user not found when adding new team members',
},
- 'libraries.authz.manage.add.member.partial': {
- id: 'libraries.authz.manage.add.member.failure',
- defaultMessage: '{countSuccess, plural, one {# team member added successfully.} other {# team members added successfully.}}. We couldn\'t find a user for {countFailure, plural, one {# email address or username.} other {# email addresses or usernames.}}
Please check the values and try again, or invite them to join your organization first.',
- description: 'Error message when adding new team members',
+ 'libraries.authz.manage.add.member.failure.generic': {
+ id: 'libraries.authz.manage.add.member.failure.generic',
+ defaultMessage: 'We couldn\'t assign the role to {count, plural, one {team member} other {team members}} ({userIds})}.
Please check the values and try again.',
+ description: 'Generic error message when adding new team members',
},
'libraries.authz.manage.assign.role.existing': {
id: 'libraries.authz.manage.assign.existing',
- defaultMessage: 'The user already has the role.',
+ defaultMessage: 'The {count, plural, one {user already has} other {users already have}} the role ({userIds}).',
description: 'Libraries AuthZ assign existing role',
},
'libraries.authz.manage.tooltip.roles.extra.info': {
diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts
index 9c7598c..c89be35 100644
--- a/src/authz-module/libraries-manager/constants.ts
+++ b/src/authz-module/libraries-manager/constants.ts
@@ -50,6 +50,7 @@ export const libraryPermissions: PermissionMetadata[] = [
{ key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
];
+export const DEFAULT_TOAST_DELAY = 5000;
export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
username: 'skeleton',
name: '',