🗃️(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();
|
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 { useTranslation } from 'react-i18next';
|
||||||
import * as Y from 'yjs';
|
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 { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
|
||||||
|
|
||||||
|
import { useDocStore } from '../stores';
|
||||||
import { toBase64 } from '../utils';
|
import { toBase64 } from '../utils';
|
||||||
|
|
||||||
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { forceSave, setForceSave } = useDocStore();
|
||||||
|
|
||||||
const { mutate: updateDoc } = useUpdateDoc({
|
const { mutate: updateDoc } = useUpdateDoc({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -20,9 +22,12 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
|||||||
docTitle: data.title,
|
docTitle: data.title,
|
||||||
}),
|
}),
|
||||||
VariantType.SUCCESS,
|
VariantType.SUCCESS,
|
||||||
|
{
|
||||||
|
duration: 1500,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
listInvalideQueries: [KEY_LIST_DOC_VERSIONS, KEY_DOC],
|
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
||||||
});
|
});
|
||||||
const [initialDoc, setInitialDoc] = useState<string>(
|
const [initialDoc, setInitialDoc] = useState<string>(
|
||||||
toBase64(Y.encodeStateAsUpdate(doc)),
|
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.
|
* Check if the doc has been updated and can be saved.
|
||||||
*/
|
*/
|
||||||
const shouldSave = useCallback(() => {
|
const hasChanged = useCallback(() => {
|
||||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||||
return initialDoc !== newDoc && canSave;
|
return initialDoc !== newDoc;
|
||||||
}, [canSave, doc, initialDoc]);
|
}, [doc, initialDoc]);
|
||||||
|
|
||||||
|
const shouldSave = useCallback(() => {
|
||||||
|
return hasChanged() && canSave;
|
||||||
|
}, [canSave, hasChanged]);
|
||||||
|
|
||||||
const saveDoc = useCallback(() => {
|
const saveDoc = useCallback(() => {
|
||||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||||
@@ -74,6 +83,18 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
|||||||
});
|
});
|
||||||
}, [doc, docId, updateDoc]);
|
}, [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 timeout = useRef<NodeJS.Timeout>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,24 @@ interface DocStore {
|
|||||||
editor?: BlockNoteEditor;
|
editor?: BlockNoteEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ForceSaveState = 'false' | 'version' | 'current';
|
||||||
|
|
||||||
export interface UseDocStore {
|
export interface UseDocStore {
|
||||||
docsStore: {
|
docsStore: {
|
||||||
[storeId: string]: DocStore;
|
[storeId: string]: DocStore;
|
||||||
};
|
};
|
||||||
createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider;
|
createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider;
|
||||||
setStore: (storeId: string, props: Partial<DocStore>) => void;
|
setStore: (storeId: string, props: Partial<DocStore>) => void;
|
||||||
|
forceSave: ForceSaveState;
|
||||||
|
setForceSave: (forceSave: ForceSaveState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDocStore = create<UseDocStore>((set, get) => ({
|
export const useDocStore = create<UseDocStore>((set, get) => ({
|
||||||
docsStore: {},
|
docsStore: {},
|
||||||
|
forceSave: 'false',
|
||||||
|
setForceSave: (forceSave) => {
|
||||||
|
set(() => ({ forceSave }));
|
||||||
|
},
|
||||||
createProvider: (storeId: string, initialDoc: Base64) => {
|
createProvider: (storeId: string, initialDoc: Base64) => {
|
||||||
const doc = new Y.Doc({
|
const doc = new Y.Doc({
|
||||||
guid: storeId,
|
guid: storeId,
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import { useRouter } from 'next/router';
|
import { Button } from '@openfun/cunningham-react';
|
||||||
import React from '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 { useCunninghamTheme } from '@/cunningham';
|
||||||
|
import { useDocStore } from '@/features/docs/doc-editor';
|
||||||
|
import { Doc } from '@/features/docs/doc-management';
|
||||||
|
|
||||||
import { Versions } from '../types';
|
import { Versions } from '../types';
|
||||||
|
import { revertUpdate } from '../utils';
|
||||||
|
|
||||||
interface VersionItemProps {
|
interface VersionItemProps {
|
||||||
|
docId: Doc['id'];
|
||||||
text: string;
|
text: string;
|
||||||
link: string;
|
link: string;
|
||||||
versionId?: Versions['version_id'];
|
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 { colorsTokens } = useCunninghamTheme();
|
||||||
const {
|
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||||
query: { versionId: currentId },
|
|
||||||
} = useRouter();
|
|
||||||
|
|
||||||
const isActive = versionId === currentId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -35,14 +44,7 @@ export const VersionItem = ({ versionId, text, link }: VersionItemProps) => {
|
|||||||
$hasTransition
|
$hasTransition
|
||||||
$minWidth="13rem"
|
$minWidth="13rem"
|
||||||
>
|
>
|
||||||
<StyledLink
|
<Link href={link} isActive={isActive}>
|
||||||
href={link}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (isActive) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
|
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
|
||||||
$align="center"
|
$align="center"
|
||||||
@@ -58,8 +60,64 @@ export const VersionItem = ({ versionId, text, link }: VersionItemProps) => {
|
|||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
</StyledLink>
|
</Link>
|
||||||
</Box>
|
</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 { Loader } from '@openfun/cunningham-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import React, { useMemo, useRef } from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ const VersionListState = ({
|
|||||||
}: VersionListStateProps) => {
|
}: VersionListStateProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatDate } = useDate();
|
const { formatDate } = useDate();
|
||||||
|
const {
|
||||||
|
query: { versionId },
|
||||||
|
} = useRouter();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +46,8 @@ const VersionListState = ({
|
|||||||
text={t('Current version')}
|
text={t('Current version')}
|
||||||
versionId={undefined}
|
versionId={undefined}
|
||||||
link={`/docs/${doc.id}/`}
|
link={`/docs/${doc.id}/`}
|
||||||
|
docId={doc.id}
|
||||||
|
isActive={!versionId}
|
||||||
/>
|
/>
|
||||||
{versions?.map((version) => (
|
{versions?.map((version) => (
|
||||||
<VersionItem
|
<VersionItem
|
||||||
@@ -52,6 +58,8 @@ const VersionListState = ({
|
|||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
})}
|
})}
|
||||||
link={`/docs/${doc.id}/versions/${version.version_id}`}
|
link={`/docs/${doc.id}/versions/${version.version_id}`}
|
||||||
|
docId={doc.id}
|
||||||
|
isActive={version.version_id === versionId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{error && (
|
{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