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

View File

@@ -54,8 +54,10 @@ test.describe('Document list members', () => {
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
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);
expect(await list.locator('li').count()).toBeGreaterThan(20);
@@ -109,9 +111,13 @@ test.describe('Document list members', () => {
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
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);

View File

@@ -23,24 +23,27 @@ test.describe('Doc Version', () => {
name: 'Version history',
})
.click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
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').last().fill('Hello World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Hello World' }),
).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByText('Hello World')
.getByRole('heading', { name: 'Hello World' })
.fill('It will create a version');
await goToGridDoc(page, {
@@ -48,7 +51,9 @@ test.describe('Doc Version', () => {
});
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
@@ -57,19 +62,16 @@ test.describe('Doc Version', () => {
})
.click();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await expect(panel).toBeVisible();
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(
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();
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
});
test('it does not display the doc versions if not allowed', async ({
@@ -90,12 +92,6 @@ test.describe('Doc Version', () => {
await expect(
page.getByRole('button', { name: 'Version history' }),
).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 }) => {
@@ -105,7 +101,6 @@ test.describe('Doc Version', () => {
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
expect(true).toBe(true);
await goToGridDoc(page, {
title: randomDoc,
});
@@ -129,84 +124,26 @@ test.describe('Doc Version', () => {
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(page.getByText('World')).toBeHidden();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
await panel.getByLabel('Open the version options').click();
await page.getByText('Restore the version').click();
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 expect(page.getByText('Restore this version?')).toBeVisible();
await expect(modal.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.getByText('Your current document will')).toBeVisible();
await page.getByText('If a member is editing, his').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('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>;
danger?: boolean;
disabled?: boolean;
show?: boolean;
};
export type DropdownMenuProps = {
@@ -59,6 +60,10 @@ export const DropdownMenu = ({
>
<Box>
{options.map((option, index) => {
if (option.show !== undefined && !option.show) {
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<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 {
hasMore: boolean;
isLoading: boolean;
next: () => void;
scrollContainer: HTMLElement | null;
scrollContainer?: HTMLElement | null;
buttonLabel?: string;
}
export const InfiniteScroll = ({
@@ -14,42 +18,31 @@ export const InfiniteScroll = ({
hasMore,
isLoading,
next,
scrollContainer,
buttonLabel,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const timeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!scrollContainer) {
const { t } = useTranslation();
const loadMore = (inView: boolean) => {
if (!inView || isLoading) {
return;
}
void next();
};
const nextHandle = () => {
if (!hasMore || isLoading) {
return;
}
// To not wait until the end of the scroll to load more data
const heightFromBottom = 150;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
next();
}
};
const handleScroll = () => {
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>;
return (
<Box {...boxProps}>
{children}
<InView onChange={loadMore}>
{!isLoading && hasMore && (
<Button
onClick={() => void next()}
color="primary-text"
icon={<Icon iconName="arrow_downward" />}
>
{buttonLabel ?? t('Load more')}
</Button>
)}
</InView>
</Box>
);
};

View File

@@ -6,7 +6,7 @@ import * as Y from 'yjs';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { DocHeader, DocVersionHeader } from '@/features/docs/doc-header/';
import {
Doc,
base64ToBlocknoteXmlFragment,
@@ -20,12 +20,10 @@ import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { t } = useTranslation();
const { isMobile } = useResponsiveStore();
@@ -41,7 +39,12 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
return (
<>
<DocHeader doc={doc} />
{isVersion ? (
<DocVersionHeader title={doc.title} />
) : (
<DocHeader doc={doc} />
)}
{!doc.abilities.partial_update && (
<Box $width="100%" $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
@@ -49,18 +52,11 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
</Alert>
</Box>
)}
{isVersion && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit document versions.`)}
</Alert>
</Box>
)}
<Box
$background={colorsTokens()['primary-bg']}
$direction="row"
$width="100%"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip; flex: 1;"
$position="relative"
>
@@ -75,9 +71,9 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor />}
{!isMobile && !isVersion && <IconOpenPanelEditor />}
</Card>
<PanelEditor doc={doc} />
{!isVersion && <PanelEditor />}
</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 { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { useHeadingStore, usePanelEditorStore } from '../stores';
interface PanelProps {
doc: Doc;
}
export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
export const PanelEditor = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
@@ -72,7 +66,7 @@ export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
$background="white"
$position="absolute"
$height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'}
$width="100%"
$hasTransition="slow"
$css={`
border-top: 2px solid ${colorsTokens()['primary-600']};
@@ -88,7 +82,7 @@ export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
`}
/>
<BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
$minWidth="100%"
onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1}
>
@@ -103,29 +97,8 @@ export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
{t('Table of content')}
</Text>
</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>
{isPanelTableContentOpen && <TableContent />}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
<TableContent />
</Box>
</Card>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import {
Alert,
Button,
Modal,
ModalSize,
@@ -22,17 +21,18 @@ import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
import { Versions } from '../types';
import { revertUpdate } from '../utils';
interface ModalVersionProps {
interface ModalConfirmationVersionProps {
onClose: () => void;
docId: Doc['id'];
versionId: Versions['version_id'];
}
export const ModalVersion = ({
export const ModalConfirmationVersion = ({
onClose,
docId,
versionId,
}: ModalVersionProps) => {
}: ModalConfirmationVersionProps) => {
const { data: version } = useDocVersion({
docId,
versionId,
@@ -68,60 +68,50 @@ export const ModalVersion = ({
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
rightActions={
<Button
aria-label={t('Restore')}
color="primary"
fullWidth
onClick={() => {
if (!version?.content) {
return;
}
<>
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
<Button
aria-label={t('Restore')}
color="danger"
fullWidth
onClick={() => {
if (!version?.content) {
return;
}
updateDoc({
id: docId,
content: version.content,
});
updateDoc({
id: docId,
content: version.content,
});
onClose();
}}
>
{t('Restore')}
</Button>
onClose();
}}
>
{t('Restore')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Box $gap="1rem">
<Text $isMaterialIcon $size="36px" $theme="primary">
restore
</Text>
<Text as="h2" $size="h3" $margin="none">
{t('Restore this version?')}
</Text>
</Box>
<Text $size="h6" $align="flex-start">
{t('Warning')}
</Text>
}
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Alert canClose={false} type={VariantType.WARNING}>
<Box>
<Text>
{t('Your current document will revert to this version.')}
</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
</Box>
</Alert>
<Box>
<Text>{t('Your current document will revert to this version.')}</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
</Box>
</Box>
</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 { PropsWithChildren, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { Versions } from '../types';
import { ModalVersion } from './ModalVersion';
import { ModalConfirmationVersion } from './ModalConfirmationVersion';
interface VersionItemProps {
docId: Doc['id'];
text: string;
link: string;
versionId?: Versions['version_id'];
isActive: boolean;
}
@@ -22,78 +20,47 @@ export const VersionItem = ({
docId,
versionId,
text,
link,
isActive,
}: VersionItemProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isDropOpen, setIsDropOpen] = useState(false);
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
return (
<>
<Box
$width="100%"
as="li"
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'}
$background={isActive ? colorsTokens()['greyscale-100'] : 'transparent'}
$radius={spacing['3xs']}
$css={`
border-left: 4px solid transparent;
border-bottom: 1px solid ${colorsTokens()['primary-100']};
&:hover{
border-left: 4px solid ${colorsTokens()['primary-400']};
background: ${colorsTokens()['primary-300']};
cursor: pointer;
&:hover {
background: ${colorsTokens()['greyscale-100']};
}
`}
$hasTransition
$minWidth="13rem"
>
<Link href={link} isActive={isActive}>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
$direction="row"
$justify="space-between"
$width="100%"
>
<Box $direction="row" $gap="0.5rem" $align="center">
<Text
$isMaterialIcon
$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
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
$direction="row"
$justify="space-between"
$width="100%"
>
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $weight="bold" $size="sm" $variation="1000">
{text}
</Text>
</Box>
</Link>
</Box>
</Box>
{isModalVersionOpen && versionId && (
<ModalVersion
<ModalConfirmationVersion
onClose={() => setIsModalVersionOpen(false)}
docId={docId}
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 { useRouter } from 'next/router';
import React, { useMemo, useRef } from 'react';
import { DateTime } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
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 { useDate } from '@/hook';
@@ -18,19 +18,20 @@ interface VersionListStateProps {
error: APIError<unknown> | null;
versions?: Versions[];
doc: Doc;
selectedVersionId?: Versions['version_id'];
onSelectVersion?: (versionId: Versions['version_id']) => void;
}
const VersionListState = ({
onSelectVersion,
selectedVersionId,
isLoading,
error,
versions,
doc,
}: VersionListStateProps) => {
const { t } = useTranslation();
const { formatDate } = useDate();
const {
query: { versionId },
} = useRouter();
if (isLoading) {
return (
@@ -41,26 +42,23 @@ const VersionListState = ({
}
return (
<>
<VersionItem
text={t('Current version')}
versionId={undefined}
link={`/docs/${doc.id}/`}
docId={doc.id}
isActive={!versionId}
/>
<Box $gap="10px" $padding="xs">
{versions?.map((version) => (
<VersionItem
<BoxButton
aria-label="version item"
className="version-item"
key={version.version_id}
versionId={version.version_id}
text={formatDate(version.last_modified, {
dateStyle: 'long',
timeStyle: 'short',
})}
link={`/docs/${doc.id}/versions/${version.version_id}`}
docId={doc.id}
isActive={version.version_id === versionId}
/>
onClick={() => {
onSelectVersion?.(version.version_id);
}}
>
<VersionItem
versionId={version.version_id}
text={formatDate(version.last_modified, DateTime.DATETIME_MED)}
docId={doc.id}
isActive={version.version_id === selectedVersionId}
/>
</BoxButton>
))}
{error && (
<Box
@@ -79,15 +77,23 @@ const VersionListState = ({
/>
</Box>
)}
</>
</Box>
);
};
interface VersionListProps {
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 {
data,
error,
@@ -98,7 +104,7 @@ export const VersionList = ({ doc }: VersionListProps) => {
} = useDocVersionsInfiniteQuery({
docId: doc.id,
});
const containerRef = useRef<HTMLDivElement>(null);
const versions = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.versions);
@@ -106,24 +112,32 @@ export const VersionList = ({ doc }: VersionListProps) => {
}, [data?.pages]);
return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
<Box $css="overflow-y: auto; overflow-x: hidden;">
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
$padding="none"
$margin={{ top: 'none' }}
role="listbox"
>
{versions?.length === 0 && (
<Box $align="center" $margin="large">
<Text $size="h6" $weight="bold">
{t('No versions')}
</Text>
</Box>
)}
<VersionListState
onSelectVersion={onSelectVersion}
isLoading={isLoading}
error={error}
versions={versions}
doc={doc}
selectedVersionId={selectedVersionId}
/>
</InfiniteScroll>
</Box>

View File

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

View File

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

View File

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