Skip to content

Commit 9a3be80

Browse files
feat: main content course team
1 parent 16a821d commit 9a3be80

12 files changed

Lines changed: 652 additions & 3 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { renderWithIntl } from '@src/testUtils';
4+
import CourseTeamPage from './CourseTeamPage';
5+
6+
// Mock the child components, each component should have its own test suite
7+
jest.mock('./components/MembersContent', () => {
8+
return function MembersContent() {
9+
return <div>Members Content</div>;
10+
};
11+
});
12+
13+
jest.mock('./components/RolesContent', () => {
14+
return function RolesContent() {
15+
return <div>Roles Content</div>;
16+
};
17+
});
18+
19+
describe('CourseTeamPage', () => {
20+
it('renders the course team title', () => {
21+
renderWithIntl(<CourseTeamPage />);
22+
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
23+
});
24+
25+
it('renders the add team member button', () => {
26+
renderWithIntl(<CourseTeamPage />);
27+
expect(screen.getByRole('button', { name: /add team member/i })).toBeInTheDocument();
28+
});
29+
30+
it('renders both tabs', () => {
31+
renderWithIntl(<CourseTeamPage />);
32+
expect(screen.getByRole('tab', { name: /members/i })).toBeInTheDocument();
33+
expect(screen.getByRole('tab', { name: /roles/i })).toBeInTheDocument();
34+
});
35+
36+
it('renders MembersContent by default', () => {
37+
renderWithIntl(<CourseTeamPage />);
38+
expect(screen.getByText('Members Content')).toBeInTheDocument();
39+
});
40+
41+
it('has correct CSS classes on title', () => {
42+
renderWithIntl(<CourseTeamPage />);
43+
const title = screen.getByRole('heading', { level: 3 });
44+
expect(title).toHaveClass('text-primary-700', 'mb-0');
45+
});
46+
47+
it('has primary variant on add button', () => {
48+
renderWithIntl(<CourseTeamPage />);
49+
const button = screen.getByRole('button', { name: /add team member/i });
50+
expect(button).toHaveClass('btn-primary');
51+
});
52+
53+
it('renders RolesContent when Roles tab is selected', async () => {
54+
renderWithIntl(<CourseTeamPage />);
55+
const rolesTab = screen.getByRole('tab', { name: /roles/i });
56+
const user = userEvent.setup();
57+
await user.click(rolesTab);
58+
expect(screen.getByText('Roles Content')).toBeInTheDocument();
59+
});
60+
});

