Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
1a193aa
Start documenting initial idea.
SilasBerger Jan 27, 2026
d983bcc
Remove unnecessary import.
SilasBerger Jan 27, 2026
4855a91
Rework syntax.
SilasBerger Jan 27, 2026
3a8868a
Implement basic radio buttons.
SilasBerger Jan 27, 2026
e122ed6
Add support for MC.
SilasBerger Jan 27, 2026
f8e5c67
Draft new syntax.
SilasBerger Jan 27, 2026
79272e8
Tweak syntax examples.
SilasBerger Jan 28, 2026
8680d05
Work on plugin.
SilasBerger Jan 28, 2026
6a63d4c
Cleanup.
SilasBerger Jan 28, 2026
ae459ac
Make MDX plugin not suck.
SilasBerger Jan 28, 2026
c180085
Enumerate options.
SilasBerger Jan 28, 2026
ba2d47c
Implement saving.
SilasBerger Jan 29, 2026
6924fff
Show save icon.
SilasBerger Jan 29, 2026
fe9a647
Run formatter.
SilasBerger Jan 29, 2026
459e625
Start integrating Quiz.
SilasBerger Jan 29, 2026
367bc13
Start adding Quiz.
SilasBerger Feb 2, 2026
0b9b522
Fix prop passing.
SilasBerger Feb 2, 2026
91e5df4
Inject doc from quiz.
SilasBerger Feb 2, 2026
27a78bd
Prevent redundant visit of questions in quiz.
SilasBerger Feb 2, 2026
d4c791d
Format.
SilasBerger Feb 2, 2026
e45daed
Improve save icon handling.
SilasBerger Feb 2, 2026
53da651
Cleanup.
SilasBerger Feb 2, 2026
587a307
Implement deleting answer.
SilasBerger Feb 2, 2026
ebd1a38
Add support for true/false answer.
SilasBerger Feb 2, 2026
30392aa
Cleanup.
SilasBerger Feb 2, 2026
950cba5
Add styling and support for question title.
SilasBerger Feb 2, 2026
0930e44
Improve visuals.
SilasBerger Feb 2, 2026
c7ff69c
Add option randomization.
SilasBerger Feb 3, 2026
e732a4b
Implement quiz randomization.
SilasBerger Feb 3, 2026
64cfacc
Cleanup and fixes.
SilasBerger Feb 3, 2026
ab16f82
Clean up.
SilasBerger Feb 5, 2026
2d94413
Start working on component cleanup.
SilasBerger Feb 6, 2026
9913b00
Slim context further.
SilasBerger Feb 6, 2026
67ee1d9
Reduce choice answer props to minimum.
SilasBerger Feb 6, 2026
ebab0da
Fix non-randomization of true/false options.
SilasBerger Feb 6, 2026
04b897f
Hide delete btn behind hover.
SilasBerger Feb 6, 2026
f34f4b5
Make fade a little snappier.
SilasBerger Feb 6, 2026
06073bb
Use canEdit, fix some bugs, clean up.
SilasBerger Feb 6, 2026
2f98410
Use card style.
SilasBerger Feb 7, 2026
0f0f97a
Start working on auto-grading.
SilasBerger Mar 16, 2026
a8fa353
Refactor, work on auto-grading.
SilasBerger Mar 16, 2026
4007fb6
Implement basic SC grading logic.
SilasBerger Mar 16, 2026
7807798
Implement MC grading.
SilasBerger Mar 17, 2026
e9bf2d6
Cleanup.
SilasBerger Mar 17, 2026
887a426
Fix reset.
SilasBerger Mar 17, 2026
5a79c29
Map true/false answer correct state.
SilasBerger Mar 17, 2026
33f1ec3
Move controls for quiz.
SilasBerger Mar 17, 2026
cd93b0e
Use grading color in header.
SilasBerger Mar 17, 2026
0adc3f8
Restyle feedback.
SilasBerger Mar 22, 2026
a11b28c
Work on grading.
SilasBerger Mar 22, 2026
c9bea09
Implement basic grading.
SilasBerger Mar 22, 2026
6ace3d1
Update readme.
SilasBerger Mar 22, 2026
8bd2681
Show max points when not graded.
SilasBerger Mar 22, 2026
dfc4a31
Add noPoints grading function.
SilasBerger Mar 22, 2026
91a3dab
Implement grading hints.
SilasBerger Mar 23, 2026
8e6bec4
Add some documentation.
SilasBerger Mar 23, 2026
f00152c
Implement MC grading.
SilasBerger Mar 23, 2026
d6e6353
Implement lower points limit for quiz.
SilasBerger Mar 23, 2026
b183eec
Prevent answer changes when not allowed.
SilasBerger Mar 23, 2026
e414aaa
Cleanup.
SilasBerger Mar 23, 2026
d9da2b7
Fix some grading/verification edge cases.
SilasBerger Mar 23, 2026
7c0a362
Restructure.
SilasBerger Mar 24, 2026
d37db6d
Integrate grading hint into points badge.
SilasBerger Mar 24, 2026
5b73f7d
Remove standalone scoring hint if no questions have scoring.
SilasBerger Mar 24, 2026
b07fd05
Work on renaming.
SilasBerger Mar 24, 2026
4b08937
Split grading into scoring and assessment.
SilasBerger Mar 24, 2026
4a4fb72
Rename grading prop to scoring.
SilasBerger Mar 24, 2026
6228226
Finish nomenclature change.
SilasBerger Mar 24, 2026
af7c8a6
Clean up README.
SilasBerger Mar 24, 2026
6c00d40
Fix z-index issue.
SilasBerger Mar 24, 2026
4807a05
Cover scoring-but-no-scoring-hint edge case.
SilasBerger Mar 24, 2026
9b1b104
Clean up feedback.
SilasBerger Mar 24, 2026
007982d
Enforce header if there is scoring.
SilasBerger Mar 24, 2026
94644bd
Cleanup.
SilasBerger Mar 24, 2026
f0fc07b
Work on documentation.
SilasBerger Mar 24, 2026
047c8f3
Update docs.
SilasBerger Mar 24, 2026
d8468a7
Get rid of header-less questions.
SilasBerger Mar 24, 2026
3a60831
Add imports to documentation examples.
SilasBerger Mar 25, 2026
d06b914
Make question controls more mobile friendly.
SilasBerger Mar 25, 2026
ac61ad7
Make points badge more mobile friendly.
SilasBerger Mar 25, 2026
490c718
Make quiz controls and feedback more mobile friendly.
SilasBerger Mar 25, 2026
3bfc2d1
Fix visual glitches in multiline options.
SilasBerger Mar 25, 2026
4642c1a
Improve option alignment.
SilasBerger Mar 25, 2026
511e385
Revert unnecessary change to ProgressState.
SilasBerger Mar 25, 2026
3e58075
Cleanup.
SilasBerger Mar 25, 2026
9732668
Cleanup.
SilasBerger Mar 25, 2026
857a332
Remove obsolete feedback admonition.
SilasBerger Mar 25, 2026
648ad33
Simplify feedback style computation.
SilasBerger Mar 25, 2026
7bce9bf
Remove playground.
SilasBerger Mar 25, 2026
c58e04a
Merge branch 'main' into feature/choice-answer
SilasBerger Mar 25, 2026
a998cf0
Add installation docs.
SilasBerger Mar 25, 2026
787992b
Fix hook usage.
SilasBerger Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/api/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import iDocumentContainer from '@tdev-models/iDocumentContainer';
import iViewStore from '@tdev-stores/ViewStores/iViewStore';
import Code from '@tdev-models/documents/Code';
import { iTaskableDocument } from '@tdev-models/iTaskableDocument';
import ChoiceAnswer, {
ChoiceAnswerChoices,
ChoiceAnswerOptionOrders,
ChoiceAnswerQuestionOrder
} from '@tdev-models/documents/ChoiceAnswer';

