Skip to content

Commit 3034f07

Browse files
feat(text-response): clarify text response solution UI
- replace specific validation strings with generic strings - use plain text input in solution content to match student answer format - reworked / expanded notes detailing autograder behavior
1 parent 5bac60b commit 3034f07

12 files changed

Lines changed: 184 additions & 178 deletions

File tree

client/app/bundles/course/assessment/question/text-responses/commons/validations.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,24 @@ import { AttachmentType } from 'types/course/assessment/question/text-responses'
22
import { AnyObjectSchema, array, bool, number, object, string } from 'yup';
33

44
import { MessageTranslator } from 'lib/hooks/useTranslation';
5+
import formTranslations from 'lib/translations/form';
56

67
import translations from '../../../translations';
78
import { commonQuestionFieldsValidation } from '../../components/CommonQuestionFields';
89

910
const solutionSchema = object({
10-
solutionType: string().required(translations.mustSpecifySolutionType),
11+
solutionType: string().required(formTranslations.required),
1112
solution: string().when('toBeDeleted', {
1213
is: true,
1314
then: string().notRequired(),
14-
otherwise: string().required(translations.mustSpecifySolution),
15+
otherwise: string().required(formTranslations.required),
1516
}),
1617
grade: number().when('toBeDeleted', {
1718
is: true,
1819
then: number().notRequired(),
1920
otherwise: number()
20-
.typeError(translations.mustSpecifyGrade)
21-
.required(translations.mustSpecifyGrade),
21+
.typeError(formTranslations.numeric)
22+
.required(formTranslations.numeric),
2223
}),
2324
explanation: string().nullable(),
2425
toBeDeleted: bool(),

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Controller, useFormContext } from 'react-hook-form';
22
import { InputAdornment, RadioGroup } from '@mui/material';
33
import {
44
AttachmentType,
5-
TextResponseQuestionFormData,
5+
TextResponseEditableFormData,
66
} from 'types/course/assessment/question/text-responses';
77

88
import RadioButton from 'lib/components/core/buttons/RadioButton';
@@ -21,19 +21,20 @@ const FileUploadManager = (props: Props): JSX.Element => {
2121
const { disabled, isTextResponseQuestion } = props;
2222
const { t } = useTranslation();
2323

24-
const { control, watch } = useFormContext<TextResponseQuestionFormData>();
24+
const { control, watch } = useFormContext<TextResponseEditableFormData>();
2525

2626
return (
2727
<>
2828
<div className="mt-5 mb-5">
2929
<Controller
3030
control={control}
31-
name="isAttachmentRequired"
31+
name="question.isAttachmentRequired"
3232
render={({ field, fieldState }): JSX.Element => (
3333
<FormCheckboxField
3434
disabled={
3535
disabled ||
36-
watch('attachmentType') === AttachmentType.NO_ATTACHMENT
36+
watch('question.attachmentType') ===
37+
AttachmentType.NO_ATTACHMENT
3738
}
3839
field={field}
3940
fieldState={fieldState}
@@ -44,7 +45,7 @@ const FileUploadManager = (props: Props): JSX.Element => {
4445
</div>
4546
<Controller
4647
control={control}
47-
name="attachmentType"
48+
name="question.attachmentType"
4849
render={({ field }): JSX.Element => (
4950
<RadioGroup className="space-y-5" {...field}>
5051
{isTextResponseQuestion && (
@@ -71,11 +72,12 @@ const FileUploadManager = (props: Props): JSX.Element => {
7172
)}
7273
/>
7374

74-
{watch('attachmentType') === AttachmentType.MULTIPLE_ATTACHMENT && (
75+
{watch('question.attachmentType') ===
76+
AttachmentType.MULTIPLE_ATTACHMENT && (
7577
<div className="mt-5">
7678
<Controller
7779
control={control}
78-
name="maxAttachments"
80+
name="question.maxAttachments"
7981
render={({ field, fieldState }): JSX.Element => (
8082
<FormTextField
8183
className="w-1/2"
@@ -91,11 +93,11 @@ const FileUploadManager = (props: Props): JSX.Element => {
9193
</div>
9294
)}
9395

94-
{watch('attachmentType') !== AttachmentType.NO_ATTACHMENT && (
96+
{watch('question.attachmentType') !== AttachmentType.NO_ATTACHMENT && (
9597
<div className="mt-5">
9698
<Controller
9799
control={control}
98-
name="maxAttachmentSize"
100+
name="question.maxAttachmentSize"
99101
render={({ field, fieldState }): JSX.Element => (
100102
<FormTextField
101103
className="w-1/2"

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

Lines changed: 38 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Controller, useFormContext } from 'react-hook-form';
22
import { Undo } from '@mui/icons-material';
3-
import { IconButton, Select, Tooltip, Typography } from '@mui/material';
3+
import { Alert, IconButton, Select, Tooltip } from '@mui/material';
44
import FormHelperText from '@mui/material/FormHelperText';
55
import { TextResponseEditableFormData } from 'types/course/assessment/question/text-responses';
66

77
import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText';
8-
import TextField from 'lib/components/core/fields/TextField';
8+
import FormTextField from 'lib/components/form/fields/TextField';
99
import { formatErrorMessage } from 'lib/components/form/fields/utils/mapError';
1010
import useTranslation from 'lib/hooks/useTranslation';
1111

@@ -29,12 +29,11 @@ const Solution = ({
2929

3030
const toBeDeleted = watch(`solutions.${index}.toBeDeleted`);
3131
const isDraft = watch(`solutions.${index}.draft`);
32-
const solutionId = watch(`solutions.${index}.id`);
3332

3433
return (
3534
<section
3635
className={`flex border-0 border-b border-solid border-neutral-200 last:border-b-0 ${
37-
toBeDeleted ? 'border-neutral-300 bg-neutral-200' : ''
36+
toBeDeleted ? 'bg-red-50' : ''
3837
} ${isDraft ? 'bg-lime-50' : ''}`}
3938
>
4039
<div className="mx-8 mt-0 flex w-[calc(100%_-_84px)] flex-col space-y-4 py-4">
@@ -49,7 +48,6 @@ const Solution = ({
4948
<Select
5049
disabled={toBeDeleted || disabled}
5150
error={!!fieldState.error}
52-
id="solution-type"
5351
native
5452
variant="outlined"
5553
{...field}
@@ -75,19 +73,13 @@ const Solution = ({
7573
control={control}
7674
name={`solutions.${index}.grade`}
7775
render={({ field, fieldState }): JSX.Element => (
78-
<>
79-
<TextField
80-
disabled={toBeDeleted || disabled}
81-
error={!!fieldState.error}
82-
placeholder={t(translations.zeroGrade)}
83-
{...field}
84-
/>
85-
{fieldState.error && (
86-
<FormHelperText error>
87-
{formatErrorMessage(fieldState.error.message)}
88-
</FormHelperText>
89-
)}
90-
</>
76+
<FormTextField
77+
disabled={toBeDeleted || disabled}
78+
disableMargins
79+
field={field}
80+
fieldState={fieldState}
81+
placeholder={t(translations.zeroGrade)}
82+
/>
9183
)}
9284
/>
9385
</div>
@@ -101,10 +93,9 @@ const Solution = ({
10193
name={`solutions.${index}.solution`}
10294
render={({ field, fieldState }): JSX.Element => (
10395
<>
104-
<TextField
96+
<textarea
97+
className="w-full h-full resize-none rounded border border-solid border-neutral-400 p-2 disabled:bg-neutral-100 disabled:text-neutral-400"
10598
disabled={toBeDeleted || disabled}
106-
error={!!fieldState.error}
107-
multiline
10899
rows={2}
109100
{...field}
110101
/>
@@ -120,34 +111,37 @@ const Solution = ({
120111

121112
<div className="flex w-2/4 flex-col space-y-2">
122113
<FormHelperText>{t(translations.explanation)}</FormHelperText>
123-
{toBeDeleted ? (
124-
<Typography className="italic text-neutral-500" variant="body2">
125-
{t(translations.solutionWillBeDeleted)}
126-
</Typography>
127-
) : (
128-
<Controller
129-
control={control}
130-
name={`solutions.${index}.explanation`}
131-
render={({ field }): JSX.Element => (
132-
<CKEditorRichText
133-
disabled={disabled}
134-
disableMargins
135-
inputId={`solution-${solutionId}-explanation`}
136-
name="explanation"
137-
onChange={field.onChange}
138-
placeholder={t(translations.explanationDescription)}
139-
value={field.value ?? ''}
140-
/>
141-
)}
142-
/>
143-
)}
114+
<Controller
115+
control={control}
116+
name={`solutions.${index}.explanation`}
117+
render={({ field }): JSX.Element => (
118+
<CKEditorRichText
119+
disabled={toBeDeleted || disabled}
120+
disableMargins
121+
name={`solutions.${index}.explanation`}
122+
onChange={field.onChange}
123+
placeholder={t(translations.explanationDescription)}
124+
value={field.value ?? ''}
125+
/>
126+
)}
127+
/>
144128
</div>
145129
</div>
146130

147131
{isDraft && (
148-
<Typography className="italic text-neutral-500" variant="body2">
132+
<Alert
133+
className="border-lime-300"
134+
severity="warning"
135+
variant="outlined"
136+
>
149137
{t(translations.newSolutionCannotUndo)}
150-
</Typography>
138+
</Alert>
139+
)}
140+
141+
{toBeDeleted && (
142+
<Alert className="border-red-300" severity="error" variant="outlined">
143+
{t(translations.solutionWillBeDeleted)}
144+
</Alert>
151145
)}
152146
</div>
153147

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

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { useFieldArray, useFormContext } from 'react-hook-form';
22
import { Add } from '@mui/icons-material';
3-
import { Alert, Button, Paper, Typography } from '@mui/material';
3+
import { Alert, Button, Paper } from '@mui/material';
44
import {
55
SolutionEntity,
66
TextResponseEditableFormData,
77
} from 'types/course/assessment/question/text-responses';
88

9-
import { formatErrorMessage } from 'lib/components/form/fields/utils/mapError';
109
import useTranslation from 'lib/hooks/useTranslation';
1110

1211
import translations from '../../../translations';
@@ -24,10 +23,7 @@ const SolutionsManager = ({
2423
}: SolutionsManagerProps): JSX.Element => {
2524
const { t } = useTranslation();
2625

27-
const {
28-
control,
29-
formState: { errors },
30-
} = useFormContext<TextResponseEditableFormData>();
26+
const { control, watch } = useFormContext<TextResponseEditableFormData>();
3127

3228
const { fields, append, remove, update } = useFieldArray({
3329
control,
@@ -37,7 +33,7 @@ const SolutionsManager = ({
3733

3834
const addNewSolution = (): void => {
3935
append({
40-
id: `new-solution-${fields.length}`,
36+
id: `new-solution-${new Date().getTime()}`,
4137
solution: '',
4238
solutionType: 'exact_match',
4339
grade: '',
@@ -47,7 +43,7 @@ const SolutionsManager = ({
4743
};
4844

4945
const getFieldData = (index: number): SolutionEntity => {
50-
const fieldData: SolutionEntity & { _key?: string } = fields[index];
46+
const fieldData: SolutionEntity & { _key?: string } = { ...fields[index] };
5147
delete fieldData._key;
5248
return fieldData;
5349
};
@@ -64,20 +60,39 @@ const SolutionsManager = ({
6460
update(index, { ...getFieldData(index), toBeDeleted: undefined });
6561
};
6662

67-
const solutionsError = (errors.solutions as { message?: string } | undefined)
68-
?.message;
63+
// "fields" is only updated when a solution is added or removed.
64+
// We require more eager watching of the "fields" array, such as when a solution is marked/unmarked for deletion.
65+
const eagerFields = watch('solutions') ?? [];
66+
const maximumPossibleGrade = eagerFields.reduce((maxGrade, field) => {
67+
if (field.toBeDeleted) return maxGrade;
68+
69+
const grade = Number(field.grade);
70+
return Number.isNaN(grade) ? maxGrade : maxGrade + Math.max(grade, 0);
71+
}, 0);
72+
const numberOfFieldsToKeep = eagerFields.filter(
73+
(field) => !field.toBeDeleted,
74+
).length;
6975

7076
return (
7177
<>
72-
{isAssessmentAutograded && (
73-
<Alert severity="info">{t(translations.textResponseNote)}</Alert>
78+
{isAssessmentAutograded && numberOfFieldsToKeep === 0 && (
79+
<Alert severity="info">{t(translations.noSolutionsNote)}</Alert>
7480
)}
75-
<Alert severity="info">{t(translations.solutionTypeExplanation)}</Alert>
76-
{solutionsError && (
77-
<Typography color="error" variant="body2">
78-
{formatErrorMessage(solutionsError)}
79-
</Typography>
81+
{numberOfFieldsToKeep > 0 && (
82+
<Alert severity="info">{t(translations.atLeastOneSolutionNote)}</Alert>
8083
)}
84+
{eagerFields.some((field) => field.solutionType === 'exact_match') &&
85+
eagerFields.some((field) => field.solutionType !== 'exact_match') && (
86+
<Alert severity="info">
87+
{t(translations.exactMatchSolutionNote)}
88+
</Alert>
89+
)}
90+
{maximumPossibleGrade > 0 &&
91+
maximumPossibleGrade > Number(watch('question.maximumGrade')) && (
92+
<Alert severity="info">
93+
{t(translations.solutionGradesExceedMaximumGradeNote)}
94+
</Alert>
95+
)}
8196

8297
{Boolean(fields.length) && (
8398
<Paper variant="outlined">

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useRef, useState } from 'react';
2-
import { Controller } from 'react-hook-form';
2+
import { Control, Controller } from 'react-hook-form';
33
import { Alert } from '@mui/material';
44
import {
55
AttachmentType,
@@ -98,7 +98,7 @@ const TextResponseForm = (props: TextResponseFormProps): JSX.Element => {
9898
data.defaultMaxAttachments,
9999
)}
100100
>
101-
{(control): JSX.Element => (
101+
{(control: Control<TextResponseEditableFormData>): JSX.Element => (
102102
<>
103103
<CommonQuestionFields
104104
availableSkills={data.availableSkills}
@@ -115,7 +115,7 @@ const TextResponseForm = (props: TextResponseFormProps): JSX.Element => {
115115
>
116116
<Controller
117117
control={control}
118-
name="templateText"
118+
name="question.templateText"
119119
render={({ field, fieldState }): JSX.Element => (
120120
<FormRichTextField
121121
disabled={submitting}

0 commit comments

Comments
 (0)