Skip to content

Commit 525ba16

Browse files
feat: add resume action
1 parent 45d9a3c commit 525ba16

10 files changed

Lines changed: 431 additions & 49 deletions

File tree

src/specialExams/components/Attempts.test.tsx

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { AxiosError } from 'axios';
44
import Attempts from './Attempts';
5-
import { useAttempts, useResetAttempt } from '../data/apiHook';
5+
import { useAttempts, useResetAttempt, useResumeAttempt } from '../data/apiHook';
66
import { Attempt } from '../types';
77
import { renderWithAlertAndIntl } from '@src/testUtils';
88
import messages from '../messages';
@@ -15,44 +15,86 @@ jest.mock('react-router-dom', () => ({
1515
jest.mock('../data/apiHook', () => ({
1616
useAttempts: jest.fn(),
1717
useResetAttempt: jest.fn(),
18+
useResumeAttempt: jest.fn(),
1819
}));
1920

20-
const mockAttempt: Attempt = {
21+
const mockAttempts: Attempt[] = [{
2122
id: 1,
22-
user: { username: 'testuser' },
23+
user: { username: 'testuser', id: 1 },
2324
examId: 42,
2425
examName: 'Midterm Exam',
2526
allowedTimeLimitMins: 60,
26-
type: 'proctored',
27+
examType: 'proctored',
2728
startTime: '2024-01-01T00:00:00Z',
2829
endTime: '2024-01-01T01:00:00Z',
2930
status: 'started',
3031
readyToResume: false,
31-
};
32+
}, {
33+
id: 2,
34+
user: { username: 'testuser2', id: 2 },
35+
examId: 43,
36+
examName: 'Final Exam',
37+
allowedTimeLimitMins: 120,
38+
examType: 'proctored',
39+
startTime: '2024-01-02T00:00:00Z',
40+
endTime: null,
41+
status: 'error',
42+
readyToResume: false,
43+
},
44+
{
45+
id: 3,
46+
user: { username: 'testuser3', id: 3 },
47+
examId: 44,
48+
examName: 'Quiz',
49+
allowedTimeLimitMins: 30,
50+
examType: 'timed',
51+
startTime: '2024-01-03T00:00:00Z',
52+
endTime: null,
53+
status: 'error',
54+
readyToResume: true,
55+
}
56+
];
3257

3358
describe('Attempts', () => {
3459
const mockResetAttempt = jest.fn();
60+
const mockResumeAttempt = jest.fn();
3561

3662
beforeEach(() => {
3763
jest.clearAllMocks();
3864
(useResetAttempt as jest.Mock).mockReturnValue({ mutate: mockResetAttempt });
65+
(useResumeAttempt as jest.Mock).mockReturnValue({ mutate: mockResumeAttempt });
3966
(useAttempts as jest.Mock).mockReturnValue({
40-
data: { results: [mockAttempt] },
67+
data: { results: mockAttempts },
4168
isLoading: false,
4269
isError: false,
4370
});
4471
});
4572

46-
const clickReset = async () => {
47-
const actionsButton = screen.getByRole('button', { name: messages.actions.defaultMessage });
48-
await userEvent.click(actionsButton);
73+
const clickReset = async (index = 0) => {
74+
const actionsButton = screen.getAllByRole('button', { name: messages.actions.defaultMessage });
75+
await userEvent.click(actionsButton[index]);
4976
const resetButton = screen.getByRole('button', { name: messages.reset.defaultMessage });
5077
await userEvent.click(resetButton);
78+
const confirmationModal = screen.getByRole('dialog', { name: messages.confirmationModal.defaultMessage });
79+
expect(confirmationModal).toBeInTheDocument();
80+
const confirmReset = screen.getByRole('button', { name: messages.reset.defaultMessage });
81+
await userEvent.click(confirmReset);
82+
};
83+
84+
const clickResume = async (index = 0) => {
85+
const actionsButton = screen.getAllByRole('button', { name: messages.actions.defaultMessage });
86+
await userEvent.click(actionsButton[index]);
87+
const resumeButton = screen.getByRole('button', { name: messages.resume.defaultMessage });
88+
await userEvent.click(resumeButton);
89+
const confirmationModal = screen.getByRole('dialog', { name: messages.confirmationModal.defaultMessage });
90+
expect(confirmationModal).toBeInTheDocument();
91+
const confirmResume = screen.getByRole('button', { name: messages.resume.defaultMessage });
92+
await userEvent.click(confirmResume);
5193
};
5294

5395
it('renders AttemptsList component', () => {
5496
renderWithAlertAndIntl(<Attempts />);
55-
const usernameCell = screen.getByRole('cell', { name: mockAttempt.user.username });
97+
const usernameCell = screen.getByRole('cell', { name: mockAttempts[0].user.username });
5698
expect(usernameCell).toBeInTheDocument();
5799
});
58100

@@ -82,7 +124,7 @@ describe('Attempts', () => {
82124
renderWithAlertAndIntl(<Attempts />);
83125
await clickReset();
84126

85-
const successMessage = screen.getByText(messages.successOnReset.defaultMessage.replace('{student}', mockAttempt.user.username).replace('{examName}', mockAttempt.examName));
127+
const successMessage = screen.getByText(messages.successOnReset.defaultMessage.replace('{student}', mockAttempts[0].user.username).replace('{examName}', mockAttempts[0].examName));
86128

87129
await waitFor(() => {
88130
expect(successMessage).toBeInTheDocument();
@@ -127,4 +169,82 @@ describe('Attempts', () => {
127169
});
128170
expect(screen.getByText(messages.close.defaultMessage)).toBeInTheDocument();
129171
});
172+
173+
it('calls useResumeAttempt with courseId from route params', () => {
174+
renderWithAlertAndIntl(<Attempts />);
175+
expect(useResumeAttempt).toHaveBeenCalledWith('course-v1:edX+Test+2024');
176+
});
177+
178+
it('calls resumeAttempt with attemptId and userId when onResume is triggered', async () => {
179+
renderWithAlertAndIntl(<Attempts />);
180+
const studentIndexToTest = 1;
181+
await clickResume(studentIndexToTest);
182+
183+
expect(mockResumeAttempt).toHaveBeenCalledWith(
184+
{ attemptId: mockAttempts[studentIndexToTest].id, userId: mockAttempts[studentIndexToTest].user.id },
185+
expect.objectContaining({
186+
onSuccess: expect.any(Function),
187+
onError: expect.any(Function),
188+
}),
189+
);
190+
});
191+
192+
it('shows a success toast when resume succeeds', async () => {
193+
mockResumeAttempt.mockImplementation((_params, options) => {
194+
options.onSuccess();
195+
});
196+
197+
renderWithAlertAndIntl(<Attempts />);
198+
const studentIndexToTest = 1;
199+
await clickResume(studentIndexToTest);
200+
201+
const successMessage = screen.getByText(messages.successOnResume.defaultMessage.replace('{student}', mockAttempts[studentIndexToTest].user.username));
202+
203+
await waitFor(() => {
204+
expect(successMessage).toBeInTheDocument();
205+
});
206+
});
207+
208+
it('shows an error modal with API detail when resume fails with AxiosError', async () => {
209+
const axiosError = new AxiosError('Request failed');
210+
axiosError.response = {
211+
data: { detail: 'Cannot resume this attempt.' },
212+
status: 400,
213+
statusText: 'Bad Request',
214+
headers: {},
215+
config: {} as any,
216+
};
217+
218+
mockResumeAttempt.mockImplementation((_params, options) => {
219+
options.onError(axiosError);
220+
});
221+
222+
renderWithAlertAndIntl(<Attempts />);
223+
224+
const studentIndexToTest = 1;
225+
await clickResume(studentIndexToTest);
226+
227+
await waitFor(() => {
228+
expect(screen.getByText('Cannot resume this attempt.')).toBeInTheDocument();
229+
});
230+
expect(screen.getByText(messages.close.defaultMessage)).toBeInTheDocument();
231+
});
232+
233+
it('shows a generic error modal when resume fails with a non-Axios error', async () => {
234+
const genericError = new Error('Something went wrong');
235+
236+
mockResumeAttempt.mockImplementation((_params, options) => {
237+
options.onError(genericError);
238+
});
239+
240+
renderWithAlertAndIntl(<Attempts />);
241+
242+
const studentIndexToTest = 1;
243+
await clickResume(studentIndexToTest);
244+
245+
await waitFor(() => {
246+
expect(screen.getByText(messages.errorOnResume.defaultMessage)).toBeInTheDocument();
247+
});
248+
expect(screen.getByText(messages.close.defaultMessage)).toBeInTheDocument();
249+
});
130250
});

src/specialExams/components/Attempts.tsx

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,93 @@
1+
import { useState } from 'react';
12
import { useParams } from 'react-router-dom';
23
import { isAxiosError } from 'axios';
3-
import { Attempt } from '../types';
4-
import AttemptsList from './AttemptsList';
5-
import { useResetAttempt } from '../data/apiHook';
6-
import messages from '../messages';
7-
import { useAlert } from '@src/providers/AlertProvider';
84
import { useIntl } from '@openedx/frontend-base';
5+
import { ActionRow, Button, ModalDialog, useToggle } from '@openedx/paragon';
6+
import AttemptsList from '@src/specialExams/components/AttemptsList';
7+
import { useResetAttempt, useResumeAttempt } from '@src/specialExams/data/apiHook';
8+
import messages from '@src/specialExams/messages';
9+
import { Attempt, AttemptAction } from '@src/specialExams/types';
10+
import { useAlert } from '@src/providers/AlertProvider';
911

1012
const Attempts = () => {
1113
const { courseId = '' } = useParams<{ courseId: string }>();
1214
const intl = useIntl();
1315
const { mutate: resetAttempt } = useResetAttempt(courseId);
16+
const { mutate: resumeAttempt } = useResumeAttempt(courseId);
1417
const { showModal, showToast } = useAlert();
18+
const [isOpenConfirmationModal, openConfirmationModal, closeConfirmationModal] = useToggle(false);
19+
const [attemptAction, setAttemptAction] = useState<AttemptAction>('reset');
20+
const [attempt, setAttempt] = useState<Attempt | null>(null);
1521

16-
const handleResume = (attempt: Attempt) => {
17-
// Implement resume logic here
18-
console.log('Resume attempt:', courseId, attempt);
22+
const handleResume = () => {
23+
if (!attempt) return;
24+
const { user, id } = attempt;
25+
resumeAttempt({ attemptId: id, userId: user.id }, {
26+
onSuccess: () => {
27+
showToast(intl.formatMessage(messages.successOnResume, { student: attempt.user.username }));
28+
},
29+
onError: (error) => {
30+
const errorMessage = (isAxiosError(error) && error?.response?.data?.detail) || intl.formatMessage(messages.errorOnResume);
31+
showModal({ variant: 'danger', message: errorMessage, confirmText: intl.formatMessage(messages.close) });
32+
}
33+
});
34+
closeConfirmationModal();
1935
};
2036

21-
const handleReset = (attempt: Attempt) => {
37+
const handleReset = () => {
38+
if (!attempt) return;
2239
const { user, examId, examName } = attempt;
2340
resetAttempt({ username: user.username, examId }, {
2441
onSuccess: () => {
2542
showToast(intl.formatMessage(messages.successOnReset, { examName, student: user.username }));
2643
},
2744
onError: (error) => {
2845
const errorMessage = (isAxiosError(error) && error?.response?.data?.detail) || intl.formatMessage(messages.errorOnReset, { examName, student: user.username });
29-
showModal({ message: errorMessage, confirmText: intl.formatMessage(messages.close) });
46+
showModal({ variant: 'danger', message: errorMessage, confirmText: intl.formatMessage(messages.close) });
3047
}
3148
});
49+
closeConfirmationModal();
50+
};
51+
52+
const openAndSetConfirmationModal = (action: AttemptAction, attempt: Attempt) => {
53+
openConfirmationModal();
54+
setAttemptAction(action);
55+
setAttempt(attempt);
3256
};
3357

3458
return (
35-
<AttemptsList onResume={handleResume} onReset={handleReset} />
59+
<>
60+
<AttemptsList
61+
onResume={(attempt: Attempt) => openAndSetConfirmationModal('resume', attempt)}
62+
onReset={(attempt: Attempt) => openAndSetConfirmationModal('reset', attempt)}
63+
/>
64+
{ attempt && (
65+
<ModalDialog
66+
title={intl.formatMessage(messages.confirmationModal)}
67+
isOpen={isOpenConfirmationModal}
68+
onClose={closeConfirmationModal}
69+
isOverflowVisible={false}
70+
hasCloseButton={false}
71+
>
72+
<ModalDialog.Body>
73+
{attemptAction === 'reset' ? intl.formatMessage(messages.confirmationMessageReset) : intl.formatMessage(messages.confirmationMessageResume)}
74+
</ModalDialog.Body>
75+
<ModalDialog.Footer>
76+
<ActionRow>
77+
<Button variant="tertiary" onClick={closeConfirmationModal}>
78+
{intl.formatMessage(messages.cancel)}
79+
</Button>
80+
<Button
81+
variant="primary"
82+
onClick={() => attemptAction === 'reset' ? handleReset() : handleResume()}
83+
>
84+
{attemptAction === 'reset' ? intl.formatMessage(messages.reset) : intl.formatMessage(messages.resume)}
85+
</Button>
86+
</ActionRow>
87+
</ModalDialog.Footer>
88+
</ModalDialog>
89+
)}
90+
</>
3691
);
3792
};
3893

src/specialExams/components/AttemptsList.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,42 @@ jest.mock('../data/apiHook', () => ({
1616
const mockExamAttempts = {
1717
results: [
1818
{
19+
id: 1,
1920
user: {
2021
username: 'user1',
22+
id: 1,
2123
},
2224
examName: 'Midterm',
2325
allowedTimeLimitMins: 60,
2426
examType: 'proctored',
2527
startTime: '2024-01-01',
2628
endTime: '2024-01-02',
2729
status: 'completed',
30+
readyToResume: false,
31+
},
32+
{
33+
id: 2,
34+
user: { username: 'testuser2', id: 2 },
35+
examId: 43,
36+
examName: 'Final Exam',
37+
allowedTimeLimitMins: 120,
38+
examType: 'timed',
39+
startTime: '2024-01-03T00:00:00Z',
40+
endTime: null,
41+
status: 'error',
42+
readyToResume: false,
43+
},
44+
{
45+
id: 3,
46+
user: { username: 'testuser3', id: 3 },
47+
examId: 44,
48+
examName: 'Quiz',
49+
allowedTimeLimitMins: 30,
50+
examType: 'timed',
51+
startTime: '2024-01-04T00:00:00Z',
52+
endTime: null,
53+
status: 'error',
54+
readyToResume: true,
2855
},
2956
],
3057
count: 1,
@@ -106,4 +133,43 @@ describe('AttemptsList', () => {
106133
renderComponent();
107134
expect(useAttempts).toHaveBeenCalledWith('course-v1:edX+Test+2024', expect.any(Object));
108135
});
136+
137+
describe('Resume option visibility', () => {
138+
beforeEach(() => {
139+
(useAttempts as jest.Mock).mockReturnValue({
140+
data: mockExamAttempts,
141+
isLoading: false,
142+
});
143+
});
144+
145+
it('does not show Resume option when status is not "error"', async () => {
146+
renderComponent();
147+
148+
const user = userEvent.setup();
149+
const actionsButton = screen.getAllByRole('button', { name: 'Actions' });
150+
await user.click(actionsButton[0]);
151+
152+
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
153+
});
154+
155+
it('shows Resume option when status is "error" and readyToResume is false', async () => {
156+
renderComponent();
157+
158+
const user = userEvent.setup();
159+
const actionsButton = screen.getAllByRole('button', { name: 'Actions' });
160+
await user.click(actionsButton[1]);
161+
162+
expect(screen.getByText('Resume')).toBeInTheDocument();
163+
});
164+
165+
it('does not show Resume option when readyToResume is true', async () => {
166+
renderComponent();
167+
168+
const user = userEvent.setup();
169+
const actionsButton = screen.getAllByRole('button', { name: 'Actions' });
170+
await user.click(actionsButton[2]);
171+
172+
expect(screen.queryByText('Resume')).not.toBeInTheDocument();
173+
});
174+
});
109175
});

0 commit comments

Comments
 (0)