export enum Access {
RO_DocumentRoot = 'RO_DocumentRoot',
Expand All @@ -41,6 +46,13 @@ export interface StringData {
text: string;
}

export interface ChoiceAnswerData {
choices: ChoiceAnswerChoices;
optionOrders: ChoiceAnswerOptionOrders;
questionOrder: ChoiceAnswerQuestionOrder | null;
assessed: boolean;
}

export interface QuillV2Data {
delta: Delta;
}
Expand Down Expand Up @@ -124,6 +136,7 @@ export interface TypeDataMapping extends TaskableDocumentMapping, ContainerTypeD
// TODO: rename to `code_version`?
['script_version']: ScriptVersionData;
['string']: StringData;
['choice_answer']: ChoiceAnswerData;
['quill_v2']: QuillV2Data;
['solution']: SolutionData;
['dir']: DirData;
Expand Down Expand Up @@ -160,6 +173,7 @@ export interface TypeModelMapping extends TaskableTypeModelMapping, ContainerTyp
// TODO: rename to `code_version`?
['script_version']: ScriptVersion;
['string']: String;
['choice_answer']: ChoiceAnswer;
['quill_v2']: QuillV2;
['solution']: Solution;
['dir']: Directory;
Expand Down
266 changes: 266 additions & 0 deletions src/components/documents/ChoiceAnswer/Component/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument';
import ChoiceAnswerDocument, {
ChoiceAnswerCorrectness,
ModelMeta
} from '@tdev-models/documents/ChoiceAnswer';
import { observer } from 'mobx-react-lite';
import React from 'react';
import clsx from 'clsx';
import styles from './styles.module.scss';
import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType';
import Loader from '@tdev-components/Loader';
import useIsBrowser from '@docusaurus/useIsBrowser';
import { QuizContext } from '../Quiz';
import Button from '@tdev-components/shared/Button';
import { mdiTrashCanOutline } from '@mdi/js';
import { createRandomOrderMap } from '../helpers/shared';
import QuestionControls from '../Controls';
import { FeedbackBadge } from '../Feedback';
import { ScoringFunction } from '../helpers/scoring';
import { assess } from '../helpers/assessment';

export interface ChoiceAnswerProps {
id: string;
title?: string;
correct?: number[];
scoring?: ScoringFunction;
questionIndex?: number;
inQuiz?: boolean;
multiple?: boolean;
randomizeOptions?: boolean;
numOptions: number;
readonly?: boolean;
children: React.ReactNode;
}

interface ThinWrapperProps {
children: React.ReactNode;
}

interface OptionProps {
children: React.ReactNode;
optionIndex: number;
}

type ChoiceAnswerSubComponents = {
Before: React.FC<ThinWrapperProps>;
Options: React.FC<ThinWrapperProps>;
Option: React.FC<OptionProps>;
After: React.FC<ThinWrapperProps>;
};

const ChoiceAnswerContext = React.createContext({
doc: undefined,
questionIndex: 0,
multiple: false,
randomizeOptions: false,
onChange: () => {}
} as {
doc?: ChoiceAnswerDocument;
questionIndex: number;
multiple?: boolean;
randomizeOptions?: boolean;
onChange: (optionIndex: number, checked: boolean) => void;
});

const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
const parentProps = React.useContext(QuizContext);
const [meta] = React.useState(new ModelMeta(props));
const ownDoc = useFirstMainDocument(props.inQuiz ? undefined : props.id, meta);
const doc = props.inQuiz ? parentProps.doc : ownDoc;
const questionIndex = props.questionIndex ?? 0;
const randomizeOptions =
props.randomizeOptions !== undefined ? props.randomizeOptions : parentProps.randomizeOptions;
const isBrowser = useIsBrowser();

React.useEffect(() => {
if (randomizeOptions && !doc?.data.optionOrders?.[questionIndex]) {
doc?.updateOptionOrders({
...doc.data.optionOrders,
[questionIndex]: createRandomOrderMap(props.numOptions)
});
}
}, [randomizeOptions, doc, questionIndex, props.numOptions]);

React.useEffect(() => {
if (!doc) {
return;
}

if (props.correct === undefined) {
// If no correct options are given, we assume that this question doesn't support assessment.
return;
}
const correctOptions = new Set(props.correct);

const scoringFunction = props.scoring ?? parentProps.scoring;
const assessment = assess(
doc,
props.multiple ?? false,
questionIndex,
correctOptions,
props.numOptions,
scoringFunction
);
doc.updateAssessment(questionIndex, assessment);
}, [doc, doc?.choices, doc?.assessed]);

if (!doc) {
return <UnknownDocumentType type={meta.type} />;
}

if (!isBrowser) {
return <Loader />;
}

const assessment = doc.getAssessment(questionIndex);
const feedbackStyle = {
[styles.correct]: doc.assessed && assessment?.correctness === ChoiceAnswerCorrectness.Correct,
[styles.partiallyCorrect]:
doc.assessed && assessment?.correctness === ChoiceAnswerCorrectness.PartiallyCorrect,
[styles.incorrect]: doc.assessed && assessment?.correctness === ChoiceAnswerCorrectness.Incorrect
};

const childrenArray = React.Children.toArray(props.children);
const beforeBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.Before
);
const optionsBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.Options
);
const afterBlock = childrenArray.find(
(child) => React.isValidElement(child) && child.type === ChoiceAnswer.After
);

