🚸(frontend) separate viewers from editors

We are now totally separating the viewers with
the editors. We will not load the provider
when we are in viewer mode, meaning the
viewers will not be aware of other users and
will not show their cursors anymore.
We still get the document updates in real-time.
This commit is contained in:
Anthony LC
2025-10-21 17:28:25 +02:00
parent 39c22b074d
commit eb71028f6b
8 changed files with 152 additions and 123 deletions

View File

@@ -10,16 +10,17 @@ and this project adheres to
- ✨(frontend) create skeleton component for DocEditor #1491 - ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381 - ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
### Changed ### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375 - ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length - ♻️(backend) increase user short_name field length
- 🚸(frontend) separate viewers from editors #1509
### Fixed ### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479 - 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(frontend) show full nested doc names with ajustable bar #1456
- 🐛(backend) fix trashbin list - 🐛(backend) fix trashbin list
- ♿(frontend) improve accessibility: - ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516 - ♿(frontend) remove empty alt on logo due to Axe a11y error #1516

View File

@@ -7,7 +7,7 @@ import {
keyCloakSignIn, keyCloakSignIn,
verifyDocName, verifyDocName,
} from './utils-common'; } from './utils-common';
import { writeInEditor } from './utils-editor'; import { getEditor, writeInEditor } from './utils-editor';
import { addNewMember, connectOtherUserToDoc } from './utils-share'; import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages'; import { createRootSubPage } from './utils-sub-pages';
@@ -182,15 +182,14 @@ test.describe('Doc Visibility: Restricted', () => {
}); });
test.describe('Doc Visibility: Public', () => { test.describe('Doc Visibility: Public', () => {
test.use({ storageState: { cookies: [], origins: [] } }); test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('It checks a public doc in read only mode', async ({ test('It checks a public doc in read only mode', async ({
page, page,
browserName, browserName,
}) => { }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc( const [docTitle] = await createDoc(
page, page,
'Public read only', 'Public read only',
@@ -200,6 +199,8 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle); await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Viewonly' });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility'); const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click(); await selectVisibility.click();
@@ -241,49 +242,63 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('search-docs-button')).toBeVisible(); await expect(page.getByTestId('search-docs-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible(); await expect(page.getByTestId('new-doc-button')).toBeVisible();
const urlDoc = page.url(); const docUrl = page.url();
await page const { otherPage, cleanup } = await connectOtherUserToDoc({
.getByRole('button', { browserName,
name: 'Logout', docUrl,
}) withoutSignIn: true,
.click(); });
await expectLoginPage(page); await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await page.goto(urlDoc); await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await expect(
await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); otherPage.getByRole('button', { name: 'Share' }),
await expect(page.getByTestId('search-docs-button')).toBeHidden(); ).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeHidden(); const card = otherPage.getByLabel('It is the card information');
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible(); await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible(); await expect(card.getByText('Reader')).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click(); const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'false');
await expect(otherEditor.getByText('Hello Public Viewonly')).toBeVisible();
// Cursor and selection of the anonymous user are not visible
await otherEditor.getByText('Hello Public').selectText();
await expect( await expect(
page.getByText( page.locator('.collaboration-cursor-custom__base'),
).toBeHidden();
await expect(page.locator('.ProseMirror-yjs-selection')).toBeHidden();
// Can still see changes made by others
await writeInEditor({ page, text: 'Can you see it ?' });
await expect(otherEditor.getByText('Can you see it ?')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.', 'You can view this document but need additional access to see its members or modify settings.',
), ),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page.getByRole('button', { name: 'Request access' }), otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden(); ).toBeHidden();
await cleanup();
}); });
test('It checks a public doc in editable mode', async ({ test('It checks a public doc in editable mode', async ({
page, page,
browserName, browserName,
}) => { }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1); const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle); await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Editable' });
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility'); const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click(); await selectVisibility.click();
@@ -317,20 +332,47 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }), cardContainer.getByText('Public document', { exact: true }),
).toBeVisible(); ).toBeVisible();
const urlDoc = page.url(); const docUrl = page.url();
await page const { otherPage, cleanup } = await connectOtherUserToDoc({
.getByRole('button', { browserName,
name: 'Logout', docUrl,
}) withoutSignIn: true,
.click(); docTitle,
});
await expectLoginPage(page); await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
await page.goto(urlDoc); const otherEditor = await getEditor({ page: otherPage });
await expect(otherEditor).toHaveAttribute('contenteditable', 'true');
await expect(otherEditor.getByText('Hello Public Editable')).toBeVisible();
await verifyDocName(page, docTitle); // We can see the collaboration cursor of the anonymous user
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); await otherEditor.getByText('Hello Public').selectText();
await expect(
page.locator('.collaboration-cursor-custom__base').getByText('Anonymous'),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Share' }),
).toBeVisible();
const card = otherPage.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Editor')).toBeVisible();
await otherPage.getByRole('button', { name: 'Share' }).click();
await expect(
otherPage.getByText(
'You can view this document but need additional access to see its members or modify settings.',
),
).toBeVisible();
await expect(
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
}); });
}); });

