(frontend) feature versioning

We can now see the version of the document
and navigate to different versions.
Collaboration is possible per version.
This commit is contained in:
Anthony LC
2024-07-17 15:30:52 +02:00
committed by Anthony LC
parent 1ed20c3896
commit a9383212a3
23 changed files with 671 additions and 216 deletions

View File

@@ -11,6 +11,11 @@ and this project adheres to
## Added
- 🎨(frontend) better conversion editor to pdf #151
- ✨(frontend) Versioning #147
## Fixed
- 🐛(y-webrtc) fix prob connection #147
## [1.1.0] - 2024-07-15

View File

@@ -140,3 +140,34 @@ export const goToGridDoc = async (
return docTitle as string;
};
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
created_at: '2021-09-01T09:00:00Z',
...json,
},
});
} else {
await route.continue();
}
});
};

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc } from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -153,34 +153,17 @@ test.describe('Doc Editor', () => {
});
test('it cannot edit if viewer', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
});
await goToGridDoc(page);

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
import { goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -8,57 +8,42 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
});
} else {
await route.continue();
}
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);

View File

@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc, goToGridDoc } from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -200,34 +200,17 @@ test.describe('Doc Tools', () => {
});
test('it checks the options available if administrator', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
@@ -250,34 +233,17 @@ test.describe('Doc Tools', () => {
});
test('it checks the options available if editor', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
});
await goToGridDoc(page);
@@ -300,34 +266,17 @@ test.describe('Doc Tools', () => {
});
test('it checks the options available if reader', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
});
await goToGridDoc(page);

View File

@@ -0,0 +1,69 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const panel = page.getByLabel('Document version panel');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
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 page
.locator('.ProseMirror.bn-editor')
.last()
.fill('It will create a version');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await panel.locator('li').nth(1).click();
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 ({
page,
}) => {
await mockedDocument(page, {
abilities: {
versions_list: false,
partial_update: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByLabel('Document version panel')).toBeHidden();
});
});

View File

@@ -8,6 +8,10 @@ server {
try_files $uri index.html $uri/ =404;
}
location ~ ^/docs/(.*)/versions/(.*)/$ {
error_page 404 /docs/[id]/versions/[versionId]/;
}
location /docs/ {
error_page 404 /docs/[id]/;
}

View File

@@ -8,6 +8,7 @@ import { WebrtcProvider } from 'y-webrtc';
import { Box } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore } from '../stores';
@@ -17,31 +18,46 @@ import { BlockNoteToolbar } from './BlockNoteToolbar';
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
}
export const BlockNoteEditor = ({ doc }: BlockNoteEditorProps) => {
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const { createProvider, docsStore } = useDocStore();
const provider = docsStore?.[doc.id]?.provider;
const storeId = version?.id || doc.id;
const initialContent = version?.content || doc.content;
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.doc.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
if (!provider) {
createProvider(doc.id, doc.content);
return null;
}
return <BlockNoteContent doc={doc} provider={provider} />;
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
};
interface BlockNoteContentProps {
doc: Doc;
provider: WebrtcProvider;
storeId: string;
}
export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
export const BlockNoteContent = ({
doc,
provider,
storeId,
}: BlockNoteContentProps) => {
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
useSaveDoc(doc.id, provider.doc, doc.abilities.partial_update);
const canSave = doc.abilities.partial_update && !isVersion;
useSaveDoc(doc.id, provider.doc, canSave);
const storedEditor = docsStore?.[doc.id]?.editor;
const storedEditor = docsStore?.[storeId]?.editor;
const editor = useMemo(() => {
if (storedEditor) {
return storedEditor;
@@ -60,8 +76,8 @@ export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
}, [provider, storedEditor, userData?.email]);
useEffect(() => {
setStore(doc.id, { editor });
}, [setStore, doc.id, editor]);
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
return (
<Box
@@ -77,7 +93,7 @@ export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
<BlockNoteView
editor={editor}
formattingToolbar={false}
editable={doc.abilities.partial_update}
editable={doc.abilities.partial_update && !isVersion}
theme="light"
>
<BlockNoteToolbar />

View File

@@ -1,9 +1,18 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card } from '@/components';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import {
Panel,
Versions,
useDocVersion,
} from '@/features/docs/doc-versioning/';
import { BlockNoteEditor } from './BlockNoteEditor';
@@ -12,24 +21,101 @@ interface DocEditorProps {
}
export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { t } = useTranslation();
const isVersion = versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
return (
<>
<DocHeader doc={doc} />
{!doc.abilities.partial_update && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert
type={VariantType.WARNING}
>{`Read only, you don't have the right to update this document.`}</Alert>
<Alert type={VariantType.WARNING}>
{t(`Read only, you don't have the right to update this document.`)}
</Alert>
</Box>
)}
<Card
$margin={{ all: 'big', top: 'none' }}
$padding="big"
$css="flex:1;"
{isVersion && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`You cannot edit document version.`)}
</Alert>
</Box>
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$margin={{ all: 'small', top: 'none' }}
$gap="1rem"
$overflow="auto"
>
<BlockNoteEditor doc={doc} />
</Card>
<Card $padding="big" $css="flex:1;" $overflow="auto">
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
)}
</Card>
{doc.abilities.versions_list && <Panel doc={doc} />}
</Box>
</>
);
};
interface DocVersionEditorProps {
doc: Doc;
versionId: Versions['version_id'];
}
export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
const {
data: version,
isLoading,
isError,
error,
} = useDocVersion({
docId: doc.id,
versionId,
});
const navigate = useNavigate();
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
return null;
}
return (
<Box $margin="large">
<TextErrors
causes={error.cause}
icon={
error.status === 502 ? (
<Text className="material-icons" $theme="danger">
wifi_off
</Text>
) : undefined
}
/>
</Box>
);
}
if (isLoading || !version) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
}
return <BlockNoteEditor doc={doc} version={version} />;
};

