Skip to content

Commit 84d2435

Browse files
authored
Merge pull request #222 from janezd/batch-answer
Implement `Quiz` as a batch of questions with common submit button
2 parents ec852da + 369a4da commit 84d2435

10 files changed

Lines changed: 710 additions & 514 deletions

File tree

components/MdxContent.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { AnswersInBook } from "@/api/quiz";
66
import { determineQuestionType } from "@/utils/questions";
77
import { Explanation, IExplanation } from "@/components/Quiz/Explanation";
88

9-
import Question, { QuizPropsBase } from "./Quiz/Quiz";
9+
import Question, { QuizPropsBase } from "./Quiz/Question";
10+
import { Quiz } from "@/components/Quiz/Quiz";
1011
import Image from "./Image";
1112
import { ExpandingSideImg, Sidenote } from "@/components/Book/Sidenote";
1213

@@ -65,6 +66,7 @@ export const MdxContent = ({content, chapterId, bookId, t, allAnswers}: {
6566
},
6667

6768
Explanation: (props: IExplanation) => <Explanation {...props} />,
69+
Quiz,
6870

6971
Sidenote,
7072
SideNote: Sidenote,

components/Quiz/Question.tsx

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import React, { JSXElementConstructor } from "react";
2+
import { RiAlertLine, RiCheckboxCircleFill, RiCloseCircleLine, RiRecordCircleLine, RiFundsLine,
3+
} from "react-icons/ri";
4+
5+
import { UserDesc } from "@/api/quiz";
6+
import { QuestionTypes } from "@/types";
7+
import { corrColor, corrSym } from "@/utils/questions";
8+
import { useIntl } from "@/i18n";
9+
import { QuizContext, useBatchSubmission, useFileAnswer, useLastAnswer } from "@/context/QuizContextProvider";
10+
import { FileDropFunction, FileQuestion } from "./UploadQuestion";
11+
import { FileDockQuestion } from "@/components/Quiz/FileDockQuestion";
12+
import { LongTextQuestion, TextQuestion } from "./TextQuestions";
13+
import { SingleChoiceQuestion } from "./SingleChoiceQuestion";
14+
15+
16+
export interface QuizPropsBase {
17+
question: string;
18+
options?: string[];
19+
checker?: (option: string) => string | null;
20+
children?: React.ReactNode;
21+
}
22+
23+
interface IQuestion extends QuizPropsBase {
24+
id: string;
25+
type: QuestionTypes;
26+
scorer: ((option: string) => (boolean | undefined)) | undefined
27+
accept?: string[];
28+
usersAnswers?: (
29+
UserDesc &
30+
{ answers: {
31+
answer: string;
32+
isCorrect?: boolean;
33+
points?: number;
34+
}[]
35+
}
36+
)[];
37+
}
38+
39+
export default function Question(props: IQuestion) {
40+
const { maxAttempts, maxPoints } = React.useContext(QuizContext).getQuestionSettings(props.id)!;
41+
return props.type.startsWith("upload") ? UploadQuestion({...props, maxAttempts}) : ValueQuestion({...props, maxPoints, maxAttempts});
42+
}
43+
44+
function ValueQuestion({type, id, question, options = [], checker, scorer,
45+
maxPoints = 0, maxAttempts = 1, children, usersAnswers}: IQuestion & { maxPoints: number, maxAttempts: number }) {
46+
const { t } = useIntl();
47+
const [answer, setAnswer] = React.useState<null | string>(null);
48+
const [submitted, setSubmitted] = React.useState(false);
49+
const { isCorrect, points, attempts, submissionErrored,
50+
answerQuestion, correctAnswer, answer: submittedAnswer } = useLastAnswer(id);
51+
const { displayedAnswer } = React.useContext(QuizContext);
52+
const batchSubmission = useBatchSubmission();
53+
React.useEffect(() => {
54+
const displayed = displayedAnswer(id);
55+
if (displayed) {
56+
setSubmitted(true);
57+
setAnswer(displayed);
58+
}
59+
}, [id, displayedAnswer])
60+
61+
const submitDisabled = React.useMemo(() =>
62+
!!maxAttempts && attempts >= maxAttempts,
63+
[maxAttempts, attempts]);
64+
65+
const onSubmit = React.useCallback(
66+
async (answer: string) => {
67+
const isCorrect = scorer && scorer(answer.trim().toLowerCase());
68+
if (await answerQuestion({
69+
answer,
70+
isCorrect,
71+
points: (scorer && isCorrect) ? maxPoints : 0})) {
72+
setSubmitted(true);
73+
}
74+
},
75+
[scorer, maxPoints, answerQuestion]
76+
);
77+
78+
const message = React.useMemo(() => {
79+
if (submissionErrored) {
80+
return;
81+
}
82+
switch (isCorrect) {
83+
case null: {
84+
if (maxAttempts > 1) {
85+
return `${t("quiz.attempts")}: ${maxAttempts - attempts}`;
86+
}
87+
return;
88+
}
89+
case false: {
90+
let msg = t("quiz.incorrect");
91+
if (attempts < maxAttempts && maxAttempts > 1) {
92+
msg += ` ${t("quiz.remaining")}: ${maxAttempts - attempts}`
93+
}
94+
else {
95+
if (maxAttempts > 0 && correctAnswer) {
96+
msg += ` ${t("quiz.correct-answer")} "${correctAnswer}"`;
97+
}
98+
}
99+
return msg;
100+
}
101+
case true: {
102+
let msg = t("quiz.correct");
103+
if (points) {
104+
msg += ` ${t("quiz.points")}: ${points}`;
105+
}
106+
return msg;
107+
}
108+
default:
109+
return null;
110+
}
111+
}, [t, submissionErrored, isCorrect, attempts, maxAttempts, points, correctAnswer]);
112+
113+
const pendingSubmission = React.useMemo(() =>
114+
batchSubmission && answer !== null && answer !== submittedAnswer,
115+
[batchSubmission, answer, submittedAnswer]
116+
);
117+
const correctnessClass = React.useMemo(() => {
118+
if (submissionErrored) {
119+
return "submission-errored";
120+
}
121+
if (pendingSubmission) {
122+
return "";
123+
}
124+
switch (isCorrect) {
125+
case undefined: return "neutral";
126+
case true: return "correct";
127+
case false: return "incorrect";
128+
default: return ""
129+
}
130+
}, [pendingSubmission, submissionErrored, isCorrect]);
131+
132+
const icon = React.useMemo(() =>
133+
pendingSubmission ? <RiFundsLine />
134+
: submissionErrored ? <RiAlertLine />
135+
: type === "long-text" ? (submitted ? <RiRecordCircleLine /> : null)
136+
: isCorrect === true ? <RiCheckboxCircleFill />
137+
: isCorrect === false ? <RiCloseCircleLine />
138+
: null,
139+
[pendingSubmission, submissionErrored, isCorrect, submitted, type]);
140+
141+
const childrenWithProps: any = React.Children.map(children, (child) => {
142+
if (
143+
React.isValidElement(child) &&
144+
(child.type as JSXElementConstructor<any>).name === "Explanation"
145+
) {
146+
return React.cloneElement(child as React.ReactElement<any>, {
147+
nattempts: attempts,
148+
attemptsExhausted: !!(maxAttempts && maxAttempts === attempts),
149+
isCorrect: !submissionErrored && isCorrect,
150+
});
151+
}
152+
return child;
153+
});
154+
155+
const textProps = React.useMemo(() => ({
156+
answer, setAnswer, checker, setSubmitted,
157+
onSubmit: !submitDisabled && onSubmit,
158+
}), [submitDisabled, checker, onSubmit, setSubmitted, answer, setAnswer]);
159+
160+
return <>
161+
<a id={`question-${id}`} />
162+
<div
163+
className={`quiz
164+
${batchSubmission ? "joint-submission" : ""}
165+
${pendingSubmission ? "pending-submission"
166+
: (usersAnswers ? "" : correctnessClass)}`}
167+
>
168+
<div className="quiz-question">
169+
<h3>
170+
{question} {!!maxPoints && <span>({maxPoints}pt.)</span>}
171+
</h3>
172+
{icon}
173+
</div>
174+
175+
<form>
176+
<fieldset disabled={submitDisabled}>
177+
{ type === "text" && <TextQuestion {...textProps} /> }
178+
{ type === "long-text" && <LongTextQuestion {...textProps} /> }
179+
{ type === "singlechoice" && <SingleChoiceQuestion
180+
options={options} answer={answer} onSubmit={onSubmit} /> }
181+
</fieldset>
182+
183+
{ !usersAnswers &&
184+
<>
185+
{ message &&
186+
<p className="quiz-message">{message}</p>
187+
}
188+
{ submissionErrored &&
189+
<div
190+
className="bg-red-100 border-red-500 text-red-700 mt-2 p-1 pl-4 rounded"
191+
role="alert"
192+
>
193+
<p>
194+
{ typeof submissionErrored === "string"
195+
? submissionErrored
196+
: t("quiz.submission-error")}
197+
</p>
198+
</div>
199+
}
200+
{ childrenWithProps}
201+
</>
202+
}
203+
</form>
204+
205+
{usersAnswers && usersAnswers.length > 0 && (
206+
<div className="users-answers">
207+
{usersAnswers.map(({ name, surname, answers }, ui) => (
208+
<p key={ui}>
209+
{name} {surname}:&nbsp;
210+
{answers?.map(({answer, isCorrect}, i) => (
211+
<React.Fragment key={i}>
212+
{ i > 0 && ", " }
213+
<span style={{color: corrColor(isCorrect)}} key={i}>
214+
{answer} {corrSym(isCorrect)}
215+
</span>
216+
</React.Fragment>
217+
))}
218+
</p>
219+
))}
220+
</div>
221+
)}
222+
</div>
223+
</>;
224+
}
225+
226+
227+
function UploadQuestion({type, id, question, maxAttempts = 1}: IQuestion & { maxAttempts: number }) {
228+
const { t } = useIntl();
229+
const { files, submissionErrored } = useFileAnswer(id);
230+
231+
const submitDisabled = React.useMemo(
232+
() => maxAttempts === 1 && files.length !== 0,
233+
[maxAttempts, files]);
234+
235+
const icon = React.useMemo(() =>
236+
submissionErrored ? <RiAlertLine />
237+
: files.length ? <RiRecordCircleLine /> : null,
238+
[submissionErrored, files]);
239+
240+
const [isDragging, setIsDragging] = React.useState(false);
241+
const onDragOver = React.useCallback((e: React.DragEvent<HTMLDivElement>) => {
242+
e.preventDefault();
243+
setIsDragging(true);
244+
}, []);
245+
246+
const onDragLeave = React.useCallback((e: React.DragEvent<HTMLDivElement>) => {
247+
// Only deactivate when actually leaving the container, not child elements
248+
if (e.currentTarget.contains(e.relatedTarget as Node)) {
249+
return;
250+
}
251+
setIsDragging(false);
252+
}, []);
253+
254+
const onFileDropRef = React.useRef<FileDropFunction | null>(null);
255+
const onDrop = React.useCallback((e: React.DragEvent<HTMLElement>) => {
256+
setIsDragging(false);
257+
onFileDropRef.current?.(e);
258+
},
259+
[setIsDragging])
260+
261+
const accept = React.useContext(QuizContext).getCorrectAnswer(id);
262+
const acceptList = accept?.toLocaleLowerCase()
263+
?.replaceAll("*", "")
264+
.replaceAll(";", " ")
265+
.replaceAll(",", " ")
266+
.split(/\s+/)
267+
|| undefined;
268+
269+
return <>
270+
<a id={`question-${id}`} />
271+
<div
272+
className="quiz"
273+
onDrop={onDrop}
274+
onDragOver={onDragOver}
275+
onDragLeave={onDragLeave}
276+
>
277+
{isDragging &&
278+
<div className="absolute inset-0 bg-blue-200/40 border-2 border-blue-400 rounded-md flex items-center justify-center pointer-events-none" />
279+
}
280+
<div className="quiz-question">
281+
<h3>
282+
{question}
283+
</h3>
284+
{icon}
285+
</div>
286+
287+
<form>
288+
<fieldset disabled={submitDisabled}>
289+
{ maxAttempts === 1
290+
? <FileQuestion id={id} ref={onFileDropRef} accept={acceptList} multiple={type === "uploads"} />
291+
: <FileDockQuestion id={id} ref={onFileDropRef} accept={acceptList} multiple={type === "uploads"} />
292+
}
293+
</fieldset>
294+
{ submissionErrored &&
295+
<div
296+
className="bg-red-100 border-red-500 text-red-700 mt-2 p-1 pl-4 rounded"
297+
role="alert"
298+
>
299+
<p>
300+
{ typeof submissionErrored === "string" ? submissionErrored : t("quiz.submission-error") }
301+
</p>
302+
</div>
303+
}
304+
</form>
305+
</div>
306+
</>;
307+
}

0 commit comments

Comments
 (0)