mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
[V2] feat(unzip/front-end): Implement ZIP extraction confirmation for archives over 20 files (#4834)
# Description of Changes TLDR: - Introduced a user confirmation dialog for extracting ZIP files with more than **20 files**. - Created `useZipConfirmation` hook to handle confirmation dialog logic and state. - Implemented `ZipWarningModal` component to display the confirmation dialog. - Updated `zipFileService` to count files in ZIP and trigger confirmation callback for large files. - Integrated confirmation flow into `FileContext` and `useToolResources`. - Added translations for new ZIP warning dialog messages. This pull request introduces a user confirmation dialog when attempting to extract large ZIP files (**over 20 files**), improving safety and user experience by preventing accidental extraction of very large archives. The implementation includes a reusable confirmation modal, a custom hook to handle dialog state and resolution, and updates to the ZIP extraction logic to support this workflow. **User Experience Improvements** * Added a new localized warning dialog (`ZipWarningModal`) that prompts users for confirmation when extracting ZIP files containing more than 20 files. This dialog displays the ZIP file name, file count, and offers "Cancel" and "Extract" actions, with responsive layouts for desktop and mobile **ZIP Extraction Workflow Enhancements** * Updated the ZIP extraction logic in `ZipFileService` to count the number of files in a ZIP and invoke a confirmation callback if the file count exceeds the threshold. Extraction proceeds only if the user confirms; otherwise, the ZIP remains unextracted. * Added a new hook (`useZipConfirmation`) to manage the confirmation dialog’s state and provide a promise-based API for requesting user confirmation. **Integration with Application State** * Integrated the confirmation workflow into `FileContext`, passing the confirmation function into ZIP extraction calls and rendering the modal dialog at the appropriate time. * Updated relevant interfaces and method signatures to support the optional confirmation callback for large ZIP extractions throughout the codebase. <img width="515" height="321" alt="image" src="https://github.com/user-attachments/assets/f35a7588-4635-4ccd-9ee6-95edb17fee99" /> <img width="515" height="321" alt="image" src="https://github.com/user-attachments/assets/0525acf3-4174-42cd-8912-910e754c467c" /> <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
parent
c8615518a6
commit
74a1438c21
@ -35,6 +35,12 @@
|
||||
"discardChanges": "Discard & Leave",
|
||||
"applyAndContinue": "Save & Leave",
|
||||
"exportAndContinue": "Export & Continue",
|
||||
"zipWarning": {
|
||||
"title": "Large ZIP File",
|
||||
"message": "This ZIP contains {{count}} files. Extract anyway?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Extract"
|
||||
},
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
|
||||
96
frontend/src/core/components/shared/ZipWarningModal.tsx
Normal file
96
frontend/src/core/components/shared/ZipWarningModal.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
interface ZipWarningModalProps {
|
||||
opened: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
fileCount: number;
|
||||
zipFileName: string;
|
||||
}
|
||||
|
||||
const WARNING_ICON_STYLE: CSSProperties = {
|
||||
fontSize: 36,
|
||||
display: 'block',
|
||||
margin: '0 auto 8px',
|
||||
color: 'var(--mantine-color-blue-6)'
|
||||
};
|
||||
|
||||
const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("zipWarning.title", "Large ZIP File")}
|
||||
centered
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Stack ta="center" p="md" gap="sm">
|
||||
<WarningAmberIcon style={WARNING_ICON_STYLE} />
|
||||
<Text size="md" fw={300}>
|
||||
{zipFileName}
|
||||
</Text>
|
||||
<Text size="lg" fw={500}>
|
||||
{t("zipWarning.message", {
|
||||
count: fileCount,
|
||||
defaultValue: "This ZIP contains {{count}} files. Extract anyway?"
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Desktop layout: centered buttons */}
|
||||
<Group justify="center" gap="sm" visibleFrom="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="var(--mantine-color-gray-8)"
|
||||
onClick={onCancel}
|
||||
leftSection={<CancelIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="var(--mantine-color-blue-9)"
|
||||
onClick={onConfirm}
|
||||
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.confirm", "Extract")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Mobile layout: vertical stack */}
|
||||
<Stack align="center" gap="sm" hiddenFrom="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="var(--mantine-color-gray-8)"
|
||||
onClick={onCancel}
|
||||
leftSection={<CancelIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="var(--mantine-color-blue-9)"
|
||||
onClick={onConfirm}
|
||||
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.confirm", "Extract")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZipWarningModal;
|
||||
@ -31,6 +31,8 @@ import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createF
|
||||
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext';
|
||||
import { useZipConfirmation } from '@app/hooks/useZipConfirmation';
|
||||
import ZipWarningModal from '@app/components/shared/ZipWarningModal';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@ -52,6 +54,9 @@ function FileContextInner({
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
|
||||
// ZIP confirmation dialog
|
||||
const { confirmationState, requestConfirmation, handleConfirm, handleCancel } = useZipConfirmation();
|
||||
|
||||
// Create lifecycle manager
|
||||
const lifecycleManagerRef = useRef<FileLifecycleManager | null>(null);
|
||||
if (!lifecycleManagerRef.current) {
|
||||
@ -86,7 +91,9 @@ function FileContextInner({
|
||||
...options,
|
||||
// For direct file uploads: ALWAYS unzip (except HTML ZIPs)
|
||||
// skipAutoUnzip bypasses preference checks - HTML detection still applies
|
||||
skipAutoUnzip: true
|
||||
skipAutoUnzip: true,
|
||||
// Provide confirmation callback for large ZIP files
|
||||
confirmLargeExtraction: requestConfirmation
|
||||
},
|
||||
stateRef,
|
||||
filesRef,
|
||||
@ -101,7 +108,7 @@ function FileContextInner({
|
||||
}
|
||||
|
||||
return stirlingFiles;
|
||||
}, [enablePersistence]);
|
||||
}, [enablePersistence, requestConfirmation]);
|
||||
|
||||
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
|
||||
@ -237,6 +244,13 @@ function FileContextInner({
|
||||
<FileStateContext.Provider value={stateValue}>
|
||||
<FileActionsContext.Provider value={actionsValue}>
|
||||
{children}
|
||||
<ZipWarningModal
|
||||
opened={confirmationState.opened}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
fileCount={confirmationState.fileCount}
|
||||
zipFileName={confirmationState.fileName}
|
||||
/>
|
||||
</FileActionsContext.Provider>
|
||||
</FileStateContext.Provider>
|
||||
);
|
||||
|
||||
@ -186,6 +186,7 @@ interface AddFileOptions {
|
||||
autoUnzip?: boolean;
|
||||
autoUnzipFileLimit?: number;
|
||||
skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs.
|
||||
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>; // Optional callback to confirm extraction of large ZIP files
|
||||
}
|
||||
|
||||
/**
|
||||
@ -219,6 +220,7 @@ export async function addFiles(
|
||||
const autoUnzip = options.autoUnzip ?? true; // Default to true
|
||||
const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit
|
||||
const skipAutoUnzip = options.skipAutoUnzip ?? false;
|
||||
const confirmLargeExtraction = options.confirmLargeExtraction;
|
||||
|
||||
for (const file of files) {
|
||||
// Check if file is a ZIP
|
||||
@ -238,7 +240,8 @@ export async function addFiles(
|
||||
const extractedFiles = await zipFileService.extractWithPreferences(file, {
|
||||
autoUnzip,
|
||||
autoUnzipFileLimit,
|
||||
skipAutoUnzip
|
||||
skipAutoUnzip,
|
||||
confirmLargeExtraction
|
||||
});
|
||||
|
||||
if (extractedFiles.length === 1 && extractedFiles[0] === file) {
|
||||
|
||||
@ -83,12 +83,17 @@ export const useToolResources = () => {
|
||||
return results;
|
||||
}, []);
|
||||
|
||||
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
|
||||
const extractZipFiles = useCallback(async (
|
||||
zipBlob: Blob,
|
||||
skipAutoUnzip = false,
|
||||
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>
|
||||
): Promise<File[]> => {
|
||||
try {
|
||||
return await zipFileService.extractWithPreferences(zipBlob, {
|
||||
autoUnzip: preferences.autoUnzip,
|
||||
autoUnzipFileLimit: preferences.autoUnzipFileLimit,
|
||||
skipAutoUnzip
|
||||
skipAutoUnzip,
|
||||
confirmLargeExtraction
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('useToolResources.extractZipFiles - Error:', error);
|
||||
|
||||
75
frontend/src/core/hooks/useZipConfirmation.ts
Normal file
75
frontend/src/core/hooks/useZipConfirmation.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface ZipConfirmationState {
|
||||
opened: boolean;
|
||||
fileCount: number;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage ZIP warning confirmation dialog
|
||||
* Returns state and handlers for the confirmation dialog
|
||||
* Uses useRef to avoid recreating callbacks on every state change
|
||||
*/
|
||||
export const useZipConfirmation = () => {
|
||||
const [confirmationState, setConfirmationState] = useState<ZipConfirmationState>({
|
||||
opened: false,
|
||||
fileCount: 0,
|
||||
fileName: '',
|
||||
});
|
||||
|
||||
// Store resolve function in ref to avoid callback recreation
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
/**
|
||||
* Request confirmation from user for extracting a large ZIP file
|
||||
* Returns a Promise that resolves to true if user confirms, false if cancelled
|
||||
*/
|
||||
const requestConfirmation = useCallback((fileCount: number, fileName: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setConfirmationState({
|
||||
opened: true,
|
||||
fileCount,
|
||||
fileName,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle user confirmation - extract the ZIP
|
||||
*/
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(true);
|
||||
resolveRef.current = null;
|
||||
}
|
||||
setConfirmationState({
|
||||
opened: false,
|
||||
fileCount: 0,
|
||||
fileName: '',
|
||||
});
|
||||
}, []); // No dependencies - uses ref
|
||||
|
||||
/**
|
||||
* Handle user cancellation - keep ZIP as-is
|
||||
*/
|
||||
const handleCancel = useCallback(() => {
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(false);
|
||||
resolveRef.current = null;
|
||||
}
|
||||
setConfirmationState({
|
||||
opened: false,
|
||||
fileCount: 0,
|
||||
fileName: '',
|
||||
});
|
||||
}, []); // No dependencies - uses ref
|
||||
|
||||
return {
|
||||
confirmationState,
|
||||
requestConfirmation,
|
||||
handleConfirm,
|
||||
handleCancel,
|
||||
};
|
||||
};
|
||||
@ -44,6 +44,9 @@ export class ZipFileService {
|
||||
private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file
|
||||
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
|
||||
|
||||
// Warn user when extracting ZIP with more than this many files
|
||||
public static readonly ZIP_WARNING_THRESHOLD = 20;
|
||||
|
||||
// ZIP file validation constants
|
||||
private static readonly VALID_ZIP_TYPES = [
|
||||
'application/zip',
|
||||
@ -361,31 +364,35 @@ export class ZipFileService {
|
||||
|
||||
/**
|
||||
* Determine if a ZIP file should be extracted based on user preferences
|
||||
* Returns both the extraction decision and file count to avoid redundant ZIP parsing
|
||||
*
|
||||
* @param zipBlob - The ZIP file to check
|
||||
* @param autoUnzip - User preference for auto-unzipping
|
||||
* @param autoUnzipFileLimit - Maximum number of files to auto-extract
|
||||
* @param skipAutoUnzip - Bypass preference check (for automation)
|
||||
* @returns true if the ZIP should be extracted, false otherwise
|
||||
* @returns Object with shouldExtract flag and fileCount
|
||||
*/
|
||||
async shouldUnzip(
|
||||
zipBlob: Blob | File,
|
||||
autoUnzip: boolean,
|
||||
autoUnzipFileLimit: number,
|
||||
skipAutoUnzip: boolean = false
|
||||
): Promise<boolean> {
|
||||
): Promise<{ shouldExtract: boolean; fileCount: number }> {
|
||||
try {
|
||||
// Automation always extracts
|
||||
// Automation always extracts - but still need to count files for warning
|
||||
if (skipAutoUnzip) {
|
||||
return true;
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(zipBlob);
|
||||
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
|
||||
return { shouldExtract: true, fileCount };
|
||||
}
|
||||
|
||||
// Check if auto-unzip is enabled
|
||||
if (!autoUnzip) {
|
||||
return false;
|
||||
return { shouldExtract: false, fileCount: 0 };
|
||||
}
|
||||
|
||||
// Load ZIP and count files
|
||||
// Load ZIP and count files (single parse)
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(zipBlob);
|
||||
|
||||
@ -393,20 +400,22 @@ export class ZipFileService {
|
||||
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
|
||||
|
||||
// Only extract if within limit
|
||||
return fileCount <= autoUnzipFileLimit;
|
||||
return {
|
||||
shouldExtract: fileCount <= autoUnzipFileLimit,
|
||||
fileCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking shouldUnzip:', error);
|
||||
// On error, default to not extracting (safer)
|
||||
return false;
|
||||
return { shouldExtract: false, fileCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract files from ZIP with HTML detection and preference checking
|
||||
* This is the unified method that handles the common pattern of:
|
||||
* 1. Check for HTML files → keep zipped if present
|
||||
* 2. Check user preferences → respect autoUnzipFileLimit
|
||||
* 3. Extract files if appropriate
|
||||
* 3. Show warning for large ZIPs (>20 files) if callback provided
|
||||
* 4. Extract files if appropriate
|
||||
*
|
||||
* @param zipBlob - The ZIP blob to process
|
||||
* @param options - Extraction options
|
||||
@ -418,6 +427,7 @@ export class ZipFileService {
|
||||
autoUnzip: boolean;
|
||||
autoUnzipFileLimit: number;
|
||||
skipAutoUnzip?: boolean;
|
||||
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>;
|
||||
}
|
||||
): Promise<File[]> {
|
||||
try {
|
||||
@ -432,8 +442,8 @@ export class ZipFileService {
|
||||
return [zipFile];
|
||||
}
|
||||
|
||||
// Check if we should extract based on preferences
|
||||
const shouldExtract = await this.shouldUnzip(
|
||||
// Check if we should extract based on preferences (returns both decision and count)
|
||||
const { shouldExtract, fileCount } = await this.shouldUnzip(
|
||||
zipBlob,
|
||||
options.autoUnzip,
|
||||
options.autoUnzipFileLimit,
|
||||
@ -444,6 +454,14 @@ export class ZipFileService {
|
||||
return [zipFile];
|
||||
}
|
||||
|
||||
// Warn user if ZIP is large (fileCount already obtained from shouldUnzip)
|
||||
if (fileCount > ZipFileService.ZIP_WARNING_THRESHOLD && options.confirmLargeExtraction) {
|
||||
const userConfirmed = await options.confirmLargeExtraction(fileCount, zipFile.name);
|
||||
if (!userConfirmed) {
|
||||
return [zipFile]; // User cancelled, keep ZIP as-is
|
||||
}
|
||||
}
|
||||
|
||||
// Extract all files
|
||||
const extractionResult = await this.extractAllFiles(zipFile);
|
||||
return extractionResult.success ? extractionResult.extractedFiles : [zipFile];
|
||||
|
||||
@ -20,6 +20,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '@app/i18n/config';
|
||||
import { createTestStirlingFile } from '@app/tests/utils/testFileHelpers';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
|
||||
// Mock axios (for static methods like CancelToken, isCancel)
|
||||
vi.mock('axios', () => ({
|
||||
@ -88,13 +89,15 @@ const createPDFFile = (): StirlingFile => {
|
||||
|
||||
// Test wrapper component
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nextProvider>
|
||||
<MantineProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nextProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
describe('Convert Tool Integration Tests', () => {
|
||||
|
||||
@ -15,6 +15,7 @@ import i18n from '@app/i18n/config';
|
||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
||||
import { FIT_OPTIONS } from '@app/constants/convertConstants';
|
||||
import { createTestStirlingFile, createTestFilesWithId } from '@app/tests/utils/testFileHelpers';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
|
||||
// Mock axios (for static methods like CancelToken, isCancel)
|
||||
vi.mock('axios', () => ({
|
||||
@ -76,13 +77,15 @@ vi.mock('../../services/thumbnailGenerationService', () => ({
|
||||
}));
|
||||
|
||||
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nextProvider>
|
||||
<MantineProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nextProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user