From 1a193aafe5d294292898303eb97ce7e14f764ec8 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 27 Jan 2026 09:06:43 +0100 Subject: [PATCH 01/91] Start documenting initial idea. --- .../answer/choice-answer/index.mdx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx new file mode 100644 index 000000000..51aa74ad6 --- /dev/null +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -0,0 +1,110 @@ +--- +page_id: 08ac6803-b890-4608-9d4e-28f334addfb0 +tags: + - persistable +--- +import String from '@tdev-components/documents/String'; +import PermissionsPanel from "@tdev-components/PermissionsPanel" +import BrowserWindow from '@tdev-components/BrowserWindow'; + +# Choice Answer +Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. + +## Standalone-Fragen +Einfache Single- und Multiple-Choice-Fragen: + +```html + + Wir gilt als Erfinder des World Wide Web (WWW)? + + Steve Jobs + Tim Berners-Lee + Ada Lovelace + Alain Berset + Charles Bartowski + +``` + +Mithilfe der IDs können die Antwortmöglichkeiten eindeutig identifiziert werden. Dies erlaubt es, die Reihenfolge der Optionen zu ändern und z.B. Tippfehler zu korrigieren, ohne dass die Korrektheit der Antworten verloren geht. Die IDs sind **frei wählbar**: Es können z.B. UUIDs, laufende Nummern oder semantische Schlüssel verwendet werden. + +```html + + Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + + 2 ist die einzige gerade Primzahl. + Jede Primzahl ist ungerade. + 1 ist eine Primzahl. + 5 ist eine Primzahl. + Primzahlen sind nur durch 1 und sich selbst teilbar. + +``` + +Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: + +```html + + Die Erde ist flach. + +``` + +Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. Zudem dürfen bei SC-Aufgaben auch mehrere Optionen korrekt sein. Wird eine davon ausgewählt, gilt die Antwort als richtig: + +```html + + Welche der folgenden Programmiersprachen sind statisch typisiert? **Hinweis:** Es kann mehr als eine Antwort korrekt sein. + + TypeScript + Python + JavaScript + Java + Ruby + +``` + +## Fragegruppen +Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Fragen zusammengefasst. Dies kann mit der `ChoiceAnswer.Group`-Komponente erreicht werden: + +```html + + + In welchem Jahr war 2024? + + 1965 + 1983 + 1991 + 2000 + 2024 + + + + HTML ist eine Programmiersprache. + + + + Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? + + SMTP + FTP + IMAP + HTTP + + +``` + +## TODO +- Bei MC-Aufgaben sind Teilpunktzahlen möglich. + - TODO TODO: Standalone MC-Fragen müssten demnach auch nicht nur mit richtig und falsch, sondern mit "teilweise richtig" bewertet werden? +- Mehrere ChoiceAnswers können in einer ChoiceAnswerGroup zusammengefasst werden. +- Eine ChoiceAnswerGroup kann auf ein Karussell reduziert werden, um Platz zu sparen. +- Bei einer ChoiceAnswerGroup kann eine Gesamtpunktzahl definiert werden, die auf die einzelnen ChoiceAnswers aufgeteilt wird. +- Die Wertung der Antworten kann angepasst werden – es sind z.B. auch Negativpunkte möglich. Es kann zudem eingestellt werden, ob die Gruppe insgesamt weniger als 0 Punkte ergeben darf. +- Der Korrekturknopf einer ChoiceAnswerGroup kann versteckt oder mit einer Permission geschützt werden. +- Bei einer ChoiceAnswerGroup kann eingestellt werden, ob die Fragen in der vorgegebenen Reihenfolge oder zufällig angezeigt werden sollen. +- TODO TODO: + - Korrektur: Einzeln oder nur Punkte? + - Konzept eines "Durchgangs" (Versuch)? Versuch verwerfen und neu starten nach Korrektur? + - Soll eine CA / CA.Group automatische eine Aufgabe sein? Soll sie automatisch eine Checkbox erhalten? Soll diese automatisch aktiviert sein, wenn das Quiz fertig gelöst ist? Soll sie nur aktiviert sein, wenn alles korrekt gelöst wurde? Soll das alles eine Option sein? + +## Future Work +- Statt die korrekten Antworten direkt in der Komponente zu markieren, soll bei einer ChoiceAnswerGroup auch angegeben werden können, dass die Lösung extern abgespeichert ist. In diesem Fall verfügt die Gruppe über ein Upload-Feld, über welches das entsprechende Lösungs-File hochgeladen werden kann. Die Lösungen werden nach erfolgreichem Upload im LocalStore gespeichert, damit z.B. bei Prüfungen mehrere Schüler:innen korrigiert werden können. Für Admins steht zudem ein Download-Button bereit, mit dem sie ein Template für die Lösungen einer spezifischen Gruppe herunterladen können. +- Allgemeine Überlegung: Im Sinne einer Autokorrektur für Prüfungen soll die `ChoiceAnswer.Group` eine Funktion anbieten, die ein Lösungsdokument (z.B. als Teil eines Lösungsdokuments für die gesamte Prüfung) entgegennimmt und als Antwort eine Punktzahl und z.B. einen Report in Form `4 richtig | 1 falsch | 0 nicht beantwortet` zurückgibt. Dies könnte dann als Korrektur für diese Aufgabe in Korrektur-Document des entsprechenden Schülers eingetragen werden (während z.B. bei Textaufgaben eine manuelle Feedback- und Punkteeingabe durch die Lehrperson erfolgt). From d983bccf2c56e7bd94528d5473c51fcccf18cc82 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 27 Jan 2026 09:07:30 +0100 Subject: [PATCH 02/91] Remove unnecessary import. --- .../persistable-documents/answer/choice-answer/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 51aa74ad6..1ef66ff4e 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -3,7 +3,7 @@ page_id: 08ac6803-b890-4608-9d4e-28f334addfb0 tags: - persistable --- -import String from '@tdev-components/documents/String'; + import PermissionsPanel from "@tdev-components/PermissionsPanel" import BrowserWindow from '@tdev-components/BrowserWindow'; From 4855a91ebe3e54c904483ed51b500269b9152643 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 27 Jan 2026 11:00:05 +0100 Subject: [PATCH 03/91] Rework syntax. --- .../answer/choice-answer/index.mdx | 88 +++++++++++-------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 1ef66ff4e..cd2f26e61 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -13,51 +13,51 @@ Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeig ## Standalone-Fragen Einfache Single- und Multiple-Choice-Fragen: -```html - +```md + Wir gilt als Erfinder des World Wide Web (WWW)? - - Steve Jobs - Tim Berners-Lee - Ada Lovelace - Alain Berset - Charles Bartowski + + 1. Steve Jobs + 2. Tim Berners-Lee + 3. Ada Lovelace + 4. Alain Berset + 5. Charles Bartowski ``` Mithilfe der IDs können die Antwortmöglichkeiten eindeutig identifiziert werden. Dies erlaubt es, die Reihenfolge der Optionen zu ändern und z.B. Tippfehler zu korrigieren, ohne dass die Korrektheit der Antworten verloren geht. Die IDs sind **frei wählbar**: Es können z.B. UUIDs, laufende Nummern oder semantische Schlüssel verwendet werden. -```html - +```md + Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. - 2 ist die einzige gerade Primzahl. - Jede Primzahl ist ungerade. - 1 ist eine Primzahl. - 5 ist eine Primzahl. - Primzahlen sind nur durch 1 und sich selbst teilbar. + 1. 2 ist die einzige gerade Primzahl. + 2. Jede Primzahl ist ungerade. + 3. 1 ist eine Primzahl. + 4. 5 ist eine Primzahl. + 5. Primzahlen sind nur durch 1 und sich selbst teilbar. ``` Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: -```html - +```md + Die Erde ist flach. ``` Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. Zudem dürfen bei SC-Aufgaben auch mehrere Optionen korrekt sein. Wird eine davon ausgewählt, gilt die Antwort als richtig: -```html - +```md + Welche der folgenden Programmiersprachen sind statisch typisiert? **Hinweis:** Es kann mehr als eine Antwort korrekt sein. - TypeScript - Python - JavaScript - Java - Ruby + 1. TypeScript + 2. Python + 3. JavaScript + 4. Java + 5. Ruby ``` @@ -66,31 +66,47 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag ```html - + In welchem Jahr war 2024? - 1965 - 1983 - 1991 - 2000 - 2024 + 1. 1965 + 2. 1983 + 3. 1991 + 4. 2000 + 5. 2024 - + HTML ist eine Programmiersprache. - + Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? - SMTP - FTP - IMAP - HTTP + 1. SMTP + 2. FTP + 3. IMAP + 4. HTTP ``` +## Eigenschaften +### ChoiceAnswer +| Eigenschaft | Typ | Beschreibung | +|------------------|-------------------|---------------------------------------------------| +| `correct` | `number[]` | Array mit den IDs der Indizes der korrekten Antwortmöglichkeiten. | +| `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | +| `randomize` | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | + +### ChoiceAnswer.Group +| Eigenschaft | Typ | Beschreibung | +|------------------|-------------------|---------------------------------------------------| +| `randomize` | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | +| `randomizeOptions` | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge dargestellt (analog zu `ChoiceAnswer.randomize` für einzelne Fragen). | +| `carrousel` | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | +| `grading` | Object | **TODO.** Objekt zur Anpassung der Bewertungslogik. Siehe unten. | + ## TODO - Bei MC-Aufgaben sind Teilpunktzahlen möglich. - TODO TODO: Standalone MC-Fragen müssten demnach auch nicht nur mit richtig und falsch, sondern mit "teilweise richtig" bewertet werden? From 3a8868a9ac4d8393a956ec2710f16cea5e4e2707 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 27 Jan 2026 12:00:10 +0100 Subject: [PATCH 04/91] Implement basic radio buttons. --- .../documents/ChoiceAnswer/index.tsx | 43 ++++++++++++++++ .../documents/ProgressState/index.tsx | 51 +------------------ src/components/util/domHelpers.ts | 49 ++++++++++++++++++ .../answer/choice-answer/index.mdx | 15 +++++- 4 files changed, 108 insertions(+), 50 deletions(-) create mode 100644 src/components/documents/ChoiceAnswer/index.tsx create mode 100644 src/components/util/domHelpers.ts diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx new file mode 100644 index 000000000..a21c40fbb --- /dev/null +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -0,0 +1,43 @@ +import { extractListItems } from '@tdev-components/util/domHelpers'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +interface Props { + children: React.ReactElement[]; + id: string; +} + +const createInputOptions = (optionsList: React.ReactNode[], id: string): React.ReactNode[] => { + return optionsList.map((option, index) => { + const optionId = `${id}-option-${index}`; + return ( +
+ + +
+ ); + }); +}; + +const ChoiceAnswer = observer(({ children, id }: Props) => { + const optionsLists = children.filter((child) => !!child && (child.type === 'ol' || child.type === 'ul')); + if (optionsLists.length !== 1) { + throw new Error( + 'ChoiceAnswer component requires exactly one ordered or unordered list (options) as a child.' + ); + } + + const beforeOptionsList = children.slice(0, children.indexOf(optionsLists[0])); + const afterOptionsList = children.slice(children.indexOf(optionsLists[0]) + 1); + const optionsList: React.ReactNode[] = extractListItems(optionsLists[0]) || []; + + return ( +
+ {beforeOptionsList} + {createInputOptions(optionsList, id)} + {afterOptionsList} +
+ ); +}); + +export default ChoiceAnswer; diff --git a/src/components/documents/ProgressState/index.tsx b/src/components/documents/ProgressState/index.tsx index babbd11b1..defbde16e 100644 --- a/src/components/documents/ProgressState/index.tsx +++ b/src/components/documents/ProgressState/index.tsx @@ -8,6 +8,7 @@ import Item from './Item'; import { useStore } from '@tdev-hooks/useStore'; import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; +import { extractListItems } from '@tdev-components/util/domHelpers'; interface Props extends MetaInit { id: string; float?: 'left' | 'right'; @@ -15,59 +16,11 @@ interface Props extends MetaInit { labels?: React.ReactNode[]; } -const useExtractedChildren = (children: React.ReactElement): React.ReactNode[] | null => { - const liContent = React.useMemo(() => { - if (!children) { - return null; - } - /** - * Extracts the children of the first
    element. - *
      - *
    1. Item 1
    2. - *
    3. Item 2
    4. - *
    - * Is represented as: - * ```js - * { - * type: 'ol', - * props: { - * children: [ - * { - * type: 'li', - * props: { children: 'Item 1' }, - * }, - * { - * type: 'li', - * props: { children: 'Item 2' }, - * }, - * ] - * } - * } - * ``` - * Use the `children.props.children` to access the nested `
  1. ` elements, but don't enforce - * that the root element is an `
      `, as it might be a custom component that renders an `
        ` - * internally. Like that, e.g. `
          ` is supported as well (where Docusaurus uses an `MDXUl` Component...). - */ - const nestedChildren = (children.props as any)?.children; - if (Array.isArray(nestedChildren)) { - return nestedChildren - .filter((c: any) => typeof c === 'object' && c !== null && c.props?.children) - .map((c: any) => { - return c.props.children as React.ReactNode; - }); - } - throw new Error( - `ProgressState must have an
            as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}` - ); - }, [children]); - return liContent; -}; - const ProgressState = observer((props: Props) => { const [meta] = React.useState(new ModelMeta(props)); const pageStore = useStore('pageStore'); const doc = useFirstMainDocument(props.id, meta); - const children = useExtractedChildren(props.children as React.ReactElement); + const children = extractListItems(props.children as React.ReactElement); React.useEffect(() => { doc?.setTotalSteps(children?.length || 0); }, [doc, children?.length]); diff --git a/src/components/util/domHelpers.ts b/src/components/util/domHelpers.ts new file mode 100644 index 000000000..95c3d9aad --- /dev/null +++ b/src/components/util/domHelpers.ts @@ -0,0 +1,49 @@ +import React from "react"; + +export const extractListItems = (children: React.ReactElement): React.ReactNode[] | null => { + const liContent = React.useMemo(() => { + if (!children) { + return null; + } + /** + * Extracts the children of the first
              element. + *
                + *
              1. Item 1
              2. + *
              3. Item 2
              4. + *
              + * Is represented as: + * ```js + * { + * type: 'ol', + * props: { + * children: [ + * { + * type: 'li', + * props: { children: 'Item 1' }, + * }, + * { + * type: 'li', + * props: { children: 'Item 2' }, + * }, + * ] + * } + * } + * ``` + * Use the `children.props.children` to access the nested `
            1. ` elements, but don't enforce + * that the root element is an `
                `, as it might be a custom component that renders an `
                  ` + * internally. Like that, e.g. `
                    ` is supported as well (where Docusaurus uses an `MDXUl` Component...). + */ + const nestedChildren = (children.props as any)?.children; + if (Array.isArray(nestedChildren)) { + return nestedChildren + .filter((c: any) => typeof c === 'object' && c !== null && c.props?.children) + .map((c: any) => { + return c.props.children as React.ReactNode; + }); + } + throw new Error( + `ProgressState must have an
                      as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}` + ); + }, [children]); + return liContent; +}; \ No newline at end of file diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index cd2f26e61..cec0b5df7 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -6,6 +6,7 @@ tags: import PermissionsPanel from "@tdev-components/PermissionsPanel" import BrowserWindow from '@tdev-components/BrowserWindow'; +import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer'; # Choice Answer Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. @@ -17,7 +18,7 @@ Einfache Single- und Multiple-Choice-Fragen: Wir gilt als Erfinder des World Wide Web (WWW)? - 1. Steve Jobs + 1. Steve **Jobs** 2. Tim Berners-Lee 3. Ada Lovelace 4. Alain Berset @@ -25,6 +26,18 @@ Einfache Single- und Multiple-Choice-Fragen: ``` + + + Wir gilt als Erfinder des World Wide Web (WWW)? + + 1. Steve **Jobs** + 2. Tim Berners-Lee + 3. Ada Lovelace + 4. Alain Berset + 5. Charles Bartowski + + + Mithilfe der IDs können die Antwortmöglichkeiten eindeutig identifiziert werden. Dies erlaubt es, die Reihenfolge der Optionen zu ändern und z.B. Tippfehler zu korrigieren, ohne dass die Korrektheit der Antworten verloren geht. Die IDs sind **frei wählbar**: Es können z.B. UUIDs, laufende Nummern oder semantische Schlüssel verwendet werden. ```md From e122ed6ae4aaee0e8f6e2cf5dd17a8c7971e7a9e Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 27 Jan 2026 13:28:49 +0100 Subject: [PATCH 05/91] Add support for MC. --- src/components/documents/ChoiceAnswer/index.tsx | 13 +++++++++---- .../answer/choice-answer/index.mdx | 12 ++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index a21c40fbb..cd60a7d6f 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -4,22 +4,27 @@ import React from 'react'; interface Props { children: React.ReactElement[]; + multiple?: boolean; id: string; } -const createInputOptions = (optionsList: React.ReactNode[], id: string): React.ReactNode[] => { +const createInputOptions = ( + optionsList: React.ReactNode[], + multiple: boolean | undefined, + id: string +): React.ReactNode[] => { return optionsList.map((option, index) => { const optionId = `${id}-option-${index}`; return (
                      - +
                      ); }); }; -const ChoiceAnswer = observer(({ children, id }: Props) => { +const ChoiceAnswer = observer(({ children, id, multiple }: Props) => { const optionsLists = children.filter((child) => !!child && (child.type === 'ol' || child.type === 'ul')); if (optionsLists.length !== 1) { throw new Error( @@ -34,7 +39,7 @@ const ChoiceAnswer = observer(({ children, id }: Props) => { return (
                      {beforeOptionsList} - {createInputOptions(optionsList, id)} + {createInputOptions(optionsList, multiple, id)} {afterOptionsList}
                      ); diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index cec0b5df7..7739bd0f0 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -52,6 +52,18 @@ Mithilfe der IDs können die Antwortmöglichkeiten eindeutig identifiziert werde ``` + + + Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + + 1. 2 ist die einzige gerade Primzahl. + 2. Jede Primzahl ist ungerade. + 3. 1 ist eine Primzahl. + 4. 5 ist eine Primzahl. + 5. Primzahlen sind nur durch 1 und sich selbst teilbar. + + + Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: ```md From f8e5c6709b0c67f51efc1e3300d15c266987230f Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 27 Jan 2026 19:55:41 +0100 Subject: [PATCH 06/91] Draft new syntax. --- .../answer/choice-answer/index.mdx | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 7739bd0f0..9e16c258f 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -16,7 +16,7 @@ Einfache Single- und Multiple-Choice-Fragen: ```md - Wir gilt als Erfinder des World Wide Web (WWW)? + > Wir gilt als Erfinder des World Wide Web (WWW)? 1. Steve **Jobs** 2. Tim Berners-Lee @@ -28,7 +28,7 @@ Einfache Single- und Multiple-Choice-Fragen: - Wir gilt als Erfinder des World Wide Web (WWW)? + > Wir gilt als Erfinder des World Wide Web (WWW)? 1. Steve **Jobs** 2. Tim Berners-Lee @@ -38,29 +38,43 @@ Einfache Single- und Multiple-Choice-Fragen: -Mithilfe der IDs können die Antwortmöglichkeiten eindeutig identifiziert werden. Dies erlaubt es, die Reihenfolge der Optionen zu ändern und z.B. Tippfehler zu korrigieren, ohne dass die Korrektheit der Antworten verloren geht. Die IDs sind **frei wählbar**: Es können z.B. UUIDs, laufende Nummern oder semantische Schlüssel verwendet werden. +Kontextueller Text (z.B. die Fragestellung, Hinweise oder sonstige Informationen) können vor oder nach der Liste der Antwortmöglichkeiten angegeben werden. **Wichtig:** Freitext (ohne Admonition) muss in einem Zitat-Block (`>`) stehen. ```md - Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + + :::info[Bewertung] + Die Frage wird nur als richtig bewertet, wenn alle korrekten Antworten (und keine falschen) ausgewählt wurden. + ::: 1. 2 ist die einzige gerade Primzahl. 2. Jede Primzahl ist ungerade. 3. 1 ist eine Primzahl. 4. 5 ist eine Primzahl. 5. Primzahlen sind nur durch 1 und sich selbst teilbar. + + > Gewusst? **Prim**zahlen haben nichts mit **Prim**aten zu tun! + ``` - Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. + + :::info[Bewertung] + Die Frage wird nur als richtig bewertet, wenn alle korrekten Antworten (und keine falschen) ausgewählt wurden. + ::: 1. 2 ist die einzige gerade Primzahl. 2. Jede Primzahl ist ungerade. 3. 1 ist eine Primzahl. 4. 5 ist eine Primzahl. 5. Primzahlen sind nur durch 1 und sich selbst teilbar. + + > Gewusst? **Prim**zahlen haben nichts mit **Prim**aten zu tun! + @@ -87,12 +101,12 @@ Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Rei ``` ## Fragegruppen -Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Fragen zusammengefasst. Dies kann mit der `ChoiceAnswer.Group`-Komponente erreicht werden: +Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: ```html - - - In welchem Jahr war 2024? + + + > In welchem Jahr war 2024? 1. 1965 2. 1983 @@ -101,12 +115,12 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag 5. 2024 - - HTML ist eine Programmiersprache. + + > HTML ist eine Programmiersprache. - - Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? + + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP 2. FTP @@ -124,7 +138,7 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag | `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | | `randomize` | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | -### ChoiceAnswer.Group +### Quiz | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| | `randomize` | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | @@ -135,17 +149,16 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag ## TODO - Bei MC-Aufgaben sind Teilpunktzahlen möglich. - TODO TODO: Standalone MC-Fragen müssten demnach auch nicht nur mit richtig und falsch, sondern mit "teilweise richtig" bewertet werden? -- Mehrere ChoiceAnswers können in einer ChoiceAnswerGroup zusammengefasst werden. -- Eine ChoiceAnswerGroup kann auf ein Karussell reduziert werden, um Platz zu sparen. -- Bei einer ChoiceAnswerGroup kann eine Gesamtpunktzahl definiert werden, die auf die einzelnen ChoiceAnswers aufgeteilt wird. +- Ein Quiz kann auf ein Karussell reduziert werden, um Platz zu sparen. +- Bei einem Quiz kann eine Gesamtpunktzahl definiert werden, die auf die einzelnen ChoiceAnswers aufgeteilt wird. - Die Wertung der Antworten kann angepasst werden – es sind z.B. auch Negativpunkte möglich. Es kann zudem eingestellt werden, ob die Gruppe insgesamt weniger als 0 Punkte ergeben darf. -- Der Korrekturknopf einer ChoiceAnswerGroup kann versteckt oder mit einer Permission geschützt werden. -- Bei einer ChoiceAnswerGroup kann eingestellt werden, ob die Fragen in der vorgegebenen Reihenfolge oder zufällig angezeigt werden sollen. +- Der Korrekturknopf eines Quiz kann versteckt oder mit einer Permission geschützt werden. +- Bei einem Quiz kann eingestellt werden, ob die Fragen in der vorgegebenen Reihenfolge oder zufällig angezeigt werden sollen. - TODO TODO: - Korrektur: Einzeln oder nur Punkte? - Konzept eines "Durchgangs" (Versuch)? Versuch verwerfen und neu starten nach Korrektur? - Soll eine CA / CA.Group automatische eine Aufgabe sein? Soll sie automatisch eine Checkbox erhalten? Soll diese automatisch aktiviert sein, wenn das Quiz fertig gelöst ist? Soll sie nur aktiviert sein, wenn alles korrekt gelöst wurde? Soll das alles eine Option sein? ## Future Work -- Statt die korrekten Antworten direkt in der Komponente zu markieren, soll bei einer ChoiceAnswerGroup auch angegeben werden können, dass die Lösung extern abgespeichert ist. In diesem Fall verfügt die Gruppe über ein Upload-Feld, über welches das entsprechende Lösungs-File hochgeladen werden kann. Die Lösungen werden nach erfolgreichem Upload im LocalStore gespeichert, damit z.B. bei Prüfungen mehrere Schüler:innen korrigiert werden können. Für Admins steht zudem ein Download-Button bereit, mit dem sie ein Template für die Lösungen einer spezifischen Gruppe herunterladen können. -- Allgemeine Überlegung: Im Sinne einer Autokorrektur für Prüfungen soll die `ChoiceAnswer.Group` eine Funktion anbieten, die ein Lösungsdokument (z.B. als Teil eines Lösungsdokuments für die gesamte Prüfung) entgegennimmt und als Antwort eine Punktzahl und z.B. einen Report in Form `4 richtig | 1 falsch | 0 nicht beantwortet` zurückgibt. Dies könnte dann als Korrektur für diese Aufgabe in Korrektur-Document des entsprechenden Schülers eingetragen werden (während z.B. bei Textaufgaben eine manuelle Feedback- und Punkteeingabe durch die Lehrperson erfolgt). +- Statt die korrekten Antworten direkt in der Komponente zu markieren, soll bei einem Quiz auch angegeben werden können, dass die Lösung extern abgespeichert ist. In diesem Fall verfügt die Gruppe über ein Upload-Feld, über welches das entsprechende Lösungs-File hochgeladen werden kann. Die Lösungen werden nach erfolgreichem Upload im LocalStore gespeichert, damit z.B. bei Prüfungen mehrere Schüler:innen korrigiert werden können. Für Admins steht zudem ein Download-Button bereit, mit dem sie ein Template für die Lösungen einer spezifischen Gruppe herunterladen können. +- Allgemeine Überlegung: Im Sinne einer Autokorrektur für Prüfungen soll das `Quiz` eine Funktion anbieten, die ein Lösungsdokument (z.B. als Teil eines Lösungsdokuments für die gesamte Prüfung) entgegennimmt und als Antwort eine Punktzahl und z.B. einen Report in Form `4 richtig | 1 falsch | 0 nicht beantwortet` zurückgibt. Dies könnte dann als Korrektur für diese Aufgabe in Korrektur-Document des entsprechenden Schülers eingetragen werden (während z.B. bei Textaufgaben eine manuelle Feedback- und Punkteeingabe durch die Lehrperson erfolgt). \ No newline at end of file From 79272e87210920536e2718c20750c4991b247643 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 28 Jan 2026 16:32:31 +0100 Subject: [PATCH 07/91] Tweak syntax examples. --- .../answer/choice-answer/index.mdx | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 9e16c258f..7c0f78628 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -12,7 +12,7 @@ import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer'; Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. ## Standalone-Fragen -Einfache Single- und Multiple-Choice-Fragen: +Einfache Single- und Multiple-Choice-Fragen: ```md @@ -20,8 +20,8 @@ Einfache Single- und Multiple-Choice-Fragen: 1. Steve **Jobs** 2. Tim Berners-Lee - 3. Ada Lovelace - 4. Alain Berset + 3. Ada __Lovelace__ + 4. Alain Berset :mdi[cheese] 5. Charles Bartowski ``` @@ -32,14 +32,12 @@ Einfache Single- und Multiple-Choice-Fragen: 1. Steve **Jobs** 2. Tim Berners-Lee - 3. Ada Lovelace - 4. Alain Berset + 3. Ada __Lovelace__ + 4. Alain Berset :mdi[cheese] 5. Charles Bartowski -Kontextueller Text (z.B. die Fragestellung, Hinweise oder sonstige Informationen) können vor oder nach der Liste der Antwortmöglichkeiten angegeben werden. **Wichtig:** Freitext (ohne Admonition) muss in einem Zitat-Block (`>`) stehen. - ```md > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. @@ -54,8 +52,7 @@ Kontextueller Text (z.B. die Fragestellung, Hinweise oder sonstige Informationen 4. 5 ist eine Primzahl. 5. Primzahlen sind nur durch 1 und sich selbst teilbar. - > Gewusst? **Prim**zahlen haben nichts mit **Prim**aten zu tun! - + **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! ``` @@ -73,8 +70,7 @@ Kontextueller Text (z.B. die Fragestellung, Hinweise oder sonstige Informationen 4. 5 ist eine Primzahl. 5. Primzahlen sind nur durch 1 und sich selbst teilbar. - > Gewusst? **Prim**zahlen haben nichts mit **Prim**aten zu tun! - + **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! From 8680d0536dc1a0dc4be5033be392c7def36511ec Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 28 Jan 2026 16:36:44 +0100 Subject: [PATCH 08/91] Work on plugin. --- .../remark-transform-choice-answer/plugin.ts | 68 +++++++++++++++++++ src/siteConfig/markdownPluginConfigs.ts | 6 +- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/plugins/remark-transform-choice-answer/plugin.ts diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts new file mode 100644 index 000000000..c2587f70a --- /dev/null +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -0,0 +1,68 @@ +import { visit } from 'unist-util-visit'; +import type { Plugin } from 'unified'; +import type { Root, BlockContent, DefinitionContent } from 'mdast'; +import type { MdxJsxFlowElement } from 'mdast-util-mdx'; + +enum ChoiceComponentTypes { + ChoiceAnswer = 'ChoiceAnswer', + TrueFalseAnswer = 'TrueFalseAnswer' +} + +type FlowChildren = (BlockContent | DefinitionContent)[]; + +function createWrapper(name: string, children: FlowChildren): MdxJsxFlowElement { + return { + type: 'mdxJsxFlowElement', + name, + attributes: [], + children + }; +} + +const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { + return (tree) => { + visit(tree, 'mdxJsxFlowElement', (node) => { + if ( + !node.name || + !Object.values(ChoiceComponentTypes).includes(node.name as ChoiceComponentTypes) + ) { + return; + } + + const listIndex = node.children.findIndex((child) => child.type === 'list'); + + if (listIndex === -1) { + if (node.name !== ChoiceComponentTypes.TrueFalseAnswer) { + console.warn(`No list found in <${node.name}>. Expected exactly one list child.`); + } + return; + } + + if (node.children.filter((child) => child.type === 'list').length > 1) { + console.warn(`Multiple lists found in <${node.name}>. Only the first one is used.`); + } + + const beforeChildren = node.children.slice(0, listIndex) as FlowChildren; + + const listChild = node.children[listIndex] as BlockContent | DefinitionContent; + + const afterChildren = node.children.slice(listIndex + 1) as FlowChildren; + + const wrappedChildren: FlowChildren = []; + + if (beforeChildren.length > 0) { + wrappedChildren.push(createWrapper(`${node.name}.Before`, beforeChildren)); + } + + wrappedChildren.push(createWrapper(`${node.name}.Options`, [listChild])); + + if (afterChildren.length > 0) { + wrappedChildren.push(createWrapper(`${node.name}.After`, afterChildren)); + } + + node.children = wrappedChildren; + }); + }; +}; + +export default plugin; diff --git a/src/siteConfig/markdownPluginConfigs.ts b/src/siteConfig/markdownPluginConfigs.ts index 2c62430f8..09f2f7082 100644 --- a/src/siteConfig/markdownPluginConfigs.ts +++ b/src/siteConfig/markdownPluginConfigs.ts @@ -18,6 +18,7 @@ import pdfPlugin from '@tdev/remark-pdf/remark-plugin'; import codeAsAttributePlugin from '../plugins/remark-code-as-attribute/plugin'; import commentPlugin from '../plugins/remark-comments/plugin'; import enumerateAnswersPlugin from '../plugins/remark-enumerate-components/plugin'; +import transformChoiceAnswerPlugin from '../plugins/remark-transform-choice-answer/plugin'; export const flexCardsPluginConfig = [ flexCardsPlugin, @@ -153,6 +154,8 @@ export const linkAnnotationPluginConfig = [ } ]; +export const transformChoiceAnswerPluginConfig = transformChoiceAnswerPlugin; + export const rehypeKatexPluginConfig = rehypeKatex; export const recommendedBeforeDefaultRemarkPlugins = [ @@ -175,7 +178,8 @@ export const recommendedRemarkPlugins = [ pagePluginConfig, commentPluginConfig, linkAnnotationPluginConfig, - codeAsAttributePluginConfig + codeAsAttributePluginConfig, + transformChoiceAnswerPluginConfig ]; export const recommendedRehypePlugins = [rehypeKatexPluginConfig]; From 6a63d4c0e713c9ec73bac46f8af0539d686ade0e Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 28 Jan 2026 16:43:34 +0100 Subject: [PATCH 09/91] Cleanup. --- .../documents/ChoiceAnswer/index.tsx | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index cd60a7d6f..e5325b108 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -1,19 +1,22 @@ -import { extractListItems } from '@tdev-components/util/domHelpers'; +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; import { observer } from 'mobx-react-lite'; import React from 'react'; -interface Props { - children: React.ReactElement[]; - multiple?: boolean; +interface ChoiceAnswerProps { id: string; + index?: number; + multiple?: boolean; + readonly?: boolean; + children: React.ReactNode; } const createInputOptions = ( - optionsList: React.ReactNode[], + optionsList: React.ReactNode[] | undefined, multiple: boolean | undefined, id: string ): React.ReactNode[] => { - return optionsList.map((option, index) => { + return (optionsList || []).map((option, index) => { const optionId = `${id}-option-${index}`; return (
                      @@ -24,25 +27,38 @@ const createInputOptions = ( }); }; -const ChoiceAnswer = observer(({ children, id, multiple }: Props) => { - const optionsLists = children.filter((child) => !!child && (child.type === 'ol' || child.type === 'ul')); - if (optionsLists.length !== 1) { - throw new Error( - 'ChoiceAnswer component requires exactly one ordered or unordered list (options) as a child.' - ); - } - - const beforeOptionsList = children.slice(0, children.indexOf(optionsLists[0])); - const afterOptionsList = children.slice(children.indexOf(optionsLists[0]) + 1); - const optionsList: React.ReactNode[] = extractListItems(optionsLists[0]) || []; - - return ( -
                      - {beforeOptionsList} - {createInputOptions(optionsList, multiple, id)} - {afterOptionsList} -
                      +type ChoiceAnswerSubComponents = { + Before: React.FC<{ children: React.ReactNode }>; + Options: React.FC<{ children: React.ReactNode }>; + After: React.FC<{ children: React.ReactNode }>; +}; + +const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { + const [meta] = React.useState(new ModelMeta(props)); + const doc = useFirstMainDocument(props.id, meta); + + 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 + ); + + return
                      {props.children}
                      ; +}) as React.FC & ChoiceAnswerSubComponents; + +ChoiceAnswer.Before = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; +ChoiceAnswer.Options = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; +ChoiceAnswer.After = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; export default ChoiceAnswer; From ae459acaf361ea14ee8143ba59048c8622c9af44 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 28 Jan 2026 18:08:27 +0100 Subject: [PATCH 10/91] Make MDX plugin not suck. --- .../documents/ChoiceAnswer/index.tsx | 74 ++++++++++++++----- .../documents/ChoiceAnswer/styles.module.scss | 13 ++++ src/models/documents/ChoiceAnswer.ts | 65 ++++++++++++++++ .../remark-transform-choice-answer/plugin.ts | 21 +++++- 4 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 src/components/documents/ChoiceAnswer/styles.module.scss create mode 100644 src/models/documents/ChoiceAnswer.ts diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index e5325b108..df6ba71f1 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -2,6 +2,9 @@ import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; import { observer } from 'mobx-react-lite'; import React from 'react'; +import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; interface ChoiceAnswerProps { id: string; @@ -11,28 +14,34 @@ interface ChoiceAnswerProps { children: React.ReactNode; } -const createInputOptions = ( - optionsList: React.ReactNode[] | undefined, - multiple: boolean | undefined, - id: string -): React.ReactNode[] => { - return (optionsList || []).map((option, index) => { - const optionId = `${id}-option-${index}`; - return ( -
                      - - -
                      - ); - }); -}; +interface ThinWrapperProps { + children: React.ReactNode; +} + +interface OptionProps { + children: React.ReactNode; + index: number; +} type ChoiceAnswerSubComponents = { - Before: React.FC<{ children: React.ReactNode }>; - Options: React.FC<{ children: React.ReactNode }>; - After: React.FC<{ children: React.ReactNode }>; + Before: React.FC; + Options: React.FC; + Option: React.FC; + After: React.FC; }; +const ChoiceAnswerContext = React.createContext({ + id: '', + multiple: false, + readonly: false, + doc: null +} as { + id: string; + multiple?: boolean; + readonly?: boolean; + doc: ChoiceAnswerDocument | null; +}); + const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const [meta] = React.useState(new ModelMeta(props)); const doc = useFirstMainDocument(props.id, meta); @@ -48,9 +57,36 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { (child) => React.isValidElement(child) && child.type === ChoiceAnswer.After ); - return
                      {props.children}
                      ; + return ( +
                      + {beforeBlock} + + {optionsBlock} + + {afterBlock} +
                      + ); }) as React.FC & ChoiceAnswerSubComponents; +ChoiceAnswer.Option = ({ index, children }: OptionProps) => { + const parentProps = React.useContext(ChoiceAnswerContext); + const optionId = `${parentProps.id}-option-${index}`; + + return ( +
                      + + +
                      + ); +}; + ChoiceAnswer.Before = ({ children }: { children: React.ReactNode }) => { return <>{children}; }; diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/styles.module.scss new file mode 100644 index 000000000..c376315aa --- /dev/null +++ b/src/components/documents/ChoiceAnswer/styles.module.scss @@ -0,0 +1,13 @@ +.choiceAnswerOptionContainer { + display: flex; + flex-direction: row; + align-items: center; + + p { + margin: 0; + } + + label { + margin-left: 0.2em; + } +} diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts new file mode 100644 index 000000000..5093f9e59 --- /dev/null +++ b/src/models/documents/ChoiceAnswer.ts @@ -0,0 +1,65 @@ +import { TypeDataMapping, Document as DocumentProps, Access } from '@tdev-api/document'; +import { TypeMeta } from '@tdev-models/DocumentRoot'; +import iDocument, { Source } from '@tdev-models/iDocument'; +import DocumentStore from '@tdev-stores/DocumentStore'; +import { action, computed, observable } from 'mobx'; + +export interface ChoiceAnswerChoices { + [type: number]: number | number[]; +} + +export interface MetaInit { + readonly?: boolean; +} + +export class ModelMeta extends TypeMeta<'choice_answer'> { + readonly type = 'choice_answer'; + readonly readonly?: boolean; + + constructor(props: Partial) { + super('choice_answer', props.readonly ? Access.RO_User : undefined); + this.readonly = props.readonly; + } + + get defaultData(): TypeDataMapping['choice_answer'] { + return { + choices: {} + }; + } +} + +class ChoiceAnswer extends iDocument<'choice_answer'> { + @observable accessor choices: ChoiceAnswerChoices; + + constructor(props: DocumentProps<'choice_answer'>, store: DocumentStore) { + super(props, store); + this.choices = props.data?.choices || {}; + } + + @action + setData(data: TypeDataMapping['choice_answer'], from: Source, updatedAt?: Date): void { + this.choices = data.choices; + if (from === Source.LOCAL) { + this.save(); + } + if (updatedAt) { + this.updatedAt = new Date(updatedAt); + } + } + + get data(): TypeDataMapping['choice_answer'] { + return { + choices: this.choices + }; + } + + @computed + get meta(): ModelMeta { + if (this.root?.type === 'choice_answer') { + return this.root.meta as ModelMeta; + } + return new ModelMeta({}); + } +} + +export default ChoiceAnswer; diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index c2587f70a..552a36386 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -19,6 +19,17 @@ function createWrapper(name: string, children: FlowChildren): MdxJsxFlowElement }; } +const transformOptions = (listChildren: {type: string, children: FlowChildren}[]): MdxJsxFlowElement => { + // TODO: Enumerate + const options = listChildren + .filter((child) => child.type === 'listItem') + .map((child) => { + return createWrapper('ChoiceAnswer.Option', child.children); + }); + + return createWrapper('ChoiceAnswer.Options', options); +} + const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { return (tree) => { visit(tree, 'mdxJsxFlowElement', (node) => { @@ -44,7 +55,7 @@ const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { const beforeChildren = node.children.slice(0, listIndex) as FlowChildren; - const listChild = node.children[listIndex] as BlockContent | DefinitionContent; + const listChild = node.children[listIndex] as {children: FlowChildren}; const afterChildren = node.children.slice(listIndex + 1) as FlowChildren; @@ -54,7 +65,13 @@ const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { wrappedChildren.push(createWrapper(`${node.name}.Before`, beforeChildren)); } - wrappedChildren.push(createWrapper(`${node.name}.Options`, [listChild])); + /* + TODO: + - Wrap each
                    1. individually in ChoiceAnswer.Option + - Enumerate the
                    2. elements during transformation, pass as index prop to ChoiceAnswer.Option + - Get rid of the
                        , put (transformed) children directly into ChoiceAnswer.Options + */ + wrappedChildren.push(transformOptions(listChild.children)); if (afterChildren.length > 0) { wrappedChildren.push(createWrapper(`${node.name}.After`, afterChildren)); From c1800858601b55b54944c9afe1579d0cbcc556bf Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 28 Jan 2026 18:20:51 +0100 Subject: [PATCH 11/91] Enumerate options. --- .../remark-transform-choice-answer/plugin.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index 552a36386..7e8487f8d 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -10,21 +10,24 @@ enum ChoiceComponentTypes { type FlowChildren = (BlockContent | DefinitionContent)[]; -function createWrapper(name: string, children: FlowChildren): MdxJsxFlowElement { +function createWrapper(name: string, children: FlowChildren, attributes: any[] = []): MdxJsxFlowElement { return { type: 'mdxJsxFlowElement', name, - attributes: [], + attributes: attributes.map((attr) => ({ + type: 'mdxJsxAttribute', + name: attr.name, + value: attr.value + })), children }; } const transformOptions = (listChildren: {type: string, children: FlowChildren}[]): MdxJsxFlowElement => { - // TODO: Enumerate const options = listChildren .filter((child) => child.type === 'listItem') - .map((child) => { - return createWrapper('ChoiceAnswer.Option', child.children); + .map((child, index) => { + return createWrapper('ChoiceAnswer.Option', child.children, [{ name: 'index', value: index }]); }); return createWrapper('ChoiceAnswer.Options', options); From ba2d47cccf522e26cdfe182fa3d6672e9135e8b1 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Thu, 29 Jan 2026 09:19:20 +0100 Subject: [PATCH 12/91] Implement saving. --- src/api/document.ts | 7 ++++ .../documents/ChoiceAnswer/index.tsx | 35 ++++++++++++++----- src/models/documents/ChoiceAnswer.ts | 30 ++++++++++++++-- .../remark-transform-choice-answer/plugin.ts | 2 +- src/stores/DocumentStore.ts | 4 +++ 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/api/document.ts b/src/api/document.ts index b38a01558..1f9d65e3e 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -18,6 +18,7 @@ import type DocumentStore from '@tdev-stores/DocumentStore'; import iDocumentContainer from '@tdev-models/iDocumentContainer'; import iViewStore from '@tdev-stores/ViewStores/iViewStore'; import Code from '@tdev-models/documents/Code'; +import ChoiceAnswer, { ChoiceAnswerChoices } from '@tdev-models/documents/ChoiceAnswer'; export enum Access { RO_DocumentRoot = 'RO_DocumentRoot', @@ -40,6 +41,10 @@ export interface StringData { text: string; } +export interface ChoiceAnswerData { + choices: ChoiceAnswerChoices; +} + export interface QuillV2Data { delta: Delta; } @@ -119,6 +124,7 @@ export interface TypeDataMapping extends ContainerTypeDataMapping { // TODO: rename to `code_version`? ['script_version']: ScriptVersionData; ['string']: StringData; + ['choice_answer']: ChoiceAnswerData; ['quill_v2']: QuillV2Data; ['solution']: SolutionData; ['dir']: DirData; @@ -148,6 +154,7 @@ export interface TypeModelMapping extends ContainerTypeModelMapping { // TODO: rename to `code_version`? ['script_version']: ScriptVersion; ['string']: String; + ['choice_answer']: ChoiceAnswer; ['quill_v2']: QuillV2; ['solution']: Solution; ['dir']: Directory; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index df6ba71f1..01d772c2c 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -2,13 +2,12 @@ import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; import { observer } from 'mobx-react-lite'; import React from 'react'; -import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; import clsx from 'clsx'; import styles from './styles.module.scss'; interface ChoiceAnswerProps { id: string; - index?: number; + questionIndex?: number; multiple?: boolean; readonly?: boolean; children: React.ReactNode; @@ -20,7 +19,7 @@ interface ThinWrapperProps { interface OptionProps { children: React.ReactNode; - index: number; + optionIndex: number; } type ChoiceAnswerSubComponents = { @@ -34,17 +33,20 @@ const ChoiceAnswerContext = React.createContext({ id: '', multiple: false, readonly: false, - doc: null + selectedChoices: [], + onChange: () => {} } as { id: string; multiple?: boolean; readonly?: boolean; - doc: ChoiceAnswerDocument | null; + selectedChoices: number[]; + onChange: (optionIndex: number, checked: boolean) => void; }); const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const [meta] = React.useState(new ModelMeta(props)); const doc = useFirstMainDocument(props.id, meta); + const questionIndex = props.questionIndex ?? 0; const childrenArray = React.Children.toArray(props.children); const beforeBlock = childrenArray.find( @@ -57,11 +59,25 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { (child) => React.isValidElement(child) && child.type === ChoiceAnswer.After ); + const onOptionChange = (optionIndex: number, checked: boolean) => { + if (props.multiple) { + doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked); + } else { + doc?.updateSingleChoiceSelection(questionIndex, optionIndex); + } + }; + return (
                        {beforeBlock} {optionsBlock} @@ -70,9 +86,9 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { ); }) as React.FC & ChoiceAnswerSubComponents; -ChoiceAnswer.Option = ({ index, children }: OptionProps) => { +ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { const parentProps = React.useContext(ChoiceAnswerContext); - const optionId = `${parentProps.id}-option-${index}`; + const optionId = `${parentProps.id}-option-${optionIndex}`; return (
                        @@ -81,6 +97,9 @@ ChoiceAnswer.Option = ({ index, children }: OptionProps) => { id={optionId} name={parentProps.id} value={optionId} + onChange={(e) => parentProps.onChange(optionIndex, e.target.checked)} + checked={parentProps.selectedChoices.includes(optionIndex)} + disabled={parentProps.readonly} />
                        diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 5093f9e59..4c6532b20 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -5,7 +5,7 @@ import DocumentStore from '@tdev-stores/DocumentStore'; import { action, computed, observable } from 'mobx'; export interface ChoiceAnswerChoices { - [type: number]: number | number[]; + [type: number]: number[]; } export interface MetaInit { @@ -29,7 +29,7 @@ export class ModelMeta extends TypeMeta<'choice_answer'> { } class ChoiceAnswer extends iDocument<'choice_answer'> { - @observable accessor choices: ChoiceAnswerChoices; + @observable.ref accessor choices: ChoiceAnswerChoices; constructor(props: DocumentProps<'choice_answer'>, store: DocumentStore) { super(props, store); @@ -47,6 +47,32 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { } } + @action + updateSingleChoiceSelection(questionIndex: number, optionIndex: number): void { + this.updatedAt = new Date(); + this.choices = { + ...this.choices, + [questionIndex]: [optionIndex] + }; + this.saveNow(); + } + + @action + updateMultipleChoiceSelection(questionIndex: number, optionIndex: number, selected: boolean): void { + this.updatedAt = new Date(); + const currentSelections = new Set(this.choices[questionIndex] as number[] | []); + if (selected) { + currentSelections.add(optionIndex); + } else { + currentSelections.delete(optionIndex); + } + this.choices = { + ...this.choices, + [questionIndex]: Array.from(currentSelections) + }; + this.saveNow(); + } + get data(): TypeDataMapping['choice_answer'] { return { choices: this.choices diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index 7e8487f8d..0d95ebe2a 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -27,7 +27,7 @@ const transformOptions = (listChildren: {type: string, children: FlowChildren}[] const options = listChildren .filter((child) => child.type === 'listItem') .map((child, index) => { - return createWrapper('ChoiceAnswer.Option', child.children, [{ name: 'index', value: index }]); + return createWrapper('ChoiceAnswer.Option', child.children, [{ name: 'optionIndex', value: index }]); }); return createWrapper('ChoiceAnswer.Options', options); diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 8e2a33a11..942adb85f 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -35,6 +35,7 @@ import ProgressState from '@tdev-models/documents/ProgressState'; import Script from '@tdev-models/documents/Code'; import TaskState from '@tdev-models/documents/TaskState'; import Code from '@tdev-models/documents/Code'; +import ChoiceAnswer from '@tdev-models/documents/ChoiceAnswer'; const IsNotUniqueError = (error: any) => { try { @@ -60,6 +61,8 @@ export function CreateDocumentModel(data: DocumentProps, store: Do return new ScriptVersion(data as DocumentProps<'script_version'>, store); case 'string': return new String(data as DocumentProps<'string'>, store); + case 'choice_answer': + return new ChoiceAnswer(data as DocumentProps<'choice_answer'>, store); case 'quill_v2': return new QuillV2(data as DocumentProps<'quill_v2'>, store); case 'solution': @@ -87,6 +90,7 @@ const FactoryDefault: [DocumentType, Factory][] = [ ['progress_state', CreateDocumentModel], ['script_version', CreateDocumentModel], ['string', CreateDocumentModel], + ['choice_answer', CreateDocumentModel], ['quill_v2', CreateDocumentModel], ['solution', CreateDocumentModel], ['dir', CreateDocumentModel], From 6924fffc59c24575b4ae2a7b8c9cc00880fb1be2 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Thu, 29 Jan 2026 09:57:55 +0100 Subject: [PATCH 13/91] Show save icon. --- src/components/documents/ChoiceAnswer/index.tsx | 16 +++++++++++++++- .../documents/ChoiceAnswer/styles.module.scss | 10 ++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 01d772c2c..16cb17f0f 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -4,6 +4,10 @@ import { observer } from 'mobx-react-lite'; import React from 'react'; import clsx from 'clsx'; import styles from './styles.module.scss'; +import SyncStatus from '@tdev-components/SyncStatus'; +import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; +import Loader from '@tdev-components/Loader'; +import useIsBrowser from '@docusaurus/useIsBrowser'; interface ChoiceAnswerProps { id: string; @@ -47,6 +51,15 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const [meta] = React.useState(new ModelMeta(props)); const doc = useFirstMainDocument(props.id, meta); const questionIndex = props.questionIndex ?? 0; + const isBrowser = useIsBrowser(); + + if (!doc) { + return ; + } + + if (!isBrowser) { + return ; + } const childrenArray = React.Children.toArray(props.children); const beforeBlock = childrenArray.find( @@ -68,7 +81,8 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { }; return ( -
                        +
                        + {beforeBlock} Date: Thu, 29 Jan 2026 11:21:42 +0100 Subject: [PATCH 14/91] Run formatter. --- src/components/util/domHelpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/util/domHelpers.ts b/src/components/util/domHelpers.ts index 95c3d9aad..270b7778d 100644 --- a/src/components/util/domHelpers.ts +++ b/src/components/util/domHelpers.ts @@ -1,4 +1,4 @@ -import React from "react"; +import React from 'react'; export const extractListItems = (children: React.ReactElement): React.ReactNode[] | null => { const liContent = React.useMemo(() => { @@ -46,4 +46,4 @@ export const extractListItems = (children: React.ReactElement): React.ReactNode[ ); }, [children]); return liContent; -}; \ No newline at end of file +}; From 459e6251c418066b37d49c58b50d9b0653834fcb Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Thu, 29 Jan 2026 14:37:08 +0100 Subject: [PATCH 15/91] Start integrating Quiz. --- .../documents/ChoiceAnswer/Quiz.tsx | 26 ++++ .../documents/ChoiceAnswer/index.tsx | 24 ++-- src/models/documents/ChoiceAnswer.ts | 8 +- .../remark-transform-choice-answer/plugin.ts | 121 +++++++++++------- .../answer/choice-answer/index.mdx | 28 +++- 5 files changed, 146 insertions(+), 61 deletions(-) create mode 100644 src/components/documents/ChoiceAnswer/Quiz.tsx diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx new file mode 100644 index 000000000..e769cd55d --- /dev/null +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -0,0 +1,26 @@ +import { observer } from 'mobx-react-lite'; +import React from 'react'; + +interface Props { + id: string; + readonly?: boolean; + children?: React.ReactNode[]; +} + +export const QuizContext = React.createContext({ + id: '', + readonly: false +} as { + id: string; + readonly?: boolean; +}); + +const Quiz = observer((props: Props) => { + return ( + + {props.children} + + ); +}); + +export default Quiz; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 16cb17f0f..cc768b591 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -8,10 +8,11 @@ import SyncStatus from '@tdev-components/SyncStatus'; import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; import Loader from '@tdev-components/Loader'; import useIsBrowser from '@docusaurus/useIsBrowser'; +import { QuizContext } from './Quiz'; interface ChoiceAnswerProps { id: string; - questionIndex?: number; + questionIndex?: string; multiple?: boolean; readonly?: boolean; children: React.ReactNode; @@ -23,7 +24,7 @@ interface ThinWrapperProps { interface OptionProps { children: React.ReactNode; - optionIndex: number; + optionIndex: string; } type ChoiceAnswerSubComponents = { @@ -35,22 +36,26 @@ type ChoiceAnswerSubComponents = { const ChoiceAnswerContext = React.createContext({ id: '', + questionIndex: '0', multiple: false, readonly: false, selectedChoices: [], onChange: () => {} } as { id: string; + questionIndex: string; multiple?: boolean; readonly?: boolean; - selectedChoices: number[]; - onChange: (optionIndex: number, checked: boolean) => void; + selectedChoices: string[]; + onChange: (optionIndex: string, checked: boolean) => void; }); const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { + const parentProps = React.useContext(QuizContext); const [meta] = React.useState(new ModelMeta(props)); - const doc = useFirstMainDocument(props.id, meta); - const questionIndex = props.questionIndex ?? 0; + const id = parentProps.id || props.id; + const doc = useFirstMainDocument(id, meta); + const questionIndex = props.questionIndex ?? '0'; const isBrowser = useIsBrowser(); if (!doc) { @@ -72,7 +77,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { (child) => React.isValidElement(child) && child.type === ChoiceAnswer.After ); - const onOptionChange = (optionIndex: number, checked: boolean) => { + const onOptionChange = (optionIndex: string, checked: boolean) => { if (props.multiple) { doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked); } else { @@ -86,7 +91,8 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { {beforeBlock} { ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { const parentProps = React.useContext(ChoiceAnswerContext); - const optionId = `${parentProps.id}-option-${optionIndex}`; + const optionId = `${parentProps.id}-${parentProps.questionIndex}-option-${optionIndex}`; return (
                        diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 4c6532b20..931b7f821 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -5,7 +5,7 @@ import DocumentStore from '@tdev-stores/DocumentStore'; import { action, computed, observable } from 'mobx'; export interface ChoiceAnswerChoices { - [type: number]: number[]; + [type: string]: string[]; } export interface MetaInit { @@ -48,7 +48,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { } @action - updateSingleChoiceSelection(questionIndex: number, optionIndex: number): void { + updateSingleChoiceSelection(questionIndex: string, optionIndex: string): void { this.updatedAt = new Date(); this.choices = { ...this.choices, @@ -58,9 +58,9 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { } @action - updateMultipleChoiceSelection(questionIndex: number, optionIndex: number, selected: boolean): void { + updateMultipleChoiceSelection(questionIndex: string, optionIndex: string, selected: boolean): void { this.updatedAt = new Date(); - const currentSelections = new Set(this.choices[questionIndex] as number[] | []); + const currentSelections = new Set(this.choices[questionIndex] as string[] | []); if (selected) { currentSelections.add(optionIndex); } else { diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index 0d95ebe2a..40c15add9 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -1,13 +1,15 @@ import { visit } from 'unist-util-visit'; import type { Plugin } from 'unified'; import type { Root, BlockContent, DefinitionContent } from 'mdast'; -import type { MdxJsxFlowElement } from 'mdast-util-mdx'; +import type { MdxJsxAttributeValueExpression, MdxJsxFlowElement } from 'mdast-util-mdx'; enum ChoiceComponentTypes { ChoiceAnswer = 'ChoiceAnswer', TrueFalseAnswer = 'TrueFalseAnswer' } +const QUIZ_NODE_NAME = 'Quiz'; + type FlowChildren = (BlockContent | DefinitionContent)[]; function createWrapper(name: string, children: FlowChildren, attributes: any[] = []): MdxJsxFlowElement { @@ -23,64 +25,91 @@ function createWrapper(name: string, children: FlowChildren, attributes: any[] = }; } -const transformOptions = (listChildren: {type: string, children: FlowChildren}[]): MdxJsxFlowElement => { - const options = listChildren - .filter((child) => child.type === 'listItem') - .map((child, index) => { - return createWrapper('ChoiceAnswer.Option', child.children, [{ name: 'optionIndex', value: index }]); - }); +const createWrappedOption = (listChildren: { type: string; children: FlowChildren }[]): MdxJsxFlowElement => { + const options = listChildren + .filter((child) => child.type === 'listItem') + .map((child, index) => { + return createWrapper('ChoiceAnswer.Option', child.children, [ + { name: 'optionIndex', value: index } + ]); + }); - return createWrapper('ChoiceAnswer.Options', options); -} + return createWrapper('ChoiceAnswer.Options', options); +}; -const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { - return (tree) => { - visit(tree, 'mdxJsxFlowElement', (node) => { - if ( - !node.name || - !Object.values(ChoiceComponentTypes).includes(node.name as ChoiceComponentTypes) - ) { - return; - } +const transformQuestion = (questionNode: MdxJsxFlowElement) => { + const listIndex = questionNode.children.findIndex((child) => child.type === 'list'); - const listIndex = node.children.findIndex((child) => child.type === 'list'); + if (listIndex === -1) { + if (questionNode.name !== ChoiceComponentTypes.TrueFalseAnswer) { + console.warn(`No list found in <${questionNode.name}>. Expected exactly one list child.`); + } + return; + } - if (listIndex === -1) { - if (node.name !== ChoiceComponentTypes.TrueFalseAnswer) { - console.warn(`No list found in <${node.name}>. Expected exactly one list child.`); - } - return; - } + if (questionNode.children.filter((child) => child.type === 'list').length > 1) { + console.warn(`Multiple lists found in <${questionNode.name}>. Only the first one is used.`); + } - if (node.children.filter((child) => child.type === 'list').length > 1) { - console.warn(`Multiple lists found in <${node.name}>. Only the first one is used.`); - } + const beforeChildren = questionNode.children.slice(0, listIndex) as FlowChildren; + const listChild = questionNode.children[listIndex] as { children: FlowChildren }; - const beforeChildren = node.children.slice(0, listIndex) as FlowChildren; + const afterChildren = questionNode.children.slice(listIndex + 1) as FlowChildren; - const listChild = node.children[listIndex] as {children: FlowChildren}; + const wrappedChildren: FlowChildren = []; - const afterChildren = node.children.slice(listIndex + 1) as FlowChildren; + if (beforeChildren.length > 0) { + wrappedChildren.push(createWrapper(`${questionNode.name}.Before`, beforeChildren)); + } - const wrappedChildren: FlowChildren = []; + wrappedChildren.push( + createWrappedOption(listChild.children as { type: string; children: FlowChildren }[]) + ); - if (beforeChildren.length > 0) { - wrappedChildren.push(createWrapper(`${node.name}.Before`, beforeChildren)); - } + if (afterChildren.length > 0) { + wrappedChildren.push(createWrapper(`${questionNode.name}.After`, afterChildren)); + } - /* - TODO: - - Wrap each
                      • individually in ChoiceAnswer.Option - - Enumerate the
                      • elements during transformation, pass as index prop to ChoiceAnswer.Option - - Get rid of the
                          , put (transformed) children directly into ChoiceAnswer.Options - */ - wrappedChildren.push(transformOptions(listChild.children)); + questionNode.children = wrappedChildren; +}; - if (afterChildren.length > 0) { - wrappedChildren.push(createWrapper(`${node.name}.After`, afterChildren)); - } +const transformQuestions = (questionNodes: MdxJsxFlowElement[]) => { + questionNodes.forEach((questionNode, index: number) => { + transformQuestion(questionNode); + questionNode.attributes.push({ + type: 'mdxJsxAttribute', + name: 'questionIndex', + value: index.toString() + }); + }); +}; + +const transformQuiz = (quizNode: MdxJsxFlowElement) => { + const questions = [] as MdxJsxFlowElement[]; + visit(quizNode, 'mdxJsxFlowElement', (childNode) => { + if (Object.values(ChoiceComponentTypes).includes(childNode.name as ChoiceComponentTypes)) { + questions.push(childNode); + } + }); - node.children = wrappedChildren; + transformQuestions(questions); +}; + +const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { + return (tree) => { + visit(tree, 'mdxJsxFlowElement', (node) => { + if (node.name == QUIZ_NODE_NAME) { + // Enumerate and transform questions inside the quiz. + transformQuiz(node); + } else if ( + Object.values(ChoiceComponentTypes).includes(node.name as ChoiceComponentTypes) && + !((node as any).parent?.name === QUIZ_NODE_NAME) + ) { + // Transform standalone question. + transformQuestion(node); + } else { + return; + } }); }; }; diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 7c0f78628..7abeb9073 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -7,6 +7,7 @@ tags: import PermissionsPanel from "@tdev-components/PermissionsPanel" import BrowserWindow from '@tdev-components/BrowserWindow'; import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer'; +import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; # Choice Answer Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. @@ -96,7 +97,7 @@ Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Rei ``` -## Fragegruppen +## Quizzes mit mehreren Fragen Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: ```html @@ -123,9 +124,32 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag 3. IMAP 4. HTTP - + ``` + + + + > In welchem Jahr war 2024? + + 1. 1965 + 2. 1983 + 3. 1991 + 4. 2000 + 5. 2024 + + + + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? + + 1. SMTP + 2. FTP + 3. IMAP + 4. HTTP + + + + ## Eigenschaften ### ChoiceAnswer | Eigenschaft | Typ | Beschreibung | From 367bc139bf87451b36e14d63c88a287b34c06ecd Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 06:21:49 +0100 Subject: [PATCH 16/91] Start adding Quiz. --- .../documents/ChoiceAnswer/Quiz.tsx | 9 ++++- .../documents/ChoiceAnswer/index.tsx | 22 +++++----- src/models/documents/ChoiceAnswer.ts | 8 ++-- .../remark-transform-choice-answer/plugin.ts | 40 +++++++++++++------ 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx index e769cd55d..c1070afb9 100644 --- a/src/components/documents/ChoiceAnswer/Quiz.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -4,20 +4,25 @@ import React from 'react'; interface Props { id: string; readonly?: boolean; + hideQuestionNumbers?: boolean; children?: React.ReactNode[]; } export const QuizContext = React.createContext({ id: '', - readonly: false + readonly: false, + hideQuestionNumbers: false } as { id: string; readonly?: boolean; + hideQuestionNumbers?: boolean; }); const Quiz = observer((props: Props) => { return ( - + {props.children} ); diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index cc768b591..ddc4a8bba 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -9,10 +9,12 @@ import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentTy import Loader from '@tdev-components/Loader'; import useIsBrowser from '@docusaurus/useIsBrowser'; import { QuizContext } from './Quiz'; +import { parse } from 'micromatch'; interface ChoiceAnswerProps { id: string; - questionIndex?: string; + questionIndex?: number; + inQuiz?: boolean; multiple?: boolean; readonly?: boolean; children: React.ReactNode; @@ -24,7 +26,7 @@ interface ThinWrapperProps { interface OptionProps { children: React.ReactNode; - optionIndex: string; + optionIndex: number; } type ChoiceAnswerSubComponents = { @@ -36,18 +38,18 @@ type ChoiceAnswerSubComponents = { const ChoiceAnswerContext = React.createContext({ id: '', - questionIndex: '0', + questionIndex: 0, multiple: false, readonly: false, selectedChoices: [], onChange: () => {} } as { id: string; - questionIndex: string; + questionIndex: number; multiple?: boolean; readonly?: boolean; - selectedChoices: string[]; - onChange: (optionIndex: string, checked: boolean) => void; + selectedChoices: number[]; + onChange: (optionIndex: number, checked: boolean) => void; }); const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { @@ -55,7 +57,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const [meta] = React.useState(new ModelMeta(props)); const id = parentProps.id || props.id; const doc = useFirstMainDocument(id, meta); - const questionIndex = props.questionIndex ?? '0'; + const questionIndex = props.questionIndex ?? 0; const isBrowser = useIsBrowser(); if (!doc) { @@ -77,7 +79,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { (child) => React.isValidElement(child) && child.type === ChoiceAnswer.After ); - const onOptionChange = (optionIndex: string, checked: boolean) => { + const onOptionChange = (optionIndex: number, checked: boolean) => { if (props.multiple) { doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked); } else { @@ -88,6 +90,8 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { return (
                          + + {props.inQuiz && !parentProps.hideQuestionNumbers &&

                          Frage {questionIndex + 1}

                          } {beforeBlock} { ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { const parentProps = React.useContext(ChoiceAnswerContext); - const optionId = `${parentProps.id}-${parentProps.questionIndex}-option-${optionIndex}`; + const optionId = `${parentProps.id}-q${parentProps.questionIndex}-opt${optionIndex}`; return (
                          diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 931b7f821..4c6532b20 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -5,7 +5,7 @@ import DocumentStore from '@tdev-stores/DocumentStore'; import { action, computed, observable } from 'mobx'; export interface ChoiceAnswerChoices { - [type: string]: string[]; + [type: number]: number[]; } export interface MetaInit { @@ -48,7 +48,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { } @action - updateSingleChoiceSelection(questionIndex: string, optionIndex: string): void { + updateSingleChoiceSelection(questionIndex: number, optionIndex: number): void { this.updatedAt = new Date(); this.choices = { ...this.choices, @@ -58,9 +58,9 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { } @action - updateMultipleChoiceSelection(questionIndex: string, optionIndex: string, selected: boolean): void { + updateMultipleChoiceSelection(questionIndex: number, optionIndex: number, selected: boolean): void { this.updatedAt = new Date(); - const currentSelections = new Set(this.choices[questionIndex] as string[] | []); + const currentSelections = new Set(this.choices[questionIndex] as number[] | []); if (selected) { currentSelections.add(optionIndex); } else { diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index 40c15add9..ecb53edde 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -1,7 +1,8 @@ import { visit } from 'unist-util-visit'; import type { Plugin } from 'unified'; import type { Root, BlockContent, DefinitionContent } from 'mdast'; -import type { MdxJsxAttributeValueExpression, MdxJsxFlowElement } from 'mdast-util-mdx'; +import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx'; +import { toMdxJsxExpressionAttribute } from '../helpers'; enum ChoiceComponentTypes { ChoiceAnswer = 'ChoiceAnswer', @@ -12,15 +13,15 @@ const QUIZ_NODE_NAME = 'Quiz'; type FlowChildren = (BlockContent | DefinitionContent)[]; -function createWrapper(name: string, children: FlowChildren, attributes: any[] = []): MdxJsxFlowElement { +function createWrapper( + name: string, + children: FlowChildren, + attributes: MdxJsxAttribute[] = [] +): MdxJsxFlowElement { return { type: 'mdxJsxFlowElement', name, - attributes: attributes.map((attr) => ({ - type: 'mdxJsxAttribute', - name: attr.name, - value: attr.value - })), + attributes, children }; } @@ -30,7 +31,11 @@ const createWrappedOption = (listChildren: { type: string; children: FlowChildre .filter((child) => child.type === 'listItem') .map((child, index) => { return createWrapper('ChoiceAnswer.Option', child.children, [ - { name: 'optionIndex', value: index } + toMdxJsxExpressionAttribute('optionIndex', index, { + type: 'Literal', + value: index, + raw: `${index}` + }) ]); }); @@ -76,11 +81,20 @@ const transformQuestion = (questionNode: MdxJsxFlowElement) => { const transformQuestions = (questionNodes: MdxJsxFlowElement[]) => { questionNodes.forEach((questionNode, index: number) => { transformQuestion(questionNode); - questionNode.attributes.push({ - type: 'mdxJsxAttribute', - name: 'questionIndex', - value: index.toString() - }); + questionNode.attributes.push( + toMdxJsxExpressionAttribute('questionIndex', true, { + type: 'Literal', + value: index, + raw: `${index}` + }) + ); + questionNode.attributes.push( + toMdxJsxExpressionAttribute('inQuiz', true, { + type: 'Literal', + value: true, + raw: 'true' + }) + ); }); }; From 0b9b5227cd55561a22a1680ec0c2c2f881631caf Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 07:12:32 +0100 Subject: [PATCH 17/91] Fix prop passing. --- src/components/documents/ChoiceAnswer/index.tsx | 2 +- .../answer/choice-answer/index.mdx | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index ddc4a8bba..b64c6ead5 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -98,7 +98,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { id: id, questionIndex: questionIndex, multiple: props.multiple, - readonly: props.readonly, + readonly: props.readonly || parentProps.readonly, selectedChoices: doc?.data.choices[questionIndex] || [], onChange: onOptionChange }} diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 7abeb9073..4f6557627 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -156,15 +156,16 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag |------------------|-------------------|---------------------------------------------------| | `correct` | `number[]` | Array mit den IDs der Indizes der korrekten Antwortmöglichkeiten. | | `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | -| `randomize` | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | +| `randomize` (TODO) | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | +| `hideQuestionNumbers` | Flag | Wenn gesetzt, wird den Fragen innerhalb des Quiz kein Titel mit der Fragenummer hinzugefügt | ### Quiz | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| -| `randomize` | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | -| `randomizeOptions` | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge dargestellt (analog zu `ChoiceAnswer.randomize` für einzelne Fragen). | -| `carrousel` | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | -| `grading` | Object | **TODO.** Objekt zur Anpassung der Bewertungslogik. Siehe unten. | +| `randomize` (TODO) | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | +| `randomizeOptions` (TODO) | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge dargestellt (analog zu `ChoiceAnswer.randomize` für einzelne Fragen). | +| `carrousel` (TODO) | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | +| `grading` (TODO) | Object | **TODO.** Objekt zur Anpassung der Bewertungslogik. Siehe unten. | ## TODO - Bei MC-Aufgaben sind Teilpunktzahlen möglich. From 91e5df4b58999a0b0e96fb7e1b0987773ee42eee Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 07:25:50 +0100 Subject: [PATCH 18/91] Inject doc from quiz. --- src/components/documents/ChoiceAnswer/Quiz.tsx | 17 +++++++++++++++-- src/components/documents/ChoiceAnswer/index.tsx | 3 +-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx index c1070afb9..643ef40f5 100644 --- a/src/components/documents/ChoiceAnswer/Quiz.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -1,5 +1,8 @@ +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; import { observer } from 'mobx-react-lite'; import React from 'react'; +import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; interface Props { id: string; @@ -11,17 +14,27 @@ interface Props { export const QuizContext = React.createContext({ id: '', readonly: false, - hideQuestionNumbers: false + hideQuestionNumbers: false, + doc: null } as { id: string; readonly?: boolean; hideQuestionNumbers?: boolean; + doc: ChoiceAnswerDocument | null; }); const Quiz = observer((props: Props) => { + const [meta] = React.useState(new ModelMeta(props)); + const doc = useFirstMainDocument(props.id, meta); + return ( {props.children} diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index b64c6ead5..9a34a6357 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -9,7 +9,6 @@ import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentTy import Loader from '@tdev-components/Loader'; import useIsBrowser from '@docusaurus/useIsBrowser'; import { QuizContext } from './Quiz'; -import { parse } from 'micromatch'; interface ChoiceAnswerProps { id: string; @@ -56,7 +55,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const parentProps = React.useContext(QuizContext); const [meta] = React.useState(new ModelMeta(props)); const id = parentProps.id || props.id; - const doc = useFirstMainDocument(id, meta); + const doc = props.inQuiz ? parentProps.doc : useFirstMainDocument(id, meta); const questionIndex = props.questionIndex ?? 0; const isBrowser = useIsBrowser(); From 27a78bdee7e025485fbded6ad3c3cf745fd2cc11 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 08:07:33 +0100 Subject: [PATCH 19/91] Prevent redundant visit of questions in quiz. --- src/plugins/remark-transform-choice-answer/plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index ecb53edde..179d10e5c 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -112,12 +112,12 @@ const transformQuiz = (quizNode: MdxJsxFlowElement) => { const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { return (tree) => { visit(tree, 'mdxJsxFlowElement', (node) => { - if (node.name == QUIZ_NODE_NAME) { + if (node.name === QUIZ_NODE_NAME) { // Enumerate and transform questions inside the quiz. transformQuiz(node); } else if ( Object.values(ChoiceComponentTypes).includes(node.name as ChoiceComponentTypes) && - !((node as any).parent?.name === QUIZ_NODE_NAME) + !((node as any).parent?.name === QUIZ_NODE_NAME) && !node.attributes.some(attr => (attr as any).name === 'inQuiz') ) { // Transform standalone question. transformQuestion(node); From d4c791d31638775ecb514fa327edf14fc6d9c1f0 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 08:07:47 +0100 Subject: [PATCH 20/91] Format. --- src/plugins/remark-transform-choice-answer/plugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index 179d10e5c..ee2fa2d92 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -117,7 +117,8 @@ const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { transformQuiz(node); } else if ( Object.values(ChoiceComponentTypes).includes(node.name as ChoiceComponentTypes) && - !((node as any).parent?.name === QUIZ_NODE_NAME) && !node.attributes.some(attr => (attr as any).name === 'inQuiz') + !((node as any).parent?.name === QUIZ_NODE_NAME) && + !node.attributes.some((attr) => (attr as any).name === 'inQuiz') ) { // Transform standalone question. transformQuestion(node); From e45daeda4956a1f43f9933d2a85a91cf49e73bee Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 10:07:50 +0100 Subject: [PATCH 21/91] Improve save icon handling. --- src/components/documents/ChoiceAnswer/Quiz.tsx | 7 +++++++ src/components/documents/ChoiceAnswer/index.tsx | 7 +++++-- src/models/documents/ChoiceAnswer.ts | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx index 643ef40f5..2b87fbe83 100644 --- a/src/components/documents/ChoiceAnswer/Quiz.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -15,11 +15,14 @@ export const QuizContext = React.createContext({ id: '', readonly: false, hideQuestionNumbers: false, + focussedQuestion: 0, doc: null } as { id: string; readonly?: boolean; hideQuestionNumbers?: boolean; + focussedQuestion: number; + setFocussedQuestion?: (index: number) => void; doc: ChoiceAnswerDocument | null; }); @@ -27,12 +30,16 @@ const Quiz = observer((props: Props) => { const [meta] = React.useState(new ModelMeta(props)); const doc = useFirstMainDocument(props.id, meta); + const [focussedQuestion, setFocussedQuestion] = React.useState(0); + return ( diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 9a34a6357..06bf4cd56 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -79,6 +79,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { ); const onOptionChange = (optionIndex: number, checked: boolean) => { + parentProps.setFocussedQuestion?.(questionIndex); if (props.multiple) { doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked); } else { @@ -88,7 +89,9 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { return (
                          - + {parentProps.focussedQuestion === questionIndex && ( + + )} {props.inQuiz && !parentProps.hideQuestionNumbers &&

                          Frage {questionIndex + 1}

                          } {beforeBlock} @@ -97,7 +100,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { id: id, questionIndex: questionIndex, multiple: props.multiple, - readonly: props.readonly || parentProps.readonly, + readonly: props.readonly || parentProps.readonly || !doc || doc.isDummy, selectedChoices: doc?.data.choices[questionIndex] || [], onChange: onOptionChange }} diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 4c6532b20..db0f5c915 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -54,7 +54,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: [optionIndex] }; - this.saveNow(); + this.save(); } @action @@ -70,7 +70,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: Array.from(currentSelections) }; - this.saveNow(); + this.save(); } get data(): TypeDataMapping['choice_answer'] { From 53da6511bfb6953f6e57f75fd9b56e26d098bca1 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 10:09:46 +0100 Subject: [PATCH 22/91] Cleanup. --- .../answer/choice-answer/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 4f6557627..0a97841c7 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -154,15 +154,15 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag ### ChoiceAnswer | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| -| `correct` | `number[]` | Array mit den IDs der Indizes der korrekten Antwortmöglichkeiten. | -| `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | -| `randomize` (TODO) | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | -| `hideQuestionNumbers` | Flag | Wenn gesetzt, wird den Fragen innerhalb des Quiz kein Titel mit der Fragenummer hinzugefügt | +| `correct` | `number[]` | Array mit den IDs der Indizes der korrekten Antwortmöglichkeiten. | +| `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | +| `randomize` (TODO) | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | +| `hideQuestionNumbers` | Flag | Wenn gesetzt, wird den Fragen innerhalb des Quiz kein Titel mit der Fragenummer hinzugefügt | ### Quiz | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| -| `randomize` (TODO) | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | +| `randomizeQuestions` (TODO) | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | | `randomizeOptions` (TODO) | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge dargestellt (analog zu `ChoiceAnswer.randomize` für einzelne Fragen). | | `carrousel` (TODO) | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | | `grading` (TODO) | Object | **TODO.** Objekt zur Anpassung der Bewertungslogik. Siehe unten. | From 587a307d04e5520bbd43bf70fcd265fcb2082209 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 12:37:36 +0100 Subject: [PATCH 23/91] Implement deleting answer. --- .../documents/ChoiceAnswer/index.tsx | 19 ++++++++++++++++++- .../documents/ChoiceAnswer/styles.module.scss | 5 +++++ src/models/documents/ChoiceAnswer.ts | 10 ++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 06bf4cd56..e7fdc29a6 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -9,6 +9,8 @@ import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentTy import Loader from '@tdev-components/Loader'; import useIsBrowser from '@docusaurus/useIsBrowser'; import { QuizContext } from './Quiz'; +import Button from '@tdev-components/shared/Button'; +import { mdiCloseCircleOutline, mdiRestore, mdiTrashCanOutline } from '@mdi/js'; interface ChoiceAnswerProps { id: string; @@ -83,7 +85,9 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { if (props.multiple) { doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked); } else { - doc?.updateSingleChoiceSelection(questionIndex, optionIndex); + checked + ? doc?.updateSingleChoiceSelection(questionIndex, optionIndex) + : doc?.resetAnswer(questionIndex); } }; @@ -128,6 +132,19 @@ ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { disabled={parentProps.readonly} /> + {!parentProps.multiple && + !parentProps.readonly && + parentProps.selectedChoices.includes(optionIndex) && ( +
                          ); }; diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/styles.module.scss index 39ac18a01..24665873d 100644 --- a/src/components/documents/ChoiceAnswer/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/styles.module.scss @@ -20,4 +20,9 @@ label { margin-left: 0.2em; } + + .btnDeleteAnswer { + margin-left: 0.7em; + font-size: 0.8em; + } } diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index db0f5c915..75e034545 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -73,6 +73,16 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { this.save(); } + @action + resetAnswer(questionIndex: number): void { + this.updatedAt = new Date(); + this.choices = { + ...this.choices, + [questionIndex]: [] + }; + this.save(); + } + get data(): TypeDataMapping['choice_answer'] { return { choices: this.choices From ebd1a3827f2318e2fbb6796ef56cf7b9329dcefd Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 16:07:17 +0100 Subject: [PATCH 24/91] Add support for true/false answer. --- .../ChoiceAnswer/TrueFalseAnswer.tsx | 16 +++++++++ .../documents/ChoiceAnswer/index.tsx | 4 +-- src/models/documents/ChoiceAnswer.ts | 3 ++ .../remark-transform-choice-answer/plugin.ts | 14 +++++--- .../answer/choice-answer/index.mdx | 33 ++++++++++++++++++- 5 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx diff --git a/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx new file mode 100644 index 000000000..fa5eb527d --- /dev/null +++ b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx @@ -0,0 +1,16 @@ +import { observer } from 'mobx-react-lite'; +import ChoiceAnswer, { ChoiceAnswerProps } from '.'; + +const TrueFalseAnswer = observer((props: ChoiceAnswerProps) => { + return ( + + {props.children} + + Wahr + Falsch + + + ); +}); + +export default TrueFalseAnswer; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index e7fdc29a6..b670acc11 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -12,7 +12,7 @@ import { QuizContext } from './Quiz'; import Button from '@tdev-components/shared/Button'; import { mdiCloseCircleOutline, mdiRestore, mdiTrashCanOutline } from '@mdi/js'; -interface ChoiceAnswerProps { +export interface ChoiceAnswerProps { id: string; questionIndex?: number; inQuiz?: boolean; @@ -125,7 +125,7 @@ ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { parentProps.onChange(optionIndex, e.target.checked)} checked={parentProps.selectedChoices.includes(optionIndex)} diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 75e034545..409d2ac53 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -54,6 +54,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: [optionIndex] }; + console.log('Saving choice answer with choices:', this.choices); this.save(); } @@ -70,6 +71,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: Array.from(currentSelections) }; + console.log('Saving choice answer with choices:', this.choices); this.save(); } @@ -80,6 +82,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: [] }; + console.log('Saving choice answer with choices:', this.choices); this.save(); } diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index ee2fa2d92..ae7ff8ade 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -10,6 +10,10 @@ enum ChoiceComponentTypes { } const QUIZ_NODE_NAME = 'Quiz'; +const BEFORE_WRAPPER_NAME = 'ChoiceAnswer.Before'; +const OPTIONS_WRAPPER_NAME = 'ChoiceAnswer.Options'; +const OPTION_WRAPPER_NAME = 'ChoiceAnswer.Option'; +const AFTER_WRAPPER_NAME = 'ChoiceAnswer.After'; type FlowChildren = (BlockContent | DefinitionContent)[]; @@ -30,7 +34,7 @@ const createWrappedOption = (listChildren: { type: string; children: FlowChildre const options = listChildren .filter((child) => child.type === 'listItem') .map((child, index) => { - return createWrapper('ChoiceAnswer.Option', child.children, [ + return createWrapper(OPTION_WRAPPER_NAME, child.children, [ toMdxJsxExpressionAttribute('optionIndex', index, { type: 'Literal', value: index, @@ -39,7 +43,7 @@ const createWrappedOption = (listChildren: { type: string; children: FlowChildre ]); }); - return createWrapper('ChoiceAnswer.Options', options); + return createWrapper(OPTIONS_WRAPPER_NAME, options); }; const transformQuestion = (questionNode: MdxJsxFlowElement) => { @@ -49,6 +53,8 @@ const transformQuestion = (questionNode: MdxJsxFlowElement) => { if (questionNode.name !== ChoiceComponentTypes.TrueFalseAnswer) { console.warn(`No list found in <${questionNode.name}>. Expected exactly one list child.`); } + + questionNode.children = [createWrapper(BEFORE_WRAPPER_NAME, questionNode.children)]; return; } @@ -64,7 +70,7 @@ const transformQuestion = (questionNode: MdxJsxFlowElement) => { const wrappedChildren: FlowChildren = []; if (beforeChildren.length > 0) { - wrappedChildren.push(createWrapper(`${questionNode.name}.Before`, beforeChildren)); + wrappedChildren.push(createWrapper(BEFORE_WRAPPER_NAME, beforeChildren)); } wrappedChildren.push( @@ -72,7 +78,7 @@ const transformQuestion = (questionNode: MdxJsxFlowElement) => { ); if (afterChildren.length > 0) { - wrappedChildren.push(createWrapper(`${questionNode.name}.After`, afterChildren)); + wrappedChildren.push(createWrapper(AFTER_WRAPPER_NAME, afterChildren)); } questionNode.children = wrappedChildren; diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 0a97841c7..49f09afcc 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -7,6 +7,7 @@ tags: import PermissionsPanel from "@tdev-components/PermissionsPanel" import BrowserWindow from '@tdev-components/BrowserWindow'; import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer'; +import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAnswer'; import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; # Choice Answer @@ -83,6 +84,12 @@ Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: ``` + + + Die Erde ist flach. + + + Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. Zudem dürfen bei SC-Aufgaben auch mehrere Optionen korrekt sein. Wird eine davon ausgewählt, gilt die Antwort als richtig: ```md @@ -98,7 +105,7 @@ Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Rei ``` ## Quizzes mit mehreren Fragen -Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: +Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice-, Single-Choice- und Wahr/Falsch-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: ```html @@ -124,6 +131,16 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag 3. IMAP 4. HTTP + + + > Wann ist der Sinn des Lebens? + + 1. Immer im März + 2. 42 + 3. Das Bundeshaus + 4. Nein + 5. Ja, aber nur manchmal + ``` @@ -138,6 +155,10 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag 4. 2000 5. 2024 + + + > HTML ist eine Programmiersprache. + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? @@ -147,6 +168,16 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere MC- und SC-Frag 3. IMAP 4. HTTP + + + > Wann ist der Sinn des Lebens? + + 1. Immer im März + 2. 42 + 3. Das Bundeshaus + 4. Nein + 5. Ja, aber nur manchmal + From 30392aacfee5e50c624bc29ea9e1365e5f864997 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 16:10:57 +0100 Subject: [PATCH 25/91] Cleanup. --- .../documents/ChoiceAnswer/index.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index b670acc11..b2bd870e6 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -120,6 +120,10 @@ ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { const parentProps = React.useContext(ChoiceAnswerContext); const optionId = `${parentProps.id}-q${parentProps.questionIndex}-opt${optionIndex}`; + const isChecked = React.useMemo(() => { + return parentProps.selectedChoices.includes(optionIndex); + }, [parentProps.selectedChoices, optionIndex]); + return (
                          { name={optionId} value={optionId} onChange={(e) => parentProps.onChange(optionIndex, e.target.checked)} - checked={parentProps.selectedChoices.includes(optionIndex)} + checked={isChecked} disabled={parentProps.readonly} /> - {!parentProps.multiple && - !parentProps.readonly && - parentProps.selectedChoices.includes(optionIndex) && ( -
                          ); }; From 950cba5f3a489977fa4951c231706671b491f52c Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 17:06:31 +0100 Subject: [PATCH 26/91] Add styling and support for question title. --- .../documents/ChoiceAnswer/index.tsx | 44 ++++++++++----- .../documents/ChoiceAnswer/styles.module.scss | 56 +++++++++++++++---- .../answer/choice-answer/index.mdx | 22 +++++--- 3 files changed, 88 insertions(+), 34 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index b2bd870e6..749d8d7f0 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -14,6 +14,7 @@ import { mdiCloseCircleOutline, mdiRestore, mdiTrashCanOutline } from '@mdi/js'; export interface ChoiceAnswerProps { id: string; + title?: string; questionIndex?: number; inQuiz?: boolean; multiple?: boolean; @@ -91,27 +92,40 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } }; + const title = + props.inQuiz && !parentProps.hideQuestionNumbers + ? props.title + ? `Frage ${questionIndex + 1} – ${props.title}` + : `Frage ${questionIndex + 1}` + : props.title; + return (
                          {parentProps.focussedQuestion === questionIndex && ( )} - {props.inQuiz && !parentProps.hideQuestionNumbers &&

                          Frage {questionIndex + 1}

                          } - {beforeBlock} - - {optionsBlock} - - {afterBlock} + {title && ( +
                          + {title} +
                          + )} +
                          + {beforeBlock} + +
                          {optionsBlock}
                          +
                          + {afterBlock} +
                          ); }) as React.FC & ChoiceAnswerSubComponents; diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/styles.module.scss index 24665873d..2674c23b4 100644 --- a/src/components/documents/ChoiceAnswer/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/styles.module.scss @@ -1,28 +1,60 @@ .choiceAnswerContainer { position: relative; + border-radius: 1em; + border: 1px solid var(--ifm-color-secondary); + margin-bottom: 1em; + overflow: hidden; + + p { + margin: 0 0 0.5em 0; + } .syncStatus { position: absolute; top: 0.2em; right: 0.2em; } -} -.choiceAnswerOptionContainer { - display: flex; - flex-direction: row; - align-items: center; + .header { + background-color: var(--ifm-color-secondary-lightest); + padding: 0.7em 1em; - p { - margin: 0; + h3 { + margin: 0; + } + + .title { + font-weight: bold; + font-size: 1.1em; + } } - label { - margin-left: 0.2em; + .content { + padding: 1em; } +} + +.optionsContainer { + &:not(:last-child) { + margin-bottom: 1em; + } + + .choiceAnswerOptionContainer { + display: flex; + flex-direction: row; + align-items: center; + + p { + margin: 0; + } + + label { + margin-left: 0.2em; + } - .btnDeleteAnswer { - margin-left: 0.7em; - font-size: 0.8em; + .btnDeleteAnswer { + margin-left: 0.7em; + font-size: 0.8em; + } } } diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 49f09afcc..afc026712 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -41,7 +41,9 @@ Einfache Single- und Multiple-Choice-Fragen: ```md - +Hier steht etwas Text über der Frage. + + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. :::info[Bewertung] @@ -56,10 +58,14 @@ Einfache Single- und Multiple-Choice-Fragen: **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! + +Nach der Frage folgt noch weiterer Text. ``` - + Hier steht etwas Text über der Frage. + + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. :::info[Bewertung] @@ -74,18 +80,20 @@ Einfache Single- und Multiple-Choice-Fragen: **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! + + Nach der Frage folgt noch weiterer Text. Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: ```md - - Die Erde ist flach. + + > Die Erde ist flach. ``` - + Die Erde ist flach. @@ -119,7 +127,7 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice 5. 2024 - + > HTML ist eine Programmiersprache. @@ -156,7 +164,7 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice 5. 2024 - + > HTML ist eine Programmiersprache. From 0930e44b3312e40b0caa101bc02d03a13fd5aa8c Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 2 Feb 2026 20:42:11 +0100 Subject: [PATCH 27/91] Improve visuals. --- .../ChoiceAnswer/TrueFalseAnswer.tsx | 2 +- .../documents/ChoiceAnswer/index.tsx | 11 +++++++---- .../documents/ChoiceAnswer/styles.module.scss | 19 +++++++++++++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx index fa5eb527d..4474a6090 100644 --- a/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx +++ b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx @@ -6,7 +6,7 @@ const TrueFalseAnswer = observer((props: ChoiceAnswerProps) => { {props.children} - Wahr + Richtig Falsch diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 749d8d7f0..b9d08714f 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -99,17 +99,20 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { : `Frage ${questionIndex + 1}` : props.title; + const syncStatus = parentProps.focussedQuestion === questionIndex && ( + + ); + return (
                          - {parentProps.focussedQuestion === questionIndex && ( - - )} - {title && (
                          {title} + {syncStatus}
                          )} + {!title && syncStatus} +
                          {beforeBlock} Date: Tue, 3 Feb 2026 10:43:13 +0100 Subject: [PATCH 28/91] Add option randomization. --- src/api/document.ts | 3 +- .../documents/ChoiceAnswer/helpers.ts | 11 +++ .../documents/ChoiceAnswer/index.tsx | 39 ++++++++- .../documents/ChoiceAnswer/styles.module.scss | 31 +++++--- src/models/documents/ChoiceAnswer.ts | 29 +++++-- .../remark-transform-choice-answer/plugin.ts | 29 ++++++- .../answer/choice-answer/index.mdx | 79 ++++++++++++++----- 7 files changed, 174 insertions(+), 47 deletions(-) create mode 100644 src/components/documents/ChoiceAnswer/helpers.ts diff --git a/src/api/document.ts b/src/api/document.ts index 1f9d65e3e..394c0142c 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -18,7 +18,7 @@ import type DocumentStore from '@tdev-stores/DocumentStore'; import iDocumentContainer from '@tdev-models/iDocumentContainer'; import iViewStore from '@tdev-stores/ViewStores/iViewStore'; import Code from '@tdev-models/documents/Code'; -import ChoiceAnswer, { ChoiceAnswerChoices } from '@tdev-models/documents/ChoiceAnswer'; +import ChoiceAnswer, { ChoiceAnswerChoices, ChoiceAnswerOrders } from '@tdev-models/documents/ChoiceAnswer'; export enum Access { RO_DocumentRoot = 'RO_DocumentRoot', @@ -43,6 +43,7 @@ export interface StringData { export interface ChoiceAnswerData { choices: ChoiceAnswerChoices; + orders: ChoiceAnswerOrders; } export interface QuillV2Data { diff --git a/src/components/documents/ChoiceAnswer/helpers.ts b/src/components/documents/ChoiceAnswer/helpers.ts new file mode 100644 index 000000000..b0b611921 --- /dev/null +++ b/src/components/documents/ChoiceAnswer/helpers.ts @@ -0,0 +1,11 @@ +import _ from 'es-toolkit/compat'; + +export const createRandomOptionsOrder = (numOptions: number): { [originalOptionIndex: number]: number } => { + const originalIndices = Array.from({ length: numOptions }, (_, i) => i); + const shuffledIndices = _.shuffle(originalIndices); + const randomIndexMap: { [originalOptionIndex: number]: number } = {}; + originalIndices.forEach((originalIndex, i) => { + randomIndexMap[originalIndex] = shuffledIndices[i]; + }); + return randomIndexMap; +}; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index b9d08714f..2f1062fcb 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -10,7 +10,9 @@ import Loader from '@tdev-components/Loader'; import useIsBrowser from '@docusaurus/useIsBrowser'; import { QuizContext } from './Quiz'; import Button from '@tdev-components/shared/Button'; -import { mdiCloseCircleOutline, mdiRestore, mdiTrashCanOutline } from '@mdi/js'; +import { mdiTrashCanOutline } from '@mdi/js'; +import _ from 'es-toolkit/compat'; +import { createRandomOptionsOrder } from './helpers'; export interface ChoiceAnswerProps { id: string; @@ -18,6 +20,8 @@ export interface ChoiceAnswerProps { questionIndex?: number; inQuiz?: boolean; multiple?: boolean; + randomizeOptions?: boolean; + numOptions: number; readonly?: boolean; children: React.ReactNode; } @@ -44,6 +48,7 @@ const ChoiceAnswerContext = React.createContext({ multiple: false, readonly: false, selectedChoices: [], + optionOrder: undefined, onChange: () => {} } as { id: string; @@ -51,6 +56,7 @@ const ChoiceAnswerContext = React.createContext({ multiple?: boolean; readonly?: boolean; selectedChoices: number[]; + optionOrder?: { [originalOptionIndex: number]: number }; onChange: (optionIndex: number, checked: boolean) => void; }); @@ -60,6 +66,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const id = parentProps.id || props.id; const doc = props.inQuiz ? parentProps.doc : useFirstMainDocument(id, meta); const questionIndex = props.questionIndex ?? 0; + const randomizeOptions = props.randomizeOptions; const isBrowser = useIsBrowser(); if (!doc) { @@ -92,6 +99,16 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } }; + if (randomizeOptions && !doc.data.orders[questionIndex]?.optionsOrder) { + doc.updateOrders({ + ...doc.data.orders, + [questionIndex]: { + index: doc.data.orders[questionIndex]?.index || 0, + optionsOrder: createRandomOptionsOrder(props.numOptions) + } + }); + } + const title = props.inQuiz && !parentProps.hideQuestionNumbers ? props.title @@ -122,10 +139,13 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { multiple: props.multiple, readonly: props.readonly || parentProps.readonly || !doc || doc.isDummy, selectedChoices: doc?.data.choices[questionIndex] || [], + optionOrder: randomizeOptions + ? doc?.data.orders[questionIndex]?.optionsOrder + : undefined, onChange: onOptionChange }} > -
                          {optionsBlock}
                          +
                          {optionsBlock}
                          {afterBlock}
                          @@ -141,8 +161,19 @@ ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { return parentProps.selectedChoices.includes(optionIndex); }, [parentProps.selectedChoices, optionIndex]); + const optionOrder = React.useMemo( + () => (parentProps.optionOrder !== undefined ? parentProps.optionOrder[optionIndex] : optionIndex), + [parentProps.optionOrder, optionIndex] + ); + return ( -
                          +
                          { return <>{children}; }; ChoiceAnswer.Options = ({ children }: { children: React.ReactNode }) => { - return <>{children}; + return
                          {children}
                          ; }; ChoiceAnswer.After = ({ children }: { children: React.ReactNode }) => { return <>{children}; diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/styles.module.scss index 10322232c..ab085f9b0 100644 --- a/src/components/documents/ChoiceAnswer/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/styles.module.scss @@ -45,27 +45,32 @@ } } -.optionsContainer { +.optionsBlock { &:not(:last-child) { margin-bottom: 1em; } - .choiceAnswerOptionContainer { + .optionsContainer { display: flex; - flex-direction: row; - align-items: center; + flex-direction: column; - p { - margin: 0; - } + .choiceAnswerOptionContainer { + display: flex; + flex-direction: row; + align-items: center; - label { - margin-left: 0.2em; - } + p { + margin: 0; + } + + label { + margin-left: 0.2em; + } - .btnDeleteAnswer { - margin-left: 0.7em; - font-size: 0.8em; + .btnDeleteAnswer { + margin-left: 0.7em; + font-size: 0.8em; + } } } } diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 409d2ac53..51705bc3a 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -5,7 +5,16 @@ import DocumentStore from '@tdev-stores/DocumentStore'; import { action, computed, observable } from 'mobx'; export interface ChoiceAnswerChoices { - [type: number]: number[]; + [questionIndex: number]: number[]; +} + +export interface ChoiceAnswerOrders { + [questionIndex: number]: { + index: number; + optionsOrder: { + [originalOptionIndex: number]: number; + }; + }; } export interface MetaInit { @@ -23,17 +32,20 @@ export class ModelMeta extends TypeMeta<'choice_answer'> { get defaultData(): TypeDataMapping['choice_answer'] { return { - choices: {} + choices: {}, + orders: {} }; } } class ChoiceAnswer extends iDocument<'choice_answer'> { @observable.ref accessor choices: ChoiceAnswerChoices; + @observable.ref accessor orders: ChoiceAnswerOrders; constructor(props: DocumentProps<'choice_answer'>, store: DocumentStore) { super(props, store); this.choices = props.data?.choices || {}; + this.orders = props.data?.orders || {}; } @action @@ -54,7 +66,6 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: [optionIndex] }; - console.log('Saving choice answer with choices:', this.choices); this.save(); } @@ -71,7 +82,6 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: Array.from(currentSelections) }; - console.log('Saving choice answer with choices:', this.choices); this.save(); } @@ -82,13 +92,20 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { ...this.choices, [questionIndex]: [] }; - console.log('Saving choice answer with choices:', this.choices); this.save(); } + @action + updateOrders(orders: ChoiceAnswerOrders): void { + this.updatedAt = new Date(); + this.orders = orders; + this.saveNow(); + } + get data(): TypeDataMapping['choice_answer'] { return { - choices: this.choices + choices: this.choices, + orders: this.orders }; } diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index ae7ff8ade..8050680d9 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -30,7 +30,9 @@ function createWrapper( }; } -const createWrappedOption = (listChildren: { type: string; children: FlowChildren }[]): MdxJsxFlowElement => { +const createWrappedOption = ( + listChildren: { type: string; children: FlowChildren }[] +): { wrappedOptions: MdxJsxFlowElement; numOptions: number } => { const options = listChildren .filter((child) => child.type === 'listItem') .map((child, index) => { @@ -43,7 +45,7 @@ const createWrappedOption = (listChildren: { type: string; children: FlowChildre ]); }); - return createWrapper(OPTIONS_WRAPPER_NAME, options); + return { wrappedOptions: createWrapper(OPTIONS_WRAPPER_NAME, options), numOptions: options.length }; }; const transformQuestion = (questionNode: MdxJsxFlowElement) => { @@ -55,6 +57,17 @@ const transformQuestion = (questionNode: MdxJsxFlowElement) => { } questionNode.children = [createWrapper(BEFORE_WRAPPER_NAME, questionNode.children)]; + + if (questionNode.name === ChoiceComponentTypes.TrueFalseAnswer) { + questionNode.attributes.push( + toMdxJsxExpressionAttribute('numOptions', true, { + type: 'Literal', + value: 2, + raw: '2' + }) + ); + } + return; } @@ -73,8 +86,16 @@ const transformQuestion = (questionNode: MdxJsxFlowElement) => { wrappedChildren.push(createWrapper(BEFORE_WRAPPER_NAME, beforeChildren)); } - wrappedChildren.push( - createWrappedOption(listChild.children as { type: string; children: FlowChildren }[]) + const { wrappedOptions, numOptions } = createWrappedOption( + listChild.children as { type: string; children: FlowChildren }[] + ); + wrappedChildren.push(wrappedOptions); + questionNode.attributes.push( + toMdxJsxExpressionAttribute('numOptions', true, { + type: 'Literal', + value: numOptions, + raw: `${numOptions}` + }) ); if (afterChildren.length > 0) { diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index afc026712..38a829a96 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -14,7 +14,8 @@ import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. ## Standalone-Fragen -Einfache Single- und Multiple-Choice-Fragen: +### Single-Choice +Eine einfache Single-Choice-Frage kann wie folgt erstellt werden: ```md @@ -40,6 +41,53 @@ Einfache Single- und Multiple-Choice-Fragen: +Die Antwortmöglichkeiten werden als (nummerierte) Liste angegeben. In der `correct`-Liste kann festgelegt werden, welche Antwortmöglichkeit(en) als korrekt gilt/gelten. Dabei ist `1` die erste Antwortmöglichkeit, und so weiter. Im obigen Beispiel ist also nur die zweite Antwort (Tim Berners-Lee) korrekt. Die Verwendung eines Blockzitats (`>`) für die Frage dient lediglich der optischen Gestaltung und ist nicht zwingend notwendig. + +Mit dem `randomizeOptions`-Flag können die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt werden (wobei sich die `correct`-Liste immer auf die explizit im Markdown angegebene Reihenfolge bezieht; es ist also nach wie vor Tim Berners-Lee korrekt, auch wenn er z.B. an dritter Stelle angezeigt wird): + +```md + + > Wir gilt als Erfinder des World Wide Web (WWW)? + + 1. Steve **Jobs** + 2. Tim Berners-Lee + 3. Ada __Lovelace__ + 4. Alain Berset :mdi[cheese] + 5. Charles Bartowski + +``` + + + + > Wir gilt als Erfinder des World Wide Web (WWW)? + + 1. Steve **Jobs** + 2. Tim Berners-Lee + 3. Ada __Lovelace__ + 4. Alain Berset :mdi[cheese] + 5. Charles Bartowski + + + +Die Randomisierung wird beim ersten Aufruf generiert und im dahinterliegenden gespeichert. Dadurch bleibt die Reihenfolge der Antwortmöglichkeiten auch bei einem erneuten Laden der Seite gleich. Wird das Flag temporär entfernt, gehen die Antworten wieder zurück in die ursprüngliche Reihenfolge. Die persistierte Reihenfolge bleibt im Hintergrund aber erhalten und wird wiederhergestellt, sobald das Flag erneut gesetzt wird. + +Bei Single-Choice-Aufgaben dürfen auch mehrere Antworten als korrekt angegeben werden. Wird eine davon ausgewählt, gilt die Antwort als richtig: + +```md + + Welche der folgenden Programmiersprachen sind statisch typisiert? **Hinweis:** Es kann mehr als eine Antwort korrekt sein. + + 1. TypeScript + 2. Python + 3. JavaScript + 4. Java + 5. Ruby + +``` + +### Multiple-Choice +Für eine Multiple-Choice-Frage muss lediglich das `multiple`-Flag gesetzt werden. In diesem Fall müssen alle der in der `correct`-Liste angegebenen Antworten ausgewählt werden, damit die Frage als richtig bewertet wird: + ```md Hier steht etwas Text über der Frage. @@ -84,6 +132,13 @@ Nach der Frage folgt noch weiterer Text. Nach der Frage folgt noch weiterer Text. +Hier wird zusätzlich die `title`-Property verwendet, um der Frage einen Titel zu geben. Zudem wird ein Infoblock mit einer Bewertungsinformation eingeblendet. + +:::info[Randomisierung] +Die Randomisierung der Antwortmöglichkeiten funktioniert bei Multiple-Choice-Aufgaben genau gleich wie bei Single-Choice-Aufgaben. +::: + +### Wahr/Falsch Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: ```md @@ -98,20 +153,6 @@ Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: -Mit der `randomize`-Property werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. Zudem dürfen bei SC-Aufgaben auch mehrere Optionen korrekt sein. Wird eine davon ausgewählt, gilt die Antwort als richtig: - -```md - - Welche der folgenden Programmiersprachen sind statisch typisiert? **Hinweis:** Es kann mehr als eine Antwort korrekt sein. - - 1. TypeScript - 2. Python - 3. JavaScript - 4. Java - 5. Ruby - -``` - ## Quizzes mit mehreren Fragen Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice-, Single-Choice- und Wahr/Falsch-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: @@ -193,16 +234,16 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice ### ChoiceAnswer | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| -| `correct` | `number[]` | Array mit den IDs der Indizes der korrekten Antwortmöglichkeiten. | +| `correct` | `number[]` | Liste mit den Nummern der korrekten Antwortoptionen (wobei `1` die erste Antwortoption ist). | | `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | -| `randomize` (TODO) | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt. | +| `randomizeOptions` | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge angezeigt. Die zufällige Darstellungsreihenfolge hat keinen Einfluss auf die `correct`-Liste. | | `hideQuestionNumbers` | Flag | Wenn gesetzt, wird den Fragen innerhalb des Quiz kein Titel mit der Fragenummer hinzugefügt | ### Quiz | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| -| `randomizeQuestions` (TODO) | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge dargestellt. | -| `randomizeOptions` (TODO) | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge dargestellt (analog zu `ChoiceAnswer.randomize` für einzelne Fragen). | +| `randomizeQuestions` (TODO) | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge angezeigt. | +| `randomizeOptions` (TODO) | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge angezeigt (analog zu `ChoiceAnswer.randomizeOptions` für einzelne Fragen). | | `carrousel` (TODO) | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | | `grading` (TODO) | Object | **TODO.** Objekt zur Anpassung der Bewertungslogik. Siehe unten. | From e732a4b31b43bfa5294aa96733a18e46a4279857 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 3 Feb 2026 14:49:40 +0100 Subject: [PATCH 29/91] Implement quiz randomization. --- src/api/document.ts | 9 +- .../documents/ChoiceAnswer/Quiz.tsx | 40 +++++++- .../ChoiceAnswer/TrueFalseAnswer.tsx | 8 +- .../documents/ChoiceAnswer/helpers.ts | 10 +- .../documents/ChoiceAnswer/index.tsx | 25 +++-- .../documents/ChoiceAnswer/styles.module.scss | 5 + src/models/documents/ChoiceAnswer.ts | 33 ++++--- .../remark-transform-choice-answer/plugin.ts | 7 ++ .../answer/choice-answer/index.mdx | 99 +++++++++++++++++-- 9 files changed, 195 insertions(+), 41 deletions(-) diff --git a/src/api/document.ts b/src/api/document.ts index 394c0142c..9139ac305 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -18,7 +18,11 @@ import type DocumentStore from '@tdev-stores/DocumentStore'; import iDocumentContainer from '@tdev-models/iDocumentContainer'; import iViewStore from '@tdev-stores/ViewStores/iViewStore'; import Code from '@tdev-models/documents/Code'; -import ChoiceAnswer, { ChoiceAnswerChoices, ChoiceAnswerOrders } from '@tdev-models/documents/ChoiceAnswer'; +import ChoiceAnswer, { + ChoiceAnswerChoices, + ChoiceAnswerOptionOrders, + ChoiceAnswerQuestionOrder +} from '@tdev-models/documents/ChoiceAnswer'; export enum Access { RO_DocumentRoot = 'RO_DocumentRoot', @@ -43,7 +47,8 @@ export interface StringData { export interface ChoiceAnswerData { choices: ChoiceAnswerChoices; - orders: ChoiceAnswerOrders; + optionOrders: ChoiceAnswerOptionOrders; + questionOrder: ChoiceAnswerQuestionOrder | null; } export interface QuillV2Data { diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx index 2b87fbe83..da5593da2 100644 --- a/src/components/documents/ChoiceAnswer/Quiz.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -3,11 +3,19 @@ import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; import { observer } from 'mobx-react-lite'; import React from 'react'; import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; +import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; +import { isBrowser } from 'es-toolkit'; +import Loader from '@tdev-components/Loader'; +import { createRandomOrderMap } from './helpers'; +import styles from './styles.module.scss'; interface Props { id: string; readonly?: boolean; hideQuestionNumbers?: boolean; + randomizeOptions?: boolean; + randomizeQuestions?: boolean; + numQuestions: number; children?: React.ReactNode[]; } @@ -15,12 +23,18 @@ export const QuizContext = React.createContext({ id: '', readonly: false, hideQuestionNumbers: false, + randomizeQuestions: false, + questionOrder: null, + randomizeOptions: false, focussedQuestion: 0, doc: null } as { id: string; readonly?: boolean; hideQuestionNumbers?: boolean; + randomizeQuestions?: boolean; + questionOrder: { [originalQuestionIndex: number]: number } | null; + randomizeOptions?: boolean; focussedQuestion: number; setFocussedQuestion?: (index: number) => void; doc: ChoiceAnswerDocument | null; @@ -32,18 +46,42 @@ const Quiz = observer((props: Props) => { const [focussedQuestion, setFocussedQuestion] = React.useState(0); + if (!doc) { + return ; + } + + if (!isBrowser) { + return ; + } + + if (props.randomizeQuestions && !doc.data.questionOrder) { + console.log('Initializing question order for num questions', props.numQuestions); + doc.updateQuestionOrder(createRandomOrderMap(props.numQuestions)); + } + console.log( + 'randomize', + props.randomizeQuestions, + 'noOrderPresent', + !doc.data.questionOrder, + 'order', + doc.data.questionOrder + ); + return ( - {props.children} +
                          {props.children}
                          ); }); diff --git a/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx index 4474a6090..3698e4647 100644 --- a/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx +++ b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx @@ -1,10 +1,14 @@ import { observer } from 'mobx-react-lite'; import ChoiceAnswer, { ChoiceAnswerProps } from '.'; +import { QuizContext } from './Quiz'; +import React from 'react'; const TrueFalseAnswer = observer((props: ChoiceAnswerProps) => { + const parentProps = React.useContext(QuizContext); + return ( - - {props.children} + + {props.children} Richtig Falsch diff --git a/src/components/documents/ChoiceAnswer/helpers.ts b/src/components/documents/ChoiceAnswer/helpers.ts index b0b611921..4e35b7a6d 100644 --- a/src/components/documents/ChoiceAnswer/helpers.ts +++ b/src/components/documents/ChoiceAnswer/helpers.ts @@ -1,9 +1,13 @@ import _ from 'es-toolkit/compat'; -export const createRandomOptionsOrder = (numOptions: number): { [originalOptionIndex: number]: number } => { - const originalIndices = Array.from({ length: numOptions }, (_, i) => i); +const range = (numItems: number): number[] => { + return Array.from({ length: numItems }, (_, i) => i); +}; + +export const createRandomOrderMap = (numOptions: number): { [originalIndex: number]: number } => { + const originalIndices = range(numOptions); const shuffledIndices = _.shuffle(originalIndices); - const randomIndexMap: { [originalOptionIndex: number]: number } = {}; + const randomIndexMap: { [originalIndex: number]: number } = {}; originalIndices.forEach((originalIndex, i) => { randomIndexMap[originalIndex] = shuffledIndices[i]; }); diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 2f1062fcb..9a0c0062c 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -12,7 +12,7 @@ import { QuizContext } from './Quiz'; import Button from '@tdev-components/shared/Button'; import { mdiTrashCanOutline } from '@mdi/js'; import _ from 'es-toolkit/compat'; -import { createRandomOptionsOrder } from './helpers'; +import { createRandomOrderMap } from './helpers'; export interface ChoiceAnswerProps { id: string; @@ -66,7 +66,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const id = parentProps.id || props.id; const doc = props.inQuiz ? parentProps.doc : useFirstMainDocument(id, meta); const questionIndex = props.questionIndex ?? 0; - const randomizeOptions = props.randomizeOptions; + const randomizeOptions = parentProps.randomizeOptions || props.randomizeOptions; const isBrowser = useIsBrowser(); if (!doc) { @@ -99,13 +99,14 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } }; - if (randomizeOptions && !doc.data.orders[questionIndex]?.optionsOrder) { - doc.updateOrders({ - ...doc.data.orders, - [questionIndex]: { - index: doc.data.orders[questionIndex]?.index || 0, - optionsOrder: createRandomOptionsOrder(props.numOptions) - } + const questionOrder = + parentProps.randomizeQuestions && parentProps.questionOrder + ? parentProps.questionOrder[questionIndex] + : questionIndex; + if (randomizeOptions && !doc.data.optionOrders?.[questionIndex]) { + doc.updateOptionOrders({ + ...doc.data.optionOrders, + [questionIndex]: createRandomOrderMap(props.numOptions) }); } @@ -121,7 +122,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { ); return ( -
                          +
                          {title && (
                          {title} @@ -139,9 +140,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { multiple: props.multiple, readonly: props.readonly || parentProps.readonly || !doc || doc.isDummy, selectedChoices: doc?.data.choices[questionIndex] || [], - optionOrder: randomizeOptions - ? doc?.data.orders[questionIndex]?.optionsOrder - : undefined, + optionOrder: randomizeOptions ? doc?.data.optionOrders[questionIndex] : undefined, onChange: onOptionChange }} > diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/styles.module.scss index ab085f9b0..17f3f4fdc 100644 --- a/src/components/documents/ChoiceAnswer/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/styles.module.scss @@ -74,3 +74,8 @@ } } } + +.quizContainer { + display: flex; + flex-direction: column; +} diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 51705bc3a..8da3cbe87 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -8,15 +8,14 @@ export interface ChoiceAnswerChoices { [questionIndex: number]: number[]; } -export interface ChoiceAnswerOrders { +export interface ChoiceAnswerOptionOrders { [questionIndex: number]: { - index: number; - optionsOrder: { - [originalOptionIndex: number]: number; - }; + [originalOptionIndex: number]: number; }; } +export type ChoiceAnswerQuestionOrder = { [originalQuestionIndex: number]: number }; + export interface MetaInit { readonly?: boolean; } @@ -33,19 +32,22 @@ export class ModelMeta extends TypeMeta<'choice_answer'> { get defaultData(): TypeDataMapping['choice_answer'] { return { choices: {}, - orders: {} + optionOrders: {}, + questionOrder: null }; } } class ChoiceAnswer extends iDocument<'choice_answer'> { @observable.ref accessor choices: ChoiceAnswerChoices; - @observable.ref accessor orders: ChoiceAnswerOrders; + @observable.ref accessor optionOrders: ChoiceAnswerOptionOrders; + @observable.ref accessor questionOrder: ChoiceAnswerQuestionOrder | null; constructor(props: DocumentProps<'choice_answer'>, store: DocumentStore) { super(props, store); this.choices = props.data?.choices || {}; - this.orders = props.data?.orders || {}; + this.optionOrders = props.data?.optionOrders || {}; + this.questionOrder = props.data?.questionOrder || null; } @action @@ -96,16 +98,25 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { } @action - updateOrders(orders: ChoiceAnswerOrders): void { + updateOptionOrders(optionOrders: ChoiceAnswerOptionOrders): void { + this.updatedAt = new Date(); + this.optionOrders = optionOrders; + this.saveNow(); + } + + @action + updateQuestionOrder(questionOrder: ChoiceAnswerQuestionOrder): void { + console.log('Updating question order to', questionOrder); this.updatedAt = new Date(); - this.orders = orders; + this.questionOrder = questionOrder; this.saveNow(); } get data(): TypeDataMapping['choice_answer'] { return { choices: this.choices, - orders: this.orders + optionOrders: this.optionOrders, + questionOrder: this.questionOrder }; } diff --git a/src/plugins/remark-transform-choice-answer/plugin.ts b/src/plugins/remark-transform-choice-answer/plugin.ts index 8050680d9..cff572ec2 100644 --- a/src/plugins/remark-transform-choice-answer/plugin.ts +++ b/src/plugins/remark-transform-choice-answer/plugin.ts @@ -134,6 +134,13 @@ const transformQuiz = (quizNode: MdxJsxFlowElement) => { }); transformQuestions(questions); + quizNode.attributes.push( + toMdxJsxExpressionAttribute('numQuestions', true, { + type: 'Literal', + value: questions.length, + raw: `${questions.length}` + }) + ); }; const plugin: Plugin<[], Root> = function choiceAnswerWrapPlugin() { diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 38a829a96..24a3d72f4 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -46,7 +46,7 @@ Die Antwortmöglichkeiten werden als (nummerierte) Liste angegeben. In der `corr Mit dem `randomizeOptions`-Flag können die Antwortmöglichkeiten in zufälliger Reihenfolge dargestellt werden (wobei sich die `correct`-Liste immer auf die explizit im Markdown angegebene Reihenfolge bezieht; es ist also nach wie vor Tim Berners-Lee korrekt, auch wenn er z.B. an dritter Stelle angezeigt wird): ```md - + > Wir gilt als Erfinder des World Wide Web (WWW)? 1. Steve **Jobs** @@ -58,7 +58,7 @@ Mit dem `randomizeOptions`-Flag können die Antwortmöglichkeiten in zufälliger ``` - + > Wir gilt als Erfinder des World Wide Web (WWW)? 1. Steve **Jobs** @@ -91,7 +91,7 @@ Für eine Multiple-Choice-Frage muss lediglich das `multiple`-Flag gesetzt werde ```md Hier steht etwas Text über der Frage. - + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. :::info[Bewertung] @@ -113,7 +113,7 @@ Nach der Frage folgt noch weiterer Text. Hier steht etwas Text über der Frage. - + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. :::info[Bewertung] @@ -142,18 +142,22 @@ Die Randomisierung der Antwortmöglichkeiten funktioniert bei Multiple-Choice-Au Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: ```md - + > Die Erde ist flach. ``` - + Die Erde ist flach. -## Quizzes mit mehreren Fragen +:::info[Randomisierung] +Bei Wahr/Falsch-Fragen gibt es keine Randomisierung der Antwortmöglichkeiten, da es nur zwei fixe Optionen gibt. Dies gilt sowohl bei Standalone-Fragen, als auch bei Wahr/Falsch-Fragen innerhalb eines Quizzes. +::: + +## Quizzes Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice-, Single-Choice- und Wahr/Falsch-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: ```html @@ -230,6 +234,83 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice +### Randomisierung +Bei einem Quiz kann mit den entsprechenden Flags sowohl die Reihenfolge der Fragen als auch die Reihenfolge der Antwortmöglichkeiten randomisiert werden. Das Verhalten ist analog zu den einzelnen ChoiceAnswer-Fragen (siehe oben). Wird `randomizeOptions` auf Quiz-Ebene gesetzt, gilt dies für alle enthaltenen Fragen (wobei die Antwortoptionen von Wahr/Falsch-Fragen nie randomisiert werden). + +```html + + + > In welchem Jahr war 2024? + + 1. 1965 + 2. 1983 + 3. 1991 + 4. 2000 + 5. 2024 + + + + > HTML ist eine Programmiersprache. + + + + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? + + 1. SMTP + 2. FTP + 3. IMAP + 4. HTTP + + + + > Wann ist der Sinn des Lebens? + + 1. Immer im März + 2. 42 + 3. Das Bundeshaus + 4. Nein + 5. Ja, aber nur manchmal + + +``` + + + + + > In welchem Jahr war 2024? + + 1. 1965 + 2. 1983 + 3. 1991 + 4. 2000 + 5. 2024 + + + + > HTML ist eine Programmiersprache. + + + + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? + + 1. SMTP + 2. FTP + 3. IMAP + 4. HTTP + + + + > Wann ist der Sinn des Lebens? + + 1. Immer im März + 2. 42 + 3. Das Bundeshaus + 4. Nein + 5. Ja, aber nur manchmal + + + + ## Eigenschaften ### ChoiceAnswer | Eigenschaft | Typ | Beschreibung | @@ -242,8 +323,8 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice ### Quiz | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| -| `randomizeQuestions` (TODO) | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge angezeigt. | -| `randomizeOptions` (TODO) | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge angezeigt (analog zu `ChoiceAnswer.randomizeOptions` für einzelne Fragen). | +| `randomizeQuestions` | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge angezeigt. | +| `randomizeOptions` | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge angezeigt (analog zu `ChoiceAnswer.randomizeOptions` für einzelne Fragen). | | `carrousel` (TODO) | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | | `grading` (TODO) | Object | **TODO.** Objekt zur Anpassung der Bewertungslogik. Siehe unten. | From 64cfaccf28bc141c9ae1bf537250e52a73a8941c Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 3 Feb 2026 20:09:51 +0100 Subject: [PATCH 30/91] Cleanup and fixes. --- src/components/documents/ChoiceAnswer/Quiz.tsx | 9 --------- src/components/documents/ChoiceAnswer/index.tsx | 10 +++++++--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx index da5593da2..3ac1c5367 100644 --- a/src/components/documents/ChoiceAnswer/Quiz.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -55,17 +55,8 @@ const Quiz = observer((props: Props) => { } if (props.randomizeQuestions && !doc.data.questionOrder) { - console.log('Initializing question order for num questions', props.numQuestions); doc.updateQuestionOrder(createRandomOrderMap(props.numQuestions)); } - console.log( - 'randomize', - props.randomizeQuestions, - 'noOrderPresent', - !doc.data.questionOrder, - 'order', - doc.data.questionOrder - ); return ( { }); } + const questionNumberToDisplay = + (parentProps.randomizeQuestions + ? (parentProps.questionOrder?.[questionIndex] ?? questionIndex) + : questionIndex) + 1; const title = props.inQuiz && !parentProps.hideQuestionNumbers ? props.title - ? `Frage ${questionIndex + 1} – ${props.title}` - : `Frage ${questionIndex + 1}` + ? `Frage ${questionNumberToDisplay} – ${props.title}` + : `Frage ${questionNumberToDisplay}` : props.title; const syncStatus = parentProps.focussedQuestion === questionIndex && ( @@ -154,7 +158,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { const parentProps = React.useContext(ChoiceAnswerContext); - const optionId = `${parentProps.id}-q${parentProps.questionIndex}-opt${optionIndex}`; + const optionId = React.useId(); const isChecked = React.useMemo(() => { return parentProps.selectedChoices.includes(optionIndex); From ab16f82893324a981fc81daa58d1a8eeb07d336c Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Thu, 5 Feb 2026 21:12:37 +0100 Subject: [PATCH 31/91] Clean up. --- src/components/documents/ChoiceAnswer/Quiz.tsx | 10 ++++++---- .../documents/ChoiceAnswer/TrueFalseAnswer.tsx | 5 +---- src/components/documents/ChoiceAnswer/index.tsx | 15 +++++++++------ src/models/documents/ChoiceAnswer.ts | 1 - 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx index 3ac1c5367..8313088b0 100644 --- a/src/components/documents/ChoiceAnswer/Quiz.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -46,6 +46,12 @@ const Quiz = observer((props: Props) => { const [focussedQuestion, setFocussedQuestion] = React.useState(0); + React.useEffect(() => { + if (props.randomizeQuestions && !doc?.data.questionOrder) { + doc?.updateQuestionOrder(createRandomOrderMap(props.numQuestions)); + } + }, [props.randomizeQuestions, doc, props.numQuestions]); + if (!doc) { return ; } @@ -54,10 +60,6 @@ const Quiz = observer((props: Props) => { return ; } - if (props.randomizeQuestions && !doc.data.questionOrder) { - doc.updateQuestionOrder(createRandomOrderMap(props.numQuestions)); - } - return ( { - const parentProps = React.useContext(QuizContext); - return ( - + {props.children} Richtig diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index e8db6547f..b5a903092 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -69,6 +69,15 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const randomizeOptions = parentProps.randomizeOptions || props.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]); + if (!doc) { return ; } @@ -103,12 +112,6 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { parentProps.randomizeQuestions && parentProps.questionOrder ? parentProps.questionOrder[questionIndex] : questionIndex; - if (randomizeOptions && !doc.data.optionOrders?.[questionIndex]) { - doc.updateOptionOrders({ - ...doc.data.optionOrders, - [questionIndex]: createRandomOrderMap(props.numOptions) - }); - } const questionNumberToDisplay = (parentProps.randomizeQuestions diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 8da3cbe87..e3d548915 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -106,7 +106,6 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { @action updateQuestionOrder(questionOrder: ChoiceAnswerQuestionOrder): void { - console.log('Updating question order to', questionOrder); this.updatedAt = new Date(); this.questionOrder = questionOrder; this.saveNow(); From 2d9441344f3111dabcd41126eb3357014fffc278 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Fri, 6 Feb 2026 09:19:44 +0100 Subject: [PATCH 32/91] Start working on component cleanup. --- .../documents/ChoiceAnswer/index.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index b5a903092..5fc585d8a 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -1,5 +1,5 @@ import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; -import { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; +import ChoiceAnswerDocument, { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; import { observer } from 'mobx-react-lite'; import React from 'react'; import clsx from 'clsx'; @@ -43,7 +43,7 @@ type ChoiceAnswerSubComponents = { }; const ChoiceAnswerContext = React.createContext({ - id: '', + doc: undefined, questionIndex: 0, multiple: false, readonly: false, @@ -51,7 +51,7 @@ const ChoiceAnswerContext = React.createContext({ optionOrder: undefined, onChange: () => {} } as { - id: string; + doc?: ChoiceAnswerDocument; questionIndex: number; multiple?: boolean; readonly?: boolean; @@ -142,7 +142,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { {beforeBlock} { }) as React.FC & ChoiceAnswerSubComponents; ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { - const parentProps = React.useContext(ChoiceAnswerContext); + const { doc, questionIndex, multiple, selectedChoices, optionOrder, onChange } = + React.useContext(ChoiceAnswerContext); + const optionId = React.useId(); const isChecked = React.useMemo(() => { - return parentProps.selectedChoices.includes(optionIndex); - }, [parentProps.selectedChoices, optionIndex]); + return selectedChoices.includes(optionIndex); + }, [selectedChoices, optionIndex]); - const optionOrder = React.useMemo( - () => (parentProps.optionOrder !== undefined ? parentProps.optionOrder[optionIndex] : optionIndex), - [parentProps.optionOrder, optionIndex] + const applicableOptionOrder = React.useMemo( + () => (optionOrder !== undefined ? optionOrder[optionIndex] : optionIndex), + [optionOrder, optionIndex] ); return ( @@ -177,27 +179,27 @@ ChoiceAnswer.Option = ({ optionIndex, children }: OptionProps) => { key={optionId} className={clsx(styles.choiceAnswerOptionContainer)} style={{ - order: optionOrder + order: applicableOptionOrder }} > parentProps.onChange(optionIndex, e.target.checked)} + onChange={(e) => onChange(optionIndex, e.target.checked)} checked={isChecked} - disabled={parentProps.readonly} + disabled={doc?.meta.readonly /* TODO: Use doc.canEdit */} /> - {!parentProps.multiple && !parentProps.readonly && isChecked && ( + {!multiple && !doc?.meta.readonly /* TODO: Use doc.canEdit */ && isChecked && (
                          ); -}; +}); ChoiceAnswer.Before = ({ children }: { children: React.ReactNode }) => { return <>{children}; diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 24a3d72f4..1d2658e34 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -238,7 +238,7 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice Bei einem Quiz kann mit den entsprechenden Flags sowohl die Reihenfolge der Fragen als auch die Reihenfolge der Antwortmöglichkeiten randomisiert werden. Das Verhalten ist analog zu den einzelnen ChoiceAnswer-Fragen (siehe oben). Wird `randomizeOptions` auf Quiz-Ebene gesetzt, gilt dies für alle enthaltenen Fragen (wobei die Antwortoptionen von Wahr/Falsch-Fragen nie randomisiert werden). ```html - + > In welchem Jahr war 2024? @@ -275,7 +275,7 @@ Bei einem Quiz kann mit den entsprechenden Flags sowohl die Reihenfolge der Frag ``` - + > In welchem Jahr war 2024? From 2f984104d25a605715d739835a54e4cff2b9964d Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Sat, 7 Feb 2026 07:50:06 +0100 Subject: [PATCH 39/91] Use card style. --- src/components/documents/ChoiceAnswer/index.tsx | 6 +++--- src/components/documents/ChoiceAnswer/styles.module.scss | 7 ------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index f0063ac2b..67b6868bf 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -125,16 +125,16 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { ); return ( -
                          +
                          {title && ( -
                          +
                          {title} {syncStatus}
                          )} {!title && syncStatus} -
                          +
                          {beforeBlock} Date: Mon, 16 Mar 2026 09:41:55 +0100 Subject: [PATCH 40/91] Start working on auto-grading. --- src/api/document.ts | 1 + .../documents/ChoiceAnswer/index.tsx | 81 ++++++++++++++++--- .../documents/ChoiceAnswer/styles.module.scss | 28 ++++--- src/models/documents/ChoiceAnswer.ts | 8 +- .../answer/choice-answer/index.mdx | 2 +- 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/api/document.ts b/src/api/document.ts index 9139ac305..f6958259b 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -49,6 +49,7 @@ export interface ChoiceAnswerData { choices: ChoiceAnswerChoices; optionOrders: ChoiceAnswerOptionOrders; questionOrder: ChoiceAnswerQuestionOrder | null; + graded: boolean; } export interface QuillV2Data { diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 67b6868bf..4c8b5eba4 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -10,13 +10,15 @@ 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 { mdiCheckboxMarkedCircleAutoOutline, mdiRestore, mdiTrashCanOutline } from '@mdi/js'; import _ from 'es-toolkit/compat'; import { createRandomOrderMap } from './helpers'; +import { Confirm } from '@tdev-components/shared/Button/Confirm'; export interface ChoiceAnswerProps { id: string; title?: string; + correct?: number[]; questionIndex?: number; inQuiz?: boolean; multiple?: boolean; @@ -56,6 +58,57 @@ const ChoiceAnswerContext = React.createContext({ onChange: (optionIndex: number, checked: boolean) => void; }); +interface ControlsProps { + doc: ChoiceAnswerDocument; + questionIndex: number; + focussedQuestion?: boolean; +} + +const Controls = observer(({ doc, questionIndex, focussedQuestion: isFocussedQuestion }: ControlsProps) => { + if (!doc) { + return; + } + + // TODO: Potentially factor out grade / reset buttons, since they shouldn't show on a per-question basis in case of a quiz. + // TODO: Hide grade / reset button per question in quiz, show on quiz level. + const syncStatus = isFocussedQuestion && ( +
                          + + {!doc.graded && ( +
                          + ); + + return
                          {syncStatus}
                          ; +}); + +// TODO: Use graded status + correct[] to show whether the question was answered correctly, keeping in mind that there will also be a notion +// of actual "grading" and partial points (for MC questions). + const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const parentProps = React.useContext(QuizContext); const [meta] = React.useState(new ModelMeta(props)); @@ -120,19 +173,25 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { : `Frage ${questionNumberToDisplay}` : props.title; - const syncStatus = parentProps.focussedQuestion === questionIndex && ( - - ); - return (
                          {title && (
                          {title} - {syncStatus} +
                          )} - {!title && syncStatus} + {!title && ( + + )}
                          {beforeBlock} @@ -169,6 +228,10 @@ ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => { [doc?.optionOrders[questionIndex], questionIndex, optionIndex] ); + const canEdit = React.useMemo(() => { + return doc?.canEdit && !doc?.graded; + }, [doc?.canEdit, doc?.graded]); + return (
                          { value={optionId} onChange={(e) => onChange(optionIndex, e.target.checked)} checked={isChecked} - disabled={!doc?.canEdit} + disabled={!canEdit} /> - {!multiple && doc?.canEdit && isChecked && ( + {!multiple && canEdit && isChecked && (
                          + ); + + return
                          {syncStatus}
                          ; +}); + +export default Controls; diff --git a/src/components/documents/ChoiceAnswer/QuestionGrading.tsx b/src/components/documents/ChoiceAnswer/QuestionGrading.tsx new file mode 100644 index 000000000..064ca0413 --- /dev/null +++ b/src/components/documents/ChoiceAnswer/QuestionGrading.tsx @@ -0,0 +1,70 @@ +import { mdiCheckCircle, mdiCheckCircleOutline } from '@mdi/js'; +import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; +import Admonition from '@theme/Admonition'; +import { divide } from 'es-toolkit/compat'; +import { observer } from 'mobx-react-lite'; + +interface Props { + doc: ChoiceAnswerDocument; +} + +const CorrectIcon = (): React.JSX.Element => { + return ( + + + + ); +}; + +const IncorrectIcon = (): React.JSX.Element => { + return ( + + + + ); +}; + +const NoAnswerIcon = (): React.JSX.Element => { + return ( + + + + ); +}; + +const correctAdmonition = ( + }> + Sie haben die Frage korrekt beantwortet! + +); + +const incorrectAdmonition = ( + }> + Sie haben diese Frage falsch beantwortet. + +); + +const noAnswerAdmonition = ( + }> + Sie haben diese Frage nicht beantwortet. + +); + +// TODO: Should the grading decide its own visibility? Will quizzes prevent the entire grading, or just the result display? +const QuestionGrading = observer(({ doc }: Props) => { + if (!doc || !doc.graded) { + return; + } + return <>{noAnswerAdmonition}; +}); + +export default QuestionGrading; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 4c8b5eba4..44132195f 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -1,19 +1,23 @@ import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; -import ChoiceAnswerDocument, { ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; +import ChoiceAnswerDocument, { + ChoiceAnswerGrading, + ChoiceAnswerResult, + 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 SyncStatus from '@tdev-components/SyncStatus'; 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 { mdiCheckboxMarkedCircleAutoOutline, mdiRestore, mdiTrashCanOutline } from '@mdi/js'; +import { mdiTrashCanOutline } from '@mdi/js'; import _ from 'es-toolkit/compat'; import { createRandomOrderMap } from './helpers'; -import { Confirm } from '@tdev-components/shared/Button/Confirm'; +import Controls from './Controls'; +import QuestionGrading from './QuestionGrading'; export interface ChoiceAnswerProps { id: string; @@ -58,54 +62,6 @@ const ChoiceAnswerContext = React.createContext({ onChange: (optionIndex: number, checked: boolean) => void; }); -interface ControlsProps { - doc: ChoiceAnswerDocument; - questionIndex: number; - focussedQuestion?: boolean; -} - -const Controls = observer(({ doc, questionIndex, focussedQuestion: isFocussedQuestion }: ControlsProps) => { - if (!doc) { - return; - } - - // TODO: Potentially factor out grade / reset buttons, since they shouldn't show on a per-question basis in case of a quiz. - // TODO: Hide grade / reset button per question in quiz, show on quiz level. - const syncStatus = isFocussedQuestion && ( -
                          - - {!doc.graded && ( -
                          - ); - - return
                          {syncStatus}
                          ; -}); - // TODO: Use graded status + correct[] to show whether the question was answered correctly, keeping in mind that there will also be a notion // of actual "grading" and partial points (for MC questions). @@ -127,6 +83,10 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } }, [randomizeOptions, doc, questionIndex, props.numOptions]); + React.useEffect(() => { + // TODO: Implement grading logic. + }, [doc?.choices]); + if (!doc) { return ; } @@ -207,6 +167,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
                          {optionsBlock}
                          {afterBlock} + {}
                          ); @@ -228,10 +189,6 @@ ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => { [doc?.optionOrders[questionIndex], questionIndex, optionIndex] ); - const canEdit = React.useMemo(() => { - return doc?.canEdit && !doc?.graded; - }, [doc?.canEdit, doc?.graded]); - return (
                          { value={optionId} onChange={(e) => onChange(optionIndex, e.target.checked)} checked={isChecked} - disabled={!canEdit} + disabled={!doc?.canUpdateAnswer} /> - {!multiple && canEdit && isChecked && ( + {!multiple && doc?.canUpdateAnswer && isChecked && (
                          ); diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 907b48bee..868b14efb 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -23,13 +23,12 @@ export interface MetaInit { export enum ChoiceAnswerResult { Correct = 'correct', Incorrect = 'incorrect', - PartiallyCorrect = 'partially_correct' + PartiallyCorrect = 'partially_correct', + NA = 'not_answered' } export interface ChoiceAnswerGrading { result: ChoiceAnswerResult; - correctChoices: number[]; - incorrectChoices: number[]; points?: number; } From 780779885fc75fa801c910df4d74e3e0ab54f592 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 17 Mar 2026 10:58:33 +0100 Subject: [PATCH 43/91] Implement MC grading. --- .../ChoiceAnswer/QuestionGrading.tsx | 21 ++++++++++- .../documents/ChoiceAnswer/index.tsx | 35 +++++++++++++------ src/models/documents/ChoiceAnswer.ts | 1 - .../answer/choice-answer/index.mdx | 4 +-- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/QuestionGrading.tsx b/src/components/documents/ChoiceAnswer/QuestionGrading.tsx index d468e0c0f..3fb6510ef 100644 --- a/src/components/documents/ChoiceAnswer/QuestionGrading.tsx +++ b/src/components/documents/ChoiceAnswer/QuestionGrading.tsx @@ -15,6 +15,17 @@ const CorrectIcon = (): React.JSX.Element => { ); }; +const PartiallyCorrectIcon = (): React.JSX.Element => { + return ( + + + + ); +}; + const IncorrectIcon = (): React.JSX.Element => { return ( @@ -31,7 +42,7 @@ const NoAnswerIcon = (): React.JSX.Element => { ); @@ -43,6 +54,12 @@ const correctAdmonition = ( ); +const partiallyCorrectAdmonition = ( + }> + Sie haben diese Frage teilweise richtig beantwortet. + +); + const incorrectAdmonition = ( }> Sie haben diese Frage falsch beantwortet. @@ -74,6 +91,8 @@ const QuestionGrading = observer(({ doc, questionIndex }: Props) => { switch (grading.result) { case ChoiceAnswerResult.Correct: return <>{correctAdmonition}; + case ChoiceAnswerResult.PartiallyCorrect: + return <>{partiallyCorrectAdmonition}; case ChoiceAnswerResult.Incorrect: return <>{incorrectAdmonition}; case ChoiceAnswerResult.NA: diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 9020bb751..005a0d79f 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -92,27 +92,40 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } // TODO: Implement points logic. - let grading: ChoiceAnswerGrading = { + const grading: ChoiceAnswerGrading = { result: ChoiceAnswerResult.NA }; if (props.multiple) { - // TODO: Implement MC grading. - grading = { - result: ChoiceAnswerResult.PartiallyCorrect - }; + const selectedOptions = new Set(doc.choices[questionIndex] || []); + if (selectedOptions.size > 0) { + const numCorrectDecisions = _.range(0, props.numOptions).filter((optionIndex) => { + const isCorrect = correctOptions.has(optionIndex + 1); // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. + const isSelected = selectedOptions.has(optionIndex); + console.log( + `Option ${optionIndex + 1}: isCorrect=${isCorrect}, isSelected=${isSelected}` + ); + return (isCorrect && isSelected) || (!isCorrect && !isSelected); + }).length; + + grading.result = + numCorrectDecisions === props.numOptions + ? ChoiceAnswerResult.Correct + : numCorrectDecisions > 0 + ? ChoiceAnswerResult.PartiallyCorrect + : ChoiceAnswerResult.Incorrect; + } } else { const selectedOption = doc?.choices[questionIndex]?.[0]; if (selectedOption === undefined) { - grading = { - result: ChoiceAnswerResult.NA - }; + grading.result = ChoiceAnswerResult.NA; } else { - grading = correctOptions.has(selectedOption + 1) // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. - ? { result: ChoiceAnswerResult.Correct } - : { result: ChoiceAnswerResult.Incorrect }; + grading.result = correctOptions.has(selectedOption + 1) // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. + ? ChoiceAnswerResult.Correct + : ChoiceAnswerResult.Incorrect; } } + console.log(`Grading question ${questionIndex}:`, grading.result); doc.updateGrading(questionIndex, grading); }, [doc?.choices]); diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 868b14efb..a8bb1339a 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -149,7 +149,6 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { set graded(value: boolean) { this.updatedAt = new Date(); this._graded = value; - console.log('Setting graded to', value); this.saveNow(); } diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 47c29d3ca..d78e24a01 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -91,7 +91,7 @@ Für eine Multiple-Choice-Frage muss lediglich das `multiple`-Flag gesetzt werde ```md Hier steht etwas Text über der Frage. - + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. :::info[Bewertung] @@ -113,7 +113,7 @@ Nach der Frage folgt noch weiterer Text. Hier steht etwas Text über der Frage. - + > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. :::info[Bewertung] From e9bf2d6021bceb2487d414b065f51c9fe0be6e78 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 17 Mar 2026 10:59:10 +0100 Subject: [PATCH 44/91] Cleanup. --- src/components/documents/ChoiceAnswer/index.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 005a0d79f..017e081eb 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -101,9 +101,6 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const numCorrectDecisions = _.range(0, props.numOptions).filter((optionIndex) => { const isCorrect = correctOptions.has(optionIndex + 1); // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. const isSelected = selectedOptions.has(optionIndex); - console.log( - `Option ${optionIndex + 1}: isCorrect=${isCorrect}, isSelected=${isSelected}` - ); return (isCorrect && isSelected) || (!isCorrect && !isSelected); }).length; @@ -125,7 +122,6 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } } - console.log(`Grading question ${questionIndex}:`, grading.result); doc.updateGrading(questionIndex, grading); }, [doc?.choices]); From 887a42623376fc4f31bfbc29ca436357580dd99b Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 17 Mar 2026 11:00:47 +0100 Subject: [PATCH 45/91] Fix reset. --- src/components/documents/ChoiceAnswer/Controls.tsx | 2 +- src/models/documents/ChoiceAnswer.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/documents/ChoiceAnswer/Controls.tsx b/src/components/documents/ChoiceAnswer/Controls.tsx index 340200e0f..f21bb54f6 100644 --- a/src/components/documents/ChoiceAnswer/Controls.tsx +++ b/src/components/documents/ChoiceAnswer/Controls.tsx @@ -44,7 +44,7 @@ const Controls = observer(({ doc, questionIndex, focussedQuestion: isFocussedQue confirmText="Antwort zurücksetzen?" onConfirm={() => { doc.graded = false; - doc.resetAnswer(questionIndex); + doc.resetAllAnswers(); }} /> )} diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index a8bb1339a..fd0699064 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -117,6 +117,13 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { this.save(); } + @action + resetAllAnswers(): void { + this.updatedAt = new Date(); + this.choices = {}; + this.save(); + } + @action resetAnswer(questionIndex: number): void { this.updatedAt = new Date(); From 5a79c29f317c586f3faeebcfa3ed4e4c5e0b753b Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 17 Mar 2026 11:15:54 +0100 Subject: [PATCH 46/91] Map true/false answer correct state. --- src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx index e00adecd2..f70db8fb2 100644 --- a/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx +++ b/src/components/documents/ChoiceAnswer/TrueFalseAnswer.tsx @@ -2,9 +2,9 @@ import { observer } from 'mobx-react-lite'; import ChoiceAnswer, { ChoiceAnswerProps } from '.'; import React from 'react'; -const TrueFalseAnswer = observer((props: ChoiceAnswerProps) => { +const TrueFalseAnswer = observer((props: ChoiceAnswerProps & { correct: boolean }) => { return ( - + {props.children} Richtig From 33f1ec37e540392278d278c11ecb8e73c002cc05 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 17 Mar 2026 12:48:36 +0100 Subject: [PATCH 47/91] Move controls for quiz. --- .../documents/ChoiceAnswer/Controls.tsx | 66 +++++++++++++++---- .../documents/ChoiceAnswer/Quiz.tsx | 2 + .../documents/ChoiceAnswer/index.tsx | 8 ++- .../documents/ChoiceAnswer/styles.module.scss | 10 +++ 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Controls.tsx b/src/components/documents/ChoiceAnswer/Controls.tsx index f21bb54f6..ce8081f90 100644 --- a/src/components/documents/ChoiceAnswer/Controls.tsx +++ b/src/components/documents/ChoiceAnswer/Controls.tsx @@ -10,48 +10,92 @@ interface Props { doc: ChoiceAnswerDocument; questionIndex: number; focussedQuestion?: boolean; + inQuiz?: boolean; } -const Controls = observer(({ doc, questionIndex, focussedQuestion: isFocussedQuestion }: Props) => { +const QuestionControls = observer(({ doc, focussedQuestion: isFocussedQuestion, inQuiz }: Props) => { if (!doc) { return; } - // TODO: Potentially factor out grade / reset buttons, since they shouldn't show on a per-question basis in case of a quiz. - // TODO: Hide grade / reset button per question in quiz, show on quiz level. - const syncStatus = isFocussedQuestion && ( -
                          - + const syncStatus = isFocussedQuestion && ; + + // TODO: Hide if in quiz. + const checkOrResetButton = !inQuiz && ( + <> {!doc.graded && (
                          + } + > +
                          + {!grading?.points &&

                          Für diese Frage besteht keine Bewertung.

                          } + {!!grading?.points?.gradingHint && + (typeof grading.points?.gradingHint === 'function' + ? grading.points.gradingHint() + : grading.points?.gradingHint)} +
                          + + + ); +}); diff --git a/src/components/documents/ChoiceAnswer/grading.ts b/src/components/documents/ChoiceAnswer/grading.tsx similarity index 67% rename from src/components/documents/ChoiceAnswer/grading.ts rename to src/components/documents/ChoiceAnswer/grading.tsx index 33a2b5da6..30955c6b7 100644 --- a/src/components/documents/ChoiceAnswer/grading.ts +++ b/src/components/documents/ChoiceAnswer/grading.tsx @@ -4,6 +4,7 @@ import { ChoiceAnswerResult } from '@tdev-models/documents/ChoiceAnswer'; import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; +import clsx from 'clsx'; import _ from 'es-toolkit/compat'; export type GradingFunction = (result: ChoiceAnswerResult, numMistakes: number) => ChoiceAnswerPoints; @@ -13,22 +14,48 @@ export const points: ( forIncorrect?: number, forUnanswered?: number ) => GradingFunction = (forCorrect = 1, forIncorrect = 0, forUnanswered = 0) => { + const gradingHint = () => ( +
                            +
                          • + {forCorrect}{' '} + {forCorrect === 1 ? 'Punkt' : 'Punkte'} wenn richtig +
                          • +
                          • + {forIncorrect}{' '} + {forIncorrect === 1 ? 'Punkt' : 'Punkte'} wenn falsch{' '} +
                          • +
                          • + {forUnanswered}{' '} + {forUnanswered === 1 ? 'Punkt' : 'Punkte'} wenn nicht beantwortet +
                          • +
                          + ); + const template: ChoiceAnswerPoints = { + maxPoints: forCorrect, + pointsAchieved: 0, + gradingHint + }; + return (result) => { switch (result) { case ChoiceAnswerResult.Correct: - return { maxPoints: forCorrect, pointsAchieved: forCorrect }; + return { ...template, pointsAchieved: forCorrect }; case ChoiceAnswerResult.PartiallyCorrect: case ChoiceAnswerResult.Incorrect: - return { maxPoints: forCorrect, pointsAchieved: forIncorrect }; + return { ...template, pointsAchieved: forIncorrect }; case ChoiceAnswerResult.NA: - return { maxPoints: forCorrect, pointsAchieved: forUnanswered }; + return { ...template, pointsAchieved: forUnanswered }; default: - return { maxPoints: 0, pointsAchieved: 0 }; + console.warn( + `Unhandled grading result '${result}' in points() grading function. This should not happen.` + ); + return { ...template }; } }; }; -export const partialPoints = () => { +export const partialPoints = (points: { [key: number]: number }) => { + // TODO: Implement. return (result: ChoiceAnswerResult, numMistakes: number) => { return undefined; }; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 380f015df..9e4e02d93 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -15,6 +15,7 @@ import { createRandomOrderMap } from './helpers'; import QuestionControls from './Controls'; import { FeedbackAdmonition, FeedbackBadge } from './Feedback'; import { GradingFunction, updateGrading as grade } from './grading'; +import { QuestionGradingHint } from './Hints'; export interface ChoiceAnswerProps { id: string; @@ -169,6 +170,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { inQuiz={props.inQuiz} /> +
                          )} diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/styles.module.scss index 8607c9638..24c98b902 100644 --- a/src/components/documents/ChoiceAnswer/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/styles.module.scss @@ -159,3 +159,26 @@ $container-padding-left-right: 1em; margin-right: 0.5em; } } + +.gradingHintTrigger { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.gradingHintPopupHeader { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + h3 { + margin: 0; + } + + .content { + padding: 1em; + } +} diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 29981b0e9..385131c4f 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -3,6 +3,7 @@ import { TypeMeta } from '@tdev-models/DocumentRoot'; import iDocument, { Source } from '@tdev-models/iDocument'; import DocumentStore from '@tdev-stores/DocumentStore'; import { action, computed, observable } from 'mobx'; +import type { ReactElement } from 'react'; export interface ChoiceAnswerChoices { [questionIndex: number]: number[]; @@ -30,6 +31,7 @@ export enum ChoiceAnswerResult { export interface ChoiceAnswerPoints { maxPoints: number; pointsAchieved: number; + gradingHint?: string | (() => ReactElement); } export interface ChoiceAnswerGrading { From 8e6bec4aa2cde4caf983bcf78453cf80c716672a Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 23 Mar 2026 10:39:41 +0100 Subject: [PATCH 56/91] Add some documentation. --- .../persistable-documents/answer/choice-answer/index.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 639f35231..c683b6829 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -356,13 +356,17 @@ Eine `GradingFunction` ist eine Funktion, die die Bewertung einer Frage oder ein - `points(pointsForCorrect: number, pointsForIncorrect: number, pointsForUnanswered: number)`: Bewertet die Frage mit einer festen Punktzahl für richtig, falsch und nicht beantwortet (z.B. `points(1, 0, 0)` für 1 Punkt bei richtiger Antwort und 0 Punkte bei falscher oder nicht beantworteter Frage). Die Standard-Wertung (`points()`) entspricht `points(1, 0, 0)`. - `partialPoints()`: Bewertet die Frage mit Teilpunktzahlen. TODO. +- `noPoints()`: Bewertet die Frage ohne Punktevergabe (richtig/falsch wird aber weiterhin angezeigt). Diese Funktion ist dann sinnvoll, wenn für das übergeordnete Quiz eine Bewertungsfunktion definiert ist, die aber für diese spezifische Frage nicht gelten soll. ## TODO - Bei MC-Aufgaben sind Teilpunktzahlen möglich. - - TODO TODO: Standalone MC-Fragen müssten demnach auch nicht nur mit richtig und falsch, sondern mit "teilweise richtig" bewertet werden? - Ein Quiz kann auf ein Karussell reduziert werden, um Platz zu sparen. - Der Korrekturknopf eines Quiz kann versteckt oder mit einer Permission geschützt werden. ## Future Work +- Es könnte nützlich sein, das Zurücksetzen eines abgeschlossenen Quiz zu unterbinden oder mit einer Permission zu schützen. Falls kein Zurücksetzen möglich ist, sollte statt der Confirmation vermutlich ein Popup angezeigt werden, um Missverständnisse zu vermeiden. +- Die `correct`-Logik von MC-Fragen könnte so ausgeweitet werden, dass mehrere Korrekt-Sets möglich sind. Das könnte für Fragen der Art `Wählen Sie aus den folgenden 12 Protokollen zwei aus, die zur selben Schicht des TCP/IP-Stacks gehören` nützlich sein. In dem Zusammenhang könnte es auch sinnvoll sein, eine maximale Anzahl an auswählbaren Antworten zu definieren (z.B. `maxSelectable={2}`), um die Schüler:innen darauf hinzuweisen, dass sie nur zwei Antworten auswählen dürfen. +- Bei jeder Frage kann eine Funktion angegeben werden, die anhand der Bewertung einen entsprechenden Antworthinweis liefert. Bei einer richtigen Antwort könnte so z.B. eine vertiefende Erklärung geliefert, bei einer falschen Antwort ein Lösungshinweis angeboten werden. Dieser Funktion sollten möglichst viele Bewertungsinformationen zur Verfügung stehen (wobei zu berücksichtigen ist, dass es in Zukunft nicht unbedingt möglich sein wird, auf Ebene der einzelnen Antwortoption zu entscheiden, ob diese korrekt ist oder nicht). Es sollen zudem sinnvolle Default-Funktionen bereitgestellt werden, wie dies beispielsweise bei der `GradingFunction` der Fall ist. - Statt die korrekten Antworten direkt in der Komponente zu markieren, soll bei einem Quiz auch angegeben werden können, dass die Lösung extern abgespeichert ist. In diesem Fall verfügt die Gruppe über ein Upload-Feld, über welches das entsprechende Lösungs-File hochgeladen werden kann. Die Lösungen werden nach erfolgreichem Upload im LocalStore gespeichert, damit z.B. bei Prüfungen mehrere Schüler:innen korrigiert werden können. Für Admins steht zudem ein Download-Button bereit, mit dem sie ein Template für die Lösungen einer spezifischen Gruppe herunterladen können. -- Allgemeine Überlegung: Im Sinne einer Autokorrektur für Prüfungen soll das `Quiz` eine Funktion anbieten, die ein Lösungsdokument (z.B. als Teil eines Lösungsdokuments für die gesamte Prüfung) entgegennimmt und als Antwort eine Punktzahl und z.B. einen Report in Form `4 richtig | 1 falsch | 0 nicht beantwortet` zurückgibt. Dies könnte dann als Korrektur für diese Aufgabe in Korrektur-Document des entsprechenden Schülers eingetragen werden (während z.B. bei Textaufgaben eine manuelle Feedback- und Punkteeingabe durch die Lehrperson erfolgt). \ No newline at end of file +- Allgemeine Überlegung: Im Sinne einer Autokorrektur für Prüfungen soll das `Quiz` eine Funktion anbieten, die ein Lösungsdokument (z.B. als Teil eines Lösungsdokuments für die gesamte Prüfung) entgegennimmt und als Antwort eine Punktzahl und z.B. einen Report in Form `4 richtig | 1 falsch | 0 nicht beantwortet` zurückgibt. Dies könnte dann als Korrektur für diese Aufgabe in Korrektur-Document des entsprechenden Schülers eingetragen werden (während z.B. bei Textaufgaben eine manuelle Feedback- und Punkteeingabe durch die Lehrperson erfolgt). +- Für Prüfungen könnte es nützlich sein, das Konzept des Quiz zu erweitern, um nicht nur `ChoiceAnswer`-Fragen zu unterstützen, sondern auch andere Fragetypen wie z.B. `TextAnswer` oder `CodeAnswer`. So könnte eine Prüfung aus einem einzigen Quiz bestehen, in dem auch Fragen vorkommen, die nicht automatisch korrigiert werden können. Der Vorteil dabei wäre, dass so auch diese Fragen in die Randomisierung der Reihenfolge mit einbezogen werden könnten. Zudem könnte ein Report (PDF, druckbar) generiert werden, der die Ergebnisse aller automatisch korrigierten Fragen enthält, und für das Feedback zu den manuell zu korrigierenden Fragen einen Platzhalter lässt. \ No newline at end of file From f00152cb26822735eb3afd18f586adc5877a4b7d Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 23 Mar 2026 10:58:50 +0100 Subject: [PATCH 57/91] Implement MC grading. --- .../documents/ChoiceAnswer/grading.tsx | 42 +++++++++++++++++-- .../answer/choice-answer/Playground.mdx | 4 +- .../answer/choice-answer/index.mdx | 4 +- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/grading.tsx b/src/components/documents/ChoiceAnswer/grading.tsx index 30955c6b7..6b5389172 100644 --- a/src/components/documents/ChoiceAnswer/grading.tsx +++ b/src/components/documents/ChoiceAnswer/grading.tsx @@ -54,10 +54,44 @@ export const points: ( }; }; -export const partialPoints = (points: { [key: number]: number }) => { - // TODO: Implement. - return (result: ChoiceAnswerResult, numMistakes: number) => { - return undefined; +export const multipleChoicePoints = ( + maxPoints: number, + deductionPerWrongChoice: number, + allowNegativeTotal: boolean = false +) => { + const gradingHint = () => ( +
                            +
                          • + {maxPoints}{' '} + {maxPoints === 1 ? 'Punkt' : 'Punkte'} wenn alle Antworten richtig sind +
                          • +
                          • + {deductionPerWrongChoice}{' '} + {deductionPerWrongChoice === 1 ? 'Punkt' : 'Punkte'} Abzug pro falscher Auswahl / + Nicht-Auswahl +
                          • + {allowNegativeTotal && ( +
                          • + Die Gesamtpunktzahl kann negativ sein, wenn mehr falsche als richtige Antworten + ausgewählt wurden. +
                          • + )} + {!allowNegativeTotal && ( +
                          • + Es werden nicht weniger als 0 Punkte vergeben. +
                          • + )} +
                          + ); + + return (_: ChoiceAnswerResult, numMistakes: number) => { + const points = maxPoints - numMistakes * deductionPerWrongChoice; + const finalPoints = allowNegativeTotal ? points : Math.max(points, 0); + return { + maxPoints, + pointsAchieved: finalPoints, + gradingHint + }; }; }; diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx index 6d54c9fd4..291408087 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx @@ -9,7 +9,7 @@ import BrowserWindow from '@tdev-components/BrowserWindow'; import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer'; import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAnswer'; import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; -import { points, partialPoints, noPoints } from '@tdev-components/documents/ChoiceAnswer/grading'; +import { points, multipleChoicePoints, noPoints } from '@tdev-components/documents/ChoiceAnswer/grading'; # Playground Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. @@ -33,7 +33,7 @@ Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. > Mayonnaise ist ein Instrument. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index c683b6829..ae6d13ef8 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -9,7 +9,7 @@ import BrowserWindow from '@tdev-components/BrowserWindow'; import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer'; import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAnswer'; import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; -import { points, partialPoints } from '@tdev-components/documents/ChoiceAnswer/grading'; +import { points, multipleChoicePoints } from '@tdev-components/documents/ChoiceAnswer/grading'; # Choice Answer Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. @@ -229,7 +229,7 @@ import { points } from '@tdev-components/documents/ChoiceAnswer/grading'; > HTML ist eine Programmiersprache. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP From d6e635331a66270183e453503fa36975526067fd Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 23 Mar 2026 11:08:07 +0100 Subject: [PATCH 58/91] Implement lower points limit for quiz. --- .../documents/ChoiceAnswer/Feedback.tsx | 42 +++++++++++-------- .../documents/ChoiceAnswer/Quiz.tsx | 3 +- .../answer/choice-answer/Playground.mdx | 4 +- .../answer/choice-answer/index.mdx | 3 +- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Feedback.tsx b/src/components/documents/ChoiceAnswer/Feedback.tsx index 9e60c0e3d..4c1dd4fcc 100644 --- a/src/components/documents/ChoiceAnswer/Feedback.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback.tsx @@ -53,27 +53,33 @@ export const FeedbackBadge = observer( } ); -export const QuizGrading = observer(({ doc }: { doc: ChoiceAnswerDocument }) => { - if (!doc || doc.gradings.size === 0) { - return; - } +export const QuizGrading = observer( + ({ doc, minPoints }: { doc: ChoiceAnswerDocument; minPoints?: number }) => { + if (!doc || doc.gradings.size === 0) { + return; + } - let totalPointsAchieved = 0; - let totalMaxPoints = 0; - doc.gradings.forEach((grading) => { - if (grading.points) { - totalPointsAchieved += grading.points.pointsAchieved; - totalMaxPoints += grading.points.maxPoints; + let totalPointsAchieved = 0; + let totalMaxPoints = 0; + doc.gradings.forEach((grading) => { + if (grading.points) { + totalPointsAchieved += grading.points.pointsAchieved; + totalMaxPoints += grading.points.maxPoints; + } + }); + + if (minPoints !== undefined) { + totalPointsAchieved = Math.max(totalPointsAchieved, minPoints); } - }); - return ( - - {doc.graded && {totalPointsAchieved}/} - {totalMaxPoints} {totalMaxPoints === 1 ? 'Punkt' : 'Punkte'} - - ); -}); + return ( + + {doc.graded && {totalPointsAchieved}/} + {totalMaxPoints} {totalMaxPoints === 1 ? 'Punkt' : 'Punkte'} + + ); + } +); const CorrectIcon = (): React.JSX.Element => { return ( diff --git a/src/components/documents/ChoiceAnswer/Quiz.tsx b/src/components/documents/ChoiceAnswer/Quiz.tsx index dec754cdd..9a7028cec 100644 --- a/src/components/documents/ChoiceAnswer/Quiz.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz.tsx @@ -19,6 +19,7 @@ interface Props { randomizeOptions?: boolean; randomizeQuestions?: boolean; grading?: GradingFunction; + minPoints?: number; numQuestions: number; children?: React.ReactNode[]; } @@ -82,7 +83,7 @@ const Quiz = observer((props: Props) => { >
                          {props.children}
                          - +
                          diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx index 291408087..671e71923 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx @@ -14,7 +14,7 @@ import { points, multipleChoicePoints, noPoints } from '@tdev-components/documen # Playground Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. - + > In welchem Jahr war 2024? @@ -33,7 +33,7 @@ Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. > Mayonnaise ist ein Instrument. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index ae6d13ef8..1a800f61b 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -350,12 +350,13 @@ import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; | `randomizeOptions` | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge angezeigt (analog zu `ChoiceAnswer.randomizeOptions` für einzelne Fragen). | | `carrousel` (TODO) | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | | `grading` | `GradingFunction` | Eine vordefinierte oder benutzerdefinierte `GradingFunction`. | +| `minPoints` | `number` | Die minimale Punktzahl, die für das Quiz erreicht werden kann. Kann z.B. genutzt werden, um bei Fragen mit Minuspunkten sicherzustellen, dass das gesamte Quiz nicht mit einer negativen Punktzahl bewertet wird. Standard: `undefined`. | ### `GradingFunction` Eine `GradingFunction` ist eine Funktion, die die Bewertung einer Frage oder eines Quizzes übernimmt. Vordefinierte Grading-Funktionen sind: - `points(pointsForCorrect: number, pointsForIncorrect: number, pointsForUnanswered: number)`: Bewertet die Frage mit einer festen Punktzahl für richtig, falsch und nicht beantwortet (z.B. `points(1, 0, 0)` für 1 Punkt bei richtiger Antwort und 0 Punkte bei falscher oder nicht beantworteter Frage). Die Standard-Wertung (`points()`) entspricht `points(1, 0, 0)`. -- `partialPoints()`: Bewertet die Frage mit Teilpunktzahlen. TODO. +- `multipleChoicePoints(maxPoints: number, deductionPerWrongChoice: number, allowNegativeTotal: boolean = false)`: Bewertet eine Frage mit der gegebenen Maximalpunktzahl, minus einer Abzugsrate pro falscher Entscheidung (fälschlicherweise ausgewählt, oder fälschlicherweise nicht ausgewählt). Standardmässig wird die Frage nicht mit weniger als 0 Punkten bewertet, was mit `allowNegativeTotal` jedoch übersteuert werden kann. - `noPoints()`: Bewertet die Frage ohne Punktevergabe (richtig/falsch wird aber weiterhin angezeigt). Diese Funktion ist dann sinnvoll, wenn für das übergeordnete Quiz eine Bewertungsfunktion definiert ist, die aber für diese spezifische Frage nicht gelten soll. ## TODO From b183eec9ef760b131e2b9f0a04de3b23135baed1 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 23 Mar 2026 11:10:45 +0100 Subject: [PATCH 59/91] Prevent answer changes when not allowed. --- src/models/documents/ChoiceAnswer.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 385131c4f..e30b53170 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -96,6 +96,10 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { @action updateSingleChoiceSelection(questionIndex: number, optionIndex: number): void { + if (!this.canUpdateAnswer) { + return; + } + this.updatedAt = new Date(); this.choices = { ...this.choices, @@ -106,6 +110,10 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { @action updateMultipleChoiceSelection(questionIndex: number, optionIndex: number, selected: boolean): void { + if (!this.canUpdateAnswer) { + return; + } + this.updatedAt = new Date(); const currentSelections = new Set(this.choices[questionIndex] as number[] | []); if (selected) { @@ -122,6 +130,10 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { @action resetAllAnswers(): void { + if (!this.canUpdateAnswer) { + return; + } + this.updatedAt = new Date(); this.choices = {}; this.save(); @@ -129,6 +141,10 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { @action resetAnswer(questionIndex: number): void { + if (!this.canUpdateAnswer) { + return; + } + this.updatedAt = new Date(); this.choices = { ...this.choices, From e414aaaecae629d4b6a4b09abe785f15ea91d884 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 23 Mar 2026 11:11:59 +0100 Subject: [PATCH 60/91] Cleanup. --- src/components/documents/ChoiceAnswer/grading.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/grading.tsx b/src/components/documents/ChoiceAnswer/grading.tsx index 6b5389172..2258f017c 100644 --- a/src/components/documents/ChoiceAnswer/grading.tsx +++ b/src/components/documents/ChoiceAnswer/grading.tsx @@ -72,13 +72,12 @@ export const multipleChoicePoints = ( {allowNegativeTotal && (
                        • - Die Gesamtpunktzahl kann negativ sein, wenn mehr falsche als richtige Antworten - ausgewählt wurden. + Die Gesamtpunktzahl kann negativ sein.
                        • )} {!allowNegativeTotal && (
                        • - Es werden nicht weniger als 0 Punkte vergeben. + Die Gesamtpunktzahl kann nicht negativ sein.
                        • )}
                        From d9da2b7fe637c51fdb430813e2f5f4d044a835eb Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Mon, 23 Mar 2026 13:56:12 +0100 Subject: [PATCH 61/91] Fix some grading/verification edge cases. --- .../documents/ChoiceAnswer/Controls.tsx | 2 +- .../documents/ChoiceAnswer/Feedback.tsx | 5 +++ .../documents/ChoiceAnswer/grading.tsx | 31 ++++++++++--------- .../documents/ChoiceAnswer/index.tsx | 22 +++++++------ 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Controls.tsx b/src/components/documents/ChoiceAnswer/Controls.tsx index aa3e6ce91..eba5dac04 100644 --- a/src/components/documents/ChoiceAnswer/Controls.tsx +++ b/src/components/documents/ChoiceAnswer/Controls.tsx @@ -65,7 +65,7 @@ const QuestionControls = observer(({ doc, focussedQuestion: isFocussedQuestion, export const QuizCheckOrResetButton = observer(({ doc }: { doc: ChoiceAnswerDocument }) => { return (
                        - {!doc.graded && ( + {!doc.graded && doc.gradings.size > 0 && ( 0) { - const numCorrectDecisions = _.range(0, numOptions).filter((optionIndex) => { - const isCorrect = correctOptions.has(optionIndex + 1); // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. - const isSelected = selectedOptions.has(optionIndex); - return (isCorrect && isSelected) || (!isCorrect && !isSelected); - }).length; - numMistakes = numOptions - numCorrectDecisions; + const numCorrectDecisions = _.range(0, numOptions).filter((optionIndex) => { + const isCorrect = correctOptions.has(optionIndex + 1); // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. + const isSelected = selectedOptions.has(optionIndex); + return (isCorrect && isSelected) || (!isCorrect && !isSelected); + }).length; + numMistakes = numOptions - numCorrectDecisions; - grading.result = - numCorrectDecisions === numOptions - ? ChoiceAnswerResult.Correct - : numCorrectDecisions > 0 - ? ChoiceAnswerResult.PartiallyCorrect - : ChoiceAnswerResult.Incorrect; - } + grading.result = + numCorrectDecisions === numOptions + ? ChoiceAnswerResult.Correct + : numCorrectDecisions > 0 + ? ChoiceAnswerResult.PartiallyCorrect + : ChoiceAnswerResult.Incorrect; } else { + if (correctOptions.size === 0) { + console.warn( + `Question ${questionIndex} has an empty list of correct options. This is not allowed for single-choice questions and may lead to unexpected grading results (no options selected = question not answered).` + ); + } const selectedOption = doc?.choices[questionIndex]?.[0]; if (selectedOption === undefined) { grading.result = ChoiceAnswerResult.NA; diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/index.tsx index 9e4e02d93..34693b86e 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/index.tsx @@ -84,12 +84,12 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { if (!doc) { return; } - const correctOptions = new Set(props.correct || []); - if (correctOptions.size === 0) { + + if (props.correct === undefined) { // If no correct options are given, we assume that this question doesn't support grading. - // TODO: Factor out this logic and don't show the grading button in that case. return; } + const correctOptions = new Set(props.correct); const gradingFunction = props.grading ?? parentProps.grading; const grading = grade( @@ -163,18 +163,20 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
                        {title}
                        - + {!!props.correct && ( + + )}
                        )} - {!title && ( + {!title && !!props.correct && ( Date: Tue, 24 Mar 2026 07:56:11 +0100 Subject: [PATCH 62/91] Restructure. --- .../ChoiceAnswer/{ => Component}/index.tsx | 14 +- .../{ => Component}/styles.module.scss | 79 +------- .../documents/ChoiceAnswer/Controls.tsx | 101 ----------- .../documents/ChoiceAnswer/Controls/index.tsx | 108 +++++++++++ .../ChoiceAnswer/Controls/styles.module.scss | 27 +++ .../{Feedback.tsx => Feedback/index.tsx} | 168 +++++++++--------- .../ChoiceAnswer/Feedback/styles.module.scss | 10 ++ .../{Hints.tsx => Hints/index.tsx} | 0 .../ChoiceAnswer/Hints/styles.module.scss | 22 +++ .../ChoiceAnswer/{Quiz.tsx => Quiz/index.tsx} | 14 +- .../ChoiceAnswer/Quiz/styles.module.scss | 13 ++ .../index.tsx} | 2 +- .../documents/ChoiceAnswer/_vars.scss | 2 + .../ChoiceAnswer/{ => helpers}/grading.tsx | 0 .../{helpers.ts => helpers/shared.ts} | 0 .../answer/choice-answer/Playground.mdx | 15 +- .../answer/choice-answer/index.mdx | 4 +- 17 files changed, 302 insertions(+), 277 deletions(-) rename src/components/documents/ChoiceAnswer/{ => Component}/index.tsx (95%) rename src/components/documents/ChoiceAnswer/{ => Component}/styles.module.scss (63%) delete mode 100644 src/components/documents/ChoiceAnswer/Controls.tsx create mode 100644 src/components/documents/ChoiceAnswer/Controls/index.tsx create mode 100644 src/components/documents/ChoiceAnswer/Controls/styles.module.scss rename src/components/documents/ChoiceAnswer/{Feedback.tsx => Feedback/index.tsx} (62%) create mode 100644 src/components/documents/ChoiceAnswer/Feedback/styles.module.scss rename src/components/documents/ChoiceAnswer/{Hints.tsx => Hints/index.tsx} (100%) create mode 100644 src/components/documents/ChoiceAnswer/Hints/styles.module.scss rename src/components/documents/ChoiceAnswer/{Quiz.tsx => Quiz/index.tsx} (88%) create mode 100644 src/components/documents/ChoiceAnswer/Quiz/styles.module.scss rename src/components/documents/ChoiceAnswer/{TrueFalseAnswer.tsx => TrueFalseAnswer/index.tsx} (90%) create mode 100644 src/components/documents/ChoiceAnswer/_vars.scss rename src/components/documents/ChoiceAnswer/{ => helpers}/grading.tsx (100%) rename src/components/documents/ChoiceAnswer/{helpers.ts => helpers/shared.ts} (100%) diff --git a/src/components/documents/ChoiceAnswer/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx similarity index 95% rename from src/components/documents/ChoiceAnswer/index.tsx rename to src/components/documents/ChoiceAnswer/Component/index.tsx index 34693b86e..4711e01b2 100644 --- a/src/components/documents/ChoiceAnswer/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -7,15 +7,15 @@ 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 { QuizContext } from '../Quiz'; import Button from '@tdev-components/shared/Button'; import { mdiTrashCanOutline } from '@mdi/js'; import _ from 'es-toolkit/compat'; -import { createRandomOrderMap } from './helpers'; -import QuestionControls from './Controls'; -import { FeedbackAdmonition, FeedbackBadge } from './Feedback'; -import { GradingFunction, updateGrading as grade } from './grading'; -import { QuestionGradingHint } from './Hints'; +import { createRandomOrderMap } from '../helpers/shared'; +import QuestionControls from '../Controls'; +import { FeedbackAdmonition, FeedbackBadge } from '../Feedback'; +import { GradingFunction, updateGrading as grade } from '../helpers/grading'; +import { QuestionGradingHint } from '../Hints'; export interface ChoiceAnswerProps { id: string; @@ -169,6 +169,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { questionIndex={questionIndex} focussedQuestion={parentProps.focussedQuestion === questionIndex} inQuiz={props.inQuiz} + inHeader /> )} @@ -182,6 +183,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { questionIndex={questionIndex} focussedQuestion={parentProps.focussedQuestion === questionIndex} inQuiz={props.inQuiz} + inHeader={false} /> )} diff --git a/src/components/documents/ChoiceAnswer/styles.module.scss b/src/components/documents/ChoiceAnswer/Component/styles.module.scss similarity index 63% rename from src/components/documents/ChoiceAnswer/styles.module.scss rename to src/components/documents/ChoiceAnswer/Component/styles.module.scss index 24c98b902..f733fe8a8 100644 --- a/src/components/documents/ChoiceAnswer/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/Component/styles.module.scss @@ -1,5 +1,4 @@ -$container-padding-top-bottom: 0.7em; -$container-padding-left-right: 1em; +@use '../_vars' as *; .choiceAnswerContainer { position: relative; @@ -60,13 +59,7 @@ $container-padding-left-right: 1em; display: flex; flex-direction: row; align-items: center; - gap: 0.5em; - } - - .controlsContainer { - position: relative; - top: 0; - right: 0; + gap: 0.2em; } } } @@ -114,71 +107,3 @@ $container-padding-left-right: 1em; } } } - -.quizContainer { - display: flex; - flex-direction: column; -} - -.quizFooter { - display: flex; - flex-direction: row; - justify-content: flex-end; - align-items: center; - margin-top: 0.5em; - gap: 0.5em; -} - -.controlsContainer { - position: absolute; - top: $container-padding-top-bottom; - right: $container-padding-left-right; - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5em; -} - -.checkButton { - z-index: 9999; -} - -.quizCheckOrResetButtonContainer { - display: flex; - flex-direction: row; - justify-content: flex-end; -} - -.feedbackBadge { - display: flex; - height: 100%; - align-items: center; - justify-content: center; - - .pointsBadge { - margin-right: 0.5em; - } -} - -.gradingHintTrigger { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.gradingHintPopupHeader { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - - h3 { - margin: 0; - } - - .content { - padding: 1em; - } -} diff --git a/src/components/documents/ChoiceAnswer/Controls.tsx b/src/components/documents/ChoiceAnswer/Controls.tsx deleted file mode 100644 index eba5dac04..000000000 --- a/src/components/documents/ChoiceAnswer/Controls.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import styles from './styles.module.scss'; -import { Confirm } from '@tdev-components/shared/Button/Confirm'; -import Button from '@tdev-components/shared/Button'; -import SyncStatus from '@tdev-components/SyncStatus'; -import { observer } from 'mobx-react-lite'; -import { mdiCheckboxMarkedCircleAutoOutline, mdiRestore } from '@mdi/js'; -import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; - -interface Props { - doc: ChoiceAnswerDocument; - questionIndex: number; - focussedQuestion?: boolean; - inQuiz?: boolean; -} - -const QuestionControls = observer(({ doc, focussedQuestion: isFocussedQuestion, inQuiz }: Props) => { - if (!doc) { - return; - } - - const syncStatus = isFocussedQuestion && ; - - // TODO: Hide if in quiz. - const checkOrResetButton = !inQuiz && ( - <> - {!doc.graded && ( -
                        )} diff --git a/src/components/documents/ChoiceAnswer/Component/styles.module.scss b/src/components/documents/ChoiceAnswer/Component/styles.module.scss index f733fe8a8..4d5f9291c 100644 --- a/src/components/documents/ChoiceAnswer/Component/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/Component/styles.module.scss @@ -59,7 +59,7 @@ display: flex; flex-direction: row; align-items: center; - gap: 0.2em; + gap: 0.5em; } } } diff --git a/src/components/documents/ChoiceAnswer/Feedback/index.tsx b/src/components/documents/ChoiceAnswer/Feedback/index.tsx index cac068817..348dba41f 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/index.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback/index.tsx @@ -6,6 +6,7 @@ import { observer } from 'mobx-react-lite'; import styles from './styles.module.scss'; import React from 'react'; import clsx from 'clsx'; +import { QuestionGradingHint } from '../Hints'; const CorrectIcon = (): React.JSX.Element => { return ( @@ -144,11 +145,18 @@ export const FeedbackBadge = observer(({ doc, questionIndex }: FeedbackBadgeProp return (
                        {grading.points && ( - - {doc.graded && {grading.points.pointsAchieved}/} - {grading.points.maxPoints} {grading.points.maxPoints === 1 ? 'Punkt' : 'Punkte'} - + + {doc.graded && {grading.points.pointsAchieved}/} + {grading.points.maxPoints} {grading.points.maxPoints === 1 ? 'Punkt' : 'Punkte'} + + } + /> )} + {!grading.points && } {icon && doc.graded && }
                        ); diff --git a/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss b/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss index e49334dbc..711e4255b 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss @@ -3,8 +3,9 @@ height: 100%; align-items: center; justify-content: center; + gap: 0.5em; .pointsBadge { - margin-right: 0.5em; + cursor: pointer; } } diff --git a/src/components/documents/ChoiceAnswer/Hints/index.tsx b/src/components/documents/ChoiceAnswer/Hints/index.tsx index d5b6f41a6..346f867d4 100644 --- a/src/components/documents/ChoiceAnswer/Hints/index.tsx +++ b/src/components/documents/ChoiceAnswer/Hints/index.tsx @@ -12,10 +12,11 @@ import styles from './styles.module.scss'; interface QuestionGradingHintProps { doc?: ChoiceAnswerDocument; + trigger?: React.ReactNode; questionIndex?: number; } -export const QuestionGradingHint = observer(({ doc, questionIndex }: QuestionGradingHintProps) => { +export const QuestionGradingHint = observer(({ doc, trigger, questionIndex }: QuestionGradingHintProps) => { const ref = React.useRef(null); if (!doc || questionIndex === undefined) { @@ -31,9 +32,11 @@ export const QuestionGradingHint = observer(({ doc, questionIndex }: QuestionGra return ( - - + trigger || ( + + + + ) } lockScroll closeOnEscape={true} From 5b73f7d318b62fbf079cad7c6540792b98b5d288 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 10:33:40 +0100 Subject: [PATCH 64/91] Remove standalone scoring hint if no questions have scoring. --- .../documents/ChoiceAnswer/Feedback/index.tsx | 8 +++++- .../answer/choice-answer/Playground.mdx | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/components/documents/ChoiceAnswer/Feedback/index.tsx b/src/components/documents/ChoiceAnswer/Feedback/index.tsx index 348dba41f..1defd2660 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/index.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback/index.tsx @@ -142,6 +142,10 @@ export const FeedbackBadge = observer(({ doc, questionIndex }: FeedbackBadgeProp break; } + const questionsWithScoringExist = Array.from(doc.gradings.values()).some( + (grading) => grading.points !== undefined + ); + return (
                        {grading.points && ( @@ -156,7 +160,9 @@ export const FeedbackBadge = observer(({ doc, questionIndex }: FeedbackBadgeProp } /> )} - {!grading.points && } + {!grading.points && questionsWithScoringExist && ( + + )} {icon && doc.graded && }
                        ); diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx index af3019c09..2a50788af 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx @@ -62,4 +62,32 @@ Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. 4. Nein 5. Ja, aber nur manchmal + + +## Quiz 2 +## Einzelfragen + + > Was ist die Hauptstadt von Frankreich? + + 1. Berlin + 2. Paris + 3. Rom + 4. Madrid + + +## Quiz + + + > In welchem Jahr war 2024? + + 1. 1965 + 2. 1983 + 3. 1991 + 4. 2000 + 5. 2024 + + + + > HTML ist eine Programmiersprache. + \ No newline at end of file From b07fd05a52fc8019bf030d9b13fc6300a5029144 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 10:43:31 +0100 Subject: [PATCH 65/91] Work on renaming. --- src/api/document.ts | 2 +- .../ChoiceAnswer/Component/index.tsx | 18 +++-- .../documents/ChoiceAnswer/Controls/index.tsx | 16 ++--- .../documents/ChoiceAnswer/Feedback/index.tsx | 69 ++++++++++--------- .../documents/ChoiceAnswer/Hints/index.tsx | 18 ++--- .../documents/ChoiceAnswer/Quiz/index.tsx | 4 +- .../ChoiceAnswer/helpers/grading.tsx | 44 ++++++------ src/models/documents/ChoiceAnswer.ts | 44 ++++++------ 8 files changed, 110 insertions(+), 105 deletions(-) diff --git a/src/api/document.ts b/src/api/document.ts index f6958259b..fd67b8280 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -49,7 +49,7 @@ export interface ChoiceAnswerData { choices: ChoiceAnswerChoices; optionOrders: ChoiceAnswerOptionOrders; questionOrder: ChoiceAnswerQuestionOrder | null; - graded: boolean; + assessed: boolean; } export interface QuillV2Data { diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index 4fbd496ae..0cf23ba30 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -1,5 +1,8 @@ import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; -import ChoiceAnswerDocument, { ChoiceAnswerResult, ModelMeta } from '@tdev-models/documents/ChoiceAnswer'; +import ChoiceAnswerDocument, { + ChoiceAnswerCorrectness, + ModelMeta +} from '@tdev-models/documents/ChoiceAnswer'; import { observer } from 'mobx-react-lite'; import React from 'react'; import clsx from 'clsx'; @@ -15,7 +18,7 @@ import { createRandomOrderMap } from '../helpers/shared'; import QuestionControls from '../Controls'; import { FeedbackAdmonition, FeedbackBadge } from '../Feedback'; import { GradingFunction, updateGrading as grade } from '../helpers/grading'; -import { QuestionGradingHint } from '../Hints'; +import { QuestionScoringHint } from '../Hints'; export interface ChoiceAnswerProps { id: string; @@ -101,12 +104,13 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { gradingFunction ); setGradingStyle({ - [styles.correct]: doc.graded && grading.result === ChoiceAnswerResult.Correct, - [styles.partiallyCorrect]: doc.graded && grading.result === ChoiceAnswerResult.PartiallyCorrect, - [styles.incorrect]: doc.graded && grading.result === ChoiceAnswerResult.Incorrect + [styles.correct]: doc.assessed && grading.correctness === ChoiceAnswerCorrectness.Correct, + [styles.partiallyCorrect]: + doc.assessed && grading.correctness === ChoiceAnswerCorrectness.PartiallyCorrect, + [styles.incorrect]: doc.assessed && grading.correctness === ChoiceAnswerCorrectness.Incorrect }); - doc.updateGrading(questionIndex, grading); - }, [doc, doc?.choices, doc?.graded]); + doc.updateAssessment(questionIndex, grading); + }, [doc, doc?.choices, doc?.assessed]); if (!doc) { return ; diff --git a/src/components/documents/ChoiceAnswer/Controls/index.tsx b/src/components/documents/ChoiceAnswer/Controls/index.tsx index b94df2cd8..79d2bc98a 100644 --- a/src/components/documents/ChoiceAnswer/Controls/index.tsx +++ b/src/components/documents/ChoiceAnswer/Controls/index.tsx @@ -25,7 +25,7 @@ const QuestionControls = observer( const checkOrResetButton = !inQuiz && ( <> - {!doc.graded && ( + {!doc.assessed && (
                      ); - const template: ChoiceAnswerPoints = { + const template: ChoiceAnswerScoring = { maxPoints: forCorrect, pointsAchieved: 0, - gradingHint + scoringHint: gradingHint }; return (result) => { switch (result) { - case ChoiceAnswerResult.Correct: + case ChoiceAnswerCorrectness.Correct: return { ...template, pointsAchieved: forCorrect }; - case ChoiceAnswerResult.PartiallyCorrect: - case ChoiceAnswerResult.Incorrect: + case ChoiceAnswerCorrectness.PartiallyCorrect: + case ChoiceAnswerCorrectness.Incorrect: return { ...template, pointsAchieved: forIncorrect }; - case ChoiceAnswerResult.NA: + case ChoiceAnswerCorrectness.NA: return { ...template, pointsAchieved: forUnanswered }; default: console.warn( @@ -83,7 +83,7 @@ export const multipleChoicePoints = (
                  ); - return (_: ChoiceAnswerResult, numMistakes: number) => { + return (_: ChoiceAnswerCorrectness, numMistakes: number) => { const points = maxPoints - numMistakes * deductionPerWrongChoice; const finalPoints = allowNegativeTotal ? points : Math.max(points, 0); return { @@ -107,8 +107,8 @@ export const updateGrading = ( gradingFunction?: GradingFunction ) => { let numMistakes = 0; - const grading: ChoiceAnswerGrading = { - result: ChoiceAnswerResult.NA + const grading: ChoiceAnswerAssessment = { + correctness: ChoiceAnswerCorrectness.NA }; if (multiple) { const selectedOptions = new Set(doc.choices[questionIndex] || []); @@ -119,12 +119,12 @@ export const updateGrading = ( }).length; numMistakes = numOptions - numCorrectDecisions; - grading.result = + grading.correctness = numCorrectDecisions === numOptions - ? ChoiceAnswerResult.Correct + ? ChoiceAnswerCorrectness.Correct : numCorrectDecisions > 0 - ? ChoiceAnswerResult.PartiallyCorrect - : ChoiceAnswerResult.Incorrect; + ? ChoiceAnswerCorrectness.PartiallyCorrect + : ChoiceAnswerCorrectness.Incorrect; } else { if (correctOptions.size === 0) { console.warn( @@ -133,16 +133,16 @@ export const updateGrading = ( } const selectedOption = doc?.choices[questionIndex]?.[0]; if (selectedOption === undefined) { - grading.result = ChoiceAnswerResult.NA; + grading.correctness = ChoiceAnswerCorrectness.NA; } else { - grading.result = correctOptions.has(selectedOption + 1) // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. - ? ChoiceAnswerResult.Correct - : ChoiceAnswerResult.Incorrect; + grading.correctness = correctOptions.has(selectedOption + 1) // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. + ? ChoiceAnswerCorrectness.Correct + : ChoiceAnswerCorrectness.Incorrect; } } if (gradingFunction) { - grading.points = gradingFunction(grading.result, numMistakes) ?? undefined; + grading.scoring = gradingFunction(grading.correctness, numMistakes) ?? undefined; } return grading; diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index e30b53170..226d51c21 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -21,22 +21,22 @@ export interface MetaInit { readonly?: boolean; } -export enum ChoiceAnswerResult { +export enum ChoiceAnswerCorrectness { Correct = 'correct', Incorrect = 'incorrect', PartiallyCorrect = 'partially_correct', NA = 'not_answered' } -export interface ChoiceAnswerPoints { +export interface ChoiceAnswerScoring { maxPoints: number; pointsAchieved: number; - gradingHint?: string | (() => ReactElement); + scoringHint?: string | (() => ReactElement); } -export interface ChoiceAnswerGrading { - result: ChoiceAnswerResult; - points?: ChoiceAnswerPoints; +export interface ChoiceAnswerAssessment { + correctness: ChoiceAnswerCorrectness; + scoring?: ChoiceAnswerScoring; } export class ModelMeta extends TypeMeta<'choice_answer'> { @@ -53,7 +53,7 @@ export class ModelMeta extends TypeMeta<'choice_answer'> { choices: {}, optionOrders: {}, questionOrder: null, - graded: false + assessed: false }; } } @@ -62,16 +62,16 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { @observable.ref accessor choices: ChoiceAnswerChoices; @observable.ref accessor optionOrders: ChoiceAnswerOptionOrders; @observable.ref accessor questionOrder: ChoiceAnswerQuestionOrder | null; - gradings = observable.map(); - @observable accessor _graded: boolean; + assessments = observable.map(); + @observable accessor _assessed: boolean; constructor(props: DocumentProps<'choice_answer'>, store: DocumentStore) { super(props, store); this.choices = props.data?.choices || {}; this.optionOrders = props.data?.optionOrders || {}; this.questionOrder = props.data?.questionOrder || null; - this._graded = props.data?.graded || false; - this.gradings = observable.map(); + this._assessed = props.data?.assessed || false; + this.assessments = observable.map(); } @action @@ -79,7 +79,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { this.choices = data.choices; this.optionOrders = data.optionOrders; this.questionOrder = data.questionOrder; - this._graded = data.graded; + this._assessed = data.assessed; if (from === Source.LOCAL) { this.save(); @@ -91,7 +91,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { @computed get canUpdateAnswer() { - return this.canEdit && !this._graded; + return this.canEdit && !this._assessed; } @action @@ -167,24 +167,24 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { this.saveNow(); } - get graded() { - return this._graded; + get assessed() { + return this._assessed; } @action - set graded(value: boolean) { + set assessed(value: boolean) { this.updatedAt = new Date(); - this._graded = value; + this._assessed = value; this.saveNow(); } @action - updateGrading(questionIndex: number, grading: ChoiceAnswerGrading): void { - this.gradings.set(questionIndex, grading); + updateAssessment(questionIndex: number, assessment: ChoiceAnswerAssessment): void { + this.assessments.set(questionIndex, assessment); } - getGrading(questionIndex: number): ChoiceAnswerGrading | undefined { - return this.gradings.get(questionIndex); + getAssessment(questionIndex: number): ChoiceAnswerAssessment | undefined { + return this.assessments.get(questionIndex); } get data(): TypeDataMapping['choice_answer'] { @@ -192,7 +192,7 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { choices: this.choices, optionOrders: this.optionOrders, questionOrder: this.questionOrder, - graded: this._graded + assessed: this._assessed }; } From 4b08937b01d1920a9afcef87630d6e9af221ebef Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 10:51:07 +0100 Subject: [PATCH 66/91] Split grading into scoring and assessment. --- .../ChoiceAnswer/Component/index.tsx | 12 ++-- .../documents/ChoiceAnswer/Quiz/index.tsx | 6 +- .../ChoiceAnswer/helpers/assessment.ts | 56 +++++++++++++++ .../helpers/{grading.tsx => scoring.tsx} | 71 +++---------------- .../answer/choice-answer/Playground.mdx | 2 +- .../answer/choice-answer/index.mdx | 2 +- 6 files changed, 75 insertions(+), 74 deletions(-) create mode 100644 src/components/documents/ChoiceAnswer/helpers/assessment.ts rename src/components/documents/ChoiceAnswer/helpers/{grading.tsx => scoring.tsx} (52%) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index 0cf23ba30..2c6a68b66 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -17,14 +17,14 @@ import _ from 'es-toolkit/compat'; import { createRandomOrderMap } from '../helpers/shared'; import QuestionControls from '../Controls'; import { FeedbackAdmonition, FeedbackBadge } from '../Feedback'; -import { GradingFunction, updateGrading as grade } from '../helpers/grading'; -import { QuestionScoringHint } from '../Hints'; +import { ScoringFunction } from '../helpers/scoring'; +import { updateAssessment } from '../helpers/assessment'; export interface ChoiceAnswerProps { id: string; title?: string; correct?: number[]; - grading?: GradingFunction; + grading?: ScoringFunction; questionIndex?: number; inQuiz?: boolean; multiple?: boolean; @@ -94,14 +94,14 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } const correctOptions = new Set(props.correct); - const gradingFunction = props.grading ?? parentProps.grading; - const grading = grade( + const scoringFunction = props.grading ?? parentProps.grading; + const grading = updateAssessment( doc, props.multiple ?? false, questionIndex, correctOptions, props.numOptions, - gradingFunction + scoringFunction ); setGradingStyle({ [styles.correct]: doc.assessed && grading.correctness === ChoiceAnswerCorrectness.Correct, diff --git a/src/components/documents/ChoiceAnswer/Quiz/index.tsx b/src/components/documents/ChoiceAnswer/Quiz/index.tsx index 238e417a0..b775b1642 100644 --- a/src/components/documents/ChoiceAnswer/Quiz/index.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz/index.tsx @@ -9,7 +9,7 @@ import Loader from '@tdev-components/Loader'; import { createRandomOrderMap } from '../helpers/shared'; import styles from './styles.module.scss'; import { QuizControls } from '../Controls'; -import { GradingFunction } from '../helpers/grading'; +import { ScoringFunction } from '../helpers/scoring'; import { QuizScore } from '../Feedback'; interface Props { @@ -18,7 +18,7 @@ interface Props { hideQuestionNumbers?: boolean; randomizeOptions?: boolean; randomizeQuestions?: boolean; - grading?: GradingFunction; + grading?: ScoringFunction; minPoints?: number; numQuestions: number; children?: React.ReactNode[]; @@ -38,7 +38,7 @@ export const QuizContext = React.createContext({ readonly?: boolean; hideQuestionNumbers?: boolean; randomizeQuestions?: boolean; - grading?: GradingFunction; + grading?: ScoringFunction; questionOrder: { [originalQuestionIndex: number]: number } | null; randomizeOptions?: boolean; focussedQuestion: number; diff --git a/src/components/documents/ChoiceAnswer/helpers/assessment.ts b/src/components/documents/ChoiceAnswer/helpers/assessment.ts new file mode 100644 index 000000000..7368f72fb --- /dev/null +++ b/src/components/documents/ChoiceAnswer/helpers/assessment.ts @@ -0,0 +1,56 @@ +import ChoiceAnswerDocument, { + ChoiceAnswerAssessment, + ChoiceAnswerCorrectness +} from '@tdev-models/documents/ChoiceAnswer'; +import { ScoringFunction } from './scoring'; +import _ from 'es-toolkit/compat'; + +export const updateAssessment = ( + doc: ChoiceAnswerDocument, + multiple: boolean, + questionIndex: number, + correctOptions: Set, + numOptions: number, + scoringFunction?: ScoringFunction +) => { + let numMistakes = 0; + const assessment: ChoiceAnswerAssessment = { + correctness: ChoiceAnswerCorrectness.NA + }; + if (multiple) { + const selectedOptions = new Set(doc.choices[questionIndex] || []); + const numCorrectDecisions = _.range(0, numOptions).filter((optionIndex) => { + const isCorrect = correctOptions.has(optionIndex + 1); // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. + const isSelected = selectedOptions.has(optionIndex); + return (isCorrect && isSelected) || (!isCorrect && !isSelected); + }).length; + numMistakes = numOptions - numCorrectDecisions; + + assessment.correctness = + numCorrectDecisions === numOptions + ? ChoiceAnswerCorrectness.Correct + : numCorrectDecisions > 0 + ? ChoiceAnswerCorrectness.PartiallyCorrect + : ChoiceAnswerCorrectness.Incorrect; + } else { + if (correctOptions.size === 0) { + console.warn( + `Question ${questionIndex} has an empty list of correct options. This is not allowed for single-choice questions and may lead to unexpected assessment results (no options selected = question not answered).` + ); + } + const selectedOption = doc?.choices[questionIndex]?.[0]; + if (selectedOption === undefined) { + assessment.correctness = ChoiceAnswerCorrectness.NA; + } else { + assessment.correctness = correctOptions.has(selectedOption + 1) // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. + ? ChoiceAnswerCorrectness.Correct + : ChoiceAnswerCorrectness.Incorrect; + } + } + + if (scoringFunction) { + assessment.scoring = scoringFunction(assessment.correctness, numMistakes) ?? undefined; + } + + return assessment; +}; diff --git a/src/components/documents/ChoiceAnswer/helpers/grading.tsx b/src/components/documents/ChoiceAnswer/helpers/scoring.tsx similarity index 52% rename from src/components/documents/ChoiceAnswer/helpers/grading.tsx rename to src/components/documents/ChoiceAnswer/helpers/scoring.tsx index 2d2afce7d..246318f4d 100644 --- a/src/components/documents/ChoiceAnswer/helpers/grading.tsx +++ b/src/components/documents/ChoiceAnswer/helpers/scoring.tsx @@ -1,20 +1,15 @@ -import { - ChoiceAnswerAssessment, - ChoiceAnswerScoring, - ChoiceAnswerCorrectness -} from '@tdev-models/documents/ChoiceAnswer'; -import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; +import { ChoiceAnswerScoring, ChoiceAnswerCorrectness } from '@tdev-models/documents/ChoiceAnswer'; import clsx from 'clsx'; import _ from 'es-toolkit/compat'; -export type GradingFunction = (result: ChoiceAnswerCorrectness, numMistakes: number) => ChoiceAnswerScoring; +export type ScoringFunction = (result: ChoiceAnswerCorrectness, numMistakes: number) => ChoiceAnswerScoring; export const points: ( forCorrect?: number, forIncorrect?: number, forUnanswered?: number -) => GradingFunction = (forCorrect = 1, forIncorrect = 0, forUnanswered = 0) => { - const gradingHint = () => ( +) => ScoringFunction = (forCorrect = 1, forIncorrect = 0, forUnanswered = 0) => { + const scoringHint = () => (
                  • {forCorrect}{' '} @@ -33,7 +28,7 @@ export const points: ( const template: ChoiceAnswerScoring = { maxPoints: forCorrect, pointsAchieved: 0, - scoringHint: gradingHint + scoringHint }; return (result) => { @@ -47,7 +42,7 @@ export const points: ( return { ...template, pointsAchieved: forUnanswered }; default: console.warn( - `Unhandled grading result '${result}' in points() grading function. This should not happen.` + `Unhandled correctness type '${result}' in points() scoring function. This should not happen.` ); return { ...template }; } @@ -59,7 +54,7 @@ export const multipleChoicePoints = ( deductionPerWrongChoice: number, allowNegativeTotal: boolean = false ) => { - const gradingHint = () => ( + const scoringHint = () => (
                    • {maxPoints}{' '} @@ -89,7 +84,7 @@ export const multipleChoicePoints = ( return { maxPoints, pointsAchieved: finalPoints, - gradingHint + scoringHint }; }; }; @@ -97,53 +92,3 @@ export const multipleChoicePoints = ( export const noPoints = () => { return () => undefined; }; - -export const updateGrading = ( - doc: ChoiceAnswerDocument, - multiple: boolean, - questionIndex: number, - correctOptions: Set, - numOptions: number, - gradingFunction?: GradingFunction -) => { - let numMistakes = 0; - const grading: ChoiceAnswerAssessment = { - correctness: ChoiceAnswerCorrectness.NA - }; - if (multiple) { - const selectedOptions = new Set(doc.choices[questionIndex] || []); - const numCorrectDecisions = _.range(0, numOptions).filter((optionIndex) => { - const isCorrect = correctOptions.has(optionIndex + 1); // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. - const isSelected = selectedOptions.has(optionIndex); - return (isCorrect && isSelected) || (!isCorrect && !isSelected); - }).length; - numMistakes = numOptions - numCorrectDecisions; - - grading.correctness = - numCorrectDecisions === numOptions - ? ChoiceAnswerCorrectness.Correct - : numCorrectDecisions > 0 - ? ChoiceAnswerCorrectness.PartiallyCorrect - : ChoiceAnswerCorrectness.Incorrect; - } else { - if (correctOptions.size === 0) { - console.warn( - `Question ${questionIndex} has an empty list of correct options. This is not allowed for single-choice questions and may lead to unexpected grading results (no options selected = question not answered).` - ); - } - const selectedOption = doc?.choices[questionIndex]?.[0]; - if (selectedOption === undefined) { - grading.correctness = ChoiceAnswerCorrectness.NA; - } else { - grading.correctness = correctOptions.has(selectedOption + 1) // +1 since optionIndex is 0-based, but correct[] is 1-based for better readability. - ? ChoiceAnswerCorrectness.Correct - : ChoiceAnswerCorrectness.Incorrect; - } - } - - if (gradingFunction) { - grading.scoring = gradingFunction(grading.correctness, numMistakes) ?? undefined; - } - - return grading; -}; diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx index 2a50788af..c60d2c2b9 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx @@ -9,7 +9,7 @@ import BrowserWindow from '@tdev-components/BrowserWindow'; import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer/Component'; import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAnswer'; import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; -import { points, multipleChoicePoints, noPoints } from '@tdev-components/documents/ChoiceAnswer/helpers/grading'; +import { points, multipleChoicePoints, noPoints } from '@tdev-components/documents/ChoiceAnswer/helpers/scoring'; # Playground Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index f43dcdd59..ca099e3d2 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -9,7 +9,7 @@ import BrowserWindow from '@tdev-components/BrowserWindow'; import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer/Component'; import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAnswer'; import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; -import { points, multipleChoicePoints } from '@tdev-components/documents/ChoiceAnswer/helpers/grading'; +import { points, multipleChoicePoints } from '@tdev-components/documents/ChoiceAnswer/helpers/scoring'; # Choice Answer Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. From 4a4fb72ca37addf5ae2de91864984aa0696180d1 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 10:56:39 +0100 Subject: [PATCH 67/91] Rename grading prop to scoring. --- .../ChoiceAnswer/Component/index.tsx | 26 +++++++++---------- .../documents/ChoiceAnswer/Feedback/index.tsx | 3 +-- .../documents/ChoiceAnswer/Hints/index.tsx | 20 +++++++------- .../documents/ChoiceAnswer/Quiz/index.tsx | 6 ++--- .../ChoiceAnswer/helpers/assessment.ts | 2 +- .../answer/choice-answer/Playground.mdx | 10 +++---- .../answer/choice-answer/index.mdx | 16 ++++++------ 7 files changed, 41 insertions(+), 42 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index 2c6a68b66..00d35b079 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -18,13 +18,13 @@ import { createRandomOrderMap } from '../helpers/shared'; import QuestionControls from '../Controls'; import { FeedbackAdmonition, FeedbackBadge } from '../Feedback'; import { ScoringFunction } from '../helpers/scoring'; -import { updateAssessment } from '../helpers/assessment'; +import { assess } from '../helpers/assessment'; export interface ChoiceAnswerProps { id: string; title?: string; correct?: number[]; - grading?: ScoringFunction; + scoring?: ScoringFunction; questionIndex?: number; inQuiz?: boolean; multiple?: boolean; @@ -72,7 +72,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const randomizeOptions = props.randomizeOptions !== undefined ? props.randomizeOptions : parentProps.randomizeOptions; const isBrowser = useIsBrowser(); - const [gradingStyle, setGradingStyle] = React.useState({}); + const [feedbackStyle, setFeedbackStyle] = React.useState({}); React.useEffect(() => { if (randomizeOptions && !doc?.data.optionOrders?.[questionIndex]) { @@ -89,13 +89,13 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } if (props.correct === undefined) { - // If no correct options are given, we assume that this question doesn't support grading. + // 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.grading ?? parentProps.grading; - const grading = updateAssessment( + const scoringFunction = props.scoring ?? parentProps.scoring; + const assessment = assess( doc, props.multiple ?? false, questionIndex, @@ -103,13 +103,13 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { props.numOptions, scoringFunction ); - setGradingStyle({ - [styles.correct]: doc.assessed && grading.correctness === ChoiceAnswerCorrectness.Correct, + setFeedbackStyle({ + [styles.correct]: doc.assessed && assessment.correctness === ChoiceAnswerCorrectness.Correct, [styles.partiallyCorrect]: - doc.assessed && grading.correctness === ChoiceAnswerCorrectness.PartiallyCorrect, - [styles.incorrect]: doc.assessed && grading.correctness === ChoiceAnswerCorrectness.Incorrect + doc.assessed && assessment.correctness === ChoiceAnswerCorrectness.PartiallyCorrect, + [styles.incorrect]: doc.assessed && assessment.correctness === ChoiceAnswerCorrectness.Incorrect }); - doc.updateAssessment(questionIndex, grading); + doc.updateAssessment(questionIndex, assessment); }, [doc, doc?.choices, doc?.assessed]); if (!doc) { @@ -160,11 +160,11 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { return (
                      {title && ( -
                      +
                      {title}
                      {!!props.correct && ( diff --git a/src/components/documents/ChoiceAnswer/Feedback/index.tsx b/src/components/documents/ChoiceAnswer/Feedback/index.tsx index 5d1b2a55b..0fc103492 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/index.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback/index.tsx @@ -81,7 +81,6 @@ interface FeedbackAdmonitionProps { questionIndex: number; } -// TODO: Should the grading decide its own visibility? Will quizzes prevent the entire grading, or just the result display? export const FeedbackAdmonition = observer(({ doc, questionIndex }: FeedbackAdmonitionProps) => { if (!doc || !doc.assessed) { return; @@ -189,7 +188,7 @@ export const QuizScore = observer(({ doc, minPoints }: QuizScoreProps) => { }); if (totalMaxPoints === 0) { - // No gradings with points, so we don't show anything. + // No scoring, so we don't show anything. return; } diff --git a/src/components/documents/ChoiceAnswer/Hints/index.tsx b/src/components/documents/ChoiceAnswer/Hints/index.tsx index 00b9cb7ac..f079183c3 100644 --- a/src/components/documents/ChoiceAnswer/Hints/index.tsx +++ b/src/components/documents/ChoiceAnswer/Hints/index.tsx @@ -10,22 +10,22 @@ import Popup from 'reactjs-popup'; import { PopupActions } from 'reactjs-popup/dist/types'; import styles from './styles.module.scss'; -interface QuestionGradingHintProps { +interface QuestionScoringHintProps { doc?: ChoiceAnswerDocument; trigger?: React.ReactNode; questionIndex?: number; } -export const QuestionScoringHint = observer(({ doc, trigger, questionIndex }: QuestionGradingHintProps) => { +export const QuestionScoringHint = observer(({ doc, trigger, questionIndex }: QuestionScoringHintProps) => { const ref = React.useRef(null); if (!doc || questionIndex === undefined) { return; } - const grading = doc?.assessments.get(questionIndex); - if (!!grading?.scoring && !grading.scoring.scoringHint) { - // This question has a grading but no grading hint, so we don't show anything. + const assessment = doc?.assessments.get(questionIndex); + if (!!assessment?.scoring && !assessment.scoring.scoringHint) { + // This question has a scoring but no scoring hint, so we don't show anything. return; } @@ -60,11 +60,11 @@ export const QuestionScoringHint = observer(({ doc, trigger, questionIndex }: Qu } >
                      - {!grading?.scoring &&

                      Für diese Frage besteht keine Bewertung.

                      } - {!!grading?.scoring?.scoringHint && - (typeof grading.scoring?.scoringHint === 'function' - ? grading.scoring.scoringHint() - : grading.scoring?.scoringHint)} + {!assessment?.scoring &&

                      Für diese Frage besteht keine Bewertung.

                      } + {!!assessment?.scoring?.scoringHint && + (typeof assessment.scoring?.scoringHint === 'function' + ? assessment.scoring.scoringHint() + : assessment.scoring?.scoringHint)}
                      diff --git a/src/components/documents/ChoiceAnswer/Quiz/index.tsx b/src/components/documents/ChoiceAnswer/Quiz/index.tsx index b775b1642..26931fb25 100644 --- a/src/components/documents/ChoiceAnswer/Quiz/index.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz/index.tsx @@ -18,7 +18,7 @@ interface Props { hideQuestionNumbers?: boolean; randomizeOptions?: boolean; randomizeQuestions?: boolean; - grading?: ScoringFunction; + scoring?: ScoringFunction; minPoints?: number; numQuestions: number; children?: React.ReactNode[]; @@ -38,7 +38,7 @@ export const QuizContext = React.createContext({ readonly?: boolean; hideQuestionNumbers?: boolean; randomizeQuestions?: boolean; - grading?: ScoringFunction; + scoring?: ScoringFunction; questionOrder: { [originalQuestionIndex: number]: number } | null; randomizeOptions?: boolean; focussedQuestion: number; @@ -76,7 +76,7 @@ const Quiz = observer((props: Props) => { randomizeQuestions: props.randomizeQuestions, questionOrder: doc.data.questionOrder, randomizeOptions: props.randomizeOptions, - grading: props.grading, + scoring: props.scoring, focussedQuestion: focussedQuestion, setFocussedQuestion: setFocussedQuestion }} diff --git a/src/components/documents/ChoiceAnswer/helpers/assessment.ts b/src/components/documents/ChoiceAnswer/helpers/assessment.ts index 7368f72fb..01ad5b4e5 100644 --- a/src/components/documents/ChoiceAnswer/helpers/assessment.ts +++ b/src/components/documents/ChoiceAnswer/helpers/assessment.ts @@ -5,7 +5,7 @@ import ChoiceAnswerDocument, { import { ScoringFunction } from './scoring'; import _ from 'es-toolkit/compat'; -export const updateAssessment = ( +export const assess = ( doc: ChoiceAnswerDocument, multiple: boolean, questionIndex: number, diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx index c60d2c2b9..dcdbe88a4 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx @@ -25,8 +25,8 @@ Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. ## Quiz - - + + > In welchem Jahr war 2024? 1. 1965 @@ -36,15 +36,15 @@ Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. 5. 2024 - + > HTML ist eine Programmiersprache. - + > Mayonnaise ist ein Instrument. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index ca099e3d2..a2fc143b0 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -177,8 +177,8 @@ import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAn import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; import { points } from '@tdev-components/documents/ChoiceAnswer/grading'; - - + + > In welchem Jahr war 2024? 1. 1965 @@ -188,11 +188,11 @@ import { points } from '@tdev-components/documents/ChoiceAnswer/grading'; 5. 2024 - + > HTML ist eine Programmiersprache. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP @@ -214,8 +214,8 @@ import { points } from '@tdev-components/documents/ChoiceAnswer/grading'; ``` - - + + > In welchem Jahr war 2024? 1. 1965 @@ -225,11 +225,11 @@ import { points } from '@tdev-components/documents/ChoiceAnswer/grading'; 5. 2024 - + > HTML ist eine Programmiersprache. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP From 622822602318b96fcd8228aa90be26e7a3279890 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 11:04:28 +0100 Subject: [PATCH 68/91] Finish nomenclature change. --- .../documents/ChoiceAnswer/Hints/index.tsx | 4 +- .../ChoiceAnswer/Hints/styles.module.scss | 4 +- .../answer/choice-answer/index.mdx | 39 +++++++------------ 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Hints/index.tsx b/src/components/documents/ChoiceAnswer/Hints/index.tsx index f079183c3..5dcaa371b 100644 --- a/src/components/documents/ChoiceAnswer/Hints/index.tsx +++ b/src/components/documents/ChoiceAnswer/Hints/index.tsx @@ -33,7 +33,7 @@ export const QuestionScoringHint = observer(({ doc, trigger, questionIndex }: Qu + ) @@ -48,7 +48,7 @@ export const QuestionScoringHint = observer(({ doc, trigger, questionIndex }: Qu > +

                      Bewertung

                      } > -
                      - {!assessment?.scoring &&

                      Für diese Frage besteht keine Bewertung.

                      } - {!!assessment?.scoring?.scoringHint && - (typeof assessment.scoring?.scoringHint === 'function' - ? assessment.scoring.scoringHint() - : assessment.scoring?.scoringHint)} -
                      +
                      {scoringHint}
                      ); From 9b1b1043c3e4eac6f9974c699fa1408a27d8eb44 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 11:41:34 +0100 Subject: [PATCH 72/91] Clean up feedback. --- .../documents/ChoiceAnswer/Feedback/index.tsx | 13 ++++++++++--- .../ChoiceAnswer/Feedback/styles.module.scss | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Feedback/index.tsx b/src/components/documents/ChoiceAnswer/Feedback/index.tsx index 0fc103492..e48d22f3f 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/index.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback/index.tsx @@ -1,4 +1,10 @@ -import { mdiCheckCircleOutline, mdiCloseCircleOutline, mdiProgressCheck, mdiProgressQuestion } from '@mdi/js'; +import { + mdiCheckCircleOutline, + mdiCloseCircleOutline, + mdiHelpCircleOutline, + mdiProgressCheck, + mdiProgressQuestion +} from '@mdi/js'; import Icon from '@mdi/react'; import ChoiceAnswerDocument, { ChoiceAnswerCorrectness } from '@tdev-models/documents/ChoiceAnswer'; import Admonition from '@theme/Admonition'; @@ -198,8 +204,9 @@ export const QuizScore = observer(({ doc, minPoints }: QuizScoreProps) => { return ( - {doc.assessed && {totalPointsAchieved}/} - {totalMaxPoints} {totalMaxPoints === 1 ? 'Punkt' : 'Punkte'} + {!doc.assessed && Zu erreichen: } + {doc.assessed && Ergebnis: {totalPointsAchieved} /} {totalMaxPoints}{' '} + {totalMaxPoints === 1 ? 'Punkt' : 'Punkte'} ); }); diff --git a/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss b/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss index 711e4255b..d39432ccc 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/Feedback/styles.module.scss @@ -7,5 +7,8 @@ .pointsBadge { cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; } } From 007982d479d6ec8fcaf050d33a2926b9bc83d099 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 11:51:40 +0100 Subject: [PATCH 73/91] Enforce header if there is scoring. --- .../ChoiceAnswer/Component/index.tsx | 11 +++++----- src/models/documents/ChoiceAnswer.ts | 5 +++++ .../answer/choice-answer/Playground.mdx | 20 ++++++++++++++++++- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index 00d35b079..dac2a4b1e 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -151,21 +151,22 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { (parentProps.randomizeQuestions ? (parentProps.questionOrder?.[questionIndex] ?? questionIndex) : questionIndex) + 1; - const title = + const canonicalTitle = props.inQuiz && !parentProps.hideQuestionNumbers ? props.title ? `Frage ${questionNumberToDisplay} – ${props.title}` : `Frage ${questionNumberToDisplay}` : props.title; + const displayTitle = !canonicalTitle && doc.hasQuestionsWithScoring ? 'Frage' : canonicalTitle; return (
                      - {title && ( + {displayTitle && (
                      - {title} + {displayTitle}
                      {!!props.correct && ( {
                      )} - {!title && !!props.correct && ( + {!displayTitle && !!props.correct && ( {
                      {optionsBlock}
                      {afterBlock} - {!title && } + {!displayTitle && }
                      ); diff --git a/src/models/documents/ChoiceAnswer.ts b/src/models/documents/ChoiceAnswer.ts index 226d51c21..cd9dbac79 100644 --- a/src/models/documents/ChoiceAnswer.ts +++ b/src/models/documents/ChoiceAnswer.ts @@ -187,6 +187,11 @@ class ChoiceAnswer extends iDocument<'choice_answer'> { return this.assessments.get(questionIndex); } + @computed + get hasQuestionsWithScoring(): boolean { + return Array.from(this.assessments.values()).some((assessment) => !!assessment.scoring); + } + get data(): TypeDataMapping['choice_answer'] { return { choices: this.choices, diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx index dcdbe88a4..af81b7a6b 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx @@ -15,7 +15,7 @@ import { points, multipleChoicePoints, noPoints } from '@tdev-components/documen Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. ## Einzelfragen - + > Was ist die Hauptstadt von Frankreich? 1. Berlin @@ -24,6 +24,24 @@ Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. 4. Madrid + + > Warum ist die Banane krumm? + + 1. Weil sie so wächst. + 2. Weil sie von einem verrückten Botaniker gezüchtet wurde. + 3. Weil sie Angst vor geraden Linien hat. + 4. Weil du immer so komische Fragen stellst. + + + + > Was in Vegas passiert… + + 1. …ist spannend. + 2. …ist meistens gefährlich. + 3. …bleibt in Vegas. + 4. …geht dich nichts an. + + ## Quiz From 94644bd9e3bd2d516366b8035c3656289f677523 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 11:53:07 +0100 Subject: [PATCH 74/91] Cleanup. --- src/components/documents/ChoiceAnswer/Feedback/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Feedback/index.tsx b/src/components/documents/ChoiceAnswer/Feedback/index.tsx index e48d22f3f..d53d9398e 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/index.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback/index.tsx @@ -147,10 +147,6 @@ export const FeedbackBadge = observer(({ doc, questionIndex }: FeedbackBadgeProp break; } - const questionsWithScoringExist = Array.from(doc.assessments.values()).some( - (assessment) => assessment.scoring !== undefined - ); - return (
                      {assessment.scoring && ( @@ -166,7 +162,7 @@ export const FeedbackBadge = observer(({ doc, questionIndex }: FeedbackBadgeProp } /> )} - {!assessment.scoring && questionsWithScoringExist && ( + {!assessment.scoring && doc.hasQuestionsWithScoring && ( )} {icon && doc.assessed && } From f0fc07b6e276615f38483b71262473d9e06a2f9c Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 12:43:42 +0100 Subject: [PATCH 75/91] Work on documentation. --- .../documents/ChoiceAnswer/Feedback/index.tsx | 8 +- .../answer/choice-answer/index.mdx | 191 +++++++++--------- 2 files changed, 91 insertions(+), 108 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Feedback/index.tsx b/src/components/documents/ChoiceAnswer/Feedback/index.tsx index d53d9398e..72e9e5afc 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/index.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback/index.tsx @@ -1,10 +1,4 @@ -import { - mdiCheckCircleOutline, - mdiCloseCircleOutline, - mdiHelpCircleOutline, - mdiProgressCheck, - mdiProgressQuestion -} from '@mdi/js'; +import { mdiCheckCircleOutline, mdiCloseCircleOutline, mdiProgressCheck, mdiProgressQuestion } from '@mdi/js'; import Icon from '@mdi/react'; import ChoiceAnswerDocument, { ChoiceAnswerCorrectness } from '@tdev-models/documents/ChoiceAnswer'; import Admonition from '@theme/Admonition'; diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index be477fd71..d653bdb4e 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -9,7 +9,7 @@ import BrowserWindow from '@tdev-components/BrowserWindow'; import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer/Component'; import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAnswer'; import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; -import { points, multipleChoicePoints } from '@tdev-components/documents/ChoiceAnswer/helpers/scoring'; +import { points, multipleChoicePoints, noPoints } from '@tdev-components/documents/ChoiceAnswer/helpers/scoring'; # Choice Answer Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. @@ -90,14 +90,8 @@ Bei Single-Choice-Aufgaben dürfen auch mehrere Antworten als korrekt angegeben Für eine Multiple-Choice-Frage muss lediglich das `multiple`-Flag gesetzt werden. In diesem Fall müssen alle der in der `correct`-Liste angegebenen Antworten ausgewählt werden, damit die Frage als richtig bewertet wird: ```md -Hier steht etwas Text über der Frage. - > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. - - :::info[Bewertung] - Die Frage wird nur als richtig bewertet, wenn alle korrekten Antworten (und keine falschen) ausgewählt wurden. - ::: 1. 2 ist die einzige gerade Primzahl. 2. Jede Primzahl ist ungerade. @@ -107,19 +101,11 @@ Hier steht etwas Text über der Frage. **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! - -Nach der Frage folgt noch weiterer Text. ``` - Hier steht etwas Text über der Frage. - > Welche der folgenden Aussagen zu Primzahlen sind korrekt? Es sind **mehrere** Antworten möglich. - - :::info[Bewertung] - Die Frage wird nur als richtig bewertet, wenn alle korrekten Antworten (und keine falschen) ausgewählt wurden. - ::: 1. 2 ist die einzige gerade Primzahl. 2. Jede Primzahl ist ungerade. @@ -129,8 +115,6 @@ Nach der Frage folgt noch weiterer Text. **Gewusst?** **Prim**zahlen haben nichts mit **Prim**aten zu tun! - - Nach der Frage folgt noch weiterer Text. Hier wird zusätzlich die `title`-Property verwendet, um der Frage einen Titel zu geben. Zudem wird ein Infoblock mit einer Bewertungsinformation eingeblendet. @@ -158,12 +142,70 @@ Für Wahr/Falsch-Fragen steht eine spezielle Komponente zur Verfügung: Bei Wahr/Falsch-Fragen gibt es keine Randomisierung der Antwortmöglichkeiten, da es nur zwei fixe Optionen gibt. Dies gilt sowohl bei Standalone-Fragen, als auch bei Wahr/Falsch-Fragen innerhalb eines Quizzes. ::: +### Bewertung +Fragen können mit Punkten bewertet werden. Dazu wird die `scoring`-Property verwendet, der man eine vordefinierte oder benutzerdefinierte Bewertungsfunktion ([vgl. `ScoringFunction`](#scoringfunction)) zuweist. + +:::info[Bewertungshinweis] +Bewertungsfunktionen können einen Bewertungshinweis bereitstellen. Bei den vordefinierten Bewertungsfunktionen wird dieser automatisch aus den entsprechenden Parametern generiert. Angezeigt wird der Bewertungshinweis mit einem Klick auf den Punktezahl-Badge im Header der Frage. +::: + +```md + + > Was ist die Hauptstadt von Frankreich? + + 1. Berlin + 2. Paris + 3. Rom + 4. Madrid + + + + > Wählen Sie alle Primzahlen aus. + + 1. 1 + 2. 2 + 3. 3 + 4. 4 + 5. 5 + + + + > Das Protokoll `HTTP` gehört zur Anwendungsschicht des TCP/IP-Stacks. + +``` + + + + > Was ist die Hauptstadt von Frankreich? + + 1. Berlin + 2. Paris + 3. Rom + 4. Madrid + + + + > Wählen Sie alle Primzahlen aus. + + 1. 1 + 2. 2 + 3. 3 + 4. 4 + 5. 5 + + + + > Das Protokoll `HTTP` gehört zur Anwendungsschicht des TCP/IP-Stacks. + + + + ## Quizzes Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice-, Single-Choice- und Wahr/Falsch-Fragen zusammengefasst. Dies kann mit der ``-Komponente erreicht werden: ```html - - + + > In welchem Jahr war 2024? 1. 1965 @@ -173,11 +215,21 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice 5. 2024 + + > Wählen Sie eine korrekte Aussage aus. + + 1. Wenn Daten in der Cloud (z.B. OneDrive) gespeichert werden, ist kein Backup mehr nötig. + 2. Cloud-Dienste wie OneDrive erlauben es uns, Dateien mit anderen Personen zu teilen. + 3. Cloud-Dienste machen es einfacher, Dateien zwischen verschiedenen Geräten zu synchronisieren. + 4. Das Abspeichern sensibler Daten auf Cloud-Diensten ist immer unproblematisch, da diese ja sicher sind. + 5. Alle der oben genannten Aussagen sind korrekt. + + > HTML ist eine Programmiersprache. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP @@ -186,7 +238,7 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice 4. HTTP - + > Wann ist der Sinn des Lebens? 1. Immer im März @@ -199,8 +251,8 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice ``` - - + + > In welchem Jahr war 2024? 1. 1965 @@ -210,11 +262,21 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice 5. 2024 + + > Wählen Sie eine korrekte Aussage aus. + + 1. Wenn Daten in der Cloud (z.B. OneDrive) gespeichert werden, ist kein Backup mehr nötig. + 2. Cloud-Dienste wie OneDrive erlauben es uns, Dateien mit anderen Personen zu teilen. + 3. Cloud-Dienste machen es einfacher, Dateien zwischen verschiedenen Geräten zu synchronisieren. + 4. Das Abspeichern sensibler Daten auf Cloud-Diensten ist immer unproblematisch, da diese ja sicher sind. + 5. Alle der oben genannten Aussagen sind korrekt. + + > HTML ist eine Programmiersprache. - + > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? 1. SMTP @@ -223,7 +285,7 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice 4. HTTP - + > Wann ist der Sinn des Lebens? 1. Immer im März @@ -235,84 +297,11 @@ Bei Abschluss-Quizzes und Prüfungen werden in der Regel mehrere Multiple-Choice -Dabei kann für das Quiz eine `scoring`-Funktion angegeben werden, die als Standard-Bewertungsfunktion für alle enthaltenen Fragen gilt. Diese kann bei Bedarf durch die `scoring`-Property der einzelnen Fragen überschrieben werden (siehe obiges Beispiel). In diesem Fall gilt die spezifische Bewertungsfunktion nur für diese eine Frage, während die anderen Fragen weiterhin die Bewertungsfunktion des übergeordneten Quizzes verwenden. Es kann entweder eine vordefinierte Bewertungsfunktion (z.B. `points(1, 0, 0)`) oder eine benutzerdefinierte Funktion verwendet werden. Mehr dazu [hier]() +### Bewertung +In einem Quiz kann die [`ScoringFunction`](#scoringfunction) auch auf Quiz-Ebene definiert werden. Sie gilt dann für alle Fragen innerhalb des Quizzes, sofern diese nicht durch die `scoring`-Property der einzelnen Fragen überschrieben wird. ### Randomisierung -Bei einem Quiz kann mit den entsprechenden Flags sowohl die Reihenfolge der Fragen als auch die Reihenfolge der Antwortmöglichkeiten randomisiert werden. Das Verhalten ist analog zu den einzelnen ChoiceAnswer-Fragen (siehe oben). Wird `randomizeOptions` auf Quiz-Ebene gesetzt, gilt dies für alle enthaltenen Fragen (wobei die Antwortoptionen von Wahr/Falsch-Fragen nie randomisiert werden). - -```html - - - > In welchem Jahr war 2024? - - 1. 1965 - 2. 1983 - 3. 1991 - 4. 2000 - 5. 2024 - - - - > HTML ist eine Programmiersprache. - - - - > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? - - 1. SMTP - 2. FTP - 3. IMAP - 4. HTTP - - - - > Wann ist der Sinn des Lebens? - - 1. Immer im März - 2. 42 - 3. Das Bundeshaus - 4. Nein - 5. Ja, aber nur manchmal - - -``` - - - - - > In welchem Jahr war 2024? - - 1. 1965 - 2. 1983 - 3. 1991 - 4. 2000 - 5. 2024 - - - - > HTML ist eine Programmiersprache. - - - - > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? - - 1. SMTP - 2. FTP - 3. IMAP - 4. HTTP - - - - > Wann ist der Sinn des Lebens? - - 1. Immer im März - 2. 42 - 3. Das Bundeshaus - 4. Nein - 5. Ja, aber nur manchmal - - - +Mit den Flags `randomizeQuestions` und `randomizeOptions` werden im obigen Quiz sowohl die Reihenfolge der Fragen als auch die Reihenfolge der Antwortmöglichkeiten innerhalb jeder Frage randomisiert. Die Randomisierung funktioniert dabei genauso wie bei den Standalone-Fragen (siehe oben). ## Eigenschaften und Funktionen ### Eigenschaften ChoiceAnswer From 047c8f30992af76410bb7d6b5c629f2fb0a65b06 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 13:40:04 +0100 Subject: [PATCH 76/91] Update docs. --- .../answer/choice-answer/index.mdx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index d653bdb4e..b4fc54ab9 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -304,7 +304,7 @@ In einem Quiz kann die [`ScoringFunction`](#scoringfunction) auch auf Quiz-Ebene Mit den Flags `randomizeQuestions` und `randomizeOptions` werden im obigen Quiz sowohl die Reihenfolge der Fragen als auch die Reihenfolge der Antwortmöglichkeiten innerhalb jeder Frage randomisiert. Die Randomisierung funktioniert dabei genauso wie bei den Standalone-Fragen (siehe oben). ## Eigenschaften und Funktionen -### Eigenschaften ChoiceAnswer +### Eigenschaften der `ChoiceAnswer` | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| | `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | @@ -313,21 +313,23 @@ Mit den Flags `randomizeQuestions` und `randomizeOptions` werden im obigen Quiz | `randomizeOptions` | Flag | Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge angezeigt. Die zufällige Darstellungsreihenfolge hat keinen Einfluss auf die `correct`-Liste. | | `hideQuestionNumbers` | Flag | Wenn gesetzt, wird den Fragen innerhalb des Quiz kein Titel mit der Fragenummer hinzugefügt | -### Eigenschaften Quiz +### Eigenschaften des `Quiz` | Eigenschaft | Typ | Beschreibung | |------------------|-------------------|---------------------------------------------------| | `randomizeQuestions` | Flag | Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge angezeigt. | | `randomizeOptions` | Flag | Wenn gesetzt , werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge angezeigt (analog zu `ChoiceAnswer.randomizeOptions` für einzelne Fragen). | -| `carrousel` (TODO) | Flag | Wenn gesetzt, werden die Fragen in einem Karussell dargestellt, sodass immer nur eine Frage sichtbar ist. | | `scoring` | `ScoringFunction` | Eine vordefinierte oder benutzerdefinierte `ScoringFunction`. | | `minPoints` | `number` | Die minimale Punktzahl, die für das Quiz erreicht werden kann. Kann z.B. genutzt werden, um bei Fragen mit Minuspunkten sicherzustellen, dass das gesamte Quiz nicht mit einer negativen Punktzahl bewertet wird. Standard: `undefined`. | ### `ScoringFunction` Eine `ScoringFunction` ist eine Funktion, die die Bewertung einer Frage oder eines Quizzes übernimmt. Vordefinierte Scoring-Funktionen sind: -- `points(pointsForCorrect: number, pointsForIncorrect: number, pointsForUnanswered: number)`: Bewertet die Frage mit einer festen Punktzahl für richtig, falsch und nicht beantwortet (z.B. `points(1, 0, 0)` für 1 Punkt bei richtiger Antwort und 0 Punkte bei falscher oder nicht beantworteter Frage). Die Standard-Wertung (`points()`) entspricht `points(1, 0, 0)`. -- `multipleChoicePoints(maxPoints: number, deductionPerWrongChoice: number, allowNegativeTotal: boolean = false)`: Bewertet eine Frage mit der gegebenen Maximalpunktzahl, minus einer Abzugsrate pro falscher Entscheidung (fälschlicherweise ausgewählt, oder fälschlicherweise nicht ausgewählt). Standardmässig wird die Frage nicht mit weniger als 0 Punkten bewertet, was mit `allowNegativeTotal` jedoch übersteuert werden kann. -- `noPoints()`: Bewertet die Frage ohne Punktevergabe (richtig/falsch wird aber weiterhin angezeigt). Diese Funktion ist dann sinnvoll, wenn für das übergeordnete Quiz eine Bewertungsfunktion definiert ist, die aber für diese spezifische Frage nicht gelten soll. + +| Funktion | Beschreibung | +| ------------------|-------------------| +| `points(forCorrect, forIncorrect, forUnanswered)` | Bewertet die Frage mit einer festen Punktzahl für richtig, falsch und nicht beantwortet. Die Standard-Wertung (`points()`) entspricht `points(1, 0, 0)`.

                      **Beispiel:** `points(1, -0.5, 0)` bewertet die Frage mit 1 Punkt bei richtiger Antwort, -0.5 Punkte bei falscher Antwort und 0 Punkten wenn nicht beantwortet. | +| `multipleChoicePoints(max, deduction, allowNegative)` | Bewertet eine Frage mit der gegebenen Maximalpunktzahl (`max`), minus einer Abzugsrate (`deduction`) pro falscher Entscheidung (fälschlicherweise ausgewählt, oder fälschlicherweise nicht ausgewählt). Standardmässig wird die Frage nicht mit weniger als 0 Punkten bewertet, was mit `allowNegative = true` jedoch übersteuert werden kann.

                      **Beispiel:** `multipleChoicePoints(3, 1)` bewertet eine vollständig korrekt beantwortete Frage mit 3 Punkten, vergibt bei einem Fehler noch 2 und bei zwei Fehlern noch 1 Punkt, und sonst 0 Punkte. | +| `noPoints()` | No-Op-Bewertungsfunktion. Kann verwendet werden, wenn für das übergeordnete Quiz eine Bewertungsfunktion definiert ist, für eine spezifische Frage jedoch keine Punkte vergeben werden sollen. | ## Imports ```ts From d8468a73226ed0666756924ec8030e410afdd3ad Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 24 Mar 2026 13:47:32 +0100 Subject: [PATCH 77/91] Get rid of header-less questions. --- .../ChoiceAnswer/Component/index.tsx | 4 +- .../documents/ChoiceAnswer/Controls/index.tsx | 91 +++++++++---------- .../ChoiceAnswer/Controls/styles.module.scss | 12 +-- 3 files changed, 46 insertions(+), 61 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index dac2a4b1e..97b723413 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -157,7 +157,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { ? `Frage ${questionNumberToDisplay} – ${props.title}` : `Frage ${questionNumberToDisplay}` : props.title; - const displayTitle = !canonicalTitle && doc.hasQuestionsWithScoring ? 'Frage' : canonicalTitle; + const displayTitle = !canonicalTitle ? 'Frage' : canonicalTitle; return (
                      { questionIndex={questionIndex} focussedQuestion={parentProps.focussedQuestion === questionIndex} inQuiz={props.inQuiz} - inHeader /> )} @@ -187,7 +186,6 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { questionIndex={questionIndex} focussedQuestion={parentProps.focussedQuestion === questionIndex} inQuiz={props.inQuiz} - inHeader={false} /> )} diff --git a/src/components/documents/ChoiceAnswer/Controls/index.tsx b/src/components/documents/ChoiceAnswer/Controls/index.tsx index 79d2bc98a..978499080 100644 --- a/src/components/documents/ChoiceAnswer/Controls/index.tsx +++ b/src/components/documents/ChoiceAnswer/Controls/index.tsx @@ -12,58 +12,55 @@ interface ControlsProps { questionIndex: number; focussedQuestion?: boolean; inQuiz?: boolean; - inHeader?: boolean; } -const QuestionControls = observer( - ({ doc, focussedQuestion: isFocussedQuestion, inQuiz, inHeader }: ControlsProps) => { - if (!doc) { - return; - } +const QuestionControls = observer(({ doc, focussedQuestion: isFocussedQuestion, inQuiz }: ControlsProps) => { + if (!doc) { + return; + } - const syncStatus = isFocussedQuestion && ; + const syncStatus = isFocussedQuestion && ; - const checkOrResetButton = !inQuiz && ( - <> - {!doc.assessed && ( -
                      diff --git a/src/components/documents/ChoiceAnswer/Component/styles.module.scss b/src/components/documents/ChoiceAnswer/Component/styles.module.scss index 4d527b4f1..208cafbd2 100644 --- a/src/components/documents/ChoiceAnswer/Component/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/Component/styles.module.scss @@ -84,7 +84,7 @@ .choiceAnswerOptionContainer { display: flex; flex-direction: row; - align-items: center; + align-items: baseline; p { margin: 0; @@ -105,6 +105,11 @@ font-size: 0.8em; opacity: 0; transition: opacity $btnRemoveAnswerTransitionDuration; + visibility: hidden; + + &.visible { + visibility: visible; + } } &:hover .btnDeleteAnswer { From 4642c1a3bf09d837de10bbeda2b7b488c062da81 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 08:28:24 +0100 Subject: [PATCH 83/91] Improve option alignment. --- .../ChoiceAnswer/Component/index.tsx | 43 +++++++++++-------- .../ChoiceAnswer/Component/styles.module.scss | 30 +++++++++---- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index 7b830b889..706c5a602 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -234,27 +234,32 @@ ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => { order: optionOrder }} > - onChange(optionIndex, e.target.checked)} - checked={isChecked} - disabled={!doc?.canUpdateAnswer} - /> +
                      + onChange(optionIndex, e.target.checked)} + checked={isChecked} + className={styles.checkbox} + disabled={!doc?.canUpdateAnswer} + /> +
                      {!multiple && ( -
                      )}
                      ); diff --git a/src/components/documents/ChoiceAnswer/Component/styles.module.scss b/src/components/documents/ChoiceAnswer/Component/styles.module.scss index 208cafbd2..c8ca511cf 100644 --- a/src/components/documents/ChoiceAnswer/Component/styles.module.scss +++ b/src/components/documents/ChoiceAnswer/Component/styles.module.scss @@ -84,12 +84,18 @@ .choiceAnswerOptionContainer { display: flex; flex-direction: row; - align-items: baseline; + align-items: flex-start; p { margin: 0; } + .checkboxContainer { + display: flex; + align-items: center; + margin-top: 0.23em; + } + label { margin-left: 0.2em; } @@ -100,15 +106,21 @@ } } - .btnDeleteAnswer { - margin-left: 0.7em; - font-size: 0.8em; - opacity: 0; - transition: opacity $btnRemoveAnswerTransitionDuration; - visibility: hidden; + .btnDeleteAnswerContainer { + display: flex; + align-items: center; + margin-left: 0.5em; + margin-top: 0.15em; + + .btnDeleteAnswer { + font-size: 0.8em; + opacity: 0; + transition: opacity $btnRemoveAnswerTransitionDuration; + visibility: hidden; - &.visible { - visibility: visible; + &.visible { + visibility: visible; + } } } From 511e3854405951249878130602e4941e464c8791 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 08:50:04 +0100 Subject: [PATCH 84/91] Revert unnecessary change to ProgressState. --- .../documents/ProgressState/index.tsx | 51 ++++++++++++++++++- src/components/util/domHelpers.ts | 49 ------------------ 2 files changed, 49 insertions(+), 51 deletions(-) delete mode 100644 src/components/util/domHelpers.ts diff --git a/src/components/documents/ProgressState/index.tsx b/src/components/documents/ProgressState/index.tsx index defbde16e..babbd11b1 100644 --- a/src/components/documents/ProgressState/index.tsx +++ b/src/components/documents/ProgressState/index.tsx @@ -8,7 +8,6 @@ import Item from './Item'; import { useStore } from '@tdev-hooks/useStore'; import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; -import { extractListItems } from '@tdev-components/util/domHelpers'; interface Props extends MetaInit { id: string; float?: 'left' | 'right'; @@ -16,11 +15,59 @@ interface Props extends MetaInit { labels?: React.ReactNode[]; } +const useExtractedChildren = (children: React.ReactElement): React.ReactNode[] | null => { + const liContent = React.useMemo(() => { + if (!children) { + return null; + } + /** + * Extracts the children of the first
                        element. + *
                          + *
                        1. Item 1
                        2. + *
                        3. Item 2
                        4. + *
                        + * Is represented as: + * ```js + * { + * type: 'ol', + * props: { + * children: [ + * { + * type: 'li', + * props: { children: 'Item 1' }, + * }, + * { + * type: 'li', + * props: { children: 'Item 2' }, + * }, + * ] + * } + * } + * ``` + * Use the `children.props.children` to access the nested `
                      1. ` elements, but don't enforce + * that the root element is an `
                          `, as it might be a custom component that renders an `
                            ` + * internally. Like that, e.g. `
                              ` is supported as well (where Docusaurus uses an `MDXUl` Component...). + */ + const nestedChildren = (children.props as any)?.children; + if (Array.isArray(nestedChildren)) { + return nestedChildren + .filter((c: any) => typeof c === 'object' && c !== null && c.props?.children) + .map((c: any) => { + return c.props.children as React.ReactNode; + }); + } + throw new Error( + `ProgressState must have an
                                as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}` + ); + }, [children]); + return liContent; +}; + const ProgressState = observer((props: Props) => { const [meta] = React.useState(new ModelMeta(props)); const pageStore = useStore('pageStore'); const doc = useFirstMainDocument(props.id, meta); - const children = extractListItems(props.children as React.ReactElement); + const children = useExtractedChildren(props.children as React.ReactElement); React.useEffect(() => { doc?.setTotalSteps(children?.length || 0); }, [doc, children?.length]); diff --git a/src/components/util/domHelpers.ts b/src/components/util/domHelpers.ts deleted file mode 100644 index 270b7778d..000000000 --- a/src/components/util/domHelpers.ts +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; - -export const extractListItems = (children: React.ReactElement): React.ReactNode[] | null => { - const liContent = React.useMemo(() => { - if (!children) { - return null; - } - /** - * Extracts the children of the first
                                  element. - *
                                    - *
                                  1. Item 1
                                  2. - *
                                  3. Item 2
                                  4. - *
                                  - * Is represented as: - * ```js - * { - * type: 'ol', - * props: { - * children: [ - * { - * type: 'li', - * props: { children: 'Item 1' }, - * }, - * { - * type: 'li', - * props: { children: 'Item 2' }, - * }, - * ] - * } - * } - * ``` - * Use the `children.props.children` to access the nested `
                                1. ` elements, but don't enforce - * that the root element is an `
                                    `, as it might be a custom component that renders an `
                                      ` - * internally. Like that, e.g. `
                                        ` is supported as well (where Docusaurus uses an `MDXUl` Component...). - */ - const nestedChildren = (children.props as any)?.children; - if (Array.isArray(nestedChildren)) { - return nestedChildren - .filter((c: any) => typeof c === 'object' && c !== null && c.props?.children) - .map((c: any) => { - return c.props.children as React.ReactNode; - }); - } - throw new Error( - `ProgressState must have an
                                          as a child, found ${typeof children.type === 'function' ? children.type.name : children.type}` - ); - }, [children]); - return liContent; -}; From 3e580756fdee9d4a67fd8f70c6ebc403cc4b1a09 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 09:16:19 +0100 Subject: [PATCH 85/91] Cleanup. --- .../ChoiceAnswer/Component/index.tsx | 55 +++++++------------ .../ChoiceAnswer/helpers/scoring.tsx | 5 +- .../documents/ChoiceAnswer/helpers/shared.ts | 6 +- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index 706c5a602..ee7ffff0b 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -16,7 +16,7 @@ import { mdiTrashCanOutline } from '@mdi/js'; import _ from 'es-toolkit/compat'; import { createRandomOrderMap } from '../helpers/shared'; import QuestionControls from '../Controls'; -import { FeedbackAdmonition, FeedbackBadge } from '../Feedback'; +import { FeedbackBadge } from '../Feedback'; import { ScoringFunction } from '../helpers/scoring'; import { assess } from '../helpers/assessment'; import useIsMobileView from '@tdev-hooks/useIsMobileView'; @@ -68,7 +68,8 @@ const ChoiceAnswerContext = React.createContext({ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const parentProps = React.useContext(QuizContext); const [meta] = React.useState(new ModelMeta(props)); - const doc = props.inQuiz ? parentProps.doc : useFirstMainDocument(props.id, meta); + 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; @@ -158,37 +159,27 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { ? `Frage ${questionNumberToDisplay} – ${props.title}` : `Frage ${questionNumberToDisplay}` : props.title; - const displayTitle = !canonicalTitle ? 'Frage' : canonicalTitle; + const displayTitle = canonicalTitle || 'Frage'; return (
                                          - {displayTitle && ( -
                                          - {displayTitle} -
                                          - {!!props.correct && ( - - )} - -
                                          +
                                          + {displayTitle} +
                                          + {!!props.correct && ( + + )} +
                                          - )} - {!displayTitle && !!props.correct && ( - - )} +
                                          {beforeBlock} @@ -204,7 +195,6 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
                                          {optionsBlock}
                                          {afterBlock} - {!displayTitle && }
                                          ); @@ -218,13 +208,10 @@ ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => { const isChecked = !!doc?.choices[questionIndex]?.includes(optionIndex); - const optionOrder = React.useMemo( - () => - randomizeOptions && doc?.optionOrders[questionIndex] !== undefined - ? doc?.optionOrders[questionIndex][optionIndex] - : optionIndex, - [doc?.optionOrders[questionIndex], questionIndex, optionIndex] - ); + const optionOrder = + randomizeOptions && doc?.optionOrders[questionIndex] !== undefined + ? doc.optionOrders[questionIndex][optionIndex] + : optionIndex; return (
                                          ChoiceAnswerScoring; +export type ScoringFunction = ( + result: ChoiceAnswerCorrectness, + numMistakes: number +) => ChoiceAnswerScoring | undefined; export const points: ( forCorrect?: number, diff --git a/src/components/documents/ChoiceAnswer/helpers/shared.ts b/src/components/documents/ChoiceAnswer/helpers/shared.ts index 4e35b7a6d..dccda0646 100644 --- a/src/components/documents/ChoiceAnswer/helpers/shared.ts +++ b/src/components/documents/ChoiceAnswer/helpers/shared.ts @@ -1,11 +1,7 @@ import _ from 'es-toolkit/compat'; -const range = (numItems: number): number[] => { - return Array.from({ length: numItems }, (_, i) => i); -}; - export const createRandomOrderMap = (numOptions: number): { [originalIndex: number]: number } => { - const originalIndices = range(numOptions); + const originalIndices = _.range(numOptions); const shuffledIndices = _.shuffle(originalIndices); const randomIndexMap: { [originalIndex: number]: number } = {}; originalIndices.forEach((originalIndex, i) => { From 9732668cbad5cf86228a2dea53ed87b2974d44c2 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 09:23:08 +0100 Subject: [PATCH 86/91] Cleanup. --- src/components/documents/ChoiceAnswer/Component/index.tsx | 6 +++--- src/components/documents/ChoiceAnswer/Hints/index.tsx | 4 ++-- src/components/documents/ChoiceAnswer/helpers/scoring.tsx | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index ee7ffff0b..c8a78ec0b 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -13,13 +13,11 @@ import useIsBrowser from '@docusaurus/useIsBrowser'; import { QuizContext } from '../Quiz'; import Button from '@tdev-components/shared/Button'; import { mdiTrashCanOutline } from '@mdi/js'; -import _ from 'es-toolkit/compat'; import { createRandomOrderMap } from '../helpers/shared'; import QuestionControls from '../Controls'; import { FeedbackBadge } from '../Feedback'; import { ScoringFunction } from '../helpers/scoring'; import { assess } from '../helpers/assessment'; -import useIsMobileView from '@tdev-hooks/useIsMobileView'; export interface ChoiceAnswerProps { id: string; @@ -165,6 +163,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => {
                                          {displayTitle} @@ -225,12 +224,13 @@ ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => { onChange(optionIndex, e.target.checked)} checked={isChecked} className={styles.checkbox} disabled={!doc?.canUpdateAnswer} + tabIndex={optionOrder} />
                                          diff --git a/src/components/documents/ChoiceAnswer/Hints/index.tsx b/src/components/documents/ChoiceAnswer/Hints/index.tsx index 4d15edf90..e044aba88 100644 --- a/src/components/documents/ChoiceAnswer/Hints/index.tsx +++ b/src/components/documents/ChoiceAnswer/Hints/index.tsx @@ -1,8 +1,8 @@ -import { mdiClose, mdiCloseCircleOutline, mdiInformationOutline } from '@mdi/js'; +import { mdiCloseCircleOutline, mdiInformationOutline } from '@mdi/js'; import Icon from '@mdi/react'; import Button from '@tdev-components/shared/Button'; import Card from '@tdev-components/shared/Card'; -import ChoiceAnswerDocument, { ChoiceAnswerScoring } from '@tdev-models/documents/ChoiceAnswer'; +import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; import React from 'react'; diff --git a/src/components/documents/ChoiceAnswer/helpers/scoring.tsx b/src/components/documents/ChoiceAnswer/helpers/scoring.tsx index 6b6426b16..77081cc1f 100644 --- a/src/components/documents/ChoiceAnswer/helpers/scoring.tsx +++ b/src/components/documents/ChoiceAnswer/helpers/scoring.tsx @@ -1,6 +1,5 @@ import { ChoiceAnswerScoring, ChoiceAnswerCorrectness } from '@tdev-models/documents/ChoiceAnswer'; import clsx from 'clsx'; -import _ from 'es-toolkit/compat'; export type ScoringFunction = ( result: ChoiceAnswerCorrectness, From 857a332b34ecb00e41272b819219c9b66aebe340 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 09:24:43 +0100 Subject: [PATCH 87/91] Remove obsolete feedback admonition. --- .../documents/ChoiceAnswer/Feedback/index.tsx | 98 ------------------- 1 file changed, 98 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Feedback/index.tsx b/src/components/documents/ChoiceAnswer/Feedback/index.tsx index 4e35ec4ba..7d88c5f86 100644 --- a/src/components/documents/ChoiceAnswer/Feedback/index.tsx +++ b/src/components/documents/ChoiceAnswer/Feedback/index.tsx @@ -1,7 +1,6 @@ import { mdiCheckCircleOutline, mdiCloseCircleOutline, mdiProgressCheck, mdiProgressQuestion } from '@mdi/js'; import Icon from '@mdi/react'; import ChoiceAnswerDocument, { ChoiceAnswerCorrectness } from '@tdev-models/documents/ChoiceAnswer'; -import Admonition from '@theme/Admonition'; import { observer } from 'mobx-react-lite'; import styles from './styles.module.scss'; import React from 'react'; @@ -9,103 +8,6 @@ import clsx from 'clsx'; import { QuestionScoringHint } from '../Hints'; import useIsMobileView from '@tdev-hooks/useIsMobileView'; -const CorrectIcon = (): React.JSX.Element => { - return ( - - - - ); -}; - -const PartiallyCorrectIcon = (): React.JSX.Element => { - return ( - - - - ); -}; - -const IncorrectIcon = (): React.JSX.Element => { - return ( - - - - ); -}; - -const NoAnswerIcon = (): React.JSX.Element => { - return ( - - - - ); -}; - -const correctAdmonition = ( - }> - Sie haben die Frage korrekt beantwortet! - -); - -const partiallyCorrectAdmonition = ( - }> - Sie haben diese Frage teilweise richtig beantwortet. - -); - -const incorrectAdmonition = ( - }> - Sie haben diese Frage falsch beantwortet. - -); - -const noAnswerAdmonition = ( - }> - Sie haben diese Frage nicht beantwortet. - -); - -interface FeedbackAdmonitionProps { - doc: ChoiceAnswerDocument; - questionIndex: number; -} - -export const FeedbackAdmonition = observer(({ doc, questionIndex }: FeedbackAdmonitionProps) => { - if (!doc || !doc.assessed) { - return; - } - - const assessment = doc.assessments.get(questionIndex); - if (!assessment) { - return; - } - - switch (assessment.correctness) { - case ChoiceAnswerCorrectness.Correct: - return <>{correctAdmonition}; - case ChoiceAnswerCorrectness.PartiallyCorrect: - return <>{partiallyCorrectAdmonition}; - case ChoiceAnswerCorrectness.Incorrect: - return <>{incorrectAdmonition}; - case ChoiceAnswerCorrectness.NA: - return <>{noAnswerAdmonition}; - default: - return; - } -}); - interface FeedbackBadgeProps { doc: ChoiceAnswerDocument; questionIndex: number; From 648ad33610f583892d0e8e79b09d51581aa83d1f Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 09:28:55 +0100 Subject: [PATCH 88/91] Simplify feedback style computation. --- .../documents/ChoiceAnswer/Component/index.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx index c8a78ec0b..47929854c 100644 --- a/src/components/documents/ChoiceAnswer/Component/index.tsx +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -72,7 +72,6 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const randomizeOptions = props.randomizeOptions !== undefined ? props.randomizeOptions : parentProps.randomizeOptions; const isBrowser = useIsBrowser(); - const [feedbackStyle, setFeedbackStyle] = React.useState({}); React.useEffect(() => { if (randomizeOptions && !doc?.data.optionOrders?.[questionIndex]) { @@ -103,12 +102,6 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { props.numOptions, scoringFunction ); - setFeedbackStyle({ - [styles.correct]: doc.assessed && assessment.correctness === ChoiceAnswerCorrectness.Correct, - [styles.partiallyCorrect]: - doc.assessed && assessment.correctness === ChoiceAnswerCorrectness.PartiallyCorrect, - [styles.incorrect]: doc.assessed && assessment.correctness === ChoiceAnswerCorrectness.Incorrect - }); doc.updateAssessment(questionIndex, assessment); }, [doc, doc?.choices, doc?.assessed]); @@ -120,6 +113,14 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { return ; } + 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 From 7bce9bf54fe4d88805f035e14a2a586e982670ba Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 09:30:33 +0100 Subject: [PATCH 89/91] Remove playground. --- .../answer/choice-answer/Playground.mdx | 111 ------------------ 1 file changed, 111 deletions(-) delete mode 100644 tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx deleted file mode 100644 index af81b7a6b..000000000 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/Playground.mdx +++ /dev/null @@ -1,111 +0,0 @@ ---- -page_id: 08ac6803-b890-4608-9d4e-28f334addfb0 -tags: - - persistable ---- - -import PermissionsPanel from "@tdev-components/PermissionsPanel" -import BrowserWindow from '@tdev-components/BrowserWindow'; -import ChoiceAnswer from '@tdev-components/documents/ChoiceAnswer/Component'; -import TrueFalseAnswer from '@tdev-components/documents/ChoiceAnswer/TrueFalseAnswer'; -import Quiz from '@tdev-components/documents/ChoiceAnswer/Quiz'; -import { points, multipleChoicePoints, noPoints } from '@tdev-components/documents/ChoiceAnswer/helpers/scoring'; - -# Playground -Diese Seite dient nur der Entwicklung. Sie soll vor dem Merge entfernt werden. - -## Einzelfragen - - > Was ist die Hauptstadt von Frankreich? - - 1. Berlin - 2. Paris - 3. Rom - 4. Madrid - - - - > Warum ist die Banane krumm? - - 1. Weil sie so wächst. - 2. Weil sie von einem verrückten Botaniker gezüchtet wurde. - 3. Weil sie Angst vor geraden Linien hat. - 4. Weil du immer so komische Fragen stellst. - - - - > Was in Vegas passiert… - - 1. …ist spannend. - 2. …ist meistens gefährlich. - 3. …bleibt in Vegas. - 4. …geht dich nichts an. - - -## Quiz - - - > In welchem Jahr war 2024? - - 1. 1965 - 2. 1983 - 3. 1991 - 4. 2000 - 5. 2024 - - - - > HTML ist eine Programmiersprache. - - - - > Mayonnaise ist ein Instrument. - - - - > Welche der folgenden Protokolle werden für die Übertragung von E-Mails verwendet? - - 1. SMTP - 2. FTP - 3. IMAP - 4. HTTP - - - - > Wann ist der Sinn des Lebens? - - 1. Immer im März - 2. 42 - 3. Das Bundeshaus - 4. Nein - 5. Ja, aber nur manchmal - - - -## Quiz 2 -## Einzelfragen - - > Was ist die Hauptstadt von Frankreich? - - 1. Berlin - 2. Paris - 3. Rom - 4. Madrid - - -## Quiz - - - > In welchem Jahr war 2024? - - 1. 1965 - 2. 1983 - 3. 1991 - 4. 2000 - 5. 2024 - - - - > HTML ist eine Programmiersprache. - - \ No newline at end of file From a998cf0c302df3df2415704b0d81125cf71ee6bd Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 09:56:46 +0100 Subject: [PATCH 90/91] Add installation docs. --- .../remark-transform-choice-answer/index.mdx | 28 +++++++++++++++++++ .../answer/choice-answer/index.mdx | 4 +++ 2 files changed, 32 insertions(+) create mode 100644 tdev-website/docs/app-architecture/remark-plugins/remark-transform-choice-answer/index.mdx diff --git a/tdev-website/docs/app-architecture/remark-plugins/remark-transform-choice-answer/index.mdx b/tdev-website/docs/app-architecture/remark-plugins/remark-transform-choice-answer/index.mdx new file mode 100644 index 000000000..0492ec3a5 --- /dev/null +++ b/tdev-website/docs/app-architecture/remark-plugins/remark-transform-choice-answer/index.mdx @@ -0,0 +1,28 @@ +--- +page_id: 16ecfeb9-15cf-40ff-9880-f130e99f6c53 +--- + +# Transform Choice Answer + +Dieses Plugin ermöglicht die Verwendung spezifischer MDX Syntax innerhalb von [``](../../../gallery/persistable-documents/answer/choice-answer/index.mdx) Komponenten. + +## Installation + +:::info[Code] +- `src/components/documents/ChoiceAnswer/**` +- `src/plugins/remark-transform-choice-answer` +- `src/models/documents/ChoiceAnswer.ts` +::: + + +:::info[`docusaurus.config.ts`] + +```ts +import transformChoiceAnswerPlugin from '../plugins/remark-transform-choice-answer/plugin'; + +const REMARK_PLUGINS = [ + /* ... */ + transformChoiceAnswerPlugin +]; +``` +::: diff --git a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 40fa30352..fd40a3ed7 100644 --- a/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx +++ b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx @@ -14,6 +14,10 @@ import { points, multipleChoicePoints, noPoints } from '@tdev-components/documen # Choice Answer Choice-Antwort für Multiple-Choice, Single-Choice und Wahr/Falsch-Fragen. Geeignet für Aufgaben, Quizzes und Prüfungen. +:::warning[Voraussetzung] +Das [remark-transform-choice-answer](../../../../app-architecture/remark-plugins/remark-transform-choice-answer/index.mdx) Plugin muss installiert und in der Docusaurus-Konfiguration aktiviert sein, damit die oben beschriebene MDX-Syntax korrekt in die entsprechenden React-Komponenten transformiert werden kann. +::: + ## Standalone-Fragen ### Single-Choice Eine einfache Single-Choice-Frage kann wie folgt erstellt werden: From 787992b20885c322d54b1b74c57358154d12a2b3 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Wed, 25 Mar 2026 14:33:47 +0100 Subject: [PATCH 91/91] Fix hook usage. --- src/components/documents/ChoiceAnswer/Quiz/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/documents/ChoiceAnswer/Quiz/index.tsx b/src/components/documents/ChoiceAnswer/Quiz/index.tsx index 26931fb25..f0af7df3f 100644 --- a/src/components/documents/ChoiceAnswer/Quiz/index.tsx +++ b/src/components/documents/ChoiceAnswer/Quiz/index.tsx @@ -4,13 +4,13 @@ import { observer } from 'mobx-react-lite'; import React from 'react'; import ChoiceAnswerDocument from '@tdev-models/documents/ChoiceAnswer'; import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; -import { isBrowser } from 'es-toolkit'; import Loader from '@tdev-components/Loader'; import { createRandomOrderMap } from '../helpers/shared'; import styles from './styles.module.scss'; import { QuizControls } from '../Controls'; import { ScoringFunction } from '../helpers/scoring'; import { QuizScore } from '../Feedback'; +import useIsBrowser from '@docusaurus/useIsBrowser'; interface Props { id: string; @@ -49,6 +49,7 @@ export const QuizContext = React.createContext({ const Quiz = observer((props: Props) => { const [meta] = React.useState(new ModelMeta(props)); const doc = useFirstMainDocument(props.id, meta); + const isBrowser = useIsBrowser(); const [focussedQuestion, setFocussedQuestion] = React.useState(0);