Skip to content
Merged
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
61 changes: 61 additions & 0 deletions src/specialExams/SpecialExamsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithIntl } from '@src/testUtils';
import SpecialExamsPage from './SpecialExamsPage';

// Mock child components
jest.mock('./components/Allowances', () => {
function MockedAllowances() {
return <div>Allowances Component</div>;
}
return MockedAllowances;
});
jest.mock('./components/AttemptsList', () => {
function MockedAttemptsList() {
return <div>AttemptsList Component</div>;
}
return MockedAttemptsList;
});

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

it('renders the attempts tab and its content by default', () => {
renderWithIntl(<SpecialExamsPage />);
expect(screen.getByText('Exam Attempts')).toBeInTheDocument();
expect(screen.getByText('AttemptsList Component')).toBeInTheDocument();
expect(screen.queryByText('Allowances Component')).not.toBeInTheDocument();
});

it('switches to allowances tab when clicked', async () => {
renderWithIntl(<SpecialExamsPage />);
const user = userEvent.setup();
await user.click(screen.getByText('Allowances'));
expect(screen.getByText('Allowances Component')).toBeInTheDocument();
expect(screen.queryByText('AttemptsList Component')).not.toBeInTheDocument();
});

it('switches back to attempts tab when clicked', async () => {
renderWithIntl(<SpecialExamsPage />);
const user = userEvent.setup();
await user.click(screen.getByText('Allowances'));
await user.click(screen.getByText('Exam Attempts'));
expect(screen.getByText('AttemptsList Component')).toBeInTheDocument();
expect(screen.queryByText('Allowances Component')).not.toBeInTheDocument();
});

it('applies correct button variants based on selected tab', async () => {
renderWithIntl(<SpecialExamsPage />);
const attemptsButton = screen.getByText('Exam Attempts');
const allowancesButton = screen.getByText('Allowances');
expect(attemptsButton).toHaveClass('btn-primary');
expect(allowancesButton).toHaveClass('btn-outline-primary');
const user = userEvent.setup();
await user.click(allowancesButton);
expect(allowancesButton).toHaveClass('btn-primary');
expect(attemptsButton).toHaveClass('btn-outline-primary');
});
});
25 changes: 22 additions & 3 deletions src/specialExams/SpecialExamsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { useState } from 'react';
import { useIntl } from '@openedx/frontend-base';
import { Button, ButtonGroup, Card } from '@openedx/paragon';
import messages from './messages';
import Allowances from './components/Allowances';
import AttemptsList from './components/AttemptsList';

const SpecialExamsPage = () => {
const intl = useIntl();
const [selectedTab, setSelectedTab] = useState<'attempts' | 'allowances'>('attempts');

return (
<div>
<h3>Special Exams</h3>
</div>
<>
<h3 className="text-primary-700">{intl.formatMessage(messages.specialExamsTitle)}</h3>
<Card className="bg-light-200 mt-4.5">
<ButtonGroup className="d-block mx-4 mt-4">
<Button variant={selectedTab === 'attempts' ? 'primary' : 'outline-primary'} onClick={() => setSelectedTab('attempts')}>{intl.formatMessage(messages.examAttempts)}</Button>
<Button variant={selectedTab === 'allowances' ? 'primary' : 'outline-primary'} onClick={() => setSelectedTab('allowances')}>{intl.formatMessage(messages.allowances)}</Button>
</ButtonGroup>
{
selectedTab === 'attempts' ? <AttemptsList /> : <Allowances />
}
</Card>
</>
);
};

Expand Down
5 changes: 5 additions & 0 deletions src/specialExams/components/Allowances.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const Allowances = () => {
return <div>Allowances</div>;
};

export default Allowances;
103 changes: 103 additions & 0 deletions src/specialExams/components/AttemptsList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AttemptsList, { ATTEMPTS_PAGE_SIZE } from './AttemptsList';
import { renderWithIntl } from '@src/testUtils';
import { useAttempts } from '../data/apiHook';

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({ courseId: 'course-v1:edX+Test+2024' }),
}));

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

const mockExamAttempts = {
results: [
{
username: 'user1',
examName: 'Midterm',
timeLimit: '60',
type: 'proctored',
startedAt: '2024-01-01',
completedAt: '2024-01-02',
status: 'completed',
},
],
count: 1,
numPages: 1,
};

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

