✈️(frontend) add tree offline support

The doc tree can now be accessed offline, allowing
users to view and interact with the document
hierarchy even without an internet connection.
This commit is contained in:
Anthony LC
2026-09-22 16:03:34 +02:00
committed by Manuel Raynaud
parent d3e9bf73ee
commit 36fe98d690
4 changed files with 209 additions and 11 deletions
@@ -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');
}
@@ -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']);
});
});
@@ -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>,
): 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();
/**
@@ -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