🛂(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,
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true,
},
});
} else {
await route.continue();
}
/**
* 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/',
});
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,24 +88,39 @@ 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' }}>
{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.',
)}
<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(
'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);
setIsLoading(false);
}, 5000);
return () => clearTimeout(timer);
}, [isConnected, isOffline, isShared]);
setIsEditable(can_edit);
setIsLoading(false);
}, [can_edit, isLoadingCanEdit]);
if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) {
return {