🚸(frontend) let loader until resource ready

The backend can analyze the upload file, this take
time, so we need to show a loader
until the backend finish the analysis.
This commit is contained in:
Anthony LC
2025-05-19 22:48:55 +02:00
parent 074585337b
commit 6ca654bf1a
10 changed files with 310 additions and 9 deletions

View File

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

View File

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

View File

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

View File

@@ -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<CheckDocMediaStatusResponse> => {
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<CheckDocMediaStatusResponse>;
};

View File

@@ -0,0 +1,27 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#3b82f6"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="analyzing-spinner"
>
<circle cx="12" cy="12" r="10" opacity="0.2"></circle>
<path d="M12 2a10 10 0 0 1 10 10" opacity="0.8">
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="1s"
repeatCount="indefinite"
calcMode="spline"
keySplines="0.42 0 0.58 1"
keyTimes="0;1"
/>
</path>
</svg>

After

Width:  |  Height:  |  Size: 598 B

View File

@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="#FFA500"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View File

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

View File

@@ -32,7 +32,6 @@ export const ModalConfirmDownloadUnsafe = ({
aria-label={t('Download')}
color="danger"
onClick={() => {
console.log('onClick');
if (onConfirm) {
void onConfirm();
}

View File

@@ -1,2 +1,3 @@
export * from './useHeadings';
export * from './useSaveDoc';
export * from './useUploadFile';

View File

@@ -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<CheckDocMediaStatusResponse> 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<Record<string, NodeJS.Timeout>>({});
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]);
};