Skip to content

Commit 5bac60b

Browse files
feat(text-response): refactor SolutionsManager to use react-hook-form
- refactor SingleFileInput and several preview components to TS
1 parent a2c055f commit 5bac60b

21 files changed

Lines changed: 1008 additions & 747 deletions

File tree

client/app/api/course/Assessment/Question/TextResponse.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ export default class TextResponseAPI extends BaseAPI {
1212
return `/courses/${this.courseId}/assessments/${this.assessmentId}/question/text_responses`;
1313
}
1414

15-
fetchNewTextResponse(): APIResponse<TextResponseFormData<'new'>> {
15+
fetchNewTextResponse(): APIResponse<Omit<TextResponseFormData, 'question'>> {
1616
return this.client.get(`${this.#urlPrefix}/new`);
1717
}
1818

19-
fetchNewFileUpload(): APIResponse<TextResponseFormData<'new'>> {
19+
fetchNewFileUpload(): APIResponse<Omit<TextResponseFormData, 'question'>> {
2020
return this.client.get(`${this.#urlPrefix}/new`, {
2121
params: { file_upload: true },
2222
});
2323
}
2424

25-
fetchEdit(id: number): APIResponse<TextResponseFormData<'edit'>> {
25+
fetchEdit(id: number): APIResponse<TextResponseFormData> {
2626
return this.client.get(`${this.#urlPrefix}/${id}/edit`);
2727
}
2828

client/app/bundles/course/assessment/question/text-responses/EditTextResponsePage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useParams } from 'react-router-dom';
22
import {
3-
TextResponseData,
3+
TextResponseEditableFormData,
44
TextResponseFormData,
55
} from 'types/course/assessment/question/text-responses';
66

@@ -20,8 +20,8 @@ const EditTextResponsePage = (): JSX.Element => {
2020
const id = parseInt(params?.questionId ?? '', 10) || undefined;
2121
if (!id) throw new Error(`EditTextResponseForm was loaded with ID: ${id}.`);
2222

23-
const fetchData = (): Promise<TextResponseFormData<'edit'>> => fetchEdit(id);
24-
const handleSubmit = (data: TextResponseData): Promise<void> =>
23+
const fetchData = (): Promise<TextResponseFormData> => fetchEdit(id);
24+
const handleSubmit = (data: TextResponseEditableFormData): Promise<void> =>
2525
update(id, data).then(({ redirectUrl }) => {
2626
toast.success(t(formTranslations.changesSaved));
2727
window.location.href = redirectUrl;

client/app/bundles/course/assessment/question/text-responses/NewTextResponsePage.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
AttachmentType,
55
INITIAL_MAX_ATTACHMENT_SIZE,
66
INITIAL_MAX_ATTACHMENTS,
7-
TextResponseData,
7+
TextResponseEditableFormData,
88
TextResponseFormData,
99
} from 'types/course/assessment/question/text-responses';
1010

@@ -22,7 +22,7 @@ import TextResponseForm, {
2222
} from './components/TextResponseForm';
2323
import { create, fetchNewFileUpload, fetchNewTextResponse } from './operations';
2424

25-
const NEW_TEXT_RESPONSE_VALUE = {
25+
const NEW_TEXT_RESPONSE_VALUE: TextResponseFormData['question'] = {
2626
...commonQuestionFieldsInitialValues,
2727
hideText: false,
2828
templateText: null,
@@ -32,7 +32,7 @@ const NEW_TEXT_RESPONSE_VALUE = {
3232
isAttachmentRequired: false,
3333
};
3434

35-
const NEW_FILE_UPLOAD_RESPONSE_VALUE = {
35+
const NEW_FILE_UPLOAD_RESPONSE_VALUE: TextResponseFormData['question'] = {
3636
...commonQuestionFieldsInitialValues,
3737
hideText: true,
3838
templateText: null,
@@ -42,9 +42,9 @@ const NEW_FILE_UPLOAD_RESPONSE_VALUE = {
4242
isAttachmentRequired: true,
4343
};
4444

45-
type Fetcher = () => Promise<TextResponseFormData<'new'>>;
46-
type Form = ElementType<TextResponseFormProps<'new'>>;
47-
type FormInitialValue = TextResponseData['question'];
45+
type Fetcher = () => Promise<Omit<TextResponseFormData, 'question'>>;
46+
type Form = ElementType<TextResponseFormProps>;
47+
type FormInitialValue = TextResponseFormData['question'];
4848

4949
type Adapter = [Fetcher, Form, FormInitialValue];
5050

@@ -78,7 +78,9 @@ const NewTextResponsePage = (): JSX.Element => {
7878
const [fetchData, FormComponent, initialFormValue] =
7979
newTextResponseAdapter[type];
8080

81-
const handleSubmit = async (data: TextResponseData): Promise<void> => {
81+
const handleSubmit = async (
82+
data: TextResponseEditableFormData,
83+
): Promise<void> => {
8284
const { redirectUrl } = await create(data);
8385
toast.success(t(translations.questionCreated));
8486
window.location.href = redirectUrl;
@@ -87,8 +89,12 @@ const NewTextResponsePage = (): JSX.Element => {
8789
return (
8890
<Preload render={<LoadingIndicator />} while={fetchData}>
8991
{(data): JSX.Element => {
90-
data.question = initialFormValue;
91-
return <FormComponent onSubmit={handleSubmit} with={data} />;
92+
return (
93+
<FormComponent
94+
onSubmit={handleSubmit}
95+
with={{ ...data, question: initialFormValue }}
96+
/>
97+
);
9298
}}
9399
</Preload>
94100
);
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { fireEvent, render, waitFor } from 'test-utils';
2+
import {
3+
AttachmentType,
4+
SolutionEntity,
5+
TextResponseEditableFormData,
6+
TextResponseFormData,
7+
} from 'types/course/assessment/question/text-responses';
8+
9+
import TextResponseForm from '../components/TextResponseForm';
10+
11+
jest.mock(
12+
'lib/components/core/fields/CKEditorRichText',
13+
() =>
14+
// eslint-disable-next-line react/display-name
15+
({
16+
name,
17+
onChange,
18+
value,
19+
}: {
20+
name: string;
21+
onChange?: (v: string) => void;
22+
value?: string;
23+
}): JSX.Element => (
24+
<textarea
25+
name={name}
26+
onChange={(e): void => onChange?.(e.target.value)}
27+
value={value ?? ''}
28+
/>
29+
),
30+
);
31+
32+
const mockSavedSolution: SolutionEntity = {
33+
id: 1,
34+
solution: 'exact answer',
35+
solutionType: 'exact_match',
36+
grade: 5,
37+
explanation: '',
38+
};
39+
40+
/** Matches the shape of a real backend response for a text_response question. */
41+
const makeFormData = (
42+
solutions: SolutionEntity[] = [],
43+
): TextResponseFormData => ({
44+
questionType: 'text_response',
45+
isAssessmentAutograded: false,
46+
defaultMaxAttachmentSize: 100,
47+
defaultMaxAttachments: 10,
48+
// solutions lives at the top level, matching the backend response shape
49+
solutions,
50+
question: {
51+
title: '',
52+
description: '',
53+
staffOnlyComments: '',
54+
maximumGrade: '10',
55+
skillIds: [],
56+
hideText: false,
57+
templateText: null,
58+
attachmentType: AttachmentType.NO_ATTACHMENT,
59+
maxAttachments: 3,
60+
maxAttachmentSize: 10,
61+
isAttachmentRequired: false,
62+
},
63+
availableSkills: null,
64+
skillsUrl: '',
65+
});
66+
67+
/** Wait for I18nProvider to load async translations before asserting. */
68+
const waitForForm = async (page: ReturnType<typeof render>): Promise<void> => {
69+
await waitFor(() =>
70+
expect(
71+
page.getByRole('button', { name: 'Add a new solution' }),
72+
).toBeVisible(),
73+
);
74+
};
75+
76+
describe('TextResponseForm — solutions UI', () => {
77+
it('loads solutions from a backend response into the form', async () => {
78+
const page = render(
79+
<TextResponseForm
80+
onSubmit={jest.fn()}
81+
with={makeFormData([
82+
mockSavedSolution,
83+
{
84+
id: 2,
85+
solution: 'keyword answer',
86+
solutionType: 'keyword',
87+
grade: 3,
88+
explanation: '',
89+
},
90+
])}
91+
/>,
92+
);
93+
94+
await waitForForm(page);
95+
96+
expect(page.getAllByPlaceholderText('0.0')).toHaveLength(2);
97+
expect(page.getByDisplayValue('exact answer')).toBeVisible();
98+
expect(page.getByDisplayValue('keyword answer')).toBeVisible();
99+
});
100+
101+
it('renders existing saved solutions', async () => {
102+
const page = render(
103+
<TextResponseForm
104+
onSubmit={jest.fn()}
105+
with={makeFormData([mockSavedSolution])}
106+
/>,
107+
);
108+
109+
await waitForForm(page);
110+
111+
expect(page.getAllByPlaceholderText('0.0')).toHaveLength(1);
112+
expect(page.getByDisplayValue('exact answer')).toBeVisible();
113+
});
114+
115+
it('adds a new draft solution when "Add a new solution" is clicked', async () => {
116+
const page = render(
117+
<TextResponseForm onSubmit={jest.fn()} with={makeFormData()} />,
118+
);
119+
120+
await waitForForm(page);
121+
expect(page.queryByPlaceholderText('0.0')).not.toBeInTheDocument();
122+
123+
fireEvent.click(page.getByRole('button', { name: 'Add a new solution' }));
124+
125+
expect(page.getAllByPlaceholderText('0.0')).toHaveLength(1);
126+
expect(
127+
page.getByText(
128+
'This is a new solution. It will immediately disappear if you delete before saving it.',
129+
),
130+
).toBeVisible();
131+
});
132+
133+
it('removes a draft solution immediately when deleted', async () => {
134+
const page = render(
135+
<TextResponseForm onSubmit={jest.fn()} with={makeFormData()} />,
136+
);
137+
138+
await waitForForm(page);
139+
fireEvent.click(page.getByRole('button', { name: 'Add a new solution' }));
140+
expect(page.getAllByPlaceholderText('0.0')).toHaveLength(1);
141+
142+
fireEvent.click(page.getByRole('button', { name: 'Delete solution' }));
143+
144+
expect(page.queryByPlaceholderText('0.0')).not.toBeInTheDocument();
145+
});
146+
147+
it('keeps a saved solution visible but marks it for deletion', async () => {
148+
const page = render(
149+
<TextResponseForm
150+
onSubmit={jest.fn()}
151+
with={makeFormData([mockSavedSolution])}
152+
/>,
153+
);
154+
155+
await waitForForm(page);
156+
157+
fireEvent.click(page.getByRole('button', { name: 'Delete solution' }));
158+
159+
expect(page.getAllByPlaceholderText('0.0')).toHaveLength(1);
160+
expect(
161+
page.getByText(
162+
'This solution will be deleted once you save your changes.',
163+
),
164+
).toBeVisible();
165+
});
166+
167+
it('restores a saved solution when deletion is undone', async () => {
168+
const page = render(
169+
<TextResponseForm
170+
onSubmit={jest.fn()}
171+
with={makeFormData([mockSavedSolution])}
172+
/>,
173+
);
174+
175+
await waitForForm(page);
176+
177+
fireEvent.click(page.getByRole('button', { name: 'Delete solution' }));
178+
expect(
179+
page.getByText(
180+
'This solution will be deleted once you save your changes.',
181+
),
182+
).toBeVisible();
183+
184+
fireEvent.click(page.getByRole('button', { name: 'Undo delete solution' }));
185+
186+
expect(
187+
page.queryByText(
188+
'This solution will be deleted once you save your changes.',
189+
),
190+
).not.toBeInTheDocument();
191+
});
192+
193+
it('passes solutions at the top level of the submitted payload', async () => {
194+
const onSubmit = jest.fn().mockResolvedValue({});
195+
196+
const page = render(
197+
<TextResponseForm
198+
onSubmit={onSubmit}
199+
with={makeFormData([mockSavedSolution])}
200+
/>,
201+
);
202+
203+
await waitForForm(page);
204+
205+
fireEvent.change(page.getByPlaceholderText('0.0'), {
206+
target: { value: '8' },
207+
});
208+
209+
await waitFor(() =>
210+
expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible(),
211+
);
212+
213+
fireEvent.click(page.getByRole('button', { name: 'Save changes' }));
214+
215+
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
216+
217+
const submitted = onSubmit.mock.calls[0][0] as TextResponseEditableFormData;
218+
// solutions must be at top level, not inside question
219+
expect(submitted.solutions).toHaveLength(1);
220+
expect(submitted.solutions![0]).toMatchObject({
221+
id: 1,
222+
solution: 'exact answer',
223+
solutionType: 'exact_match',
224+
});
225+
expect(
226+
(submitted.question as unknown as Record<string, unknown>).solutions,
227+
).toBeUndefined();
228+
});
229+
230+
it('clears solutions from the payload for file_upload questions even if form state has them', async () => {
231+
const onSubmit = jest.fn().mockResolvedValue({});
232+
233+
// file_upload hides the SolutionsManager, but solutions may still be
234+
// present in the form's internal state from initialization.
235+
const fileUploadData: TextResponseFormData = {
236+
...makeFormData([mockSavedSolution]),
237+
questionType: 'file_upload',
238+
};
239+
240+
const page = render(
241+
<TextResponseForm onSubmit={onSubmit} with={fileUploadData} />,
242+
);
243+
244+
// No solutions UI for file_upload — dirty the form via a common field.
245+
await waitFor(() =>
246+
expect(
247+
page.getByLabelText('Maximum grade', { exact: false }),
248+
).toBeVisible(),
249+
);
250+
251+
fireEvent.change(page.getByLabelText('Maximum grade', { exact: false }), {
252+
target: { value: '20' },
253+
});
254+
255+
await waitFor(() =>
256+
expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible(),
257+
);
258+
259+
fireEvent.click(page.getByRole('button', { name: 'Save changes' }));
260+
261+
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
262+
263+
const submitted = onSubmit.mock.calls[0][0] as TextResponseEditableFormData;
264+
expect(submitted.solutions).toHaveLength(0);
265+
});
266+
});

0 commit comments

Comments
 (0)