🛂(frontend) block edition only when not alone

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.
This commit is contained in:
Anthony LC
2025-07-03 20:38:50 +02:00
committed by Manuel Raynaud
parent 9a8f952210
commit 55979e4370
9 changed files with 252 additions and 116 deletions

View File

@@ -1,14 +1,13 @@
import path from 'path'; import path from 'path';
import { expect, test } from '@playwright/test'; import { chromium, expect, test } from '@playwright/test';
import cs from 'convert-stream'; import cs from 'convert-stream';
import { import {
CONFIG,
addNewMember,
createDoc, createDoc,
goToGridDoc, goToGridDoc,
mockedDocument, mockedDocument,
overrideConfig,
verifyDocName, verifyDocName,
} from './common'; } from './common';
@@ -522,52 +521,141 @@ test.describe('Doc Editor', () => {
test('it checks block editing when not connected to collab server', async ({ test('it checks block editing when not connected to collab server', async ({
page, page,
browserName,
}) => { }) => {
await page.route('**/api/v1.0/config/', async (route) => { /**
const request = route.request(); * The good port is 4444, but we want to simulate a not connected
if (request.method().includes('GET')) { * collaborative server.
await route.fulfill({ * So we use a port that is not used by the collaborative server.
json: { * The server will not be able to connect to the collaborative server.
...CONFIG, */
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
},
});
} else {
await route.continue();
}
}); });
await page.goto('/'); await page.goto('/');
void page const [title] = await createDoc(page, 'editing-blocking', browserName, 1);
.getByRole('button', {
name: 'New doc',
})
.click();
const card = page.getByLabel('It is the card information'); const card = page.getByLabel('It is the card information');
await expect( await expect(
card.getByText('Your network do not allow you to edit'), card.getByText('Others are editing. Your network prevent changes.'),
).toBeHidden(); ).toBeHidden();
const editor = page.locator('.ProseMirror'); const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'true'); 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 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 // Close the modal
await page.getByRole('button', { name: 'close' }).first().click(); 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( await expect(
card.getByText('Your network do not allow you to edit'), card.getByText('Others are editing. Your network prevent changes.'),
).toBeVisible({ ).toBeVisible({
timeout: 10000, timeout: 10000,
}); });
await expect(editor).toHaveAttribute('contenteditable', 'false'); 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 }) => { test('it checks if callout custom block', async ({ page, browserName }) => {

View File

@@ -15,49 +15,11 @@ test.beforeEach(async ({ page }) => {
}); });
test.describe('Doc Header', () => { test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => { test('it checks the element are correctly displayed', async ({
await mockedDocument(page, { page,
accesses: [ browserName,
{ }) => {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg', await createDoc(page, 'doc-update', browserName, 1);
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);
const card = page.getByLabel( const card = page.getByLabel(
'It is the card information about the document.', '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' }); const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible(); 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('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible(); await expect(card.getByText('Owner ·')).toBeVisible();

View File

@@ -50,9 +50,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc); const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isConnectedToCollabServer = provider.isSynced;
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; 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 { i18n } = useTranslation();
const lang = i18n.resolvedLanguage; const lang = i18n.resolvedLanguage;

View File

@@ -41,7 +41,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true), { renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -73,7 +73,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, false), { renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -107,7 +107,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, true), { renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -143,7 +143,7 @@ describe('useSaveDoc', () => {
}), }),
}); });
renderHook(() => useSaveDoc(docId, yDoc, true), { renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });
@@ -164,7 +164,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id'; const docId = 'test-doc-id';
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), { const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper, wrapper: AppWrapper,
}); });

View File

@@ -10,7 +10,12 @@ import { toBase64 } from '../utils';
const SAVE_INTERVAL = 60000; 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({ const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS], listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => { onSuccess: () => {
@@ -49,10 +54,18 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
updateDoc({ updateDoc({
id: docId, id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)), content: toBase64(Y.encodeStateAsUpdate(yDoc)),
websocket: isConnectedToCollabServer,
}); });
return true; return true;
}, [canSave, yDoc, docId, isLocalChange, updateDoc]); }, [
canSave,
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
]);
const router = useRouter(); const router = useRouter();

View File

