Skip to content
Merged
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
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 @@ -21,20 +21,23 @@ jest.mock('./AddNewTeamMemberModal', () => {
isOpen, close, onSave, isLoading, formValues, handleChangeForm,
}) => (
isOpen ? (
<div data-testid="add-team-member-modal">
<button type="button" onClick={close} data-testid="close-modal">Close</button>
<button type="button" onClick={onSave} data-testid="save-modal">Save</button>
<div data-testid="add-team-member-modal" role="dialog" aria-label="Add New Team Member">
<button type="button" onClick={close} aria-label="Close modal" data-testid="close-modal">Close</button>
<button type="button" onClick={onSave} aria-label="Save team member" data-testid="save-modal">Save</button>
<textarea
name="users"
value={formValues?.users || ''}
onChange={handleChangeForm}
data-testid="users-input"
aria-label="Enter user emails or usernames"
placeholder="Enter emails or usernames"
/>
<select
name="role"
value={formValues?.role || ''}
onChange={handleChangeForm}
data-testid="role-select"
aria-label="Select role"
>
<option value="">Select role</option>
<option value="admin">Admin</option>
Expand Down Expand Up @@ -146,7 +149,7 @@ describe('AddNewTeamMemberTrigger', () => {
expect(screen.queryByTestId('add-team-member-modal')).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 @@ -179,6 +182,50 @@ describe('AddNewTeamMemberTrigger', () => {
expect(screen.getByTestId('add-team-member-modal')).toBeInTheDocument();
});

it('filters out successfully added users from error users list', async () => {
const user = userEvent.setup();

const mockPartialResponse = {
completed: [
{ userIdentifier: '[email protected]' },
],
errors: [
{ userIdentifier: '[email protected]', error: 'USER_NOT_FOUND' },
{ userIdentifier: '[email protected]', 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, '[email protected], [email protected], [email protected]');
await user.selectOptions(roleSelect, 'editor');
await user.click(saveButton);

await waitFor(() => {
expect(usersInput).toHaveValue('[email protected], [email protected]');
});

await user.type(usersInput, ', [email protected]');

await waitFor(() => {
expect(usersInput).toHaveValue('[email protected], [email protected], [email protected]');
});
});

it('displays only error toast when all additions fail', async () => {
const user = userEvent.setup();
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
Expand Down Expand Up @@ -207,6 +254,33 @@ describe('AddNewTeamMemberTrigger', () => {
expect(screen.getByTestId('add-team-member-modal')).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: '[email protected]', error: 'user_not_found' },
{ userIdentifier: '[email protected]', 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>);
Expand Down Expand Up @@ -258,7 +332,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
Loading