mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-05 17:27:48 +02:00
🔥(frontend) remove "can-edit" mechanism
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -25,7 +25,6 @@ vi.mock('../../doc-management', async () => {
|
||||
const actual = await vi.importActual<any>('../../doc-management');
|
||||
return {
|
||||
...actual,
|
||||
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
|
||||
useProviderStore: () => ({
|
||||
provider: {
|
||||
roomname: 'test-doc-id',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Box>
|
||||
<Card
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
$radius={spacingsTokens['3xs']}
|
||||
$padding="xs"
|
||||
$flex={1}
|
||||
$align="center"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$theme="warning"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap={spacingsTokens['2xs']}
|
||||
$align="center"
|
||||
$withThemeInherited
|
||||
>
|
||||
<Icon iconName="mobiledata_off" $withThemeInherited />
|
||||
<Text $withThemeInherited $weight={500}>
|
||||
{t('Others are editing. Your network prevent changes.')}
|
||||
</Text>
|
||||
</Box>
|
||||
<BoxButton
|
||||
$direction="row"
|
||||
$gap={spacingsTokens['3xs']}
|
||||
$align="center"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
$withThemeInherited
|
||||
>
|
||||
<Icon
|
||||
iconName="info"
|
||||
$withThemeInherited
|
||||
$size="md"
|
||||
$weight="500"
|
||||
$margin={{ top: 'auto' }}
|
||||
/>
|
||||
<Text $withThemeInherited $weight="500" $size="xs">
|
||||
{t('Learn more')}
|
||||
</Text>
|
||||
</BoxButton>
|
||||
</Card>
|
||||
</Box>
|
||||
{isModalOpen && (
|
||||
<AlertNetworkModal onClose={() => setIsModalOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlertNetworkModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
aria-label={t("Why you can't edit the document?")}
|
||||
rightActions={
|
||||
<>
|
||||
<Button
|
||||
aria-label={t('OK')}
|
||||
onClick={onClose}
|
||||
color="error"
|
||||
autoFocus
|
||||
>
|
||||
{t('I understand')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
<Text $size="h6" as="h6" $margin={{ all: '0' }} $align="flex-start">
|
||||
{t("Why you can't edit the document?")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box className="--docs--modal-alert-network" $margin={{ top: 'md' }}>
|
||||
<Text $size="sm" $variation="secondary">
|
||||
{t(
|
||||
'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.',
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
$size="sm"
|
||||
$variation="secondary"
|
||||
$margin={{ top: 'xs' }}
|
||||
$weight="bold"
|
||||
$display="inline"
|
||||
>
|
||||
{t("This means you can't edit until others leave.")}{' '}
|
||||
<Text
|
||||
$size="sm"
|
||||
$variation="secondary"
|
||||
$margin={{ top: 'xs' }}
|
||||
$weight="normal"
|
||||
$display="inline"
|
||||
>
|
||||
{t(
|
||||
'If you wish to be able to co-edit in real-time, contact your Information Systems Security Manager about allowing WebSockets.',
|
||||
)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
<Box
|
||||
$gap="base"
|
||||
$padding={{
|
||||
bottom: isDeletedDoc || !isEditable ? 'base' : undefined,
|
||||
bottom: isDeletedDoc ? 'base' : undefined,
|
||||
}}
|
||||
>
|
||||
{isDeletedDoc && <AlertRestore doc={doc} />}
|
||||
{!isEditable && <AlertNetwork />}
|
||||
</Box>
|
||||
<Box $gap="sm">
|
||||
<Box>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<VisibilityDoc doc={doc} />
|
||||
{transRole(isEditable ? doc.user_role || doc.link_role : Role.READER)}
|
||||
{transRole(
|
||||
doc.abilities.partial_update
|
||||
? doc.user_role || doc.link_role
|
||||
: Role.READER,
|
||||
)}
|
||||
·
|
||||
</Text>
|
||||
<Text as="dt" $variation="tertiary" $size="s" $margin="0">
|
||||
|
||||
@@ -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 <DocTitleText />;
|
||||
|
||||
@@ -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<DocCanEditResponse> => {
|
||||
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<DocCanEditResponse>;
|
||||
};
|
||||
|
||||
export const KEY_CAN_EDIT = 'doc-can-edit';
|
||||
|
||||
export function useDocCanEdit(
|
||||
param: string,
|
||||
queryConfig?: UseQueryOptions<
|
||||
DocCanEditResponse,
|
||||
APIError,
|
||||
DocCanEditResponse
|
||||
>,
|
||||
) {
|
||||
return useQuery<DocCanEditResponse, APIError, DocCanEditResponse>({
|
||||
queryKey: [KEY_CAN_EDIT, param],
|
||||
queryFn: () => docCanEdit(param),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ export * from './useCopyDocLink';
|
||||
export * from './useCreateChildDocTree';
|
||||
export * from './useDocTitleUpdate';
|
||||
export * from './useDocUtils';
|
||||
export * from './useIsCollaborativeEditable';
|
||||
export * from './useTrans';
|
||||
|
||||
-80
@@ -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<NodeJS.Timeout | null>(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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user