diff --git a/.gitignore b/.gitignore index 2e97c4922..89144484f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ yarn-error.log* .env # Sentry Config File .env.sentry-build-plugin -/tmp-assets \ No newline at end of file +/tmp-assets +.codex \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 945ff42e9..c6bc6cf29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ Follow existing TypeScript/TSX patterns and `.prettierrc`: four-space indentatio Use PascalCase for components and classes, `useCamelCase` for hooks. Prefer configured `@tdev-*` import aliases and keep site-specific overrides in the appropriate `tdev-website/` directory. +New components should normally be put in an `index.tsx` alongside a `styles.module.css` file, where the folder is named after the component. Use `clsx` for conditional class names. Avoid inline styles and global CSS. + +Use mobx for state management and only use local reactish component state for component-specific state that does not need to be shared or persisted after unmounting. Use `useStore` to access stores. + ## Testing Guidelines Use tests only for remark plugins or packages which are run from the cli. No tests for components, hooks, or stores. Use Vitest with descriptive `describe` and `it` blocks. Name tests `*.test.ts` under the relevant module's `tests/` directory. Plugin tests commonly use inline snapshots; review snapshot changes for intended output differences. No coverage threshold is configured. @@ -41,3 +45,13 @@ In pull requests, describe the behavior change, link relevant issues, report val ## Configuration & Secrets Use `.env.example` and the README to configure local settings. Keep credentials and tokens out of commits. + +## Agent delegation + +For complex tasks, delegate independent work to subagents: + +- Use explorer to investigate relevant code and existing patterns. +- Use reviewer to review the completed changes. +- The main agent implements changes and runs validation. +- Avoid having multiple agents edit the same files. +- Keep simple tasks in the main agent \ No newline at end of file diff --git a/packages/tdev/text-message/README.mdx b/packages/tdev/text-message/README.mdx index a11bb6ecc..dc8e73be2 100644 --- a/packages/tdev/text-message/README.mdx +++ b/packages/tdev/text-message/README.mdx @@ -5,25 +5,25 @@ tags: - dynamic-documents --- -import SimpleChat from '@tdev/text-message/components/SimpleChat'; -import DynamicDocumentRoots from '@tdev-components/documents/DynamicDocumentRoots'; import CodeShowcase from '@tdev-components/CodeShowcase'; +import DynamicDocumentRoots from '@tdev-components/documents/DynamicDocumentRoots'; +import SimpleChat from '@tdev/text-message/components/SimpleChat'; # text-message Textnachrichten ermöglichen einen simplen Chat - dies ist eher ein PoC und die Basis für weitere, unterrichtsrelevante Interaktionsmöglichkeiten. - + - + -## Technischer Hintergrund +## Technischer Hintergrundnaa Die `DocumentRoot` wird verwendet, um in MDX-Dokumenten bspw. Antworten User-Basiert abzuspeichern - es ist das Elternelement für alle Antworten und jede User:in kann dann ein eigenes `Document` hinzufügen. Über die `DocumentRoot` werden auch die Berechtigungen festgelegt. diff --git a/packages/tdev/text-message/components/SimpleChat/CreateSimpleChat.tsx b/packages/tdev/text-message/components/SimpleChat/CreateSimpleChat.tsx index 6adbe493f..baf77a4bf 100644 --- a/packages/tdev/text-message/components/SimpleChat/CreateSimpleChat.tsx +++ b/packages/tdev/text-message/components/SimpleChat/CreateSimpleChat.tsx @@ -1,3 +1,4 @@ +import { NotCreated } from '@tdev-components/Rooms'; import Button from '@tdev-components/shared/Button'; import { useCreateDocument } from '@tdev-hooks/useCreateDocument'; import { useStore } from '@tdev-hooks/useStore'; @@ -20,7 +21,12 @@ const CreateSimpleChat = observer((props: Props) => { const meta = React.useMemo(() => new ModelMeta({ name: props.name }), [props.id, props.name]); const { create, apiState } = useCreateDocument(props.id, meta, true); if (!userStore.current?.hasElevatedAccess) { - return null; + return ( + <> + + + + ); } return (
diff --git a/packages/tdev/text-message/components/SimpleChat/index.tsx b/packages/tdev/text-message/components/SimpleChat/index.tsx index df01176d1..40f9af04f 100644 --- a/packages/tdev/text-message/components/SimpleChat/index.tsx +++ b/packages/tdev/text-message/components/SimpleChat/index.tsx @@ -1,4 +1,4 @@ -import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import { useGlobalFirstMainDocument } from '@tdev-hooks/useGlobalFirstMainDocument'; import { ModelMeta } from '@tdev/text-message/models/SimpleChat/ModelMeta'; import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; @@ -16,7 +16,7 @@ interface Props { const SimpleChat = observer((props: Props): React.ReactNode => { const { id, name } = props; const meta = React.useMemo(() => new ModelMeta({ name }), [id, name]); - const simpleChat = useFirstMainDocument(id, meta, false); + const simpleChat = useGlobalFirstMainDocument(id, meta, false); if (!simpleChat || simpleChat.isDummy) { return ; } diff --git a/src/api/OfflineApi/Adapter/IndexedDb.ts b/src/api/OfflineApi/Adapter/IndexedDb.ts index 0fee7baee..d76452d85 100644 --- a/src/api/OfflineApi/Adapter/IndexedDb.ts +++ b/src/api/OfflineApi/Adapter/IndexedDb.ts @@ -1,152 +1,192 @@ -import { Document, DocumentType } from '@tdev-api/document'; -import { IDBPDatabase, openDB } from 'idb'; -import { DbAdapter, DBSchema } from '.'; +import type { Document, DocumentType } from '@tdev-api/document'; +import { deleteDB, type IDBPDatabase, openDB } from 'idb'; +import type { DbAdapter, DBSchema, DocumentsTransaction } from '.'; import { OfflineUser } from '..'; +import { normalizeDocumentParent } from '../documents'; +import { IMPORT_STORES, type ImportedStores, prepareImport } from './import'; +import { DB_VERSION, migrations } from './migrations'; const withFallback = (fn: () => Promise, fallback: T = undefined as T) => { return fn().catch(() => fallback); }; type InitStores = 'fsHandles' | 'documents' | 'studentGroups' | 'permissions' | 'users'; -const DB_VERSION = 3 as const; class IndexedDbAdapter implements DbAdapter { private dbName: string; private dbPromise: Promise>; readonly mode = 'indexedDB'; - constructor(dbName: string, initializeStores: boolean = false) { + // Retain the constructor argument for existing callers; every mode now uses the same schema. + constructor(dbName: string, _initializeStores: boolean = false) { this.dbName = dbName; - this.dbPromise = this.initDB(initializeStores); + this.dbPromise = this.initDB(); + void this.dbPromise.catch((error) => console.error('Failed to initialize IndexedDB:', error)); } - private async initDB(initializeStores: boolean): Promise> { - return openDB(this.dbName, DB_VERSION, { - upgrade(db) { - if (!db.objectStoreNames.contains('fsHandles')) { - db.createObjectStore('fsHandles'); - } - if (process.env.NODE_ENV !== 'production' || initializeStores) { - if (!db.objectStoreNames.contains('documents')) { - const store = db.createObjectStore('documents', { keyPath: 'id' }); - store.createIndex('documentRootId', 'documentRootId', { unique: false }); - } - if (!db.objectStoreNames.contains('studentGroups')) { - db.createObjectStore('studentGroups', { keyPath: 'id' }); + private async initDB(): Promise> { + let upgraded: Promise = Promise.resolve(); + let upgradeError: unknown; + const opened = openDB(this.dbName, DB_VERSION, { + upgrade(db, oldVersion, newVersion, transaction) { + // Index population can fail asynchronously after createIndex has returned. + void transaction.done.catch((error) => { + upgradeError ??= error; + }); + upgraded = (async () => { + for (const migration of migrations) { + if ( + oldVersion < migration.version && + migration.version <= (newVersion ?? DB_VERSION) + ) { + await migration.upgrade(db, transaction); + } } - if (!db.objectStoreNames.contains('permissions')) { - db.createObjectStore('permissions', { keyPath: 'id' }); + })().catch((error) => { + upgradeError = error; + console.error('IndexedDB migration failed:', error); + try { + transaction.abort(); + } catch { + // A failed IndexedDB request may have already aborted the upgrade. } - if (!db.objectStoreNames.contains('users')) { - db.createObjectStore('users', { keyPath: 'id' }); - } - } - } + }); + }, + blocked: () => console.warn('IndexedDB upgrade blocked by another open tab:', this.dbName), + blocking: () => void this.dbPromise.then((db) => db.close()) }); + try { + const db = await opened; + await upgraded; + if (upgradeError) { + db.close(); + throw upgradeError; + } + return db; + } catch (error) { + throw upgradeError ?? error; + } + } + + async withDocumentsTransaction(work: (store: DocumentsTransaction) => Promise): Promise { + const db = await this.dbPromise; + const transaction = db.transaction('documents', 'readwrite'); + const done = transaction.done; + // Observe aborts even when a request or validation fails before awaiting completion. + void done.catch(() => {}); + const store: DocumentsTransaction = { + get: (id) => transaction.store.get(id), + getAll: () => transaction.store.getAll(), + put: async (document) => { + await transaction.store.put(normalizeDocumentParent(document)); + }, + delete: async (id) => { + await transaction.store.delete(id); + } + }; + try { + const result = await work(store); + await done; + return result; + } catch (error) { + try { + transaction.abort(); + } catch { + // A failed IndexedDB request may have already aborted the transaction. + } + await done.catch(() => {}); + throw error; + } } async get(storeName: InitStores, id: string): Promise; async get(storeName: string, id: string): Promise; - async get(storeName: InitStores | string, id: string): Promise { - return withFallback(async () => { - const db = await this.dbPromise; - return db.get(storeName, id) as Promise; - }); + async get(storeName: string, id: string): Promise { + const db = await this.dbPromise; + return withFallback(() => db.get(storeName, id) as Promise); } async byDocumentRootId( documentRootId: string | null | undefined ): Promise[]> { + const db = await this.dbPromise; if (!documentRootId) { - return Promise.resolve([]); + return []; } - return withFallback(async () => { - const db = await this.dbPromise; - const index = db.transaction('documents', 'readonly').store.index('documentRootId'); - const docs = (await index.getAll(documentRootId)) as Document[]; - return docs; - }, []); + return db.getAllFromIndex('documents', 'documentRootId', documentRootId); } async getAll(storeName: string): Promise { - return withFallback(async () => { - const db = await this.dbPromise; - return db.getAll(storeName) as Promise; - }, []); + const db = await this.dbPromise; + return withFallback(() => db.getAll(storeName) as Promise, []); } + async put(storeName: string, item: T, id: IDBKeyRange | IDBValidKey): Promise; async put(storeName: string, item: T & { id: string }): Promise; - async put( - storeName: string, - item: (T & { id: string }) | T, - id?: IDBKeyRange | IDBValidKey - ): Promise { - return withFallback(async () => { - const db = await this.dbPromise; - await db.put(storeName, item, id); - }); + async put(storeName: string, item: T, id?: IDBKeyRange | IDBValidKey): Promise { + if (storeName === 'documents') { + return this.withDocumentsTransaction((store) => store.put(item as Document)); + } + const db = await this.dbPromise; + await db.put(storeName, item, id); } - async delete(storeName: InitStores, id: string): Promise; - async delete(storeName: string, id: string): Promise; - async delete(storeName: string | InitStores, id: string): Promise { - return withFallback(async () => { - const db = await this.dbPromise; - await db.delete(storeName, id); - }); + async delete(storeName: string, id: string): Promise { + if (storeName === 'documents') { + return this.withDocumentsTransaction((store) => store.delete(id)); + } + const db = await this.dbPromise; + await db.delete(storeName, id); } async destroyDb(): Promise { - return withFallback(async () => { - const db = await this.dbPromise; - await db.close(); - // Optionally delete the database - await indexedDB.deleteDatabase(this.dbName); - }); + const db = await this.dbPromise; + db.close(); + await deleteDB(this.dbName); } + async exportDb(): Promise<{ [storeName: string]: any }> { - return withFallback(async () => { - const db = await this.dbPromise; - const exportData: { [storeName: string]: any } = {}; - for (const storeName of ['documents', 'studentGroups', 'permissions']) { - exportData[storeName] = await db.getAll(storeName); - } - // Here you can handle the exportData, e.g., save it to a file or send it to a server - return exportData; - }); + const db = await this.dbPromise; + const transaction = db.transaction(['documents', 'studentGroups', 'permissions'], 'readonly'); + const data: { [storeName: string]: any } = { user: OfflineUser.getUser() }; + for (const name of transaction.objectStoreNames) { + data[name] = await transaction.objectStore(name).getAll(); + } + await transaction.done; + return data; } + async importDb(data: { [storeName: string]: any }): Promise { - const hasUser = data['user']; - let needsCleanup = false; - if (hasUser) { - const user = data['user']; - const current = OfflineUser.getUser(); - if (!current || current.id !== user.id) { - needsCleanup = true; + const db = await this.dbPromise; + const transaction = db.transaction([...IMPORT_STORES], 'readwrite'); + const done = transaction.done; + void done.catch(() => {}); + try { + const existing = {} as ImportedStores; + for (const name of IMPORT_STORES) { + existing[name] = await transaction.objectStore(name).getAll(); } - OfflineUser.setUser(user); - } - return withFallback(async () => { - const db = await this.dbPromise; - if (needsCleanup) { - await db.clear('users'); - await db.clear('documents'); - await db.clear('studentGroups'); - await db.clear('permissions'); - } - for (const storeName of Object.keys(data)) { - if (storeName === 'user') { - await db.put('users', data['user']); - } - if (!db.objectStoreNames.contains(storeName)) { - continue; - } - const items = data[storeName]; - for (const item of items) { - await db.put(storeName, item); + const imported = prepareImport(data, existing, OfflineUser.getUser()); + for (const name of IMPORT_STORES) { + const store = transaction.objectStore(name); + await store.clear(); + for (const record of imported.stores[name]) { + await store.put(record); } } - }); + await done; + if (imported.user) { + OfflineUser.setUser(imported.user); + } + } catch (error) { + try { + transaction.abort(); + } catch { + // The transaction may already be aborted. + } + await done.catch(() => {}); + throw error; + } } } diff --git a/src/api/OfflineApi/Adapter/MemoryDb.ts b/src/api/OfflineApi/Adapter/MemoryDb.ts index 287988ac5..7a112001a 100644 --- a/src/api/OfflineApi/Adapter/MemoryDb.ts +++ b/src/api/OfflineApi/Adapter/MemoryDb.ts @@ -1,65 +1,120 @@ -import { DocumentType, type Document } from '@tdev-api/document'; -import { DbAdapter } from '.'; +import type { Document, DocumentType } from '@tdev-api/document'; +import type { DbAdapter, DocumentsTransaction } from '.'; +import { OfflineUser } from '..'; +import { assertUniqueDocument, normalizeDocumentParent } from '../documents'; +import { IMPORT_STORES, type ImportedStores, prepareImport } from './import'; class MemoryDbAdapter implements DbAdapter { private db: { [storeName: string]: { [id: string]: any } } = {}; + private pending: Promise = Promise.resolve(); readonly mode = 'memory'; + private serialize(work: () => Promise): Promise { + const result = this.pending.then(work); + this.pending = result.then( + () => {}, + () => {} + ); + return result; + } + + withDocumentsTransaction(work: (store: DocumentsTransaction) => Promise): Promise { + return this.serialize(async () => { + const documents = structuredClone(this.db.documents ?? {}) as Record< + string, + Document + >; + const result = await work({ + get: async (id) => structuredClone(documents[id]), + getAll: async () => structuredClone(Object.values(documents)), + put: async (document) => { + assertUniqueDocument(Object.values(documents), document); + documents[document.id] = structuredClone(normalizeDocumentParent(document)); + }, + delete: async (id) => { + delete documents[id]; + } + }); + this.db.documents = documents; + return result; + }); + } + async get(storeName: string, id: string): Promise { - return this.db[storeName]?.[id] as T | undefined; + await this.pending; + const value = this.db[storeName]?.[id] as T | undefined; + return storeName === 'documents' ? structuredClone(value) : value; } async getAll(storeName: string): Promise { - if (!this.db[storeName]) { - return Promise.resolve([]); - } - return Promise.resolve(Object.values(this.db[storeName]) as T[]); + await this.pending; + const values = Object.values(this.db[storeName] ?? {}) as T[]; + return storeName === 'documents' ? structuredClone(values) : values; } async byDocumentRootId( documentRootId: string | undefined | null ): Promise[]> { if (!documentRootId) { - return Promise.resolve([]); + return []; } - return Promise.resolve( - this.filter('documents', (doc) => doc.documentRootId === documentRootId) as Promise[]> + return (await this.getAll>('documents')).filter( + (document) => document.documentRootId === documentRootId ); } + async put(storeName: string, item: T, id: IDBKeyRange | IDBValidKey): Promise; async put(storeName: string, item: T & { id: string }): Promise; async put(storeName: string, item: T & { id: string }, id?: IDBKeyRange | IDBValidKey): Promise { - if (!this.db[storeName]) { - this.db[storeName] = {}; + if (storeName === 'documents') { + return this.withDocumentsTransaction((store) => + store.put(item as unknown as Document) + ); } - this.db[storeName][item.id ?? id] = item; - return Promise.resolve(); + return this.serialize(async () => { + this.db[storeName] ??= {}; + this.db[storeName][item.id ?? (id as string)] = item; + }); } async delete(storeName: string, id: string): Promise { - if (this.db[storeName] && this.db[storeName][id]) { - delete this.db[storeName][id]; + if (storeName === 'documents') { + return this.withDocumentsTransaction((store) => store.delete(id)); } - return Promise.resolve(); - } - async filter(storeName: string, filterFn: (item: T) => boolean): Promise { - const allItems = await this.getAll(storeName); - return Promise.resolve(allItems.filter(filterFn)); + return this.serialize(async () => { + if (this.db[storeName]) { + delete this.db[storeName][id]; + } + }); } async destroyDb(): Promise { - this.db = {}; - console.log('MemoryDbAdapter: Database destroyed'); - return Promise.resolve(); + return this.serialize(async () => { + this.db = {}; + }); } async exportDb(): Promise<{ [storeName: string]: any }> { - return Promise.resolve(this.db); + await this.pending; + return structuredClone({ ...this.db, user: OfflineUser.getUser() }); } + async importDb(data: { [storeName: string]: any }): Promise { - this.db = data; - console.log('MemoryDbAdapter: Database imported'); - return Promise.resolve(); + return this.serialize(async () => { + const existing = {} as ImportedStores; + for (const name of IMPORT_STORES) { + existing[name] = Object.values(this.db[name] ?? {}); + } + const imported = prepareImport(data, existing, OfflineUser.getUser()); + const next = { ...this.db }; + for (const name of IMPORT_STORES) { + next[name] = Object.fromEntries(imported.stores[name].map((record) => [record.id, record])); + } + this.db = next; + if (imported.user) { + OfflineUser.setUser(imported.user); + } + }); } } diff --git a/src/api/OfflineApi/Adapter/import.ts b/src/api/OfflineApi/Adapter/import.ts new file mode 100644 index 000000000..c3883cbc6 --- /dev/null +++ b/src/api/OfflineApi/Adapter/import.ts @@ -0,0 +1,76 @@ +import type { Document, DocumentType } from '@tdev-api/document'; +import type { User } from '@tdev-api/user'; +import { migrateLegacyDocuments } from '../documents'; + +export const IMPORT_STORES = ['documents', 'studentGroups', 'permissions', 'users'] as const; +export type ImportStores = (typeof IMPORT_STORES)[number]; +export type ImportedStores = Record; + +interface PreparedImport { + stores: ImportedStores; + user?: User; +} + +/** Unwrap single-user exports and recognize users stored in historical database backups. */ +const normalizeImport = (input: unknown): { [storeName: string]: any } => { + let data = input; + if (Array.isArray(data)) { + if (data.length !== 1) { + throw new Error('Please export a single user before importing a user-data export'); + } + data = data[0]; + } + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('Invalid backup format'); + } + const backup = { ...data } as { [storeName: string]: any }; + if (!('user' in backup) && !IMPORT_STORES.some((name) => name in backup)) { + throw new Error('Backup contains no supported records'); + } + if (!backup.user && backup.users) { + const users = Array.isArray(backup.users) ? backup.users : Object.values(backup.users); + if (users.length > 1) { + throw new Error('Backup contains multiple users; export a single user before importing'); + } + backup.user = users[0]; + } + return backup; +}; + +/** Accept both array backups and the historical memory adapter's ID-keyed backups. */ +export const prepareImport = ( + input: unknown, + existing: ImportedStores, + currentUser: User +): PreparedImport => { + const data = normalizeImport(input); + if (data.user && (typeof data.user !== 'object' || typeof data.user.id !== 'string')) { + throw new Error('Invalid user in backup'); + } + const replace = data.user && data.user.id !== currentUser.id; + const stores = {} as ImportedStores; + for (const name of IMPORT_STORES) { + const records = new Map((replace ? [] : existing[name]).map((record) => [record.id, record])); + const incoming = data[name]; + if (incoming) { + for (const record of Array.isArray(incoming) ? incoming : Object.values(incoming)) { + if ( + !record || + typeof record !== 'object' || + !('id' in record) || + typeof record.id !== 'string' + ) { + throw new Error(`Invalid record in ${name} backup`); + } + records.set(record.id, structuredClone(record as { id: string })); + } + } + stores[name] = [...records.values()]; + } + if (data.user) { + // Offline startup uses a single persisted user, so do not retain another user's identity. + stores.users = [structuredClone(data.user)]; + } + stores.documents = migrateLegacyDocuments(stores.documents as Document[]); + return { stores, user: data.user }; +}; diff --git a/src/api/OfflineApi/Adapter/index.ts b/src/api/OfflineApi/Adapter/index.ts index 98301a20a..aa1263d99 100644 --- a/src/api/OfflineApi/Adapter/index.ts +++ b/src/api/OfflineApi/Adapter/index.ts @@ -3,6 +3,14 @@ import { GroupPermission, UserPermission } from '@tdev-api/permission'; import { StudentGroup } from '@tdev-api/studentGroup'; export interface DBSchema { + fsHandles: { + key: string; + value: FileSystemHandle; + }; + users: { + key: string; + value: import('@tdev-api/user').User; + }; documents: { key: string; value: Document; @@ -17,8 +25,16 @@ export interface DBSchema { }; } +export interface DocumentsTransaction { + get(id: string): Promise | undefined>; + getAll(): Promise[]>; + put(document: Document): Promise; + delete(id: string): Promise; +} + export interface DbAdapter { mode: 'indexedDB' | 'memory'; + withDocumentsTransaction(work: (store: DocumentsTransaction) => Promise): Promise; get(storeName: string, id: string): Promise; byDocumentRootId( documentRootId: string | undefined | null diff --git a/src/api/OfflineApi/Adapter/migrations/004-initialize-stores.ts b/src/api/OfflineApi/Adapter/migrations/004-initialize-stores.ts new file mode 100644 index 000000000..f4c92f5b6 --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/004-initialize-stores.ts @@ -0,0 +1,21 @@ +import type { Migration } from './types'; + +const migration: Migration = { + version: 4, + upgrade(db, transaction) { + if (!db.objectStoreNames.contains('fsHandles')) { + db.createObjectStore('fsHandles'); + } + for (const name of ['documents', 'studentGroups', 'permissions', 'users']) { + if (!db.objectStoreNames.contains(name)) { + db.createObjectStore(name, { keyPath: 'id' }); + } + } + const documents = transaction.objectStore('documents'); + if (!documents.indexNames.contains('documentRootId')) { + documents.createIndex('documentRootId', 'documentRootId', { unique: false }); + } + } +}; + +export default migration; diff --git a/src/api/OfflineApi/Adapter/migrations/005-link-quiz-answers.ts b/src/api/OfflineApi/Adapter/migrations/005-link-quiz-answers.ts new file mode 100644 index 000000000..52966c518 --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/005-link-quiz-answers.ts @@ -0,0 +1,10 @@ +import { linkLegacyQuizAnswers } from '../../documents'; +import { migrateDocuments } from './documents'; +import type { Migration } from './types'; + +const migration: Migration = { + version: 5, + upgrade: (_db, transaction) => migrateDocuments(transaction, linkLegacyQuizAnswers) +}; + +export default migration; diff --git a/src/api/OfflineApi/Adapter/migrations/006-constrain-singleton-roots.ts b/src/api/OfflineApi/Adapter/migrations/006-constrain-singleton-roots.ts new file mode 100644 index 000000000..a830460d2 --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/006-constrain-singleton-roots.ts @@ -0,0 +1,10 @@ +import { constrainSingletonRoots } from '../../documents'; +import { migrateDocuments } from './documents'; +import type { Migration } from './types'; + +const migration: Migration = { + version: 6, + upgrade: (_db, transaction) => migrateDocuments(transaction, constrainSingletonRoots) +}; + +export default migration; diff --git a/src/api/OfflineApi/Adapter/migrations/007-constrain-quiz-answers.ts b/src/api/OfflineApi/Adapter/migrations/007-constrain-quiz-answers.ts new file mode 100644 index 000000000..946c8cd1f --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/007-constrain-quiz-answers.ts @@ -0,0 +1,15 @@ +import { assertUniqueDocuments, constrainLegacyQuizAnswers } from '../../documents'; +import { migrateDocuments } from './documents'; +import type { Migration } from './types'; + +const migration: Migration = { + version: 7, + upgrade: (_db, transaction) => + migrateDocuments(transaction, (documents) => { + const migrated = constrainLegacyQuizAnswers(documents); + assertUniqueDocuments(migrated); + return migrated; + }) +}; + +export default migration; diff --git a/src/api/OfflineApi/Adapter/migrations/008-add-unique-document-indexes.ts b/src/api/OfflineApi/Adapter/migrations/008-add-unique-document-indexes.ts new file mode 100644 index 000000000..cec79bdc6 --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/008-add-unique-document-indexes.ts @@ -0,0 +1,21 @@ +import { normalizeDocumentParent } from '../../documents'; +import { migrateDocuments } from './documents'; +import type { Migration } from './types'; + +const migration: Migration = { + version: 8, + async upgrade(_db, transaction) { + await migrateDocuments(transaction, (documents) => documents.map(normalizeDocumentParent)); + const documents = transaction.objectStore('documents'); + documents.createIndex('uniqOnRoot', ['documentRootId', 'authorId', 'type', 'uniqOnRoot'], { + unique: true + }); + documents.createIndex( + 'uniqOnParent', + ['documentRootId', 'authorId', 'type', 'parentId', 'uniqOnParent'], + { unique: true } + ); + } +}; + +export default migration; diff --git a/src/api/OfflineApi/Adapter/migrations/documents.ts b/src/api/OfflineApi/Adapter/migrations/documents.ts new file mode 100644 index 000000000..6a64ef833 --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/documents.ts @@ -0,0 +1,20 @@ +import type { Document, DocumentType } from '@tdev-api/document'; +import type { UpgradeTransaction } from './types'; + +export const migrateDocuments = async ( + transaction: UpgradeTransaction, + transform: (documents: Document[]) => Document[] +) => { + const store = transaction.objectStore('documents'); + const before = (await store.getAll()) as Document[]; + const after = transform(before); + const retained = new Set(after.map((document) => document.id)); + for (const document of before) { + if (!retained.has(document.id)) { + await store.delete(document.id); + } + } + for (const document of after) { + await store.put(document); + } +}; diff --git a/src/api/OfflineApi/Adapter/migrations/index.ts b/src/api/OfflineApi/Adapter/migrations/index.ts new file mode 100644 index 000000000..a2fb56352 --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/index.ts @@ -0,0 +1,21 @@ +import { orderBy } from 'es-toolkit/array'; +import initializeStores from './004-initialize-stores'; +import linkQuizAnswers from './005-link-quiz-answers'; +import constrainSingletonRoots from './006-constrain-singleton-roots'; +import constrainQuizAnswers from './007-constrain-quiz-answers'; +import addUniqueDocumentIndexes from './008-add-unique-document-indexes'; +import type { Migration } from './types'; + +// Versions 1–3 predate the migration registry. Keep released migrations immutable. +export const migrations: readonly Migration[] = orderBy( + [ + initializeStores, + linkQuizAnswers, + constrainSingletonRoots, + constrainQuizAnswers, + addUniqueDocumentIndexes + ], + ['version'], + ['asc'] +); +export const DB_VERSION = migrations[migrations.length - 1].version; diff --git a/src/api/OfflineApi/Adapter/migrations/types.ts b/src/api/OfflineApi/Adapter/migrations/types.ts new file mode 100644 index 000000000..b8becde26 --- /dev/null +++ b/src/api/OfflineApi/Adapter/migrations/types.ts @@ -0,0 +1,9 @@ +import type { IDBPDatabase, IDBPTransaction } from 'idb'; +import type { DBSchema } from '..'; + +export type UpgradeTransaction = IDBPTransaction; + +export interface Migration { + version: number; + upgrade(db: IDBPDatabase, transaction: UpgradeTransaction): void | Promise; +} diff --git a/src/api/OfflineApi/documents.ts b/src/api/OfflineApi/documents.ts new file mode 100644 index 000000000..d914df514 --- /dev/null +++ b/src/api/OfflineApi/documents.ts @@ -0,0 +1,226 @@ +import type { Document, DocumentType } from '@tdev-api/document'; + +type OfflineDocument = Document; + +export class OfflineDocumentError extends Error { + constructor( + message: string, + readonly status = 403 + ) { + super(message); + } +} + +/** The memory adapter and validation use the same error name as IndexedDB's unique indexes. */ +export class OfflineDocumentConstraintError extends Error { + readonly name = 'ConstraintError'; + + constructor() { + super('Unique constraint violation'); + } +} + +export const isDocumentConstraintError = (error: unknown): error is Error => + error instanceof Error && error.name === 'ConstraintError'; + +/** Empty parent IDs mean no parent on the backend and must not enter IndexedDB's parent index. */ +export const normalizeDocumentParent = (document: Document): Document => + document.parentId === '' ? { ...document, parentId: undefined } : document; + +export const compareDocuments = (a: OfflineDocument, b: OfflineDocument) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + +export const cleanDocument = (document: Document): Document => { + const cleaned = { ...document }; + // remove empty string or null values for parentId, uniqOnRoot, and uniqOnParent + for (const key of ['parentId', 'uniqOnRoot', 'uniqOnParent'] as const) { + if (!cleaned[key]) { + delete cleaned[key]; + } + } + return cleaned; +}; + +export const sameDocumentScope = (a: OfflineDocument, b: OfflineDocument) => + a.documentRootId === b.documentRootId && a.authorId === b.authorId && a.type === b.type; + +export const matchingConstraint = (a: OfflineDocument, b: OfflineDocument) => + sameDocumentScope(a, b) && + ((a.uniqOnRoot != null && a.uniqOnRoot === b.uniqOnRoot) || + (!!a.parentId && + a.parentId === b.parentId && + a.uniqOnParent != null && + a.uniqOnParent === b.uniqOnParent)); + +export const assertUniqueDocument = (documents: OfflineDocument[], document: OfflineDocument) => { + if (documents.some((other) => other.id !== document.id && matchingConstraint(document, other))) { + throw new OfflineDocumentConstraintError(); + } +}; + +export const assertUniqueDocuments = (documents: OfflineDocument[]) => { + const roots = new Set(); + const parents = new Set(); + for (const document of documents) { + const scope = [document.documentRootId, document.authorId, document.type]; + for (const [key, keys] of [ + [document.uniqOnRoot != null ? JSON.stringify([...scope, document.uniqOnRoot]) : null, roots], + [ + document.parentId && document.uniqOnParent != null + ? JSON.stringify([...scope, document.parentId, document.uniqOnParent]) + : null, + parents + ] + ] as const) { + if (key !== null) { + if (keys.has(key)) { + throw new OfflineDocumentConstraintError(); + } + keys.add(key); + } + } + } +}; + +export const deleteDocumentTree = (documents: OfflineDocument[], ids: string[]) => { + const deleted = new Set(ids); + let changed = true; + while (changed) { + changed = false; + for (const document of documents) { + if (document.parentId && deleted.has(document.parentId) && !deleted.has(document.id)) { + deleted.add(document.id); + changed = true; + } + } + } + return documents.filter((document) => !deleted.has(document.id)); +}; + +/** Preserve the oldest identity and the largest payload, matching the API's legacy cleanup. */ +export const consolidateDocuments = (documents: OfflineDocument[], candidates: OfflineDocument[]) => { + const ordered = [...candidates].sort(compareDocuments); + const retained = ordered[0]; + let data = retained.data; + const size = (value: OfflineDocument['data']) => (value ? JSON.stringify(value).length : 0); + for (const document of ordered) { + if (size(document.data) > size(data)) { + data = document.data; + } + } + const removed = new Set(ordered.slice(1).map((document) => document.id)); + const moved = documents.map((document) => { + if (document.id === retained.id) { + return { ...document, data }; + } + if (document.parentId && removed.has(document.parentId) && document.uniqOnParent == null) { + return { ...document, parentId: retained.id }; + } + return document; + }); + return { documents: deleteDocumentTree(moved, [...removed]), id: retained.id }; +}; + +const questionId = (document: OfflineDocument) => { + if (document.type !== 'choice_answer' && document.type !== 'true_false_answer') { + return undefined; + } + const data = document.data; + return data && 'qid' in data && typeof data.qid === 'string' && data.qid ? data.qid : undefined; +}; + +export const linkLegacyQuizAnswers = (documents: OfflineDocument[]) => + documents.map((document) => { + if (document.parentId || document.uniqOnParent !== undefined || !questionId(document)) { + return document; + } + const quiz = documents + .filter( + (other) => + other.type === 'quiz' && + other.documentRootId === document.documentRootId && + other.authorId === document.authorId + ) + .sort(compareDocuments)[0]; + return quiz ? { ...document, parentId: quiz.id } : document; + }); + +export const constrainSingletonRoots = (documents: OfflineDocument[]) => { + const groups = new Map(); + for (const document of documents) { + const key = JSON.stringify([document.documentRootId, document.authorId]); + const group = groups.get(key) ?? []; + group.push(document); + groups.set(key, group); + } + const excluded = new Set(['dynamic_document_roots', 'mdx_comment', 'cms_text', 'script_version']); + const mainIds = new Set( + [...groups.values()] + .filter( + (group) => + group.length === 1 && + !group[0].parentId && + group[0].uniqOnRoot === undefined && + !excluded.has(group[0].type) + ) + .map((group) => group[0].id) + ); + return documents.map((document) => + mainIds.has(document.id) ? { ...document, uniqOnRoot: 'main' } : document + ); +}; + +export const constrainLegacyQuizAnswers = (documents: OfflineDocument[]) => { + const groups = new Map(); + for (const document of documents) { + const qid = questionId(document); + if (!document.parentId || !qid || document.uniqOnParent !== undefined) { + continue; + } + const key = JSON.stringify([ + document.documentRootId, + document.authorId, + document.type, + document.parentId, + qid + ]); + const group = groups.get(key) ?? []; + group.push(document); + groups.set(key, group); + } + let migrated = documents; + for (const candidates of groups.values()) { + const first = candidates[0]; + const qid = questionId(first)!; + const existing = migrated.find( + (document) => + sameDocumentScope(document, first) && + document.parentId === first.parentId && + document.uniqOnParent === qid + ); + // Explicit constraints take precedence over legacy records. + if (existing) { + continue; + } + const liveCandidates = candidates.filter((candidate) => + migrated.some((doc) => doc.id === candidate.id) + ); + if (liveCandidates.length === 0) { + continue; + } + const consolidated = consolidateDocuments(migrated, liveCandidates); + migrated = consolidated.documents.map((document) => + document.id === consolidated.id ? { ...document, uniqOnParent: qid } : document + ); + } + return migrated; +}; + +export const migrateLegacyDocuments = (documents: OfflineDocument[]) => { + const migrated = constrainLegacyQuizAnswers( + constrainSingletonRoots(linkLegacyQuizAnswers(documents.map(normalizeDocumentParent))) + ); + assertUniqueDocuments(migrated); + return migrated; +}; diff --git a/src/api/OfflineApi/index.ts b/src/api/OfflineApi/index.ts index d19e3c492..4f16f5b05 100644 --- a/src/api/OfflineApi/index.ts +++ b/src/api/OfflineApi/index.ts @@ -1,9 +1,8 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import { DB_NAME } from '@tdev-api/config'; -import { AxiosPromise } from 'axios'; -import { orderBy } from 'es-toolkit/array'; +import { AxiosError, type AxiosPromise } from 'axios'; import { v4 as uuidv4 } from 'uuid'; -import { Access, Document, DocumentType } from '../document'; +import { Access, type Document, type DocumentType, type UniquenessConstraints } from '../document'; import { DocumentRoot } from '../documentRoot'; import { GroupPermission, Permissions, UserPermission } from '../permission'; import { StudentGroup } from '../studentGroup'; @@ -11,6 +10,16 @@ import { Role, User } from '../user'; import { DbAdapter } from './Adapter'; import IndexedDbAdapter from './Adapter/IndexedDb'; import MemoryDbAdapter from './Adapter/MemoryDb'; +import { + assertUniqueDocument, + cleanDocument, + consolidateDocuments, + deleteDocumentTree, + isDocumentConstraintError, + matchingConstraint, + OfflineDocumentError, + sameDocumentScope +} from './documents'; const TIME_NOW = new Date().toISOString(); const LOG_REQUESTS = false; @@ -57,14 +66,54 @@ const resolveResponse = (data: T, statusCode: number = 200, statusText: strin }) as AxiosPromise; }; const rejectResponse = (data: T, statusCode: number = 400, statusText: string = ''): AxiosPromise => { - return Promise.reject({ + const response = { data, status: statusCode, statusText: statusText, config: {} as any, headers: {}, request: {} - }); + }; + return Promise.reject( + new AxiosError( + statusText || `Request failed with status code ${statusCode}`, + statusCode >= 500 ? AxiosError.ERR_BAD_RESPONSE : AxiosError.ERR_BAD_REQUEST, + response.config, + response.request, + response + ) + ); +}; + +const rejectDocumentResponse = (error: unknown): AxiosPromise => { + if (error instanceof OfflineDocumentError) { + const name = + { 400: 'BAD REQUEST', 403: 'FORBIDDEN', 404: 'NOT FOUND' }[error.status] ?? + 'INTERNAL SERVER ERROR'; + return rejectResponse( + { + errors: [ + { + name, + message: `[${error.status}] ${error.message}`, + status: error.status, + isOperational: true + } + ] + } as T, + error.status, + error.message + ); + } + if (isDocumentConstraintError(error)) { + // The backend leaves update/relink database conflicts to its generic 500 error handler. + return rejectResponse( + { errors: [{ name: error.name, message: 'Unique constraint violation' }] } as T, + 500, + 'Unique constraint violation' + ); + } + throw error; }; const log = (method: string, url: string, data?: any) => { @@ -141,36 +190,83 @@ export default class OfflineApi { if (!documentRootId) { return Promise.resolve([]); } - return this.dbAdapter.byDocumentRootId(documentRootId); + return (await this.dbAdapter.byDocumentRootId(documentRootId)).map(cleanDocument); } - async upsertDocumentRecord( - data: Partial>, - uniqueMain: boolean = false - ): Promise> { - if (uniqueMain) { - const savedDocuments = await this.documentsBy(data.documentRootId); - if (savedDocuments.length > 0) { - const firstByType = orderBy(savedDocuments, ['createdAt'], ['asc']).find( - (doc) => doc.type === data.type + private async createDocumentRecord( + data: Partial>, + onBehalfOf: boolean + ): Promise<{ document: Document; exists: boolean }> { + const actor = OfflineUser.getUser(); + const authorId = onBehalfOf ? (data.authorId ?? actor.id) : actor.id; + if (authorId !== actor.id && actor.role === Role.STUDENT) { + throw new OfflineDocumentError('Insufficient access permission'); + } + if (!data.documentRootId || !data.type) { + throw new OfflineDocumentError('Missing document root or type', 400); + } + const now = new Date().toISOString(); + // Client IDs identify requests; the server independently assigns persisted identities. + const requested = { + ...data, + id: uuidv4(), + authorId, + parentId: data.parentId || undefined, + createdAt: now, + updatedAt: now + } as Document; + return this.dbAdapter.withDocumentsTransaction(async (store) => { + const all = await store.getAll(); + const documents = all.filter((document) => document.documentRootId === requested.documentRootId); + const existing = documents.find((document) => matchingConstraint(requested, document)); + if (existing) { + return { document: cleanDocument(existing as Document), exists: true }; + } + if (requested.uniqOnRoot === 'main') { + const legacy = documents.filter( + (document) => + sameDocumentScope(document, requested) && + !document.parentId && + document.uniqOnRoot == null ); - if (firstByType) { - return firstByType; + if (legacy.length > 0) { + const consolidated = consolidateDocuments(documents, legacy); + const retainedIds = new Set(consolidated.documents.map((document) => document.id)); + for (const document of documents) { + if (!retainedIds.has(document.id)) { + await store.delete(document.id); + } + } + let main: Document | undefined; + for (const document of consolidated.documents) { + const updated = + document.id === consolidated.id ? { ...document, uniqOnRoot: 'main' } : document; + await store.put(updated); + if (updated.id === consolidated.id) { + main = updated as Document; + } + } + return { document: cleanDocument(main!), exists: true }; } } - } - const tNow = new Date().toISOString(); - const document: Document = { - createdAt: tNow, - updatedAt: tNow, - authorId: OfflineUser.getUser().id, - documentRootId: data.documentRootId, - id: uuidv4(), - ...data - } as Document; + await store.put(requested); + return { document: cleanDocument(requested), exists: false }; + }); + } + + async upsertDocumentRecord( + data: Partial>, + onBehalfOf: boolean = false + ): Promise> { + return (await this.createDocumentRecord(data, onBehalfOf)).document; + } - await this.dbAdapter.put(DOCUMENTS_STORE, document); - return document; + private async documentResponse(work: () => Promise): AxiosPromise { + try { + return resolveResponse(await work()); + } catch (error) { + return rejectDocumentResponse(error); + } } async upsertStudentGroupRecord(data: Partial, _id?: string): Promise { @@ -252,13 +348,45 @@ export default class OfflineApi { documentRootIds.has(doc.documentRootId) ); log('-> post', url, filteredDocuments); - return resolveResponse(filteredDocuments as unknown as T); + return resolveResponse(filteredDocuments.map(cleanDocument) as unknown as T); + } + try { + const result = await this.createDocumentRecord( + data as Partial>, + query.get('onBehalfOf') === 'true' + ); + return resolveResponse(result.document as unknown as T, result.exists ? 200 : 201); + } catch (error) { + if (isDocumentConstraintError(error)) { + const requested = data as Partial>; + const actor = OfflineUser.getUser(); + try { + const existing = (await this.documentsBy(requested.documentRootId)).find( + (document) => + matchingConstraint( + { + ...requested, + authorId: + query.get('onBehalfOf') === 'true' + ? (requested.authorId ?? actor.id) + : actor.id, + parentId: requested.parentId || undefined + } as Document, + document + ) + ); + if (existing) { + return resolveResponse(existing as unknown as T); + } + } catch (lookupError) { + return rejectDocumentResponse(lookupError); + } + return rejectDocumentResponse( + new OfflineDocumentError('Unique constraint violation') + ); + } + return rejectDocumentResponse(error); } - const document = await this.upsertDocumentRecord( - data as Partial>, - query.has('uniqueMain') - ); - return resolveResponse(document as unknown as T); case 'documentRoots': if (id === 'permissions') { const permissions = (data as { documentRootIds: string[] }).documentRootIds.map( @@ -323,7 +451,7 @@ export default class OfflineApi { if (id) { const document = await this.dbAdapter.get>(DOCUMENTS_STORE, id); if (document) { - return resolveResponse(document as unknown as T); + return resolveResponse(cleanDocument(document) as unknown as T); } return resolveResponse(null); } @@ -337,11 +465,13 @@ export default class OfflineApi { filteredDocuments.push(doc); } } - return resolveResponse(filteredDocuments as unknown as T); + return resolveResponse(filteredDocuments.map(cleanDocument) as unknown as T); } return resolveResponse( - (await this.dbAdapter.getAll>(DOCUMENTS_STORE)) as unknown as T + (await this.dbAdapter.getAll>(DOCUMENTS_STORE)).map( + cleanDocument + ) as unknown as T ); case 'users': return resolveResponse([OfflineUser.getUser()] as unknown as T); @@ -401,28 +531,47 @@ export default class OfflineApi { switch (model) { case 'documents': - let document = await this.dbAdapter.get>(DOCUMENTS_STORE, id); - if (document) { - const updatedAt = new Date().toISOString(); - - if (parts[0] === 'linkTo') { - document = { - ...document, - parentId: parts[1], - updatedAt: updatedAt - }; - } else { - document = { - ...document, - ...(data as Partial>), - updatedAt: updatedAt - }; - } - await this.dbAdapter.put(DOCUMENTS_STORE, document); - - return resolveResponse(document as unknown as T); - } - return resolveResponse(null); // or rejectResponse if you prefer + return this.documentResponse(async () => { + const updated = await this.dbAdapter.withDocumentsTransaction(async (store) => { + const document = await store.get(id); + if (!document) { + throw new OfflineDocumentError('Document not found', 404); + } + let changes: Partial>; + if (parts[0] === 'constraints') { + if (document.authorId !== OfflineUser.getUser().id) { + throw new OfflineDocumentError('Not authorized'); + } + const input = (data as { data?: UniquenessConstraints }).data; + const constraints: UniquenessConstraints = {}; + for (const key of ['uniqOnRoot', 'uniqOnParent'] as const) { + const value = input?.[key]; + if (value === null || (typeof value === 'string' && value.length > 0)) { + constraints[key] = value; + } + } + if (Object.keys(constraints).length === 0) { + throw new OfflineDocumentError('No valid constraints to update'); + } + changes = constraints; + } else if (parts[0] === 'linkTo') { + const parent = await store.get(parts[1]); + if (!parent || parent.documentRootId !== document.documentRootId) { + throw new OfflineDocumentError('Parent document not found', 404); + } + changes = { parentId: parent.id }; + } else { + changes = { + data: (data as Partial>).data ?? document.data + }; + } + const result = { ...document, ...changes, updatedAt: new Date().toISOString() }; + assertUniqueDocument(await store.getAll(), result); + await store.put(result); + return cleanDocument(result); + }); + return updated as unknown as T; + }); case 'permissions': return rejectResponse({} as T, 400, 'Not implemented'); @@ -445,45 +594,26 @@ export default class OfflineApi { switch (model) { case 'documents': - const doc = await this.dbAdapter.get>(DOCUMENTS_STORE, id); - await this.dbAdapter.delete(DOCUMENTS_STORE, id); - if (doc && doc.documentRootId) { - const toDelete = new Set(); - - const parentChildMap = new Map(); - const docs = await this.dbAdapter - .byDocumentRootId(doc.documentRootId) - .then((docs) => docs.filter((d) => !!d.parentId)); - for (const d of docs) { - const children = parentChildMap.get(d.parentId!); - if (children) { - children.push(d.id); - } else { - parentChildMap.set(d.parentId!, [d.id]); - } - } - const pending = [id]; - while (pending.length > 0) { - const currentId = pending.shift()!; - for (const childId of parentChildMap.get(currentId) || []) { - if (toDelete.has(childId)) { - continue; - } - toDelete.add(childId); - pending.push(childId); + await this.dbAdapter.withDocumentsTransaction(async (store) => { + const documents = await store.getAll(); + const retained = new Set( + deleteDocumentTree(documents, [id]).map((document) => document.id) + ); + for (const document of documents) { + if (!retained.has(document.id)) { + await store.delete(document.id); } } - await Promise.all( - [...toDelete].map((docId) => this.dbAdapter.delete(DOCUMENTS_STORE, docId)) - ); - } + }); return resolveResponse(null); case 'documentRoots': - // Deleting a document root could involve deleting associated documents - const documents = await this.dbAdapter.byDocumentRootId(id); - for (const doc of documents) { - await this.dbAdapter.delete(DOCUMENTS_STORE, doc.id); - } + await this.dbAdapter.withDocumentsTransaction(async (store) => { + for (const document of await store.getAll()) { + if (document.documentRootId === id) { + await store.delete(document.id); + } + } + }); return resolveResponse(null); case 'permissions': await this.dbAdapter.delete(PERMISSIONS_STORE, id); diff --git a/src/api/admin.ts b/src/api/admin.ts index dfe115ac9..118a3732f 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -29,7 +29,7 @@ export function linkUserPassword(userId: string, userPW: string, signal: AbortSi } export function revokeUserPassword(userId: string, signal: AbortSignal): AxiosPromise { - return api.post(`/admin/users/${userId}/revokeUserPassword`, { signal }); + return api.post(`/admin/users/${userId}/revokeUserPassword`, {}, { signal }); } type ExportData = { diff --git a/src/api/cms.ts b/src/api/cms.ts index 1709b252c..8629051e4 100644 --- a/src/api/cms.ts +++ b/src/api/cms.ts @@ -28,5 +28,5 @@ export function githubToken(token: string, signal: AbortSignal): AxiosPromise { - return api.post(`/cms/logout`, { signal }); + return api.post(`/cms/logout`, {}, { signal }); } diff --git a/src/api/document.ts b/src/api/document.ts index 758da5d01..2b196fe1d 100644 --- a/src/api/document.ts +++ b/src/api/document.ts @@ -233,9 +233,22 @@ export interface Document { data: TypeDataMapping[Type]; + /** + * A unique identifier for the document within its root document. + */ + uniqOnRoot?: string | null | undefined; + /** + * A unique identifier for the document within its parent document (only has an effect when parentId is set). + */ + uniqOnParent?: string | null | undefined; + createdAt: string; updatedAt: string; } +export interface UniquenessConstraints { + uniqOnRoot?: string | null; + uniqOnParent?: string | null; +} export type Factory = ( data: Document, @@ -252,16 +265,12 @@ export function find( export function create( data: Partial>, onBehalfOf: boolean, - isMain: boolean, signal: AbortSignal ): AxiosPromise> { const queryParams: string[] = []; if (onBehalfOf) { queryParams.push('onBehalfOf=true'); } - if (isMain) { - queryParams.push('uniqueMain=true'); - } return api.post(`/documents${queryParams.length > 0 ? `?${queryParams.join('&')}` : ''}`, data, { signal }); @@ -292,5 +301,13 @@ export function linkTo( linkToId: string, signal: AbortSignal ): AxiosPromise> { - return api.put(`/documents/${id}/linkTo/${linkToId}`, { signal }); + return api.put(`/documents/${id}/linkTo/${linkToId}`, {}, { signal }); +} + +export function updateConstraints( + id: string, + constraints: UniquenessConstraints, + signal: AbortSignal +): AxiosPromise> { + return api.put(`/documents/${id}/constraints`, { data: constraints }, { signal }); } diff --git a/src/api/studentGroup.ts b/src/api/studentGroup.ts index 9e47e52ea..eace2af8d 100644 --- a/src/api/studentGroup.ts +++ b/src/api/studentGroup.ts @@ -57,7 +57,7 @@ export function update( } export function addUser(id: string, userId: string, signal: AbortSignal): AxiosPromise { - return api.post(`/studentGroups/${id}/members/${userId}`, { signal }); + return api.post(`/studentGroups/${id}/members/${userId}`, {}, { signal }); } export function removeUser(id: string, userId: string, signal: AbortSignal): AxiosPromise { diff --git a/src/components/Rooms/index.tsx b/src/components/Rooms/index.tsx index 7edc9fa1d..58dfbf447 100644 --- a/src/components/Rooms/index.tsx +++ b/src/components/Rooms/index.tsx @@ -10,7 +10,7 @@ import { default as DynamicDocumentRootsComponent } from '@tdev-components/docum import Loader from '@tdev-components/Loader'; import PermissionsPanel from '@tdev-components/PermissionsPanel'; import NoAccess from '@tdev-components/shared/NoAccess'; -import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; +import { useGlobalFirstMainDocument } from '@tdev-hooks/useGlobalFirstMainDocument'; import { useStore } from '@tdev-hooks/useStore'; import type DynamicDocumentRoots from '@tdev-models/documents/DynamicDocumentRoots'; import { ModelMeta } from '@tdev-models/documents/DynamicDocumentRoots'; @@ -37,11 +37,11 @@ const NoType = ({ dynamicRoot }: { dynamicRoot: DynamicDocumentRoots { +export const NotCreated = ({ message }: { message?: React.ReactNode }) => { return (
- Dieser Dokument-Container wurde noch nicht erzeugt. Warten auf die Lehrperson. + {message ?? 'Dieser Dokument-Container wurde noch nicht erzeugt. Warten auf die Lehrperson.'}
@@ -112,7 +112,7 @@ interface WithModelProps { const WithDocumentRoot = observer((props: WithModelProps): React.ReactNode => { const { rootId, docContainerId } = props; const meta = React.useMemo(() => new ModelMeta({ type: 'dummy' as ContainerType }), []); - const dynDoc = useFirstMainDocument(rootId, meta, false) as DynamicDocumentRoots | null; + const dynDoc = useGlobalFirstMainDocument(rootId, meta, false); if (!rootId || !dynDoc) { return ; diff --git a/src/components/documents/Assessable/QuestionCard/index.tsx b/src/components/documents/Assessable/QuestionCard/index.tsx index 3ca5f2b2e..49fdef159 100644 --- a/src/components/documents/Assessable/QuestionCard/index.tsx +++ b/src/components/documents/Assessable/QuestionCard/index.tsx @@ -1,6 +1,10 @@ +import { mdiAlert } from '@mdi/js'; +import Icon from '@mdi/react'; import { AssessableType, TypeModelMapping } from '@tdev-api/document'; import DocumentContext from '@tdev-components/documents/DocumentContext'; import Card from '@tdev-components/shared/Card'; +import { SIZE_S } from '@tdev-components/shared/iconSizes'; +import { isDummyId } from '@tdev-hooks/useDummyId'; import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; import React from 'react'; @@ -17,6 +21,7 @@ interface Props { const QuestionCard = observer((props: Props) => { const { doc } = props; const correctAnswer = doc.linkedMeta?.correct ?? []; + const isDummy = doc.isDummy || isDummyId(doc.id); return ( (props: Props) => {
{correctAnswer && } + {isDummy && ( + + )}
} diff --git a/src/components/documents/Assessable/QuestionCard/styles.module.scss b/src/components/documents/Assessable/QuestionCard/styles.module.scss index d5315ab69..de2f8593f 100644 --- a/src/components/documents/Assessable/QuestionCard/styles.module.scss +++ b/src/components/documents/Assessable/QuestionCard/styles.module.scss @@ -67,6 +67,9 @@ flex-direction: row; align-items: center; gap: 0.5em; + .dummyIcon { + cursor: help; + } } } } diff --git a/src/components/documents/Assessable/Quiz/OfflineQuiz/index.tsx b/src/components/documents/Assessable/Quiz/OfflineQuiz/index.tsx new file mode 100644 index 000000000..918bee22c --- /dev/null +++ b/src/components/documents/Assessable/Quiz/OfflineQuiz/index.tsx @@ -0,0 +1,40 @@ +import { mdiAlert } from '@mdi/js'; +import Icon from '@mdi/react'; +import Alert from '@tdev-components/shared/Alert'; +import { SIZE_M } from '@tdev-components/shared/iconSizes'; +import { DocumentRootIdContext } from '@tdev-hooks/useContextDocumentRootId'; +import clsx from 'clsx'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import styles from './styles.module.scss'; + +interface Props { + documentRootId: string; + children: React.ReactNode; + className?: string; +} + +const OfflineQuiz = observer((props: Props) => { + return ( +
+ +

+ Offline Quiz + +

+ Antworten werden nicht gespeichert und das Quiz kann nicht bewertet werden. +
+ +
{props.children}
+
+
+ ); +}); + +export default OfflineQuiz; diff --git a/src/components/documents/Assessable/Quiz/OfflineQuiz/styles.module.scss b/src/components/documents/Assessable/Quiz/OfflineQuiz/styles.module.scss new file mode 100644 index 000000000..a56dc9ac9 --- /dev/null +++ b/src/components/documents/Assessable/Quiz/OfflineQuiz/styles.module.scss @@ -0,0 +1,20 @@ +.offlineQuiz { + border: 1px solid var(--ifm-color-warning); + padding: 0.25em; + border-radius: var(--ifm-global-radius); + .title { + margin-bottom: 0.15em; + display: flex; + justify-content: space-between; + .icon { + cursor: help; + } + } + .content { + display: flex; + flex-direction: column; + > :last-child { + margin-bottom: 0; + } + } +} diff --git a/src/components/documents/Assessable/Quiz/index.tsx b/src/components/documents/Assessable/Quiz/index.tsx index 7c0d3111c..ba86b09ec 100644 --- a/src/components/documents/Assessable/Quiz/index.tsx +++ b/src/components/documents/Assessable/Quiz/index.tsx @@ -1,7 +1,8 @@ import { AssessableType } from '@tdev-api/document'; import LoginRequiredForDocumentType from '@tdev-components/shared/Alert/LoginRequiredForDocumentType'; import { DocumentRootIdContext } from '@tdev-hooks/useContextDocumentRootId'; -import { useFirstRealMainDocument } from '@tdev-hooks/useFirstRealMainDocument'; +import { isDummyId } from '@tdev-hooks/useDummyId'; +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; import useLinkedMetaModel from '@tdev-hooks/useLinkedMetaModel'; import { useScrollTo } from '@tdev-hooks/useScrollTo'; import { AssessableComponentProps } from '@tdev-models/documents/Assessable/AssessableMeta'; @@ -10,6 +11,7 @@ import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; import React from 'react'; import { QuizScore } from '../Feedback/QuizScore'; +import OfflineQuiz from './OfflineQuiz'; import QuizControls from './QuizControls'; import styles from './styles.module.scss'; @@ -28,13 +30,16 @@ export interface Props extends AssessableComponentProps { const Quiz = observer((props: Props) => { const meta = React.useMemo(() => new ModelMeta(props), [props.id]); - const doc = useFirstRealMainDocument(props.id, meta); + const doc = useFirstMainDocument(props.id, meta); const [ref, animate] = useScrollTo(doc, 'end'); useLinkedMetaModel(doc, meta); if (!doc) { return ; } + if (doc.isDummy || isDummyId(doc.id)) { + return {props.children}; + } return (
(undefined); +const CmsMeta = new CmsTextMeta({}); /** * @returns an existing, real CmsText document or undefined. (no dummy document is returned) */ export function useFirstCmsTextDocumentIfExists(id?: string): CmsText | undefined { - const meta = React.useMemo(() => new CmsTextMeta({}), [id]); if (!id) { return undefined; } - - // Not using useFirstMainDocument() here because that would always supply a (dummy) document. - const docRoot = useDocumentRoot(id, meta, false); - return docRoot?.documentsByType?.get(meta.type)?.[0] as CmsText | undefined; + const doc = useGlobalFirstMainDocument(id, CmsMeta, false, true); + if (doc.isDummy) { + return; + } + return doc; } diff --git a/src/components/documents/DynamicDocumentRoots/index.tsx b/src/components/documents/DynamicDocumentRoots/index.tsx index ff6a91096..81d0d0052 100644 --- a/src/components/documents/DynamicDocumentRoots/index.tsx +++ b/src/components/documents/DynamicDocumentRoots/index.tsx @@ -1,7 +1,7 @@ import { Access, ContainerType } from '@tdev-api/document'; import PermissionsPanel from '@tdev-components/PermissionsPanel'; import { NotCreated } from '@tdev-components/Rooms'; -import { useFirstRealMainDocument } from '@tdev-hooks/useFirstRealMainDocument'; +import { useGlobalFirstMainDocument } from '@tdev-hooks/useGlobalFirstMainDocument'; import { useStore } from '@tdev-hooks/useStore'; import { default as DynamicDocumentRootsModel, @@ -23,7 +23,7 @@ const DynamicDocumentRoots = observer((props: Props) => { const meta = React.useMemo(() => new ModelMeta({ type: props.type }), [props.id]); const userStore = useStore('userStore'); const user = userStore.current; - const doc = useFirstRealMainDocument(props.id, meta, user?.hasElevatedAccess, { + const doc = useGlobalFirstMainDocument(props.id, meta, user?.hasElevatedAccess, false, { access: Access.RO_DocumentRoot, /** * there is only one document root for dynamic document roots @@ -34,11 +34,14 @@ const DynamicDocumentRoots = observer((props: Props) => { sharedAccess: Access.RW_DocumentRoot }) as DynamicDocumentRootsModel | undefined; React.useEffect(() => { + if (doc?.isDummy) { + return; + } if (doc && doc.linkedDocumentContainersMap.size === 0) { doc.loadDocumentRoots(); } }, [doc]); - if (!doc) { + if (!doc || doc.isDummy) { return (
diff --git a/src/components/documents/FileSystem/DocumentFileTree/FileTreeComponent/actions/onRename.ts b/src/components/documents/FileSystem/DocumentFileTree/FileTreeComponent/actions/onRename.ts index aca495724..d0b588c0b 100644 --- a/src/components/documents/FileSystem/DocumentFileTree/FileTreeComponent/actions/onRename.ts +++ b/src/components/documents/FileSystem/DocumentFileTree/FileTreeComponent/actions/onRename.ts @@ -61,7 +61,7 @@ export const onRename = ( if (file.type === 'file' && (file as File).document) { const doc = file as File; const ext = doc.fileExtension.toLowerCase(); - if (doc.document.type === 'code') { + if (doc.document?.type === 'code') { const newExt = newName.includes('.') ? newName.split('.').pop()!.toLowerCase() : ''; const code = doc.document as Code; if (newExt && newExt !== ext && code.code.trim() === '') { diff --git a/src/components/documents/FileSystem/index.tsx b/src/components/documents/FileSystem/index.tsx index ebeaa6ae5..119bda985 100644 --- a/src/components/documents/FileSystem/index.tsx +++ b/src/components/documents/FileSystem/index.tsx @@ -1,7 +1,7 @@ import type * as DocumentFileTreeLib from '@tdev-components/documents/FileSystem/DocumentFileTree'; import Loader from '@tdev-components/Loader'; import { useClientLib } from '@tdev-hooks/useClientLib'; -import { useFirstRealMainDocument } from '@tdev-hooks/useFirstRealMainDocument'; +import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument'; import { ModelMeta } from '@tdev-models/documents/FileSystem/Directory'; import { MetaInit } from '@tdev-models/documents/FileSystem/iFileSystem'; import { observer } from 'mobx-react-lite'; @@ -19,7 +19,7 @@ const importStatement = () => import('@tdev-components/documents/FileSystem/Docu const FileSystem = observer((props: Props) => { const meta = React.useMemo(() => new ModelMeta(props), [props.id]); - const doc = useFirstRealMainDocument(props.id, meta); + const doc = useFirstMainDocument(props.id, meta); const Lib = useClientLib( importStatement, '@tdev-components/documents/FileSystem/DocumentFileTree' diff --git a/src/components/documents/MdxComment/AddComment/index.tsx b/src/components/documents/MdxComment/AddComment/index.tsx index 62cbb465d..a534a1bc5 100644 --- a/src/components/documents/MdxComment/AddComment/index.tsx +++ b/src/components/documents/MdxComment/AddComment/index.tsx @@ -42,6 +42,7 @@ const AddComment = observer((props: Props) => { documentRootId: props.pageId, parentId: comment.id, type: 'quill_v2', + uniqOnParent: 'comment', data: { delta: { ops: [{ insert: '\n' }] } as Delta } diff --git a/src/components/utils/DbActions/DbImport.tsx b/src/components/utils/DbActions/DbImport.tsx index 4c7c80791..318970076 100644 --- a/src/components/utils/DbActions/DbImport.tsx +++ b/src/components/utils/DbActions/DbImport.tsx @@ -35,16 +35,26 @@ const DbImport = observer((props: Props) => { const file = (event.target as HTMLInputElement).files?.[0]; if (file) { const reader = new FileReader(); - reader.onload = (e) => { + reader.onload = async (e) => { try { const jsonData = JSON.parse(e.target?.result as string); - localDb.importDb(jsonData).then(() => { + await localDb.importDb(jsonData); + if (sessionStore.apiMode === 'indexedDB') { window.location.reload(); - }); + } else if (sessionStore.apiMode === 'memory') { + const user = (await localDb.getAll('users')) as { id: string }[]; + if (user.length > 0) { + sessionStore.root.cleanup(); + sessionStore.root.load(user[0].id); + } + } } catch (error) { window.alert('Fehler beim Importieren der Daten: ' + error); } }; + reader.onerror = () => { + window.alert('Fehler beim Lesen der Datei: ' + reader.error); + }; reader.readAsText(file); } }; diff --git a/src/hooks/useCreateDocument.ts b/src/hooks/useCreateDocument.ts index 86df1dd68..e762db523 100644 --- a/src/hooks/useCreateDocument.ts +++ b/src/hooks/useCreateDocument.ts @@ -5,7 +5,6 @@ import { useStore } from '@tdev-hooks/useStore'; import { TypeMeta } from '@tdev-models/DocumentRoot'; import { runInAction } from 'mobx'; import React from 'react'; -import { v4 as uuidv4 } from 'uuid'; export const DUMMY_DOCUMENT_ID = 'dummy' as const; @@ -23,7 +22,7 @@ export const useCreateDocument = ( uniqueMain: boolean = true, access: Partial = {} ) => { - const [uuid] = React.useState(uuidv4()); + const id = React.useId(); const documentRoot = useDocumentRoot(documentRootId, meta, false, access); const userStore = useStore('userStore'); const userId = userStore.current?.id; @@ -34,18 +33,16 @@ export const useCreateDocument = ( return Promise.resolve(null); } return runInAction(() => { - return documentStore.create( - { - id: uuid, // this won't have any effect server-side, but allows to track the creation state - documentRootId: documentRoot.id, - authorId: userId, - type: meta.type, - data: meta.defaultData - }, - uniqueMain - ); + return documentStore.create({ + id: id, // this won't have any effect server-side, but allows to track the creation state + documentRootId: documentRoot.id, + authorId: userId, + type: meta.type, + uniqOnRoot: uniqueMain ? 'main' : undefined, + data: meta.defaultData + }); }); - }, [userId, uniqueMain, uuid]); + }, [userId, uniqueMain, id]); - return { create, apiState: documentStore.apiStateFor(`create-${uuid}`) }; + return { create, apiState: documentStore.apiStateFor(`create-${id}`) }; }; diff --git a/src/hooks/useDocumentRoot.ts b/src/hooks/useDocumentRoot.ts index 566fd68a0..40c2ba786 100644 --- a/src/hooks/useDocumentRoot.ts +++ b/src/hooks/useDocumentRoot.ts @@ -1,7 +1,7 @@ import { Access, DocumentType } from '@tdev-api/document'; import { Config } from '@tdev-api/documentRoot'; import { useStore } from '@tdev-hooks/useStore'; -import DocumentRoot, { MetaHasher, TypeMeta } from '@tdev-models/DocumentRoot'; +import DocumentRoot, { TypeMeta } from '@tdev-models/DocumentRoot'; import { reaction } from 'mobx'; import React from 'react'; import { useDummyId } from './useDummyId'; @@ -23,23 +23,23 @@ export const useDocumentRoot = ( access: Partial = {}, skipCreate?: boolean, loadOnlyType?: DocumentType -) => { +): DocumentRoot => { const defaultRootDocId = useDummyId(); const userStore = useStore('userStore'); const documentRootStore = useStore('documentRootStore'); - const componentStore = useStore('componentStore'); - const dummyDocumentRoot = React.useMemo(() => { - return new DocumentRoot( - { - id: id || defaultRootDocId, - access: Access.RW_DocumentRoot, - sharedAccess: Access.None_DocumentRoot - }, - meta, - documentRootStore, - true - ); - }, []); + const [dummyDocumentRoot] = React.useState( + () => + new DocumentRoot( + { + id: id || defaultRootDocId, + access: Access.RW_DocumentRoot, + sharedAccess: Access.None_DocumentRoot + }, + meta, + documentRootStore, + true + ) + ); /** initial load */ React.useEffect(() => { @@ -88,20 +88,20 @@ export const useDocumentRoot = ( return; } return reaction( - () => documentRootStore.find(dummyDocumentRoot.id)?._triggerDocumentReload, + () => documentRootStore.find(id)?._triggerDocumentReload, () => { - const docRoot = documentRootStore.find(dummyDocumentRoot.id); + const docRoot = documentRootStore.find(id); if (!docRoot) { return; } - const firstMainDoc = docRoot.documentsByType.get(meta.type)?.[0]; - if (firstMainDoc) { + if (docRoot.mainDocument) { return; } if (userStore.isUserSwitched) { documentRootStore.loadInNextBatch(id, meta, { documentRoot: 'addIfMissing', - skipCreate: true + skipCreate: true, + documentType: loadOnlyType }); } else { documentRootStore.loadInNextBatch( @@ -111,40 +111,42 @@ export const useDocumentRoot = ( access ); } - } + }, + { fireImmediately: true } ); - }, [userStore, id, userStore.current?.hasElevatedAccess]); + }, [id, userStore.current?.hasElevatedAccess]); - React.useEffect(() => { - const rootDoc = documentRootStore.find(id); - if (!rootDoc || !rootDoc.isLoaded) { - return; - } - const hash = MetaHasher.toHashSync(meta.props); - if (hash === rootDoc._metaHash) { - return; - } - if (componentStore.taskableDocuments.has(meta.type)) { - const isNested = - !!(meta.props as { qid?: string }).qid && !(rootDoc.meta.props as { qid?: string }).qid; - if (isNested) { - return; - } - } - // update the metadata for this documentRoot, because it changed since the last load - documentRootStore.addDocumentRoot( - new DocumentRoot( - { - id: rootDoc.id, - access: rootDoc.rootAccess, - sharedAccess: rootDoc.sharedAccess - }, - meta, - documentRootStore, - false - ) - ); - }, [id, meta]); + // TODO: is this needed, or only a dev convenience? + // React.useEffect(() => { + // const rootDoc = documentRootStore.find(id); + // if (!rootDoc || !rootDoc.isLoaded) { + // return; + // } + // const hash = MetaHasher.toHashSync(meta.props); + // if (hash === rootDoc._metaHash) { + // return; + // } + // if (componentStore.taskableDocuments.has(meta.type)) { + // const isNested = + // !!(meta.props as { qid?: string }).qid && !(rootDoc.meta.props as { qid?: string }).qid; + // if (isNested) { + // return; + // } + // } + // // update the metadata for this documentRoot, because it changed since the last load + // documentRootStore.addDocumentRoot( + // new DocumentRoot( + // { + // id: rootDoc.id, + // access: rootDoc.rootAccess, + // sharedAccess: rootDoc.sharedAccess + // }, + // meta, + // documentRootStore, + // false + // ) + // ); + // }, [id, meta]); const rootDoc = documentRootStore.find(id); return rootDoc || dummyDocumentRoot; diff --git a/src/hooks/useDummyId.ts b/src/hooks/useDummyId.ts index 0108546d3..776fd73ab 100644 --- a/src/hooks/useDummyId.ts +++ b/src/hooks/useDummyId.ts @@ -40,5 +40,5 @@ export const useDummyId = (refId?: string) => { if (!refId || isDummyId(refId)) { return `${DUMMY_PREFIX}${id}`; } - return `${TEMP_PREFIX}${id}`; + return `${TEMP_PREFIX}${refId}-${id}`; }; diff --git a/src/hooks/useFirstMainDocument.ts b/src/hooks/useFirstMainDocument.ts index 13e6d7a94..f8c6a5c60 100644 --- a/src/hooks/useFirstMainDocument.ts +++ b/src/hooks/useFirstMainDocument.ts @@ -10,7 +10,7 @@ import { useDummyId } from './useDummyId'; export const DUMMY_DOCUMENT_ID = 'dummy' as const; /** - * This hook provides access to the first main document of the rootDocument. + * This hook provides access to the first main document from the viewed user, of the given rootDocument. * This is especially useful, when the DocumentType is expected to have only * one main document - like a TaskState. * @@ -24,6 +24,7 @@ export const useFirstMainDocument = ( access: Partial = {}, loadOnlyType?: DocumentType ) => { + const createRequestedAt = React.useRef(0); const defaultDocId = useDummyId(documentRootId); const documentRoot = useDocumentRoot(documentRootId, meta, true, access, false, loadOnlyType); const userStore = useStore('userStore'); @@ -52,20 +53,23 @@ export const useFirstMainDocument = ( if (!needsCreation || !createDocument) { return; } + const now = Date.now(); + if (now - createRequestedAt.current < 1000) { + return; + } if (!loadOnlyType || loadOnlyType === meta.type) { - documentStore.create( - { - documentRootId: documentRoot.id, - authorId: userStore.current!.id, - type: meta.type, - data: meta.defaultData - }, - true - ); + createRequestedAt.current = Date.now(); + documentStore.create({ + documentRootId: documentRoot.id, + authorId: userStore.current!.id, + type: meta.type, + uniqOnRoot: 'main', + data: meta.defaultData + }); } }, { fireImmediately: true } ); }, [userStore, createDocument, documentRoot]); - return (documentRoot?.documentsByType?.get(meta.type)?.[0] as TypeModelMapping[Type]) || dummyDocument; + return (documentRoot?.mainDocument as TypeModelMapping[Type]) || dummyDocument; }; diff --git a/src/hooks/useGlobalFirstMainDocument.ts b/src/hooks/useGlobalFirstMainDocument.ts new file mode 100644 index 000000000..d55e669d4 --- /dev/null +++ b/src/hooks/useGlobalFirstMainDocument.ts @@ -0,0 +1,47 @@ +import { DocumentType, TypeModelMapping } from '@tdev-api/document'; +import { Config } from '@tdev-api/documentRoot'; +import { type default as DocumentRoot, TypeMeta } from '@tdev-models/DocumentRoot'; +import { useFirstMainDocument } from './useFirstMainDocument'; + +export const DUMMY_DOCUMENT_ID = 'dummy' as const; + +/** + * This hook provides access to the first main document (personal or shared) of the rootDocument. + */ +export const useGlobalFirstMainDocument = ( + documentRootId: string | undefined, + meta: TypeMeta, + createDocument: boolean = true, + preferUsersMain: boolean = false, + access: Partial = {}, + loadOnlyType?: DocumentType +): TypeModelMapping[Type] => { + const usersFirstMainDocument = useFirstMainDocument( + documentRootId, + meta, + createDocument, + access, + loadOnlyType + ); + const root = usersFirstMainDocument.root as DocumentRoot | undefined; + if (!root) { + return usersFirstMainDocument; + } + const typeMismatch = root.type !== meta.type; + if (typeMismatch) { + const candidates = root.mainConstrainedDocuments.filter( + (doc) => doc.type === meta.type + ) as TypeModelMapping[Type][]; + if (preferUsersMain) { + const usersMain = candidates.find((d) => d.authorId === root.viewedUserId); + if (usersMain) { + return usersMain; + } + } + return candidates[0] ?? usersFirstMainDocument; + } + if (preferUsersMain && root.mainDocument) { + return root.mainDocument; + } + return root.mainDocuments[0] ?? usersFirstMainDocument; +}; diff --git a/src/hooks/useLinkedMetaModel.ts b/src/hooks/useLinkedMetaModel.ts index 0795a62d6..df899cbd2 100644 --- a/src/hooks/useLinkedMetaModel.ts +++ b/src/hooks/useLinkedMetaModel.ts @@ -1,6 +1,6 @@ import { AssessableType, AssessableTypeModelMapping } from '@tdev-api/document'; import { AssessableMeta } from '@tdev-models/documents/Assessable/AssessableMeta'; -import iAssessable from '@tdev-models/documents/Assessable/iAssessable'; +import type iAssessable from '@tdev-models/documents/Assessable/iAssessable'; import React from 'react'; const useLinkedMetaModel = ( @@ -8,7 +8,13 @@ const useLinkedMetaModel = ( meta: AssessableMeta ) => { React.useEffect(() => { - (doc as iAssessable | undefined)?.setLinkedMeta(meta); + if (!doc) { + return; + } + if (doc.type === meta.type) { + // The mapped model union loses its correlation with Type; the guard verifies it at runtime. + (doc as unknown as iAssessable).setLinkedMeta(meta); + } }, [doc, meta]); }; diff --git a/src/hooks/useNestedAssessableDocumentBy.ts b/src/hooks/useNestedAssessableDocumentBy.ts index 5310a97dd..dffc0be71 100644 --- a/src/hooks/useNestedAssessableDocumentBy.ts +++ b/src/hooks/useNestedAssessableDocumentBy.ts @@ -1,18 +1,12 @@ -import type { AssessableType, AssessableTypeModelMapping, DocumentModelType } from '@tdev-api/document'; +import type { AssessableType } from '@tdev-api/document'; import { Config } from '@tdev-api/documentRoot'; -import { useDocumentRoot } from '@tdev-hooks/useDocumentRoot'; -import { useStore } from '@tdev-hooks/useStore'; import { AssessableMeta } from '@tdev-models/documents/Assessable/AssessableMeta'; -import { reaction } from 'mobx'; -import React from 'react'; -import { useDummyId } from './useDummyId'; -import { DUMMY_DOCUMENT_ID } from './useFirstMainDocument'; +import { useFirstMainDocument } from './useFirstMainDocument'; import useLinkedMetaModel from './useLinkedMetaModel'; +import { useUniqChildOfMainDocument } from './useUniqChildOfMainDocument'; 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 @@ -28,89 +22,9 @@ export const useNestedAssessableDocumentBy = ( /** ensure to put the selector in a React.useCallback */ qid: string | undefined ) => { - // // when inside a quizz, this will share the document root with the quiz - const selector = React.useCallback( - (doc: DocumentModelType) => { - if (qid) { - return doc.type === meta.type && doc.data.qid === qid; - } - return doc.type === meta.type; - }, - [meta.type, qid] - ); - const defaultDocId = useDummyId(documentRootId); - // if qid is provided, we are in e.g. a quiz and don't want to create a new document, since the quiz should already have created it. - const skipCreate = !!qid; - const documentRoot = useDocumentRoot(documentRootId, meta, true, access, skipCreate); - const userStore = useStore('userStore'); - const documentStore = useStore('documentStore'); - const dummyDocument = React.useMemo( - () => - documentStore.createDocument({ - id: defaultDocId, - type: meta.type, - data: meta.defaultData, - authorId: DUMMY_DOCUMENT_ID, - documentRootId: documentRoot.id, - parentId: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }) as AssessableTypeModelMapping[Type], - [meta.type, defaultDocId, documentRoot.id, meta.defaultData] - ); - - const [canRequest, setCanRequest] = React.useState(false); - React.useEffect(() => { - if (!documentRoot) { - return; - } - const timeoutId = setTimeout(() => { - setCanRequest(true); - }, 25); - return () => { - clearTimeout(timeoutId); - }; - }, [documentRoot]); - - React.useEffect(() => { - if (!documentRoot || !canRequest) { - return; - } - return reaction( - () => { - if (!documentRoot?._canInitializeDocuments) { - return false; - } - const byType = documentRoot.documentsByType.get(meta.type); - return !byType?.find(selector); - }, - (needsCreation) => { - if (!needsCreation) { - return; - } - 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, canRequest]); - const byType = documentRoot?.documentsByType.get(meta.type); - const firstDoc = byType?.find(selector) as AssessableTypeModelMapping[Type] | undefined; - const doc = firstDoc || dummyDocument; - + const doc = !!qid + ? useUniqChildOfMainDocument(documentRootId, null, meta, qid) + : useFirstMainDocument(documentRootId, meta, true); useLinkedMetaModel(doc, meta); return doc; }; diff --git a/src/hooks/useOrAwaitFirstMainDocument.ts b/src/hooks/useOrAwaitFirstMainDocument.ts new file mode 100644 index 000000000..f603523ba --- /dev/null +++ b/src/hooks/useOrAwaitFirstMainDocument.ts @@ -0,0 +1,39 @@ +import type { DocumentType, TypeModelMapping } from '@tdev-api/document'; +import { Config } from '@tdev-api/documentRoot'; +import { useStore } from '@tdev-hooks/useStore'; +import { TypeMeta } from '@tdev-models/DocumentRoot'; +import { useFirstMainDocument } from './useFirstMainDocument'; + +const access = {} as Config; + +/** + * This hook provides access to a unique child document based on the provided criteria. + * It ensures that only one document of the specified type exists under the given parent. + * + * For bridging the time until the first main document is loaded, + * a dummy document is provided in the meantime. + */ +export const useOrAwaitFirstMainDocument = ( + documentRootId: string | undefined, + meta: TypeMeta | null, + createDocument: boolean = true, + access: Partial = {}, + loadOnlyType?: DocumentType +) => { + const documentRootStore = useStore('documentRootStore'); + if (!meta && !documentRootId) { + return null; + } + if (!meta) { + const root = documentRootStore.find(documentRootId); + return (root?.mainDocument as TypeModelMapping[Type]) || null; + } + const mainDocument = useFirstMainDocument( + documentRootId, + meta, + createDocument, + access, + loadOnlyType + ); + return mainDocument; +}; diff --git a/src/hooks/useUniqChildOfMainDocument.ts b/src/hooks/useUniqChildOfMainDocument.ts new file mode 100644 index 000000000..c392e764c --- /dev/null +++ b/src/hooks/useUniqChildOfMainDocument.ts @@ -0,0 +1,95 @@ +import type { DocumentType, TypeModelMapping } from '@tdev-api/document'; +import { Config } from '@tdev-api/documentRoot'; +import { useStore } from '@tdev-hooks/useStore'; +import { TypeMeta } from '@tdev-models/DocumentRoot'; +import { reaction } from 'mobx'; +import React from 'react'; +import { isDummyId, useDummyId } from './useDummyId'; +import { DUMMY_DOCUMENT_ID } from './useFirstMainDocument'; +import { useOrAwaitFirstMainDocument } from './useOrAwaitFirstMainDocument'; + +const access = {} as Config; + +/** + * This hook provides access to a unique child document based on the provided criteria. + * It ensures that only one document of the specified type exists under the given parent. + * + * For bridging the time until the first main document is loaded, + * a dummy document is provided in the meantime. + */ +export const useUniqChildOfMainDocument = ( + documentRootId: string | undefined, + mainMeta: TypeMeta | null, + /** ensure to put meta in a React.useState */ + meta: TypeMeta, + /** uniqueness key for the child document (scoped by type and user) */ + uniqOnParentKey: string +) => { + const createRequestedAt = React.useRef(0); + const mainDocument = useOrAwaitFirstMainDocument(documentRootId, mainMeta, true); + const defaultRootDocId = useDummyId(documentRootId); + const defaultDocId = useDummyId(documentRootId); + const documentStore = useStore('documentStore'); + const documentRootStore = useStore('documentRootStore'); + const dummyDocument = React.useMemo( + () => + documentStore.createDocument({ + id: defaultDocId, + type: meta.type, + data: meta.defaultData, + authorId: DUMMY_DOCUMENT_ID, + documentRootId: documentRootId ?? defaultRootDocId, + parentId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) as TypeModelMapping[Type], + [meta.type, defaultDocId, meta.defaultData] + ); + + React.useEffect(() => { + return reaction( + () => { + const mainDoc = documentRootStore.find(documentRootId)?.mainDocument; + if (!documentRootId) { + return undefined; + } + const isDummy = !mainDoc || mainDoc.isDummy || isDummyId(mainDoc.id); + if (isDummy || !mainDoc.root?._canInitializeDocuments) { + return undefined; + } + + const existingChild = mainDoc.children.find( + (d) => d.type === meta.type && d.uniqOnParent === uniqOnParentKey + ); + return existingChild ? undefined : mainDoc; + }, + (mainDoc) => { + if (!mainDoc || !documentRootId) { + return; + } + + const now = Date.now(); + if (now - createRequestedAt.current < 200) { + return; + } + + createRequestedAt.current = now; + documentStore.create({ + documentRootId, + type: meta.type, + data: meta.defaultData, + parentId: mainDoc.id, + uniqOnParent: uniqOnParentKey + }); + }, + { fireImmediately: true } + ); + }, [documentRootId, uniqOnParentKey, meta.type]); + + const firstDoc = mainDocument?.children.find( + (d) => d.type === meta.type && d.uniqOnParent === uniqOnParentKey + ); + const doc = (firstDoc || dummyDocument) as TypeModelMapping[Type]; + + return doc; +}; diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index e8f510909..00af19f4b 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -1,4 +1,4 @@ -import { Access, DocumentType, TypeDataMapping, TypeModelMapping } from '@tdev-api/document'; +import { Access, DocumentType, TypeDataMapping, type TypeModelMapping } from '@tdev-api/document'; import { DocumentRootBase as DocumentRootProps } from '@tdev-api/documentRoot'; import { isDummyId } from '@tdev-hooks/useDummyId'; import { DocumentRootStore } from '@tdev-stores/DocumentRootStore'; @@ -237,16 +237,32 @@ class DocumentRoot { } @computed - get documentsByType(): Map { - const sortedDocs = orderBy(this.documents, ['createdAt', 'id'], ['asc', 'asc']); - return sortedDocs.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()); + get mainConstrainedDocuments() { + return orderBy( + this.documents.filter((d) => d.uniqOnRoot === 'main'), + ['createdAt', 'id'], + ['asc', 'asc'] + ); + } + + @computed + get mainDocuments(): TypeModelMapping[T][] { + const type = this.meta.type; + return orderBy( + this.mainConstrainedDocuments.filter((d) => d.type === type) as TypeModelMapping[T][], + ['createdAt', 'id'], + ['asc', 'asc'] + ); + } + + @computed + get mainDocument() { + return this.mainDocuments.find((d) => d.authorId === this.viewedUserId); + } + + @computed + get sharedMainDocuments() { + return this.mainDocuments.filter((d) => d.authorId !== this.viewedUserId); } @action @@ -285,13 +301,12 @@ class DocumentRoot { @computed get _needsInitialDocumentCreation() { - return this._canInitializeDocuments && !this.documentsByType.has(this.meta.type); + return this._canInitializeDocuments && !this.mainDocument; } @computed get _triggerDocumentReload() { - const firstMainDoc = this.documentsByType.get(this.meta.type)?.[0]; - return `${firstMainDoc?.id}-${this.store.root.userStore.viewedUserId}`; + return `${this.mainDocument?.id}-${this.store.root.userStore.viewedUserId}`; } } diff --git a/src/models/documents/Assessable/iAssessable.ts b/src/models/documents/Assessable/iAssessable.ts index 68ee73643..ab851a57d 100644 --- a/src/models/documents/Assessable/iAssessable.ts +++ b/src/models/documents/Assessable/iAssessable.ts @@ -66,6 +66,9 @@ abstract class iAssessable extends iDocument implem @action setLinkedMeta(metadata: AssessableMeta) { + if (this.type !== metadata.type) { + return; + } this._linkedMeta = metadata; this.onLinkedMetaChange(); } diff --git a/src/models/documents/DynamicDocumentRoots/index.ts b/src/models/documents/DynamicDocumentRoots/index.ts index c13802d0a..2b13f81b9 100644 --- a/src/models/documents/DynamicDocumentRoots/index.ts +++ b/src/models/documents/DynamicDocumentRoots/index.ts @@ -142,7 +142,8 @@ class DynamicDocumentRoots extends iDocument<'dynami return this.store.create({ documentRootId: id, type: this.containerType, - data: meta.defaultData + data: meta.defaultData, + uniqOnRoot: 'main' }); }) .then((success) => { @@ -218,7 +219,7 @@ class DynamicDocumentRoots extends iDocument<'dynami return this.linkedDynamicDocumentRoots .flatMap( (dr) => - dr.documentsByType.get(this.containerType)?.[0] as + dr.mainConstrainedDocuments.find((doc) => doc.type === this.containerType) as ContainerTypeModelMapping[Type] | undefined ) .filter((d) => !!d); @@ -229,7 +230,9 @@ class DynamicDocumentRoots extends iDocument<'dynami return new Map( this.linkedDynamicDocumentRoots.map((dr) => [ dr.id, - dr.documentsByType.get(this.containerType)?.[0] as ContainerTypeModelMapping[Type] + dr.mainConstrainedDocuments.find( + (doc) => doc.type === this.containerType + ) as ContainerTypeModelMapping[Type] ]) ); } diff --git a/src/models/documents/FileSystem/Directory.ts b/src/models/documents/FileSystem/Directory.ts index c51c650fa..0ccd095bc 100644 --- a/src/models/documents/FileSystem/Directory.ts +++ b/src/models/documents/FileSystem/Directory.ts @@ -207,6 +207,7 @@ class Directory extends iFileSystem<'dir'> { return this.store.create({ documentRootId: this.documentRootId, parentId: fileId, + uniqOnParent: 'file', type, data: { ...(fileConfig.defaultData ?? {}) diff --git a/src/models/documents/FileSystem/File.ts b/src/models/documents/FileSystem/File.ts index ff6f11752..e4ce6eed6 100644 --- a/src/models/documents/FileSystem/File.ts +++ b/src/models/documents/FileSystem/File.ts @@ -1,4 +1,4 @@ -import { Document as DocumentProps } from '@tdev-api/document'; +import { type DocumentModelType, Document as DocumentProps } from '@tdev-api/document'; import { formatDateTime } from '@tdev-models/helpers/date'; import DocumentStore from '@tdev-stores/DocumentStore'; import { computed } from 'mobx'; @@ -51,8 +51,8 @@ class File extends iFileSystem<'file'> { } @computed - get document() { - return this.children[0]; + get document(): DocumentModelType | undefined { + return this.children.find((d) => d.uniqOnParent === 'file') ?? this.children[0]; } } diff --git a/src/models/documents/ScriptVersion.ts b/src/models/documents/ScriptVersion.ts index 54deb5804..7074b8858 100644 --- a/src/models/documents/ScriptVersion.ts +++ b/src/models/documents/ScriptVersion.ts @@ -2,7 +2,7 @@ import type { Document as DocumentProps, ScriptVersionData } from '@tdev-api/doc import iDocument from '@tdev-models/iDocument'; import DocumentStore from '@tdev-stores/DocumentStore'; import { computed } from 'mobx'; -import type Script from './Code'; +import iCode from './iCode'; // TODO: Rename to CodeVersion class ScriptVersion extends iDocument<'script_version'> { @@ -24,11 +24,11 @@ class ScriptVersion extends iDocument<'script_version'> { @computed get version() { - const script = this.root?.documentsByType.get('script')?.[0] as Script; - if (!script) { + const code = this.root?.mainDocument as iCode | undefined; + if (!code) { return 0; } - return script.versions.indexOf(this) + 1; + return code.versions.indexOf(this) + 1; } get pasted() { diff --git a/src/models/iDocument.ts b/src/models/iDocument.ts index c584cbd69..b44713138 100644 --- a/src/models/iDocument.ts +++ b/src/models/iDocument.ts @@ -24,6 +24,8 @@ abstract class iDocument { readonly parentId: string | null | undefined; readonly documentRootId: string; readonly type: Type; + readonly uniqOnRoot: string | null; + readonly uniqOnParent: string | null; @observableRef accessor _pristine: TypeDataMapping[Type]; readonly createdAt: Date; @@ -54,6 +56,8 @@ abstract class iDocument { this.store = store; this.id = props.id; this.authorId = props.authorId; + this.uniqOnRoot = props.uniqOnRoot ? props.uniqOnRoot : null; + this.uniqOnParent = props.uniqOnParent ? props.uniqOnParent : null; this.parentId = props.parentId; this.documentRootId = props.documentRootId; this.type = props.type; @@ -154,7 +158,7 @@ abstract class iDocument { @computed get isMain() { - return !this.parentId && this.root?.type === this.type; + return this.uniqOnRoot === 'main' && this.root?.type === this.type; } @computed diff --git a/src/stores/DocumentStore.ts b/src/stores/DocumentStore.ts index 1e78507be..a99be7667 100644 --- a/src/stores/DocumentStore.ts +++ b/src/stores/DocumentStore.ts @@ -6,11 +6,13 @@ import { remove as apiDelete, linkTo as apiLinkTo, update as apiUpdate, + updateConstraints as apiUpdateConstraints, DocumentModelType, Document as DocumentProps, DocumentType, Factory, - TypeModelMapping + TypeModelMapping, + UniquenessConstraints } from '@tdev-api/document'; import { ChangedDocument } from '@tdev-api/IoEventTypes'; import DocumentRoot, { MetaHasher } from '@tdev-models/DocumentRoot'; @@ -113,7 +115,7 @@ const FactoryDefault: [DocumentType, Factory][] = [ ['dynamic_document_roots', CreateDocumentModel] ]; -class DocumentStore extends iStore<`delete-${string}` | `move-${string}`> { +class DocumentStore extends iStore<`delete-${string}` | `move-${string}` | `update-${string}`> { readonly root: RootStore; documents = observable.array([]); factories = new Map(FactoryDefault); @@ -335,8 +337,7 @@ class DocumentStore extends iStore<`delete-${string}` | `move-${string}`> { @action create( - model: { documentRootId: string; type: Type } & Partial>, - isMain: boolean = false + model: { documentRootId: string; type: Type } & Partial> ) { const rootDoc = this.root.documentRootStore.find(model.documentRootId); if (!rootDoc || rootDoc.isDummy) { @@ -365,7 +366,7 @@ class DocumentStore extends iStore<`delete-${string}` | `move-${string}`> { return Promise.all(preTasks) .then(() => this.withAbortController(`create-${model.id || uuidv4()}`, (sig) => { - return apiCreate(model, onBehalfOf, isMain, sig.signal); + return apiCreate(model, onBehalfOf, sig.signal); }) ) .then( @@ -375,13 +376,6 @@ class DocumentStore extends iStore<`delete-${string}` | `move-${string}`> { ) .catch((err) => { if (!axios.isCancel(err)) { - if (IsNotUniqueError(err)) { - const docRoot = this.root.documentRootStore.find(model.documentRootId); - 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); - } - } console.warn('Error creating document', err); } return undefined; @@ -500,6 +494,28 @@ class DocumentStore extends iStore<`delete-${string}` | `move-${string}`> { return false; }); } + + @action + updateConstraints( + document: TypeModelMapping[T], + constraints: UniquenessConstraints + ): Promise { + let signal: AbortSignal; + return this.withAbortController(`update-${document.id}`, (sig) => { + signal = sig.signal; + return apiUpdateConstraints(document.id, constraints, sig.signal); + }) + .then((res) => { + if (signal.aborted) { + return undefined; + } + return this.addToStore(res.data); + }) + .catch((err) => { + console.warn('Error updating constraints', err); + return undefined; + }); + } } export default DocumentStore; 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 33213da81..d807fdfa6 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 @@ -4,17 +4,45 @@ tags: - persistable --- -import PermissionsPanel from "@tdev-components/PermissionsPanel" import BrowserWindow from '@tdev-components/BrowserWindow'; +import CodeShowcase from '@tdev-components/CodeShowcase'; import ChoiceAnswer from '@tdev-components/documents/Assessable/ChoiceAnswer'; -import TrueFalseAnswer from '@tdev-components/documents/Assessable/TrueFalseAnswer'; -import Quiz from '@tdev-components/documents/Assessable/Quiz'; import Hint from '@tdev-components/documents/Assessable/Feedback/Hint'; -import { points, multipleChoicePoints, noPoints } from '@tdev-components/documents/Assessable/helpers/scoring'; -import CodeShowcase from '@tdev-components/CodeShowcase'; +import { multipleChoicePoints, noPoints, points } from '@tdev-components/documents/Assessable/helpers/scoring'; +import Quiz from '@tdev-components/documents/Assessable/Quiz'; +import TrueFalseAnswer from '@tdev-components/documents/Assessable/TrueFalseAnswer'; +import PermissionsPanel from "@tdev-components/PermissionsPanel"; # Choice Answer + + + 1. Diese Antwort ist richtig. + 2. Diese Antwort ist falsch. + 3. Diese Antwort ist Blödsinn. + 4. Diese Antwort ist nicht korrekt. + + + Wie macht die Kuh? + + 1. Wuff. + 2. Muh. + 3. Miau. + 4. Roar. + + + Wähle alle Farben an. + + 1. Hund + 2. blau + 3. Zwanzig + 4. grün + + + Die Sonne ist ein Planet. + + + export const Imports = { Quiz: `import Quiz from '@tdev-components/documents/Assessable/Quiz';`, ChoiceAnswer: `import ChoiceAnswer from '@tdev-components/documents/Assessable/ChoiceAnswer';`, @@ -485,7 +513,7 @@ Eine `ScoringFunction` ist eine Funktion, die die Bewertung einer Frage oder ein ## Testing Only Dummy Quiz zum Test des Fixes. - + 1. Diese Antwort ist richtig. 2. Diese Antwort ist falsch.