Refactor: Move courseTeam from redux store to react query (#2888)

* refactor: Move `courseTeam` from redux store to react query

* fix: Broken types

* fix: Show user readable error

* fix: Broken coverage

* fix: Broken coverage

* refactor: Add default message error
This commit is contained in:
Chris Chávez
2026-02-24 10:11:36 -05:00
committed by GitHub
parent b57386b9b6
commit 56726448fc
16 changed files with 594 additions and 701 deletions

View File

@@ -1,219 +0,0 @@
// @ts-check
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import CourseTeam from './CourseTeam';
import messages from './messages';
import { USER_ROLES } from '../constants';
import { executeThunk } from '../utils';
import { RequestStatus } from '../data/constants';
import { changeRoleTeamUserQuery, deleteCourseTeamQuery } from './data/thunk';
import {
fireEvent,
initializeMocks,
render as baseRender,
waitFor,
} from '../testUtils';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<CourseTeam />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<CourseTeam />', () => {
beforeEach(() => {
const mocks = initializeMocks();
store = mocks.reduxStore;
axiosMock = mocks.axiosMock;
});
it('render CourseTeam component with 3 team members correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const {
getByText, getByRole, getByTestId, queryAllByTestId,
} = render();
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(queryAllByTestId('course-team-member')).toHaveLength(3);
});
});
it('render CourseTeam component with 1 team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const {
getByText, getByRole, getByTestId, getAllByTestId,
} = render();
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(getAllByTestId('course-team-member')).toHaveLength(1);
});
});
it('render CourseTeam component without team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
const {
getByText, getByRole, getByTestId, queryAllByTestId,
} = render();
await waitFor(() => {
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(queryAllByTestId('course-team-member')).toHaveLength(0);
});
});
it('render CourseTeam component with initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
const { getByTestId, queryByTestId } = render();
await waitFor(() => {
expect(getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
});
});
it('render CourseTeam component without initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { getByTestId, queryByTestId } = render();
await waitFor(() => {
expect(queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
expect(getByTestId('course-team-sidebar')).toBeInTheDocument();
});
});
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const { getByRole, queryByTestId } = render();
await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = getByRole('button', { name: messages.addNewMemberButton.defaultMessage });
fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument();
});
});
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
const { getByRole, queryByTestId } = render();
await waitFor(() => {
expect(queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = getByRole('button', { name: 'Add a new team member' });
fireEvent.click(addButton);
expect(queryByTestId('add-user-form')).toBeInTheDocument();
});
});
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, {
...courseTeamWithOneUser,
allowActions: false,
});
const { queryByRole, queryByTestId } = render();
await waitFor(() => {
expect(queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByTestId('add-team-member')).not.toBeInTheDocument();
});
});
it('should delete user', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { queryByText } = render();
axiosMock
.onDelete(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
.reply(200);
await executeThunk(deleteCourseTeamQuery(courseId, 'staff@example.com'), store.dispatch);
expect(queryByText('staff@example.com')).not.toBeInTheDocument();
});
it('should change role user', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const { getAllByText } = render();
axiosMock
.onPut(updateCourseTeamUserApiUrl(courseId, 'staff@example.com'))
.reply(200, { role: USER_ROLES.admin });
await executeThunk(changeRoleTeamUserQuery(courseId, 'staff@example.com', { role: USER_ROLES.admin }), store.dispatch);
expect(getAllByText('Admin')).toHaveLength(1);
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(403);
const { getByRole } = render();
await waitFor(() => {
expect(getByRole('alert')).toBeInTheDocument();
const { loadingCourseTeamStatus } = store.getState().courseTeam;
expect(loadingCourseTeamStatus).toEqual(RequestStatus.DENIED);
});
});
it('sets loading status to FAILED upon receiving a 404 response from the API', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(404);
render();
await waitFor(() => {
const { loadingCourseTeamStatus } = store.getState().courseTeam;
expect(loadingCourseTeamStatus).toEqual(RequestStatus.FAILED);
});
});
});

View File

