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.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..358796a 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 () => { @@ -259,7 +259,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..a5f4f46 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,64 @@ 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 alreadyHasRole = errors + .filter((err) => err.error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE) + .map((err) => err.userIdentifier.trim()); + + 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.assign.role.existing', + users: alreadyHasRole, + }, + { + 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 handleAddTeamMember = () => { @@ -125,20 +138,32 @@ const AddNewTeamMemberTrigger: FC = ({ libraryId } assignTeamMembersRole(variables, { onSuccess: (response) => { const { completed, errors } = response; + const feedbackMessages: Array = []; - if (completed.length && !errors.length) { - showToast({ - message: intl.formatMessage(messages['libraries.authz.manage.add.member.success'], { - count: completed.length, - }), - type: 'success', - }); - handleClose(); - return; + if (completed.length) { + feedbackMessages.push(buildSuccessMessage(completed)); } - if (errors.length) { - handleErrors(errors, completed.length); + const errorMessages = buildErrorMessages(errors); + feedbackMessages.push(...errorMessages); + + const errorUserIds = normalizedUsers.filter((user) => !completed.map(c => c.userIdentifier).includes(user)); + setErrorUsers(errorUserIds); + setIsError(true); + setFormValues((prev) => ({ + ...prev, + users: errorUserIds.join(', '), + })); + } + + // 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 (!errors.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..3331690 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}} ({userIds}).

Please check the values 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 user already has 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 27446a1..e57f138 100644 --- a/src/authz-module/libraries-manager/constants.ts +++ b/src/authz-module/libraries-manager/constants.ts @@ -49,3 +49,5 @@ export const libraryPermissions: PermissionMetadata[] = [ { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, { 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;