feat(authz): [FC-0099] Manage action feedback using the Toast component. (#26)
Surface errors via toasts while keeping the modal open. This ensures they can retry or correct input without losing context, improving usability and reducing frustration.
This commit is contained in:
@@ -4,13 +4,13 @@ module.exports = createConfig('jest', {
|
||||
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
|
||||
// If you want to add config BEFORE jest loads, use setupFiles instead.
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.jsx',
|
||||
'<rootDir>/src/setupTest.tsx',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@src/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.jsx',
|
||||
'src/setupTest.tsx',
|
||||
'src/i18n',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -65,4 +65,10 @@
|
||||
// Move toast to the right
|
||||
left: auto;
|
||||
right: var(--pgn-spacing-toast-container-gutter-lg);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix a bug with a toast on edit tags sheet component: can't click on close toast button
|
||||
// https://github.com/openedx/frontend-app-authoring/issues/1898
|
||||
#toast-root[data-focus-on-hidden] {
|
||||
pointer-events: initial !important;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ jest.mock('./context', () => {
|
||||
LibraryAuthZProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
|
||||
|
||||
jest.mock('@src/authz-module/data/hooks', () => ({
|
||||
@@ -165,6 +166,10 @@ describe('LibrariesTeamManager', () => {
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const { onSuccess } = (mutate as jest.Mock).mock.calls[0][1];
|
||||
onSuccess?.();
|
||||
|
||||
expect(await screen.findByText(/updated successfully/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the toggle if the user can not manage team and the Library Public Read is disabled', () => {
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('LibrariesUserManager', () => {
|
||||
await user.click(removeButton);
|
||||
|
||||
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback();
|
||||
onSuccessCallback({ errors: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/The Admin role has been successfully removed/)).toBeInTheDocument();
|
||||
@@ -278,14 +278,14 @@ describe('LibrariesUserManager', () => {
|
||||
await user.click(removeButton);
|
||||
|
||||
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback();
|
||||
onSuccessCallback({ errors: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/The user no longer has access to this library/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast when role revocation fails', async () => {
|
||||
it('shows error toast when role revocation fails with server error', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
@@ -302,8 +302,50 @@ describe('LibrariesUserManager', () => {
|
||||
const onErrorCallback = mockMutate.mock.calls[0][1].onError;
|
||||
onErrorCallback(new Error('Network error'));
|
||||
|
||||
// Wait for the error toast to appear with a retry button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong on our end/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Second call to mutate also fails
|
||||
mockMutate.mockImplementationOnce((_vars, { onError }) => {
|
||||
onError(new Error('Network error'), _vars);
|
||||
});
|
||||
|
||||
// Click retry button
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
await user.click(retryButton);
|
||||
|
||||
// The retry toast should appear again
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Something went wrong/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Ensure mutate was called twice (original + retry)
|
||||
expect(mockMutate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows error toast when API fails to remove a role', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent();
|
||||
|
||||
const deleteButton = screen.getByText('delete-role-Admin');
|
||||
await user.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remove role?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const removeButton = screen.getByText('Remove');
|
||||
await user.click(removeButton);
|
||||
|
||||
const { onSuccess } = mockMutate.mock.calls[0][1];
|
||||
onSuccess({ errors: [{ error: 'role_removal_error' }] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,11 +364,12 @@ describe('LibrariesUserManager', () => {
|
||||
await user.click(removeButton);
|
||||
|
||||
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback();
|
||||
onSuccessCallback({ errors: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Remove role?')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByText(/role has been successfully removed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables delete action when revocation is in progress', async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { Container, Skeleton } from '@openedx/paragon';
|
||||
import { ROUTES } from '@src/authz-module/constants';
|
||||
import { Role } from 'types';
|
||||
@@ -47,7 +46,9 @@ const LibrariesUserManager = () => {
|
||||
|
||||
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null);
|
||||
const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false);
|
||||
const { handleShowToast, handleDiscardToast } = useToastManager();
|
||||
const {
|
||||
showToast, showErrorToast, Bold, Br,
|
||||
} = useToastManager();
|
||||
|
||||
const {
|
||||
data: teamMember, isLoading: isLoadingTeamMember, isFetching: isFetchingMember,
|
||||
@@ -78,7 +79,6 @@ const LibrariesUserManager = () => {
|
||||
const handleShowConfirmDeletionModal = (role: Role) => {
|
||||
if (isRevokingUserRole) { return; }
|
||||
|
||||
handleDiscardToast();
|
||||
setRoleToDelete(role);
|
||||
setShowConfirmDeletionModal(true);
|
||||
};
|
||||
@@ -92,25 +92,42 @@ const LibrariesUserManager = () => {
|
||||
scope: libraryId,
|
||||
};
|
||||
|
||||
revokeUserRoles({ data }, {
|
||||
onSuccess: () => {
|
||||
const remainingRolesCount = userRoles.length - 1;
|
||||
handleShowToast(intl.formatMessage(
|
||||
messages['library.authz.team.remove.user.toast.success.description'],
|
||||
{
|
||||
role: roleToDelete.name,
|
||||
rolesCount: remainingRolesCount,
|
||||
},
|
||||
));
|
||||
handleCloseConfirmDeletionModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
logError(error);
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
handleShowToast(intl.formatMessage(messages['library.authz.team.default.error.toast.message'], { b: chunk => <b>{chunk}</b>, br: () => <br /> }));
|
||||
handleCloseConfirmDeletionModal();
|
||||
},
|
||||
});
|
||||
const runRevokeRole = (variables = { data }) => {
|
||||
revokeUserRoles(variables, {
|
||||
onSuccess: (response) => {
|
||||
const { errors } = response;
|
||||
|
||||
if (errors.length) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
message: intl.formatMessage(
|
||||
messages['library.authz.team.toast.default.error.message'],
|
||||
{ Bold, Br },
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingRolesCount = userRoles.length - 1;
|
||||
showToast({
|
||||
message: intl.formatMessage(
|
||||
messages['library.authz.team.remove.user.toast.success.description'],
|
||||
{
|
||||
role: roleToDelete.name,
|
||||
rolesCount: remainingRolesCount,
|
||||
},
|
||||
),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
onError: (error, retryVariables) => {
|
||||
showErrorToast(error, () => runRevokeRole(retryVariables));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
handleCloseConfirmDeletionModal();
|
||||
runRevokeRole();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import { screen, waitFor, render as rtlRender } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { renderWrapper } from '@src/setupTest';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { ToastManagerProvider, useToastManager } from './ToastManagerContext';
|
||||
|
||||
const render = (ui: React.ReactElement) => rtlRender(
|
||||
<IntlProvider locale="en">
|
||||
{ui}
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging');
|
||||
const TestComponent = () => {
|
||||
const { handleShowToast, handleDiscardToast } = useToastManager();
|
||||
const { showToast } = useToastManager();
|
||||
|
||||
const handleShowToast = () => showToast({ message: 'Test toast message', type: 'error' });
|
||||
const handleShowAnotherToast = () => showToast({ message: 'Another message', type: 'success' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => handleShowToast('Test toast message')}>
|
||||
Show Toast
|
||||
</button>
|
||||
<button type="button" onClick={() => handleShowToast('Another message')}>
|
||||
Show Another Toast
|
||||
</button>
|
||||
<button type="button" onClick={handleDiscardToast}>
|
||||
Discard Toast
|
||||
</button>
|
||||
<button type="button" onClick={handleShowToast}>Show Toast</button>
|
||||
<button type="button" onClick={handleShowAnotherToast}>Show Another Toast</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +22,7 @@ const TestComponent = () => {
|
||||
describe('ToastManagerContext', () => {
|
||||
describe('ToastManagerProvider', () => {
|
||||
it('does not show toast initially', () => {
|
||||
render(
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<TestComponent />
|
||||
</ToastManagerProvider>,
|
||||
@@ -39,15 +31,14 @@ describe('ToastManagerContext', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows toast when handleShowToast is called', async () => {
|
||||
it('shows toast when showToast is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<TestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
// handleShowToast is called on button click
|
||||
const showButton = screen.getByText('Show Toast');
|
||||
await user.click(showButton);
|
||||
|
||||
@@ -57,59 +48,29 @@ describe('ToastManagerContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates toast message when handleShowToast is called with different message', async () => {
|
||||
it('adds multiple toasts when showToast is called multiple times', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<TestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
// Show first toast
|
||||
const showButton = screen.getByText('Show Toast');
|
||||
await user.click(showButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test toast message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Show another toast
|
||||
const showAnotherButton = screen.getByText('Show Another Toast');
|
||||
|
||||
await user.click(showButton);
|
||||
await user.click(showAnotherButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Another message')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Test toast message')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides toast when handleDiscardToast is called', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ToastManagerProvider>
|
||||
<TestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
const showButton = screen.getByText('Show Toast');
|
||||
await user.click(showButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test toast message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// handleDiscardToast is called on button click
|
||||
const discardButton = screen.getByText('Discard Toast');
|
||||
await user.click(discardButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Another message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides toast when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<TestComponent />
|
||||
</ToastManagerProvider>,
|
||||
@@ -127,39 +88,13 @@ describe('ToastManagerContext', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls handleClose callback when toast is closed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleClose = jest.fn();
|
||||
|
||||
render(
|
||||
<ToastManagerProvider handleClose={mockHandleClose}>
|
||||
<TestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
const showButton = screen.getByText('Show Toast');
|
||||
await user.click(showButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test toast message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await user.click(closeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}, { timeout: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useToastManager hook', () => {
|
||||
it('throws error when used outside ToastManagerProvider', () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
|
||||
|
||||
const TestComponentWithoutProvider = () => {
|
||||
useToastManager();
|
||||
@@ -167,10 +102,39 @@ describe('ToastManagerContext', () => {
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponentWithoutProvider />);
|
||||
}).toThrow('useToastManager must be used within an ToastManagerProvider');
|
||||
renderWrapper(<TestComponentWithoutProvider />);
|
||||
}).toThrow('useToastManager must be used within a ToastManagerProvider');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls retry function when retry button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const retryFn = jest.fn();
|
||||
|
||||
const ErrorTestComponent = () => {
|
||||
const { showErrorToast } = useToastManager();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showErrorToast({ customAttributes: { httpErrorStatus: 500 } }, retryFn)}
|
||||
>Retry Error
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<ErrorTestComponent />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Retry Error'));
|
||||
const retryButton = await screen.findByText('Retry');
|
||||
await user.click(retryButton);
|
||||
|
||||
expect(logError).toHaveBeenCalled();
|
||||
expect(retryFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,57 +1,117 @@
|
||||
import {
|
||||
createContext, useContext, useMemo, useState,
|
||||
createContext, useContext, useState, useMemo,
|
||||
} from 'react';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Toast } from '@openedx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'error-retry';
|
||||
|
||||
export const ERROR_TOAST_MAP: Record<number | string, { type: ToastType; messageId: string }> = {
|
||||
// Transient (retryable) server errors
|
||||
500: { type: 'error-retry', messageId: 'library.authz.team.toast.500.error.message' },
|
||||
502: { type: 'error-retry', messageId: 'library.authz.team.toast.502.error.message' },
|
||||
503: { type: 'error-retry', messageId: 'library.authz.team.toast.503.error.message' },
|
||||
408: { type: 'error-retry', messageId: 'library.authz.team.toast.408.error.message' },
|
||||
|
||||
// Generic fallback error
|
||||
DEFAULT: { type: 'error-retry', messageId: 'library.authz.team.toast.default.error.message' },
|
||||
};
|
||||
|
||||
interface AppToast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
const Bold = (chunk: string) => <b>{chunk}</b>;
|
||||
const Br = () => <br />;
|
||||
|
||||
type ToastManagerContextType = {
|
||||
handleShowToast: (message: string) => void;
|
||||
handleDiscardToast: () => void;
|
||||
showToast: (toast: Omit<AppToast, 'id'>) => void;
|
||||
showErrorToast: (error, retryFn?: () => void) => void;
|
||||
Bold: (chunk: string) => JSX.Element;
|
||||
Br: () => JSX.Element;
|
||||
};
|
||||
|
||||
const ToastManagerContext = createContext<ToastManagerContextType | undefined>(undefined);
|
||||
|
||||
interface ToastManagerProviderProps {
|
||||
handleClose?: () => void
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
}
|
||||
|
||||
export const ToastManagerProvider = ({ handleClose, children }: ToastManagerProviderProps) => {
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) => {
|
||||
const intl = useIntl();
|
||||
const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]);
|
||||
|
||||
const handleShowToast = (message: string) => {
|
||||
setToastMessage(message);
|
||||
const showToast = (toast: Omit<AppToast, 'id'>) => {
|
||||
const id = `toast-notification-${Date.now()}`;
|
||||
const newToast = { ...toast, id, visible: true };
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
};
|
||||
|
||||
const handleDiscardToast = () => {
|
||||
setToastMessage(null);
|
||||
const discardToast = (id: string) => {
|
||||
setToasts(prev => prev.map(t => (t.id === id ? { ...t, visible: false } : t)));
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const value = useMemo((): ToastManagerContextType => ({
|
||||
handleShowToast,
|
||||
handleDiscardToast,
|
||||
}), []);
|
||||
const value = useMemo<ToastManagerContextType>(() => {
|
||||
const showErrorToast = (error, retryFn?: () => void) => {
|
||||
logError(error);
|
||||
const errorStatus = error?.customAttributes?.httpErrorStatus;
|
||||
const toastConfig = ERROR_TOAST_MAP[errorStatus] || ERROR_TOAST_MAP.DEFAULT;
|
||||
const message = intl.formatMessage(messages[toastConfig.messageId], { Bold, Br });
|
||||
|
||||
showToast({
|
||||
message,
|
||||
type: toastConfig.type,
|
||||
onRetry: toastConfig.type === 'error-retry' && retryFn ? retryFn : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return ({
|
||||
showToast,
|
||||
showErrorToast,
|
||||
Bold,
|
||||
Br,
|
||||
});
|
||||
}, [intl]);
|
||||
|
||||
return (
|
||||
<ToastManagerContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
<Toast
|
||||
onClose={() => {
|
||||
if (handleClose) { handleClose(); }
|
||||
setToastMessage(null);
|
||||
}}
|
||||
show={!!toastMessage}
|
||||
>
|
||||
{toastMessage ?? ''}
|
||||
</Toast>
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
show={toast.visible}
|
||||
onClose={() => discardToast(toast.id)}
|
||||
action={toast.onRetry ? {
|
||||
onClick: () => {
|
||||
discardToast(toast.id);
|
||||
toast.onRetry?.();
|
||||
},
|
||||
label: intl.formatMessage(messages['library.authz.team.toast.retry.label']),
|
||||
} : undefined}
|
||||
>
|
||||
{toast.message}
|
||||
</Toast>
|
||||
))}
|
||||
</div>
|
||||
</ToastManagerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToastManager = (): ToastManagerContextType => {
|
||||
const context = useContext(ToastManagerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useToastManager must be used within an ToastManagerProvider');
|
||||
if (!context) {
|
||||
throw new Error('useToastManager must be used within a ToastManagerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ const AddNewTeamMemberModal: FC<AddNewTeamMemberModalProps> = ({
|
||||
size="lg"
|
||||
variant="dark"
|
||||
hasCloseButton
|
||||
isBlocking
|
||||
isOverflowVisible={false}
|
||||
zIndex={5}
|
||||
>
|
||||
|
||||
@@ -3,8 +3,11 @@ import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWrapper } from '@src/setupTest';
|
||||
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
|
||||
import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
|
||||
import AddNewTeamMemberTrigger from './AddNewTeamMemberTrigger';
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging');
|
||||
|
||||
const mockMutate = jest.fn();
|
||||
|
||||
// Mock the hooks module
|
||||
@@ -59,7 +62,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
});
|
||||
|
||||
it('renders the trigger button', () => {
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const button = screen.getByRole('button', { name: /add new team member/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
@@ -67,7 +70,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('opens modal when trigger button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -77,7 +80,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('closes modal when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -92,7 +95,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('calls addTeamMember with correct data when save is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -121,7 +124,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('displays success toast and closes modal on successful addition with no errors', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -148,7 +151,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('displays mixed success and error toast on partial success', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -178,7 +181,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('displays only error toast when all additions fail', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -206,7 +209,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('resets form values after successful addition with no errors', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -238,7 +241,7 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
|
||||
it('allows closing the success/error toast message', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
renderWrapper(<ToastManagerProvider><AddNewTeamMemberTrigger libraryId={mockLibraryId} /></ToastManagerProvider>);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
@@ -268,23 +271,108 @@ describe('AddNewTeamMemberTrigger', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('displays loading state when adding team member', async () => {
|
||||
it('shows retry toast on API failure and displays another toast when retry fails again', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock loading state
|
||||
(useAssignTeamMembersRole as jest.Mock).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
isPending: true,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
} as any);
|
||||
const mockError = new Error('Network error');
|
||||
|
||||
renderWrapper(<AddNewTeamMemberTrigger libraryId={mockLibraryId} />);
|
||||
mockMutate.mockImplementationOnce((_vars, { onError }) => {
|
||||
onError(mockError, _vars);
|
||||
});
|
||||
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<AddNewTeamMemberTrigger libraryId={mockLibraryId} />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
// Loading indicator should be visible in the modal
|
||||
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
mockMutate.mockImplementationOnce((_vars, { onError }) => {
|
||||
onError(new Error('Network error'), _vars);
|
||||
});
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
await user.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Ensure mutate was called twice (original + retry)
|
||||
expect(mockMutate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('displays loading state when adding team member', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { rerender } = renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<AddNewTeamMemberTrigger libraryId="lib:123" />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
const rerenderHook = () => rerender(
|
||||
<ToastManagerProvider>
|
||||
<AddNewTeamMemberTrigger libraryId="lib:123" />
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
let isPending = false;
|
||||
const mutateMock = jest.fn((_args, { onSuccess }) => {
|
||||
isPending = true;
|
||||
rerenderHook();
|
||||
setTimeout(() => {
|
||||
isPending = false;
|
||||
rerenderHook();
|
||||
onSuccess?.({
|
||||
completed: [{ userIdentifier: _args.data.users[0], status: 'role_added' }],
|
||||
errors: [],
|
||||
});
|
||||
}, 10);
|
||||
});
|
||||
|
||||
(useAssignTeamMembersRole as jest.Mock).mockImplementation(() => ({
|
||||
mutate: mutateMock,
|
||||
isPending,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
}));
|
||||
|
||||
const triggerButton = screen.getByRole('button', { name: /add new team member/i });
|
||||
await user.click(triggerButton);
|
||||
|
||||
const userInput = screen.getByTestId('users-input');
|
||||
const roleSelect = screen.getByTestId('role-select');
|
||||
await user.type(userInput, 'alice@example.com');
|
||||
await user.selectOptions(roleSelect, 'editor');
|
||||
|
||||
const saveButton = screen.getByTestId('save-modal');
|
||||
await user.click(saveButton);
|
||||
|
||||
// should now reflect isPending = true
|
||||
const loadingIndicator = await screen.findByTestId('loading-indicator');
|
||||
expect(loadingIndicator).toBeInTheDocument();
|
||||
expect(loadingIndicator).toHaveTextContent('Loading...');
|
||||
|
||||
expect(mutateMock).toHaveBeenCalledWith(
|
||||
{
|
||||
data: {
|
||||
users: ['alice@example.com'],
|
||||
role: 'editor',
|
||||
scope: 'lib:123',
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Toast, useToggle } from '@openedx/paragon';
|
||||
import { Button, useToggle } from '@openedx/paragon';
|
||||
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 AddNewTeamMemberModal from './AddNewTeamMemberModal';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -18,113 +19,142 @@ const DEFAULT_FORM_VALUES = {
|
||||
role: '',
|
||||
};
|
||||
|
||||
const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({
|
||||
libraryId,
|
||||
}) => {
|
||||
const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({ libraryId }) => {
|
||||
const intl = useIntl();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [additionMessage, setAdditionMessage] = useState<string | null>(null);
|
||||
const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [errorValidationUsers, setNotFoundUsers] = useState<string[]>([]);
|
||||
const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole();
|
||||
const [errorUsers, setErrorUsers] = useState<string[]>([]);
|
||||
|
||||
const handleChangeForm = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { mutate: assignTeamMembersRole, isPending } = useAssignTeamMembersRole();
|
||||
const {
|
||||
showToast, showErrorToast, Bold, Br,
|
||||
} = useToastManager();
|
||||
|
||||
const resetForm = () => {
|
||||
setFormValues(DEFAULT_FORM_VALUES);
|
||||
setErrorUsers([]);
|
||||
setIsError(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
close();
|
||||
};
|
||||
|
||||
const handleChangeForm = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
const userIds = value
|
||||
.split(',')
|
||||
.map(userId => userId.trim())
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
const hasErrorUser = errorValidationUsers.find((noUser) => userIds.includes(noUser));
|
||||
|
||||
if (hasErrorUser) {
|
||||
setIsError(true);
|
||||
} else {
|
||||
setIsError(false);
|
||||
}
|
||||
// Flag error if current value still includes invalid users
|
||||
const hasInvalidUser = errorUsers.some((u) => userIds.includes(u));
|
||||
setIsError(hasInvalidUser);
|
||||
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
setFormValues((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleErrors = (errors: PutAssignTeamMembersRoleResponse['errors']) => {
|
||||
setIsError(false);
|
||||
const notFoundUsers = errors.filter(err => err.error === RoleOperationErrorStatus.USER_NOT_FOUND)
|
||||
.map(err => err.userIdentifier.trim());
|
||||
const handleErrors = (
|
||||
errors: PutAssignTeamMembersRoleResponse['errors'],
|
||||
successfulCount: number,
|
||||
) => {
|
||||
const notFoundUsers = errors
|
||||
.filter((err) => err.error === RoleOperationErrorStatus.USER_NOT_FOUND)
|
||||
.map((err) => err.userIdentifier.trim());
|
||||
|
||||
if (errors.length === 1 && errors[0].error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE) {
|
||||
setFormValues(DEFAULT_FORM_VALUES);
|
||||
close();
|
||||
const alreadyHasRole = errors.some(
|
||||
(err) => 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) {
|
||||
setNotFoundUsers(notFoundUsers);
|
||||
setErrorUsers(notFoundUsers);
|
||||
setIsError(true);
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
users: notFoundUsers.join(', '),
|
||||
}));
|
||||
|
||||
setAdditionMessage((prevMessage) => (
|
||||
`${prevMessage ? `${prevMessage} ` : ''}${intl.formatMessage(
|
||||
messages['libraries.authz.manage.add.member.failure'],
|
||||
{ count: notFoundUsers.length },
|
||||
)}`
|
||||
));
|
||||
setShowToast(true);
|
||||
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 handleAddTeamMember = () => {
|
||||
const normalizedUsers = new Set(formValues.users.split(',').map(user => user.trim()).filter(user => user));
|
||||
const data = {
|
||||
users: [...normalizedUsers],
|
||||
const normalizedUsers = [...new Set(
|
||||
formValues.users
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean),
|
||||
)];
|
||||
|
||||
const payload = {
|
||||
users: normalizedUsers,
|
||||
role: formValues.role,
|
||||
scope: libraryId,
|
||||
};
|
||||
|
||||
assignTeamMembersRole({ data }, {
|
||||
onSuccess: (successData) => {
|
||||
setAdditionMessage(null);
|
||||
const runAssignMembers = (variables = { data: payload }) => {
|
||||
assignTeamMembersRole(variables, {
|
||||
onSuccess: (response) => {
|
||||
const { completed, errors } = response;
|
||||
|
||||
if (successData.completed.length) {
|
||||
setAdditionMessage(
|
||||
intl.formatMessage(
|
||||
messages['libraries.authz.manage.add.member.success'],
|
||||
{ count: successData.completed.length },
|
||||
),
|
||||
);
|
||||
setShowToast(true);
|
||||
}
|
||||
if (completed.length && !errors.length) {
|
||||
showToast({
|
||||
message: intl.formatMessage(messages['libraries.authz.manage.add.member.success'], {
|
||||
count: completed.length,
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (successData.errors.length) {
|
||||
handleErrors(successData.errors);
|
||||
} else {
|
||||
setIsError(false);
|
||||
setNotFoundUsers([]);
|
||||
close();
|
||||
setFormValues(DEFAULT_FORM_VALUES);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const handleClose = () => {
|
||||
setFormValues(DEFAULT_FORM_VALUES);
|
||||
setNotFoundUsers([]);
|
||||
setIsError(false);
|
||||
setAdditionMessage(null);
|
||||
close();
|
||||
if (errors.length) {
|
||||
handleErrors(errors, completed.length);
|
||||
}
|
||||
},
|
||||
onError: (error, retryVariables) => {
|
||||
showErrorToast(error, () => runAssignMembers(retryVariables));
|
||||
},
|
||||
});
|
||||
};
|
||||
runAssignMembers();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
key="authz-header-action-new-team-member"
|
||||
iconBefore={Plus}
|
||||
onClick={open}
|
||||
disabled={isPending}
|
||||
>
|
||||
{intl.formatMessage(messages['libraries.authz.manage.add.member.title'])}
|
||||
</Button>
|
||||
@@ -135,20 +165,11 @@ const AddNewTeamMemberTrigger: FC<AddNewTeamMemberTriggerProps> = ({
|
||||
isError={isError}
|
||||
close={handleClose}
|
||||
onSave={handleAddTeamMember}
|
||||
isLoading={isAssignTeamMembersRolePending}
|
||||
isLoading={isPending}
|
||||
formValues={formValues}
|
||||
handleChangeForm={handleChangeForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{additionMessage && (
|
||||
<Toast
|
||||
onClose={() => setShowToast(false)}
|
||||
show={showToast}
|
||||
>
|
||||
{additionMessage}
|
||||
</Toast>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,9 +58,19 @@ const messages = defineMessages({
|
||||
},
|
||||
'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.',
|
||||
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.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.assign.role.existing': {
|
||||
id: 'libraries.authz.manage.assign.existing',
|
||||
defaultMessage: 'The user already has the role.',
|
||||
description: 'Libraries AuthZ assign existing role',
|
||||
},
|
||||
'libraries.authz.manage.tooltip.roles.extra.info': {
|
||||
id: 'libraries.authz.manage.tooltip.roles.extra.info',
|
||||
defaultMessage: 'View detailed permissions for each role.',
|
||||
|
||||
@@ -28,6 +28,7 @@ const AssignNewRoleModal: FC<AssignNewRoleModalProps> = ({
|
||||
size="lg"
|
||||
variant="dark"
|
||||
hasCloseButton
|
||||
isBlocking
|
||||
isOverflowVisible={false}
|
||||
zIndex={5}
|
||||
>
|
||||
|
||||
@@ -3,8 +3,11 @@ import userEvent from '@testing-library/user-event';
|
||||
import { renderWrapper } from '@src/setupTest';
|
||||
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
|
||||
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
|
||||
import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
|
||||
import AssignNewRoleTrigger from './AssignNewRoleTrigger';
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging');
|
||||
|
||||
jest.mock('@src/authz-module/libraries-manager/context', () => ({
|
||||
useLibraryAuthZ: jest.fn(),
|
||||
}));
|
||||
@@ -90,7 +93,7 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
const renderComponent = (props = {}) => {
|
||||
const finalProps = { ...defaultProps, ...props };
|
||||
return renderWrapper(<AssignNewRoleTrigger {...finalProps} />);
|
||||
return renderWrapper(<ToastManagerProvider><AssignNewRoleTrigger {...finalProps} /></ToastManagerProvider>);
|
||||
};
|
||||
|
||||
describe('Initial Render', () => {
|
||||
@@ -231,7 +234,7 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
// Simulate successful API call
|
||||
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback();
|
||||
onSuccessCallback({ errors: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/role added successfully/i)).toBeInTheDocument();
|
||||
@@ -251,7 +254,7 @@ describe('AssignNewRoleTrigger', () => {
|
||||
|
||||
// Simulate successful API call
|
||||
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback();
|
||||
onSuccessCallback({ errors: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('assign-new-role-modal')).not.toBeInTheDocument();
|
||||
@@ -262,4 +265,72 @@ describe('AssignNewRoleTrigger', () => {
|
||||
expect(screen.getByTestId('role-select')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handle', () => {
|
||||
it('shows error toast when API fails to assign a role', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
await user.selectOptions(screen.getByTestId('role-select'), 'admin');
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
const { onSuccess } = mockMutate.mock.calls[0][1];
|
||||
onSuccess({ errors: [{ error: 'role_assignment_error' }] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('role-select')).toHaveValue(''); // role reset
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error toast on API failure and allows retry', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockError = new Error('Network error');
|
||||
|
||||
// First call to mutate triggers onError
|
||||
mockMutate.mockImplementationOnce((_vars, { onError }) => {
|
||||
onError(mockError, _vars);
|
||||
});
|
||||
|
||||
renderWrapper(
|
||||
<ToastManagerProvider>
|
||||
<AssignNewRoleTrigger
|
||||
username="testuser"
|
||||
libraryId="lib:test-library"
|
||||
currentUserRoles={['instructor']}
|
||||
/>
|
||||
</ToastManagerProvider>,
|
||||
);
|
||||
|
||||
// Open modal and select a role
|
||||
await user.click(screen.getByRole('button', { name: /add new role/i }));
|
||||
await user.selectOptions(screen.getByTestId('role-select'), 'admin');
|
||||
await user.click(screen.getByTestId('save-button'));
|
||||
|
||||
// Wait for the error toast to appear with a retry button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Second call to mutate also fails
|
||||
mockMutate.mockImplementationOnce((_vars, { onError }) => {
|
||||
onError(new Error('Network error'), _vars);
|
||||
});
|
||||
|
||||
// Click retry button
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
await user.click(retryButton);
|
||||
|
||||
// The retry toast should appear again
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Something went wrong/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Ensure mutate was called twice (original + retry)
|
||||
expect(mockMutate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { FC, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Toast, useToggle } from '@openedx/paragon';
|
||||
import { Button, useToggle } from '@openedx/paragon';
|
||||
import { Plus } from '@openedx/paragon/icons';
|
||||
|
||||
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
|
||||
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
|
||||
import messages from '../messages';
|
||||
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
|
||||
import AssignNewRoleModal from './AssignNewRoleModal';
|
||||
|
||||
import messages from '../messages';
|
||||
import authZLibrariesMessages from '../../messages';
|
||||
|
||||
interface AssignNewRoleTriggerProps {
|
||||
username: string;
|
||||
libraryId: string;
|
||||
@@ -21,9 +24,10 @@ const AssignNewRoleTrigger: FC<AssignNewRoleTriggerProps> = ({
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const { roles } = useLibraryAuthZ();
|
||||
|
||||
const {
|
||||
showToast, showErrorToast, Bold, Br,
|
||||
} = useToastManager();
|
||||
const [newRole, setNewRole] = useState<string>('');
|
||||
|
||||
const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole();
|
||||
@@ -36,22 +40,46 @@ const AssignNewRoleTrigger: FC<AssignNewRoleTriggerProps> = ({
|
||||
};
|
||||
|
||||
if (currentUserRoles.includes(newRole)) {
|
||||
showToast({
|
||||
message: intl.formatMessage(messages['libraries.authz.manage.assign.role.existing']),
|
||||
type: 'success',
|
||||
});
|
||||
close();
|
||||
setNewRole('');
|
||||
return;
|
||||
}
|
||||
|
||||
assignTeamMembersRole({ data }, {
|
||||
onSuccess: () => {
|
||||
setToastMessage(
|
||||
intl.formatMessage(
|
||||
messages['libraries.authz.manage.assign.role.success'],
|
||||
),
|
||||
);
|
||||
close();
|
||||
setNewRole('');
|
||||
},
|
||||
});
|
||||
const runAssignRole = (variables = { data }) => {
|
||||
assignTeamMembersRole(variables, {
|
||||
onSuccess: (response) => {
|
||||
const { errors } = response;
|
||||
|
||||
if (errors.length) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
message: intl.formatMessage(
|
||||
authZLibrariesMessages['library.authz.team.toast.default.error.message'],
|
||||
{ Bold, Br },
|
||||
),
|
||||
});
|
||||
setNewRole('');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: intl.formatMessage(messages['libraries.authz.manage.assign.role.success']),
|
||||
type: 'success',
|
||||
});
|
||||
close();
|
||||
setNewRole('');
|
||||
},
|
||||
onError: (error, retryVariables) => {
|
||||
showErrorToast(error, () => runAssignRole(retryVariables));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
runAssignRole();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -76,15 +104,6 @@ const AssignNewRoleTrigger: FC<AssignNewRoleTriggerProps> = ({
|
||||
|
||||
/>
|
||||
)}
|
||||
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
onClose={() => setToastMessage(null)}
|
||||
show={!!toastMessage}
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,19 +10,37 @@ type PublicReadToggleProps = {
|
||||
canEditToggle: boolean;
|
||||
};
|
||||
|
||||
type UpdateLibraryPublicRead = {
|
||||
libraryId: string;
|
||||
updatedData: { allowPublicRead: boolean };
|
||||
};
|
||||
|
||||
const PublicReadToggle = ({ libraryId, canEditToggle }: PublicReadToggleProps) => {
|
||||
const intl = useIntl();
|
||||
const { data: library } = useLibrary(libraryId);
|
||||
const { mutate: updateLibrary, isPending } = useUpdateLibrary();
|
||||
const { handleShowToast } = useToastManager();
|
||||
const onChangeToggle = () => updateLibrary({
|
||||
libraryId,
|
||||
updatedData: { allowPublicRead: !library.allowPublicRead },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
handleShowToast(intl.formatMessage(messages['libraries.authz.public.read.toggle.success']));
|
||||
},
|
||||
});
|
||||
const { showToast, showErrorToast } = useToastManager();
|
||||
|
||||
const onChangeToggle = () => {
|
||||
const runUpdate = (variables: UpdateLibraryPublicRead = {
|
||||
libraryId,
|
||||
updatedData: { allowPublicRead: !library.allowPublicRead },
|
||||
}) => {
|
||||
updateLibrary(variables, {
|
||||
onSuccess: () => {
|
||||
showToast({
|
||||
message: intl.formatMessage(messages['libraries.authz.public.read.toggle.success']),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
onError: (error, retryVariables) => {
|
||||
showErrorToast(error, () => runUpdate(retryVariables as UpdateLibraryPublicRead));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
runUpdate();
|
||||
};
|
||||
|
||||
if (!library.allowPublicRead && !canEditToggle) {
|
||||
return null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { renderWrapper } from '@src/setupTest';
|
||||
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
||||
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
|
||||
import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
|
||||
import TeamTable from './index';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
@@ -85,7 +86,7 @@ describe('TeamTable', () => {
|
||||
});
|
||||
(useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ);
|
||||
|
||||
renderWrapper(<TeamTable />);
|
||||
renderWrapper(<ToastManagerProvider><TeamTable /></ToastManagerProvider>);
|
||||
|
||||
const skeletons = screen.getAllByText('', { selector: '[aria-busy="true"]' });
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
@@ -98,7 +99,7 @@ describe('TeamTable', () => {
|
||||
});
|
||||
(useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ);
|
||||
|
||||
renderWrapper(<TeamTable />);
|
||||
renderWrapper(<ToastManagerProvider><TeamTable /></ToastManagerProvider>);
|
||||
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
@@ -117,7 +118,7 @@ describe('TeamTable', () => {
|
||||
});
|
||||
(useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ);
|
||||
|
||||
renderWrapper(<TeamTable />);
|
||||
renderWrapper(<ToastManagerProvider><TeamTable /></ToastManagerProvider>);
|
||||
|
||||
const editButtons = screen.queryAllByText('Edit');
|
||||
// Should not find Edit button for current user
|
||||
@@ -139,7 +140,7 @@ describe('TeamTable', () => {
|
||||
canManageTeam: false,
|
||||
});
|
||||
|
||||
renderWrapper(<TeamTable />);
|
||||
renderWrapper(<ToastManagerProvider><TeamTable /></ToastManagerProvider>);
|
||||
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -151,7 +152,7 @@ describe('TeamTable', () => {
|
||||
});
|
||||
(useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ);
|
||||
|
||||
renderWrapper(<TeamTable />);
|
||||
renderWrapper(<ToastManagerProvider><TeamTable /></ToastManagerProvider>);
|
||||
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Edit } from '@openedx/paragon/icons';
|
||||
import { TableCellValue, TeamMember } from '@src/types';
|
||||
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
||||
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
|
||||
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
|
||||
import { useQuerySettings } from './hooks/useQuerySettings';
|
||||
import TableControlBar from './components/TableControlBar';
|
||||
import messages from './messages';
|
||||
@@ -58,14 +59,18 @@ const TeamTable = () => {
|
||||
libraryId, canManageTeam, username, roles,
|
||||
} = useLibraryAuthZ();
|
||||
const roleLabels = roles.reduce((acc, role) => ({ ...acc, [role.role]: role.name }), {} as Record<string, string>);
|
||||
const { showErrorToast } = useToastManager();
|
||||
|
||||
const { querySettings, handleTableFetch } = useQuerySettings();
|
||||
|
||||
// TODO: Display error in the notification system
|
||||
const {
|
||||
data: teamMembers, isLoading, isError,
|
||||
data: teamMembers, isLoading, isError, error, refetch,
|
||||
} = useTeamMembers(libraryId, querySettings);
|
||||
|
||||
if (error) {
|
||||
showErrorToast(error, refetch);
|
||||
}
|
||||
|
||||
const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS);
|
||||
const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / DEFAULT_PAGE_SIZE) : 1;
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Role added successfully.',
|
||||
description: 'Libraries AuthZ assign role success message',
|
||||
},
|
||||
'libraries.authz.manage.assign.role.existing': {
|
||||
id: 'libraries.authz.manage.assign.existing',
|
||||
defaultMessage: 'The user already has the role.',
|
||||
description: 'Libraries AuthZ assign existing role',
|
||||
},
|
||||
'library.authz.team.remove.user.modal.title': {
|
||||
id: 'library.authz.team.remove.user.modal.title',
|
||||
defaultMessage: 'Remove role?',
|
||||
|
||||
@@ -31,10 +31,35 @@ const messages = defineMessages({
|
||||
defaultMessage: 'The {role} role has been successfully removed.{rolesCount, plural, =0 { The user no longer has access to this library and has been removed from the member list.} other {}}',
|
||||
description: 'Libraries team management remove user toast success',
|
||||
},
|
||||
'library.authz.team.default.error.toast.message': {
|
||||
id: 'library.authz.team.default.error.toast.message',
|
||||
defaultMessage: '<b>Something went wrong on our end</b> <br></br> Please try again later.',
|
||||
description: 'Libraries team management remove user toast success',
|
||||
'library.authz.team.toast.default.error.message': {
|
||||
id: 'library.authz.team.toast.default.error.message',
|
||||
defaultMessage: '<Bold>Something went wrong on our end.</Bold> <Br></Br>Please try again later.',
|
||||
description: 'Libraries default error message',
|
||||
},
|
||||
'library.authz.team.toast.500.error.message': {
|
||||
id: 'library.authz.team.toast.500.error.message',
|
||||
defaultMessage: '<Bold>We\'re experiencing technical difficulties.</Bold> <Br></Br>Please try again later.',
|
||||
description: 'Libraries internal server error message',
|
||||
},
|
||||
'library.authz.team.toast.502.error.message': {
|
||||
id: 'library.authz.team.toast.502.error.message',
|
||||
defaultMessage: '<Bold>We\'re having trouble connecting to our services.</Bold> <Br></Br>Please try again later.',
|
||||
description: 'Libraries bad getaway error message',
|
||||
},
|
||||
'library.authz.team.toast.503.error.message': {
|
||||
id: 'library.authz.team.toast.503.error.message',
|
||||
defaultMessage: '<Bold>The service is temporarily unavailable.</Bold> <Br></Br>Please try again in a few moments.',
|
||||
description: 'Libraries service temporary unabailable message',
|
||||
},
|
||||
'library.authz.team.toast.408.error.message': {
|
||||
id: 'library.authz.team.toast.408.error.message',
|
||||
defaultMessage: '<Bold>The request took too long.</Bold> <Br></Br>Please check your connection and try again.',
|
||||
description: 'Libraries request timeout message',
|
||||
},
|
||||
'library.authz.team.toast.retry.label': {
|
||||
id: 'library.authz.team.toast.retry.label',
|
||||
defaultMessage: 'Retry',
|
||||
description: 'Label for retry button.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import '@testing-library/jest-dom';
|
||||
import { ReactNode } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
@@ -15,22 +16,28 @@ const mockAppContext = {
|
||||
},
|
||||
};
|
||||
|
||||
export const renderWrapper = (children) => render(
|
||||
<BrowserRouter>
|
||||
<AppContext.Provider value={mockAppContext}>
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
</AppContext.Provider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
interface WrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const renderWrapper = (ui, options = {}) => {
|
||||
const Wrapper = ({ children }: WrapperProps) => (
|
||||
<BrowserRouter>
|
||||
<AppContext.Provider value={mockAppContext}>
|
||||
<IntlProvider locale="en">{children}</IntlProvider>
|
||||
</AppContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...options });
|
||||
};
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
observe() { }
|
||||
|
||||
unobserve() {}
|
||||
unobserve() { }
|
||||
|
||||
disconnect() {}
|
||||
disconnect() { }
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserver;
|
||||
Reference in New Issue
Block a user