(frontend) enhance document versioning and loading experience

- Updated tests for document member list and versioning to utilize
'Load more' button instead of mouse wheel scrolling.
- Improved UI for document versioning, including visibility
checks and modal interactions.
- Refactored InfiniteScroll component to include a button for
loading more items, enhancing user experience.
- Adjusted DocEditor and DocHeader components to handle
version IDs more effectively.
- Removed deprecated versioning pages to streamline the codebase.
This commit is contained in:
Nathan Panchout
2024-12-03 10:02:45 +01:00
committed by Anthony LC
parent 5bcce0c64a
commit a8a89def98
20 changed files with 437 additions and 384 deletions

View File

@@ -21,6 +21,9 @@ and this project adheres to
- ♻️(frontend) better separation collaboration process #528 - ♻️(frontend) better separation collaboration process #528
- 💄(frontend) updating the header and leftpanel for responsive #421 - 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431 - 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- 💄(frontend) update doc versioning ui #463
## [1.10.0] - 2024-12-17 ## [1.10.0] - 2024-12-17
@@ -40,6 +43,7 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511 - ⚡️(e2e) reduce flakiness on e2e tests #511
## [1.9.0] - 2024-12-11 ## [1.9.0] - 2024-12-11
## Added ## Added
@@ -62,8 +66,10 @@ and this project adheres to
- 🐛(backend) fix sanitize problem IA #490 - 🐛(backend) fix sanitize problem IA #490
## [1.8.2] - 2024-11-28 ## [1.8.2] - 2024-11-28
## Changed ## Changed
- ♻️(SW) change strategy html caching #460 - ♻️(SW) change strategy html caching #460
@@ -88,9 +94,6 @@ and this project adheres to
- ✨(frontend) config endpoint #424 - ✨(frontend) config endpoint #424
- ✨(frontend) add sentry #424 - ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450 - ✨(frontend) add crisp chatbot #450
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
## Changed ## Changed

View File