@@ -32,7 +32,7 @@ export const AlertNetwork = () => {
<Box $direction="row" $gap={spacingsTokens['2xs']} $align="center"> <Box $direction="row" $gap={spacingsTokens['2xs']} $align="center">
<Icon iconName="mobiledata_off" $theme="warning" $variation="600" /> <Icon iconName="mobiledata_off" $theme="warning" $variation="600" />
<Text $theme="warning" $variation="600" $weight={500}> <Text $theme="warning" $variation="600" $weight={500}>
{t('Your network do not allow you to edit')} {t('Others are editing. Your network prevent changes.')}
</Text> </Text>
</Box> </Box>
<BoxButton <BoxButton
@@ -50,7 +50,7 @@ export const AlertNetwork = () => {
$margin={{ top: 'auto' }} $margin={{ top: 'auto' }}
/> />
<Text $theme="warning" $variation="600" $weight="500" $size="xs"> <Text $theme="warning" $variation="600" $weight="500" $size="xs">
{t('Know more')} {t('Learn more')}
</Text> </Text>
</BoxButton> </BoxButton>
</Box> </Box>
@@ -74,8 +74,8 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
onClose={() => onClose()} onClose={() => onClose()}
rightActions={ rightActions={
<> <>
<Button aria-label={t('OK')} onClick={onClose}> <Button aria-label={t('OK')} onClick={onClose} color="danger">
{t('OK')} {t('I understand')}
</Button> </Button>
</> </>
} }
@@ -88,25 +88,40 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
$align="flex-start" $align="flex-start"
$variation="1000" $variation="1000"
> >
{t("Why can't I edit?")} {t("Why you can't edit the document?")}
</Text> </Text>
} }
> >
<Box <Box
aria-label={t('Content modal to explain why the user cannot edit')} aria-label={t('Content modal to explain why the user cannot edit')}
className="--docs--modal-alert-network" className="--docs--modal-alert-network"
$margin={{ top: 'xs' }} $margin={{ top: 'md' }}
> >
<Text $size="sm" $variation="600"> <Text $size="sm" $variation="600">
{t( {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.',
)} )}
</Text> </Text>
<Text $size="sm" $variation="600" $margin={{ top: 'xs' }}> <Text
$size="sm"
$variation="600"
$margin={{ top: 'xs' }}
$weight="bold"
$display="inline"
>
{t("This means you can't edit until others leave.")}{' '}
<Text
$size="sm"
$variation="600"
$margin={{ top: 'xs' }}
$weight="normal"
$display="inline"
>
{t( {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.', 'If you wish to be able to co-edit in real-time, contact your Information Systems Security Manager about allowing WebSockets.',
)} )}
</Text> </Text>
</Text>
</Box> </Box>
</Modal> </Modal>
); );

View File

@@ -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<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,
});
}

View File

@@ -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 { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs'; import { Doc } from '@/features/docs';
import { KEY_CAN_EDIT } from './useDocCanEdit';
export type UpdateDocParams = Pick<Doc, 'id'> & export type UpdateDocParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'content' | 'title'>>; Partial<Pick<Doc, 'content' | 'title'>> & {
websocket?: boolean;
};
export const updateDoc = async ({ export const updateDoc = async ({
id, id,
@@ -24,25 +32,30 @@ export const updateDoc = async ({
return response.json() as Promise<Doc>; return response.json() as Promise<Doc>;
}; };
interface UpdateDocProps { type UseUpdateDoc = UseMutationOptions<Doc, APIError, Partial<Doc>> & {
onSuccess?: (data: Doc) => void;
listInvalideQueries?: string[]; listInvalideQueries?: string[];
} };
export function useUpdateDoc({ export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
onSuccess,
listInvalideQueries,
}: UpdateDocProps = {}) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({ return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc, mutationFn: updateDoc,
onSuccess: (data) => { ...queryConfig,
listInvalideQueries?.forEach((queryKey) => { onSuccess: (data, variables, context) => {
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [queryKey], queryKey: [queryKey],
}); });
}); });
onSuccess?.(data);
if (queryConfig?.onSuccess) {
void queryConfig.onSuccess(data, variables, context);
}
},
onError: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_CAN_EDIT],
});
}, },
}); });
} }

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useConfig } from '@/core'; import { useConfig } from '@/core';
import { useIsOffline } from '@/features/service-worker'; import { useIsOffline } from '@/features/service-worker';
import { KEY_CAN_EDIT, useDocCanEdit } from '../api/useDocCanEdit';
import { useProviderStore } from '../stores'; import { useProviderStore } from '../stores';
import { Doc, LinkReach } from '../types'; import { Doc, LinkReach } from '../types';
@@ -13,31 +14,30 @@ export const useIsCollaborativeEditable = (doc: Doc) => {
const docIsPublic = doc.link_reach === LinkReach.PUBLIC; const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const docHasMember = doc.nb_accesses_direct > 1; const docHasMember = doc.nb_accesses_direct > 1;
const isUserReader = !doc.abilities.partial_update;
const isShared = docIsPublic || docIsAuth || docHasMember; const isShared = docIsPublic || docIsAuth || docHasMember;
const [isEditable, setIsEditable] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const { isOffline } = useIsOffline(); 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(() => { useEffect(() => {
const _isEditable = isConnected || !isShared || isOffline; if (isLoadingCanEdit) {
setIsLoading(true);
if (_isEditable) {
setIsEditable(true);
setIsLoading(false);
return; return;
} }
const timer = setTimeout(() => { setIsEditable(can_edit);
setIsEditable(false);
setIsLoading(false); setIsLoading(false);
}, 5000); }, [can_edit, isLoadingCanEdit]);
return () => clearTimeout(timer);
}, [isConnected, isOffline, isShared]);
if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) { if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) {
return { return {