diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts index bc1f28d0..654bc58d 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts @@ -66,4 +66,41 @@ test.describe('Doc Version', () => { await expect(page.getByLabel('Document version panel')).toBeHidden(); }); + + test('it restores the doc version', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1); + + await expect(page.locator('h2').getByText(randomDoc)).toBeVisible(); + + await page.locator('.bn-block-outer').last().click(); + await page.locator('.bn-block-outer').last().fill('Hello'); + + await goToGridDoc(page, { + title: randomDoc, + }); + + await expect(page.getByText('Hello')).toBeVisible(); + await page.locator('.bn-block-outer').last().click(); + await page.keyboard.press('Enter'); + await page.locator('.bn-block-outer').last().fill('World'); + + await goToGridDoc(page, { + title: randomDoc, + }); + + await expect(page.getByText('World')).toBeVisible(); + + const panel = page.getByLabel('Document version panel'); + await panel.locator('li').nth(1).click(); + await expect(page.getByText('World')).toBeHidden(); + + await panel.getByLabel('Open the version options').click(); + await page.getByText('Restore the version').click(); + + await expect(panel.locator('li')).toHaveCount(3); + + await panel.getByText('Current version').click(); + await expect(page.getByText('Hello')).toBeVisible(); + await expect(page.getByText('World')).toBeHidden(); + }); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index 9c2ce63e..2b065d10 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -4,14 +4,16 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; -import { KEY_DOC, useUpdateDoc } from '@/features/docs/doc-management/'; +import { useUpdateDoc } from '@/features/docs/doc-management/'; import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning'; +import { useDocStore } from '../stores'; import { toBase64 } from '../utils'; const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { const { toast } = useToastProvider(); const { t } = useTranslation(); + const { forceSave, setForceSave } = useDocStore(); const { mutate: updateDoc } = useUpdateDoc({ onSuccess: (data) => { @@ -20,9 +22,12 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { docTitle: data.title, }), VariantType.SUCCESS, + { + duration: 1500, + }, ); }, - listInvalideQueries: [KEY_LIST_DOC_VERSIONS, KEY_DOC], + listInvalideQueries: [KEY_LIST_DOC_VERSIONS], }); const [initialDoc, setInitialDoc] = useState( toBase64(Y.encodeStateAsUpdate(doc)), @@ -59,10 +64,14 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { /** * Check if the doc has been updated and can be saved. */ - const shouldSave = useCallback(() => { + const hasChanged = useCallback(() => { const newDoc = toBase64(Y.encodeStateAsUpdate(doc)); - return initialDoc !== newDoc && canSave; - }, [canSave, doc, initialDoc]); + return initialDoc !== newDoc; + }, [doc, initialDoc]); + + const shouldSave = useCallback(() => { + return hasChanged() && canSave; + }, [canSave, hasChanged]); const saveDoc = useCallback(() => { const newDoc = toBase64(Y.encodeStateAsUpdate(doc)); @@ -74,6 +83,18 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => { }); }, [doc, docId, updateDoc]); + useEffect(() => { + if (forceSave === 'false') { + return; + } + + setForceSave('false'); + + if ((forceSave === 'current' && hasChanged()) || forceSave === 'version') { + saveDoc(); + } + }, [forceSave, hasChanged, saveDoc, setForceSave]); + const timeout = useRef(); const router = useRouter(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx index db877db8..93c9e759 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx @@ -11,16 +11,24 @@ interface DocStore { editor?: BlockNoteEditor; } +type ForceSaveState = 'false' | 'version' | 'current'; + export interface UseDocStore { docsStore: { [storeId: string]: DocStore; }; createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider; setStore: (storeId: string, props: Partial) => void; + forceSave: ForceSaveState; + setForceSave: (forceSave: ForceSaveState) => void; } export const useDocStore = create((set, get) => ({ docsStore: {}, + forceSave: 'false', + setForceSave: (forceSave) => { + set(() => ({ forceSave })); + }, createProvider: (storeId: string, initialDoc: Base64) => { const doc = new Y.Doc({ guid: storeId, diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx index 6dfea4c8..fcdb3b1d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx @@ -1,24 +1,33 @@ -import { useRouter } from 'next/router'; -import React from 'react'; +import { Button } from '@openfun/cunningham-react'; +import { t } from 'i18next'; +import React, { PropsWithChildren, useState } from 'react'; -import { Box, StyledLink, Text } from '@/components'; +import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; +import { useDocStore } from '@/features/docs/doc-editor'; +import { Doc } from '@/features/docs/doc-management'; import { Versions } from '../types'; +import { revertUpdate } from '../utils'; interface VersionItemProps { + docId: Doc['id']; text: string; link: string; versionId?: Versions['version_id']; + isActive: boolean; } -export const VersionItem = ({ versionId, text, link }: VersionItemProps) => { +export const VersionItem = ({ + docId, + versionId, + text, + link, + isActive, +}: VersionItemProps) => { + const { setForceSave, docsStore, setStore } = useDocStore(); const { colorsTokens } = useCunninghamTheme(); - const { - query: { versionId: currentId }, - } = useRouter(); - - const isActive = versionId === currentId; + const [isDropOpen, setIsDropOpen] = useState(false); return ( { $hasTransition $minWidth="13rem" > - { - if (isActive) { - e.preventDefault(); - } - }} - > + { {text} + {isActive && versionId && ( + + } + onOpenChange={(isOpen) => setIsDropOpen(isOpen)} + isOpen={isDropOpen} + > + + + + + )} - + ); }; + +interface LinkProps { + href: string; + isActive: boolean; +} + +const Link = ({ href, children, isActive }: PropsWithChildren) => { + return isActive ? ( + <>{children} + ) : ( + {children} + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx index 9adea71d..96c176b7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx @@ -1,4 +1,5 @@ import { Loader } from '@openfun/cunningham-react'; +import { useRouter } from 'next/router'; import React, { useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,6 +28,9 @@ const VersionListState = ({ }: VersionListStateProps) => { const { t } = useTranslation(); const { formatDate } = useDate(); + const { + query: { versionId }, + } = useRouter(); if (isLoading) { return ( @@ -42,6 +46,8 @@ const VersionListState = ({ text={t('Current version')} versionId={undefined} link={`/docs/${doc.id}/`} + docId={doc.id} + isActive={!versionId} /> {versions?.map((version) => ( ))} {error && ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-versioning/utils.ts new file mode 100644 index 00000000..123e3671 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/utils.ts @@ -0,0 +1,53 @@ +import * as Y from 'yjs'; + +/** + * Revert the doc to a previous state. + * + * We cannot simply replace a doc with another previous doc, + * because Y.js will act as if the previous doc is a new doc and so + * merge it with the current doc, so we need to revert the doc (undo). + * + * To do so we simulate a history of the doc by saving snapshots of the doc + * and then revert the doc to a previous snapshot. + * + * @param doc + * @param snapshotOrigin + * @param snapshotUpdate + */ +export function revertUpdate( + doc: Y.Doc, + snapshotOrigin: Y.Doc, + snapshotUpdate: Y.Doc, +) { + try { + const snapshotDoc = new Y.Doc(); + Y.applyUpdate( + snapshotDoc, + Y.encodeStateAsUpdate(snapshotUpdate), + snapshotOrigin, + ); + + const currentStateVector = Y.encodeStateVector(doc); + const snapshotStateVector = Y.encodeStateVector(snapshotDoc); + + const changesSinceSnapshotUpdate = Y.encodeStateAsUpdate( + doc, + snapshotStateVector, + ); + + const undoManager = new Y.UndoManager( + [snapshotDoc.getMap('document-store')], + { + trackedOrigins: new Set([snapshotOrigin]), + }, + ); + + Y.applyUpdate(snapshotDoc, changesSinceSnapshotUpdate, snapshotOrigin); + undoManager.undo(); + const revertChangesSinceSnapshotUpdate = Y.encodeStateAsUpdate( + snapshotDoc, + currentStateVector, + ); + Y.applyUpdate(doc, revertChangesSinceSnapshotUpdate, snapshotOrigin); + } catch (e) {} +}