Skip to content

Commit 961549d

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 2046b6c commit 961549d

11 files changed

Lines changed: 151 additions & 136 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: 39 additions & 42 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

@@ -34,7 +34,7 @@ const Solution = ({
3434
return (
3535
<section
3636
className={`flex border-0 border-b border-solid border-neutral-200 last:border-b-0 ${
37-
toBeDeleted ? 'border-neutral-300 bg-neutral-200' : ''
37+
toBeDeleted ? 'bg-red-50' : ''
3838
} ${isDraft ? 'bg-lime-50' : ''}`}
3939
>
4040
<div className="mx-8 mt-0 flex w-[calc(100%_-_84px)] flex-col space-y-4 py-4">
@@ -75,19 +75,13 @@ const Solution = ({
7575
control={control}
7676
name={`solutions.${index}.grade`}
7777
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-
</>
78+
<FormTextField
79+
disabled={toBeDeleted || disabled}
80+
disableMargins
81+
field={field}
82+
fieldState={fieldState}
83+
placeholder={t(translations.zeroGrade)}
84+
/>
9185
)}
9286
/>
9387
</div>
@@ -101,10 +95,9 @@ const Solution = ({
10195
name={`solutions.${index}.solution`}
10296
render={({ field, fieldState }): JSX.Element => (
10397
<>
104-
<TextField
98+
<textarea
99+
className="w-full h-full resize-none rounded border border-solid border-neutral-400 p-2 disabled:bg-neutral-100 disabled:text-neutral-400"
105100
disabled={toBeDeleted || disabled}
106-
error={!!fieldState.error}
107-
multiline
108101
rows={2}
109102
{...field}
110103
/>
@@ -120,34 +113,38 @@ const Solution = ({
120113

121114
<div className="flex w-2/4 flex-col space-y-2">
122115
<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-
)}
116+
<Controller
117+
control={control}
118+
name={`solutions.${index}.explanation`}
119+
render={({ field }): JSX.Element => (
120+
<CKEditorRichText
121+
disabled={toBeDeleted || disabled}
122+
disableMargins
123+
inputId={`solution-${solutionId}-explanation`}
124+
name="explanation"
125+
onChange={field.onChange}
126+
placeholder={t(translations.explanationDescription)}
127+
value={field.value ?? ''}
128+
/>
129+
)}
130+
/>
144131
</div>
145132
</div>
146133

147134
{isDraft && (
148-
<Typography className="italic text-neutral-500" variant="body2">
135+
<Alert
136+
className="border-lime-300"
137+
severity="warning"
138+
variant="outlined"
139+
>
149140
{t(translations.newSolutionCannotUndo)}
150-
</Typography>
141+
</Alert>
142+
)}
143+
144+
{toBeDeleted && (
145+
<Alert className="border-red-300" severity="error" variant="outlined">
146+
{t(translations.solutionWillBeDeleted)}
147+
</Alert>
151148
)}
152149
</div>
153150

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const SolutionsManager = ({
2727
const {
2828
control,
2929
formState: { errors },
30+
watch,
3031
} = useFormContext<TextResponseEditableFormData>();
3132

3233
const { fields, append, remove, update } = useFieldArray({
@@ -37,7 +38,7 @@ const SolutionsManager = ({
3738

3839
const addNewSolution = (): void => {
3940
append({
40-
id: `new-solution-${fields.length}`,
41+
id: `new-solution-${new Date().getTime()}`,
4142
solution: '',
4243
solutionType: 'exact_match',
4344
grade: '',
@@ -67,12 +68,34 @@ const SolutionsManager = ({
6768
const solutionsError = (errors.solutions as { message?: string } | undefined)
6869
?.message;
6970

71+
const maximumPossibleGrade = (watch('solutions') ?? []).reduce(
72+
(maxGrade, field) => {
73+
const grade = Number(field.grade);
74+
return Number.isNaN(grade) ? maxGrade : maxGrade + Math.max(grade, 0);
75+
},
76+
0,
77+
);
78+
7079
return (
7180
<>
72-
{isAssessmentAutograded && (
73-
<Alert severity="info">{t(translations.textResponseNote)}</Alert>
81+
{isAssessmentAutograded && fields.length === 0 && (
82+
<Alert severity="info">{t(translations.noSolutionsNote)}</Alert>
83+
)}
84+
{fields.length > 0 && (
85+
<Alert severity="info">{t(translations.atLeastOneSolutionNote)}</Alert>
7486
)}
75-
<Alert severity="info">{t(translations.solutionTypeExplanation)}</Alert>
87+
{fields.some((field) => field.solutionType === 'exact_match') &&
88+
fields.some((field) => field.solutionType !== 'exact_match') && (
89+
<Alert severity="info">
90+
{t(translations.exactMatchSolutionNote)}
91+
</Alert>
92+
)}
93+
{maximumPossibleGrade > 0 &&
94+
maximumPossibleGrade > Number(watch('question.maximumGrade')) && (
95+
<Alert severity="info">
96+
{t(translations.solutionGradesExceedMaximumGradeNote)}
97+
</Alert>
98+
)}
7699
{solutionsError && (
77100
<Typography color="error" variant="body2">
78101
{formatErrorMessage(solutionsError)}

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}

client/app/bundles/course/assessment/translations.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -933,8 +933,7 @@ const translations = defineMessages({
933933
},
934934
solutionsHint: {
935935
id: 'course.assessment.question.textResponses.solutionsHint',
936-
defaultMessage:
937-
'Adding solutions allows the answer to be autograded. Students can only input plain text.',
936+
defaultMessage: 'Adding solutions allows the answer to be autograded.',
938937
},
939938
solutionWillBeDeleted: {
940939
id: 'course.assessment.question.textResponses.solutionWillBeDeleted',
@@ -1034,11 +1033,6 @@ const translations = defineMessages({
10341033
id: 'course.assessment.question.textResponses.solutionType',
10351034
defaultMessage: 'Type of Solution',
10361035
},
1037-
solutionTypeExplanation: {
1038-
id: 'course.assessment.question.textResponses.solutionTypeExplanation',
1039-
defaultMessage:
1040-
'If Exact Match is selected, solutions with multiple lines must match student answers exactly for the answer to be graded as correct.',
1041-
},
10421036
exactMatch: {
10431037
id: 'course.assessment.question.textResponses.exactMatch',
10441038
defaultMessage: 'Exact Match',
@@ -1055,29 +1049,31 @@ const translations = defineMessages({
10551049
id: 'course.assessment.question.textResponses.deleteSolution',
10561050
defaultMessage: 'Delete solution',
10571051
},
1058-
mustSpecifyGrade: {
1059-
id: 'course.assessment.question.textResponses.mustSpecifyGrade',
1060-
defaultMessage: 'You must specify a valid number for grade.',
1052+
noSolutionsNote: {
1053+
id: 'course.assessment.question.textResponses.noSolutionsNote',
1054+
defaultMessage:
1055+
'If no solutions are provided, the autograder will always award the maximum grade.',
10611056
},
1062-
mustSpecifySolution: {
1063-
id: 'course.assessment.question.textResponses.mustSpecifySolution',
1064-
defaultMessage: 'You must specify a valid solution title.',
1057+
atLeastOneSolutionNote: {
1058+
id: 'course.assessment.question.textResponses.atLeastOneSolutionNote',
1059+
defaultMessage:
1060+
'If at least one solution is provided, students will only be able to input plain text.',
10651061
},
1066-
textResponseNote: {
1067-
id: 'course.assessment.question.textResponses.textResponseNote',
1062+
exactMatchSolutionNote: {
1063+
id: 'course.assessment.question.textResponses.exactMatchSolutionNote',
10681064
defaultMessage:
1069-
'Note: If no solutions are provided, the autograder will always award the maximum grade.',
1065+
'If Exact Match is selected, solutions with multiple lines must match student answers exactly for the answer to be graded as correct. If such a match is found, the autograder will award the specified grade without considering other solutions.',
1066+
},
1067+
solutionGradesExceedMaximumGradeNote: {
1068+
id: 'course.assessment.question.textResponses.solutionGradesExceedMaximumGradeNote',
1069+
defaultMessage:
1070+
"If the student answer satisfies multiple solutions, the autograder will award the sum of those solutions' grades, up to the question's maximum grade.",
10701071
},
10711072
fileUploadNote: {
10721073
id: 'course.assessment.question.textResponses.fileUploadNote',
10731074
defaultMessage:
10741075
'Note: File upload question is not auto-gradable. The autograder will always award the maximum grade.',
10751076
},
1076-
mustSpecifySolutionType: {
1077-
id: 'course.assessment.question.textResponses.mustSpecifySolutionType',
1078-
defaultMessage:
1079-
'You must choose either exact match or keyword as solution type.',
1080-
},
10811077
validAttachmentSettingValues: {
10821078
id: 'course.assessment.question.textResponses.validAttachmentSettingValues',
10831079
defaultMessage:

client/app/lib/components/form/fields/SingleFileInput/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ComponentType, UIEvent, useEffect, useState } from 'react';
1+
import { ComponentType, UIEvent, useEffect, useRef, useState } from 'react';
22
import Dropzone, { Accept } from 'react-dropzone';
33
import {
44
ControllerFieldState,
@@ -61,6 +61,7 @@ const FormSingleFileInput = <
6161
} = props;
6262

6363
const [file, setFile] = useState<File | null>(null);
64+
const mounted = useRef(false);
6465

6566
const onCancel = (e: UIEvent): void => {
6667
setFile(null);
@@ -72,6 +73,10 @@ const FormSingleFileInput = <
7273
};
7374

7475
useEffect(() => {
76+
if (!mounted.current) {
77+
mounted.current = true;
78+
return;
79+
}
7580
onChange({ file, url, name });
7681
}, [file]);
7782

0 commit comments

Comments
 (0)