From eb71028f6b21f136896dfc821ef4f8191e42665e Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 21 Oct 2025 17:28:25 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=B8(frontend)=20separate=20viewers=20f?= =?UTF-8?q?rom=20editors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 3 +- .../app-impress/doc-visibility.spec.ts | 114 ++++++++++++------ .../e2e/__tests__/app-impress/utils-common.ts | 1 + .../doc-editor/components/BlockNoteEditor.tsx | 61 ++++------ .../docs/doc-editor/components/DocEditor.tsx | 39 +++++- .../hook/__tests__/useSaveDoc.test.tsx | 39 +----- .../docs/doc-editor/hook/useSaveDoc.tsx | 12 +- .../components/DocVersionEditor.tsx | 6 +- 8 files changed, 152 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2cd9de6..b3c1913e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts index 24cf4bfb..88881e43 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -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(); }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index fdfea088..6377c07e 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -277,6 +277,7 @@ export const expectLoginPage = async (page: Page) => ).toBeVisible({ timeout: 10000, }); + // language helper export const TestLanguage = { English: { 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 3b6a67ad..9423cbb1 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 @@ -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 ( - + <> {errorAttachment && ( { editor={editor} formattingToolbar={false} slashMenu={false} - editable={!readOnly} theme="light" > - + ); }; -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 ( - - - + ); }; 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 860d424e..1ec11e91 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 @@ -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" > - {docEditor} + + {docEditor} + @@ -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) => { )} } - docEditor={} + docEditor={ + readOnly ? ( + + ) : ( + + ) + } + isDeletedDoc={!!doc.deleted_at} + readOnly={readOnly} /> ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx index 30f62d29..e532c804 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -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, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index 57656a63..d32648ec 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -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(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx index 652a61ee..a7574a44 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx @@ -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 ( } - docEditor={} + docEditor={} + isDeletedDoc={false} + readOnly={true} /> ); };