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.