@@ -0,0 +1,242 @@
import userEvent from '@testing-library/user-event';
import {
screen,
initializeMocks,
render as baseRender,
waitFor,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { USER_ROLES } from '@src/constants';
import { courseTeamMock, courseTeamWithOneUser, courseTeamWithoutUsers } from './__mocks__';
import { getCourseTeamApiUrl, updateCourseTeamUserApiUrl } from './data/api';
import CourseTeam from './CourseTeam';
import messages from './messages';
import addUserFormMessages from './add-user-form/messages';
let axiosMock;
const mockPathname = '/foo-bar';
const courseId = '123';
const render = () => baseRender(
<CourseAuthoringProvider courseId={courseId}>
<CourseTeam />
</CourseAuthoringProvider>,
{ path: mockPathname },
);
describe('<CourseTeam />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
});
it('render CourseTeam component with 3 team members correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
render();
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(screen.queryAllByTestId('course-team-member')).toHaveLength(3);
});
it('render CourseTeam component with 1 team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByTestId('course-team-sidebar')).toBeInTheDocument();
expect(screen.getAllByTestId('course-team-member')).toHaveLength(1);
});
it('render CourseTeam component without team member correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
render();
expect(await screen.findByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByRole('button', { name: messages.addNewMemberButton.defaultMessage })).toBeInTheDocument();
expect(screen.getByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(screen.queryAllByTestId('course-team-member')).toHaveLength(0);
});
it('render CourseTeam component with initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithoutUsers);
render();
expect(await screen.findByTestId('course-team-sidebar__initial')).toBeInTheDocument();
expect(screen.queryByTestId('course-team-sidebar')).not.toBeInTheDocument();
});
it('render CourseTeam component without initial sidebar correctly', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
render();
expect(await screen.findByTestId('course-team-sidebar')).toBeInTheDocument();
expect(screen.queryByTestId('course-team-sidebar__initial')).not.toBeInTheDocument();
});
it('displays AddUserForm when clicking the "Add New Member" button', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage });
expect(addButton).toBeInTheDocument();
await user.click(addButton);
expect(screen.queryByTestId('add-user-form')).toBeInTheDocument();
});
it('displays AddUserForm when clicking the "Add a New Team member" button', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
const addButton = await screen.findByRole('button', { name: 'Add a new team member' });
expect(addButton).toBeInTheDocument();
await user.click(addButton);
expect(screen.queryByTestId('add-user-form')).toBeInTheDocument();
});
it('not displays "Add New Member" and AddTeamMember component when isAllowActions is false', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, {
...courseTeamWithOneUser,
allowActions: false,
});
render();
await screen.findByText(messages.headingTitle.defaultMessage);
expect(screen.queryByRole('button', { name: messages.addNewMemberButton.defaultMessage })).not.toBeInTheDocument();
expect(screen.queryByTestId('add-team-member')).not.toBeInTheDocument();
});
it('should delete user', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const deleteUrl = updateCourseTeamUserApiUrl(courseId, 'staff@example.com');
axiosMock
.onDelete(deleteUrl)
.reply(200);
render();
const deleteButton = (await screen.findAllByRole('button', { name: /delete user/i }))[0];
expect(deleteButton).toBeInTheDocument();
await user.click(deleteButton);
expect(await screen.findByText('Delete course team member'));
const confirmDelete = screen.getByRole('button', { name: /delete/i });
expect(confirmDelete).toBeInTheDocument();
await user.click(confirmDelete);
await waitFor(() => {
expect(axiosMock.history.delete.length).toBe(1);
});
expect(axiosMock.history.delete[0].url).toEqual(deleteUrl);
});
it('should change role user', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamMock);
const updateUrl = updateCourseTeamUserApiUrl(courseId, 'staff@example.com');
axiosMock
.onPut(updateUrl)
.reply(200, { role: USER_ROLES.admin });
render();
const updateButton = (await screen.findAllByRole('button', { name: /add admin access/i }))[0];
expect(updateButton).toBeInTheDocument();
await user.click(updateButton);
await waitFor(() => {
expect(axiosMock.history.put.length).toBe(1);
});
expect(axiosMock.history.put[0].url).toEqual(updateUrl);
});
it('should show warning modal when submitting an already existing user email', async () => {
const user = userEvent.setup();
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
render();
await user.click(await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage }));
await user.type(screen.getByRole('textbox'), 'staff@example.com');
await user.click(screen.getByRole('button', { name: addUserFormMessages.addUserButton.defaultMessage }));
expect(await screen.findByText('Already a course team member')).toBeInTheDocument();
});
it('should hide the form after successfully adding a new user', async () => {
const user = userEvent.setup();
const newEmail = 'newuser@example.com';
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(200, courseTeamWithOneUser);
axiosMock
.onPost(updateCourseTeamUserApiUrl(courseId, newEmail))
.reply(200);
render();
await user.click(await screen.findByRole('button', { name: messages.addNewMemberButton.defaultMessage }));
await user.type(screen.getByRole('textbox'), newEmail);
await user.click(screen.getByRole('button', { name: addUserFormMessages.addUserButton.defaultMessage }));
await waitFor(() => {
expect(screen.queryByTestId('add-user-form')).not.toBeInTheDocument();
});
});
it('displays an alert and sets status to DENIED when API responds with 403', async () => {
axiosMock
.onGet(getCourseTeamApiUrl(courseId))
.reply(403);
render();
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
});

