diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index b0cc3c9b..7432004d 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 e29187b3..ac3a9b30 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 7b6bd2e7..eede9ba2 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 df846e67..6181ecab 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 e25122d4..d4b9d1e9 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 ed074c1e..d9df77b3 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 00000000..9721f267 --- /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 }; +};