Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/courseTeam/CourseTeamPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import CourseTeamPage from '@src/courseTeam/CourseTeamPage';

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(() => ({ courseId: 'course-v1:test-course' })),
}));

jest.mock('./data/apiHook', () => ({
useAddTeamMember: () => ({ mutate: jest.fn() }),
}));

// Mock the child components, each component should have its own test suite
jest.mock('./components/MembersContent', () => {
return function MembersContent() {
Expand All @@ -16,6 +25,12 @@ jest.mock('./components/RolesContent', () => {
};
});

jest.mock('./components/AddTeamMemberModal', () => {
return function AddTeamMemberModal() {
return <div>Add Team Member Modal</div>;
};
});

describe('CourseTeamPage', () => {
it('renders the course team title', () => {
renderWithIntl(<CourseTeamPage />);
Expand Down Expand Up @@ -44,10 +59,12 @@ describe('CourseTeamPage', () => {
expect(title).toHaveClass('text-primary-700', 'mb-0');
});

it('has primary variant on add button', () => {
it('shows the AddTeamMemberModal when add button is clicked', async () => {
renderWithIntl(<CourseTeamPage />);
const button = screen.getByRole('button', { name: /add team member/i });
expect(button).toHaveClass('btn-primary');
const user = userEvent.setup();
await user.click(button);
expect(screen.getByText('Add Team Member Modal')).toBeInTheDocument();
});

it('renders RolesContent when Roles tab is selected', async () => {
Expand Down
19 changes: 17 additions & 2 deletions src/courseTeam/CourseTeamPage.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { Button, Tab, Tabs } from '@openedx/paragon';
import { Button, Tab, Tabs, useToggle } from '@openedx/paragon';
import { Plus } from '@openedx/paragon/icons';
import AddTeamMemberModal from '@src/courseTeam/components/AddTeamMemberModal';
import MembersContent from '@src/courseTeam/components/MembersContent';
import RolesContent from '@src/courseTeam/components/RolesContent';
import { useAddTeamMember } from '@src/courseTeam/data/apiHook';
import messages from '@src/courseTeam/messages';

const CourseTeamPage = () => {
const intl = useIntl();
const { courseId = '' } = useParams<{ courseId: string }>();
const [isOpenAddModal, openAddModal, closeAddModal] = useToggle(false);
const { mutate: addTeamMember } = useAddTeamMember(courseId);

const handleAdd = ({ users, role }: { users: string[], role: string }) => {
addTeamMember({ users, role }, {
onSuccess: () => {
closeAddModal();
},
});
};

return (
<>
<div className="d-flex justify-content-between align-items-center mb-3">
<h3 className="text-primary-700 mb-0">{intl.formatMessage(messages.courseTeamTitle)}</h3>
<Button iconBefore={Plus} variant="primary">{intl.formatMessage(messages.addTeamMember)}</Button>
<Button iconBefore={Plus} variant="primary" onClick={openAddModal}>{intl.formatMessage(messages.addTeamMember)}</Button>
</div>
<Tabs>
<Tab eventKey="members" title={intl.formatMessage(messages.membersTab)}>
Expand All @@ -22,6 +36,7 @@ const CourseTeamPage = () => {
<RolesContent />
</Tab>
</Tabs>
{isOpenAddModal && <AddTeamMemberModal isOpen={isOpenAddModal} onClose={closeAddModal} onSave={handleAdd} />}
</>
);
};
Expand Down
72 changes: 72 additions & 0 deletions src/courseTeam/components/AddTeamMemberModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import AddTeamMemberModal from './AddTeamMemberModal';
import messages from '../messages';
import { useRoles } from '../data/apiHook';

// Mocks
jest.mock('react-router-dom', () => ({
useParams: () => ({ courseId: 'course-v1:test+id' }),
}));

jest.mock('@src/data/apiHook', () => ({
useCourseInfo: () => ({ data: { displayName: 'Test Course' } }),
}));

jest.mock('../data/apiHook', () => ({
useRoles: jest.fn(),
}));

describe('AddTeamMemberModal', () => {
const defaultProps = {
isOpen: true,
onClose: jest.fn(),
onSave: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
(useRoles as jest.Mock).mockReturnValue({ data: [{ id: 'admin', name: 'Admin' }] });
});

it('renders modal with correct title and description', () => {
renderWithIntl(<AddTeamMemberModal {...defaultProps} />);
expect(screen.getByText(messages.addNewTeamMember.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.addNewTeamMemberDescription.defaultMessage.replace('{courseName}', 'Test Course'))).toBeInTheDocument();
});

it('renders users textarea and role select', () => {
renderWithIntl(<AddTeamMemberModal {...defaultProps} />);
expect(screen.getByLabelText(messages.addUsersLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.usersPlaceholder.defaultMessage)).toBeInTheDocument();
expect(screen.getByLabelText(messages.roleLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.rolePlaceholder.defaultMessage)).toBeInTheDocument();
});

it('calls onClose when Cancel button is clicked', async () => {
renderWithIntl(<AddTeamMemberModal {...defaultProps} />);
const user = userEvent.setup();
await user.click(screen.getByText(messages.cancelButton.defaultMessage));
expect(defaultProps.onClose).toHaveBeenCalled();
});

it('calls onSave when Save button is clicked', async () => {
renderWithIntl(<AddTeamMemberModal {...defaultProps} />);
const user = userEvent.setup();
await user.click(screen.getByText(messages.saveButton.defaultMessage));
expect(defaultProps.onSave).toHaveBeenCalledWith({ users: [''], role: '' });
});

it('does not render modal when isOpen is false', () => {
renderWithIntl(<AddTeamMemberModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText(messages.addNewTeamMember.defaultMessage)).not.toBeInTheDocument();
});

it('disables role select when only placeholder role exists', () => {
(useRoles as jest.Mock).mockReturnValue({ data: [] });
renderWithIntl(<AddTeamMemberModal {...defaultProps} />);
expect(screen.getByLabelText(messages.roleLabel.defaultMessage)).toBeInTheDocument();
expect(screen.getByPlaceholderText(messages.rolePlaceholder.defaultMessage)).toBeDisabled();
});
});
64 changes: 64 additions & 0 deletions src/courseTeam/components/AddTeamMemberModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { ActionRow, Button, Form, ModalDialog } from '@openedx/paragon';
import messages from '../messages';
import { useCourseInfo } from '@src/data/apiHook';
import { useRoles } from '../data/apiHook';

interface AddTeamMemberModalProps {
isOpen: boolean,
onClose: () => void,
onSave: ({ users, role }: { users: string[], role: string }) => void,
}

const AddTeamMemberModal = ({
isOpen,
onClose,
onSave,
}: AddTeamMemberModalProps) => {
const intl = useIntl();
const { courseId = '' } = useParams<{ courseId: string }>();
const { data: { displayName } = { displayName: '' } } = useCourseInfo(courseId);
const { data: { results } = { results: [] } } = useRoles(courseId);

const roles = [{ role: '', displayName: intl.formatMessage(messages.rolePlaceholder) }, ...(results || [])];

const handleSave = () => {
onSave({ users: [''], role: '' });
};

return (
<ModalDialog isOpen={isOpen} onClose={onClose} title={intl.formatMessage(messages.addNewTeamMember)} isOverflowVisible={false} size="lg">
<ModalDialog.Header>
<h3 className="text-primary-500">{intl.formatMessage(messages.addNewTeamMember)}</h3>
</ModalDialog.Header>
<ModalDialog.Body>
<p>{intl.formatMessage(messages.addNewTeamMemberDescription, { courseName: displayName })}</p>
<Form.Group>
<Form.Label>{intl.formatMessage(messages.addUsersLabel)}</Form.Label>
<Form.Control as="textarea" rows={3} placeholder={intl.formatMessage(messages.usersPlaceholder)} />
</Form.Group>
<Form.Group>
<Form.Label>{intl.formatMessage(messages.roleLabel)}</Form.Label>
<Form.Control as="select" defaultValue="" placeholder={intl.formatMessage(messages.rolePlaceholder)} disabled={roles.length === 1}>
{
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.displayName}
</option>
))
}
</Form.Control>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<Button variant="tertiary" onClick={onClose}>{intl.formatMessage(messages.cancelButton)}</Button>
<Button variant="primary" onClick={handleSave}>{intl.formatMessage(messages.saveButton)}</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
);
};

export default AddTeamMemberModal;
29 changes: 28 additions & 1 deletion src/courseTeam/data/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getAuthenticatedHttpClient } from '@openedx/frontend-base';
import { getTeamMembers, getRoles } from '@src/courseTeam/data/api';
import { getTeamMembers, getRoles, addTeamMember } from '@src/courseTeam/data/api';

jest.mock('@openedx/frontend-base', () => ({
...jest.requireActual('@openedx/frontend-base'),
Expand All @@ -12,6 +12,7 @@ jest.mock('../../data/api', () => ({

const httpClientMock = {
get: jest.fn(),
post: jest.fn(),
};

beforeEach(() => {
Expand Down Expand Up @@ -52,6 +53,15 @@ describe('courseTeam API', () => {
const expectedUrl = `/api/instructor/v2/courses/${courseId}/team?page=1&page_size=10&role=instructor`;
expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
});

it('should handle errors when API call fails', async () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const params = { page: 0, pageSize: 10 };
const error = new Error('API error');
httpClientMock.get.mockRejectedValue(error);

await expect(getTeamMembers(courseId, params)).rejects.toThrow('API error');
});
});

describe('getRoles', () => {
Expand All @@ -76,4 +86,21 @@ describe('courseTeam API', () => {
expect(result).toEqual(data);
});
});

describe('addTeamMember', () => {
it('should call the correct endpoint to add a team member', async () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const users = ['testuser'];
const role = 'instructor';
httpClientMock.post.mockResolvedValue({ data: {
users,
role,
} });

await addTeamMember(courseId, users, role);

const expectedUrl = `/api/instructor/v2/courses/${courseId}/team`;
expect(httpClientMock.post).toHaveBeenCalledWith(expectedUrl, { users, role });
});
});
});
7 changes: 7 additions & 0 deletions src/courseTeam/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ export const getRoles = async (courseId: string): Promise<Omit<DataList<Role>, '
);
return camelCaseObject(data);
};

export const addTeamMember = async (courseId: string, users: string[], role: string): Promise<void> => {
await getAuthenticatedHttpClient().post(
`${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/team`,
{ users, role }
);
};
15 changes: 13 additions & 2 deletions src/courseTeam/data/apiHook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { getRoles, getTeamMembers } from '@src/courseTeam/data/api';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { addTeamMember, getRoles, getTeamMembers } from '@src/courseTeam/data/api';
import { courseTeamQueryKeys } from '@src/courseTeam/data/queryKeys';
import { CourseTeamMemberQueryParams } from '@src/courseTeam/types';

Expand All @@ -18,3 +18,14 @@ export const useRoles = (courseId: string) => (
enabled: !!courseId,
})
);

export const useAddTeamMember = (courseId: string) => {
const queryClient = useQueryClient();
return (useMutation({
mutationFn: ({ users, role }: { users: string[], role: string }) => addTeamMember(courseId, users, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: courseTeamQueryKeys.byCourse(courseId) });
}
})
);
};
42 changes: 41 additions & 1 deletion src/courseTeam/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,47 @@ const messages = defineMessages({
id: 'instruct.courseTeam.roles.ccxCoachDescription',
defaultMessage: 'CCX Coaches are able to create their own Custom Courses based on this course, which they can use to provide personalized instruction to their own students based in this course material.',
description: 'Description for CCX coach role',
}
},
addNewTeamMember: {
id: 'instruct.courseTeam.addNewTeamMember',
defaultMessage: 'Add New Team Member',
description: 'Title for add new team member form',
},
addNewTeamMemberDescription: {
id: 'instruct.courseTeam.addNewTeamMemberDescription',
defaultMessage: 'Add new members to {courseName}’s Course team and assign them a role to define their permissions.',
description: 'Description for add new team member form',
},
addUsersLabel: {
id: 'instruct.courseTeam.addUsersLabel',
defaultMessage: 'Add users by username or email',
description: 'Label for input to add users to course team',
},
usersPlaceholder: {
id: 'instruct.courseTeam.usersPlaceholder',
defaultMessage: 'Enter one or more email addresses or usernames',
description: 'Placeholder for input to add users to course team',
},
roleLabel: {
id: 'instruct.courseTeam.roleLabel',
defaultMessage: 'Role',
description: 'Label for role selection when adding users to course team',
},
rolePlaceholder: {
id: 'instruct.courseTeam.rolePlaceholder',
defaultMessage: 'Select Role',
description: 'Placeholder for role selection when adding users to course team',
},
cancelButton: {
id: 'instruct.courseTeam.cancelButton',
defaultMessage: 'Cancel',
description: 'Label for cancel button when adding users to course team',
},
saveButton: {
id: 'instruct.courseTeam.saveButton',
defaultMessage: 'Save',
description: 'Label for save button when adding users to course team',
},
});

export default messages;
Loading