From a9383212a37f9e1583895e98251f7de7adad59d9 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 17 Jul 2024 15:30:52 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20feature=20versioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We can now see the version of the document and navigate to different versions. Collaboration is possible per version. --- CHANGELOG.md | 5 + .../apps/e2e/__tests__/app-impress/common.ts | 31 +++++ .../__tests__/app-impress/doc-editor.spec.ts | 41 ++---- .../__tests__/app-impress/doc-header.spec.ts | 87 +++++-------- .../__tests__/app-impress/doc-tools.spec.ts | 119 +++++------------ .../__tests__/app-impress/doc-version.spec.ts | 69 ++++++++++ src/frontend/apps/impress/conf/default.conf | 4 + .../doc-editor/components/BlockNoteEditor.tsx | 36 +++-- .../docs/doc-editor/components/DocEditor.tsx | 108 +++++++++++++-- .../docs/doc-editor/stores/useDocStore.tsx | 30 ++--- .../docs/doc-header/components/DocHeader.tsx | 2 +- .../features/docs/doc-versioning/api/index.ts | 1 + .../docs/doc-versioning/components/Panel.tsx | 90 +++++++++++++ .../doc-versioning/components/VersionItem.tsx | 65 +++++++++ .../doc-versioning/components/VersionList.tsx | 123 ++++++++++++++++++ .../docs/doc-versioning/components/index.ts | 1 + .../src/features/docs/doc-versioning/index.ts | 3 + .../src/features/docs/doc-versioning/types.ts | 1 + .../apps/impress/src/hook/useDate.tsx | 7 +- .../pages/docs/{[id].tsx => [id]/index.tsx} | 28 ++-- .../pages/docs/[id]/versions/[versionId].tsx | 13 ++ .../src/pages/docs/[id]/versions/index.tsx | 13 ++ .../apps/impress/src/pages/globals.css | 10 ++ 23 files changed, 671 insertions(+), 216 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-versioning/components/Panel.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-versioning/components/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-versioning/index.ts rename src/frontend/apps/impress/src/pages/docs/{[id].tsx => [id]/index.tsx} (73%) create mode 100644 src/frontend/apps/impress/src/pages/docs/[id]/versions/[versionId].tsx create mode 100644 src/frontend/apps/impress/src/pages/docs/[id]/versions/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 708cde87..9288f36f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index e620e735..8ca94fc0 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -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(); + } + }); +}; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 4654945c..d27cb052 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -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); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index 0da98bd5..eb6dfa51 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -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); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tools.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tools.spec.ts index 96184095..eb3096ca 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tools.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tools.spec.ts @@ -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); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts new file mode 100644 index 00000000..bc1f28d0 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts @@ -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(); + }); +}); diff --git a/src/frontend/apps/impress/conf/default.conf b/src/frontend/apps/impress/conf/default.conf index e2ab0b11..cb639442 100644 --- a/src/frontend/apps/impress/conf/default.conf +++ b/src/frontend/apps/impress/conf/default.conf @@ -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]/; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index da092f09..b1447397 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -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 ; + return ; }; 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 ( { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 7f134879..e8660739 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -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 ( <> {!doc.abilities.partial_update && ( - {`Read only, you don't have the right to update this document.`} + + {t(`Read only, you don't have the right to update this document.`)} + )} - + + {t(`You cannot edit document version.`)} + + + )} + - - + + {isVersion ? ( + + ) : ( + + )} + + {doc.abilities.versions_list && } + ); }; + +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 ( + + + wifi_off + + ) : undefined + } + /> + + ); + } + + if (isLoading || !version) { + return ( + + + + ); + } + + return ; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx index 4784ac12..db877db8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/stores/useDocStore.tsx @@ -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) => void; + createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider; + setStore: (storeId: string, props: Partial) => void; } -const initialState = { - docsStore: {}, -}; - export const useDocStore = create((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, }, }, diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index 4a842548..68582013 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -25,7 +25,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { return ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-versioning/api/index.ts index fad889b3..d300be2c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/api/index.ts @@ -1 +1,2 @@ export * from './useDocVersions'; +export * from './useDocVersion'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/Panel.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/Panel.tsx new file mode 100644 index 00000000..a370760b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/Panel.tsx @@ -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 ( + + setIsOpen(!isOpen)} + $radius="2px" + /> + + + + {t('VERSIONS')} + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx new file mode 100644 index 00000000..6dfea4c8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionItem.tsx @@ -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 ( + + { + if (isActive) { + e.preventDefault(); + } + }} + > + + + + description + + + {text} + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx new file mode 100644 index 00000000..9adea71d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/VersionList.tsx @@ -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 | null; + versions?: Versions[]; + doc: Doc; +} + +const VersionListState = ({ + isLoading, + error, + versions, + doc, +}: VersionListStateProps) => { + const { t } = useTranslation(); + const { formatDate } = useDate(); + + if (isLoading) { + return ( + + + + ); + } + + return ( + <> + + {versions?.map((version) => ( + + ))} + {error && ( + + + wifi_off + + ) : undefined + } + /> + + )} + + ); +}; + +interface VersionListProps { + doc: Doc; +} + +export const VersionList = ({ doc }: VersionListProps) => { + const { + data, + error, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useDocVersionsInfiniteQuery({ + docId: doc.id, + }); + const containerRef = useRef(null); + const versions = useMemo(() => { + return data?.pages.reduce((acc, page) => { + return acc.concat(page.results); + }, [] as Versions[]); + }, [data?.pages]); + + return ( + + { + void fetchNextPage(); + }} + scrollContainer={containerRef.current} + as="ul" + $padding="none" + $margin={{ top: 'none' }} + role="listbox" + > + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/index.ts new file mode 100644 index 00000000..8960d84f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/index.ts @@ -0,0 +1 @@ +export * from './Panel'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/index.ts b/src/frontend/apps/impress/src/features/docs/doc-versioning/index.ts new file mode 100644 index 00000000..314dad0c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './components'; +export * from './types'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/types.ts b/src/frontend/apps/impress/src/features/docs/doc-versioning/types.ts index 4c60b988..d6246c78 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/types.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/types.ts @@ -10,4 +10,5 @@ export interface Versions { export interface Version { content: Doc['content']; last_modified: string; + id: string; } diff --git a/src/frontend/apps/impress/src/hook/useDate.tsx b/src/frontend/apps/impress/src/hook/useDate.tsx index bfe2bd4c..5b1b78be 100644 --- a/src/frontend/apps/impress/src/hook/useDate.tsx +++ b/src/frontend/apps/impress/src/hook/useDate.tsx @@ -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); diff --git a/src/frontend/apps/impress/src/pages/docs/[id].tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx similarity index 73% rename from src/frontend/apps/impress/src/pages/docs/[id].tsx rename to src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index c3164be3..72c200d0 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id].tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -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 ; -}; + return ( + + + + ); +} 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 ? ( - + wifi_off ) : undefined @@ -62,8 +66,12 @@ const Doc = ({ id }: DocProps) => { return ; }; -Page.getLayout = function getLayout(page: ReactElement) { - return {page}; +const Page: NextPageWithLayout = () => { + return null; +}; + +Page.getLayout = function getLayout() { + return ; }; export default Page; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/versions/[versionId].tsx b/src/frontend/apps/impress/src/pages/docs/[id]/versions/[versionId].tsx new file mode 100644 index 00000000..0eed2ba3 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/docs/[id]/versions/[versionId].tsx @@ -0,0 +1,13 @@ +import { NextPageWithLayout } from '@/types/next'; + +import { DocLayout } from '../index'; + +const Page: NextPageWithLayout = () => { + return null; +}; + +Page.getLayout = function getLayout() { + return ; +}; + +export default Page; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/versions/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/versions/index.tsx new file mode 100644 index 00000000..0eed2ba3 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/docs/[id]/versions/index.tsx @@ -0,0 +1,13 @@ +import { NextPageWithLayout } from '@/types/next'; + +import { DocLayout } from '../index'; + +const Page: NextPageWithLayout = () => { + return null; +}; + +Page.getLayout = function getLayout() { + return ; +}; + +export default Page; diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index 20e745a5..930d8359 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -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; +}