diff --git a/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts b/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts index 1d98a7018..38b60608a 100644 --- a/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts +++ b/src/frontend/apps/impress/src/features/service-worker/DocsDB.ts @@ -20,6 +20,10 @@ interface IDocsDB extends DBSchema { key: string; value: Doc; }; + 'doc-tree': { + key: string; + value: Doc; + }; 'doc-mutation': { key: string; value: DBRequest; @@ -30,7 +34,7 @@ interface IDocsDB extends DBSchema { }; } -type TableName = 'doc-list' | 'doc-item' | 'doc-mutation'; +type TableName = 'doc-list' | 'doc-item' | 'doc-tree' | 'doc-mutation'; /** * IndexDB prefers incremental versioning when upgrading the database, @@ -72,6 +76,9 @@ export class DocsDB { if (!db.objectStoreNames.contains('doc-item')) { db.createObjectStore('doc-item'); } + if (!db.objectStoreNames.contains('doc-tree')) { + db.createObjectStore('doc-tree'); + } if (!db.objectStoreNames.contains('doc-mutation')) { db.createObjectStore('doc-mutation'); } @@ -125,6 +132,7 @@ export class DocsDB { await DocsDB.deleteAll('doc-item'); await DocsDB.deleteAll('doc-list'); + await DocsDB.deleteAll('doc-tree'); await DocsDB.deleteAll('doc-mutation'); await db.put('doc-version', currentVersion, 'version'); } diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx index 14605a283..688b7b06f 100644 --- a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { RequestSerializer } from '../RequestSerializer'; import { SyncManager } from '../SyncManager'; -import { ApiPlugin } from '../plugins/ApiPlugin'; +import { ApiPlugin, patchTreeNode, pruneTreeNode } from '../plugins/ApiPlugin'; const mockedGet = vi.fn().mockResolvedValue({}); const mockedGetAllKeys = vi.fn().mockResolvedValue([]); @@ -17,6 +17,7 @@ const mockedOpendDB = vi.fn().mockResolvedValue({ delete: mockedDelete, clear: vi.fn().mockResolvedValue({}), close: mockedClose, + objectStoreNames: { contains: () => true }, }); vi.mock('idb', async () => ({ @@ -30,6 +31,7 @@ describe('ApiPlugin', () => { [ { type: 'item', table: 'doc-item' }, { type: 'list', table: 'doc-list' }, + { type: 'tree', table: 'doc-tree' }, { type: 'update', table: 'doc-item' }, ].forEach(({ type, table }) => { it(`calls fetchDidSucceed with type ${type} and status 200`, async () => { @@ -147,6 +149,7 @@ describe('ApiPlugin', () => { [ { type: 'list', tableName: 'doc-list' }, { type: 'item', tableName: 'doc-item' }, + { type: 'tree', tableName: 'doc-tree' }, ].forEach(({ type, tableName }) => { it(`checks handlerDidError with type ${type}`, async () => { const requestInit = { @@ -156,8 +159,8 @@ describe('ApiPlugin', () => { } as any; const apiPlugin = new ApiPlugin({ - type: type as 'list' | 'item' | 'update' | 'create' | 'delete', - tableName: tableName as 'doc-list' | 'doc-item', + type: type as 'list' | 'item' | 'tree' | 'update' | 'create' | 'delete', + tableName: tableName as 'doc-list' | 'doc-item' | 'doc-tree', syncManager: {} as SyncManager, }); @@ -214,6 +217,7 @@ describe('ApiPlugin', () => { 'http://test.jest/documents/123456/', ); expect(mockedGetAllKeys).toHaveBeenCalledWith('doc-list'); + expect(mockedGetAllKeys).toHaveBeenCalledWith('doc-tree'); expect(mockedPut).toHaveBeenCalledWith( 'doc-mutation', @@ -238,8 +242,14 @@ describe('ApiPlugin', () => { { results: [{ id: '123456', test: 'test', title: 'test' }] }, 'http://test.jest/documents/?page=1', ); + // the tree cache is patched too — mutation, item, list, tree + expect(mockedPut).toHaveBeenCalledWith( + 'doc-tree', + expect.anything(), + 'http://test.jest/documents/?page=1', + ); - expect(mockedPut).toHaveBeenCalledTimes(3); + expect(mockedPut).toHaveBeenCalledTimes(4); expect(mockedClose).toHaveBeenCalled(); expect(response?.status).toBe(200); }); @@ -321,8 +331,10 @@ describe('ApiPlugin', () => { }), 'http://test.jest/documents/?page=1', ); + // the tree cache is pruned too — the queued mutation, the list, the tree + expect(mockedGetAllKeys).toHaveBeenCalledWith('doc-tree'); - expect(mockedPut).toHaveBeenCalledTimes(2); + expect(mockedPut).toHaveBeenCalledTimes(3); expect(mockedClose).toHaveBeenCalled(); expect(response?.status).toBe(204); }); @@ -384,6 +396,11 @@ describe('ApiPlugin', () => { expect.objectContaining({}), 'http://test.jest/documents/444555/', ); + expect(mockedPut).toHaveBeenCalledWith( + 'doc-tree', + expect.objectContaining({ id: '444555', children: [] }), + 'http://test.jest/documents/444555/tree/', + ); expect(mockedPut).toHaveBeenCalledWith( 'doc-list', expect.objectContaining({ @@ -400,9 +417,66 @@ describe('ApiPlugin', () => { 'doc-list', 'http://test.jest/documents/?page=1', ); - // doc-item, doc-list and the queued mutation — the doc-content entry is gone - expect(mockedPut).toHaveBeenCalledTimes(3); + // the queued mutation, doc-item, doc-tree and doc-list + expect(mockedPut).toHaveBeenCalledTimes(4); expect(mockedClose).toHaveBeenCalled(); expect(response?.status).toBe(201); }); }); + +const tree = () => + ({ + id: 'root', + title: 'Root', + children: [ + { + id: 'a', + title: 'A', + children: [{ id: 'b', title: 'B', children: [] }], + }, + { id: 'c', title: 'C', children: [] }, + ], + }) as any; + +describe('patchTreeNode', () => { + it('merges the patch into a matching node, root or nested', () => { + const patchedRoot = patchTreeNode(tree(), 'root', { title: 'Renamed' }); + expect(patchedRoot.title).toBe('Renamed'); + + const patchedDeep = patchTreeNode(tree(), 'b', { title: 'Renamed' }); + expect(patchedDeep.children?.[0].children?.[0].title).toBe('Renamed'); + }); + + it('leaves the input untouched and non-matching nodes alone', () => { + const input = tree(); + const patched = patchTreeNode(input, 'a', { title: 'Renamed' }); + + expect(input.children[0].title).toBe('A'); + expect(patched.children?.[1].title).toBe('C'); + }); + + it('is a no-op when nothing matches', () => { + expect(patchTreeNode(tree(), 'missing', { title: 'x' })).toEqual(tree()); + }); +}); + +describe('pruneTreeNode', () => { + it('removes a matching node from its parent', () => { + const pruned = pruneTreeNode(tree(), 'a'); + + expect(pruned.children?.map((c) => c.id)).toEqual(['c']); + }); + + it('removes a deeply nested node', () => { + const pruned = pruneTreeNode(tree(), 'b'); + + expect(pruned.children?.[0].children).toEqual([]); + }); + + it('leaves the input untouched', () => { + const input = tree(); + pruneTreeNode(input, 'a'); + + expect(input.children.map((c: { id: string }) => c.id)).toEqual(['a', 'c']); + }); +}); diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 9061e0d00..d7b111c5c 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -8,8 +8,8 @@ import { RequestSerializer } from '../RequestSerializer'; import { SyncManager } from '../SyncManager'; interface OptionsReadonly { - tableName: 'doc-list' | 'doc-item'; - type: 'list' | 'item'; + tableName: 'doc-list' | 'doc-item' | 'doc-tree'; + type: 'list' | 'item' | 'tree'; } interface OptionsMutate { @@ -24,6 +24,45 @@ type Options = (OptionsReadonly | OptionsMutate | OptionsSync) & { syncManager: SyncManager; }; +/** + * A cached `documents/{id}/tree/` response with `patch` merged into every node + * whose id matches — the root included. Returns a new tree; the input is left + * alone. + */ +export const patchTreeNode = ( + node: Doc, + docId: string, + patch: Partial, +): Doc => { + const next = node.id === docId ? { ...node, ...patch } : node; + + if (!node.children?.length) { + return next; + } + + return { + ...next, + children: node.children.map((child) => patchTreeNode(child, docId, patch)), + }; +}; + +/** + * The same walk, dropping the node whose id matches from its parent's + * `children`. A tree rooted on that node is the caller's to discard. + */ +export const pruneTreeNode = (node: Doc, docId: string): Doc => { + if (!node.children?.length) { + return node; + } + + return { + ...node, + children: node.children + .filter((child) => child.id !== docId) + .map((child) => pruneTreeNode(child, docId)), + }; +}; + export class ApiPlugin implements WorkboxPlugin { private readonly options: Options; private isFetchDidFailed = false; @@ -56,7 +95,11 @@ export class ApiPlugin implements WorkboxPlugin { return response; } - if (this.options.type === 'list' || this.options.type === 'item') { + if ( + this.options.type === 'list' || + this.options.type === 'item' || + this.options.type === 'tree' + ) { const tableName = this.options.tableName; const body = (await response.clone().json()) as DocsResponse | Doc; await DocsDB.cacheResponse(request.url, body, tableName); @@ -135,6 +178,7 @@ export class ApiPlugin implements WorkboxPlugin { return this.handlerDidErrorUpdate(request); case 'list': case 'item': + case 'tree': return this.handlerDidErrorRead(this.options.tableName, request.url); } @@ -254,6 +298,15 @@ export class ApiPlugin implements WorkboxPlugin { 'doc-item', ); + /** + * Seed the tree for the new document, so the doc tree renders it offline + */ + await DocsDB.cacheResponse( + `${request.url}${uuid}/tree/`, + { ...newResponse, children: [] }, + 'doc-tree', + ); + /** * Add the new entry to the cache list. */ @@ -317,6 +370,30 @@ export class ApiPlugin implements WorkboxPlugin { await DocsDB.cacheResponse(key, list, 'doc-list'); } + /** + * Drop the doc from every cached tree, and discard a tree rooted on it — + * the same reason as the list loop above: the tree carries its own copies. + */ + if (docId && db.objectStoreNames.contains('doc-tree')) { + for (const key of await db.getAllKeys('doc-tree')) { + const tree = await db.get('doc-tree', key); + + if (!tree) { + continue; + } + + if (tree.id === docId) { + await db.delete('doc-tree', key); + } else { + await DocsDB.cacheResponse( + key, + pruneTreeNode(tree, docId), + 'doc-tree', + ); + } + } + } + db.close(); /** @@ -387,6 +464,24 @@ export class ApiPlugin implements WorkboxPlugin { await DocsDB.cacheResponse(key, list, 'doc-list'); } + /** + * Update the doc wherever a cached tree holds a copy of it — its own root, + * or nested under an ancestor — for the same reason as the list loop above. + */ + if (docId && db.objectStoreNames.contains('doc-tree')) { + for (const key of await db.getAllKeys('doc-tree')) { + const tree = await db.get('doc-tree', key); + + if (tree) { + await DocsDB.cacheResponse( + key, + patchTreeNode(tree, docId, bodyMutate), + 'doc-tree', + ); + } + } + } + db.close(); /** diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts index 32d38c0a0..7e4211200 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts @@ -27,6 +27,9 @@ export const isApiUrl = (href: string) => { const isDocumentApiUrl = (url: URL) => isApiUrl(url.href) && /.*\/documents\/([a-z0-9-]+)\/$/g.test(url.href); +const isDocumentTreeApiUrl = (url: URL) => + isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/tree\/$/.test(url.href); + const isCollaborationUrl = (url: URL, endpoint: string) => new RegExp(`/${endpoint}/v1/[^/]+/[^/]+/?$`).test(url.pathname); @@ -93,6 +96,24 @@ registerRoute( 'GET', ); +/** + * Cache the document tree so it renders offline. + */ +registerRoute( + ({ url }) => isDocumentTreeApiUrl(url), + new NetworkOnly({ + plugins: [ + new ApiPlugin({ + tableName: 'doc-tree', + type: 'tree', + syncManager, + }), + new OfflinePlugin(), + ], + }), + 'GET', +); + /** * Mutate routes for the document update * It will save in cache the request if the document update fails, and will retry