🚸(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -32,7 +32,6 @@ export const ModalConfirmDownloadUnsafe = ({
|
||||
aria-label={t('Download')}
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
console.log('onClick');
|
||||
if (onConfirm) {
|
||||
void onConfirm();
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './useHeadings';
|
||||
export * from './useSaveDoc';
|
||||
export * from './useUploadFile';
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user