View File

@@ -4,7 +4,7 @@ import * as Y from 'yjs';
import { create } from 'zustand';
import { signalingUrl } from '@/core';
import { Base64, Doc } from '@/features/docs/doc-management';
import { Base64 } from '@/features/docs/doc-management';
interface DocStore {
provider: WebrtcProvider;
@@ -13,43 +13,39 @@ interface DocStore {
export interface UseDocStore {
docsStore: {
[docId: Doc['id']]: DocStore;
[storeId: string]: DocStore;
};
createProvider: (docId: Doc['id'], initialDoc: Base64) => WebrtcProvider;
setStore: (docId: Doc['id'], props: Partial<DocStore>) => void;
createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider;
setStore: (storeId: string, props: Partial<DocStore>) => void;
}
const initialState = {
docsStore: {},
};
export const useDocStore = create<UseDocStore>((set, get) => ({
docsStore: initialState.docsStore,
createProvider: (docId: string, initialDoc: Base64) => {
docsStore: {},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: docId,
guid: storeId,
});
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new WebrtcProvider(docId, doc, {
signaling: [signalingUrl(docId)],
const provider = new WebrtcProvider(storeId, doc, {
signaling: [signalingUrl(storeId)],
maxConns: 5,
});
get().setStore(docId, { provider });
get().setStore(storeId, { provider });
return provider;
},
setStore: (docId, props) => {
setStore: (storeId, props) => {
set(({ docsStore }) => {
return {
docsStore: {
...docsStore,
[docId]: {
...docsStore[docId],
[storeId]: {
...docsStore[storeId],
...props,
},
},

View File

@@ -25,7 +25,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
return (
<Card
$margin="big"
$margin="small"
aria-label={t('It is the card information about the document.')}
>
<Box $padding="small" $direction="row" $align="center">

View File

@@ -1 +1,2 @@
export * from './useDocVersions';
export * from './useDocVersion';

View File

@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { VersionList } from './VersionList';
interface PanelProps {
doc: Doc;
}
export const Panel = ({ doc }: PanelProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(true);
const closedOverridingStyles = !isOpen && {
$width: '0',
$maxWidth: '0',
$minWidth: '0',
};
const transition = 'all 0.5s ease-in-out';
return (
<Card
$width="100%"
$maxWidth="20rem"
$position="relative"
$css={`
transition: ${transition};
${
!isOpen &&
`
box-shadow: none;
border: none;
`
}
`}
aria-label={t('Document version panel')}
{...closedOverridingStyles}
>
<IconBG
iconName="menu_open"
aria-label={
isOpen
? t('Close the document version panel')
: t('Open the document version panel')
}
$background="white"
$size="h2"
$zIndex={1}
$css={`
cursor: pointer;
left: ${isOpen ? '0' : '-3.3'}rem;
top: 0.1rem;
transform: rotate(${isOpen ? '180' : '0'}deg);
transition: ${transition};
user-select: none;
`}
$position="absolute"
onClick={() => setIsOpen(!isOpen)}
$radius="2px"
/>
<Box
$overflow="hidden"
$css={`
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
>
<Box
$padding={{ all: 'small' }}
$direction="row"
$align="center"
$justify="center"
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
>
<Text $weight="bold" $size="l" $theme="primary">
{t('VERSIONS')}
</Text>
</Box>
<VersionList doc={doc} />
</Box>
</Card>
);
};

View File

@@ -0,0 +1,65 @@
import { useRouter } from 'next/router';
import React from 'react';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Versions } from '../types';
interface VersionItemProps {
text: string;
link: string;
versionId?: Versions['version_id'];
}
export const VersionItem = ({ versionId, text, link }: VersionItemProps) => {
const { colorsTokens } = useCunninghamTheme();
const {
query: { versionId: currentId },
} = useRouter();
const isActive = versionId === currentId;
return (
<Box
as="li"
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'}
$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']};
}
`}
$hasTransition
$minWidth="13rem"
>
<StyledLink
href={link}
onClick={(e) => {
if (isActive) {
e.preventDefault();
}
}}
>
<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">
description
</Text>
<Text $weight="bold" $theme="primary" $size="m">
{text}
</Text>
</Box>
</Box>
</StyledLink>
</Box>
);
};

View File

@@ -0,0 +1,123 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, InfiniteScroll, Text, TextErrors } from '@/components';
import { Doc } from '@/features/docs/doc-management';
import { useDate } from '@/hook';
import { useDocVersionsInfiniteQuery } from '../api/useDocVersions';
import { Versions } from '../types';
import { VersionItem } from './VersionItem';
interface VersionListStateProps {
isLoading: boolean;
error: APIError<unknown> | null;
versions?: Versions[];
doc: Doc;
}
const VersionListState = ({
isLoading,
error,
versions,
doc,
}: VersionListStateProps) => {
const { t } = useTranslation();
const { formatDate } = useDate();
if (isLoading) {
return (
<Box $align="center" $margin="large">
<Loader />
</Box>
);
}
return (
<>
<VersionItem
text={t('Current version')}
versionId={undefined}
link={`/docs/${doc.id}/`}
/>
{versions?.map((version) => (
<VersionItem
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}`}
/>
))}
{error && (
<Box
$justify="center"
$margin={{ vertical: 'big', horizontal: 'auto' }}
>
<TextErrors
causes={error.cause}
icon={
error.status === 502 ? (
<Text $isMaterialIcon $theme="danger">
wifi_off
</Text>
) : undefined
}
/>
</Box>
)}
</>
);
};
interface VersionListProps {
doc: Doc;
}
export const VersionList = ({ doc }: VersionListProps) => {
const {
data,
error,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useDocVersionsInfiniteQuery({
docId: doc.id,
});
const containerRef = useRef<HTMLDivElement>(null);
const versions = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Versions[]);
}, [data?.pages]);
return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
$padding="none"
$margin={{ top: 'none' }}
role="listbox"
>
<VersionListState
isLoading={isLoading}
error={error}
versions={versions}
doc={doc}
/>
</InfiniteScroll>
</Box>
);
};

View File

@@ -0,0 +1 @@
export * from './Panel';

View File

@@ -0,0 +1,3 @@
export * from './api';
export * from './components';
export * from './types';

View File

@@ -10,4 +10,5 @@ export interface Versions {
export interface Version {
content: Doc['content'];
last_modified: string;
id: string;
}

View File

@@ -1,7 +1,7 @@
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useTranslation } from 'react-i18next';
const format: DateTimeFormatOptions = {
const formatDefault: DateTimeFormatOptions = {
month: '2-digit',
day: '2-digit',
year: 'numeric',
@@ -12,7 +12,10 @@ const format: DateTimeFormatOptions = {
export const useDate = () => {
const { i18n } = useTranslation();
const formatDate = (date: string): string => {
const formatDate = (
date: string,
format: DateTimeFormatOptions = formatDefault,
): string => {
return DateTime.fromISO(date)
.setLocale(i18n.language)
.toLocaleString(format);

View File

@@ -1,15 +1,15 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { Box, Text, TextErrors } from '@/components/';
import { DocEditor } from '@/features/docs/doc-editor';
import { Box, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { DocEditor } from '@/features/docs';
import { useDoc } from '@/features/docs/doc-management';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
export function DocLayout() {
const {
query: { id },
} = useRouter();
@@ -18,14 +18,18 @@ const Page: NextPageWithLayout = () => {
return null;
}
return <Doc id={id} />;
};
return (
<MainLayout>
<DocPage id={id} />
</MainLayout>
);
}
interface DocProps {
id: string;
}
const Doc = ({ id }: DocProps) => {
const DocPage = ({ id }: DocProps) => {
const { data: doc, isLoading, isError, error } = useDoc({ id });
const navigate = useNavigate();
@@ -41,7 +45,7 @@ const Doc = ({ id }: DocProps) => {
causes={error.cause}
icon={
error.status === 502 ? (
<Text className="material-icons" $theme="danger">
<Text $isMaterialIcon $theme="danger">
wifi_off
</Text>
) : undefined
@@ -62,8 +66,12 @@ const Doc = ({ id }: DocProps) => {
return <DocEditor doc={doc} />;
};
Page.getLayout = function getLayout(page: ReactElement) {
return <MainLayout>{page}</MainLayout>;
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;

View File

@@ -0,0 +1,13 @@
import { NextPageWithLayout } from '@/types/next';
import { DocLayout } from '../index';
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;

View File

@@ -0,0 +1,13 @@
import { NextPageWithLayout } from '@/types/next';
import { DocLayout } from '../index';
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;

View File

@@ -31,3 +31,13 @@ main ::-webkit-scrollbar-thumb:hover,
.ReactModalPortal ::-webkit-scrollbar-thumb:hover {
background-color: #a8bbbf;
}
.btn-unstyled {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}