View File

@@ -5,11 +5,14 @@ import {
Layout,
} from '@openedx/paragon';
import { Add as IconAdd } from '@openedx/paragon/icons';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import SubHeader from '../generic/sub-header/SubHeader';
import { USER_ROLES } from '../constants';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
import SubHeader from '@src/generic/sub-header/SubHeader';
import { USER_ROLES } from '@src/constants';
import getPageHeadTitle from '@src/generic/utils';
import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert';
import messages from './messages';
import CourseTeamSideBar from './course-team-sidebar/CourseTeamSidebar';
import AddUserForm from './add-user-form/AddUserForm';
@@ -17,12 +20,9 @@ import AddTeamMember from './add-team-member/AddTeamMember';
import CourseTeamMember from './course-team-member/CourseTeamMember';
import InfoModal from './info-modal/InfoModal';
import { useCourseTeam } from './hooks';
import getPageHeadTitle from '../generic/utils';
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
const CourseTeam = () => {
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const {
@@ -43,7 +43,6 @@ const CourseTeam = () => {
isShowAddTeamMember,
isShowInitialSidebar,
isShowUserFilledSidebar,
isInternetConnectionAlertFailed,
openForm,
hideForm,
closeInfoModal,
@@ -51,8 +50,7 @@ const CourseTeam = () => {
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
handleInternetConnectionFailed,
} = useCourseTeam({ intl, courseId });
} = useCourseTeam();
document.title = getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle));
@@ -86,7 +84,7 @@ const CourseTeam = () => {
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={isAllowActions && (
headerActions={isAllowActions ? (
<Button
variant="primary"
iconBefore={IconAdd}
@@ -96,7 +94,7 @@ const CourseTeam = () => {
>
{intl.formatMessage(messages.addNewMemberButton)}
</Button>
)}
) : undefined}
/>
<section className="course-team-section">
<div className="members-container">
@@ -139,7 +137,7 @@ const CourseTeam = () => {
isOpen={isInfoModalOpen}
close={closeInfoModal}
currentEmail={currentEmail}
errorMessage={errorMessage}
errorMessage={errorMessage ?? ''}
courseName={courseName}
modalType={modalType}
onDeleteSubmit={handleDeleteUserSubmit}
@@ -161,9 +159,9 @@ const CourseTeam = () => {
</Container>
<div className="alert-toast">
<InternetConnectionAlert
isFailed={isInternetConnectionAlertFailed}
isFailed={errorMessage !== undefined}
isQueryPending={isQueryPending}
onInternetConnectionFailed={handleInternetConnectionFailed}
onInternetConnectionFailed={/* istanbul ignore next */ () => {}}
/>
</div>
</>

View File

@@ -1,128 +0,0 @@
import React from 'react';
import {
render,
fireEvent,
act,
waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { EXAMPLE_USER_EMAIL } from '../constants';
import initializeStore from '../../store';
import { USER_ROLES } from '../../constants';
import { updateCourseTeamUserApiUrl } from '../data/api';
import { createCourseTeamQuery } from '../data/thunk';
import { executeThunk } from '../../utils';
import AddUserForm from './AddUserForm';
import messages from './messages';
let axiosMock;
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const onSubmitMock = jest.fn();
const onCancelMock = jest.fn();
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
<AddUserForm
onSubmit={onSubmitMock}
onCancel={onCancelMock}
/>
</IntlProvider>
</AppProvider>
);
describe('<AddUserForm />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('render AddUserForm component correctly', () => {
const { getByText, getByPlaceholderText } = render(<RootWrapper />);
expect(getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
expect(getByPlaceholderText(messages.formPlaceholder.defaultMessage
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
expect(getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
});
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
const { getByPlaceholderText, getByRole } = render(<RootWrapper />);
const emailInput = getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
const addUserButton = getByRole('button', { name: messages.addUserButton.defaultMessage });
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
await act(async () => {
fireEvent.click(addUserButton);
});
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
expect(onSubmitMock).toHaveBeenCalledWith(
{ email: EXAMPLE_USER_EMAIL },
expect.objectContaining({ submitForm: expect.any(Function) }),
);
});
axiosMock
.onPost(updateCourseTeamUserApiUrl(courseId, EXAMPLE_USER_EMAIL), { role: USER_ROLES.staff })
.reply(200, { role: USER_ROLES.staff });
await executeThunk(createCourseTeamQuery(courseId, EXAMPLE_USER_EMAIL), store.dispatch);
});
it('calls onCancel when the "Cancel" button is clicked', () => {
const { getByText } = render(<RootWrapper />);
const cancelButton = getByText(messages.cancelButton.defaultMessage);
fireEvent.click(cancelButton);
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
it('"Add User" button is disabled when the email input field is empty', () => {
const { getByText } = render(<RootWrapper />);
const addUserButton = getByText(messages.addUserButton.defaultMessage);
expect(addUserButton).toBeDisabled();
});
it('"Add User" button is not disabled when the email input field is not empty', () => {
const { getByPlaceholderText, getByText } = render(<RootWrapper />);
const emailInput = getByPlaceholderText(
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
);
const addUserButton = getByText(messages.addUserButton.defaultMessage);
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
expect(addUserButton).not.toBeDisabled();
});
});

View File

@@ -0,0 +1,96 @@
import userEvent from '@testing-library/user-event';
import {
render,
screen,
fireEvent,
waitFor,
initializeMocks,
} from '@src/testUtils';
import { EXAMPLE_USER_EMAIL } from '../constants';
import AddUserForm from './AddUserForm';
import messages from './messages';
const mockPathname = '/foo-bar';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const onSubmitMock = jest.fn();
const onCancelMock = jest.fn();
const renderComponent = () => render(
<AddUserForm
onSubmit={onSubmitMock}
onCancel={onCancelMock}
/>,
);
describe('<AddUserForm />', () => {
beforeEach(() => {
initializeMocks();
});
it('render AddUserForm component correctly', () => {
renderComponent();
expect(screen.getByText(messages.formTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.formLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.formPlaceholder.defaultMessage
.replace('{email}', EXAMPLE_USER_EMAIL))).toBeInTheDocument();
expect(screen.getByText(messages.cancelButton.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.addUserButton.defaultMessage)).toBeInTheDocument();
});
it('calls onSubmit when the "Add User" button is clicked with a valid email', async () => {
const user = userEvent.setup();
renderComponent();
const emailInput = screen.getByPlaceholderText(messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL));
const addUserButton = screen.getByRole('button', { name: messages.addUserButton.defaultMessage });
fireEvent.change(emailInput, { target: { value: EXAMPLE_USER_EMAIL } });
await user.click(addUserButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
});
expect(onSubmitMock).toHaveBeenCalledWith(
{ email: EXAMPLE_USER_EMAIL },
expect.objectContaining({ submitForm: expect.any(Function) }),
);
});
it('calls onCancel when the "Cancel" button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const cancelButton = screen.getByText(messages.cancelButton.defaultMessage);
await user.click(cancelButton);
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
it('"Add User" button is disabled when the email input field is empty', () => {
renderComponent();
const addUserButton = screen.getByText(messages.addUserButton.defaultMessage);
expect(addUserButton).toBeDisabled();
});
it('"Add User" button is not disabled when the email input field is not empty', () => {
renderComponent();
const emailInput = screen.getByPlaceholderText(
messages.formPlaceholder.defaultMessage.replace('{email}', EXAMPLE_USER_EMAIL),
);
const addUserButton = screen.getByText(messages.addUserButton.defaultMessage);
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
expect(addUserButton).not.toBeDisabled();
});
});

View File

@@ -4,6 +4,8 @@ export const MODAL_TYPES = {
warning: 'warning',
} as const;
export type ModalType = typeof MODAL_TYPES[keyof typeof MODAL_TYPES];
export const BADGE_STATES = {
admin: 'primary-700',
staff: 'gray-500',

View File

@@ -1,54 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { USER_ROLES } from '../../constants';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseTeamApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
export const updateCourseTeamUserApiUrl = (courseId, email) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
/**
* Get course team.
* @param {string} courseId
* @returns {Promise<Object>}
*/
export async function getCourseTeam(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseTeamApiUrl(courseId));
return camelCaseObject(data);
}
/**
* Create course team user.
* @param {string} courseId
* @param {string} email
* @returns {Promise<Object>}
*/
export async function createTeamUser(courseId, email) {
await getAuthenticatedHttpClient()
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
}
/**
* Change role course team user.
* @param {string} courseId
* @param {string} email
* @param {string} role
* @returns {Promise<Object>}
*/
export async function changeRoleTeamUser(courseId, email, role) {
await getAuthenticatedHttpClient()
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
}
/**
* Delete course team user.
* @param {string} courseId
* @param {string} email
* @returns {Promise<Object>}
*/
export async function deleteTeamUser(courseId, email) {
await getAuthenticatedHttpClient()
.delete(updateCourseTeamUserApiUrl(courseId, email));
}

View File

@@ -0,0 +1,54 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { USER_ROLES } from '@src/constants';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseTeamApiUrl = (courseId: string) => `${getApiBaseUrl()}/api/contentstore/v1/course_team/${courseId}`;
export const updateCourseTeamUserApiUrl = (courseId: string, email: string) => `${getApiBaseUrl()}/course_team/${courseId}/${email}`;
export interface CourseTeamUser {
id: number;
email: string;
role: string;
username: string;
}
export interface CourseTeam {
users: CourseTeamUser[];
allowActions: boolean;
showTransferOwnershipHint: boolean;
}
/**
* Get course team.
*/
export async function getCourseTeam(courseId: string): Promise<CourseTeam> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseTeamApiUrl(courseId));
return camelCaseObject(data);
}
/**
* Create course team user.
*/
export async function createTeamUser(courseId: string, email: string) {
await getAuthenticatedHttpClient()
.post(updateCourseTeamUserApiUrl(courseId, email), { role: USER_ROLES.staff });
}
/**
* Change role course team user.
*/
export async function changeRoleTeamUser(courseId: string, email: string, role: string) {
await getAuthenticatedHttpClient()
.put(updateCourseTeamUserApiUrl(courseId, email), { role });
}
/**
* Delete course team user.
*/
export async function deleteTeamUser(courseId: string, email: string) {
await getAuthenticatedHttpClient()
.delete(updateCourseTeamUserApiUrl(courseId, email));
}

