From efe4d44316f2446e3bf88f6262a67a06097d3487 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 5 Aug 2026 15:43:40 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A5(frontend)=20remove=20content=20GET?= =?UTF-8?q?=20PATCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We remove the code related to the content GET and PATCH endpoints, as they are no longer used in the codebase. The yhub server will handle the content management directly, providing the content and managing the updates. This change simplifies the code and reduces the complexity of the frontend application. We will need to reimplement the saving mechanism in the service worker when we are offline. Let's wait that the service is fully developed on the yhub side before we implement this feature. --- .../e2e/__tests__/app-impress/utils-common.ts | 1 - .../impress/src/core/config/api/useConfig.tsx | 1 - .../doc-editor/components/BlockNoteEditor.tsx | 2 - .../hook/__tests__/useSaveDoc.test.tsx | 258 ------------------ .../features/docs/doc-editor/hook/index.ts | 1 - .../docs/doc-editor/hook/useCollaboration.tsx | 24 +- .../docs/doc-editor/hook/useSaveDoc.tsx | 162 ----------- .../docs/doc-management/api/useDocContent.tsx | 41 --- .../api/useDocContentUpdate.tsx | 146 ---------- .../doc-management/api/useDuplicateDoc.tsx | 26 +- .../components/ModalConfirmationVersion.tsx | 58 ++-- .../service-worker/plugins/ApiPlugin.ts | 119 +------- .../service-worker/service-worker-api.ts | 36 --- 13 files changed, 31 insertions(+), 844 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContent.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 66aa639fc..60053a2f0 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -20,7 +20,6 @@ export const CONFIG = { API_USERS_SEARCH_QUERY_MIN_LENGTH: 3, COLLABORATION_WS_INACTIVITY_TIMEOUT: 15, COLLABORATION_WS_URL: process.env.COLLABORATION_WS_URL, - COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: true, CONVERSION_UPLOAD_ENABLED: true, CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'], CONVERSION_FILE_MAX_SIZE: 20971520, diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index c5ef93bb5..be5d56224 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -49,7 +49,6 @@ export interface ConfigResponse { AI_FEATURE_LEGACY_ENABLED?: boolean; API_USERS_SEARCH_QUERY_MIN_LENGTH?: number; COLLABORATION_WS_URL?: string; - COLLABORATION_WS_NOT_CONNECTED_READ_ONLY?: boolean; COLLABORATION_WS_INACTIVITY_TIMEOUT?: number | null; CONVERSION_FILE_EXTENSIONS_ALLOWED: string[]; CONVERSION_FILE_MAX_SIZE: number; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index 25f0f650c..c8df47a7c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -50,7 +50,6 @@ import { useAnalytics } from '@/libs/Analytics'; import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf'; import { useHeadings, - useSaveDoc, useScrollToBlockAnchor, useShortcuts, useUploadFile, @@ -109,7 +108,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { setEditor } = useEditorStore(); const { themeTokens } = useCunninghamTheme(); const refEditorContainer = useRef(null); - useSaveDoc(doc.id, provider.doc); const { i18n, t } = useTranslation(); const langLocalesBN = diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx deleted file mode 100644 index 8b055d9a6..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import fetchMock from 'fetch-mock'; -import { useRouter } from 'next/router'; -import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as Y from 'yjs'; - -import { AppWrapper } from '@/tests/utils'; - -import { useSaveDoc } from '../useSaveDoc'; - -vi.mock('next/router', () => ({ - useRouter: vi.fn(), -})); - -vi.mock('@/docs/doc-versioning', () => ({ - KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions', -})); - -vi.mock('@/docs/doc-management', async () => ({ - useUpdateDoc: ( - await vi.importActual('@/docs/doc-management/api/useUpdateDoc') - ).useUpdateDoc, -})); - -describe('useSaveDoc', () => { - const mockRouterEvents = { - on: vi.fn(), - off: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - fetchMock.hardReset(); - fetchMock.mockGlobal(); - - (useRouter as Mock).mockReturnValue({ - events: mockRouterEvents, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should setup event listeners on mount', () => { - const yDoc = new Y.Doc(); - const docId = 'test-doc-id'; - - const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - // Verify router event listeners are set up - expect(mockRouterEvents.on).toHaveBeenCalledWith( - 'routeChangeStart', - expect.any(Function), - ); - - // Verify window event listener is set up - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'beforeunload', - expect.any(Function), - ); - - addEventListenerSpy.mockRestore(); - }); - - it('should save when there are local changes', async () => { - vi.useFakeTimers(); - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, { - body: JSON.stringify({ - id: docId, - content: 'test-content', - }), - }); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Trigger a local update - yDoc.getMap('test').set('key', 'value'); - }); - - act(() => { - // Advance timers to trigger the save interval - vi.advanceTimersByTime(61000); - }); - - // Switch to real timers to allow the mutation promise to resolve - vi.useRealTimers(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - }); - - it('should not save when there are no local changes', () => { - vi.useFakeTimers(); - const yDoc = new Y.Doc(); - const docId = 'test-doc-id'; - - fetchMock.patch( - 'http://test.jest/api/v1.0/documents/test-doc-id/content/', - { - body: JSON.stringify({ - id: 'test-doc-id', - content: 'test-content', - }), - }, - ); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Advance timers without triggering any local updates - vi.advanceTimersByTime(61000); - }); - - // Since there are no local changes, no API call should be made - expect(fetchMock.callHistory.calls().length).toBe(0); - - vi.useRealTimers(); - }); - - const setupSavedDoc = async (yDoc: Y.Doc, docId: string) => { - fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, { - body: JSON.stringify({ id: docId, content: 'test-content' }), - }); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Trigger a local update so there is something to save - yDoc.getMap('test').set('key', 'value'); - }); - }; - - const dispatchBeforeUnload = () => { - const event = new Event('beforeunload', { cancelable: true }); - act(() => { - window.dispatchEvent(event); - }); - return event; - }; - - it('should save with keepalive when the page is unloading', async () => { - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - await setupSavedDoc(yDoc, docId); - - const event = dispatchBeforeUnload(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - - expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBe(true); - // The browser owns the request, no need to hold the unload back - expect(event.defaultPrevented).toBe(false); - }); - - it('should not use keepalive when saving without unloading', async () => { - vi.useFakeTimers(); - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - await setupSavedDoc(yDoc, docId); - - act(() => { - vi.advanceTimersByTime(61000); - }); - - vi.useRealTimers(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - - expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBeFalsy(); - }); - - it('should hold the unload back when the doc is too big for keepalive with firefox', async () => { - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - // Mock Firefox user agent to simulate Firefox behavior - vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/117.0', - ); - - fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, { - body: JSON.stringify({ id: docId, content: 'test-content' }), - }); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Over the 64 KiB keepalive cap once base64 encoded - yDoc.getText('big').insert(0, 'a'.repeat(70 * 1024)); - }); - - const event = dispatchBeforeUnload(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - - expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBe(false); - // Regular fetch: the unload is held back so the request has time to go out - expect(event.defaultPrevented).toBe(true); - }); - - it('should cleanup event listeners on unmount', () => { - const yDoc = new Y.Doc(); - const docId = 'test-doc-id'; - const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); - - const { unmount } = renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - unmount(); - - // Verify router event listeners are cleaned up - expect(mockRouterEvents.off).toHaveBeenCalledWith( - 'routeChangeStart', - expect.any(Function), - ); - - // Verify window event listener is cleaned up - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'beforeunload', - expect.any(Function), - ); - }); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts index d647518a5..a168772d8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts @@ -1,5 +1,4 @@ export * from './useHeadings'; -export * from './useSaveDoc'; export * from './useScrollToBlockAnchor'; export * from './useShortcuts'; export * from './useUploadFile'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx index 150b7c07b..eb26a0cb9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx @@ -3,10 +3,6 @@ import { useEffect } from 'react'; import { useCollaborationUrl, useConfig } from '@/core/config'; import { KEY_DOC } from '@/docs/doc-management/api/useDoc'; -import { - KEY_DOC_CONTENT, - useDocContent, -} from '@/docs/doc-management/api/useDocContent'; import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore'; import { useIsOffline } from '@/features/service-worker/hooks/useOffline'; import { useBroadcastStore } from '@/stores/useBroadcastStore'; @@ -33,13 +29,6 @@ export const useCollaboration = (room: string) => { resumeFromInactivity, } = useProviderStore(); const isOffline = useIsOffline((state) => state.isOffline); - const { data: docContent } = useDocContent( - { id: room }, - { - staleTime: 30000, // 30 seconds - We keep the data fresh as it is a highly collaborative page - queryKey: [KEY_DOC_CONTENT, { id: room }], - }, - ); /** * When offline, the WebSocket never connects so the provider would stay @@ -89,20 +78,13 @@ export const useCollaboration = (room: string) => { * Set the provider when the collaboration URL and the document content are available. */ useEffect(() => { - if (!room || !collaborationUrl || provider || docContent === undefined) { + if (!room || !collaborationUrl || provider) { return; } - const newProvider = createProvider(collaborationUrl, room, docContent); + const newProvider = createProvider(collaborationUrl, room); setBroadcastProvider(newProvider); - }, [ - provider, - collaborationUrl, - createProvider, - docContent, - room, - setBroadcastProvider, - ]); + }, [provider, collaborationUrl, createProvider, room, setBroadcastProvider]); /** * Destroy the provider when the component is unmounted diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx deleted file mode 100644 index 726b19913..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useRouter } from 'next/router'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { WebsocketProvider } from 'y-websocket'; -import * as Y from 'yjs'; - -import { - canKeepaliveContent, - useDocContentUpdate, -} from '@/docs/doc-management/api/useDocContentUpdate'; -import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore'; -import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions'; -import { COMMENT_UPDATE_ORIGIN } from '@/features/docs/doc-comments/api/DocsThreadStore'; -import { useIsOffline } from '@/features/service-worker'; -import { toBase64 } from '@/utils/string'; -import { isFirefox } from '@/utils/userAgent'; - -const SAVE_INTERVAL = 60000; - -export const useSaveDoc = (docId: string, yDoc: Y.Doc) => { - /** - * isSynced is more reliable than isConnected in this cases - * because it indicates that the content is fully synchronised - * with the yjs server - */ - const { isSynced: isConnectedToCollabServer } = useProviderStore(); - - const { isOffline } = useIsOffline(); - const isSavingRef = useRef(false); - const { mutate: updateDocContent } = useDocContentUpdate({ - listInvalidQueries: [KEY_LIST_DOC_VERSIONS], - isOptimistic: isOffline, // Enable optimistic updates when offline, to update the cache immediately - onSuccess: () => { - isSavingRef.current = false; - setIsLocalChange(false); - }, - onError: () => { - isSavingRef.current = false; - }, - }); - const [isLocalChange, setIsLocalChange] = useState(false); - - /** - * Update initial doc when doc is updated by other users, - * so only the user typing will trigger the save. - * This is to avoid saving the same doc multiple time. - */ - useEffect(() => { - const onUpdate = ( - _uintArray: Uint8Array, - _pluginKey: string, - _updatedDoc: Y.Doc, - transaction: Y.Transaction, - ) => { - /** - * When the AI edit the doc transaction.local is false, - * so we check the transaction origin to know where - * the transaction comes from. - * "PluginKey" origin comes from the current user, but transaction.local is more reliable - * Updates from other users are applied by the collaboration server with - * the provider instance as origin, it seems quite reliable too. - * The AI origin seems to not be reliable enough, but by deduction if it's not local - * and not from other users, it has to be from the AI. - * - * TODO: see if we can get the local changes from the AI - */ - const isAIChange = - !transaction.local && - !(transaction.origin instanceof WebsocketProvider); - - /** - * notifySubscribers generate a transaction that can be - * interpreted as a local change. - * We intercept the update with this origin to - * avoid marking the change as local. - */ - if (transaction.origin === COMMENT_UPDATE_ORIGIN) { - return; - } - - setIsLocalChange(transaction.local || isAIChange); - }; - - yDoc.on('update', onUpdate); - - return () => { - yDoc.off('update', onUpdate); - }; - }, [yDoc]); - - /** - * `isSaving` tells whether a request was actually sent, `isKeptAlive` - * whether it was handed over to the browser process (see `keepalive`) and - * will therefore outlive the page. - */ - const saveDoc = useCallback( - ({ isUnloading = false }: { isUnloading?: boolean } = {}) => { - if (!isLocalChange || isSavingRef.current) { - return { isSaving: false, isKeptAlive: false }; - } - - isSavingRef.current = true; - const content = toBase64(Y.encodeStateAsUpdate(yDoc)); - const websocket = isConnectedToCollabServer; - updateDocContent({ - id: docId, - content, - websocket, - keepalive: isUnloading, - }); - - return { - isSaving: true, - isKeptAlive: isUnloading && canKeepaliveContent({ content, websocket }), - }; - }, - [isLocalChange, updateDocContent, docId, yDoc, isConnectedToCollabServer], - ); - - const router = useRouter(); - - useEffect(() => { - const onSave = (e?: Event) => { - const isUnloading = typeof e !== 'undefined' && e.type === 'beforeunload'; - const { isSaving, isKeptAlive } = saveDoc({ isUnloading }); - - /** - * Firefox does not trigger the request every time the user leaves the page. - * Plus the request is not intercepted by the service worker. - * So we prevent the default behavior to have the popup asking the user - * if he wants to leave the page, by adding the popup, we let the time to the - * request to be sent, and intercepted by the service worker (for the offline part). - * - * We do the same for documents too big to be sent with `keepalive`: the - * request is a regular fetch, so it dies with the page unless we hold - * the unload back. - */ - if ( - isSaving && - isUnloading && - e.preventDefault && - isFirefox() && - !isKeptAlive - ) { - e.preventDefault(); - } - }; - - // Save every minute - const timeout = setInterval(() => onSave(), SAVE_INTERVAL); - // Save when the user leaves the page - addEventListener('beforeunload', onSave); - // Save when the user navigates to another page - router.events.on('routeChangeStart', onSave); - - return () => { - clearInterval(timeout); - - removeEventListener('beforeunload', onSave); - router.events.off('routeChangeStart', onSave); - }; - }, [router.events, saveDoc]); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContent.tsx deleted file mode 100644 index 8b9882a6e..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContent.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; -import { validate as uuidValidate } from 'uuid'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -export type DocContentParams = { - id: string; -}; - -export const getDocContent = async ({ - id, -}: DocContentParams): Promise => { - if (!uuidValidate(id)) { - throw new Error(`Invalid doc id in getDocContent: ${id}`); - } - - const response = await fetchAPI(`documents/${id}/content/`, { - headers: { - accept: 'text/plain,application/json', - }, - }); - - if (!response.ok) { - throw new APIError('Failed to get the doc', await errorCauses(response)); - } - - return response.text(); -}; - -export const KEY_DOC_CONTENT = 'doc-content'; - -export function useDocContent( - param: DocContentParams, - queryConfig?: UseQueryOptions, -) { - return useQuery({ - queryKey: queryConfig?.queryKey ?? [KEY_DOC_CONTENT, param], - queryFn: () => getDocContent(param), - ...queryConfig, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx deleted file mode 100644 index be986e857..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; -import { validate as uuidValidate } from 'uuid'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -import { Doc } from '../types'; - -import { KEY_DOC_CONTENT } from './useDocContent'; - -export interface UpdateDocContentParams { - id: Doc['id']; - content: string; // Base64 encoded content - websocket?: boolean; - /** - * Hand the request over to the browser process so it survives the page - * being torn down. Needed when saving from "beforeunload": a regular fetch - * is aborted with the document, and the server never sees the request. - */ - keepalive?: boolean; -} - -/** - * The fetch spec caps the body of a keepalive request. Over that limit the - * browser rejects the request outright, so we fall back to a regular fetch. - */ -const KEEPALIVE_MAX_BODY_SIZE = 64 * 1024; - -const buildContentBody = ({ - content, - websocket, -}: Pick) => - JSON.stringify({ - content, - websocket, - }); - -/** Whether the doc is small enough to be saved with a keepalive request. */ -export const canKeepaliveContent = ( - params: Pick, -) => buildContentBody(params).length <= KEEPALIVE_MAX_BODY_SIZE; - -export const updateDocContent = async ({ - id, - content, - websocket, - keepalive, -}: UpdateDocContentParams): Promise => { - if (!uuidValidate(id)) { - throw new Error(`Invalid doc id in updateDocContent: ${id}`); - } - - const body = buildContentBody({ content, websocket }); - - const response = await fetchAPI(`documents/${id}/content/`, { - method: 'PATCH', - body, - keepalive: keepalive && body.length <= KEEPALIVE_MAX_BODY_SIZE, - }); - - if (!response.ok) { - throw new APIError( - 'Failed to update the doc content', - await errorCauses(response), - ); - } -}; - -type UseDocContentUpdate = UseMutationOptions< - void, - APIError, - UpdateDocContentParams -> & { - isOptimistic?: boolean; - listInvalidQueries?: string[]; -}; - -export function useDocContentUpdate(queryConfig?: UseDocContentUpdate) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: updateDocContent, - ...queryConfig, - onMutate: (variables) => { - /** - * If optimistic, we update the content cache immediately with the new content - * It is useful when we are in offline mode because the onSuccess is not always triggered. - */ - if (queryConfig?.isOptimistic) { - const previousContent = queryClient.getQueryData([ - KEY_DOC_CONTENT, - { id: variables.id }, - ]); - - queryClient.setQueryData( - [KEY_DOC_CONTENT, { id: variables.id }], - variables.content, - ); - - return { previousContent }; - } - }, - onSuccess: (data, variables, onMutateResult, context) => { - if (!queryConfig?.isOptimistic) { - /** - * If not optimistic, we need to update the content cache with the new content returned - * from the server - */ - queryClient.setQueryData( - [KEY_DOC_CONTENT, { id: variables.id }], - variables.content, - ); - } - - queryConfig?.listInvalidQueries?.forEach((queryKey) => { - void queryClient.resetQueries({ - queryKey: [queryKey], - }); - }); - - if (queryConfig?.onSuccess) { - void queryConfig.onSuccess(data, variables, onMutateResult, context); - } - }, - onError: (error, variables, onMutateResult, context) => { - if ( - queryConfig?.isOptimistic && - (onMutateResult as { previousContent: unknown })?.previousContent - ) { - const previousContent = (onMutateResult as { previousContent: unknown }) - .previousContent; - - queryClient.setQueryData( - [KEY_DOC_CONTENT, { id: variables.id }], - previousContent, - ); - } - - if (queryConfig?.onError) { - queryConfig.onError(error, variables, onMutateResult, context); - } - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx index 9a8afbc43..9ef8530f4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx @@ -5,16 +5,11 @@ import { useQueryClient, } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import * as Y from 'yjs'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions'; -import { toBase64 } from '@/utils/string'; -import { useProviderStore } from '../stores'; import { Doc } from '../types'; -import { useDocContentUpdate } from './useDocContentUpdate'; import { KEY_LIST_DOC } from './useDocs'; interface DuplicateDocPayload { @@ -57,27 +52,10 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) { const queryClient = useQueryClient(); const { toast } = useToastProvider(); const { t } = useTranslation(); - const { provider } = useProviderStore(); - - const { mutateAsync: updateDocContent } = useDocContentUpdate({ - listInvalidQueries: [KEY_LIST_DOC_VERSIONS], - }); return useMutation({ - mutationFn: async (variables) => { - // Save the document if we can first, to ensure the latest state is duplicated - const canSave = - variables.canSave && provider && provider.doc.guid === variables.docId; - - if (canSave) { - await updateDocContent({ - id: variables.docId, - content: toBase64(Y.encodeStateAsUpdate(provider.doc)), - }); - } - - return await duplicateDoc(variables); - }, + // TODO(yhub): double check the saving is made correctly from the back so + mutationFn: duplicateDoc, onSuccess: (data, variables, onMutateResult, context) => { void queryClient.resetQueries({ queryKey: [KEY_LIST_DOC], diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx index 7ee3c8008..dfcf317e3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx @@ -1,22 +1,12 @@ -import { - Button, - Modal, - ModalSize, - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/ui-components'; +import { Button, Modal, ModalSize } from '@gouvfr-lasuite/ui-components'; import { useTranslation } from 'react-i18next'; import { createGlobalStyle } from 'styled-components'; import { Box, Text } from '@/components'; -import { useThreadStore } from '@/docs/doc-comments/stores/useThreadStore'; -import { Doc, base64ToYDoc, useProviderStore } from '@/docs/doc-management/'; -import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate'; +import { Doc } from '@/docs/doc-management/'; import { useDocVersion } from '../api'; -import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions'; import { Versions } from '../types'; -import { revertUpdate } from '../utils'; const ModalStyle = createGlobalStyle` .c__modal__title { @@ -33,7 +23,7 @@ interface ModalConfirmationVersionProps { export const ModalConfirmationVersion = ({ onClose, - onSuccess, + onSuccess: __onSuccess, docId, versionId, }: ModalConfirmationVersionProps) => { @@ -42,29 +32,28 @@ export const ModalConfirmationVersion = ({ versionId, }); const { t } = useTranslation(); - const { toast } = useToastProvider(); - const { provider } = useProviderStore(); - const { threadStore } = useThreadStore(); - const { mutate: updateDocContent } = useDocContentUpdate({ - listInvalidQueries: [KEY_LIST_DOC_VERSIONS], - onSuccess: () => { - const onDisplaySuccess = () => { - toast(t('Version restored successfully'), VariantType.SUCCESS); - onSuccess(); - }; - if (!provider || !version?.content) { - onDisplaySuccess(); - return; - } + // TODO(yhub) : Revert the doc to a previous state using Y.js / Yhub + // const { mutate: updateDocContent } = useDocContentUpdate({ + // listInvalidQueries: [KEY_LIST_DOC_VERSIONS], + // onSuccess: () => { + // const onDisplaySuccess = () => { + // toast(t('Version restored successfully'), VariantType.SUCCESS); + // onSuccess(); + // }; - revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content)); + // if (!provider || !version?.content) { + // onDisplaySuccess(); + // return; + // } - threadStore?.refreshThreads(); + // revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content)); - onDisplaySuccess(); - }, - }); + // threadStore?.refreshThreads(); + + // onDisplaySuccess(); + // }, + // }); if (!version) { return null; @@ -96,11 +85,6 @@ export const ModalConfirmationVersion = ({ return; } - updateDocContent({ - id: docId, - content: version.content, - }); - onClose(); }} > diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index e381acddc..500e144ef 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -2,19 +2,20 @@ import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/docs/doc-management'; import { LinkReach, LinkRole, Role } from '@/docs/doc-management/types'; -import { UpdateDocContentParams } from '@/features/docs/doc-management/api/useDocContentUpdate'; import { DBRequest, DocsDB } from '../DocsDB'; import { RequestSerializer } from '../RequestSerializer'; import { SyncManager } from '../SyncManager'; interface OptionsReadonly { - tableName: 'doc-list' | 'doc-item' | 'doc-content'; - type: 'list' | 'item' | 'content'; + tableName: 'doc-list' | 'doc-item'; + type: 'list' | 'item'; } +// TODO(yhub): Used to work offline, we need to implement the patch mechanism +// It will be probably linked to the HTTP fallback mechanism of yhub interface OptionsMutate { - type: 'update' | 'delete' | 'create' | 'content-update'; + type: 'update' | 'delete' | 'create'; } interface OptionsSync { @@ -53,27 +54,6 @@ export class ApiPlugin implements WorkboxPlugin { response, }) => { try { - // For content requests, a 304 means the document hasn't changed: - // transparently serve the cached version from IDB. - if (this.options.type === 'content' && response.status === 304) { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - if (entry) { - return new Response(entry.content, { - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': 'text/plain', - ...(entry.etag && { ETag: entry.etag }), - ...(entry.lastModified && { - 'Last-Modified': entry.lastModified, - }), - }, - }); - } - } - if (response.status !== 200) { return response; } @@ -82,17 +62,6 @@ export class ApiPlugin implements WorkboxPlugin { const tableName = this.options.tableName; const body = (await response.clone().json()) as DocsResponse | Doc; await DocsDB.cacheResponse(request.url, body, tableName); - } else if (this.options.type === 'content') { - // Cache the content response with its ETag / Last-Modified to be - // able to use it for conditional requests and offline access. - const content = await response.clone().text(); - const etag = response.headers.get('ETag') ?? ''; - const lastModified = response.headers.get('Last-Modified') ?? ''; - await DocsDB.cacheResponse( - request.url, - { etag, lastModified, content }, - 'doc-content', - ); } else if (this.options.type === 'update') { const db = await DocsDB.open(); const storedResponse = await db.get('doc-item', request.url); @@ -135,7 +104,6 @@ export class ApiPlugin implements WorkboxPlugin { requestWillFetch: WorkboxPlugin['requestWillFetch'] = async ({ request }) => { if ( this.options.type === 'update' || - this.options.type === 'content-update' || this.options.type === 'create' || this.options.type === 'delete' ) { @@ -144,27 +112,6 @@ export class ApiPlugin implements WorkboxPlugin { await this.options.syncManager.sync(); - // For content requests, add If-None-Match / If-Modified-Since from IDB - // so the backend can return a 304 when the document hasn't changed. - if (this.options.type === 'content') { - try { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - if (entry?.etag || entry?.lastModified) { - const headers = new Headers(request.headers); - if (entry.etag) { - headers.set('If-None-Match', entry.etag); - } else { - headers.set('If-Modified-Since', entry.lastModified); - } - return new Request(request, { headers }); - } - } catch (error) { - console.error('SW: ApiPlugin requestWillFetch content error', error); - } - } - return Promise.resolve(request); }; @@ -188,13 +135,9 @@ export class ApiPlugin implements WorkboxPlugin { return this.handlerDidErrorDelete(request); case 'update': return this.handlerDidErrorUpdate(request); - case 'content-update': - return this.handlerDidErrorContentUpdate(request); case 'list': case 'item': return this.handlerDidErrorRead(this.options.tableName, request.url); - case 'content': - return this.handlerDidErrorContent(request); } return Promise.resolve(ApiPlugin.getApiCatchHandler()); @@ -492,56 +435,4 @@ export class ApiPlugin implements WorkboxPlugin { }, }); }; - - private handlerDidErrorContent = async (request: Request) => { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - - if (!entry) { - return Promise.resolve(ApiPlugin.getApiCatchHandler()); - } - - return new Response(entry.content, { - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': 'text/plain', - ...(entry.etag && { ETag: entry.etag }), - ...(entry.lastModified && { 'Last-Modified': entry.lastModified }), - }, - }); - }; - - /** - * When the content update fails, we save the new content in the cache, and we will sync it later with the SyncManager. - * We return a 204 to the client to say that the update is successful, and we update the content in the cache so the - * client can see the new content while offline. - */ - private handlerDidErrorContentUpdate = async (request: Request) => { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - - if (!entry || !this.initialRequest) { - return new Response('Not found', { status: 404 }); - } - - await this.queueMutation(this.initialRequest); - - const bodyMutate = (await this.initialRequest - .clone() - .json()) as Partial; - const newContent = bodyMutate.content ?? entry.content; - await DocsDB.cacheResponse( - request.url, - { etag: '', lastModified: '', content: newContent }, - 'doc-content', - ); - - return new Response(null, { - status: 204, - statusText: 'No Content', - }); - }; } diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts index 1de19733e..80c8be8b6 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts @@ -62,42 +62,6 @@ registerRoute( 'GET', ); -registerRoute( - ({ url }) => - isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/content\/$/.test(url.href), - new NetworkOnly({ - plugins: [ - new ApiPlugin({ - tableName: 'doc-content', - type: 'content', - syncManager, - }), - new OfflinePlugin(), - ], - }), - 'GET', -); - -/** - * Mutate routes for the content update - * It will save in cache the request if the content update fails, and will retry - * to sync it later with the SyncManager - */ -registerRoute( - ({ url }) => - isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/content\/$/.test(url.href), - new NetworkOnly({ - plugins: [ - new ApiPlugin({ - type: 'content-update', - syncManager, - }), - new OfflinePlugin(), - ], - }), - 'PATCH', -); - /** * Mutate routes for the document update * It will save in cache the request if the document update fails, and will retry