src/courseTeam/CourseTeamPage.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
1+
import { useIntl } from '@openedx/frontend-base';
2+
import messages from './messages';
3+
import { Button, Tab, Tabs } from '@openedx/paragon';
4+
import MembersContent from './components/MembersContent';
5+
import RolesContent from './components/RolesContent';
6+
17
const CourseTeamPage = () => {
8+
const intl = useIntl();
9+
210
return (
3-
<div>
4-
<h3>Course Team</h3>
5-
</div>
11+
<>
12+
<div className="d-flex justify-content-between align-items-center mb-3">
13+
<h3 className="text-primary-700 mb-0">{intl.formatMessage(messages.courseTeamTitle)}</h3>
14+
<Button variant="primary">+ {intl.formatMessage(messages.addTeamMember)}</Button>
15+
</div>
16+
<Tabs>
17+
<Tab eventKey="members" title={intl.formatMessage(messages.membersTab)}>
18+
<MembersContent />
19+
</Tab>
20+
<Tab eventKey="roles" title={intl.formatMessage(messages.rolesTab)}>
21+
<RolesContent />
22+
</Tab>
23+
</Tabs>
24+
</>
625
);
726
};
827

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { renderWithIntl } from '@src/testUtils';
4+
import { useTeamMembers } from '../data/apiHook';
5+
import MembersContent from './MembersContent';
6+
import messages from '../messages';
7+
8+
const courseId = 'course-v1:edX+DemoX+Demo_Course';
9+
10+
jest.mock('../data/apiHook', () => ({
11+
useTeamMembers: jest.fn(),
12+
}));
13+
14+
jest.mock('react-router-dom', () => ({
15+
...jest.requireActual('react-router-dom'),
16+
useParams: () => ({ courseId: courseId }),
17+
}));
18+
19+
const mockTeamMembers = [
20+
{ username: 'user1', email: 'user1@example.com', role: 'Admin' },
21+
{ username: 'user2', email: 'user2@example.com', role: 'Staff' },
22+
];
23+
24+
const renderComponent = () => renderWithIntl(<MembersContent />);
25+
26+
describe('MembersContent', () => {
27+
beforeEach(() => {
28+
jest.clearAllMocks();
29+
});
30+
31+
it('renders loading state correctly', () => {
32+
(useTeamMembers as jest.Mock).mockReturnValue({
33+
data: { results: [], numPages: 1, count: 0 },
34+
isLoading: true,
35+
});
36+
37+
renderComponent();
38+
expect(screen.getByRole('table')).toBeInTheDocument();
39+
});
40+
41+
it('renders team members data correctly', () => {
42+
(useTeamMembers as jest.Mock).mockReturnValue({
43+
data: { results: mockTeamMembers, numPages: 1, count: 2 },
44+
isLoading: false,
45+
});
46+
47+
renderComponent();
48+
49+
expect(screen.getByText(mockTeamMembers[0].username)).toBeInTheDocument();
50+
expect(screen.getByText(mockTeamMembers[0].email)).toBeInTheDocument();
51+
expect(screen.getByText(mockTeamMembers[0].role)).toBeInTheDocument();
52+
expect(screen.getByText(mockTeamMembers[1].username)).toBeInTheDocument();
53+
expect(screen.getByText(mockTeamMembers[1].email)).toBeInTheDocument();
54+
expect(screen.getByText(mockTeamMembers[1].role)).toBeInTheDocument();
55+
});
56+
57+
it('renders empty state when no team members', () => {
58+
(useTeamMembers as jest.Mock).mockReturnValue({
59+
data: { results: [], numPages: 1, count: 0 },
60+
isLoading: false,
61+
});
62+
63+
renderComponent();
64+
expect(screen.getByText(messages.noTeamMembers.defaultMessage)).toBeInTheDocument();
65+
});
66+
67+
it('calls useTeamMembers with correct parameters', () => {
68+
(useTeamMembers as jest.Mock).mockReturnValue({
69+
data: { results: [], numPages: 1, count: 0 },
70+
isLoading: false,
71+
});
72+
73+
renderComponent();
74+
75+
expect(useTeamMembers).toHaveBeenCalledWith(courseId, {
76+
page: 0,
77+
emailOrUsername: '',
78+
role: '',
79+
pageSize: 25,
80+
});
81+
});
82+
83+
it('handles pagination correctly', async () => {
84+
(useTeamMembers as jest.Mock).mockReturnValue({
85+
data: { results: mockTeamMembers, numPages: 3, count: 50 },
86+
isLoading: false,
87+
});
88+
89+
renderComponent();
90+
91+
const nextPageButton = screen.getByLabelText(/next/i);
92+
const user = userEvent.setup();
93+
await user.click(nextPageButton);
94+
95+
expect(useTeamMembers).toHaveBeenLastCalledWith(courseId, {
96+
page: 1,
97+
emailOrUsername: '',
98+
role: '',
99+
pageSize: 25,
100+
});
101+
});
102+
103+
it('renders action buttons for each row', () => {
104+
(useTeamMembers as jest.Mock).mockReturnValue({
105+
data: { results: mockTeamMembers, numPages: 1, count: 2 },
106+
isLoading: false,
107+
});
108+
109+
renderComponent();
110+
111+
const editButtons = screen.getAllByText(messages.edit.defaultMessage);
112+
expect(editButtons).toHaveLength(2);
113+
});
114+
115+
it('renders table headers correctly', () => {
116+
(useTeamMembers as jest.Mock).mockReturnValue({
117+
data: { results: mockTeamMembers, numPages: 1, count: 2 },
118+
isLoading: false,
119+
});
120+
121+
renderComponent();
122+
123+
expect(screen.getByText(messages.username.defaultMessage)).toBeInTheDocument();
124+
expect(screen.getByText(messages.email.defaultMessage)).toBeInTheDocument();
125+
expect(screen.getByText(messages.role.defaultMessage)).toBeInTheDocument();
126+
expect(screen.getByText(messages.actions.defaultMessage)).toBeInTheDocument();
127+
});
128+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { useState, useCallback, useMemo } from 'react';
2+
import { useParams } from 'react-router-dom';
3+
import { useIntl } from '@openedx/frontend-base';
4+
import { Button, DataTable } from '@openedx/paragon';
5+
import messages from '../messages';
6+
import { useTeamMembers } from '../data/apiHook';
7+
8+
const TEAM_MEMBERS_PAGE_SIZE = 25;
9+
10+
const MembersContent = () => {
11+
const intl = useIntl();
12+
const { courseId = '' } = useParams<{ courseId: string }>();
13+
const [filters, setFilters] = useState({ page: 0, emailOrUsername: '', role: '' });
14+
const { data: { results: teamMembers = [], numPages = 1, count = 0 } = {}, isLoading = false } = useTeamMembers(courseId, { ...filters, pageSize: TEAM_MEMBERS_PAGE_SIZE });
15+
16+
const tableColumns = useMemo(() => [
17+
{ accessor: 'username', Header: intl.formatMessage(messages.username) },
18+
{ accessor: 'email', Header: intl.formatMessage(messages.email) },
19+
{ accessor: 'role', Header: intl.formatMessage(messages.role) },
20+
], [intl]);
21+
22+
const additionalColumns = useMemo(() => [{
23+
id: 'actions',
24+
Header: intl.formatMessage(messages.actions),
25+
Cell: () => (
26+
<Button variant="link" size="inline">
27+
{intl.formatMessage(messages.edit)}
28+
</Button>
29+
)
30+
}], [intl]);
31+
32+
const handleFetchData = useCallback(({ pageIndex, filters: tableFilters }: { pageIndex: number, filters: { id: string, value: string }[] }) => {
33+
// Filters will be handled in a future iteration, for now we will just update pagination
34+
console.log(pageIndex, tableFilters);
35+
if (pageIndex !== filters.page) {
36+
setFilters(prevFilters => ({
37+
...prevFilters,
38+
page: pageIndex,
39+
}));
40+
}
41+
// eslint-disable-next-line react-hooks/exhaustive-deps
42+
}, []);
43+
44+
const tableState = useMemo(() => ({
45+
pageIndex: filters.page,
46+
pageSize: TEAM_MEMBERS_PAGE_SIZE,
47+
}), [filters.page]);
48+
49+
return (
50+
<DataTable
51+
additionalColumns={additionalColumns}
52+
columns={tableColumns}
53+
data={teamMembers}
54+
fetchData={handleFetchData}
55+
state={tableState}
56+
isLoading={isLoading}
57+
isPaginated
58+
itemCount={count}
59+
manualFilters
60+
manualPagination
61+
pageSize={TEAM_MEMBERS_PAGE_SIZE}
62+
pageCount={numPages}
63+
RowStatusComponent={() => null}
64+
>
65+
<DataTable.TableControlBar />
66+
<DataTable.Table />
67+
<DataTable.EmptyTable content={intl.formatMessage(messages.noTeamMembers)} />
68+
<DataTable.TableFooter />
69+
</DataTable>
70+
);
71+
};
72+
73+
export default MembersContent;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const RolesContent = () => {
2+
return (
3+
<div className="mt-4">
4+
Roles content goes here.
5+
</div>
6+
);
7+
};
8+
9+
export default RolesContent;

src/courseTeam/data/api.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { getAuthenticatedHttpClient } from '@openedx/frontend-base';
2+
import { getTeamMembers, getRoles } from './api';
3+
4+
jest.mock('@openedx/frontend-base', () => ({
5+
...jest.requireActual('@openedx/frontend-base'),
6+
getAuthenticatedHttpClient: jest.fn(),
7+
}));
8+
9+
jest.mock('../../data/api', () => ({
10+
getApiBaseUrl: jest.fn().mockReturnValue(''),
11+
}));
12+
13+
const httpClientMock = {
14+
get: jest.fn(),
15+
};
16+
17+
beforeEach(() => {
18+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue(httpClientMock);
19+
});
20+
21+
describe('courseTeam API', () => {
22+
describe('getTeamMembers', () => {
23+
it('should call the correct endpoint to get team members', async () => {
24+
const courseId = 'course-v1:edX+DemoX+Demo_Course';
25+
const params = { page: 0, pageSize: 10 };
26+
httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } });
27+
28+
await getTeamMembers(courseId, params);
29+
30+
const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10`;
31+
expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
32+
});
33+
34+
it('should include email_or_username in query params if provided', async () => {
35+
const courseId = 'course-v1:edX+DemoX+Demo_Course';
36+
const params = { page: 0, pageSize: 10, emailOrUsername: 'test@example.com' };
37+
httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } });
38+
39+
await getTeamMembers(courseId, params);
40+
41+
const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10&email_or_username=test%40example.com`;
42+
expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
43+
});
44+
45+
it('should include role in query params if provided', async () => {
46+
const courseId = 'course-v1:edX+DemoX+Demo_Course';
47+
const params = { page: 0, pageSize: 10, role: 'instructor' };
48+
httpClientMock.get.mockResolvedValue({ data: { results: [], count: 0 } });
49+
50+
await getTeamMembers(courseId, params);
51+
52+
const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_members?page=1&page_size=10&role=instructor`;
53+
expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
54+
});
55+
});
56+
57+
describe('getRoles', () => {
58+
it('should call the correct endpoint to get roles', async () => {
59+
const courseId = 'course-v1:edX+DemoX+Demo_Course';
60+
httpClientMock.get.mockResolvedValue({ data: { roles: [] } });
61+
62+
await getRoles(courseId);
63+
64+
const expectedUrl = `/api/instructor/v2/courses/${courseId}/team_roles`;
65+
expect(httpClientMock.get).toHaveBeenCalledWith(expectedUrl);
66+
});
67+
68+
it('should return the roles from the response', async () => {
69+
const courseId = 'course-v1:edX+DemoX+Demo_Course';
70+
const roles = ['instructor', 'staff'];
71+
httpClientMock.get.mockResolvedValue({ data: { roles } });
72+
73+
const result = await getRoles(courseId);
74+
75+
expect(result).toEqual(roles);
76+
});
77+
});
78+
});

0 commit comments

Comments
 (0)