diff --git a/src/components/documents/Assessable/ChoiceAnswer/index.tsx b/src/components/documents/Assessable/ChoiceAnswer/index.tsx index c46b801cf..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'; @@ -45,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) { diff --git a/src/components/documents/Assessable/Inputs/Option/index.tsx b/src/components/documents/Assessable/Inputs/Option/index.tsx index 4dee50c1b..1445abe77 100644 --- a/src/components/documents/Assessable/Inputs/Option/index.tsx +++ b/src/components/documents/Assessable/Inputs/Option/index.tsx @@ -5,8 +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 ChoiceAnswer from '@tdev-models/documents/Assessable/ChoiceAnswer'; +import type { AssessableType, AssessableTypeModelMapping } from '@tdev-api/document'; export interface Props { type?: T; diff --git a/src/components/documents/Assessable/Quiz/index.tsx b/src/components/documents/Assessable/Quiz/index.tsx index 7cd9a60cf..40e301c22 100644 --- a/src/components/documents/Assessable/Quiz/index.tsx +++ b/src/components/documents/Assessable/Quiz/index.tsx @@ -1,10 +1,7 @@ -import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; import { observer } from 'mobx-react-lite'; import React from 'react'; import UnknownDocumentType from '@tdev-components/shared/Alert/UnknownDocumentType'; -import Loader from '@tdev-components/Loader'; import styles from './styles.module.scss'; -import useIsBrowser from '@docusaurus/useIsBrowser'; import { DocumentRootIdContext } from '@tdev-hooks/useContextDocumentRootId'; import { AssessableComponentProps } from '@tdev-models/documents/Assessable/AssessableMeta'; import { ModelMeta } from '@tdev-models/documents/Assessable/Quiz'; 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/iAssessable.ts b/src/models/documents/Assessable/iAssessable.ts index 33e76caaf..2d946a652 100644 --- a/src/models/documents/Assessable/iAssessable.ts +++ b/src/models/documents/Assessable/iAssessable.ts @@ -130,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 @@ -147,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/persistable-documents/answer/choice-answer/index.mdx b/tdev-website/docs/gallery/persistable-documents/answer/choice-answer/index.mdx index 8089109d4..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 @@ -402,7 +402,7 @@ import { points, multipleChoicePoints, noPoints } from '@tdev-components/documen ``` - + > In welchem Jahr war 2024?