From 1a193aafe5d294292898303eb97ce7e14f764ec8 Mon Sep 17 00:00:00 2001 From: Silas Berger Date: Tue, 27 Jan 2026 09:06:43 +0100 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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],