Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1e84109
move ChoiceAnswer model to folder
lebalz Apr 6, 2026
519535a
add some basic tests
lebalz Apr 8, 2026
455f6b3
Merge branch 'main' into refactor-attempt/choice-answer
lebalz Apr 21, 2026
7ee0664
rewrite remark-transformer
lebalz Apr 21, 2026
cae081d
Add new interface Props to Quiz component
lebalz Apr 21, 2026
221e97d
Refactor ChoiceAnswer to Quiz/ChoiceAnswer structure and update relat…
lebalz Apr 28, 2026
f5ed8fe
fix typo
lebalz Apr 28, 2026
db52758
some progress
lebalz May 25, 2026
f0507f1
remove redundant inQuiz prop
lebalz May 25, 2026
2b8924a
setup shuffling
lebalz May 26, 2026
2ae412a
finalize single ChoiceAnswers
lebalz May 27, 2026
f52e23a
refactor: simplify correctness handling in FeedbackBadge and iAssessable
lebalz May 27, 2026
46b4bc0
refactor: update imports to use Assessable namespace for ChoiceAnswer…
lebalz May 27, 2026
a9e52cb
refactor: remove unused assessment logic and streamline scoring inter…
lebalz May 27, 2026
bf666d1
rename ChoiceAnswer to Assessable
lebalz May 27, 2026
a728ea3
first draft of TrueFalseAnswer
lebalz May 27, 2026
ebfe072
refactor: integrate QuestionCard component and streamline ChoiceAnswe…
lebalz May 28, 2026
286c467
ensure TrueFalseAnswer has its own model type
lebalz May 28, 2026
151f02c
setup quiz model
lebalz May 29, 2026
7a311cf
finalize quiz
lebalz May 31, 2026
b21b350
cleanup visual glitches
lebalz May 31, 2026
2899593
refactor feedback
lebalz May 31, 2026
b9fc366
make remark plugin configurable
lebalz May 31, 2026
ffa5ccc
rename to assessable components
lebalz May 31, 2026
7aa1c56
debounce only by 50 ms
lebalz May 31, 2026
3ee8e90
add sync state
lebalz May 31, 2026
1f48674
indicate unanswered questions
lebalz May 31, 2026
dee3c42
make questions taskable
lebalz May 31, 2026
86d8af7
add scroll to animations in taskable overview
lebalz May 31, 2026
7b2edc3
remove browser check
lebalz May 31, 2026
ec47faa
fix loading document roots issues
lebalz Jun 1, 2026
9eb5570
streamline color hints
lebalz Jun 1, 2026
976fddf
ignore comments for ChoiceAnswer.Options
lebalz Jun 1, 2026
a618a9d
use standard css variables
lebalz Jun 1, 2026
b25a80c
Merge branch 'feature/choice-answer' into refactor-attempt/choice-answer
lebalz Jun 1, 2026
94475f5
cleanup comments
lebalz Jun 2, 2026
3a09b8c
fix
lebalz Jun 2, 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
4 changes: 0 additions & 4 deletions packages/tdev/page-index/remark-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,6 @@ const scheduleExportDb = debounce(
{ edges: ['trailing'] }
);

