Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
76 changes: 76 additions & 0 deletions src/authz-module/libraries-manager/ToastManagerContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
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 @@ -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>);
Expand Down Expand Up @@ -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 \(unknown@example.com\)/)).toBeInTheDocument();
expect(screen.getByText(/The user already has the role \(already@example.com\)/)).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>);
Expand Down Expand Up @@ -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
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
Loading