From 6f0bdc7fb3d876470f1220b2dcafcb552c3ff29d Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 5 Aug 2026 14:11:14 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5(frontend)=20remove=20"can-edit"=20?= =?UTF-8?q?mechanism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will not block anymore the users not connected to the collaboration server from editing the document, we will have an HTTP fallback instead, so we can remove the "can-edit" mechanism and the related code. --- .../app-impress/doc-collaboration.spec.ts | 143 +----------------- .../doc-editor/__tests__/DocEditor.spec.tsx | 1 - .../docs/doc-editor/components/DocEditor.tsx | 5 +- .../doc-header/components/AlertNetwork.tsx | 125 --------------- .../docs/doc-header/components/DocHeader.tsx | 6 +- .../doc-header/components/DocHeaderInfo.tsx | 10 +- .../docs/doc-header/components/DocTitle.tsx | 4 +- .../docs/doc-management/api/useDocCanEdit.tsx | 32 ---- .../api/useDocContentUpdate.tsx | 7 - .../docs/doc-management/hooks/index.ts | 1 - .../hooks/useIsCollaborativeEditable.tsx | 80 ---------- 11 files changed, 10 insertions(+), 404 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts index 526e25a74..fb237161a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts @@ -5,7 +5,6 @@ import { expect, test } from '@playwright/test'; import { createDoc, overrideConfig, verifyDocName } from './utils-common'; import { openSuggestionMenu, writeInEditor } from './utils-editor'; import { connectOtherUserToDoc, updateShareLink } from './utils-share'; -import { createRootSubPage } from './utils-sub-pages'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -111,147 +110,7 @@ test.describe('Doc Collaboration', () => { await cleanup(); }); - // TODO(yhub): re-enable when yhub exposes a connection-info API - the test - // asserts `can_edit=false` while another user is connected to the - // collaborative server, but `get_document_connection_info` is currently - // stubbed to report no connections. - test.skip('it checks block editing when not connected to collab server', async ({ - page, - browserName, - }) => { - test.slow(); - - /** - * The good port is 3002, 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/ws/docs', - COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: true, - }); - - await page.goto('/'); - - const [parentTitle] = await createDoc( - page, - 'editing-blocking', - browserName, - 1, - ); - - const card = page.getByLabel('It is the card information'); - await expect( - 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 updateShareLink(page, 'Public', 'Editing'); - - // Close the modal - await page.getByRole('button', { name: 'close' }).first().click(); - - const urlParentDoc = page.url(); - - const { name: childTitle } = await createRootSubPage( - page, - browserName, - 'editing-blocking - child', - ); - - let responseCanEdit = await responseCanEditPromise; - expect(responseCanEdit.ok()).toBeTruthy(); - let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; - expect(jsonCanEdit.can_edit).toBeTruthy(); - - const urlChildDoc = page.url(); - - /** - * We open another browser that will connect to the collaborative server - * and will block the current browser to edit the doc. - */ - const { otherPage, cleanup } = await connectOtherUserToDoc({ - browserName, - docUrl: urlChildDoc, - docTitle: childTitle, - withoutSignIn: true, - }); - - const webSocketPromise = otherPage.waitForEvent( - 'websocket', - (webSocket) => { - return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`); - }, - ); - - await otherPage.goto(urlChildDoc); - - const webSocket = await webSocketPromise; - expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`); - - await verifyDocName(otherPage, childTitle); - - await page.reload(); - - responseCanEdit = await page.waitForResponse( - (response) => - response.url().includes(`/can-edit/`) && response.status() === 200, - ); - expect(responseCanEdit.ok()).toBeTruthy(); - - jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; - expect(jsonCanEdit.can_edit).toBeFalsy(); - - await expect( - card.getByText('Others are editing. Your network prevent changes.'), - ).toBeVisible({ - timeout: 10000, - }); - - await expect(editor).toHaveAttribute('contenteditable', 'false'); - - await expect( - page.getByRole('textbox', { name: 'Document title' }), - ).toBeHidden(); - await expect(page.getByRole('heading', { name: childTitle })).toBeVisible(); - - await page.goto(urlParentDoc); - - await verifyDocName(page, parentTitle); - - await page.getByRole('button', { name: 'Share' }).click(); - - await page.getByTestId('doc-access-mode').click(); - await page.getByRole('menuitemradio', { name: 'Reading' }).click(); - - // Close the modal - await page.getByRole('button', { name: 'close' }).first().click(); - - await page.goto(urlChildDoc); - - await expect(editor).toHaveAttribute('contenteditable', 'true'); - - await expect( - page.getByRole('textbox', { name: 'Document title' }), - ).toContainText(childTitle); - await expect(page.getByRole('heading', { name: childTitle })).toBeHidden(); - - await expect( - card.getByText('Others are editing. Your network prevent changes.'), - ).toBeHidden(); - - await cleanup(); - }); + // TODO(yhub): Add test to check that no connected websocket users can collaborate test('checks disconnection and reconnection when changing tab visibility', async ({ page, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx index e27870b94..9db868131 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/__tests__/DocEditor.spec.tsx @@ -25,7 +25,6 @@ vi.mock('../../doc-management', async () => { const actual = await vi.importActual('../../doc-management'); return { ...actual, - useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }), useProviderStore: () => ({ provider: { roomname: 'test-doc-id', 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 f24db166b..79bedea1f 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 @@ -8,7 +8,6 @@ import { Doc, LinkReach, getDocLinkReach, - useIsCollaborativeEditable, useProviderStore, } from '@/docs/doc-management'; import { useAuth } from '@/features/auth/'; @@ -85,10 +84,8 @@ interface DocEditorProps { export const DocEditor = ({ doc }: DocEditorProps) => { useCollaboration(doc.id); - const { isEditable, isLoading } = useIsCollaborativeEditable(doc); const isDeletedDoc = !!doc.deleted_at; - const readOnly = - !doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc; + const readOnly = !doc.abilities.partial_update || isDeletedDoc; const { trackEvent } = useAnalytics(); const [hasTracked, setHasTracked] = useState(false); const { authenticated } = useAuth(); 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 deleted file mode 100644 index a0837feeb..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { t } from 'i18next'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Box, BoxButton, Card, Icon, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; - -export const AlertNetwork = () => { - const { t } = useTranslation(); - const { spacingsTokens } = useCunninghamTheme(); - const [isModalOpen, setIsModalOpen] = useState(false); - - return ( - <> - - - - - - {t('Others are editing. Your network prevent changes.')} - - - setIsModalOpen(true)} - $withThemeInherited - > - - - {t('Learn more')} - - - - - {isModalOpen && ( - setIsModalOpen(false)} /> - )} - - ); -}; - -interface AlertNetworkModalProps { - onClose: () => void; -} - -export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => { - return ( - onClose()} - aria-label={t("Why you can't edit the document?")} - rightActions={ - <> - - - } - size={ModalSize.MEDIUM} - title={ - - {t("Why you can't edit the document?")} - - } - > - - - {t( - 'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.', - )} - - - {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-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index 6e24e17fb..6e0e1e72c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -10,10 +10,8 @@ import { getEmojiAndTitle, useDocTitleUpdate, useDocUtils, - useIsCollaborativeEditable, } from '@/docs/doc-management'; -import { AlertNetwork } from './AlertNetwork'; import { AlertRestore } from './AlertRestore'; import { DocHeaderInfo } from './DocHeaderInfo'; import { DocTitle } from './DocTitle'; @@ -24,7 +22,6 @@ interface DocHeaderProps { export const DocHeader = ({ doc }: DocHeaderProps) => { const { t } = useTranslation(); - const { isEditable } = useIsCollaborativeEditable(doc); const isDeletedDoc = !!doc.deleted_at; // Emoji Management const { emoji } = getEmojiAndTitle(doc.title ?? ''); @@ -57,11 +54,10 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { {isDeletedDoc && } - {!isEditable && } diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx index c5f7f8d73..530e0e83b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeaderInfo.tsx @@ -9,7 +9,6 @@ import { LinkReach, Role, getDocLinkReach, - useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; import { useDate } from '@/hooks'; @@ -20,7 +19,6 @@ interface DocHeaderInfoProps { export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => { const { transRole } = useTrans(); - const { isEditable } = useIsCollaborativeEditable(doc); const { relativeDate, calculateDaysLeft } = useDate(); const { data: config } = useConfig(); @@ -50,12 +48,16 @@ export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => { $variation="tertiary" $size="s" $weight="bold" - $theme={isEditable ? 'neutral' : 'warning'} + $theme={doc.abilities.partial_update ? 'neutral' : 'warning'} $direction="row" $margin="0" > - {transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)} + {transRole( + doc.abilities.partial_update + ? doc.user_role || doc.link_role + : Role.READER, + )}  ยท  diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index 23a06c107..b7e38b830 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -11,7 +11,6 @@ import { useDocStore, useDocTitleUpdate, useDocUtils, - useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg'; @@ -24,8 +23,7 @@ interface DocTitleProps { } export const DocTitle = ({ doc }: DocTitleProps) => { - const { isEditable, isLoading } = useIsCollaborativeEditable(doc); - const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; + const readOnly = !doc.abilities.partial_update; if (readOnly) { return ; 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 deleted file mode 100644 index 8847ef94e..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocCanEdit.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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/useDocContentUpdate.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx index 23cb7402e..8f845a29b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx @@ -9,7 +9,6 @@ import { APIError, errorCauses, fetchAPI } from '@/api'; import { Doc } from '../types'; -import { KEY_CAN_EDIT } from './useDocCanEdit'; import { KEY_DOC_CONTENT } from './useDocContent'; export interface UpdateDocContentParams { @@ -112,12 +111,6 @@ export function useDocContentUpdate(queryConfig?: UseDocContentUpdate) { ); } - // If error it means the user is probably not allowed to edit the doc - // so we invalidate the canEdit query to update the UI accordingly - void queryClient.invalidateQueries({ - queryKey: [KEY_CAN_EDIT], - }); - if (queryConfig?.onError) { queryConfig.onError(error, variables, onMutateResult, context); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts index ba5d9640d..eb2fc20ea 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts @@ -2,5 +2,4 @@ export * from './useCopyDocLink'; export * from './useCreateChildDocTree'; export * from './useDocTitleUpdate'; export * from './useDocUtils'; -export * from './useIsCollaborativeEditable'; export * from './useTrans'; 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 deleted file mode 100644 index d2d2f172a..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect, useRef, 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, LinkRole } from '../types'; - -export const useIsCollaborativeEditable = (doc: Doc) => { - const { isConnected } = useProviderStore(); - const { data: conf } = useConfig(); - - const docIsPublic = - doc.computed_link_reach === LinkReach.PUBLIC && - doc.computed_link_role === LinkRole.EDITOR; - const docIsAuth = - doc.computed_link_reach === LinkReach.AUTHENTICATED && - doc.computed_link_role === LinkRole.EDITOR; - const docHasMember = - doc.nb_accesses_direct > 1 || doc.nb_accesses_ancestors > 1; - const isUserReader = !doc.abilities.partial_update; - const isShared = docIsPublic || docIsAuth || docHasMember; - const { isOffline } = useIsOffline(); - const _isEditable = isUserReader || isConnected || !isShared || isOffline; - const [isEditable, setIsEditable] = useState(true); - const [isLoading, setIsLoading] = useState(!_isEditable); - const timeout = useRef(null); - const { data: editingRight, isLoading: isLoadingCanEdit } = useDocCanEdit( - doc.id, - { - enabled: !_isEditable, - queryKey: [KEY_CAN_EDIT, doc.id], - staleTime: 0, - }, - ); - - useEffect(() => { - if (isLoadingCanEdit || _isEditable || !editingRight) { - return; - } - - // Connection to the WebSocket can take some time, so we set a timeout to ensure the loading state is cleared after a reasonable time. - timeout.current = setTimeout(() => { - setIsEditable(editingRight.can_edit); - setIsLoading(false); - }, 1500); - - return () => { - if (timeout.current) { - clearTimeout(timeout.current); - } - }; - }, [editingRight, isLoadingCanEdit, _isEditable]); - - useEffect(() => { - if (!_isEditable) { - return; - } - - if (timeout.current) { - clearTimeout(timeout.current); - } - - setIsEditable(true); - setIsLoading(false); - }, [_isEditable]); - - if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY) { - return { - isEditable: true, - isLoading: false, - }; - } - - return { - isEditable, - isLoading, - }; -};