View File

@@ -0,0 +1,64 @@
/* eslint-disable import/no-extraneous-dependencies */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import * as api from './api';
export const courseTeamQueryKeys = {
all: ['courseTeam'],
/** Base key for course team data specific to a courseId */
courseTeam: (courseId: string) => [...courseTeamQueryKeys.all, courseId],
};
/**
* Hook to fetch the course team for the given courseId
*/
export const useCourseTeamData = (courseId: string) => (
useQuery<api.CourseTeam, AxiosError>({
queryKey: courseTeamQueryKeys.courseTeam(courseId),
queryFn: () => api.getCourseTeam(courseId),
})
);
/**
* Hook to create a new course team user
*/
export const useCreateTeamUser = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, string>({
mutationFn: (email: string) => api.createTeamUser(courseId, email),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
},
});
};
export type ChangeRoleRequest = {
email: string;
role: string;
};
/**
* Hook to change the role of a course team user
*/
export const useChangeRoleTeamUser = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, ChangeRoleRequest>({
mutationFn: ({ email, role }: ChangeRoleRequest) => api.changeRoleTeamUser(courseId, email, role),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
},
});
};
/**
* Hook to delete a course team user
*/
export const useDeleteTeamUser = (courseId: string) => {
const queryClient = useQueryClient();
return useMutation<void, AxiosError, string>({
mutationFn: (email: string) => api.deleteTeamUser(courseId, email),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.courseTeam(courseId) });
},
});
};