const onOptionChange = (optionIndex: number, checked: boolean) => {
parentProps.setFocussedQuestion?.(questionIndex);
if (props.multiple) {
doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked);
} else {
checked
? doc?.updateSingleChoiceSelection(questionIndex, optionIndex)
: doc?.resetAnswer(questionIndex);
}
};

const questionOrder =
parentProps.randomizeQuestions && parentProps.questionOrder
? parentProps.questionOrder[questionIndex]
: questionIndex;

const questionNumberToDisplay =
(parentProps.randomizeQuestions
? (parentProps.questionOrder?.[questionIndex] ?? questionIndex)
: questionIndex) + 1;
const canonicalTitle =
props.inQuiz && !parentProps.hideQuestionNumbers
? props.title
? `Frage ${questionNumberToDisplay} – ${props.title}`
: `Frage ${questionNumberToDisplay}`
: props.title;
const displayTitle = canonicalTitle || 'Frage';

return (
<div
className={clsx('card', styles.choiceAnswerContainer, feedbackStyle)}
style={{ order: questionOrder }}
tabIndex={questionOrder}
>
<div className={clsx('card__header', styles.header, feedbackStyle)}>
<span className={clsx(styles.title)}>{displayTitle}</span>
<div className={clsx(styles.controlsAndFeedback)}>
{!!props.correct && (
<QuestionControls
doc={doc}
questionIndex={questionIndex}
focussedQuestion={parentProps.focussedQuestion === questionIndex}
inQuiz={props.inQuiz}
/>
)}
<FeedbackBadge doc={doc} questionIndex={questionIndex} />
</div>
</div>

<div className={clsx('card__body')}>
{beforeBlock}
<ChoiceAnswerContext.Provider
value={{
doc: doc,
questionIndex: questionIndex,
multiple: props.multiple,
randomizeOptions: randomizeOptions,
onChange: onOptionChange
}}
>
<div className={styles.optionsBlock}>{optionsBlock}</div>
</ChoiceAnswerContext.Provider>
{afterBlock}
</div>
</div>
);
}) as React.FC<ChoiceAnswerProps> & ChoiceAnswerSubComponents;

ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => {
const { doc, questionIndex, multiple, randomizeOptions, onChange } =
React.useContext(ChoiceAnswerContext);

const optionId = React.useId();

const isChecked = !!doc?.choices[questionIndex]?.includes(optionIndex);

const optionOrder =
randomizeOptions && doc?.optionOrders[questionIndex] !== undefined
? doc.optionOrders[questionIndex][optionIndex]
: optionIndex;

return (
<div
key={optionId}
className={clsx(styles.choiceAnswerOptionContainer)}
style={{
order: optionOrder
}}
>
<div className={styles.checkboxContainer}>
<input
type={multiple ? 'checkbox' : 'radio'}
id={optionId}
name={multiple ? optionId : `${doc?.id}-q${questionIndex}`}
value={optionId}
onChange={(e) => onChange(optionIndex, e.target.checked)}
checked={isChecked}
className={styles.checkbox}
disabled={!doc?.canUpdateAnswer}
tabIndex={optionOrder}
/>
</div>
<label htmlFor={optionId}>{children}</label>
{!multiple && (
<div className={styles.btnDeleteAnswerContainer}>
<Button
color="danger"
icon={mdiTrashCanOutline}
iconSide="left"
size={0.7}
onClick={() => onChange(optionIndex, false)}
className={clsx(styles.btnDeleteAnswer, {
[styles.visible]: doc?.canUpdateAnswer && isChecked
})}
/>
</div>
)}
</div>
);
});

ChoiceAnswer.Before = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
ChoiceAnswer.Options = ({ children }: { children: React.ReactNode }) => {
return <div className={clsx(styles.optionsContainer)}>{children}</div>;
};
ChoiceAnswer.After = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};

export default ChoiceAnswer;
Loading
Loading