🚸(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) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
### Changed
- ♻️(frontend) adapt custom blocks to new implementation #1375
- ♻️(backend) increase user short_name field length
- 🚸(frontend) separate viewers from editors #1509
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(frontend) show full nested doc names with ajustable bar #1456
- 🐛(backend) fix trashbin list
- ♿(frontend) improve accessibility:
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516

View File

@@ -7,7 +7,7 @@ import {
keyCloakSignIn,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import { getEditor, writeInEditor } from './utils-editor';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -182,15 +182,14 @@ test.describe('Doc Visibility: Restricted', () => {
});
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 ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(
page,
'Public read only',
@@ -200,6 +199,8 @@ test.describe('Doc Visibility: Public', () => {
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Viewonly' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -241,49 +242,63 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('search-docs-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
const urlDoc = page.url();
const docUrl = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
});
await expectLoginPage(page);
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByTestId('search-docs-button')).toBeHidden();
await expect(page.getByTestId('new-doc-button')).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
const card = page.getByLabel('It is the card information');
await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
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('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(
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.',
),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Request access' }),
otherPage.getByRole('button', { name: 'Request access' }),
).toBeHidden();
await cleanup();
});
test('It checks a public doc in editable mode', async ({
page,
browserName,
}) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await writeInEditor({ page, text: 'Hello Public Editable' });
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
@@ -317,20 +332,47 @@ test.describe('Doc Visibility: Public', () => {
cardContainer.getByText('Public document', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
const docUrl = page.url();
await page
.getByRole('button', {
name: 'Logout',
})
.click();
const { otherPage, cleanup } = await connectOtherUserToDoc({
browserName,
docUrl,
withoutSignIn: true,
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);
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
// We can see the collaboration cursor of the anonymous user
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({
timeout: 10000,
});
// language helper
export const TestLanguage = {
English: {

View File

@@ -17,11 +17,7 @@ import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import {
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import {
@@ -32,7 +28,6 @@ import {
useUploadStatus,
} from '../hook';
import { useEditorStore } from '../stores';
import { cssEditor } from '../styles';
import { DocsBlockNoteEditor } from '../types';
import { randomColor } from '../utils';
@@ -85,25 +80,19 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { t } = useTranslation();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const isDeletedDoc = !!doc.deleted_at;
useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = readOnly
? 'Reader'
: user?.full_name || user?.email || t('Anonymous');
const collabName = user?.full_name || user?.email || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider,
provider: provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: collabName,
@@ -117,10 +106,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
renderCursor: (user: { color: string; name: string }) => {
const cursorElement = document.createElement('span');
if (user.name === 'Reader') {
return cursorElement;
}
cursorElement.classList.add('collaboration-cursor-custom__base');
const caretElement = document.createElement('span');
caretElement.classList.add('collaboration-cursor-custom__caret');
@@ -181,12 +166,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly, isDeletedDoc)}
className="--docs--editor-container"
>
<>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -201,24 +181,21 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
slashMenu={false}
editable={!readOnly}
theme="light"
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
</BlockNoteView>
</Box>
</>
);
};
interface BlockNoteEditorVersionProps {
interface BlockNoteReaderProps {
initialContent: Y.XmlFragment;
}
export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
const { setEditor } = useEditorStore();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -234,9 +211,23 @@ export const BlockNoteEditorVersion = ({
[initialContent],
);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useHeadings(editor);
return (
<Box $css={cssEditor(readOnly, true)} className="--docs--editor-container">
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
</Box>
<BlockNoteView
editor={editor}
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 { 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 { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor } from './BlockNoteEditor';
import { cssEditor } from '../styles';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
interface DocEditorContainerProps {
docHeader: React.ReactNode;
docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
}
export const DocEditorContainer = ({
docHeader,
docEditor,
isDeletedDoc,
readOnly,
}: DocEditorContainerProps) => {
const { isDesktop } = useResponsiveStore();
@@ -44,7 +54,14 @@ export const DocEditorContainer = ({
className="--docs--doc-editor-content"
>
<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>
@@ -59,6 +76,8 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
@@ -87,7 +106,19 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
)}
<DocEditorContainer
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');
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -62,37 +62,6 @@ describe('useSaveDoc', () => {
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 () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
@@ -106,7 +75,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -143,7 +112,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -163,7 +132,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});

View File

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

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs';
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 { Versions, useDocVersion } from '@/docs/doc-versioning/';
@@ -77,7 +77,9 @@ export const DocVersionEditor = ({
return (
<DocEditorContainer
docHeader={<DocVersionHeader />}
docEditor={<BlockNoteEditorVersion initialContent={initialContent} />}
docEditor={<BlockNoteReader initialContent={initialContent} />}
isDeletedDoc={false}
readOnly={true}
/>
);
};