From 55979e43703a32ffd0225439165e4189ef9859cb Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 3 Jul 2025 20:38:50 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=82(frontend)=20block=20edition=20only?= =?UTF-8?q?=20when=20not=20alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We added a system to know if a user is alone on a document or not. We adapt the frontend to block the edition only when the user is not alone on the document. --- .../__tests__/app-impress/doc-editor.spec.ts | 136 ++++++++++++++---- .../__tests__/app-impress/doc-header.spec.ts | 60 +++----- .../doc-editor/components/BlockNoteEditor.tsx | 3 +- .../hook/__tests__/useSaveDoc.test.tsx | 10 +- .../docs/doc-editor/hook/useSaveDoc.tsx | 17 ++- .../doc-header/components/AlertNetwork.tsx | 37 +++-- .../docs/doc-management/api/useDocCanEdit.tsx | 32 +++++ .../docs/doc-management/api/useUpdateDoc.tsx | 37 +++-- .../hooks/useIsCollaborativeEditable.tsx | 36 ++--- 9 files changed, 252 insertions(+), 116 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index f64139b7..12da91e8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -1,14 +1,13 @@ import path from 'path'; -import { expect, test } from '@playwright/test'; +import { chromium, expect, test } from '@playwright/test'; import cs from 'convert-stream'; import { - CONFIG, - addNewMember, createDoc, goToGridDoc, mockedDocument, + overrideConfig, verifyDocName, } from './common'; @@ -522,52 +521,141 @@ test.describe('Doc Editor', () => { test('it checks block editing when not connected to collab server', async ({ page, + browserName, }) => { - await page.route('**/api/v1.0/config/', async (route) => { - const request = route.request(); - if (request.method().includes('GET')) { - await route.fulfill({ - json: { - ...CONFIG, - COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', - COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, - }, - }); - } else { - await route.continue(); - } + /** + * The good port is 4444, but we want to simulate a not connected + * collaborative server. + * So we use a port that is not used by the collaborative server. + * The server will not be able to connect to the collaborative server. + */ + await overrideConfig(page, { + COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', }); await page.goto('/'); - void page - .getByRole('button', { - name: 'New doc', - }) - .click(); + const [title] = await createDoc(page, 'editing-blocking', browserName, 1); const card = page.getByLabel('It is the card information'); await expect( - card.getByText('Your network do not allow you to edit'), + card.getByText('Others are editing. Your network prevent changes.'), ).toBeHidden(); const editor = page.locator('.ProseMirror'); await expect(editor).toHaveAttribute('contenteditable', 'true'); + let responseCanEditPromise = page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + await page.getByRole('button', { name: 'Share' }).click(); - await addNewMember(page, 0, 'Editor', 'impress'); + await page.getByLabel('Visibility', { exact: true }).click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByLabel('Visibility mode').click(); + await page.getByRole('menuitem', { name: 'Editing' }).click(); // Close the modal await page.getByRole('button', { name: 'close' }).first().click(); + let responseCanEdit = await responseCanEditPromise; + expect(responseCanEdit.ok()).toBeTruthy(); + let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeTruthy(); + + const urlDoc = page.url(); + + /** + * We open another browser that will connect to the collaborative server + * and will block the current browser to edit the doc. + */ + const otherBrowser = await chromium.launch({ headless: true }); + const otherContext = await otherBrowser.newContext({ + locale: 'en-US', + timezoneId: 'Europe/Paris', + permissions: [], + storageState: { + cookies: [], + origins: [], + }, + }); + const otherPage = await otherContext.newPage(); + + const webSocketPromise = otherPage.waitForEvent( + 'websocket', + (webSocket) => { + return webSocket + .url() + .includes('ws://localhost:4444/collaboration/ws/?room='); + }, + ); + + await otherPage.goto(urlDoc); + + const webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + 'ws://localhost:4444/collaboration/ws/?room=', + ); + + await verifyDocName(otherPage, title); + + await page.reload(); + + responseCanEditPromise = page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + + responseCanEdit = await responseCanEditPromise; + expect(responseCanEdit.ok()).toBeTruthy(); + + jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeFalsy(); + await expect( - card.getByText('Your network do not allow you to edit'), + card.getByText('Others are editing. Your network prevent changes.'), ).toBeVisible({ timeout: 10000, }); await expect(editor).toHaveAttribute('contenteditable', 'false'); + + await page.getByRole('button', { name: 'Share' }).click(); + + await page.getByLabel('Visibility mode').click(); + await page.getByRole('menuitem', { name: 'Reading' }).click(); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + await page.reload(); + + responseCanEditPromise = page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + + responseCanEdit = await responseCanEditPromise; + expect(responseCanEdit.ok()).toBeTruthy(); + + jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeTruthy(); + + await expect( + card.getByText('Others are editing. Your network prevent changes.'), + ).toBeHidden(); }); test('it checks if callout custom block', async ({ page, browserName }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index 0eec2455..feaf18a6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -15,49 +15,11 @@ test.beforeEach(async ({ page }) => { }); test.describe('Doc Header', () => { - test('it checks the element are correctly displayed', async ({ page }) => { - await mockedDocument(page, { - accesses: [ - { - id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', - role: 'owner', - user: { - email: 'super@owner.com', - full_name: 'Super Owner', - }, - }, - { - 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 - link_configuration: true, - versions_destroy: true, - versions_list: true, - versions_retrieve: true, - accesses_manage: true, - accesses_view: true, - update: true, - partial_update: true, - retrieve: true, - }, - link_reach: 'public', - created_at: '2021-09-01T09:00:00Z', - }); - - await goToGridDoc(page); + test('it checks the element are correctly displayed', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-update', browserName, 1); const card = page.getByLabel( 'It is the card information about the document.', @@ -66,6 +28,18 @@ test.describe('Doc Header', () => { const docTitle = card.getByRole('textbox', { name: 'doc title input' }); await expect(docTitle).toBeVisible(); + await page.getByRole('button', { name: 'Share' }).click(); + + await page.getByLabel('Visibility', { exact: true }).click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await page.getByRole('button', { name: 'close' }).first().click(); + await expect(card.getByText('Public document')).toBeVisible(); await expect(card.getByText('Owner ยท')).toBeVisible(); 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 9a486b58..fe419b80 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 @@ -50,9 +50,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { t } = useTranslation(); const { isEditable, isLoading } = useIsCollaborativeEditable(doc); + const isConnectedToCollabServer = provider.isSynced; const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; - useSaveDoc(doc.id, provider.document, !readOnly); + useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer); const { i18n } = useTranslation(); const lang = i18n.resolvedLanguage; 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 0a20001d..a2f41938 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 @@ -41,7 +41,7 @@ describe('useSaveDoc', () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, true), { wrapper: AppWrapper, }); @@ -73,7 +73,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, false), { + renderHook(() => useSaveDoc(docId, yDoc, false, true), { wrapper: AppWrapper, }); @@ -107,7 +107,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, true), { wrapper: AppWrapper, }); @@ -143,7 +143,7 @@ describe('useSaveDoc', () => { }), }); - renderHook(() => useSaveDoc(docId, yDoc, true), { + renderHook(() => useSaveDoc(docId, yDoc, true, true), { wrapper: AppWrapper, }); @@ -164,7 +164,7 @@ describe('useSaveDoc', () => { const docId = 'test-doc-id'; const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), { + const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, 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 274adcff..c6ca782e 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 @@ -10,7 +10,12 @@ import { toBase64 } from '../utils'; const SAVE_INTERVAL = 60000; -const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { +const useSaveDoc = ( + docId: string, + yDoc: Y.Doc, + canSave: boolean, + isConnectedToCollabServer: boolean, +) => { const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_LIST_DOC_VERSIONS], onSuccess: () => { @@ -49,10 +54,18 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { updateDoc({ id: docId, content: toBase64(Y.encodeStateAsUpdate(yDoc)), + websocket: isConnectedToCollabServer, }); return true; - }, [canSave, yDoc, docId, isLocalChange, updateDoc]); + }, [ + canSave, + isLocalChange, + updateDoc, + docId, + yDoc, + isConnectedToCollabServer, + ]); const router = useRouter(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx index b070356c..dd9cb3eb 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx @@ -32,7 +32,7 @@ export const AlertNetwork = () => { - {t('Your network do not allow you to edit')} + {t('Others are editing. Your network prevent changes.')} { $margin={{ top: 'auto' }} /> - {t('Know more')} + {t('Learn more')} @@ -74,8 +74,8 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => { onClose={() => onClose()} rightActions={ <> - } @@ -88,24 +88,39 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => { $align="flex-start" $variation="1000" > - {t("Why can't I edit?")} + {t("Why you can't edit the document?")} } > {t( - 'The network configuration of your workstation or internet connection does not allow editing shared documents.', + 'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.', )} - - {t( - 'Docs use WebSockets to enable real-time editing. These communication channels allow instant and bidirectional exchanges between your browser and our servers. To access collaborative editing, please contact your IT department to enable WebSockets.', - )} + + {t("This means you can't edit until others leave.")}{' '} + + {t( + 'If you wish to be able to co-edit in real-time, contact your Information Systems Security Manager about allowing WebSockets.', + )} + diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx new file mode 100644 index 00000000..8847ef94 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx @@ -0,0 +1,32 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +type DocCanEditResponse = { can_edit: boolean }; + +export const docCanEdit = async (id: string): Promise => { + const response = await fetchAPI(`documents/${id}/can-edit/`); + + if (!response.ok) { + throw new APIError('Failed to get the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export const KEY_CAN_EDIT = 'doc-can-edit'; + +export function useDocCanEdit( + param: string, + queryConfig?: UseQueryOptions< + DocCanEditResponse, + APIError, + DocCanEditResponse + >, +) { + return useQuery({ + queryKey: [KEY_CAN_EDIT, param], + queryFn: () => docCanEdit(param), + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx index a012cf26..3660c005 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDoc.tsx @@ -1,10 +1,18 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Doc } from '@/features/docs'; +import { KEY_CAN_EDIT } from './useDocCanEdit'; + export type UpdateDocParams = Pick & - Partial>; + Partial> & { + websocket?: boolean; + }; export const updateDoc = async ({ id, @@ -24,25 +32,30 @@ export const updateDoc = async ({ return response.json() as Promise; }; -interface UpdateDocProps { - onSuccess?: (data: Doc) => void; +type UseUpdateDoc = UseMutationOptions> & { listInvalideQueries?: string[]; -} +}; -export function useUpdateDoc({ - onSuccess, - listInvalideQueries, -}: UpdateDocProps = {}) { +export function useUpdateDoc(queryConfig?: UseUpdateDoc) { const queryClient = useQueryClient(); return useMutation({ mutationFn: updateDoc, - onSuccess: (data) => { - listInvalideQueries?.forEach((queryKey) => { + ...queryConfig, + onSuccess: (data, variables, context) => { + queryConfig?.listInvalideQueries?.forEach((queryKey) => { void queryClient.invalidateQueries({ queryKey: [queryKey], }); }); - onSuccess?.(data); + + if (queryConfig?.onSuccess) { + void queryConfig.onSuccess(data, variables, context); + } + }, + onError: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_CAN_EDIT], + }); }, }); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx index 0bb4f8f2..1064e866 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useConfig } from '@/core'; import { useIsOffline } from '@/features/service-worker'; +import { KEY_CAN_EDIT, useDocCanEdit } from '../api/useDocCanEdit'; import { useProviderStore } from '../stores'; import { Doc, LinkReach } from '../types'; @@ -13,31 +14,30 @@ export const useIsCollaborativeEditable = (doc: Doc) => { const docIsPublic = doc.link_reach === LinkReach.PUBLIC; const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; const docHasMember = doc.nb_accesses_direct > 1; + const isUserReader = !doc.abilities.partial_update; const isShared = docIsPublic || docIsAuth || docHasMember; - const [isEditable, setIsEditable] = useState(true); - const [isLoading, setIsLoading] = useState(true); const { isOffline } = useIsOffline(); + const _isEditable = isUserReader || isConnected || !isShared || isOffline; + const [isEditable, setIsEditable] = useState(true); + const [isLoading, setIsLoading] = useState(!_isEditable); + + const { + data: { can_edit } = { can_edit: _isEditable }, + isLoading: isLoadingCanEdit, + } = useDocCanEdit(doc.id, { + enabled: !_isEditable, + queryKey: [KEY_CAN_EDIT, doc.id], + staleTime: 0, + }); - /** - * Connection can take a few seconds - */ useEffect(() => { - const _isEditable = isConnected || !isShared || isOffline; - setIsLoading(true); - - if (_isEditable) { - setIsEditable(true); - setIsLoading(false); + if (isLoadingCanEdit) { return; } - const timer = setTimeout(() => { - setIsEditable(false); - setIsLoading(false); - }, 5000); - - return () => clearTimeout(timer); - }, [isConnected, isOffline, isShared]); + setIsEditable(can_edit); + setIsLoading(false); + }, [can_edit, isLoadingCanEdit]); if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) { return {