diff --git a/src/components/documents/Assessable/ChoiceAnswer/index.tsx b/src/components/documents/Assessable/ChoiceAnswer/index.tsx index 6300abd9a..2cbf40a19 100644 --- a/src/components/documents/Assessable/ChoiceAnswer/index.tsx +++ b/src/components/documents/Assessable/ChoiceAnswer/index.tsx @@ -4,7 +4,7 @@ import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentTy import Loader from '@tdev-components/Loader'; import useIsBrowser from '@docusaurus/useIsBrowser'; import { useDocumentRootId } from '@tdev-hooks/useContextDocumentRootId'; -import { useFirstDocumentBy } from '@tdev-hooks/useFirstDocumentBy'; +import { useNestedAssessableDocumentBy } from '@tdev-hooks/useNestedAssessableDocumentBy'; import { DocContext } from '@tdev-components/documents/DocumentContext'; import { AssessableComponentProps } from '@tdev-models/documents/Assessable/AssessableMeta'; import { type default as ChoiceAnswerModel, ModelMeta } from '@tdev-models/documents/Assessable/ChoiceAnswer'; @@ -18,6 +18,7 @@ interface SharedProps extends AssessableComponentProps<'choice_answer'> { multiple?: boolean; randomizeOptions?: boolean; optionsCount: number; + allowSelection?: boolean; } export interface StandaloneProps extends SharedProps { @@ -44,7 +45,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { const [meta] = React.useState(new ModelMeta(props)); const docRootId = useDocumentRootId(props.id); - const doc = useFirstDocumentBy(docRootId, meta, props.qid); + const doc = useNestedAssessableDocumentBy(docRootId, meta, props.qid); const isBrowser = useIsBrowser(); if (!doc) { @@ -56,7 +57,7 @@ const ChoiceAnswer = observer((props: ChoiceAnswerProps) => { } return ( - + {props.children} ); diff --git a/src/components/documents/Assessable/Inputs/Option/index.tsx b/src/components/documents/Assessable/Inputs/Option/index.tsx index 22a5883ce..1445abe77 100644 --- a/src/components/documents/Assessable/Inputs/Option/index.tsx +++ b/src/components/documents/Assessable/Inputs/Option/index.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite'; import { useDocument } from '@tdev-hooks/useContextDocument'; import Button from '@tdev-components/shared/Button'; import { mdiTrashCanOutline } from '@mdi/js'; -import { AssessableType, AssessableTypeModelMapping } from '@tdev-api/document'; +import type { AssessableType, AssessableTypeModelMapping } from '@tdev-api/document'; export interface Props { type?: T; @@ -22,7 +22,18 @@ const Option = observer((props: Props) => { const doc = useDocument(); const optionId = React.useId(); const { children, optionIndex, optionOrder, onChange, isChecked } = props; - + if (doc.keepExpanded === 'none') { + return null; + } + if (doc.keepExpanded === 'selected' && !isChecked) { + return null; + } + const correct = doc.linkedMeta?.correct ?? []; + if (doc.keepExpanded === 'correct' && correct.length > 0) { + if (!correct.includes(optionIndex)) { + return null; + } + } return (
{ +const Options = observer(({ children }: { children: React.ReactNode }) => { + const doc = useDocument(); return (
{children}
+ {doc.canCollapseOptions && ( + <> +
); -}; +}); export default Options; diff --git a/src/components/documents/Assessable/Inputs/styles.module.scss b/src/components/documents/Assessable/Inputs/styles.module.scss index 34eeb73e1..4a703a430 100644 --- a/src/components/documents/Assessable/Inputs/styles.module.scss +++ b/src/components/documents/Assessable/Inputs/styles.module.scss @@ -1,6 +1,18 @@ .optionsBlock { --tdev-assessable-btn-remove-answer-transition-duration: 0.15s; + position: relative; + .btnExpandCollapseOptions { + position: absolute; + right: 0em; + bottom: 0em; + } + &:not(:hover) { + .btnExpandCollapseOptions { + opacity: 0.25; + transition: opacity var(--tdev-assessable-btn-remove-answer-transition-duration); + } + } &:not(:last-child) { margin-bottom: 1em; } diff --git a/src/components/documents/Assessable/QuestionCard/index.tsx b/src/components/documents/Assessable/QuestionCard/index.tsx index 6dd204dc3..55f86fbcb 100644 --- a/src/components/documents/Assessable/QuestionCard/index.tsx +++ b/src/components/documents/Assessable/QuestionCard/index.tsx @@ -10,6 +10,7 @@ import QuestionControls from './Controls'; interface Props { doc: TypeModelMapping[T]; + allowSelection?: boolean; children: React.ReactNode; } @@ -19,7 +20,11 @@ const QuestionCard = observer((props: Props) => { return ( { randomizeOptions?: boolean; randomizeQuestions?: boolean; minPoints?: number; + allowSelection?: boolean; } const Quiz = observer((props: Props) => { @@ -41,6 +39,7 @@ const Quiz = observer((props: Props) => { className={clsx( styles.quiz, animate && styles.animate, + props.allowSelection && styles.allowSelection, doc.isAssessed && doc.assessment && styles[doc.assessment?.correctness] )} ref={ref} diff --git a/src/components/documents/Assessable/Quiz/styles.module.scss b/src/components/documents/Assessable/Quiz/styles.module.scss index 2ee41d8ce..0ed9385b0 100644 --- a/src/components/documents/Assessable/Quiz/styles.module.scss +++ b/src/components/documents/Assessable/Quiz/styles.module.scss @@ -14,6 +14,9 @@ .quiz { --tdev-quiz-flash-color: var(--ifm-color-warning); + &.allowSelection { + --tdev-choice-answers-user-selection: text; + } &.correct { --tdev-quiz-flash-color: var(--ifm-color-success); } diff --git a/src/components/documents/Assessable/TrueFalseAnswer/index.tsx b/src/components/documents/Assessable/TrueFalseAnswer/index.tsx index 8a5fec40a..2ce82a569 100644 --- a/src/components/documents/Assessable/TrueFalseAnswer/index.tsx +++ b/src/components/documents/Assessable/TrueFalseAnswer/index.tsx @@ -6,7 +6,7 @@ import { ModelMeta } from '@tdev-models/documents/Assessable/TrueFalseAnswer'; import { useDocumentRootId } from '@tdev-hooks/useContextDocumentRootId'; -import { useFirstDocumentBy } from '@tdev-hooks/useFirstDocumentBy'; +import { useNestedAssessableDocumentBy } from '@tdev-hooks/useNestedAssessableDocumentBy'; import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; import QuestionCard from '../QuestionCard'; import { DocContext } from '@tdev-components/documents/DocumentContext'; @@ -37,7 +37,7 @@ const onUpdateSelection = action((doc: TrueFalseAnswerModel, optionIndex: number const TrueFalseAnswer = observer((props: Props) => { const [meta] = React.useState(new ModelMeta({ ...props })); const docRootId = useDocumentRootId(props.id); - const doc = useFirstDocumentBy(docRootId, meta, props.qid); + const doc = useNestedAssessableDocumentBy(docRootId, meta, props.qid); if (!doc) { return ; diff --git a/src/components/documents/CmsText/shared.ts b/src/components/documents/CmsText/shared.ts index 20f1e66f4..6e12da41f 100644 --- a/src/components/documents/CmsText/shared.ts +++ b/src/components/documents/CmsText/shared.ts @@ -19,5 +19,5 @@ export function useFirstCmsTextDocumentIfExists(id?: string): CmsText | undefine // Not using useFirstMainDocument() here because that would always supply a (dummy) document. const docRoot = useDocumentRoot(id, meta, false); - return docRoot?.firstMainDocument; + return docRoot?.documentsByType?.get(meta.type)?.[0] as CmsText | undefined; } diff --git a/src/hooks/useDocumentRoot.ts b/src/hooks/useDocumentRoot.ts index 15315a089..27169fc62 100644 --- a/src/hooks/useDocumentRoot.ts +++ b/src/hooks/useDocumentRoot.ts @@ -89,7 +89,11 @@ export const useDocumentRoot = ( return reaction( () => documentRootStore.find(dummyDocumentRoot.id)?._triggerDocumentReload, () => { - const firstMainDoc = documentRootStore.find(dummyDocumentRoot.id)?.firstMainDocument; + const docRoot = documentRootStore.find(dummyDocumentRoot.id); + if (!docRoot) { + return; + } + const firstMainDoc = docRoot.documentsByType.get(meta.type)?.[0]; if (firstMainDoc) { return; } diff --git a/src/hooks/useFirstMainDocument.ts b/src/hooks/useFirstMainDocument.ts index 864c21364..8f177b9ba 100644 --- a/src/hooks/useFirstMainDocument.ts +++ b/src/hooks/useFirstMainDocument.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { DocumentType } from '@tdev-api/document'; +import { DocumentType, TypeModelMapping } from '@tdev-api/document'; import { TypeMeta } from '@tdev-models/DocumentRoot'; import { useDocumentRoot } from '@tdev-hooks/useDocumentRoot'; import { useStore } from '@tdev-hooks/useStore'; @@ -65,6 +65,5 @@ export const useFirstMainDocument = ( { fireImmediately: true } ); }, [userStore, createDocument, documentRoot]); - - return documentRoot?.firstMainDocument || dummyDocument; + return (documentRoot?.documentsByType?.get(meta.type)?.[0] as TypeModelMapping[Type]) || dummyDocument; }; diff --git a/src/hooks/useFirstDocumentBy.ts b/src/hooks/useNestedAssessableDocumentBy.ts similarity index 71% rename from src/hooks/useFirstDocumentBy.ts rename to src/hooks/useNestedAssessableDocumentBy.ts index f9dbb7839..d7ceb66b5 100644 --- a/src/hooks/useFirstDocumentBy.ts +++ b/src/hooks/useNestedAssessableDocumentBy.ts @@ -8,9 +8,12 @@ import { reaction } from 'mobx'; import { DUMMY_DOCUMENT_ID } from './useFirstMainDocument'; import { AssessableMeta } from '@tdev-models/documents/Assessable/AssessableMeta'; import useLinkedMetaModel from './useLinkedMetaModel'; +import _ from 'es-toolkit/compat'; const access = {} as Config; +const requested = new Set(); + /** * This hook provides access to the first main document of the rootDocument. * This is especially useful, when the DocumentType is expected to have only @@ -19,7 +22,7 @@ const access = {} as Config; * For bridging the time until the first main document is loaded, * a dummy document is provided in the meantime. */ -export const useFirstDocumentBy = ( +export const useNestedAssessableDocumentBy = ( documentRootId: string | undefined, /** ensure to put meta in a React.useState */ meta: AssessableMeta, @@ -54,26 +57,48 @@ export const useFirstDocumentBy = ( updatedAt: new Date().toISOString() }) as AssessableTypeModelMapping[Type] ); + const [canRequest, setCanRequest] = React.useState(false); React.useEffect(() => { if (!documentRoot) { return; } + const timeoutId = setTimeout(() => { + setCanRequest(true); + }, 5); + return () => { + clearTimeout(timeoutId); + }; + }, [documentRoot]); + + React.useEffect(() => { + if (!documentRoot || !canRequest) { + return; + } return reaction( () => documentRoot?._canInitializeDocuments && !documentRoot.documents.some(selector), (needsCreation) => { if (!needsCreation) { return; } - documentStore.create({ - documentRootId: documentRoot.id, - authorId: userStore.current!.id, - type: meta.type, - data: meta.defaultData - }); + const key = `${documentRoot.id}::${userStore.current!.id}::${meta.type}::${qid}`; + if (requested.has(key)) { + return; + } + requested.add(key); + documentStore + .create({ + documentRootId: documentRoot.id, + authorId: userStore.current!.id, + type: meta.type, + data: meta.defaultData + }) + .then(() => { + requested.delete(key); + }); }, { fireImmediately: true } ); - }, [userStore, documentRoot]); + }, [userStore, documentRoot, canRequest]); const firstDoc = documentRoot?.documents.find(selector) as AssessableTypeModelMapping[Type] | undefined; const doc = firstDoc || dummyDocument; diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index 3ff8f93f0..1bca2d7c3 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -227,41 +227,16 @@ class DocumentRoot { return this.store.root.userStore.viewedUserId; } - /** - * All documents which - * - **don't have a parent** - * - having the **same type** as this document root - * - * @returns All main documents, **ordered by creation date**, oldest first. - */ - @computed - get mainDocuments(): TypeModelMapping[T][] { - const docs = orderBy( - this.documents.filter((d) => d.isMain), - ['createdAt', 'id'], - ['asc', 'asc'] - ) as TypeModelMapping[T][]; - if (this.isDummy) { - return docs; - } - const byUser = docs.filter((d) => d.authorId === this.viewedUserId); - - if ( - this.store.root.userStore.current?.hasElevatedAccess && - this.store.root.userStore.isUserSwitched - ) { - return byUser; - } - - if (NoneAccess.has(this.sharedAccess)) { - return byUser; - } - return [...byUser, ...docs.filter((d) => d.authorId !== this.viewedUserId)]; - } - @computed - get firstMainDocument(): TypeModelMapping[T] | undefined { - return this.mainDocuments[0]; + get documentsByType(): Map { + return orderBy(this.documents, ['createdAt', 'id'], ['asc', 'asc']).reduce((map, doc) => { + const docs = map.get(doc.type) || []; + if (docs.length === 0) { + map.set(doc.type, docs); + } + docs.push(doc); + return map; + }, new Map()); } @action @@ -300,12 +275,13 @@ class DocumentRoot { @computed get _needsInitialDocumentCreation() { - return this._canInitializeDocuments && !this.firstMainDocument; + return this._canInitializeDocuments && !this.documentsByType.has(this.meta.type); } @computed get _triggerDocumentReload() { - return `${this.firstMainDocument?.id}-${this.store.root.userStore.viewedUserId}`; + const firstMainDoc = this.documentsByType.get(this.meta.type)?.[0]; + return `${firstMainDoc?.id}-${this.store.root.userStore.viewedUserId}`; } } diff --git a/src/models/Page.ts b/src/models/Page.ts index 5cd875b4c..0bec2baa0 100644 --- a/src/models/Page.ts +++ b/src/models/Page.ts @@ -105,14 +105,6 @@ export default class Page { }); } - @computed - get documents() { - return this.documentRoots - .flatMap((doc) => doc.firstMainDocument) - .filter((d) => d?.root?.meta.pagePosition) - .sort((a, b) => a!.root!.meta!.pagePosition - b!.root!.meta.pagePosition); - } - @computed get studentGroupName() { const pathParts = this.path.split('/').filter((p) => p.length > 0); diff --git a/src/models/documents/Assessable/AssessableMeta.ts b/src/models/documents/Assessable/AssessableMeta.ts index dc06f6a41..e81baa45a 100644 --- a/src/models/documents/Assessable/AssessableMeta.ts +++ b/src/models/documents/Assessable/AssessableMeta.ts @@ -3,11 +3,20 @@ import type { default as iAssessable, Assessement } from './iAssessable'; import { TypeMeta } from '@tdev-models/DocumentRoot'; import { observable } from 'mobx'; +export type ExpandedOption = 'all' | 'correct' | 'selected' | 'none'; + export interface AssessableComponentProps { id?: string; qid?: string; title?: string; correct?: number[]; + /** + * collapse options in the ui, when the question is answered and only + * display the correct answer(s) and the scoring result. + * This is useful for questions that have a lot of options, + * such as multiple choice questions with many distractors. + */ + keepExpanded?: ExpandedOption; scoring?: ScoringFunction; readonly?: boolean; children: React.ReactNode; @@ -19,6 +28,7 @@ export abstract class AssessableMeta extends TypeMeta< readonly qid?: string; readonly correct?: number[]; readonly scoring?: ScoringFunction; + readonly keepExpanded: ExpandedOption | undefined; @observable accessor title: string | undefined; constructor(type: T, props: Partial>) { const explicitProps: Partial> = { @@ -26,6 +36,7 @@ export abstract class AssessableMeta extends TypeMeta< qid: props.qid, title: props.title, correct: props.correct, + keepExpanded: props.keepExpanded, scoring: props.scoring, readonly: props.readonly }; @@ -34,6 +45,7 @@ export abstract class AssessableMeta extends TypeMeta< this.correct = props.correct?.map((index) => index - 1); // convert to 0-based index this.scoring = props.scoring; this.title = props.title; + this.keepExpanded = props.keepExpanded; } abstract get defaultData(): TypeDataMapping[T]; } diff --git a/src/models/documents/Assessable/iAssessable.ts b/src/models/documents/Assessable/iAssessable.ts index 6b63b3644..2d946a652 100644 --- a/src/models/documents/Assessable/iAssessable.ts +++ b/src/models/documents/Assessable/iAssessable.ts @@ -3,7 +3,7 @@ import iDocument from '@tdev-models/iDocument'; import DocumentStore from '@tdev-stores/DocumentStore'; import { action, computed, observable, observableRef } from 'mobx'; import React from 'react'; -import { AssessableMeta } from './AssessableMeta'; +import { AssessableMeta, ExpandedOption } from './AssessableMeta'; import Quiz from './Quiz'; import { iTaskableDocument } from '@tdev-models/iTaskableDocument'; import { mdiTooltipQuestionOutline } from '@mdi/js'; @@ -44,6 +44,7 @@ abstract class iAssessable extends iDocument implem @observable accessor _assessed: boolean; // @observableRef accessor scoringFunction: ((self: this) => Assessement) | null = null; @observableRef accessor linkedMeta: AssessableMeta | null = null; + @observable accessor showAllOptions: boolean = false; constructor(props: DocumentProps, store: DocumentStore) { super(props, store, 50); @@ -61,6 +62,37 @@ abstract class iAssessable extends iDocument implem // By default, do nothing. Only applicable for certain assessable document types (e.g. ChoiceAnswer). } + @action + setShowAllOptions(value: boolean) { + this.showAllOptions = value; + } + + /** + * returns wheter the + * - the answer is correct + * - the question is answered + * - linked meta allows collapsing + */ + @computed + get canCollapseOptions(): boolean { + if (!this.linkedMeta) { + return false; + } + if (!this.isAssessed || this.correctness !== Correctness.Correct) { + return false; + } + const opts = [this.linkedMeta.keepExpanded, this.quiz?.linkedMeta?.keepExpanded].filter((v) => !!v); + return opts.some((o) => o !== 'all'); + } + + @computed + get keepExpanded(): ExpandedOption { + if (this.showAllOptions || !this.canCollapseOptions) { + return 'all'; + } + return this.linkedMeta?.keepExpanded ?? this.quiz?.linkedMeta?.keepExpanded ?? 'all'; + } + @computed get isDone(): boolean { return this.isAssessed; @@ -98,13 +130,14 @@ abstract class iAssessable extends iDocument implem @computed get quiz(): Quiz | undefined { - if (!this.inQuiz || this.root?.firstMainDocument?.type !== 'quiz') { + if (!this.inQuiz) { return undefined; } - if (this.root.firstMainDocument.id === this.id) { + const firstQuiz = this.root?.documents.find((d) => d.type === 'quiz') as Quiz | undefined; + if (!firstQuiz || firstQuiz.id === this.id) { return undefined; } - return this.root.firstMainDocument; + return firstQuiz; } @computed @@ -115,7 +148,7 @@ abstract class iAssessable extends iDocument implem if (!this.inQuiz || this.type === 'quiz') { return null; } - const quiz = this.root?.firstMainDocument; + const quiz = this.root?.documentsByType?.get('quiz')?.[0] as Quiz | undefined; if (quiz?.type !== 'quiz') { return null; } diff --git a/src/models/documents/DynamicDocumentRoots/index.ts b/src/models/documents/DynamicDocumentRoots/index.ts index 97afc58fc..12d714c8d 100644 --- a/src/models/documents/DynamicDocumentRoots/index.ts +++ b/src/models/documents/DynamicDocumentRoots/index.ts @@ -215,7 +215,11 @@ class DynamicDocumentRoots extends iDocument<'dynami @computed get linkedDocumentContainers(): ContainerTypeModelMapping[Type][] { return this.linkedDynamicDocumentRoots - .flatMap((dr) => dr.firstMainDocument as ContainerTypeModelMapping[Type] | undefined) + .flatMap( + (dr) => + dr.documentsByType.get(this.containerType)?.[0] as + ContainerTypeModelMapping[Type] | undefined + ) .filter((d) => !!d); } @@ -224,7 +228,7 @@ class DynamicDocumentRoots extends iDocument<'dynami return new Map( this.linkedDynamicDocumentRoots.map((dr) => [ dr.id, - dr.firstMainDocument as ContainerTypeModelMapping[Type] + dr.documentsByType.get(this.containerType)?.[0] as ContainerTypeModelMapping[Type] ]) ); } diff --git a/src/models/documents/ScriptVersion.ts b/src/models/documents/ScriptVersion.ts index d9d9d0233..54d06a96d 100644 --- a/src/models/documents/ScriptVersion.ts +++ b/src/models/documents/ScriptVersion.ts @@ -24,7 +24,7 @@ class ScriptVersion extends iDocument<'script_version'> { @computed get version() { - const script = this.root?.firstMainDocument as Script; + const script = this.root?.documentsByType.get('script')?.[0] as Script; if (!script) { return 0; } diff --git a/src/stores/DocumentRootStore.ts b/src/stores/DocumentRootStore.ts index 75732d0d6..9af950cba 100644 --- a/src/stores/DocumentRootStore.ts +++ b/src/stores/DocumentRootStore.ts @@ -1,4 +1,4 @@ -import { action, computed, observable, runInAction } from 'mobx'; +import { action, computed, observable, runInAction, transaction } from 'mobx'; import { RootStore } from '@tdev-stores/rootStore'; import { computedFn } from 'mobx-utils'; import DocumentRoot, { TypeMeta } from '@tdev-models/DocumentRoot'; @@ -325,36 +325,44 @@ export class DocumentRootStore extends iStore { if (!documentRoot) { return; } - if (config.load.documentRoot) { - if (config.load.documentRoot === 'addIfMissing') { - const current = this.find(data.id); - if (!current || current.isUnknown) { - this.addDocumentRoot(documentRoot); + return transaction(() => { + if (config.load.documentRoot) { + if (config.load.documentRoot === 'addIfMissing') { + const current = this.find(data.id); + if (!current || current.isUnknown) { + this.addDocumentRoot(documentRoot); + } + } else { + this.addDocumentRoot(documentRoot, { cleanup: true, deep: false }); } - } else { - this.addDocumentRoot(documentRoot, { cleanup: true, deep: false }); } - } - if (config.load.groupPermissions) { - data.groupPermissions.forEach((gp) => { - this.root.permissionStore.addGroupPermission( - new GroupPermission({ ...gp, documentRootId: documentRoot.id }, this.root.permissionStore) - ); - }); - } - if (config.load.userPermissions) { - data.userPermissions.forEach((up) => { - this.root.permissionStore.addUserPermission( - new UserPermission({ ...up, documentRootId: documentRoot.id }, this.root.permissionStore) - ); - }); - } - if (config.load.documents) { - data.documents.forEach((doc) => { - this.root.documentStore.addToStore(doc); - }); - } - return documentRoot; + if (config.load.groupPermissions) { + data.groupPermissions.forEach((gp) => { + this.root.permissionStore.addGroupPermission( + new GroupPermission( + { ...gp, documentRootId: documentRoot.id }, + this.root.permissionStore + ) + ); + }); + } + if (config.load.userPermissions) { + data.userPermissions.forEach((up) => { + this.root.permissionStore.addUserPermission( + new UserPermission( + { ...up, documentRootId: documentRoot.id }, + this.root.permissionStore + ) + ); + }); + } + if (config.load.documents) { + data.documents.forEach((doc) => { + this.root.documentStore.addToStore(doc); + }); + } + return documentRoot; + }); } @action diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 77f7260ed..daf4bf4d7 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -3,7 +3,6 @@ import { RootStore } from './rootStore'; import { computedFn } from 'mobx-utils'; import { allDocuments as apiAllDocuments, - find as apiFind, create as apiCreate, Document as DocumentProps, DocumentType, @@ -41,7 +40,6 @@ import ChoiceAnswer from '@tdev-models/documents/Assessable/ChoiceAnswer'; import TrueFalseAnswer from '@tdev-models/documents/Assessable/TrueFalseAnswer'; import Quiz from '@tdev-models/documents/Assessable/Quiz'; import { isStalledUpdate } from '@tdev/helpers/isStalledUpdate'; -import Unknown from '@tdev-models/documents/Unknown'; const IsNotUniqueError = (error: any) => { try { @@ -347,7 +345,7 @@ class DocumentStore extends iStore<`delete-${string}`> { if (!axios.isCancel(err)) { if (IsNotUniqueError(err)) { const docRoot = this.root.documentRootStore.find(model.documentRootId); - if ((docRoot?.mainDocuments?.length || 0) < 1) { + if ((docRoot?.documentsByType?.get(model.type)?.length || 0) < 1) { console.log('The main document must be unique - try to load it from the api.'); return this.root.documentRootStore.loadInNextBatch(model.documentRootId); } diff --git a/tdev-website/docs/gallery/_category_.yaml b/tdev-website/docs/gallery/_category_.yaml new file mode 100644 index 000000000..a21b29a95 --- /dev/null +++ b/tdev-website/docs/gallery/_category_.yaml @@ -0,0 +1,2 @@ +collapsed: false +collapsible: true diff --git a/tdev-website/docs/gallery/persistable-documents/_category_.yaml b/tdev-website/docs/gallery/persistable-documents/_category_.yaml new file mode 100644 index 000000000..a21b29a95 --- /dev/null +++ b/tdev-website/docs/gallery/persistable-documents/_category_.yaml @@ -0,0 +1,2 @@ +collapsed: false +collapsible: true diff --git a/tdev-website/docs/gallery/persistable-documents/answer/_category_.yaml b/tdev-website/docs/gallery/persistable-documents/answer/_category_.yaml new file mode 100644 index 000000000..a21b29a95 --- /dev/null +++ b/tdev-website/docs/gallery/persistable-documents/answer/_category_.yaml @@ -0,0 +1,2 @@ +collapsed: false +collapsible: true 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 0464a49be..3c4bb55e5 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 @@ -85,7 +85,7 @@ Bei Single-Choice-Aufgaben dürfen auch mehrere Antworten als korrekt angegeben ```tsx import ChoiceAnswer from '@tdev-components/documents/Assessable/ChoiceAnswer'; - + Welche der folgenden Programmiersprachen sind statisch typisiert? **Hinweis:** Es kann mehr als eine Antwort korrekt sein. 1. TypeScript @@ -96,6 +96,18 @@ import ChoiceAnswer from '@tdev-components/documents/Assessable/ChoiceAnswer'; ``` + + + 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: @@ -216,6 +228,123 @@ import { points, multipleChoicePoints } from '@tdev-components/documents/Assessa +### `keepExpanded` +Die `keepExpanded`-Property steuert, welche Antwortmöglichkeiten nach der Beantwortung der Frage angezeigt werden. Standardmässig werden alle Antwortmöglichkeiten angezeigt, es kann aber auch eingestellt werden, dass nur die korrekten Antworten, nur die ausgewählten Antworten oder gar keine Antworten angezeigt werden. + +```tsx + + 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 + +``` + +:::flex{minWidth="max(250px, 45%)"} +`keepExpanded="all"` + + + 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 + + +::br +`keepExpanded="correct"` + + + 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 + + + +::br +`keepExpanded="selected"` + + + 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 + + + +::br +`keepExpanded="none"` + + + 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 + + +::: + +### `allowSelection` + +Standardmässig kann der Fragetext nicht ausgewählt werden, so dass das "Copy & Paste" von Fragen in LLM-Tools zumindest erschwert wird. + +Mit der `allowSelection`-Property kann dies jedoch überschrieben werden, so dass der Fragetext wieder ausgewählt werden kann: + +```tsx + + 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 + +``` + +:::flex{minWidth="max(250px, 45%)"} +`allowSelection` + + + 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 + + +::br +`allowSelection={false}` (Standard) + + + 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 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: @@ -273,7 +402,7 @@ import { points, multipleChoicePoints, noPoints } from '@tdev-components/documen ``` - + > In welchem Jahr war 2024? @@ -327,22 +456,55 @@ 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 + +### Allgemeine Eigenschaften + +`keepExpanded` +: *optional*, `all | correct | selected | none`. +: Steuert, welche Antwortmöglichkeiten nach der Beantwortung der Frage angezeigt werden. +: Standard: `all`. +`allowSelection` +: *optional*, `boolean`. +: Steuert, ob der Fragetext ausgewählt werden kann. Standardmässig ist dies deaktiviert, um das "Copy & Paste" von Fragen in LLM-Tools zumindest zu erschweren. + ### Eigenschaften der `ChoiceAnswer` -| Eigenschaft | Typ | Beschreibung | -|------------------|-------------------|---------------------------------------------------| -| `multiple` | Flag | Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). Standard: Single-Choice. | -| `correct` | `number[]` | Liste mit den Nummern der korrekten Antwortoptionen (wobei `1` die erste Antwortoption ist). | -| `scoring` | `ScoringFunction` | Übersteuert die `ScoringFunction` des übergeordneten Quizzes für diese spezifische Frage. | -| `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 | + +`title` +: *optional*, `string`. +: Titel der Frage. Wird im Header der Frage angezeigt. +: Standard: `'Frage'`. +`multiple` +: *optional*, `boolean`. +: Wenn gesetzt, können mehrere Antworten ausgewählt werden (Multiple-Choice). +: Standard: `false`. +`correct` +: *optional*, `number[]`. +: Liste mit den Nummern der korrekten Antwortoptionen (wobei `1` die erste Antwortoption ist). +`randomizeOptions` +: *optional*, `boolean`. +: Wenn gesetzt, werden die Antwortmöglichkeiten in zufälliger Reihenfolge angezeigt. Die zufällige Darstellungsreihenfolge hat keinen Einfluss auf die `correct`-Liste. +`scoring` +: *optional*, `ScoringFunction`. +: Falls innerhalb eines __Quizzes__ definiert, wird die übergeordneten `ScoringFunction` des Quizzes überschrieben. +`hideQuestionNumbers`\* +: *optional*, `boolean` - \* nur relevant innerhalb von Quizzes. +: Wenn gesetzt, wird den Fragen innerhalb des Quiz kein Titel mit der Fragenummer hinzugefügt. + ### 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). | -| `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`. | + +`randomizeQuestions` +: *optional*, `boolean`. +: Wenn gesetzt, werden die Fragen in zufälliger Reihenfolge angezeigt. +`randomizeOptions` +: *optional*, `boolean`. +: Wenn gesetzt, werden die Antwortmöglichkeiten jeder Frage in zufälliger Reihenfolge angezeigt (analog zu `ChoiceAnswer.randomizeOptions` für einzelne Fragen). +`scoring` +: *optional*, `ScoringFunction`. +: Eine vordefinierte oder benutzerdefinierte `ScoringFunction`. +`minPoints` +: *optional*, `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. ### `ScoringFunction` Eine `ScoringFunction` ist eine Funktion, die die Bewertung einer Frage oder eines Quizzes übernimmt. Vordefinierte Scoring-Funktionen sind: