diff --git a/jest.config.js b/jest.config.js index 5d3f71e5..61ef1bf2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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: [ - '/src/setupTest.jsx', + '/src/setupTest.tsx', ], moduleNameMapper: { '^@src/(.*)$': '/src/$1', }, coveragePathIgnorePatterns: [ - 'src/setupTest.jsx', + 'src/setupTest.tsx', 'src/i18n', ], }); diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index f7be36bf..51d9c88e 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -65,4 +65,10 @@ // Move toast to the right left: auto; right: var(--pgn-spacing-toast-container-gutter-lg); -} \ No newline at end of file +} + +// 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; +} diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index 75eb88d0..a178291d 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -15,6 +15,7 @@ jest.mock('./context', () => { LibraryAuthZProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, }; }); + 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', () => { diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx index 76dfa1c9..8e9f4d00 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx @@ -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 () => { diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 1433a414..7811f1f3 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -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(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 => {chunk}, 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 ( diff --git a/src/authz-module/libraries-manager/ToastManagerContext.test.tsx b/src/authz-module/libraries-manager/ToastManagerContext.test.tsx index 28a7547a..fe67c41d 100644 --- a/src/authz-module/libraries-manager/ToastManagerContext.test.tsx +++ b/src/authz-module/libraries-manager/ToastManagerContext.test.tsx @@ -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( - - {ui} - , -); - +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 (
- - - + +
); }; @@ -30,7 +22,7 @@ const TestComponent = () => { describe('ToastManagerContext', () => { describe('ToastManagerProvider', () => { it('does not show toast initially', () => { - render( + renderWrapper( , @@ -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( , ); - // 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( , ); - // 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(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( - - - , - ); - - const showButton = screen.getByText('Show Toast'); await user.click(showButton); + await user.click(showAnotherButton); 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( , @@ -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( - - - , - ); - - 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(); - }).toThrow('useToastManager must be used within an ToastManagerProvider'); + renderWrapper(); + }).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 ( + + ); + }; + + renderWrapper( + + + , + ); + + await user.click(screen.getByText('Retry Error')); + const retryButton = await screen.findByText('Retry'); + await user.click(retryButton); + + expect(logError).toHaveBeenCalled(); + expect(retryFn).toHaveBeenCalled(); + }); }); diff --git a/src/authz-module/libraries-manager/ToastManagerContext.tsx b/src/authz-module/libraries-manager/ToastManagerContext.tsx index 78621406..12089f4d 100644 --- a/src/authz-module/libraries-manager/ToastManagerContext.tsx +++ b/src/authz-module/libraries-manager/ToastManagerContext.tsx @@ -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 = { + // 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) => {chunk}; +const Br = () =>
; type ToastManagerContextType = { - handleShowToast: (message: string) => void; - handleDiscardToast: () => void; + showToast: (toast: Omit) => void; + showErrorToast: (error, retryFn?: () => void) => void; + Bold: (chunk: string) => JSX.Element; + Br: () => JSX.Element; }; const ToastManagerContext = createContext(undefined); interface ToastManagerProviderProps { - handleClose?: () => void children: React.ReactNode | React.ReactNode[]; } -export const ToastManagerProvider = ({ handleClose, children }: ToastManagerProviderProps) => { - const [toastMessage, setToastMessage] = useState(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) => { + 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(() => { + 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 ( {children} - { - if (handleClose) { handleClose(); } - setToastMessage(null); - }} - show={!!toastMessage} - > - {toastMessage ?? ''} - +
+ {toasts.map(toast => ( + 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} + + ))} +
); }; 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; }; diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx index e7d04348..af0a67ec 100644 --- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberModal.tsx @@ -54,6 +54,7 @@ const AddNewTeamMemberModal: FC = ({ size="lg" variant="dark" hasCloseButton + isBlocking isOverflowVisible={false} zIndex={5} > diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx index dc7603d0..b4b32d7f 100644 --- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.test.tsx @@ -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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); const triggerButton = screen.getByRole('button', { name: /add new team member/i }); await user.click(triggerButton); @@ -268,23 +271,108 @@ describe('AddNewTeamMemberTrigger', () => { }); }); + it('shows retry toast on API failure and displays another toast when retry fails again', async () => { + const user = userEvent.setup(); + + const mockError = new Error('Network error'); + + mockMutate.mockImplementationOnce((_vars, { onError }) => { + onError(mockError, _vars); + }); + + renderWrapper( + + + , + ); + + const triggerButton = screen.getByRole('button', { name: /add new team member/i }); + await user.click(triggerButton); + + 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(); - // Mock loading state - (useAssignTeamMembersRole as jest.Mock).mockReturnValue({ - mutate: mockMutate, - isPending: true, + const { rerender } = renderWrapper( + + + , + ); + + const rerenderHook = () => rerender( + + + , + ); + + 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, - } as any); - - renderWrapper(); + })); 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 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), + ); }); }); diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx index a70b5424..f58d691e 100644 --- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx @@ -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 = ({ - libraryId, -}) => { +const AddNewTeamMemberTrigger: FC = ({ libraryId }) => { const intl = useIntl(); const [isOpen, open, close] = useToggle(false); - const [showToast, setShowToast] = useState(false); - const [additionMessage, setAdditionMessage] = useState(null); const [formValues, setFormValues] = useState(DEFAULT_FORM_VALUES); const [isError, setIsError] = useState(false); - const [errorValidationUsers, setNotFoundUsers] = useState([]); - const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole(); + const [errorUsers, setErrorUsers] = useState([]); - const handleChangeForm = (e: React.ChangeEvent) => { + 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, + ) => { 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()); - - if (errors.length === 1 && errors[0].error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE) { - setFormValues(DEFAULT_FORM_VALUES); - close(); + const handleErrors = ( + errors: PutAssignTeamMembersRoleResponse['errors'], + successfulCount: number, + ) => { + const notFoundUsers = errors + .filter((err) => err.error === RoleOperationErrorStatus.USER_NOT_FOUND) + .map((err) => err.userIdentifier.trim()); + + const alreadyHasRole = errors.some( + (err) => err.error === RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE, + ); + + 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); - - if (successData.completed.length) { - setAdditionMessage( - intl.formatMessage( - messages['libraries.authz.manage.add.member.success'], - { count: successData.completed.length }, - ), - ); - setShowToast(true); - } - - 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(); + const runAssignMembers = (variables = { data: payload }) => { + assignTeamMembersRole(variables, { + onSuccess: (response) => { + const { completed, errors } = response; + + if (completed.length && !errors.length) { + showToast({ + message: intl.formatMessage(messages['libraries.authz.manage.add.member.success'], { + count: completed.length, + }), + type: 'success', + }); + handleClose(); + return; + } + + if (errors.length) { + handleErrors(errors, completed.length); + } + }, + onError: (error, retryVariables) => { + showErrorToast(error, () => runAssignMembers(retryVariables)); + }, + }); + }; + runAssignMembers(); }; return ( <> @@ -135,20 +165,11 @@ const AddNewTeamMemberTrigger: FC = ({ isError={isError} close={handleClose} onSave={handleAddTeamMember} - isLoading={isAssignTeamMembersRolePending} + isLoading={isPending} formValues={formValues} handleChangeForm={handleChangeForm} /> )} - - {additionMessage && ( - setShowToast(false)} - show={showToast} - > - {additionMessage} - - )} ); }; diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts index 46c5f050..c9daa33d 100644 --- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts @@ -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: '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.', description: 'Error message when adding new team members', }, + 'libraries.authz.manage.add.member.partial': { + id: 'libraries.authz.manage.add.member.failure', + defaultMessage: '{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.}}

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.', diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx index c7a3ef08..8dd0a2aa 100644 --- a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleModal.tsx @@ -28,6 +28,7 @@ const AssignNewRoleModal: FC = ({ size="lg" variant="dark" hasCloseButton + isBlocking isOverflowVisible={false} zIndex={5} > diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx index f39ee35d..8693e68a 100644 --- a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.test.tsx @@ -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(); + return renderWrapper(); }; 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( + + + , + ); + + // 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); + }); + }); }); diff --git a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx index a0cc5085..03d0ac92 100644 --- a/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx +++ b/src/authz-module/libraries-manager/components/AssignNewRoleModal/AssignNewRoleTrigger.tsx @@ -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 = ({ }) => { const intl = useIntl(); const [isOpen, open, close] = useToggle(false); - const [toastMessage, setToastMessage] = useState(null); const { roles } = useLibraryAuthZ(); - + const { + showToast, showErrorToast, Bold, Br, + } = useToastManager(); const [newRole, setNewRole] = useState(''); const { mutate: assignTeamMembersRole, isPending: isAssignTeamMembersRolePending } = useAssignTeamMembersRole(); @@ -36,22 +40,46 @@ const AssignNewRoleTrigger: FC = ({ }; 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 = ({ /> )} - - {toastMessage && ( - setToastMessage(null)} - show={!!toastMessage} - > - {toastMessage} - - )} ); }; diff --git a/src/authz-module/libraries-manager/components/PublicReadToggle.tsx b/src/authz-module/libraries-manager/components/PublicReadToggle.tsx index 2a48df28..b798a041 100644 --- a/src/authz-module/libraries-manager/components/PublicReadToggle.tsx +++ b/src/authz-module/libraries-manager/components/PublicReadToggle.tsx @@ -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; diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx index 0278d564..c2788fc0 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx @@ -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(); + renderWrapper(); 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(); + renderWrapper(); 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(); + renderWrapper(); const editButtons = screen.queryAllByText('Edit'); // Should not find Edit button for current user @@ -139,7 +140,7 @@ describe('TeamTable', () => { canManageTeam: false, }); - renderWrapper(); + renderWrapper(); expect(screen.queryByText('Edit')).not.toBeInTheDocument(); }); @@ -151,7 +152,7 @@ describe('TeamTable', () => { }); (useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ); - renderWrapper(); + renderWrapper(); expect(screen.queryByText('Edit')).not.toBeInTheDocument(); }); diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.tsx index 8ee94633..851e3d08 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/index.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx @@ -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); + 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; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index 1281ede6..2bd97f5a 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -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?', diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts index 4e174264..9ed3f7d4 100644 --- a/src/authz-module/libraries-manager/messages.ts +++ b/src/authz-module/libraries-manager/messages.ts @@ -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: 'Something went wrong on our end

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: 'Something went wrong on our end.

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: 'We\'re experiencing technical difficulties.

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: 'We\'re having trouble connecting to our services.

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: 'The service is temporarily unavailable.

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: 'The request took too long.

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.', }, }); diff --git a/src/setupTest.jsx b/src/setupTest.tsx similarity index 52% rename from src/setupTest.jsx rename to src/setupTest.tsx index a944cc82..9f051cbb 100644 --- a/src/setupTest.jsx +++ b/src/setupTest.tsx @@ -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( - - - - {children} - - - , -); +interface WrapperProps { + children: ReactNode; +} + +export const renderWrapper = (ui, options = {}) => { + const Wrapper = ({ children }: WrapperProps) => ( + + + {children} + + + ); + + return render(ui, { wrapper: Wrapper, ...options }); +}; class ResizeObserver { - observe() {} + observe() { } - unobserve() {} + unobserve() { } - disconnect() {} + disconnect() { } } global.ResizeObserver = ResizeObserver;