🗃️(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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user