Skip to content

Commit 8d9e4ea

Browse files
feat: add feedback to user
1 parent 0e4bc19 commit 8d9e4ea

3 files changed

Lines changed: 105 additions & 14 deletions

File tree

src/specialExams/components/Attempts.test.tsx

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { screen } from '@testing-library/react';
1+
import { screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3+
import { AxiosError } from 'axios';
34
import Attempts from './Attempts';
45
import { useAttempts, useResetAttempt } from '../data/apiHook';
56
import { Attempt } from '../types';
6-
import { renderWithIntl } from '@src/testUtils';
7+
import { renderWithAlertAndIntl } from '@src/testUtils';
78
import messages from '../messages';
89

910
jest.mock('react-router-dom', () => ({
@@ -42,27 +43,88 @@ describe('Attempts', () => {
4243
});
4344
});
4445

46+
const clickReset = async () => {
47+
const actionsButton = screen.getByRole('button', { name: messages.actions.defaultMessage });
48+
await userEvent.click(actionsButton);
49+
const resetButton = screen.getByRole('button', { name: messages.reset.defaultMessage });
50+
await userEvent.click(resetButton);
51+
};
52+
4553
it('renders AttemptsList component', () => {
46-
renderWithIntl(<Attempts />);
54+
renderWithAlertAndIntl(<Attempts />);
4755
const usernameCell = screen.getByRole('cell', { name: mockAttempt.user.username });
4856
expect(usernameCell).toBeInTheDocument();
4957
});
5058

5159
it('calls useResetAttempt with courseId from route params', () => {
52-
renderWithIntl(<Attempts />);
60+
renderWithAlertAndIntl(<Attempts />);
5361
expect(useResetAttempt).toHaveBeenCalledWith('course-v1:edX+Test+2024');
5462
});
5563

5664
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);
65+
renderWithAlertAndIntl(<Attempts />);
66+
await clickReset();
67+
68+
expect(mockResetAttempt).toHaveBeenCalledWith(
69+
{ username: 'testuser', examId: 42 },
70+
expect.objectContaining({
71+
onSuccess: expect.any(Function),
72+
onError: expect.any(Function),
73+
}),
74+
);
75+
});
76+
77+
it('shows a success toast when reset succeeds', async () => {
78+
mockResetAttempt.mockImplementation((_params, options) => {
79+
options.onSuccess();
80+
});
81+
82+
renderWithAlertAndIntl(<Attempts />);
83+
await clickReset();
84+
85+
const successMessage = screen.getByText(messages.successOnReset.defaultMessage.replace('{student}', mockAttempt.user.username).replace('{examName}', mockAttempt.examName));
86+
87+
await waitFor(() => {
88+
expect(successMessage).toBeInTheDocument();
89+
});
90+
});
91+
92+
it('shows an error modal with API detail when reset fails with AxiosError', async () => {
93+
const axiosError = new AxiosError('Request failed');
94+
axiosError.response = {
95+
data: { detail: 'Student has no active attempt.' },
96+
status: 400,
97+
statusText: 'Bad Request',
98+
headers: {},
99+
config: {} as any,
100+
};
101+
102+
mockResetAttempt.mockImplementation((_params, options) => {
103+
options.onError(axiosError);
104+
});
105+
106+
renderWithAlertAndIntl(<Attempts />);
107+
await clickReset();
108+
109+
await waitFor(() => {
110+
expect(screen.getByText('Student has no active attempt.')).toBeInTheDocument();
111+
});
112+
expect(screen.getByText(messages.close.defaultMessage)).toBeInTheDocument();
113+
});
114+
115+
it('shows a generic error modal when reset fails with a non-Axios error', async () => {
116+
const genericError = new Error('Something went wrong');
117+
118+
mockResetAttempt.mockImplementation((_params, options) => {
119+
options.onError(genericError);
120+
});
121+
122+
renderWithAlertAndIntl(<Attempts />);
123+
await clickReset();
62124

63-
expect(mockResetAttempt).toHaveBeenCalledWith({
64-
username: 'testuser',
65-
examId: 42,
125+
await waitFor(() => {
126+
expect(screen.getByText(messages.errorOnReset.defaultMessage)).toBeInTheDocument();
66127
});
128+
expect(screen.getByText(messages.close.defaultMessage)).toBeInTheDocument();
67129
});
68130
});

src/specialExams/components/Attempts.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,34 @@
11
import { useParams } from 'react-router-dom';
2+
import { isAxiosError } from 'axios';
23
import { Attempt } from '../types';
34
import AttemptsList from './AttemptsList';
45
import { useResetAttempt } from '../data/apiHook';
6+
import messages from '../messages';
7+
import { useAlert } from '@src/providers/AlertProvider';
8+
import { useIntl } from '@openedx/frontend-base';
59

610
const Attempts = () => {
711
const { courseId = '' } = useParams<{ courseId: string }>();
12+
const intl = useIntl();
813
const { mutate: resetAttempt } = useResetAttempt(courseId);
14+
const { showModal, showToast } = useAlert();
915

1016
const handleResume = (attempt: Attempt) => {
1117
// Implement resume logic here
1218
console.log('Resume attempt:', courseId, attempt);
1319
};
1420

1521
const handleReset = (attempt: Attempt) => {
16-
const { user, examId } = attempt;
17-
resetAttempt({ username: user.username, examId });
22+
const { user, examId, examName } = attempt;
23+
resetAttempt({ username: user.username, examId }, {
24+
onSuccess: () => {
25+
showToast(intl.formatMessage(messages.successOnReset, { examName, student: user.username }));
26+
},
27+
onError: (error) => {
28+
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)});
30+
}
31+
});
1832
};
1933

2034
return (

src/specialExams/messages.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,21 @@ const messages = defineMessages({
245245
id: 'instruct.specialExams.true',
246246
defaultMessage: 'True',
247247
description: 'Display value for a boolean true value'
248+
},
249+
successOnReset: {
250+
id: 'instruct.specialExams.successOnReset',
251+
defaultMessage: 'Successfully reset attempt on {examName} for {student}.',
252+
description: 'Success message shown after resetting an exam attempt'
253+
},
254+
errorOnReset: {
255+
id: 'instruct.specialExams.errorOnReset',
256+
defaultMessage: 'An error occurred while resetting the attempt. Please try again.',
257+
description: 'Error message shown when resetting an exam attempt fails'
258+
},
259+
close: {
260+
id: 'instruct.specialExams.close',
261+
defaultMessage: 'Close',
262+
description: 'Label for close button'
248263
}
249264
});
250265

0 commit comments

Comments
 (0)