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
77 changes: 77 additions & 0 deletions src/courseTeam/CourseTeamPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import CourseTeamPage from './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() {
return <div>Members Content</div>;
};
});

jest.mock('./components/RolesContent', () => {
return function RolesContent() {
return <div>Roles Content</div>;
};
});

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

describe('CourseTeamPage', () => {
it('renders the course team title', () => {
renderWithIntl(<CourseTeamPage />);
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
});

it('renders the add team member button', () => {
renderWithIntl(<CourseTeamPage />);
expect(screen.getByRole('button', { name: /add team member/i })).toBeInTheDocument();
});

it('renders both tabs', () => {
renderWithIntl(<CourseTeamPage />);
expect(screen.getByRole('tab', { name: /members/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /roles/i })).toBeInTheDocument();
});

it('renders MembersContent by default', () => {
renderWithIntl(<CourseTeamPage />);
expect(screen.getByText('Members Content')).toBeInTheDocument();
});

it('has correct CSS classes on title', () => {
renderWithIntl(<CourseTeamPage />);
const title = screen.getByRole('heading', { level: 3 });
expect(title).toHaveClass('text-primary-700', 'mb-0');
});

it('shows the AddTeamMemberModal when add button is clicked', async () => {
renderWithIntl(<CourseTeamPage />);
const button = screen.getByRole('button', { name: /add team member/i });
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 () => {
renderWithIntl(<CourseTeamPage />);
const rolesTab = screen.getByRole('tab', { name: /roles/i });
const user = userEvent.setup();
await user.click(rolesTab);
expect(screen.getByText('Roles Content')).toBeInTheDocument();
});
});
37 changes: 34 additions & 3 deletions src/courseTeam/CourseTeamPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { Button, Tab, Tabs, useToggle } from '@openedx/paragon';
import messages from './messages';
import MembersContent from './components/MembersContent';
import RolesContent from './components/RolesContent';
import AddTeamMemberModal from './components/AddTeamMemberModal';
import { useAddTeamMember } from './data/apiHook';

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 });
closeAddModal();
};

return (
<div>
<h3>Course Team</h3>
</div>
<>
<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 variant="primary" onClick={openAddModal}>+ {intl.formatMessage(messages.addTeamMember)}</Button>
</div>
<Tabs>
<Tab eventKey="members" title={intl.formatMessage(messages.membersTab)}>
<MembersContent />
</Tab>
<Tab eventKey="roles" title={intl.formatMessage(messages.rolesTab)}>
<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 } = useRoles(courseId);

const roles = [{ id: '', name: intl.formatMessage(messages.rolePlaceholder) }, ...(data || [])];

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.id} value={role.id}>
{role.name}
</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;
128 changes: 128 additions & 0 deletions src/courseTeam/components/MembersContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import { useTeamMembers } from '../data/apiHook';
import MembersContent from './MembersContent';
import messages from '../messages';

const courseId = 'course-v1:edX+DemoX+Demo_Course';

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

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

const mockTeamMembers = [
{ username: 'user1', email: 'user1@example.com', role: 'Admin' },
{ username: 'user2', email: 'user2@example.com', role: 'Staff' },
];

const renderComponent = () => renderWithIntl(<MembersContent />);

describe('MembersContent', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders loading state correctly', () => {
(useTeamMembers as jest.Mock).mockReturnValue({
data: { results: [], numPages: 1, count: 0 },
isLoading: true,
});

renderComponent();
expect(screen.getByRole('table')).toBeInTheDocument();
});

it('renders team members data correctly', () => {
(useTeamMembers as jest.Mock).mockReturnValue({
data: { results: mockTeamMembers, numPages: 1, count: 2 },
isLoading: false,
});

renderComponent();

expect(screen.getByText(mockTeamMembers[0].username)).toBeInTheDocument();
expect(screen.getByText(mockTeamMembers[0].email)).toBeInTheDocument();
expect(screen.getByText(mockTeamMembers[0].role)).toBeInTheDocument();
expect(screen.getByText(mockTeamMembers[1].username)).toBeInTheDocument();
expect(screen.getByText(mockTeamMembers[1].email)).toBeInTheDocument();
expect(screen.getByText(mockTeamMembers[1].role)).toBeInTheDocument();
});

it('renders empty state when no team members', () => {
(useTeamMembers as jest.Mock).mockReturnValue({
data: { results: [], numPages: 1, count: 0 },
isLoading: false,
});

renderComponent();
expect(screen.getByText(messages.noTeamMembers.defaultMessage)).toBeInTheDocument();
});

it('calls useTeamMembers with correct parameters', () => {
(useTeamMembers as jest.Mock).mockReturnValue({
data: { results: [], numPages: 1, count: 0 },
isLoading: false,
});

renderComponent();

expect(useTeamMembers).toHaveBeenCalledWith(courseId, {
page: 0,
emailOrUsername: '',
role: '',
pageSize: 25,
});
});

it('handles pagination correctly', async () => {
(useTeamMembers as jest.Mock).mockReturnValue({
data: { results: mockTeamMembers, numPages: 3, count: 50 },
isLoading: false,
});

renderComponent();

const nextPageButton = screen.getByLabelText(/next/i);
const user = userEvent.setup();
await user.click(nextPageButton);

expect(useTeamMembers).toHaveBeenLastCalledWith(courseId, {
page: 1,
emailOrUsername: '',
role: '',
pageSize: 25,
});
});

it('renders action buttons for each row', () => {
(useTeamMembers as jest.Mock).mockReturnValue({
data: { results: mockTeamMembers, numPages: 1, count: 2 },
isLoading: false,
});

renderComponent();

const editButtons = screen.getAllByText(messages.edit.defaultMessage);
expect(editButtons).toHaveLength(2);
});

it('renders table headers correctly', () => {
(useTeamMembers as jest.Mock).mockReturnValue({
data: { results: mockTeamMembers, numPages: 1, count: 2 },
isLoading: false,
});

renderComponent();

expect(screen.getByText(messages.username.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.email.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.role.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.actions.defaultMessage)).toBeInTheDocument();
});
});
Loading