View File

@@ -1,6 +0,0 @@
export const getCourseTeamUsers = (state) => state.courseTeam.users;
export const getCourseTeamLoadingStatus = (state) => state.courseTeam.loadingCourseTeamStatus;
export const getErrorMessage = (state) => state.courseTeam.errorMessage;
export const getIsAllowActions = (state) => state.courseTeam.allowActions;
export const getIsOwnershipHint = (state) => state.courseTeam.showTransferOwnershipHint;
export const getSavingStatus = (state) => state.courseTeam.savingStatus;

View File

@@ -1,46 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'courseTeam',
initialState: {
loadingCourseTeamStatus: RequestStatus.IN_PROGRESS,
savingStatus: '',
users: [],
showTransferOwnershipHint: false,
allowActions: false,
errorMessage: '',
},
reducers: {
fetchCourseTeamSuccess: (state, { payload }) => {
state.users = payload.users;
state.showTransferOwnershipHint = payload.showTransferOwnershipHint;
state.allowActions = payload.allowActions;
},
updateLoadingCourseTeamStatus: (state, { payload }) => {
state.loadingCourseTeamStatus = payload.status;
},
deleteCourseTeamUser: (state, { payload }) => {
state.users = state.users.filter((user) => user.email !== payload);
},
updateSavingStatus: (state, { payload }) => {
state.savingStatus = payload.status;
},
setErrorMessage: (state, { payload }) => {
state.errorMessage = payload;
},
},
});
export const {
fetchCourseTeamSuccess,
updateLoadingCourseTeamStatus,
deleteCourseTeamUser,
updateSavingStatus,
setErrorMessage,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -1,91 +0,0 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseTeam,
deleteTeamUser,
createTeamUser,
changeRoleTeamUser,
} from './api';
import {
fetchCourseTeamSuccess,
updateLoadingCourseTeamStatus,
deleteCourseTeamUser,
updateSavingStatus,
setErrorMessage,
} from './slice';
export function fetchCourseTeamQuery(courseId) {
return async (dispatch) => {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.IN_PROGRESS }));
try {
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
if (error.response && error.response.status === 403) {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.DENIED }));
} else {
dispatch(updateLoadingCourseTeamStatus({ status: RequestStatus.FAILED }));
}
return false;
}
};
}
export function createCourseTeamQuery(courseId, email) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await createTeamUser(courseId, email);
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch (error) {
const message = error?.response?.data?.error || '';
dispatch(setErrorMessage(message));
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function changeRoleTeamUserQuery(courseId, email, role) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await changeRoleTeamUser(courseId, email, role);
const courseTeam = await getCourseTeam(courseId);
dispatch(fetchCourseTeamSuccess(courseTeam));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}
export function deleteCourseTeamQuery(courseId, email) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
try {
await deleteTeamUser(courseId, email);
dispatch(deleteCourseTeamUser(email));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
return true;
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
return false;
}
};
}

