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:
Diana Olarte
2025-10-31 00:45:26 +11:00
committed by GitHub
parent 9fef2704bc
commit a9f8680396
20 changed files with 669 additions and 302 deletions

View File

@@ -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',
],
});

View File

@@ -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;
}

View File

@@ -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', () => {

View File

@@ -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 () => {

View File

@@ -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 (

View File

@@ -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();
});
});

View File

@@ -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;
};

View File

@@ -54,6 +54,7 @@ const AddNewTeamMemberModal: FC<AddNewTeamMemberModalProps> = ({
size="lg"
variant="dark"
hasCloseButton
isBlocking
isOverflowVisible={false}
zIndex={5}
>

View File

@@ -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),
);
});
});

View File

@@ -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>
)}
</>
);
};

View File

@@ -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.',

View File

@@ -28,6 +28,7 @@ const AssignNewRoleModal: FC<AssignNewRoleModalProps> = ({
size="lg"
variant="dark"
hasCloseButton
isBlocking
isOverflowVisible={false}
zIndex={5}
>

View File

@@ -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);
});
});
});

View File

@@ -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>
)}
</>
);
};

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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?',

View File

@@ -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.',
},
});

View File

@@ -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;