Skip to content

Commit e856d25

Browse files
feat(text-response): add date randomization, qol improvements
- fix test timestamp not updating correctly - add support for date randomization - use UTC time for all spreadsheet-related date/time objects - improvements to preview content and managers - add test cases for spreadsheet formula solution config / duplication
1 parent 1710812 commit e856d25

22 files changed

Lines changed: 772 additions & 64 deletions

File tree

client/app/bundles/course/assessment/question/text-responses/__test__/operations.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,93 @@ describe('solutions_attributes request payload', () => {
142142
expect(fd.get(`${base}[_destroy]`)).toBe('1');
143143
});
144144

145+
describe('spreadsheet randomSeed and testTimestamp fields', () => {
146+
const ss =
147+
'question_text_response[solutions_attributes][][test_spreadsheet_attributes]';
148+
149+
const makeSpreadsheetSolution = (
150+
overrides: Partial<NonNullable<SolutionEntity['spreadsheet']>>,
151+
): SolutionEntity => ({
152+
id: 1,
153+
solution: '=A1',
154+
solutionType: 'spreadsheet_formula',
155+
grade: 5,
156+
explanation: '',
157+
spreadsheet: {
158+
isRandomizationEnabled: false,
159+
isRandomSeedFixed: false,
160+
randomSeed: 0,
161+
isTimestampFixed: false,
162+
testTimestamp: null,
163+
numRandomTests: 0,
164+
variables: [],
165+
file: { name: 'sheet.xlsx', url: '' },
166+
...overrides,
167+
},
168+
});
169+
170+
it('forwards randomSeed as a number string when isRandomSeedFixed is true', async () => {
171+
const spy = jest.spyOn(
172+
CourseAPI.assessment.question.textResponse,
173+
'create',
174+
);
175+
mock.onPost().reply(200, { redirectUrl });
176+
177+
await create(
178+
makeData([
179+
makeSpreadsheetSolution({
180+
isRandomSeedFixed: true,
181+
randomSeed: 12345,
182+
}),
183+
]),
184+
);
185+
186+
const fd = spy.mock.calls[0][0] as FormData;
187+
expect(fd.get(`${ss}[is_random_seed_fixed]`)).toBe('1');
188+
expect(fd.get(`${ss}[test_random_seed]`)).toBe('12345');
189+
});
190+
191+
it('still sends randomSeed when isRandomSeedFixed is false', async () => {
192+
const spy = jest.spyOn(
193+
CourseAPI.assessment.question.textResponse,
194+
'create',
195+
);
196+
mock.onPost().reply(200, { redirectUrl });
197+
198+
await create(
199+
makeData([
200+
makeSpreadsheetSolution({ isRandomSeedFixed: false, randomSeed: 99 }),
201+
]),
202+
);
203+
204+
const fd = spy.mock.calls[0][0] as FormData;
205+
expect(fd.get(`${ss}[is_random_seed_fixed]`)).toBe('0');
206+
expect(fd.get(`${ss}[test_random_seed]`)).toBe('99');
207+
});
208+
209+
it('serialises testTimestamp Date to an ISO string', async () => {
210+
const spy = jest.spyOn(
211+
CourseAPI.assessment.question.textResponse,
212+
'create',
213+
);
214+
mock.onPost().reply(200, { redirectUrl });
215+
216+
const date = new Date('2024-01-15T10:30:00.000Z');
217+
await create(
218+
makeData([
219+
makeSpreadsheetSolution({
220+
isTimestampFixed: true,
221+
testTimestamp: date,
222+
}),
223+
]),
224+
);
225+
226+
const fd = spy.mock.calls[0][0] as FormData;
227+
expect(fd.get(`${ss}[is_timestamp_fixed]`)).toBe('1');
228+
expect(fd.get(`${ss}[test_timestamp]`)).toBe('2024-01-15T10:30:00.000Z');
229+
});
230+
});
231+
145232
it('sends spreadsheet attributes for spreadsheet_formula solutions', async () => {
146233
const spy = jest.spyOn(
147234
CourseAPI.assessment.question.textResponse,
@@ -162,7 +249,7 @@ describe('solutions_attributes request payload', () => {
162249
isRandomSeedFixed: true,
163250
randomSeed: 42,
164251
isTimestampFixed: false,
165-
testTimestamp: new Date().toISOString(),
252+
testTimestamp: new Date(),
166253
numRandomTests: 4,
167254
variables: [],
168255
file: { name: 'sheet.xlsx', url: '' },
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { FC } from 'react';
2+
import { Checkbox, FormControlLabel } from '@mui/material';
3+
import {
4+
DateTimePicker as MuiDateTimePicker,
5+
LocalizationProvider,
6+
} from '@mui/x-date-pickers';
7+
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment';
8+
import { CellRandomConfigBody } from 'types/course/assessment/question/text-responses';
9+
10+
import useTranslation from 'lib/hooks/useTranslation';
11+
import moment from 'lib/moment';
12+
import formTranslations from 'lib/translations/form';
13+
14+
import translations from '../../../translations';
15+
16+
interface Props {
17+
config: CellRandomConfigBody<'date'>;
18+
onChange: (newConfig: Partial<CellRandomConfigBody<'date'>>) => void;
19+
onBlur?: () => void;
20+
}
21+
22+
const DateRandomizationManager: FC<Props> = ({ config, onChange, onBlur }) => {
23+
const { t } = useTranslation();
24+
25+
return (
26+
<LocalizationProvider dateAdapter={AdapterMoment}>
27+
<div className="flex flex-col space-y-5 mt-1">
28+
<MuiDateTimePicker
29+
ampm={false}
30+
format="DD-MM-YYYY HH:mm"
31+
label={t(formTranslations.minimum)}
32+
onChange={(value) => {
33+
if (value?.isValid()) onChange({ min: value.toDate() });
34+
}}
35+
slotProps={{
36+
textField: { size: 'small', onBlur },
37+
}}
38+
timezone="UTC"
39+
value={config.min ? moment.utc(config.min) : null}
40+
/>
41+
<MuiDateTimePicker
42+
ampm={false}
43+
format="DD-MM-YYYY HH:mm"
44+
label={t(formTranslations.maximum)}
45+
onChange={(value) => {
46+
if (value?.isValid()) onChange({ max: value.toDate() });
47+
}}
48+
slotProps={{
49+
textField: { size: 'small', onBlur },
50+
}}
51+
timezone="UTC"
52+
value={config.max ? moment.utc(config.max) : null}
53+
/>
54+
<FormControlLabel
55+
componentsProps={{
56+
typography: { variant: 'subtitle2', fontWeight: 'normal' },
57+
}}
58+
control={
59+
<Checkbox
60+
checked={config.roundToDay}
61+
className="p-2 pl-4"
62+
onChange={(e) => onChange({ roundToDay: e.target.checked })}
63+
size="small"
64+
/>
65+
}
66+
label={t(translations.roundToDay)}
67+
/>
68+
</div>
69+
</LocalizationProvider>
70+
);
71+
};
72+
73+
export default DateRandomizationManager;

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { FC, useEffect, useRef, useState } from 'react';
2-
import { TextField } from '@mui/material';
2+
import { Checkbox, FormControlLabel, TextField } from '@mui/material';
33
import { CellRandomConfigBody } from 'types/course/assessment/question/text-responses';
44

55
import useTranslation from 'lib/hooks/useTranslation';
66
import formTranslations from 'lib/translations/form';
77

8+
import translations from '../../../translations';
9+
810
interface Props {
911
config: CellRandomConfigBody<'numeric'>;
1012
onChange: (newConfig: Partial<CellRandomConfigBody<'numeric'>>) => void;
@@ -73,6 +75,20 @@ const NumericRandomizationManager: FC<Props> = (props) => {
7375
size="small"
7476
value={maxText}
7577
/>
78+
<FormControlLabel
79+
componentsProps={{
80+
typography: { variant: 'subtitle2', fontWeight: 'normal' },
81+
}}
82+
control={
83+
<Checkbox
84+
checked={config.roundToInteger}
85+
className="p-2 pl-4"
86+
onChange={(e) => onChange({ roundToInteger: e.target.checked })}
87+
size="small"
88+
/>
89+
}
90+
label={t(translations.roundToInteger)}
91+
/>
7692
</div>
7793
);
7894
};

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FC } from 'react';
2-
import { TextField } from '@mui/material';
2+
import { Alert, FormHelperText } from '@mui/material';
33
import { CellRandomConfigBody } from 'types/course/assessment/question/text-responses';
44

55
import useTranslation from 'lib/hooks/useTranslation';
@@ -14,12 +14,14 @@ interface Props {
1414
const OverrideRandomizationManager: FC<Props> = ({ config, onChange }) => {
1515
const { t } = useTranslation();
1616
return (
17-
<div className="mt-1">
18-
<TextField
19-
fullWidth
20-
label={t(translations.overrideValue)}
17+
<div className="mt-1 flex h-full flex-col space-y-1">
18+
<Alert icon={false} severity="info">
19+
{t(translations.overrideRandomizationModeDescription)}
20+
</Alert>
21+
<FormHelperText>{t(translations.overrideValue)}</FormHelperText>
22+
<textarea
23+
className="w-full h-full resize-none rounded border border-solid border-neutral-400 p-2 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-blue-600"
2124
onChange={(e) => onChange({ value: e.target.value })}
22-
size="small"
2325
value={config.value}
2426
/>
2527
</div>

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,13 @@ const Solution: FC<SolutionProps> = ({
5050
if (solution?.solutionType === 'spreadsheet_formula') {
5151
setValue(`solutions.${index}.spreadsheet`, {
5252
isRandomizationEnabled:
53-
solution.spreadsheet?.isRandomizationEnabled ?? false,
53+
solution.spreadsheet?.isRandomizationEnabled ?? true,
5454
isRandomSeedFixed: solution.spreadsheet?.isRandomSeedFixed ?? false,
5555
randomSeed: solution.spreadsheet?.randomSeed ?? generateRandomSeed(),
5656
isTimestampFixed: solution.spreadsheet?.isTimestampFixed ?? false,
57-
testTimestamp:
58-
solution.spreadsheet?.testTimestamp ?? new Date().toISOString(),
57+
testTimestamp: new Date(
58+
solution.spreadsheet?.testTimestamp ?? Date.now(),
59+
),
5960
numRandomTests: solution.spreadsheet?.numRandomTests ?? 2,
6061
file: solution.spreadsheet?.file ?? { file: null, name: '', url: '' },
6162
variables: solution.spreadsheet?.variables,
@@ -141,7 +142,6 @@ const Solution: FC<SolutionProps> = ({
141142
<textarea
142143
className={`w-full h-full resize-none rounded border border-solid p-2 disabled:bg-neutral-100 disabled:text-neutral-400 focus:outline-none focus:ring-1 focus:ring-inset ${fieldState.error ? 'border-red-500 focus:ring-red-500' : 'border-neutral-400 focus:ring-blue-600'}`}
143144
disabled={solution.toBeDeleted || disabled}
144-
rows={2}
145145
{...field}
146146
/>
147147
{fieldState.error && (

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ const SpreadsheetManagerPrompt: FC<Props> = ({
128128
disableMargins
129129
field={field}
130130
fieldState={fieldState}
131+
timezone="UTC"
131132
/>
132133
)}
133134
/>

0 commit comments

Comments
 (0)