diff --git a/src/api/document.ts b/src/api/document.ts index 51f6c5abc..baf6b84cb 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -19,6 +19,11 @@ import iDocumentContainer from '@tdev-models/iDocumentContainer'; import iViewStore from '@tdev-stores/ViewStores/iViewStore'; import Code from '@tdev-models/documents/Code'; import { iTaskableDocument } from '@tdev-models/iTaskableDocument'; +import ChoiceAnswer, { + ChoiceAnswerChoices, + ChoiceAnswerOptionOrders, + ChoiceAnswerQuestionOrder +} from '@tdev-models/documents/ChoiceAnswer'; export enum Access { RO_DocumentRoot = 'RO_DocumentRoot', @@ -41,6 +46,13 @@ export interface StringData { text: string; } +export interface ChoiceAnswerData { + choices: ChoiceAnswerChoices; + optionOrders: ChoiceAnswerOptionOrders; + questionOrder: ChoiceAnswerQuestionOrder | null; + assessed: boolean; +} + export interface QuillV2Data { delta: Delta; } @@ -124,6 +136,7 @@ export interface TypeDataMapping extends TaskableDocumentMapping, ContainerTypeD // TODO: rename to `code_version`? ['script_version']: ScriptVersionData; ['string']: StringData; + ['choice_answer']: ChoiceAnswerData; ['quill_v2']: QuillV2Data; ['solution']: SolutionData; ['dir']: DirData; @@ -160,6 +173,7 @@ export interface TypeModelMapping extends TaskableTypeModelMapping, ContainerTyp // TODO: rename to `code_version`? ['script_version']: ScriptVersion; ['string']: String; + ['choice_answer']: ChoiceAnswer; ['quill_v2']: QuillV2; ['solution']: Solution; ['dir']: Directory; diff --git a/src/components/documents/ChoiceAnswer/Component/index.tsx b/src/components/documents/ChoiceAnswer/Component/index.tsx new file mode 100644 index 000000000..47929854c --- /dev/null +++ b/src/components/documents/ChoiceAnswer/Component/index.tsx @@ -0,0 +1,266 @@ +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import ChoiceAnswerDocument, { + ChoiceAnswerCorrectness, + ModelMeta +} from '@tdev-models/documents/ChoiceAnswer'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; +import Loader from '@tdev-components/Loader'; +import useIsBrowser from '@docusaurus/useIsBrowser'; +import { QuizContext } from '../Quiz'; +import Button from '@tdev-components/shared/Button'; +import { mdiTrashCanOutline } from '@mdi/js'; +import { createRandomOrderMap } from '../helpers/shared'; +import QuestionControls from '../Controls'; +import { FeedbackBadge } from '../Feedback'; +import { ScoringFunction } from '../helpers/scoring'; +import { assess } from '../helpers/assessment'; + +export interface ChoiceAnswerProps { + id: string; + title?: string; + correct?: number[]; + scoring?: ScoringFunction; + questionIndex?: number; + inQuiz?: boolean; + multiple?: boolean; + randomizeOptions?: boolean; + numOptions: number; + readonly?: boolean; + children: React.ReactNode; +} + +interface ThinWrapperProps { + children: React.ReactNode; +} + +interface OptionProps { + children: React.ReactNode; + optionIndex: number; +} + +type ChoiceAnswerSubComponents = { + Before: React.FC; + Options: React.FC; + Option: React.FC; + After: React.FC; +}; + +const ChoiceAnswerContext = React.createContext({ + doc: undefined, + questionIndex: 0, + multiple: false, + randomizeOptions: false, + onChange: () => {} +} as { + doc?: ChoiceAnswerDocument; + questionIndex: number; + multiple?: boolean; + randomizeOptions?: boolean; + onChange: (optionIndex: number, checked: boolean) => void; +}); + +const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { + const parentProps = React.useContext(QuizContext); + const [meta] = React.useState(new ModelMeta(props)); + const ownDoc = useFirstMainDocument(props.inQuiz ? undefined : props.id, meta); + const doc = props.inQuiz ? parentProps.doc : ownDoc; + const questionIndex = props.questionIndex ?? 0; + const randomizeOptions = + props.randomizeOptions !== undefined ? props.randomizeOptions : parentProps.randomizeOptions; + const isBrowser = useIsBrowser(); + + React.useEffect(() => { + if (randomizeOptions && !doc?.data.optionOrders?.[questionIndex]) { + doc?.updateOptionOrders({ + ...doc.data.optionOrders, + [questionIndex]: createRandomOrderMap(props.numOptions) + }); + } + }, [randomizeOptions, doc, questionIndex, props.numOptions]); + + React.useEffect(() => { + if (!doc) { + return; + } + + if (props.correct === undefined) { + // If no correct options are given, we assume that this question doesn't support assessment. + return; + } + const correctOptions = new Set(props.correct); + + const scoringFunction = props.scoring ?? parentProps.scoring; + const assessment = assess( + doc, + props.multiple ?? false, + questionIndex, + correctOptions, + props.numOptions, + scoringFunction + ); + doc.updateAssessment(questionIndex, assessment); + }, [doc, doc?.choices, doc?.assessed]); + + if (!doc) { + return ; + } + + if (!isBrowser) { + 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 + ); + const optionsBlock = childrenArray.find( + (child) => React.isValidElement(child) && child.type === ChoiceAnswer.Options + ); + const afterBlock = childrenArray.find( + (child) => React.isValidElement(child) && child.type === ChoiceAnswer.After + ); + + const onOptionChange = (optionIndex: number, checked: boolean) => { + parentProps.setFocussedQuestion?.(questionIndex); + if (props.multiple) { + doc?.updateMultipleChoiceSelection(questionIndex, optionIndex, checked); + } else { + checked + ? doc?.updateSingleChoiceSelection(questionIndex, optionIndex) + : doc?.resetAnswer(questionIndex); + } + }; + + const questionOrder = + parentProps.randomizeQuestions && parentProps.questionOrder + ? parentProps.questionOrder[questionIndex] + : questionIndex; + + const questionNumberToDisplay = + (parentProps.randomizeQuestions + ? (parentProps.questionOrder?.[questionIndex] ?? questionIndex) + : questionIndex) + 1; + const canonicalTitle = + props.inQuiz && !parentProps.hideQuestionNumbers + ? props.title + ? `Frage ${questionNumberToDisplay} – ${props.title}` + : `Frage ${questionNumberToDisplay}` + : props.title; + const displayTitle = canonicalTitle || 'Frage'; + + return ( +
+
+ {displayTitle} +
+ {!!props.correct && ( + + )} + +
+
+ +
+ {beforeBlock} + +
{optionsBlock}
+
+ {afterBlock} +
+
+ ); +}) as React.FC & ChoiceAnswerSubComponents; + +ChoiceAnswer.Option = observer(({ optionIndex, children }: OptionProps) => { + const { doc, questionIndex, multiple, randomizeOptions, onChange } = + React.useContext(ChoiceAnswerContext); + + const optionId = React.useId(); + + const isChecked = !!doc?.choices[questionIndex]?.includes(optionIndex); + + const optionOrder = + randomizeOptions && doc?.optionOrders[questionIndex] !== undefined + ? doc.optionOrders[questionIndex][optionIndex] + : optionIndex; + + return ( +
+
+ onChange(optionIndex, e.target.checked)} + checked={isChecked} + className={styles.checkbox} + disabled={!doc?.canUpdateAnswer} + tabIndex={optionOrder} + /> +
+ + {!multiple && ( +
+
+ )} +
+ ); +}); + +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; diff --git a/src/components/documents/ChoiceAnswer/Component/styles.module.scss b/src/components/documents/ChoiceAnswer/Component/styles.module.scss new file mode 100644 index 000000000..c8ca511cf --- /dev/null +++ b/src/components/documents/ChoiceAnswer/Component/styles.module.scss @@ -0,0 +1,132 @@ +@use '../_vars' as *; + +.choiceAnswerContainer { + position: relative; + margin-bottom: 1em; + + p { + margin: 0 0 0.5em 0; + } + + $boxShadowDefaults: 0 0 8px; + + &.correct { + box-shadow: $boxShadowDefaults var(--ifm-color-success); + + .header { + background-color: var(--ifm-color-success-contrast-background); + } + } + + &.partiallyCorrect { + box-shadow: $boxShadowDefaults var(--ifm-color-warning); + + .header { + background-color: var(--ifm-color-warning-contrast-background); + } + } + + &.incorrect { + box-shadow: $boxShadowDefaults var(--ifm-color-danger-lighter); + + .header { + background-color: var(--ifm-color-danger-contrast-background); + } + } + + .header { + background-color: var(--ifm-color-secondary-lightest); + padding: $container-padding-top-bottom $container-padding-left-right; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + h3 { + margin: 0; + } + + .title { + font-weight: bold; + font-size: 1.1em; + margin-right: 1em; + min-width: 2em; + flex-shrink: 1; + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; + } + + .syncStatus { + position: static; + } + + .controlsAndFeedback { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5em; + } + } +} + +.optionsBlock { + $btnRemoveAnswerTransitionDuration: 0.15s; + + &:not(:last-child) { + margin-bottom: 1em; + } + + .optionsContainer { + display: flex; + flex-direction: column; + + .choiceAnswerOptionContainer { + display: flex; + flex-direction: row; + align-items: flex-start; + + p { + margin: 0; + } + + .checkboxContainer { + display: flex; + align-items: center; + margin-top: 0.23em; + } + + label { + margin-left: 0.2em; + } + + &:hover .btnDeleteAnswer { + opacity: 1; + transition: opacity $btnRemoveAnswerTransitionDuration; + } + } + + .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; + } + } + } + + &:hover .btnDeleteAnswer { + opacity: 0.6; + transition: opacity $btnRemoveAnswerTransitionDuration; + } + } +} diff --git a/src/components/documents/ChoiceAnswer/Controls/index.tsx b/src/components/documents/ChoiceAnswer/Controls/index.tsx new file mode 100644 index 000000000..b9257c038 --- /dev/null +++ b/src/components/documents/ChoiceAnswer/Controls/index.tsx @@ -0,0 +1,110 @@ +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'; +import clsx from 'clsx'; +import useIsMobileView from '@tdev-hooks/useIsMobileView'; + +interface ControlsProps { + doc: ChoiceAnswerDocument; + questionIndex: number; + focussedQuestion?: boolean; + inQuiz?: boolean; +} + +const QuestionControls = observer(({ doc, focussedQuestion: isFocussedQuestion, inQuiz }: ControlsProps) => { + const isMobileView = useIsMobileView(); + + if (!doc) { + return; + } + + const syncStatus = isFocussedQuestion && ; + + const checkOrResetButton = !inQuiz && ( + <> + {!doc.assessed && ( +