✨(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]/;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './useDocVersions';
|
||||
export * from './useDocVersion';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Panel';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './types';
|
||||
@@ -10,4 +10,5 @@ export interface Versions {
|
||||
export interface Version {
|
||||
content: Doc['content'];
|
||||
last_modified: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user