@@ -54,8 +54,10 @@ test.describe('Document list members', () => {
const list = page.getByLabel('List members card').locator('ul'); const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20); await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover(); await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10); const loadMoreButton = page
.getByLabel('List members card')
.getByRole('button', { name: 'arrow_downward Load more' });
await loadMoreButton.scrollIntoViewIfNeeded();
await waitForElementCount(list.locator('li'), 21, 10000); await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20); expect(await list.locator('li').count()).toBeGreaterThan(20);
@@ -109,9 +111,13 @@ test.describe('Document list members', () => {
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List invitation card').locator('ul'); const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20); await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover(); await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10); const loadMoreButton = page
.getByLabel('List invitation card')
.getByRole('button', { name: 'arrow_downward Load more' });
await loadMoreButton.scrollIntoViewIfNeeded();
await waitForElementCount(list.locator('li'), 21, 10000); await waitForElementCount(list.locator('li'), 21, 10000);

View File

@@ -23,24 +23,27 @@ test.describe('Doc Version', () => {
name: 'Version history', name: 'Version history',
}) })
.click(); .click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const panel = page.getByLabel('Document panel'); const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel.getByText('Current version')).toBeVisible(); await expect(panel).toBeVisible();
expect(await panel.locator('li').count()).toBe(1); await expect(modal.getByText('No versions')).toBeVisible();
await modal.getByRole('button', { name: 'close' }).click();
await page.locator('.ProseMirror.bn-editor').click(); await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World'); await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
await goToGridDoc(page, { await goToGridDoc(page, {
title: randomDoc, title: randomDoc,
}); });
await expect(page.getByText('Hello World')).toBeVisible(); await expect(
page.getByRole('heading', { name: 'Hello World' }),
).toBeVisible();
await page await page
.locator('.ProseMirror .bn-block') .locator('.ProseMirror .bn-block')
.getByText('Hello World') .getByRole('heading', { name: 'Hello World' })
.fill('It will create a version'); .fill('It will create a version');
await goToGridDoc(page, { await goToGridDoc(page, {
@@ -48,7 +51,9 @@ test.describe('Doc Version', () => {
}); });
await expect(page.getByText('Hello World')).toBeHidden(); await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible(); await expect(
page.getByRole('heading', { name: 'It will create a version' }),
).toBeVisible();
await page.getByLabel('Open the document options').click(); await page.getByLabel('Open the document options').click();
await page await page
@@ -57,19 +62,16 @@ test.describe('Doc Version', () => {
}) })
.click(); .click();
await expect(panel.getByText('Current version')).toBeVisible(); await expect(panel).toBeVisible();
expect(await panel.locator('li').count()).toBe(2); await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await panel.locator('li').nth(1).click(); await expect(modal.getByText('Hello World')).toBeVisible();
await expect( await expect(modal.getByText('It will create a version')).toBeHidden();
page.getByText('Read only, you cannot edit document versions.'),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await expect(page.getByText('It will create a version')).toBeHidden();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
}); });
test('it does not display the doc versions if not allowed', async ({ test('it does not display the doc versions if not allowed', async ({
@@ -90,12 +92,6 @@ test.describe('Doc Version', () => {
await expect( await expect(
page.getByRole('button', { name: 'Version history' }), page.getByRole('button', { name: 'Version history' }),
).toBeDisabled(); ).toBeDisabled();
await page.getByRole('button', { name: 'Table of content' }).click();
await expect(
page.getByLabel('Document panel').getByText('Versions'),
).toBeHidden();
}); });
test('it restores the doc version', async ({ page, browserName }) => { test('it restores the doc version', async ({ page, browserName }) => {
@@ -105,7 +101,6 @@ test.describe('Doc Version', () => {
await page.locator('.bn-block-outer').last().click(); await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello'); await page.locator('.bn-block-outer').last().fill('Hello');
expect(true).toBe(true);
await goToGridDoc(page, { await goToGridDoc(page, {
title: randomDoc, title: randomDoc,
}); });
@@ -129,84 +124,26 @@ test.describe('Doc Version', () => {
}) })
.click(); .click();
const panel = page.getByLabel('Document panel'); const modal = page.getByLabel('version history modal');
await panel.locator('li').nth(1).click(); const panel = modal.getByLabel('version list');
await expect(page.getByText('World')).toBeHidden(); await expect(panel).toBeVisible();
await panel.getByLabel('Open the version options').click(); await expect(page.getByText('History', { exact: true })).toBeVisible();
await page.getByText('Restore the version').click(); await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await expect(page.getByText('Restore this version?')).toBeVisible(); await expect(modal.getByText('World')).toBeHidden();
await page await page.getByRole('button', { name: 'Restore' }).click();
.getByRole('button', { await expect(page.getByText('Your current document will')).toBeVisible();
name: 'Restore', await page.getByText('If a member is editing, his').click();
})
.click();
await expect(panel.locator('li')).toHaveCount(3); await page.getByLabel('Restore', { exact: true }).click();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello')).toBeVisible(); await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden(); await expect(page.getByText('World')).toBeHidden();
}); });
test('it restores the doc version from button title', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await verifyDocName(page, randomDoc);
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('Hello')).toBeVisible();
await editor.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(editor.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore this version',
})
.click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
});
}); });

View File

