Skip to content

Commit 0e4bc19

Browse files
feat: add actions to attempts and fix missing attributes on table
1 parent a1d315c commit 0e4bc19

13 files changed

Lines changed: 333 additions & 31 deletions

src/specialExams/SpecialExamsPage.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ jest.mock('./components/Allowances', () => {
1010
}
1111
return MockedAllowances;
1212
});
13-
jest.mock('./components/AttemptsList', () => {
13+
jest.mock('./components/Attempts', () => {
1414
function MockedAttemptsList() {
15-
return <div>AttemptsList Component</div>;
15+
return <div>Attempts Component</div>;
1616
}
1717
return MockedAttemptsList;
1818
});
@@ -26,7 +26,7 @@ describe('SpecialExamsPage', () => {
2626
it('renders the attempts tab and its content by default', () => {
2727
renderWithIntl(<SpecialExamsPage />);
2828
expect(screen.getByText('Exam Attempts')).toBeInTheDocument();
29-
expect(screen.getByText('AttemptsList Component')).toBeInTheDocument();
29+
expect(screen.getByText('Attempts Component')).toBeInTheDocument();
3030
expect(screen.queryByText('Allowances Component')).not.toBeInTheDocument();
3131
});
3232

@@ -43,7 +43,7 @@ describe('SpecialExamsPage', () => {
4343
const user = userEvent.setup();
4444
await user.click(screen.getByText('Allowances'));
4545
await user.click(screen.getByText('Exam Attempts'));
46-
expect(screen.getByText('AttemptsList Component')).toBeInTheDocument();
46+
expect(screen.getByText('Attempts Component')).toBeInTheDocument();
4747
expect(screen.queryByText('Allowances Component')).not.toBeInTheDocument();
4848
});
4949

src/specialExams/SpecialExamsPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useIntl } from '@openedx/frontend-base';
33
import { Button, ButtonGroup, Card } from '@openedx/paragon';
44
import messages from './messages';
55
import Allowances from './components/Allowances';
6-
import AttemptsList from './components/AttemptsList';
6+
import Attempts from './components/Attempts';
77

88
const SPECIAL_EXAMS_TAB = {
99
ATTEMPTS: 'attempts',
@@ -31,7 +31,7 @@ const SpecialExamsPage = () => {
3131
</Button>
3232
</ButtonGroup>
3333
{
34-
selectedTab === SPECIAL_EXAMS_TAB.ATTEMPTS ? <AttemptsList /> : <Allowances />
34+
selectedTab === SPECIAL_EXAMS_TAB.ATTEMPTS ? <Attempts /> : <Allowances />
3535
}
3636
</Card>
3737
</>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import Attempts from './Attempts';
4+
import { useAttempts, useResetAttempt } from '../data/apiHook';
5+
import { Attempt } from '../types';
6+
import { renderWithIntl } from '@src/testUtils';
7+
import messages from '../messages';
8+
9+
jest.mock('react-router-dom', () => ({
10+
...jest.requireActual('react-router-dom'),
11+
useParams: () => ({ courseId: 'course-v1:edX+Test+2024' }),
12+
}));
13+
14+
jest.mock('../data/apiHook', () => ({
15+
useAttempts: jest.fn(),
16+
useResetAttempt: jest.fn(),
17+
}));
18+
19+
const mockAttempt: Attempt = {
20+
id: 1,
21+
user: { username: 'testuser' },
22+
examId: 42,
23+
examName: 'Midterm Exam',
24+
allowedTimeLimitMins: 60,
25+
type: 'proctored',
26+
startTime: '2024-01-01T00:00:00Z',
27+
endTime: '2024-01-01T01:00:00Z',
28+
status: 'started',
29+
readyToResume: false,
30+
};
31+
32+
describe('Attempts', () => {
33+
const mockResetAttempt = jest.fn();
34+
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
(useResetAttempt as jest.Mock).mockReturnValue({ mutate: mockResetAttempt });
38+
(useAttempts as jest.Mock).mockReturnValue({
39+
data: { results: [mockAttempt] },
40+
isLoading: false,
41+
isError: false,
42+
});
43+
});
44+
45+
it('renders AttemptsList component', () => {
46+
renderWithIntl(<Attempts />);
47+
const usernameCell = screen.getByRole('cell', { name: mockAttempt.user.username });
48+
expect(usernameCell).toBeInTheDocument();
49+
});
50+
51+
it('calls useResetAttempt with courseId from route params', () => {
52+
renderWithIntl(<Attempts />);
53+
expect(useResetAttempt).toHaveBeenCalledWith('course-v1:edX+Test+2024');
54+
});
55+
56+
it('calls resetAttempt with username and examId when onReset is triggered', async () => {
57+
renderWithIntl(<Attempts />);
58+
const actionsButton = screen.getByRole('button', { name: messages.actions.defaultMessage });
59+
await userEvent.click(actionsButton);
60+
const resetButton = screen.getByRole('button', { name: messages.reset.defaultMessage });
61+
await userEvent.click(resetButton);
62+
63+
expect(mockResetAttempt).toHaveBeenCalledWith({
64+
username: 'testuser',
65+
examId: 42,
66+
});
67+
});
68+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useParams } from 'react-router-dom';
2+
import { Attempt } from '../types';
3+
import AttemptsList from './AttemptsList';
4+
import { useResetAttempt } from '../data/apiHook';
5+
6+
const Attempts = () => {
7+
const { courseId = '' } = useParams<{ courseId: string }>();
8+
const { mutate: resetAttempt } = useResetAttempt(courseId);
9+
10+
const handleResume = (attempt: Attempt) => {
11+
// Implement resume logic here
12+
console.log('Resume attempt:', courseId, attempt);
13+
};
14+
15+
const handleReset = (attempt: Attempt) => {
16+
const { user, examId } = attempt;
17+
resetAttempt({ username: user.username, examId });
18+
};
19+
20+
return (
21+
<AttemptsList onResume={handleResume} onReset={handleReset} />
22+
);
23+
};
24+
25+
export default Attempts;

src/specialExams/components/AttemptsList.test.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const mockExamAttempts = {
2121
},
2222
examName: 'Midterm',
2323
allowedTimeLimitMins: 60,
24-
type: 'proctored',
24+
examType: 'proctored',
2525
startTime: '2024-01-01',
2626
endTime: '2024-01-02',
2727
status: 'completed',
@@ -35,14 +35,18 @@ describe('AttemptsList', () => {
3535
beforeEach(() => {
3636
jest.clearAllMocks();
3737
});
38+
const mockReset = jest.fn();
39+
const mockResume = jest.fn();
40+
41+
const renderComponent = () => renderWithIntl(<AttemptsList onReset={mockReset} onResume={mockResume} />);
3842

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

45-
renderWithIntl(<AttemptsList />);
49+
renderComponent();
4650

4751
expect(screen.getByText('No exam attempts found')).toBeInTheDocument();
4852
expect(screen.getByPlaceholderText('Search By Username or Email')).toBeInTheDocument();
@@ -54,7 +58,7 @@ describe('AttemptsList', () => {
5458
isLoading: true,
5559
});
5660

57-
renderWithIntl(<AttemptsList />);
61+
renderComponent();
5862
expect(screen.getByRole('status')).toBeInTheDocument();
5963
});
6064

@@ -64,13 +68,13 @@ describe('AttemptsList', () => {
6468
isLoading: false,
6569
});
6670

67-
renderWithIntl(<AttemptsList />);
71+
renderComponent();
6872
expect(screen.getByText(mockExamAttempts.results[0].user.username)).toBeInTheDocument();
6973
expect(screen.getByText(mockExamAttempts.results[0].examName)).toBeInTheDocument();
7074
expect(screen.getByText(mockExamAttempts.results[0].allowedTimeLimitMins.toString())).toBeInTheDocument();
71-
expect(screen.getByText(mockExamAttempts.results[0].type)).toBeInTheDocument();
72-
expect(screen.getByText(mockExamAttempts.results[0].startTime)).toBeInTheDocument();
73-
expect(screen.getByText(mockExamAttempts.results[0].endTime)).toBeInTheDocument();
75+
expect(screen.getByText(mockExamAttempts.results[0].examType)).toBeInTheDocument();
76+
expect(screen.getByText(/01\/01\/2024.*UTC/)).toBeInTheDocument();
77+
expect(screen.getByText(/01\/02\/2024.*UTC/)).toBeInTheDocument();
7478
expect(screen.getByText(mockExamAttempts.results[0].status)).toBeInTheDocument();
7579
});
7680

@@ -80,7 +84,7 @@ describe('AttemptsList', () => {
8084
isLoading: false,
8185
});
8286

83-
renderWithIntl(<AttemptsList />);
87+
renderComponent();
8488

8589
const input = screen.getByRole('textbox');
8690
const user = userEvent.setup();
@@ -99,7 +103,7 @@ describe('AttemptsList', () => {
99103
isLoading: false,
100104
});
101105

102-
renderWithIntl(<AttemptsList />);
106+
renderComponent();
103107
expect(useAttempts).toHaveBeenCalledWith('course-v1:edX+Test+2024', expect.any(Object));
104108
});
105109
});

