From 64d508c260dfec4a7abad2e73b3dfbd62157f90f Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 17 May 2024 23:06:11 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20pad=20persistance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit manage the persistance of the pad. We save the pad in different ways: - when the user close the tab or the browser - when the user leave the page (go to another pad by example) - every 1 minute ---- - We save the pad only if the pad has been modified. - Pads are collaborative, to not save multiple times the same pad, we save the pad only if the user is the last to have modified the pad. ---- Because of the collaborative aspect of the pads, the best way to store our pad is to save the Y.Doc, to do so the recommended way is to convert the Y.Doc to a Uint8Array and then to a string (base64). Our pad are saved as a string in a object in a Minio bucket. --- .../__tests__/app-impress/pad-editor.spec.ts | 59 +++++++++++++ .../pads/pad/components/BlockNoteEditor.tsx | 2 + .../src/features/pads/pad/hook/useSavePad.tsx | 83 +++++++++++++++++++ .../impress/src/features/pads/pad/utils.ts | 4 + 4 files changed, 148 insertions(+) create mode 100644 src/frontend/apps/impress/src/features/pads/pad/hook/useSavePad.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts index 45402b6e..8c460c9a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts @@ -138,4 +138,63 @@ test.describe('Pad Editor', () => { await expect(page.getByText('Hello World Pad 2')).toBeHidden(); await expect(page.getByText('Hello World Pad 1')).toBeVisible(); }); + + test('it saves the doc when we change pages', async ({ + page, + browserName, + }) => { + const [pad] = await createPad(page, 'pad-save-page', browserName, 1); + + const panel = page.getByLabel('Pads panel').first(); + + // Check the first pad + await panel.getByText(pad).click(); + await expect(page.locator('h2').getByText(pad)).toBeVisible(); + await page.locator('.ProseMirror.bn-editor').click(); + await page + .locator('.ProseMirror.bn-editor') + .fill('Hello World Pad persisted 1'); + await expect(page.getByText('Hello World Pad persisted 1')).toBeVisible(); + + await panel + .getByRole('button', { + name: 'Add a pad', + }) + .click(); + + const card = page.getByLabel('Create new pad card').first(); + await expect( + card.getByRole('heading', { + name: 'Name the pad', + level: 3, + }), + ).toBeVisible(); + + await page.goto('/'); + + await panel.getByText(pad).click(); + + await expect(page.getByText('Hello World Pad persisted 1')).toBeVisible(); + }); + + test('it saves the doc when we quit pages', async ({ page, browserName }) => { + const [pad] = await createPad(page, 'pad-save-quit', browserName, 1); + + const panel = page.getByLabel('Pads panel').first(); + + // Check the first pad + await panel.getByText(pad).click(); + await expect(page.locator('h2').getByText(pad)).toBeVisible(); + await page.locator('.ProseMirror.bn-editor').click(); + await page + .locator('.ProseMirror.bn-editor') + .fill('Hello World Pad persisted 2'); + await expect(page.getByText('Hello World Pad persisted 2')).toBeVisible(); + + await page.goto('/'); + + await panel.getByText(pad).click(); + + await expect(page.getByText('Hello World Pad persisted 2')).toBeVisible(); + }); }); diff --git a/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx index 44f49a84..9b29d88c 100644 --- a/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx @@ -7,6 +7,7 @@ import { WebrtcProvider } from 'y-webrtc'; import { Box } from '@/components'; import { useAuthStore } from '@/core/auth'; +import useSavePad from '../hook/useSavePad'; import { usePadStore } from '../stores'; import { Pad } from '../types'; import { randomColor } from '../utils'; @@ -37,6 +38,7 @@ interface BlockNoteContentProps { export const BlockNoteContent = ({ pad, provider }: BlockNoteContentProps) => { const { userData } = useAuthStore(); const { setEditor, padsStore } = usePadStore(); + useSavePad(pad.id, provider.doc); const storedEditor = padsStore?.[pad.id]?.editor; const editor = useMemo(() => { diff --git a/src/frontend/apps/impress/src/features/pads/pad/hook/useSavePad.tsx b/src/frontend/apps/impress/src/features/pads/pad/hook/useSavePad.tsx new file mode 100644 index 00000000..c1a5535f --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/pad/hook/useSavePad.tsx @@ -0,0 +1,83 @@ +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import * as Y from 'yjs'; + +import { useUpdatePad } from '../api/useUpdatePad'; +import { toBase64 } from '../utils'; + +const useSavePad = (padId: string, doc: Y.Doc) => { + const { mutate: updatePad } = useUpdatePad(); + const [initialDoc, setInitialDoc] = useState( + toBase64(Y.encodeStateAsUpdate(doc)), + ); + + /** + * Update initial doc when doc is updated by other users, + * so only the user typing will trigger the save. + * This is to avoid saving the same doc multiple time. + */ + useEffect(() => { + const onUpdate = ( + _uintArray: Uint8Array, + _pluginKey: string, + updatedDoc: Y.Doc, + transaction: Y.Transaction, + ) => { + if (!transaction.local) { + setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc))); + } + }; + + doc.on('update', onUpdate); + + return () => { + doc.off('update', onUpdate); + }; + }, [doc]); + + const savePad = useCallback(() => { + const newDoc = toBase64(Y.encodeStateAsUpdate(doc)); + + /** + * Save only if the doc has changed. + */ + if (initialDoc === newDoc) { + return; + } + + setInitialDoc(newDoc); + + updatePad({ + id: padId, + content: newDoc, + }); + }, [initialDoc, padId, doc, updatePad]); + + const timeout = useRef(); + const router = useRouter(); + + useEffect(() => { + if (timeout.current) { + clearTimeout(timeout.current); + } + + const onSave = () => { + savePad(); + }; + + // Save every minute + timeout.current = setInterval(onSave, 60000); + // Save when the user leaves the page + addEventListener('beforeunload', onSave); + // Save when the user navigates to another page + router.events.on('routeChangeStart', onSave); + + return () => { + clearTimeout(timeout.current); + removeEventListener('beforeunload', onSave); + router.events.off('routeChangeStart', onSave); + }; + }, [router.events, savePad]); +}; + +export default useSavePad; diff --git a/src/frontend/apps/impress/src/features/pads/pad/utils.ts b/src/frontend/apps/impress/src/features/pads/pad/utils.ts index 325a2304..aa0d43ba 100644 --- a/src/frontend/apps/impress/src/features/pads/pad/utils.ts +++ b/src/frontend/apps/impress/src/features/pads/pad/utils.ts @@ -22,3 +22,7 @@ function hslToHex(h: number, s: number, l: number) { }; return `#${f(0)}${f(8)}${f(4)}`; } + +export const toBase64 = ( + str: WithImplicitCoercion, +) => Buffer.from(str).toString('base64');