/**
* This plugin transforms inline code and code blocks in MDX files to use
* custom MDX components by converting the code content into attributes.
*/
const remarkPlugin: Plugin<PluginOptions[], Root> = function plugin(
options = { components: [], persistedCodeType: () => 'code' }
): Transformer<Root> {
Expand Down
23 changes: 2 additions & 21 deletions packages/tdev/page-read-check/PageReadCheck/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import clsx from 'clsx';
import { mdiFlashTriangle } from '@mdi/js';
import Icon from '@mdi/react';
import PageReadChecker from '../model';
import { useScrollTo } from '@tdev-hooks/useScrollTo';

interface Props extends MetaInit {
id: string;
Expand All @@ -28,11 +29,10 @@ const defaultDisabledReason = (doc: PageReadChecker) =>
const PageReadCheck = observer((props: Props) => {
const { text = defaultText, disabledReason = defaultDisabledReason } = props;
const [meta] = React.useState(new ModelMeta(props));
const ref = React.useRef<HTMLDivElement>(null);

const viewStore = useStore('viewStore');
const doc = useFirstMainDocument(props.id, meta);
const [animate, setAnimate] = React.useState(false);
const [ref, animate] = useScrollTo(doc);

React.useEffect(() => {
if (!viewStore.isPageVisible || !doc) {
Expand All @@ -49,25 +49,6 @@ const PageReadCheck = observer((props: Props) => {
};
}, [doc, doc?.read, viewStore.isPageVisible, props.continueAfterUnlock]);

React.useEffect(() => {
if (ref.current && doc?.scrollTo) {
ref.current.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' });
doc.setScrollTo(false);
setAnimate(true);
}
}, [ref, doc?.scrollTo]);

React.useEffect(() => {
if (animate) {
const timeout = setTimeout(() => {
setAnimate(false);
}, 2000);
return () => {
clearTimeout(timeout);
};
}
}, [animate]);

if (!doc) {
return null;
}
Expand Down
1 change: 1 addition & 0 deletions packages/tdev/page-read-check/model/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const createModel: Factory = (data, store) => {
};

class PageReadChecker extends iDocument<'page_read_check'> implements iTaskableDocument<'page_read_check'> {
readonly hideFromOverview = false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a default value — implementing models, e.g. ChoiceAnswer can use this flag to hide themselves from the Taskable Overview when inside a quiz.

@observable accessor readTime: number = 0;
@observable accessor read: boolean = false;
@observable accessor scrollTo: boolean = false;
Expand Down
54 changes: 40 additions & 14 deletions src/api/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,10 @@ 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';
import type ChoiceAnswer from '@tdev-models/documents/Assessable/ChoiceAnswer';
import type TrueFalseAnswer from '@tdev-models/documents/Assessable/TrueFalseAnswer';
import type Quiz from '@tdev-models/documents/Assessable/Quiz';
import iAssessable from '@tdev-models/documents/Assessable/iAssessable';

export enum Access {
RO_DocumentRoot = 'RO_DocumentRoot',
Expand All @@ -46,11 +45,22 @@ export interface StringData {
text: string;
}

export interface ChoiceAnswerData {
choices: ChoiceAnswerChoices;
optionOrders: ChoiceAnswerOptionOrders;
questionOrder: ChoiceAnswerQuestionOrder | null;
interface AssessableData {
assessed: boolean;
qid?: string;
Comment on lines +48 to +50

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic payload for all answer types which can be assessed

}

export interface TrueFalseAnswerData extends AssessableData {
value: boolean | null;
}

export interface ChoiceAnswerData extends AssessableData {
choices: number[];
optionsOrder: number[];
}

export interface QuizData extends AssessableData {
questionOrder: number[];
}

export interface QuillV2Data {
Expand Down Expand Up @@ -122,11 +132,17 @@ export interface ViewStoreTypeMapping {
export type ViewStoreType = keyof ViewStoreTypeMapping;
export type ViewStore = ViewStoreTypeMapping[ViewStoreType];

export interface AssessableDataMapping {
['true_false_answer']: TrueFalseAnswerData;
['choice_answer']: ChoiceAnswerData;
['quiz']: QuizData;
}

export interface ContainerTypeDataMapping {
['_container_placeholder_']: { name: string }; // placeholder to avoid empty interface error
}

export interface TaskableDocumentMapping {
export interface TaskableDocumentMapping extends AssessableDataMapping {
['task_state']: TaskStateData;
['progress_state']: ProgressStateData;
}
Expand All @@ -136,7 +152,6 @@ 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 All @@ -149,6 +164,7 @@ export interface TypeDataMapping extends TaskableDocumentMapping, ContainerTypeD
}
export type ContainerType = keyof ContainerTypeDataMapping;
export type TaskableType = keyof TaskableDocumentMapping;
export type AssessableType = keyof AssessableDataMapping;

type KeysWithCode<T> = {
[K in keyof T]: 'code' extends keyof T[K] ? K : never;
Expand All @@ -160,20 +176,29 @@ export interface ContainerTypeModelMapping {
['_container_placeholder_']: iDocumentContainer<ContainerType>; // placeholder to avoid empty interface error
}

export interface TaskableTypeModelMapping {
export interface AssessableTypeModelMapping {
['true_false_answer']: TrueFalseAnswer;
['choice_answer']: ChoiceAnswer;
['quiz']: Quiz;
}
// enforce all AssessableTypeModels to extend iAssessable:
type EnsureAllAssessable<T extends { [K in keyof T]: iAssessable<any> }> = T;
null as unknown as EnsureAllAssessable<AssessableTypeModelMapping>;

export interface TaskableTypeModelMapping extends AssessableTypeModelMapping {
['task_state']: TaskState;
['progress_state']: ProgressState;
}
// enforce all TaskableTypeMpdels to extend iTaskableDocument:
type EnsureAllTaskable<T extends { [K in keyof T]: iTaskableDocument<any> }> = T;
null as unknown as EnsureAllTaskable<TaskableTypeModelMapping>;

export interface TypeModelMapping extends TaskableTypeModelMapping, ContainerTypeModelMapping {
export interface TypeModelMapping
extends TaskableTypeModelMapping, ContainerTypeModelMapping, AssessableTypeModelMapping {
['code']: Code;
// TODO: rename to `code_version`?
['script_version']: ScriptVersion;
['string']: String;
['choice_answer']: ChoiceAnswer;
['quill_v2']: QuillV2;
['solution']: Solution;
['dir']: Directory;
Expand All @@ -192,6 +217,7 @@ export interface TypeModelMapping extends TaskableTypeModelMapping, ContainerTyp

export type ContainerModelType = ContainerTypeModelMapping[ContainerType];
export type TaskableModelType = TaskableTypeModelMapping[TaskableType];
export type AssessableModelType = AssessableTypeModelMapping[AssessableType];

export type DocumentType = keyof TypeModelMapping;
export type DocumentModelType = TypeModelMapping[DocumentType];
Expand Down
83 changes: 83 additions & 0 deletions src/components/documents/Assessable/ChoiceAnswer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { observer } from 'mobx-react-lite';
import React from 'react';
import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType';
import Loader from '@tdev-components/Loader';
import useIsBrowser from '@docusaurus/useIsBrowser';
import { useDocumentRootId } from '@tdev-hooks/useContextDocumentRootId';
import { useFirstDocumentBy } from '@tdev-hooks/useFirstDocumentBy';
import { DocContext } from '@tdev-components/documents/DocumentContext';
import { AssessableComponentProps } from '@tdev-models/documents/Assessable/AssessableMeta';
import { type default as ChoiceAnswerModel, ModelMeta } from '@tdev-models/documents/Assessable/ChoiceAnswer';
import Options from '../Inputs/Options';
import Option, { Props as OptionProps } from '../Inputs/Option';
import QuestionCard from '../QuestionCard';
import { action } from 'mobx';
import { useDocument } from '@tdev-hooks/useContextDocument';

interface SharedProps extends AssessableComponentProps<'choice_answer'> {
multiple?: boolean;
randomizeOptions?: boolean;
optionsCount: number;
}

export interface StandaloneProps extends SharedProps {
id: string;
qid: never;
}

export interface InQuizProps extends SharedProps {
qid: string;
}

export type ChoiceAnswerProps = StandaloneProps | InQuizProps;

interface ThinWrapperProps {
children: React.ReactNode;
}

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

const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
const [meta] = React.useState(new ModelMeta(props));
const docRootId = useDocumentRootId(props.id);

const doc = useFirstDocumentBy(docRootId, meta, props.qid);
const isBrowser = useIsBrowser();

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

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

return (
<QuestionCard doc={doc}>
<DocContext.Provider value={doc}>{props.children}</DocContext.Provider>
</QuestionCard>
);
}) as React.FC<ChoiceAnswerProps> & ChoiceAnswerSubComponents;
Comment on lines +59 to +63

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the refactor, this is all that is needed for the Choice Answer :)


ChoiceAnswer.Options = Options;

const onUpdateSelection = action((doc: ChoiceAnswerModel, optionIndex: number, checked: boolean) => {
doc.updateSelection(optionIndex, checked, doc.multiple);
});
ChoiceAnswer.Option = observer((props: Omit<OptionProps<'choice_answer'>, 'onChange'>) => {
const doc = useDocument<'choice_answer'>();
return (
<Option
{...props}
onChange={onUpdateSelection}
optionOrder={doc.optionsDisplayOrder(props.optionIndex)}
isChecked={doc.choices.has(props.optionIndex)}
multiple={doc.multiple}
/>
);
});

export default ChoiceAnswer;
61 changes: 61 additions & 0 deletions src/components/documents/Assessable/Feedback/QuestionScore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Icon from '@mdi/react';
import { observer } from 'mobx-react-lite';
import styles from './styles.module.scss';
import React from 'react';
import { QuestionScoringHint } from '../Hints';
import useIsMobileView from '@tdev-hooks/useIsMobileView';
import { Correctness, CorrectnessColors } from '@tdev-models/documents/Assessable/iAssessable';
import { mdiCheckCircleOutline, mdiCloseCircleOutline, mdiProgressCheck, mdiProgressQuestion } from '@mdi/js';
import type { AssessableType, AssessableTypeModelMapping } from '@tdev-api/document';
import Badge from '@tdev-components/shared/Badge';
import { useScrollTo } from '@tdev-hooks/useScrollTo';
import clsx from 'clsx';

interface FeedbackBadgeProps<T extends AssessableType> {
doc: AssessableTypeModelMapping[T];
}

const ICONS_BY_CORRECTNESS: Record<Correctness, string> = {
[Correctness.Correct]: mdiCheckCircleOutline,
[Correctness.Incorrect]: mdiCloseCircleOutline,
[Correctness.PartiallyCorrect]: mdiProgressCheck,
[Correctness.NA]: mdiProgressQuestion
};

export const QuestionScore = observer(<T extends AssessableType>(props: FeedbackBadgeProps<T>) => {
const { doc } = props;
const isMobileView = useIsMobileView();
const [ref, animate] = useScrollTo(doc);

if (!doc) {
return null;
}
const maxPoints = doc.assessment?.scoring?.maxPoints ?? doc.maxHits;
const maxPointsText = isMobileView ? `${maxPoints}p` : `${maxPoints} Punkt${maxPoints !== 1 ? 'e' : ''}`;

return (
<div className={clsx(styles.feedbackBadge, animate && styles.animate)} ref={ref}>
{doc.assessment?.scoring && (
<QuestionScoringHint
doc={doc}
trigger={
<span>
<Badge type="secondary" className={styles.pointsBadge}>
{doc.isAssessed && `${doc.assessment.scoring.pointsAchieved}/`}
{maxPointsText}
</Badge>
</span>
}
/>
)}
{!doc.assessment?.scoring && <QuestionScoringHint doc={doc} />}
{doc.isAssessed && (
<Icon
path={ICONS_BY_CORRECTNESS[doc.correctness]}
color={CorrectnessColors[doc.correctness]}
size={1}
/>
)}
</div>
);
});
37 changes: 37 additions & 0 deletions src/components/documents/Assessable/Feedback/QuizScore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { observer } from 'mobx-react-lite';
import styles from './styles.module.scss';
import React from 'react';
import useIsMobileView from '@tdev-hooks/useIsMobileView';
import Quiz from '@tdev-models/documents/Assessable/Quiz';
import Badge from '@tdev-components/shared/Badge';

interface QuizScoreProps {
doc: Quiz;
}

export const QuizScore = observer(({ doc }: QuizScoreProps) => {
const isMobileView = useIsMobileView();

if (!doc || doc.maxHits === 0) {
// No scoring, so we don't show anything.
return null;
}
const maxPoints = doc.assessment?.scoring?.maxPoints ?? doc.maxHits;
const maxPointsText = isMobileView ? `${maxPoints}p` : `${maxPoints} Punkt${maxPoints !== 1 ? 'e' : ''}`;

if (!doc.isAssessed) {
return (
<Badge type="primary" className={styles.pointsBadge}>
{isMobileView ? `Max.: ${maxPointsText}` : `Zu erreichen: ${maxPointsText}`}
</Badge>
);
}
const totalPointsAchieved = doc.assessment?.scoring?.pointsAchieved ?? 'N/A';
return (
<Badge type="primary" className={styles.pointsBadge}>
{isMobileView
? `${totalPointsAchieved}/${maxPointsText}`
: `Ergebnis: ${totalPointsAchieved}/${maxPointsText}`}
</Badge>
);
});
Loading
Loading