diff --git a/CHANGELOG.md b/CHANGELOG.md index 660deabd..cf9798e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to - ✨(backend) integrate maleware_detection from django-lasuite #936 - 🩺(CI) add lint spell mistakes #954 - 🛂(frontend) block edition to not connected users #945 +- 🚸 Let loader during upload analyze #984 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 87d13c80..3996abb1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -425,6 +425,10 @@ test.describe('Doc Editor', () => { const downloadPromise = page.waitForEvent('download', (download) => { return download.suggestedFilename().includes(`html`); }); + const responseCheckPromise = page.waitForResponse( + (response) => + response.url().includes('media-check') && response.status() === 200, + ); await verifyDocName(page, randomDoc); @@ -439,6 +443,8 @@ test.describe('Doc Editor', () => { const fileChooser = await fileChooserPromise; await fileChooser.setFiles(path.join(__dirname, 'assets/test.html')); + await responseCheckPromise; + await page.locator('.bn-block-content[data-name="test.html"]').click(); await page.getByRole('button', { name: 'Download file' }).click(); @@ -455,6 +461,51 @@ test.describe('Doc Editor', () => { expect(svgBuffer.toString()).toContain('Hello svg'); }); + test('it analyzes uploads', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); + + let requestCount = 0; + await page.route( + /.*\/documents\/.*\/media-check\/\?key=.*/, + async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + status: requestCount ? 'ready' : 'processing', + file: '/anything.html', + }, + }); + + requestCount++; + } else { + await route.continue(); + } + }, + ); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await verifyDocName(page, randomDoc); + + const editor = page.locator('.ProseMirror.bn-editor'); + + await editor.click(); + await editor.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Embedded file').click(); + await page.getByText('Upload file').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.html')); + + await expect(editor.getByText('Analyzing file...')).toBeVisible(); + // The retry takes a few seconds + await expect(editor.getByText('test.html')).toBeVisible({ + timeout: 7000, + }); + await expect(editor.getByText('Analyzing file...')).toBeHidden(); + }); + test('it checks block editing when not connected to collab server', async ({ page, }) => { diff --git a/src/frontend/apps/impress/src/custom-next.d.ts b/src/frontend/apps/impress/src/custom-next.d.ts index 261cf96c..0e5e6acf 100644 --- a/src/frontend/apps/impress/src/custom-next.d.ts +++ b/src/frontend/apps/impress/src/custom-next.d.ts @@ -13,7 +13,13 @@ declare module '*.svg' { } declare module '*.svg?url' { - const content: string; + const content: { + src: string; + width: number; + height: number; + blurWidth: number; + blurHeight: number; + }; export default content; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/api/checkDocMediaStatus.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/api/checkDocMediaStatus.tsx new file mode 100644 index 00000000..81e3825e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/api/checkDocMediaStatus.tsx @@ -0,0 +1,27 @@ +import { APIError, errorCauses } from '@/api'; + +interface CheckDocMediaStatusResponse { + file?: string; + status: 'processing' | 'ready'; +} + +interface CheckDocMediaStatus { + urlMedia: string; +} + +export const checkDocMediaStatus = async ({ + urlMedia, +}: CheckDocMediaStatus): Promise => { + const response = await fetch(urlMedia, { + credentials: 'include', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to check the media status', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/loader.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/loader.svg new file mode 100644 index 00000000..c3a51be9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/loader.svg @@ -0,0 +1,27 @@ + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/warning.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/warning.svg new file mode 100644 index 00000000..531a6260 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/warning.svg @@ -0,0 +1,17 @@ + + + + + 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 e4240a02..7ed06af1 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 @@ -18,11 +18,11 @@ import { Box, TextErrors } from '@/components'; import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; -import { useUploadFile } from '../hook'; -import { useHeadings } from '../hook/useHeadings'; +import { useHeadings, useUploadFile, useUploadStatus } from '../hook/'; import useSaveDoc from '../hook/useSaveDoc'; import { useEditorStore } from '../stores'; import { cssEditor } from '../styles'; +import { DocsBlockNoteEditor } from '../types'; import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; @@ -63,7 +63,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { : user?.full_name || user?.email || t('Anonymous'); const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; - const editor = useCreateBlockNote( + const editor: DocsBlockNoteEditor = useCreateBlockNote( { codeBlock, collaboration: { @@ -127,7 +127,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, [collabName, lang, provider, uploadFile], ); + useHeadings(editor); + useUploadStatus(editor); useEffect(() => { setEditor(editor); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx index e38c8639..3929175c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx @@ -32,7 +32,6 @@ export const ModalConfirmDownloadUnsafe = ({ aria-label={t('Download')} color="danger" onClick={() => { - console.log('onClick'); if (onConfirm) { void onConfirm(); } 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 7cef4867..3934dfa2 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,2 +1,3 @@ +export * from './useHeadings'; export * from './useSaveDoc'; export * from './useUploadFile'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useUploadFile.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useUploadFile.tsx index df25f4ac..220bd291 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useUploadFile.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useUploadFile.tsx @@ -1,11 +1,86 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { backendUrl } from '@/api'; import { useMediaUrl } from '@/core/config'; +import { sleep } from '@/utils'; import { useCreateDocAttachment } from '../api'; +import { checkDocMediaStatus } from '../api/checkDocMediaStatus'; +import Loader from '../assets/loader.svg?url'; +import Warning from '../assets/warning.svg?url'; +import { DocsBlockNoteEditor } from '../types'; + +/** + * Upload file can be analyzed on the server side, + * we had this function to wait for the analysis to be done + * before returning the file url. It will keep the loader + * on the upload button until the analysis is done. + * @param url + * @returns Promise status_code + * @description Waits for the upload to be analyzed by checking the status of the file. + */ +const loopCheckDocMediaStatus = async (url: string) => { + const SLEEP_TIME = 5000; + const response = await checkDocMediaStatus({ + urlMedia: url, + }); + + if (response.status === 'ready') { + return response; + } else { + await sleep(SLEEP_TIME); + return await loopCheckDocMediaStatus(url); + } +}; + +const informationStatus = (src: string, text: string) => { + const loadingContainer = document.createElement('div'); + loadingContainer.style.display = 'flex'; + loadingContainer.style.alignItems = 'center'; + loadingContainer.style.justifyContent = 'left'; + loadingContainer.style.padding = '10px'; + loadingContainer.style.color = '#666'; + loadingContainer.className = + 'bn-visual-media bn-audio bn-file-name-with-icon'; + + // Create an image element for the SVG + const imgElement = document.createElement('img'); + imgElement.src = src; + + // Create a text span + const textSpan = document.createElement('span'); + textSpan.textContent = text; + textSpan.style.marginLeft = '8px'; + textSpan.style.verticalAlign = 'middle'; + imgElement.style.animation = 'spin 1.5s linear infinite'; + + // Add the spinner and text to the container + loadingContainer.appendChild(imgElement); + loadingContainer.appendChild(textSpan); + + return loadingContainer; +}; + +const replaceUploadContent = (blockId: string, elementReplace: HTMLElement) => { + const blockEl = document.body.querySelector( + `.bn-block[data-id="${blockId}"]`, + ); + + blockEl + ?.querySelector('.bn-visual-media-wrapper .bn-visual-media') + ?.replaceWith(elementReplace); + + blockEl + ?.querySelector('.bn-file-block-content-wrapper .bn-audio') + ?.replaceWith(elementReplace); + + blockEl + ?.querySelector('.bn-file-block-content-wrapper .bn-file-name-with-icon') + ?.replaceWith(elementReplace); +}; export const useUploadFile = (docId: string) => { - const mediaUrl = useMediaUrl(); const { mutateAsync: createDocAttachment, isError: isErrorAttachment, @@ -22,9 +97,9 @@ export const useUploadFile = (docId: string) => { body, }); - return `${mediaUrl}${ret.file}`; + return `${backendUrl()}${ret.file}`; }, - [createDocAttachment, docId, mediaUrl], + [createDocAttachment, docId], ); return { @@ -33,3 +108,98 @@ export const useUploadFile = (docId: string) => { errorAttachment, }; }; + +export const useUploadStatus = (editor: DocsBlockNoteEditor) => { + const ANALYZE_URL = 'media-check'; + const { t } = useTranslation(); + const mediaUrl = useMediaUrl(); + const timeoutIds = useRef>({}); + + const blockAnalyzeProcess = useCallback( + (editor: DocsBlockNoteEditor, blockId: string, url: string) => { + if (timeoutIds.current[url]) { + clearTimeout(timeoutIds.current[url]); + } + + // Delay to let the time to the dom to be rendered + const timoutId = setTimeout(() => { + replaceUploadContent( + blockId, + informationStatus(Loader.src, t('Analyzing file...')), + ); + + loopCheckDocMediaStatus(url) + .then((response) => { + const block = editor.getBlock(blockId); + if (!block) { + return; + } + + block.props = { + ...block.props, + url: `${mediaUrl}${response.file}`, + }; + + editor.updateBlock(blockId, block); + }) + .catch((error) => { + console.error('Error analyzing file:', error); + + replaceUploadContent( + blockId, + informationStatus(Warning.src, t('The analyze failed...')), + ); + }); + }, 250); + + timeoutIds.current[url] = timoutId; + }, + [t, mediaUrl], + ); + + useEffect(() => { + const blocksAnalyze = editor?.document.filter( + (block) => 'url' in block.props && block.props.url.includes(ANALYZE_URL), + ); + + if (!blocksAnalyze?.length) { + return; + } + + blocksAnalyze.forEach((block) => { + if (!('url' in block.props)) { + return; + } + + blockAnalyzeProcess(editor, block.id, block.props.url); + }); + }, [blockAnalyzeProcess, editor]); + + useEffect(() => { + editor.onChange((_, context) => { + const blocksChanges = context.getChanges(); + + if (!blocksChanges.length) { + return; + } + + const blockChanges = blocksChanges[0]; + + if ( + blockChanges.source.type !== 'local' || + blockChanges.type !== 'update' || + !('url' in blockChanges.block.props) || + ('url' in blockChanges.block.props && + !blockChanges.block.props.url.includes(ANALYZE_URL)) + ) { + return; + } + + blockAnalyzeProcess( + editor, + blockChanges.block.id, + blockChanges.block.props.url, + ); + }); + }, [blockAnalyzeProcess, mediaUrl, editor, t]); +};