it('renders DataTable with correct columns and empty data', () => {
(useAttempts as jest.Mock).mockReturnValue({
data: { results: [], count: 0, numPages: 0 },
isLoading: false,
});

renderWithIntl(<AttemptsList />);

expect(screen.getByText('No exam attempts found')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Search By Username or Email')).toBeInTheDocument();
});

it('shows loading state when isLoading is true', () => {
(useAttempts as jest.Mock).mockReturnValue({
data: { results: [], count: 0, numPages: 0 },
isLoading: true,
});

renderWithIntl(<AttemptsList />);
expect(screen.getByRole('status')).toBeInTheDocument();
});

it('renders attempts data', () => {
(useAttempts as jest.Mock).mockReturnValue({
data: mockExamAttempts,
isLoading: false,
});

renderWithIntl(<AttemptsList />);
expect(screen.getByText(mockExamAttempts.results[0].username)).toBeInTheDocument();
expect(screen.getByText(mockExamAttempts.results[0].examName)).toBeInTheDocument();
expect(screen.getByText(mockExamAttempts.results[0].timeLimit)).toBeInTheDocument();
expect(screen.getByText(mockExamAttempts.results[0].type)).toBeInTheDocument();
expect(screen.getByText(mockExamAttempts.results[0].startedAt)).toBeInTheDocument();
expect(screen.getByText(mockExamAttempts.results[0].completedAt)).toBeInTheDocument();
expect(screen.getByText(mockExamAttempts.results[0].status)).toBeInTheDocument();
});

it('calls fetchData when filter changes', async () => {
(useAttempts as jest.Mock).mockReturnValue({
data: { results: [], count: 0, numPages: 0 },
isLoading: false,
});

renderWithIntl(<AttemptsList />);

const input = screen.getByRole('textbox');
const user = userEvent.setup();
await user.type(input, 'testuser');

await waitFor(() => {
expect(useAttempts).toHaveBeenCalledWith(
'course-v1:edX+Test+2024', expect.objectContaining({ emailOrUsername: 'testuser', page: 0, pageSize: ATTEMPTS_PAGE_SIZE }),
);
});
});

it('uses courseId from route params', () => {
(useAttempts as jest.Mock).mockReturnValue({
data: { results: [], count: 0, numPages: 0 },
isLoading: false,
});

renderWithIntl(<AttemptsList />);
expect(useAttempts).toHaveBeenCalledWith('course-v1:edX+Test+2024', expect.any(Object));
});
});
75 changes: 75 additions & 0 deletions src/specialExams/components/AttemptsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useIntl } from '@openedx/frontend-base';
import { DataTable } from '@openedx/paragon';
import UsernameFilter from '@src/components/UsernameFilter';
import messages from '@src/specialExams/messages';
import { useAttempts } from '@src/specialExams/data/apiHook';
import { DataTableFetchDataProps } from '@src/types';

export const ATTEMPTS_PAGE_SIZE = 25;

const AttemptsList = () => {
const intl = useIntl();
const { courseId = '' } = useParams();
const [filters, setFilters] = useState({ page: 0, emailOrUsername: '' });
const { data = { results: [], count: 0, numPages: 0 }, isLoading = false } = useAttempts(courseId, {
...filters,
pageSize: ATTEMPTS_PAGE_SIZE
});

const columns = useMemo(() => [
{ accessor: 'username', Header: intl.formatMessage(messages.username), Filter: UsernameFilter, },
{ accessor: 'examName', Header: intl.formatMessage(messages.examName), disableFilters: true, },
{ accessor: 'timeLimit', Header: intl.formatMessage(messages.timeLimit), disableFilters: true, },
{ accessor: 'type', Header: intl.formatMessage(messages.type), disableFilters: true, },
{ accessor: 'startedAt', Header: intl.formatMessage(messages.startedAt), disableFilters: true, },
{ accessor: 'completedAt', Header: intl.formatMessage(messages.completedAt), disableFilters: true, },
{ accessor: 'status', Header: intl.formatMessage(messages.status), disableFilters: true, },
], [intl]);

const handleFetchData = (data: DataTableFetchDataProps) => {
const emailOrUsernameFilter = data.filters?.find((f) => f.id === 'username');
if (emailOrUsernameFilter && emailOrUsernameFilter.value !== filters.emailOrUsername) {
setFilters((prevFilters) => ({ ...prevFilters, emailOrUsername: emailOrUsernameFilter.value, page: 0 }));
return;
}
if (data.pageIndex !== filters.page) {
setFilters((prevFilters) => ({ ...prevFilters, page: data.pageIndex }));
}
};

return (
<DataTable
className="mt-3"
columns={columns}
data={data.results}
state={{
pageIndex: filters.page,
pageSize: ATTEMPTS_PAGE_SIZE,
filters: [
{ id: 'emailOrUsername', value: filters.emailOrUsername }
]
}}
fetchData={handleFetchData}
isFilterable
isLoading={isLoading}
isPaginated
isSortable
itemCount={data.count}
manualFilters
manualPagination
manualSortBy
pageSize={ATTEMPTS_PAGE_SIZE}
pageCount={data.numPages}
FilterStatusComponent={() => null}
>
<DataTable.TableControlBar className="bg-light-200 py-3 px-4" />
<DataTable.Table />
<DataTable.EmptyTable content={intl.formatMessage(messages.noAttempts)} />
<DataTable.TableFooter />
</DataTable>
);
};

export default AttemptsList;
Loading
Loading