src/specialExams/components/AttemptsList.tsx

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import { useMemo, useState } from 'react';
22
import { useParams } from 'react-router-dom';
33
import { useIntl } from '@openedx/frontend-base';
4-
import { DataTable } from '@openedx/paragon';
4+
import { DataTable, IconButton, OverlayTrigger, Popover } from '@openedx/paragon';
5+
import { MoreVert } from '@openedx/paragon/icons';
56
import UsernameFilter from '@src/components/UsernameFilter';
6-
import messages from '@src/specialExams/messages';
77
import { useAttempts } from '@src/specialExams/data/apiHook';
8-
import { DataTableFetchDataProps } from '@src/types';
8+
import messages from '@src/specialExams/messages';
9+
import { Attempt } from '@src/specialExams/types';
10+
import { DataTableFetchDataProps, TableCellValue } from '@src/types';
911

1012
export const ATTEMPTS_PAGE_SIZE = 25;
1113

12-
const AttemptsList = () => {
14+
interface AttemptsListProps {
15+
onResume: (attempt: Attempt) => void,
16+
onReset: (attempt: Attempt) => void,
17+
}
18+
19+
const AttemptsList = ({ onResume, onReset }: AttemptsListProps) => {
1320
const intl = useIntl();
1421
const { courseId = '' } = useParams();
1522
const [filters, setFilters] = useState({ page: 0, emailOrUsername: '', ordering: '' });
@@ -18,16 +25,107 @@ const AttemptsList = () => {
1825
pageSize: ATTEMPTS_PAGE_SIZE
1926
});
2027

28+
const ActionCustomCell = ({ row: { original } }: TableCellValue<Attempt>) => {
29+
const popoverContent = (
30+
<Popover
31+
id={`popover-${original.user.username}-${original.examName}`}
32+
className="border-0 shadow-sm"
33+
>
34+
<Popover.Content className="p-0 border-0">
35+
<div className="dropdown-menu show position-static border shadow-sm">
36+
<button
37+
type="button"
38+
className="dropdown-item"
39+
onClick={() => original.readyToResume ? onResume(original) : onReset(original)}
40+
>
41+
{original.readyToResume ? intl.formatMessage(messages.resume) : intl.formatMessage(messages.reset)}
42+
</button>
43+
</div>
44+
</Popover.Content>
45+
</Popover>
46+
);
47+
return (
48+
<>
49+
<OverlayTrigger
50+
trigger="click"
51+
placement="bottom-end"
52+
overlay={popoverContent}
53+
rootClose
54+
>
55+
<IconButton
56+
alt={intl.formatMessage(messages.actions)}
57+
className="lead"
58+
iconAs={MoreVert}
59+
/>
60+
</OverlayTrigger>
61+
</>
62+
);
63+
};
64+
2165
const columns = useMemo(() => [
2266
{ accessor: 'user.username', Header: intl.formatMessage(messages.username), Filter: UsernameFilter, },
2367
{ accessor: 'examName', Header: intl.formatMessage(messages.examName), disableFilters: true, },
2468
{ accessor: 'allowedTimeLimitMins', Header: intl.formatMessage(messages.timeLimit), disableFilters: true, },
25-
{ accessor: 'type', Header: intl.formatMessage(messages.type), disableFilters: true, },
26-
{ accessor: 'startTime', Header: intl.formatMessage(messages.startedAt), disableFilters: true, },
27-
{ accessor: 'endTime', Header: intl.formatMessage(messages.completedAt), disableFilters: true, },
28-
{ accessor: 'status', Header: intl.formatMessage(messages.status), disableFilters: true, },
69+
{
70+
accessor: 'examType',
71+
Cell: ({ row }: TableCellValue<any>) => <span className="text-capitalize">{row.original.examType}</span>,
72+
disableFilters: true,
73+
Header: intl.formatMessage(messages.type),
74+
},
75+
{
76+
accessor: 'startTime',
77+
Cell: ({ row }: TableCellValue<any>) => (
78+
<span>{ row.original.startTime ? `${intl.formatDate(new Date(row.original.startTime), {
79+
year: 'numeric',
80+
month: '2-digit',
81+
day: '2-digit',
82+
hour: '2-digit',
83+
minute: '2-digit',
84+
timeZone: 'UTC',
85+
})} UTC` : ''}
86+
</span>
87+
),
88+
disableFilters: true,
89+
Header: intl.formatMessage(messages.startedAt),
90+
},
91+
{
92+
accessor: 'endTime',
93+
Cell: ({ row }: TableCellValue<any>) => (
94+
<span>{row.original.endTime ? `${intl.formatDate(new Date(row.original.endTime), {
95+
year: 'numeric',
96+
month: '2-digit',
97+
day: '2-digit',
98+
hour: '2-digit',
99+
minute: '2-digit',
100+
timeZone: 'UTC',
101+
})} UTC` : ''}
102+
</span>
103+
),
104+
disableFilters: true,
105+
Header: intl.formatMessage(messages.completedAt),
106+
},
107+
{
108+
accessor: 'status',
109+
Cell: ({ row }: TableCellValue<any>) => <span className="text-capitalize">{row.original.status}</span>,
110+
disableFilters: true,
111+
Header: intl.formatMessage(messages.status),
112+
},
113+
{
114+
accessor: 'readyToResume',
115+
Cell: ({ row }: TableCellValue<any>) => (
116+
<span className="text-capitalize">{row.original.readyToResume ? intl.formatMessage(messages.true) : ''}</span>
117+
),
118+
disableFilters: true,
119+
Header: intl.formatMessage(messages.readyForResume),
120+
}
29121
], [intl]);
30122

123+
const additionalColumns = [{
124+
id: 'actions',
125+
Header: '',
126+
Cell: ActionCustomCell,
127+
}];
128+
31129
const handleFetchData = (data: DataTableFetchDataProps) => {
32130
const emailOrUsernameFilter = data.filters?.find((f) => f.id === 'user.username');
33131
const newEmailOrUsername = emailOrUsernameFilter ? emailOrUsernameFilter.value : '';
@@ -44,6 +142,7 @@ const AttemptsList = () => {
44142

45143
return (
46144
<DataTable
145+
additionalColumns={additionalColumns}
47146
className="mt-3"
48147
columns={columns}
49148
data={data.results}

0 commit comments

Comments
 (0)