From b8bdcbf7ed3a8ad764615478e44e954fc2d91364 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 21 Jan 2026 10:30:24 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=82(frontend)=20use=20max=20size=20and?= =?UTF-8?q?=20extension=20from=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The max size and allowed extensions for document import are now fetched from the application configuration. This ensures consistency across the app and allows for easier updates to these settings in the future. --- src/backend/core/api/viewsets.py | 2 + src/backend/core/tests/test_api_config.py | 2 + .../e2e/__tests__/app-impress/utils-common.ts | 2 + .../impress/src/core/config/api/useConfig.tsx | 2 + .../docs/docs-grid/api/useImportDoc.tsx | 39 ++---- .../docs/docs-grid/components/DocsGrid.tsx | 64 +--------- .../docs/docs-grid/hooks/useImport.tsx | 116 ++++++++++++++++++ 7 files changed, 137 insertions(+), 90 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useImport.tsx diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index b0cc3c9b2..7432004d9 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2340,6 +2340,8 @@ class ConfigView(drf.views.APIView): "AI_FEATURE_ENABLED", "COLLABORATION_WS_URL", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY", + "CONVERSION_FILE_EXTENSIONS_ALLOWED", + "CONVERSION_FILE_MAX_SIZE", "CRISP_WEBSITE_ID", "ENVIRONMENT", "FRONTEND_CSS_URL", diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index e29187b3e..ac3a9b309 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -46,6 +46,8 @@ def test_api_config(is_authenticated): "AI_FEATURE_ENABLED": False, "COLLABORATION_WS_URL": "http://testcollab/", "COLLABORATION_WS_NOT_CONNECTED_READY_ONLY": True, + "CONVERSION_FILE_EXTENSIONS_ALLOWED": [".docx", ".md"], + "CONVERSION_FILE_MAX_SIZE": 20971520, "CRISP_WEBSITE_ID": "123", "ENVIRONMENT": "test", "FRONTEND_CSS_URL": "http://testcss/", 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 7b6bd2e7c..eede9ba29 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -8,6 +8,8 @@ export const CONFIG = { CRISP_WEBSITE_ID: null, COLLABORATION_WS_URL: 'ws://localhost:4444/collaboration/ws/', COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, + CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'], + CONVERSION_FILE_MAX_SIZE: 20971520, ENVIRONMENT: 'development', FRONTEND_CSS_URL: null, FRONTEND_JS_URL: null, 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 df846e679..6181ecabf 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -18,6 +18,8 @@ export interface ConfigResponse { AI_FEATURE_ENABLED?: boolean; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; + CONVERSION_FILE_EXTENSIONS_ALLOWED: string[]; + CONVERSION_FILE_MAX_SIZE: number; CRISP_WEBSITE_ID?: string; ENVIRONMENT: string; FRONTEND_CSS_URL?: string; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx index e25122d48..d4b9d1e93 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx @@ -17,43 +17,22 @@ import { } from '@/api'; import { Doc, DocsResponse, KEY_LIST_DOC } from '@/docs/doc-management'; -enum ContentTypes { +export enum ContentTypes { Docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', Markdown = 'text/markdown', OctetStream = 'application/octet-stream', } -export enum ContentTypesAllowed { - Docx = ContentTypes.Docx, - Markdown = ContentTypes.Markdown, -} - -const getMimeType = (file: File): string => { - if (file.type) { - return file.type; - } - - const extension = file.name.split('.').pop()?.toLowerCase(); - - switch (extension) { - case 'md': - return ContentTypes.Markdown; - case 'markdown': - return ContentTypes.Markdown; - case 'docx': - return ContentTypes.Docx; - default: - return ContentTypes.OctetStream; - } -}; - -export const importDoc = async (file: File): Promise => { +export const importDoc = async ([file, mimeType]: [ + File, + string, +]): Promise => { const form = new FormData(); form.append( 'file', new File([file], file.name, { - type: getMimeType(file), + type: mimeType, lastModified: file.lastModified, }), ); @@ -71,14 +50,14 @@ export const importDoc = async (file: File): Promise => { return response.json() as Promise; }; -type UseImportDocOptions = UseMutationOptions; +type UseImportDocOptions = UseMutationOptions; export function useImportDoc(props?: UseImportDocOptions) { const { toast } = useToastProvider(); const queryClient = useQueryClient(); const { t } = useTranslation(); - return useMutation({ + return useMutation({ mutationFn: importDoc, ...props, onSuccess: (...successProps) => { @@ -135,7 +114,7 @@ export function useImportDoc(props?: UseImportDocOptions) { onError: (...errorProps) => { toast( t(`The document "{{documentName}}" import has failed`, { - documentName: errorProps?.[1].name || '', + documentName: errorProps?.[1][0].name || '', }), VariantType.ERROR, ); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx index ed074c1ec..d9df77b3e 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -1,11 +1,8 @@ import { Button, Tooltip as TooltipBase, - VariantType, - useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; import { useMemo, useState } from 'react'; -import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { InView } from 'react-intersection-observer'; import styled, { css } from 'styled-components'; @@ -16,7 +13,7 @@ import { DocDefaultFilter, useInfiniteDocs } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; import { useInfiniteDocsTrashbin } from '../api'; -import { ContentTypesAllowed, useImportDoc } from '../api/useImportDoc'; +import { useImport } from '../hooks/useImport'; import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; import { @@ -45,64 +42,11 @@ export const DocsGrid = ({ }: DocsGridProps) => { const { t } = useTranslation(); const [isDragOver, setIsDragOver] = useState(false); - const { toast } = useToastProvider(); - - const MAX_FILE_SIZE = { - bytes: 10 * 1024 * 1024, // 10 MB - text: '10MB', - }; - - const { getRootProps, getInputProps, open } = useDropzone({ - accept: { - [ContentTypesAllowed.Docx]: ['.docx'], - [ContentTypesAllowed.Markdown]: ['.md'], + const { getRootProps, getInputProps, open } = useImport({ + onDragOver: (dragOver: boolean) => { + setIsDragOver(dragOver); }, - maxSize: MAX_FILE_SIZE.bytes, - onDrop(acceptedFiles) { - setIsDragOver(false); - for (const file of acceptedFiles) { - importDoc(file); - } - }, - onDragEnter: () => { - setIsDragOver(true); - }, - onDragLeave: () => { - setIsDragOver(false); - }, - onDropRejected(fileRejections) { - fileRejections.forEach((rejection) => { - const isFileTooLarge = rejection.errors.some( - (error) => error.code === 'file-too-large', - ); - - if (isFileTooLarge) { - toast( - t( - 'The document "{{documentName}}" is too large. Maximum file size is {{maxFileSize}}.', - { - documentName: rejection.file.name, - maxFileSize: MAX_FILE_SIZE.text, - }, - ), - VariantType.ERROR, - ); - } else { - toast( - t( - `The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`, - { - documentName: rejection.file.name, - }, - ), - VariantType.ERROR, - ); - } - }); - }, - noClick: true, }); - const { mutate: importDoc } = useImportDoc(); const withUpload = !target || diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useImport.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useImport.tsx new file mode 100644 index 000000000..9721f2677 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useImport.tsx @@ -0,0 +1,116 @@ +import { + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/cunningham-react'; +import { t } from 'i18next'; +import { useMemo } from 'react'; +import { useDropzone } from 'react-dropzone'; + +import { useConfig } from '@/core'; + +import { ContentTypes, useImportDoc } from '../api/useImportDoc'; + +interface UseImportProps { + onDragOver: (isDragOver: boolean) => void; +} + +export const useImport = ({ onDragOver }: UseImportProps) => { + const { toast } = useToastProvider(); + const { data: config } = useConfig(); + + const MAX_FILE_SIZE = useMemo(() => { + const maxSizeInBytes = config?.CONVERSION_FILE_MAX_SIZE ?? 10 * 1024 * 1024; // Default to 10MB + + const units = ['bytes', 'KB', 'MB', 'GB']; + let size = maxSizeInBytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + return { + bytes: maxSizeInBytes, + text: `${Math.round(size * 10) / 10}${units[unitIndex]}`, + }; + }, [config?.CONVERSION_FILE_MAX_SIZE]); + + const ACCEPT = useMemo(() => { + const extensions = config?.CONVERSION_FILE_EXTENSIONS_ALLOWED; + const accept: { [key: string]: string[] } = {}; + + if (extensions && extensions.length > 0) { + extensions.forEach((ext) => { + switch (ext.toLowerCase()) { + case '.docx': + accept[ContentTypes.Docx] = ['.docx']; + break; + case '.md': + case '.markdown': + accept[ContentTypes.Markdown] = ['.md']; + break; + default: + break; + } + }); + } else { + // Default to docx and md if no configuration is provided + accept[ContentTypes.Docx] = ['.docx']; + accept[ContentTypes.Markdown] = ['.md']; + } + + return accept; + }, [config?.CONVERSION_FILE_EXTENSIONS_ALLOWED]); + + const { getRootProps, getInputProps, open } = useDropzone({ + accept: ACCEPT, + maxSize: MAX_FILE_SIZE.bytes, + onDrop(acceptedFiles) { + onDragOver(false); + for (const file of acceptedFiles) { + importDoc([file, file.type]); + } + }, + onDragEnter: () => { + onDragOver(true); + }, + onDragLeave: () => { + onDragOver(false); + }, + onDropRejected(fileRejections) { + fileRejections.forEach((rejection) => { + const isFileTooLarge = rejection.errors.some( + (error) => error.code === 'file-too-large', + ); + + if (isFileTooLarge) { + toast( + t( + 'The document "{{documentName}}" is too large. Maximum file size is {{maxFileSize}}.', + { + documentName: rejection.file.name, + maxFileSize: MAX_FILE_SIZE.text, + }, + ), + VariantType.ERROR, + ); + } else { + toast( + t( + `The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`, + { + documentName: rejection.file.name, + }, + ), + VariantType.ERROR, + ); + } + }); + }, + noClick: true, + }); + const { mutate: importDoc } = useImportDoc(); + + return { getRootProps, getInputProps, open }; +};