From 90f0c5826a6f09db54a1b27e1c8d427e24aef562 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 4 Aug 2025 15:01:36 +0100 Subject: [PATCH] Added structure for filemanager (#4078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overview Replaced scattered file inputs with a unified modal-based upload system. Users now upload files via a global Files button with intelligent tool-aware filtering. Key Changes 🔄 New Upload Flow - Before: Direct file inputs throughout the UI - After: Single Files button → Modal → Tool filters files automatically 🎯 Smart File Filtering - Modal shows only supported file types based on selected tool - Visual indicators for unsupported files (grayed out + badges) - Automatic duplicate detection ✨ Enhanced UX - Files button shows active state when modal is open - Consistent upload experience across all tools - Professional modal workflow Architecture New Components FilesModalProvider → FileUploadModal → Tool-aware filtering Button System Redesign type: 'navigation' | 'modal' | 'action' // Only navigation buttons stay active // Modal buttons show active when modal open Files Changed - ✅ QuickAccessBar.tsx - Added Files button - ✅ FileUploadModal.tsx - New tool-aware modal - ✅ HomePage.tsx - Integrated modal system - ✅ ConvertE2E.spec.ts - Updated tests for modal workflow Benefits - Unified UX: One place to upload files - Smart Filtering: Only see relevant file types - Better Architecture: Clean separation of concerns - Improved Testing: Reliable test automation Migration: File uploads now go through Files button → modal instead of direct inputs. All existing functionality preserved. --------- Co-authored-by: Connor Yoh --- frontend/src/App.tsx | 5 +- .../src/components/fileEditor/FileEditor.tsx | 68 +++++++--------- .../src/components/shared/FileUploadModal.tsx | 36 +++++++++ .../src/components/shared/LandingPage.tsx | 30 +++++++ .../src/components/shared/QuickAccessBar.tsx | 79 ++++++++++++------- .../tools/convert/ConvertSettings.tsx | 4 +- frontend/src/contexts/FilesModalContext.tsx | 30 +++++++ frontend/src/hooks/useFileHandler.ts | 27 +++++++ frontend/src/hooks/useFilesModal.ts | 57 +++++++++++++ frontend/src/pages/HomePage.tsx | 67 +++++----------- frontend/src/tests/convert/ConvertE2E.spec.ts | 55 +++++++------ 11 files changed, 318 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/shared/FileUploadModal.tsx create mode 100644 frontend/src/components/shared/LandingPage.tsx create mode 100644 frontend/src/contexts/FilesModalContext.tsx create mode 100644 frontend/src/hooks/useFileHandler.ts create mode 100644 frontend/src/hooks/useFilesModal.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de5001850..852204b25 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider'; import { FileContextProvider } from './contexts/FileContext'; +import { FilesModalProvider } from './contexts/FilesModalContext'; import HomePage from './pages/HomePage'; // Import global styles @@ -11,7 +12,9 @@ export default function App() { return ( - + + + ); diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index b4222d9ae..ca5f594b8 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -665,46 +665,35 @@ const FileEditor = ({ return ( - - + + + - - - {showBulkActions && !toolMode && ( - <> - - - - - )} - - {/* Load from storage and upload buttons */} - {showUpload && ( - <> - - - - + + - - - )} - + + )} + {files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? ( @@ -866,7 +855,8 @@ const FileEditor = ({ {error} )} - + + ); }; diff --git a/frontend/src/components/shared/FileUploadModal.tsx b/frontend/src/components/shared/FileUploadModal.tsx new file mode 100644 index 000000000..a83e96e62 --- /dev/null +++ b/frontend/src/components/shared/FileUploadModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Modal } from '@mantine/core'; +import FileUploadSelector from './FileUploadSelector'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; +import { Tool } from '../../types/tool'; + +interface FileUploadModalProps { + selectedTool?: Tool | null; +} + +const FileUploadModal: React.FC = ({ selectedTool }) => { + const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext(); + + + return ( + + + + ); +}; + +export default FileUploadModal; \ No newline at end of file diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx new file mode 100644 index 000000000..977f1f280 --- /dev/null +++ b/frontend/src/components/shared/LandingPage.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Container, Stack, Text, Button } from '@mantine/core'; +import FolderIcon from '@mui/icons-material/FolderRounded'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; + +interface LandingPageProps { + title: string; +} + +const LandingPage = ({ title }: LandingPageProps) => { + const { openFilesModal } = useFilesModalContext(); + return ( + + + + {title} + + + + + ); +}; + +export default LandingPage; \ No newline at end of file diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index 22a49617e..2f78a0a9f 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -11,6 +11,7 @@ import { useRainbowThemeContext } from "./RainbowThemeProvider"; import rainbowStyles from '../../styles/rainbow.module.css'; import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; import './QuickAccessBar.css'; interface QuickAccessBarProps { @@ -30,6 +31,7 @@ interface ButtonConfig { isRound?: boolean; size?: 'sm' | 'md' | 'lg' | 'xl'; onClick: () => void; + type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions } function NavHeader({ @@ -111,11 +113,16 @@ const QuickAccessBar = ({ readerMode, }: QuickAccessBarProps) => { const { isRainbowMode } = useRainbowThemeContext(); + const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); const isOverflow = useIsOverflowing(scrollableRef); + const handleFilesButtonClick = () => { + openFilesModal(); + }; + const buttonConfigs: ButtonConfig[] = [ { id: 'read', @@ -124,6 +131,7 @@ const QuickAccessBar = ({ tooltip: 'Read documents', size: 'lg', isRound: false, + type: 'navigation', onClick: () => { setActiveButton('read'); onReaderToggle(); @@ -139,6 +147,7 @@ const QuickAccessBar = ({ tooltip: 'Sign your document', size: 'lg', isRound: false, + type: 'navigation', onClick: () => setActiveButton('sign') }, { @@ -148,6 +157,7 @@ const QuickAccessBar = ({ tooltip: 'Automate workflows', size: 'lg', isRound: false, + type: 'navigation', onClick: () => setActiveButton('automate') }, { @@ -157,7 +167,8 @@ const QuickAccessBar = ({ tooltip: 'Manage files', isRound: true, size: 'lg', - onClick: () => setActiveButton('files') + type: 'modal', + onClick: handleFilesButtonClick }, { id: 'activity', @@ -169,6 +180,7 @@ const QuickAccessBar = ({ tooltip: 'View activity and analytics', isRound: true, size: 'lg', + type: 'navigation', onClick: () => setActiveButton('activity') }, { @@ -177,6 +189,7 @@ const QuickAccessBar = ({ icon: , tooltip: 'Configure settings', size: 'lg', + type: 'modal', onClick: () => { setConfigModalOpen(true); } @@ -190,8 +203,16 @@ const QuickAccessBar = ({ return config.isRound ? CIRCULAR_BORDER_RADIUS : ROUND_BORDER_RADIUS; }; + const isButtonActive = (config: ButtonConfig): boolean => { + return ( + (config.type === 'navigation' && activeButton === config.id) || + (config.type === 'modal' && config.id === 'files' && isFilesModalOpen) || + (config.type === 'modal' && config.id === 'config' && configModalOpen) + ); + }; + const getButtonStyle = (config: ButtonConfig) => { - const isActive = activeButton === config.id; + const isActive = isButtonActive(config); if (isActive) { return { @@ -202,7 +223,7 @@ const QuickAccessBar = ({ }; } - // Inactive state - use consistent inactive colors + // Inactive state for all buttons return { backgroundColor: 'var(--icon-inactive-bg)', color: 'var(--icon-inactive-color)', @@ -254,13 +275,14 @@ const QuickAccessBar = ({ variant="subtle" onClick={config.onClick} style={getButtonStyle(config)} - className={activeButton === config.id ? 'activeIconScale' : ''} + className={isButtonActive(config) ? 'activeIconScale' : ''} + data-testid={`${config.id}-button`} > {config.icon} - + {config.name} @@ -281,30 +303,29 @@ const QuickAccessBar = ({
{/* Config button at the bottom */} - -
- { - setConfigModalOpen(true); - }} - style={{ - backgroundColor: 'var(--icon-inactive-bg)', - color: 'var(--icon-inactive-color)', - border: 'none', - borderRadius: '8px', - }} - > - - - - - - Config - -
-
+ {buttonConfigs + .filter(config => config.id === 'config') + .map(config => ( + +
+ + + {config.icon} + + + + {config.name} + +
+
+ ))}
diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx index fa6134f54..a3051c88f 100644 --- a/frontend/src/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/components/tools/convert/ConvertSettings.tsx @@ -198,7 +198,7 @@ const ConvertSettings = ({ (null); + +export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { addToActiveFiles, addMultipleFiles } = useFileHandler(); + + const filesModal = useFilesModal({ + onFileSelect: addToActiveFiles, + onFilesSelect: addMultipleFiles, + }); + + return ( + + {children} + + ); +}; + +export const useFilesModalContext = () => { + const context = useContext(FilesModalContext); + if (!context) { + throw new Error('useFilesModalContext must be used within FilesModalProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useFileHandler.ts b/frontend/src/hooks/useFileHandler.ts new file mode 100644 index 000000000..efd988906 --- /dev/null +++ b/frontend/src/hooks/useFileHandler.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { useFileContext } from '../contexts/FileContext'; + +export const useFileHandler = () => { + const { activeFiles, addFiles } = useFileContext(); + + const addToActiveFiles = useCallback(async (file: File) => { + const exists = activeFiles.some(f => f.name === file.name && f.size === file.size); + if (!exists) { + await addFiles([file]); + } + }, [activeFiles, addFiles]); + + const addMultipleFiles = useCallback(async (files: File[]) => { + const newFiles = files.filter(file => + !activeFiles.some(f => f.name === file.name && f.size === file.size) + ); + if (newFiles.length > 0) { + await addFiles(newFiles); + } + }, [activeFiles, addFiles]); + + return { + addToActiveFiles, + addMultipleFiles, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useFilesModal.ts b/frontend/src/hooks/useFilesModal.ts new file mode 100644 index 000000000..49e9f2c5e --- /dev/null +++ b/frontend/src/hooks/useFilesModal.ts @@ -0,0 +1,57 @@ +import { useState, useCallback } from 'react'; + +export interface UseFilesModalReturn { + isFilesModalOpen: boolean; + openFilesModal: () => void; + closeFilesModal: () => void; + onFileSelect?: (file: File) => void; + onFilesSelect?: (files: File[]) => void; + onModalClose?: () => void; + setOnModalClose: (callback: () => void) => void; +} + +interface UseFilesModalProps { + onFileSelect?: (file: File) => void; + onFilesSelect?: (files: File[]) => void; +} + +export const useFilesModal = ({ + onFileSelect, + onFilesSelect +}: UseFilesModalProps = {}): UseFilesModalReturn => { + const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); + const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); + + const openFilesModal = useCallback(() => { + setIsFilesModalOpen(true); + }, []); + + const closeFilesModal = useCallback(() => { + setIsFilesModalOpen(false); + onModalClose?.(); + }, [onModalClose]); + + const handleFileSelect = useCallback((file: File) => { + onFileSelect?.(file); + closeFilesModal(); + }, [onFileSelect, closeFilesModal]); + + const handleFilesSelect = useCallback((files: File[]) => { + onFilesSelect?.(files); + closeFilesModal(); + }, [onFilesSelect, closeFilesModal]); + + const setModalCloseCallback = useCallback((callback: () => void) => { + setOnModalClose(() => callback); + }, []); + + return { + isFilesModalOpen, + openFilesModal, + closeFilesModal, + onFileSelect: handleFileSelect, + onFilesSelect: handleFilesSelect, + onModalClose, + setOnModalClose: setModalCloseCallback, + }; +}; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index cde8d3320..cccce7667 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,9 +1,10 @@ -import React, { useState, useCallback, useEffect} from "react"; +import React, { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from 'react-i18next'; import { useFileContext } from "../contexts/FileContext"; import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext"; import { useToolManagement } from "../hooks/useToolManagement"; -import { Group, Box, Button, Container } from "@mantine/core"; +import { useFileHandler } from "../hooks/useFileHandler"; +import { Group, Box, Button } from "@mantine/core"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import { PageEditorFunctions } from "../types/pageEditor"; import rainbowStyles from '../styles/rainbow.module.css'; @@ -14,17 +15,19 @@ import FileEditor from "../components/fileEditor/FileEditor"; import PageEditor from "../components/pageEditor/PageEditor"; import PageEditorControls from "../components/pageEditor/PageEditorControls"; import Viewer from "../components/viewer/Viewer"; -import FileUploadSelector from "../components/shared/FileUploadSelector"; import ToolRenderer from "../components/tools/ToolRenderer"; import QuickAccessBar from "../components/shared/QuickAccessBar"; +import LandingPage from "../components/shared/LandingPage"; +import FileUploadModal from "../components/shared/FileUploadModal"; function HomePageContent() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const fileContext = useFileContext(); - const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext; + const { activeFiles, currentView, setCurrentView } = fileContext; const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); + const { addToActiveFiles } = useFileHandler(); const { selectedToolKey, @@ -33,6 +36,7 @@ function HomePageContent() { selectTool, clearToolSelection, } = useToolManagement(); + const [sidebarsVisible, setSidebarsVisible] = useState(true); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [readerMode, setReaderMode] = useState(false); @@ -77,12 +81,6 @@ function HomePageContent() { setCurrentView(view as any); }, [setCurrentView]); - const addToActiveFiles = useCallback(async (file: File) => { - const exists = activeFiles.some(f => f.name === file.name && f.size === file.size); - if (!exists) { - await addFiles([file]); - } - }, [activeFiles, addFiles]); @@ -183,26 +181,12 @@ function HomePageContent() { }} > {!activeFiles[0] ? ( - - { - addToActiveFiles(file); - }} - onFilesSelect={(files) => { - files.forEach(addToActiveFiles); - }} - accept={["*/*"]} - supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} - loading={false} - showRecentFiles={true} - maxRecentFiles={8} - /> - + ) : currentView === "fileEditor" ? ( ) : ( - - { - addToActiveFiles(file); - }} - onFilesSelect={(files) => { - files.forEach(addToActiveFiles); - }} - accept={["*/*"]} - supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} - loading={false} - showRecentFiles={true} - maxRecentFiles={8} - /> - + )}
+ + {/* Global Modals */} + ); } diff --git a/frontend/src/tests/convert/ConvertE2E.spec.ts b/frontend/src/tests/convert/ConvertE2E.spec.ts index e60f7826c..90d203b55 100644 --- a/frontend/src/tests/convert/ConvertE2E.spec.ts +++ b/frontend/src/tests/convert/ConvertE2E.spec.ts @@ -127,6 +127,27 @@ const getExpectedExtension = (toFormat: string): string => { return extensionMap[toFormat] || '.pdf'; }; +/** + * Helper function to upload files through the modal system + */ +async function uploadFileViaModal(page: Page, filePath: string) { + // Click the Files button in the QuickAccessBar to open the modal + await page.click('[data-testid="files-button"]'); + + // Wait for the modal to open + await page.waitForSelector('.mantine-Modal-overlay', { state: 'visible' }, { timeout: 5000 }); + //await page.waitForSelector('[data-testid="file-upload-modal"]', { timeout: 5000 }); + + // Upload the file through the modal's file input + await page.setInputFiles('input[type="file"]', filePath); + + // Wait for the file to be processed and the modal to close + await page.waitForSelector('[data-testid="file-upload-modal"]', { state: 'hidden' }); + + // Wait for the file thumbnail to appear in the main interface + await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); +} + /** * Generic test function for any conversion */ @@ -288,8 +309,8 @@ test.describe('Convert Tool E2E Tests', () => { // Wait for the page to load await page.waitForLoadState('networkidle'); - // Wait for the file upload area to appear (shown when no active files) - await page.waitForSelector('[data-testid="file-dropzone"]', { timeout: 10000 }); + // Wait for the QuickAccessBar to appear + await page.waitForSelector('[data-testid="files-button"]', { timeout: 10000 }); }); test.describe('Dynamic Conversion Tests', () => { @@ -302,8 +323,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -314,8 +334,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -326,8 +345,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -338,8 +356,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -350,8 +367,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -362,8 +378,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -374,8 +389,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -386,8 +400,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -398,8 +411,7 @@ test.describe('Convert Tool E2E Tests', () => { test.skip(!isAvailable, `Endpoint ${conversion.endpoint} is not available`); const testFile = getTestFileForFormat(conversion.fromFormat); - await page.setInputFiles('input[type="file"]', testFile); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, testFile); await testConversion(page, conversion); }); @@ -410,8 +422,7 @@ test.describe('Convert Tool E2E Tests', () => { // Test that disabled conversions don't appear in dropdowns when they shouldn't test('should not show conversion button when no valid conversions available', async ({ page }) => { // This test ensures the convert button is disabled when no valid conversion is possible - await page.setInputFiles('input[type="file"]', TEST_FILES.pdf); - await page.waitForSelector('[data-testid="file-thumbnail"]', { timeout: 10000 }); + await uploadFileViaModal(page, TEST_FILES.pdf); // Click the Convert tool button await page.click('[data-testid="tool-convert"]');