fix: changing how the feedback message is displayed for adding team members (#42)
* fix: changing how the feedback message is displayed for adding team members * fix: updating feedback to consider existing roles a successful messages * fix: already has role message update * feat: update not found message
This commit is contained in:
@@ -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 }[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<button type="button" onClick={handleShowToastWithDelay}>Show Toast With Custom Delay</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<DelayTestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
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 (
|
||||
<button type="button" onClick={handleShowToastWithoutDelay}>Show Toast With Default Delay</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<DefaultDelayTestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<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>;
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
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(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
@@ -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(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
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(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -58,53 +60,72 @@ 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 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',
|
||||
const result: Array<AppToastOmitIdType> = [];
|
||||
|
||||
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,
|
||||
});
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
result.push({ message: errorMessage, type: 'error' });
|
||||
});
|
||||
|
||||
if (notFoundUsers.length) {
|
||||
setErrorUsers(notFoundUsers);
|
||||
setIsError(true);
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
users: notFoundUsers.join(', '),
|
||||
}));
|
||||
return result;
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
showToast({
|
||||
message: toastMessage,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
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<AddNewTeamMemberTriggerProps> = ({ libraryId }
|
||||
assignTeamMembersRole(variables, {
|
||||
onSuccess: (response) => {
|
||||
const { completed, errors } = response;
|
||||
const feedbackMessages: Array<AppToastOmitIdType> = [];
|
||||
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 && !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 (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) => {
|
||||
|
||||
@@ -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}}.</Bold><Br></Br> 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: '<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 {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': {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user