View File

@@ -277,6 +277,7 @@ export const expectLoginPage = async (page: Page) =>
).toBeVisible({ ).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
// language helper // language helper
export const TestLanguage = { export const TestLanguage = {
English: { English: {

View File

@@ -17,11 +17,7 @@ import { useTranslation } from 'react-i18next';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { Box, TextErrors } from '@/components'; import { Box, TextErrors } from '@/components';
import { import { Doc, useProviderStore } from '@/docs/doc-management';
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { useAuth } from '@/features/auth'; import { useAuth } from '@/features/auth';
import { import {
@@ -32,7 +28,6 @@ import {
useUploadStatus, useUploadStatus,
} from '../hook'; } from '../hook';
import { useEditorStore } from '../stores'; import { useEditorStore } from '../stores';
import { cssEditor } from '../styles';
import { DocsBlockNoteEditor } from '../types'; import { DocsBlockNoteEditor } from '../types';
import { randomColor } from '../utils'; import { randomColor } from '../utils';
@@ -85,25 +80,19 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isSynced: isConnectedToCollabServer } = useProviderStore(); const { isSynced: isConnectedToCollabServer } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc); useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const isDeletedDoc = !!doc.deleted_at;
useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage; const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id); const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = readOnly const collabName = user?.full_name || user?.email || t('Anonymous');
? 'Reader'
: user?.full_name || user?.email || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const editor: DocsBlockNoteEditor = useCreateBlockNote( const editor: DocsBlockNoteEditor = useCreateBlockNote(
{ {
collaboration: { collaboration: {
provider, provider: provider,
fragment: provider.document.getXmlFragment('document-store'), fragment: provider.document.getXmlFragment('document-store'),
user: { user: {
name: collabName, name: collabName,
@@ -117,10 +106,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
renderCursor: (user: { color: string; name: string }) => { renderCursor: (user: { color: string; name: string }) => {
const cursorElement = document.createElement('span'); const cursorElement = document.createElement('span');
if (user.name === 'Reader') {
return cursorElement;
}
cursorElement.classList.add('collaboration-cursor-custom__base'); cursorElement.classList.add('collaboration-cursor-custom__base');
const caretElement = document.createElement('span'); const caretElement = document.createElement('span');
caretElement.classList.add('collaboration-cursor-custom__caret'); caretElement.classList.add('collaboration-cursor-custom__caret');
@@ -181,12 +166,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]); }, [setEditor, editor]);
return ( return (
<Box <>
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly, isDeletedDoc)}
className="--docs--editor-container"
>
{errorAttachment && ( {errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}> <Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors <TextErrors
@@ -201,24 +181,21 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor} editor={editor}
formattingToolbar={false} formattingToolbar={false}
slashMenu={false} slashMenu={false}
editable={!readOnly}
theme="light" theme="light"
> >
<BlockNoteSuggestionMenu /> <BlockNoteSuggestionMenu />
<BlockNoteToolbar /> <BlockNoteToolbar />
</BlockNoteView> </BlockNoteView>
</Box> </>
); );
}; };
interface BlockNoteEditorVersionProps { interface BlockNoteReaderProps {
initialContent: Y.XmlFragment; initialContent: Y.XmlFragment;
} }
export const BlockNoteEditorVersion = ({ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
initialContent, const { setEditor } = useEditorStore();
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
const editor = useCreateBlockNote( const editor = useCreateBlockNote(
{ {
collaboration: { collaboration: {
@@ -234,9 +211,23 @@ export const BlockNoteEditorVersion = ({
[initialContent], [initialContent],
); );
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useHeadings(editor);
return ( return (
<Box $css={cssEditor(readOnly, true)} className="--docs--editor-container"> <BlockNoteView
<BlockNoteView editor={editor} editable={!readOnly} theme="light" /> editor={editor}
</Box> editable={false}
theme="light"
formattingToolbar={false}
slashMenu={false}
/>
); );
}; };

View File

@@ -3,21 +3,31 @@ import { css } from 'styled-components';
import { Box, Loading } from '@/components'; import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/'; import { DocHeader } from '@/docs/doc-header/';
import { Doc, useProviderStore } from '@/docs/doc-management'; import {
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/'; import { TableContent } from '@/docs/doc-table-content/';
import { useSkeletonStore } from '@/features/skeletons'; import { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor } from './BlockNoteEditor'; import { cssEditor } from '../styles';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
interface DocEditorContainerProps { interface DocEditorContainerProps {
docHeader: React.ReactNode; docHeader: React.ReactNode;
docEditor: React.ReactNode; docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
} }
export const DocEditorContainer = ({ export const DocEditorContainer = ({
docHeader, docHeader,
docEditor, docEditor,
isDeletedDoc,
readOnly,
}: DocEditorContainerProps) => { }: DocEditorContainerProps) => {
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
@@ -44,7 +54,14 @@ export const DocEditorContainer = ({
className="--docs--doc-editor-content" className="--docs--doc-editor-content"
> >
<Box $css="flex:1;" $position="relative" $width="100%"> <Box $css="flex:1;" $position="relative" $width="100%">
{docEditor} <Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly, isDeletedDoc)}
className="--docs--editor-container"
>
{docEditor}
</Box>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@@ -59,6 +76,8 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => { export const DocEditor = ({ doc }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
const { provider, isReady } = useProviderStore(); const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const { setIsSkeletonVisible } = useSkeletonStore(); const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider; const isProviderReady = isReady && provider;
@@ -87,7 +106,19 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
)} )}
<DocEditorContainer <DocEditorContainer
docHeader={<DocHeader doc={doc} />} docHeader={<DocHeader doc={doc} />}
docEditor={<BlockNoteEditor doc={doc} provider={provider} />} docEditor={
readOnly ? (
<BlockNoteReader
initialContent={provider.document.getXmlFragment(
'document-store',
)}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)
}
isDeletedDoc={!!doc.deleted_at}
readOnly={readOnly}
/> />
</> </>
); );

View File

@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true, true), { renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -62,37 +62,6 @@ describe('useSaveDoc', () => {
addEventListenerSpy.mockRestore(); addEventListenerSpy.mockRestore();
}); });
it('should not save when canSave is false', () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
// Since canSave is false, no API call should be made
expect(fetchMock.calls().length).toBe(0);
vi.useRealTimers();
});
it('should save when there are local changes', async () => { it('should save when there are local changes', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const yDoc = new Y.Doc(); const yDoc = new Y.Doc();
@@ -106,7 +75,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, true, true), { renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -143,7 +112,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, true, true), { renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -163,7 +132,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id'; const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), { const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });

View File

@@ -13,7 +13,6 @@ const SAVE_INTERVAL = 60000;
export const useSaveDoc = ( export const useSaveDoc = (
docId: string, docId: string,
yDoc: Y.Doc, yDoc: Y.Doc,
canSave: boolean,
isConnectedToCollabServer: boolean, isConnectedToCollabServer: boolean,
) => { ) => {
const { mutate: updateDoc } = useUpdateDoc({ const { mutate: updateDoc } = useUpdateDoc({
@@ -47,7 +46,7 @@ export const useSaveDoc = (
}, [yDoc]); }, [yDoc]);
const saveDoc = useCallback(() => { const saveDoc = useCallback(() => {
if (!canSave || !isLocalChange) { if (!isLocalChange) {
return false; return false;
} }
@@ -58,14 +57,7 @@ export const useSaveDoc = (
}); });
return true; return true;
}, [ }, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
canSave,
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
]);
const router = useRouter(); const router = useRouter();

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components'; import { Box, Text, TextErrors } from '@/components';
import { BlockNoteEditorVersion, DocEditorContainer } from '@/docs/doc-editor/'; import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management'; import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
import { Versions, useDocVersion } from '@/docs/doc-versioning/'; import { Versions, useDocVersion } from '@/docs/doc-versioning/';
@@ -77,7 +77,9 @@ export const DocVersionEditor = ({
return ( return (
<DocEditorContainer <DocEditorContainer
docHeader={<DocVersionHeader />} docHeader={<DocVersionHeader />}
docEditor={<BlockNoteEditorVersion initialContent={initialContent} />} docEditor={<BlockNoteReader initialContent={initialContent} />}
isDeletedDoc={false}
readOnly={true}
/> />
); );
}; };