🗃️(frontend) replace main version per another version

We can now replace the main version by another version.
Usefull either to come back to a previous version
or to update the main version with a new one.
This commit is contained in:
Anthony LC
2024-07-17 15:31:12 +02:00
committed by Anthony LC
parent a9383212a3
commit 4b61ffce01
6 changed files with 208 additions and 23 deletions

View File

@@ -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();
});
});

View File

@@ -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<string>(
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<NodeJS.Timeout>();
const router = useRouter();

View File

@@ -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<DocStore>) => void;
forceSave: ForceSaveState;
setForceSave: (forceSave: ForceSaveState) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
docsStore: {},
forceSave: 'false',
setForceSave: (forceSave) => {
set(() => ({ forceSave }));
},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: storeId,

View File

@@ -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 (
<Box
@@ -35,14 +44,7 @@ export const VersionItem = ({ versionId, text, link }: VersionItemProps) => {
$hasTransition
$minWidth="13rem"
>
<StyledLink
href={link}
onClick={(e) => {
if (isActive) {
e.preventDefault();
}
}}
>
<Link href={link} isActive={isActive}>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
@@ -58,8 +60,64 @@ export const VersionItem = ({ versionId, text, link }: VersionItemProps) => {
{text}
</Text>
</Box>
{isActive && versionId && (
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the version options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
onClick={() => {
setIsDropOpen(false);
setForceSave(versionId ? 'version' : 'current');
if (
!docsStore?.[docId]?.provider ||
!docsStore?.[versionId]?.provider
) {
return;
}
setStore(docId, {
editor: undefined,
});
revertUpdate(
docsStore[docId].provider.doc,
docsStore[docId].provider.doc,
docsStore[versionId].provider.doc,
);
}}
color="primary-text"
icon={<span className="material-icons">save</span>}
size="small"
>
<Text $theme="primary">{t('Restore the version')}</Text>
</Button>
</Box>
</DropButton>
)}
</Box>
</StyledLink>
</Link>
</Box>
);
};
interface LinkProps {
href: string;
isActive: boolean;
}
const Link = ({ href, children, isActive }: PropsWithChildren<LinkProps>) => {
return isActive ? (
<>{children}</>
) : (
<StyledLink href={href}>{children}</StyledLink>
);
};

View File

@@ -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) => (
<VersionItem
@@ -52,6 +58,8 @@ const VersionListState = ({
timeStyle: 'short',
})}
link={`/docs/${doc.id}/versions/${version.version_id}`}
docId={doc.id}
isActive={version.version_id === versionId}
/>
))}
{error && (

View File

@@ -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) {}
}