🛂(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 { 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,
/**
* 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/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
},
});
} else {
await route.continue();
}
});
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 }) => {

View File

@@ -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();

View File

@@ -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;

View File

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

View File

@@ -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();

View File

@@ -32,7 +32,7 @@ export const AlertNetwork = () => {
<Box $direction="row" $gap={spacingsTokens['2xs']} $align="center">
<Icon iconName="mobiledata_off" $theme="warning" $variation="600" />
<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>
</Box>
<BoxButton
@@ -50,7 +50,7 @@ export const AlertNetwork = () => {
$margin={{ top: 'auto' }}
/>
<Text $theme="warning" $variation="600" $weight="500" $size="xs">
{t('Know more')}
{t('Learn more')}
</Text>
</BoxButton>
</Box>
@@ -74,8 +74,8 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
onClose={() => onClose()}
rightActions={
<>
<Button aria-label={t('OK')} onClick={onClose}>
{t('OK')}
<Button aria-label={t('OK')} onClick={onClose} color="danger">
{t('I understand')}
</Button>
</>
}
@@ -88,25 +88,40 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
$align="flex-start"
$variation="1000"
>
{t("Why can't I edit?")}
{t("Why you can't edit the document?")}
</Text>
}
>
<Box
aria-label={t('Content modal to explain why the user cannot edit')}
className="--docs--modal-alert-network"
$margin={{ top: 'xs' }}
$margin={{ top: 'md' }}
>
<Text $size="sm" $variation="600">
{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 $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(
'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>
</Box>
</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 { Doc } from '@/features/docs';
import { KEY_CAN_EDIT } from './useDocCanEdit';
export type UpdateDocParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'content' | 'title'>>;
Partial<Pick<Doc, 'content' | 'title'>> & {
websocket?: boolean;
};
export const updateDoc = async ({
id,
@@ -24,25 +32,30 @@ export const updateDoc = async ({
return response.json() as Promise<Doc>;
};
interface UpdateDocProps {
onSuccess?: (data: Doc) => void;
type UseUpdateDoc = UseMutationOptions<Doc, APIError, Partial<Doc>> & {
listInvalideQueries?: string[];
}
};
export function useUpdateDoc({
onSuccess,
listInvalideQueries,
}: UpdateDocProps = {}) {
export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({
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],
});
},
});
}

View File

@@ -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);
setIsEditable(can_edit);
setIsLoading(false);
}, 5000);
return () => clearTimeout(timer);
}, [isConnected, isOffline, isShared]);
}, [can_edit, isLoadingCanEdit]);
if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) {
return {