Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
}

Expand Down
7 changes: 5 additions & 2 deletions src/authz-module/libraries-manager/ToastManagerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

interface AppToast {
export interface AppToast {
id: string;
message: string;
type: ToastType;
onRetry?: () => void;
delay?: number;
}

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

const showToast = (toast: Omit<AppToast, 'id'>) => {
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]);
};
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppToast, 'id'>;
interface AddNewTeamMemberTriggerProps {
libraryId: string;
}
Expand Down Expand Up @@ -58,53 +60,64 @@ const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({ libraryId }
setFormValues((prev) => ({ ...prev, [name]: value }));
};

const handleErrors = (
const buildErrorMessages = (
errors: PutAssignTeamMembersRoleResponse['errors'],
successfulCount: number,
) => {
): Array<AppToastOmitIdType> => {
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<AppToastOmitIdType> = [];

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 = () => {
Expand All @@ -125,20 +138,32 @@ const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({ libraryId }
assignTeamMembersRole(variables, {
onSuccess: (response) => {
const { completed, errors } = response;
const feedbackMessages: Array<AppToastOmitIdType> = [];

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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<Bold>We couldn\'t find a user for {count, plural, one {# email address or username.} other {# email addresses or usernames.}}</Bold><Br></Br> 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: '<Bold>We couldn\'t find a user for {count, plural, one {# email address or username} other {# email addresses or usernames}} ({userIds}).</Bold><Br></Br> 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: '<Bold>{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.}}</Bold><Br></Br> 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: '<Bold>We couldn\'t assign the role to {count, plural, one {team member} other {team members}} ({userIds})}.</Bold><Br></Br> 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': {
Expand Down
2 changes: 2 additions & 0 deletions src/authz-module/libraries-manager/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;