From 70b47f64f0deefd8a1f5dbfe54a4c766a1d6fd6d 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 | 12 +- .../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, 11 insertions(+), 405 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 ead2b725a..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/ui-components'; -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 d7790a76a..a89446185 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 @@ -11,10 +11,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'; @@ -25,7 +23,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 ?? ''); @@ -63,11 +60,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 9f38b10a5..e8c6640be 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 @@ -7,7 +7,6 @@ import { LinkReach, Role, getDocLinkReach, - useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; import { useDate } from '@/hooks'; @@ -21,7 +20,6 @@ interface DocHeaderInfoProps { export const DocHeaderInfo = ({ doc }: DocHeaderInfoProps) => { const { transRole } = useTrans(); - const { isEditable } = useIsCollaborativeEditable(doc); const { relativeDate, calculateDaysLeft } = useDate(); const { data: config } = useConfig(); @@ -53,7 +51,7 @@ 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" > @@ -75,12 +73,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 68923f65b..ee91213f8 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'; @@ -25,8 +24,7 @@ interface DocTitleProps { } export const DocTitle = ({ doc, onTitleUpdate }: 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 0cb0e4576..be986e857 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 { @@ -139,12 +138,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, - }; -};