View File

@@ -1,139 +0,0 @@
import { useDispatch, useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useEffect, useState } from 'react';
import { useToggle } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { USER_ROLES } from '../constants';
import { RequestStatus } from '../data/constants';
import {
changeRoleTeamUserQuery,
createCourseTeamQuery,
deleteCourseTeamQuery,
fetchCourseTeamQuery,
} from './data/thunk';
import {
getCourseTeamLoadingStatus,
getCourseTeamUsers,
getErrorMessage,
getIsAllowActions,
getIsOwnershipHint, getSavingStatus,
} from './data/selectors';
import { setErrorMessage } from './data/slice';
import { MODAL_TYPES } from './constants';
const useCourseTeam = ({ courseId }) => {
const dispatch = useDispatch();
const { email: currentUserEmail } = getAuthenticatedUser();
const { courseDetails } = useCourseAuthoringContext();
const [modalType, setModalType] = useState(MODAL_TYPES.delete);
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
const [isFormVisible, openForm, hideForm] = useToggle(false);
const [currentEmail, setCurrentEmail] = useState('');
const [isQueryPending, setIsQueryPending] = useState(false);
const courseTeamUsers = useSelector(getCourseTeamUsers);
const errorMessage = useSelector(getErrorMessage);
const savingStatus = useSelector(getSavingStatus);
const isAllowActions = useSelector(getIsAllowActions);
const isOwnershipHint = useSelector(getIsOwnershipHint);
const loadingCourseTeamStatus = useSelector(getCourseTeamLoadingStatus);
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
const handleOpenInfoModal = (type, email) => {
setCurrentEmail(email);
setModalType(type);
openInfoModal();
};
const handleCloseInfoModal = () => {
dispatch(setErrorMessage(''));
closeInfoModal();
};
const handleAddUserSubmit = (data) => {
setIsQueryPending(true);
const { email } = data;
const isUserContains = courseTeamUsers.some((user) => user.email === email);
if (isUserContains) {
handleOpenInfoModal(MODAL_TYPES.warning, email);
return;
}
dispatch(createCourseTeamQuery(courseId, email)).then((result) => {
if (result) {
hideForm();
dispatch(setErrorMessage(''));
return;
}
handleOpenInfoModal(MODAL_TYPES.error, email);
});
};
const handleDeleteUserSubmit = () => {
setIsQueryPending(true);
dispatch(deleteCourseTeamQuery(courseId, currentEmail));
handleCloseInfoModal();
};
const handleChangeRoleUserSubmit = (email, role) => {
setIsQueryPending(true);
dispatch(changeRoleTeamUserQuery(courseId, email, role));
};
const handleInternetConnectionFailed = () => {
setIsQueryPending(false);
};
const handleOpenDeleteModal = (email) => {
handleOpenInfoModal(MODAL_TYPES.delete, email);
};
useEffect(() => {
dispatch(fetchCourseTeamQuery(courseId));
}, [courseId]);
useEffect(() => {
if (savingStatus === RequestStatus.SUCCESSFUL) {
setIsQueryPending(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [savingStatus]);
return {
modalType,
errorMessage,
courseName: courseDetails?.name || '',
currentEmail,
courseTeamUsers,
currentUserEmail,
isLoading: loadingCourseTeamStatus === RequestStatus.IN_PROGRESS,
isLoadingDenied: loadingCourseTeamStatus === RequestStatus.DENIED,
isSingleAdmin,
isFormVisible,
isAllowActions,
isInfoModalOpen,
isOwnershipHint,
isQueryPending,
isInternetConnectionAlertFailed: savingStatus === RequestStatus.FAILED,
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
isShowUserFilledSidebar: Boolean(courseTeamUsers.length) || isFormVisible,
openForm,
hideForm,
closeInfoModal,
handleAddUserSubmit,
handleOpenInfoModal,
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
handleInternetConnectionFailed,
};
};
export { useCourseTeam };

118
src/course-team/hooks.tsx Normal file
View File

@@ -0,0 +1,118 @@
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useState } from 'react';
import { useToggle } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { USER_ROLES } from '../constants';
import messages from './messages';
import { MODAL_TYPES, type ModalType } from './constants';
import {
useChangeRoleTeamUser,
useCourseTeamData,
useCreateTeamUser,
useDeleteTeamUser,
} from './data/apiHooks';
const useCourseTeam = () => {
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const { email: currentUserEmail } = getAuthenticatedUser();
const { courseDetails } = useCourseAuthoringContext();
const {
data,
isPending: isLoadingCourseTeamStatus,
failureReason: courseTeamQueryError,
} = useCourseTeamData(courseId);
const {
users: courseTeamUsers = [],
allowActions: isAllowActions = false,
showTransferOwnershipHint: isOwnershipHint = false,
} = data ?? {};
const addUserMutation = useCreateTeamUser(courseId);
const editUserRoleMutation = useChangeRoleTeamUser(courseId);
const deleteUserMutation = useDeleteTeamUser(courseId);
const [modalType, setModalType] = useState<ModalType>(MODAL_TYPES.delete);
const [isInfoModalOpen, openInfoModal, closeInfoModal] = useToggle(false);
const [isFormVisible, openForm, hideForm] = useToggle(false);
const [currentEmail, setCurrentEmail] = useState('');
const courseTeamStatusIsDenied = courseTeamQueryError?.response?.status === 403;
const isSingleAdmin = courseTeamUsers.filter((user) => user.role === USER_ROLES.admin).length === 1;
const handleOpenInfoModal = (type: ModalType, email: string) => {
setCurrentEmail(email);
setModalType(type);
openInfoModal();
};
const handleAddUserSubmit = (body: { email: string }) => {
const { email } = body;
const isUserContains = courseTeamUsers.some((user) => user.email === email);
if (isUserContains) {
handleOpenInfoModal(MODAL_TYPES.warning, email);
return;
}
addUserMutation.mutateAsync(email).then(() => {
hideForm();
}).catch(() => {
handleOpenInfoModal(MODAL_TYPES.error, email);
});
};
const handleDeleteUserSubmit = () => {
deleteUserMutation.mutate(currentEmail);
closeInfoModal();
};
const handleChangeRoleUserSubmit = (email: string, role: string) => {
editUserRoleMutation.mutate({ email, role });
};
const handleOpenDeleteModal = (email: string) => {
handleOpenInfoModal(MODAL_TYPES.delete, email);
};
const getErrorMessage = () => {
const errorObject = addUserMutation.error ?? editUserRoleMutation.error ?? deleteUserMutation.error;
// @ts-ignore
return errorObject?.response?.data?.error ?? intl.formatMessage(messages.unknownError);
};
return {
modalType,
courseName: courseDetails?.name ?? '',
currentEmail,
courseTeamUsers,
currentUserEmail,
errorMessage: getErrorMessage(),
isLoading: isLoadingCourseTeamStatus,
isLoadingDenied: courseTeamStatusIsDenied,
isSingleAdmin,
isFormVisible,
isAllowActions,
isInfoModalOpen,
isOwnershipHint,
isQueryPending: addUserMutation.isPending || deleteUserMutation.isPending || editUserRoleMutation.isPending,
isShowAddTeamMember: courseTeamUsers.length === 1 && isAllowActions,
isShowInitialSidebar: !courseTeamUsers.length && !isFormVisible,
isShowUserFilledSidebar: Boolean(courseTeamUsers?.length) || isFormVisible,
openForm,
hideForm,
closeInfoModal,
handleAddUserSubmit,
handleOpenInfoModal,
handleOpenDeleteModal,
handleDeleteUserSubmit,
handleChangeRoleUserSubmit,
};
};
export { useCourseTeam };

View File

@@ -13,6 +13,11 @@ const messages = defineMessages({
id: 'course-authoring.course-team.button.new-team-member',
defaultMessage: 'New team member',
},
unknownError: {
id: 'course-authoring.course-team.error.unknown',
defaultMessage: 'An unexpected error occurred. Please try again.',
description: 'Fallback error message shown when the API returns an error in an unexpected format.',
},
});
export default messages;

View File

@@ -15,7 +15,6 @@ import { reducer as advancedSettingsReducer } from './advanced-settings/data/sli
import { reducer as studioHomeReducer } from './studio-home/data/slice';
import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/data/slice';
import { reducer as filesReducer } from './files-and-videos/files-page/data/slice';
import { reducer as courseTeamReducer } from './course-team/data/slice';
import { reducer as CourseUpdatesReducer } from './course-updates/data/slice';
import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice';
import { reducer as courseExportReducer } from './export-page/data/slice';
@@ -45,7 +44,6 @@ export interface DeprecatedReduxState {
studioHome: InferState<typeof studioHomeReducer>;
models: Record<string, any>;
live: Record<string, any>;
courseTeam: Record<string, any>;
courseUpdates: Record<string, any>;
processingNotification: Record<string, any>;
courseExport: Record<string, any>;
@@ -79,7 +77,6 @@ export default function initializeStore(preloadedState: Partial<DeprecatedReduxS
studioHome: studioHomeReducer,
models: modelsReducer,
live: liveReducer,
courseTeam: courseTeamReducer,
courseUpdates: CourseUpdatesReducer,
processingNotification: processingNotificationReducer,
courseExport: courseExportReducer,