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} /> ); };