@@ -11,6 +11,7 @@ export type DropdownMenuOption = {
callback?: () => void | Promise<unknown>; callback?: () => void | Promise<unknown>;
danger?: boolean; danger?: boolean;
disabled?: boolean; disabled?: boolean;
show?: boolean;
}; };
export type DropdownMenuProps = { export type DropdownMenuProps = {
@@ -59,6 +60,10 @@ export const DropdownMenu = ({
> >
<Box> <Box>
{options.map((option, index) => { {options.map((option, index) => {
if (option.show !== undefined && !option.show) {
return;
}
const isDisabled = option.disabled !== undefined && option.disabled; const isDisabled = option.disabled !== undefined && option.disabled;
return ( return (
<BoxButton <BoxButton

View File

@@ -1,12 +1,16 @@
import { PropsWithChildren, useEffect, useRef } from 'react'; import { Button } from '@openfun/cunningham-react';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { Box, BoxType } from '@/components'; import { Box, BoxType, Icon } from '@/components';
interface InfiniteScrollProps extends BoxType { interface InfiniteScrollProps extends BoxType {
hasMore: boolean; hasMore: boolean;
isLoading: boolean; isLoading: boolean;
next: () => void; next: () => void;
scrollContainer: HTMLElement | null; scrollContainer?: HTMLElement | null;
buttonLabel?: string;
} }
export const InfiniteScroll = ({ export const InfiniteScroll = ({
@@ -14,42 +18,31 @@ export const InfiniteScroll = ({
hasMore, hasMore,
isLoading, isLoading,
next, next,
scrollContainer, buttonLabel,
...boxProps ...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => { }: PropsWithChildren<InfiniteScrollProps>) => {
const timeout = useRef<ReturnType<typeof setTimeout>>(); const { t } = useTranslation();
const loadMore = (inView: boolean) => {
useEffect(() => { if (!inView || isLoading) {
if (!scrollContainer) {
return; return;
} }
void next();
};
const nextHandle = () => { return (
if (!hasMore || isLoading) { <Box {...boxProps}>
return; {children}
} <InView onChange={loadMore}>
{!isLoading && hasMore && (
// To not wait until the end of the scroll to load more data <Button
const heightFromBottom = 150; onClick={() => void next()}
color="primary-text"
const { scrollTop, clientHeight, scrollHeight } = scrollContainer; icon={<Icon iconName="arrow_downward" />}
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) { >
next(); {buttonLabel ?? t('Load more')}
} </Button>
}; )}
</InView>
const handleScroll = () => { </Box>
if (timeout.current) { );
clearTimeout(timeout.current);
}
timeout.current = setTimeout(nextHandle, 50);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [hasMore, isLoading, next, scrollContainer]);
return <Box {...boxProps}>{children}</Box>;
}; };

View File

@@ -6,7 +6,7 @@ import * as Y from 'yjs';
import { Box, Card, Text, TextErrors } from '@/components'; import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header'; import { DocHeader, DocVersionHeader } from '@/features/docs/doc-header/';
import { import {
Doc, Doc,
base64ToBlocknoteXmlFragment, base64ToBlocknoteXmlFragment,
@@ -20,12 +20,10 @@ import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps { interface DocEditorProps {
doc: Doc; doc: Doc;
versionId?: Versions['version_id'];
} }
export const DocEditor = ({ doc }: DocEditorProps) => { export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useResponsiveStore(); const { isMobile } = useResponsiveStore();
@@ -41,7 +39,12 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
return ( return (
<> <>
<DocHeader doc={doc} /> {isVersion ? (
<DocVersionHeader title={doc.title} />
) : (
<DocHeader doc={doc} />
)}
{!doc.abilities.partial_update && ( {!doc.abilities.partial_update && (
<Box $width="100%" $margin={{ all: 'small', top: 'none' }}> <Box $width="100%" $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}> <Alert type={VariantType.WARNING}>
@@ -49,18 +52,11 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
</Alert> </Alert>
</Box> </Box>
)} )}
{isVersion && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit document versions.`)}
</Alert>
</Box>
)}
<Box <Box
$background={colorsTokens()['primary-bg']} $background={colorsTokens()['primary-bg']}
$direction="row" $direction="row"
$width="100%" $width="100%"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip; flex: 1;" $css="overflow-x: clip; flex: 1;"
$position="relative" $position="relative"
> >
@@ -75,9 +71,9 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
) : ( ) : (
<BlockNoteEditor doc={doc} provider={provider} /> <BlockNoteEditor doc={doc} provider={provider} />
)} )}
{!isMobile && <IconOpenPanelEditor />} {!isMobile && !isVersion && <IconOpenPanelEditor />}
</Card> </Card>
<PanelEditor doc={doc} /> {!isVersion && <PanelEditor />}
</Box> </Box>
</> </>
); );

View File

@@ -1,20 +1,14 @@
import React, { PropsWithChildren, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Card, IconBG, Text } from '@/components'; import { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content'; import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { useHeadingStore, usePanelEditorStore } from '../stores'; import { useHeadingStore, usePanelEditorStore } from '../stores';
interface PanelProps { export const PanelEditor = () => {
doc: Doc;
}
export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme(); const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore(); const { isMobile } = useResponsiveStore();
@@ -72,7 +66,7 @@ export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
$background="white" $background="white"
$position="absolute" $position="absolute"
$height="100%" $height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'} $width="100%"
$hasTransition="slow" $hasTransition="slow"
$css={` $css={`
border-top: 2px solid ${colorsTokens()['primary-600']}; border-top: 2px solid ${colorsTokens()['primary-600']};
@@ -88,7 +82,7 @@ export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
`} `}
/> />
<BoxButton <BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'} $minWidth="100%"
onClick={() => setIsPanelTableContentOpen(true)} onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1} $zIndex={1}
> >
@@ -103,29 +97,8 @@ export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
{t('Table of content')} {t('Table of content')}
</Text> </Text>
</BoxButton> </BoxButton>
{doc.abilities.versions_list && (
<BoxButton
$minWidth="50%"
onClick={() => setIsPanelTableContentOpen(false)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Versions')}
</Text>
</BoxButton>
)}
</Box> </Box>
{isPanelTableContentOpen && <TableContent />} <TableContent />
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
</Box> </Box>
</Card> </Card>
); );

View File

@@ -68,6 +68,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
> >
<Box $gap={spacings['3xs']}> <Box $gap={spacings['3xs']}>
<DocTitle doc={doc} /> <DocTitle doc={doc} />
<Box $direction="row"> <Box $direction="row">
{isDesktop && ( {isDesktop && (
<> <>

View File

@@ -25,23 +25,30 @@ interface DocTitleProps {
} }
export const DocTitle = ({ doc }: DocTitleProps) => { export const DocTitle = ({ doc }: DocTitleProps) => {
const { isMobile } = useResponsiveStore();
if (!doc.abilities.partial_update) { if (!doc.abilities.partial_update) {
return ( return <DocTitleText title={doc.title} />;
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
{doc.title}
</Text>
);
} }
return <DocTitleInput doc={doc} />; return <DocTitleInput doc={doc} />;
}; };
interface DocTitleTextProps {
title: string;
}
export const DocTitleText = ({ title }: DocTitleTextProps) => {
const { isMobile } = useResponsiveStore();
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
{title}
</Text>
);
};
const DocTitleInput = ({ doc }: DocTitleProps) => { const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -1,9 +1,9 @@
import { import {
Button, Button,
VariantType, VariantType,
useModal,
useToastProvider, useToastProvider,
} from '@openfun/cunningham-react'; } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
@@ -26,7 +26,7 @@ import {
ModalRemoveDoc, ModalRemoveDoc,
ModalShare, ModalShare,
} from '@/features/docs/doc-management'; } from '@/features/docs/doc-management';
import { ModalVersion } from '@/features/docs/doc-versioning'; import { ModalSelectVersion } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { ModalPDF } from './ModalExport'; import { ModalPDF } from './ModalExport';
@@ -36,9 +36,6 @@ interface DocToolBoxProps {
} }
export const DocToolBox = ({ doc }: DocToolBoxProps) => { export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const {
query: { versionId },
} = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const { spacingsTokens, colorsTokens } = useCunninghamTheme();
@@ -48,10 +45,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalShareOpen, setIsModalShareOpen] = useState(false); const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const selectHistoryModal = useModal();
const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore(); const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore(); const { isSmallMobile, isDesktop } = useResponsiveStore();
const { authenticated } = useAuthStore(); const { authenticated } = useAuthStore();
const { editor } = useEditorStore(); const { editor } = useEditorStore();
const { toast } = useToastProvider(); const { toast } = useToastProvider();
@@ -80,9 +77,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
icon: 'history', icon: 'history',
disabled: !doc.abilities.versions_list, disabled: !doc.abilities.versions_list,
callback: () => { callback: () => {
setIsPanelOpen(true); selectHistoryModal.open();
setIsPanelTableContentOpen(false);
}, },
show: isDesktop,
}, },
{ {
label: t('Table of contents'), label: t('Table of contents'),
@@ -147,19 +144,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$gap="0.5rem 1.5rem" $gap="0.5rem 1.5rem"
$wrap={isSmallMobile ? 'wrap' : 'nowrap'} $wrap={isSmallMobile ? 'wrap' : 'nowrap'}
> >
{versionId && (
<Box $margin={{ left: 'auto' }}>
<Button
onClick={() => {
setIsModalVersionOpen(true);
}}
color="secondary"
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Restore this version')}
</Button>
</Box>
)}
<Box $direction="row" $margin={{ left: 'auto' }} $gap={spacings['2xs']}> <Box $direction="row" $margin={{ left: 'auto' }} $gap={spacings['2xs']}>
{authenticated && !isSmallMobile && ( {authenticated && !isSmallMobile && (
<Button <Button
@@ -210,11 +194,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{isModalRemoveOpen && ( {isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} /> <ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)} )}
{isModalVersionOpen && versionId && ( {selectHistoryModal.isOpen && (
<ModalVersion <ModalSelectVersion
onClose={() => setIsModalVersionOpen(false)} onClose={() => selectHistoryModal.close()}
docId={doc.id} doc={doc}
versionId={versionId as string}
/> />
)} )}
</Box> </Box>

View File

@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { useCunninghamTheme } from '@/cunningham';
import { DocTitleText } from './DocTitle';
interface DocVersionHeaderProps {
title: string;
}
export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const { t } = useTranslation();
return (
<>
<Box
$width="100%"
$padding={{ vertical: 'base' }}
$gap={spacings['base']}
aria-label={t('It is the document title')}
>
<DocTitleText title={title} />
<HorizontalSeparator />
</Box>
</>
);
};

View File

@@ -1 +1,2 @@
export * from './DocHeader'; export * from './DocHeader';
export * from './DocVersionHeader';

View File

@@ -77,6 +77,7 @@ export function useDocVersionsInfiniteQuery(
getNextPageParam(lastPage) { getNextPageParam(lastPage) {
return lastPage.next_version_id_marker || undefined; return lastPage.next_version_id_marker || undefined;
}, },
...queryConfig, ...queryConfig,
}); });
} }

View File

@@ -1,5 +1,4 @@
import { import {
Alert,
Button, Button,
Modal, Modal,
ModalSize, ModalSize,
@@ -22,17 +21,18 @@ import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
import { Versions } from '../types'; import { Versions } from '../types';
import { revertUpdate } from '../utils'; import { revertUpdate } from '../utils';
interface ModalVersionProps { interface ModalConfirmationVersionProps {
onClose: () => void; onClose: () => void;
docId: Doc['id']; docId: Doc['id'];
versionId: Versions['version_id']; versionId: Versions['version_id'];
} }
export const ModalVersion = ({ export const ModalConfirmationVersion = ({
onClose, onClose,
docId, docId,
versionId, versionId,
}: ModalVersionProps) => { }: ModalConfirmationVersionProps) => {
const { data: version } = useDocVersion({ const { data: version } = useDocVersion({
docId, docId,
versionId, versionId,
@@ -68,60 +68,50 @@ export const ModalVersion = ({
<Modal <Modal
isOpen isOpen
closeOnClickOutside closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()} onClose={() => onClose()}
rightActions={ rightActions={
<Button <>
aria-label={t('Restore')} <Button
color="primary" aria-label={t('Close the modal')}
fullWidth color="secondary"
onClick={() => { fullWidth
if (!version?.content) { onClick={() => onClose()}
return; >
} {t('Cancel')}
</Button>
<Button
aria-label={t('Restore')}
color="danger"
fullWidth
onClick={() => {
if (!version?.content) {
return;
}
updateDoc({ updateDoc({
id: docId, id: docId,
content: version.content, content: version.content,
}); });
onClose(); onClose();
}} }}
> >
{t('Restore')} {t('Restore')}
</Button> </Button>
</>
} }
size={ModalSize.MEDIUM} size={ModalSize.MEDIUM}
title={ title={
<Box $gap="1rem"> <Text $size="h6" $align="flex-start">
<Text $isMaterialIcon $size="36px" $theme="primary"> {t('Warning')}
restore </Text>
</Text>
<Text as="h2" $size="h3" $margin="none">
{t('Restore this version?')}
</Text>
</Box>
} }
> >
<Box aria-label={t('Modal confirmation to restore the version')}> <Box aria-label={t('Modal confirmation to restore the version')}>
<Alert canClose={false} type={VariantType.WARNING}> <Box>
<Box> <Text>{t('Your current document will revert to this version.')}</Text>
<Text> <Text>{t('If a member is editing, his works can be lost.')}</Text>
{t('Your current document will revert to this version.')} </Box>
</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
</Box>
</Alert>
</Box> </Box>
</Modal> </Modal>
); );

View File

@@ -0,0 +1,156 @@
import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { DocEditor } from '../../doc-editor/components/DocEditor';
import { Doc } from '../../doc-management';
import { Versions } from '../types';
import { ModalConfirmationVersion } from './ModalConfirmationVersion';
import { VersionList } from './VersionList';
const NoPaddingStyle = createGlobalStyle`
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
.c__modal__close .c__button {
right: 0;
top: 7px;
padding: 1rem 0.5rem;
}
}
`;
type ModalSelectVersionProps = {
doc: Doc;
onClose: () => void;
};
export const ModalSelectVersion = ({
onClose,
doc,
}: ModalSelectVersionProps) => {
const { t } = useTranslation();
const [selectedVersionId, setSelectedVersionId] =
useState<Versions['version_id']>();
const restoreModal = useModal();
return (
<>
<Modal
isOpen
hideCloseButton
closeOnClickOutside={true}
size={ModalSize.EXTRA_LARGE}
onClose={onClose}
>
<NoPaddingStyle />
<Box
aria-label="version history modal"
className="noPadding"
$direction="row"
$height="calc(100vh - 50px);"
$overflow="hidden"
>
<Box
$css={css`
display: flex;
flex-direction: row;
justify-content: center;
overflow-y: auto;
flex: 1;
`}
>
<Box $width="100%" $padding="base">
{selectedVersionId && (
<DocEditor doc={doc} versionId={selectedVersionId} />
)}
{!selectedVersionId && (
<Box $align="center" $justify="center" $height="100%">
<Text $size="h6" $weight="bold">
{t('Select a version on the right to restore')}
</Text>
</Box>
)}
</Box>
</Box>
<Box
$direction="column"
$justify="space-between"
$width="250px"
$height="calc(100vh - 2em - 30px);"
$css={css`
overflow-y: hidden;
border-left: 1px solid var(--c--theme--colors--greyscale-200);
`}
>
<Box
aria-label="version list"
$css={css`
overflow-y: auto;
flex: 1;
`}
>
<Box
$width="100%"
$justify="space-between"
$direction="row"
$align="center"
$css={css`
border-bottom: 1px solid
var(--c--theme--colors--greyscale-200);
`}
$padding="sm"
>
<Text $size="h6" $weight="bold">
{t('History')}
</Text>
<Button
onClick={onClose}
size="nano"
color="primary-text"
icon={<Icon iconName="close" />}
/>
</Box>
<VersionList
doc={doc}
onSelectVersion={setSelectedVersionId}
selectedVersionId={selectedVersionId}
/>
</Box>
<Box
$padding="base"
$css={css`
border-top: 1px solid var(--c--theme--colors--greyscale-200);
`}
>
<Button
fullWidth
disabled={!selectedVersionId}
onClick={restoreModal.open}
color="primary"
>
{t('Restore')}
</Button>
</Box>
</Box>
</Box>
</Modal>
{restoreModal.isOpen && selectedVersionId && (
<ModalConfirmationVersion
onClose={() => {
restoreModal.close();
onClose();
setSelectedVersionId(undefined);
}}
docId={doc.id}
versionId={selectedVersionId}
/>
)}
</>
);
};

View File

@@ -1,19 +1,17 @@
import { Button } from '@openfun/cunningham-react'; import { useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components'; import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management'; import { Doc } from '@/features/docs/doc-management';
import { Versions } from '../types'; import { Versions } from '../types';
import { ModalVersion } from './ModalVersion'; import { ModalConfirmationVersion } from './ModalConfirmationVersion';
interface VersionItemProps { interface VersionItemProps {
docId: Doc['id']; docId: Doc['id'];
text: string; text: string;
link: string;
versionId?: Versions['version_id']; versionId?: Versions['version_id'];
isActive: boolean; isActive: boolean;
} }
@@ -22,78 +20,47 @@ export const VersionItem = ({
docId, docId,
versionId, versionId,
text, text,
link,
isActive, isActive,
}: VersionItemProps) => { }: VersionItemProps) => {
const { t } = useTranslation(); const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { colorsTokens } = useCunninghamTheme(); const spacing = spacingsTokens();
const [isDropOpen, setIsDropOpen] = useState(false);
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false); const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
return ( return (
<> <>
<Box <Box
$width="100%"
as="li" as="li"
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'} $background={isActive ? colorsTokens()['greyscale-100'] : 'transparent'}
$radius={spacing['3xs']}
$css={` $css={`
border-left: 4px solid transparent; cursor: pointer;
border-bottom: 1px solid ${colorsTokens()['primary-100']};
&:hover{ &:hover {
border-left: 4px solid ${colorsTokens()['primary-400']}; background: ${colorsTokens()['greyscale-100']};
background: ${colorsTokens()['primary-300']};
} }
`} `}
$hasTransition $hasTransition
$minWidth="13rem" $minWidth="13rem"
> >
<Link href={link} isActive={isActive}> <Box
<Box $padding={{ vertical: '0.7rem', horizontal: 'small' }}
$padding={{ vertical: '0.7rem', horizontal: 'small' }} $align="center"
$align="center" $direction="row"
$direction="row" $justify="space-between"
$justify="space-between" $width="100%"
$width="100%" >
> <Box $direction="row" $gap="0.5rem" $align="center">
<Box $direction="row" $gap="0.5rem" $align="center"> <Text $weight="bold" $size="sm" $variation="1000">
<Text {text}
$isMaterialIcon </Text>
$size="24px"
$theme="primary"
$variation="600"
>
description
</Text>
<Text $weight="bold" $theme="primary" $size="m" $variation="600">
{text}
</Text>
</Box>
{isActive && versionId && (
<DropButton
button={
<IconOptions aria-label={t('Open the version options')} />
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
onClick={() => {
setIsModalVersionOpen(true);
}}
color="primary-text"
icon={<span className="material-icons">save</span>}
size="small"
>
{t('Restore the version')}
</Button>
</Box>
</DropButton>
)}
</Box> </Box>
</Link> </Box>
</Box> </Box>
{isModalVersionOpen && versionId && ( {isModalVersionOpen && versionId && (
<ModalVersion <ModalConfirmationVersion
onClose={() => setIsModalVersionOpen(false)} onClose={() => setIsModalVersionOpen(false)}
docId={docId} docId={docId}
versionId={versionId} versionId={versionId}
@@ -102,16 +69,3 @@ export const VersionItem = ({
</> </>
); );
}; };
interface LinkProps {
href: string;
isActive: boolean;
}
const Link = ({ href, children, isActive }: PropsWithChildren<LinkProps>) => {
return isActive ? (
<>{children}</>
) : (
<StyledLink href={href}>{children}</StyledLink>
);
};

View File

@@ -1,10 +1,10 @@
import { Loader } from '@openfun/cunningham-react'; import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router'; import { DateTime } from 'luxon';
import React, { useMemo, useRef } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { APIError } from '@/api'; import { APIError } from '@/api';
import { Box, InfiniteScroll, Text, TextErrors } from '@/components'; import { Box, BoxButton, InfiniteScroll, Text, TextErrors } from '@/components';
import { Doc } from '@/features/docs/doc-management'; import { Doc } from '@/features/docs/doc-management';
import { useDate } from '@/hook'; import { useDate } from '@/hook';
@@ -18,19 +18,20 @@ interface VersionListStateProps {
error: APIError<unknown> | null; error: APIError<unknown> | null;
versions?: Versions[]; versions?: Versions[];
doc: Doc; doc: Doc;
selectedVersionId?: Versions['version_id'];
onSelectVersion?: (versionId: Versions['version_id']) => void;
} }
const VersionListState = ({ const VersionListState = ({
onSelectVersion,
selectedVersionId,
isLoading, isLoading,
error, error,
versions, versions,
doc, doc,
}: VersionListStateProps) => { }: VersionListStateProps) => {
const { t } = useTranslation();
const { formatDate } = useDate(); const { formatDate } = useDate();
const {
query: { versionId },
} = useRouter();
if (isLoading) { if (isLoading) {
return ( return (
@@ -41,26 +42,23 @@ const VersionListState = ({
} }
return ( return (
<> <Box $gap="10px" $padding="xs">
<VersionItem
text={t('Current version')}
versionId={undefined}
link={`/docs/${doc.id}/`}
docId={doc.id}
isActive={!versionId}
/>
{versions?.map((version) => ( {versions?.map((version) => (
<VersionItem <BoxButton
aria-label="version item"
className="version-item"
key={version.version_id} key={version.version_id}
versionId={version.version_id} onClick={() => {
text={formatDate(version.last_modified, { onSelectVersion?.(version.version_id);
dateStyle: 'long', }}
timeStyle: 'short', >
})} <VersionItem
link={`/docs/${doc.id}/versions/${version.version_id}`} versionId={version.version_id}
docId={doc.id} text={formatDate(version.last_modified, DateTime.DATETIME_MED)}
isActive={version.version_id === versionId} docId={doc.id}
/> isActive={version.version_id === selectedVersionId}
/>
</BoxButton>
))} ))}
{error && ( {error && (
<Box <Box
@@ -79,15 +77,23 @@ const VersionListState = ({
/> />
</Box> </Box>
)} )}
</> </Box>
); );
}; };
interface VersionListProps { interface VersionListProps {
doc: Doc; doc: Doc;
onSelectVersion?: (versionId: Versions['version_id']) => void;
selectedVersionId?: Versions['version_id'];
} }
export const VersionList = ({ doc }: VersionListProps) => { export const VersionList = ({
doc,
onSelectVersion,
selectedVersionId,
}: VersionListProps) => {
const { t } = useTranslation();
const { const {
data, data,
error, error,
@@ -98,7 +104,7 @@ export const VersionList = ({ doc }: VersionListProps) => {
} = useDocVersionsInfiniteQuery({ } = useDocVersionsInfiniteQuery({
docId: doc.id, docId: doc.id,
}); });
const containerRef = useRef<HTMLDivElement>(null);
const versions = useMemo(() => { const versions = useMemo(() => {
return data?.pages.reduce((acc, page) => { return data?.pages.reduce((acc, page) => {
return acc.concat(page.versions); return acc.concat(page.versions);
@@ -106,24 +112,32 @@ export const VersionList = ({ doc }: VersionListProps) => {
}, [data?.pages]); }, [data?.pages]);
return ( return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}> <Box $css="overflow-y: auto; overflow-x: hidden;">
<InfiniteScroll <InfiniteScroll
hasMore={hasNextPage} hasMore={hasNextPage}
isLoading={isFetchingNextPage} isLoading={isFetchingNextPage}
next={() => { next={() => {
void fetchNextPage(); void fetchNextPage();
}} }}
scrollContainer={containerRef.current}
as="ul" as="ul"
$padding="none" $padding="none"
$margin={{ top: 'none' }} $margin={{ top: 'none' }}
role="listbox" role="listbox"
> >
{versions?.length === 0 && (
<Box $align="center" $margin="large">
<Text $size="h6" $weight="bold">
{t('No versions')}
</Text>
</Box>
)}
<VersionListState <VersionListState
onSelectVersion={onSelectVersion}
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
versions={versions} versions={versions}
doc={doc} doc={doc}
selectedVersionId={selectedVersionId}
/> />
</InfiniteScroll> </InfiniteScroll>
</Box> </Box>

View File

@@ -1,2 +1,3 @@
export * from './ModalVersion'; export * from './ModalConfirmationVersion';
export * from './ModalSelectVersion';
export * from './VersionList'; export * from './VersionList';

View File

@@ -19,7 +19,7 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
const isPublic = doc.link_reach === LinkReach.PUBLIC; const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED; const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED; const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.accesses.length - 1; const sharedCount = doc.nb_accesses - 1;
const isShared = sharedCount > 0; const isShared = sharedCount > 0;
return ( return (

View File

@@ -33,8 +33,8 @@ export const SimpleDocItem = ({
const spacings = spacingsTokens(); const spacings = spacingsTokens();
const isPublic = doc?.link_reach === LinkReach.PUBLIC; const isPublic = doc?.link_reach === LinkReach.PUBLIC;
const isShared = !isPublic && doc.accesses.length > 1; const isShared = !isPublic && doc.nb_accesses > 1;
const accessCount = doc.accesses.length - 1; const accessCount = doc.nb_accesses - 1;
const isSharedOrPublic = isShared || isPublic; const isSharedOrPublic = isShared || isPublic;
return ( return (