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