🛂(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:
committed by
Manuel Raynaud
parent
9a8f952210
commit
55979e4370
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user