Feature/v2/filemanager (#4121)

FileManager Component Overview

Purpose: Modal component for selecting and managing PDF files with
preview capabilities

  Architecture:
- Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx
(3-column)
- Central State: FileManagerContext handles file operations, selection,
and modal state
  - File Storage: IndexedDB persistence with thumbnail caching

  Key Components:
  - FileSourceButtons: Switch between Recent/Local/Drive sources
  - FileListArea: Scrollable file grid with search functionality
- FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow
pages based on file count)
  - FileDetails: File info card with metadata
  - CompactFileDetails: Mobile-optimized file info layout

  File Flow:
1. Users select source → browse/search files → select multiple files →
preview with navigation → open in
  tools
  2. Files persist across tool switches via FileContext integration
  3. Memory management handles large PDFs (up to 100GB+)

 ```mermaid
 graph TD
      FM[FileManager] --> ML[MobileLayout]
      FM --> DL[DesktopLayout]

      ML --> FSB[FileSourceButtons<br/>Recent/Local/Drive]
      ML --> FLA[FileListArea]
      ML --> FD[FileDetails]

      DL --> FSB
      DL --> FLA
      DL --> FD

      FLA --> FLI[FileListItem]
      FD --> FP[FilePreview]
      FD --> CFD[CompactFileDetails]

  ```

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh
2025-08-08 15:15:09 +01:00
committed by GitHub
parent 9861332040
commit 7e3321ee16
33 changed files with 1818 additions and 506 deletions

View File

@@ -23,13 +23,31 @@ import axios from 'axios';
vi.mock('axios');
const mockedAxios = vi.mocked(axios);
// Mock utility modules
vi.mock('../../utils/thumbnailUtils', () => ({
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
// Mock only essential services that are actually called by the tests
vi.mock('../../services/fileStorage', () => ({
fileStorage: {
init: vi.fn().mockResolvedValue(undefined),
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
return Promise.resolve({
id: `mock-id-${file.name}`,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
thumbnail: thumbnail
});
}),
getAllFileMetadata: vi.fn().mockResolvedValue([]),
cleanup: vi.fn().mockResolvedValue(undefined)
}
}));
vi.mock('../../utils/api', () => ({
makeApiUrl: vi.fn((path: string) => `/api/v1${path}`)
vi.mock('../../services/thumbnailGenerationService', () => ({
thumbnailGenerationService: {
generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
cleanup: vi.fn(),
destroy: vi.fn()
}
}));
// Create realistic test files
@@ -194,7 +212,14 @@ describe('Convert Tool Integration Tests', () => {
test('should correctly map image conversion parameters to API call', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
mockedAxios.post.mockResolvedValueOnce({
data: mockBlob,
status: 200,
headers: {
'content-type': 'image/jpeg',
'content-disposition': 'attachment; filename="test_converted.jpg"'
}
});
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
@@ -472,7 +497,14 @@ describe('Convert Tool Integration Tests', () => {
test('should record operation in FileContext', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
mockedAxios.post.mockResolvedValueOnce({
data: mockBlob,
status: 200,
headers: {
'content-type': 'image/png',
'content-disposition': 'attachment; filename="test_converted.png"'
}
});
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
@@ -506,7 +538,14 @@ describe('Convert Tool Integration Tests', () => {
test('should clean up blob URLs on reset', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
mockedAxios.post.mockResolvedValueOnce({
data: mockBlob,
status: 200,
headers: {
'content-type': 'image/png',
'content-disposition': 'attachment; filename="test_converted.png"'
}
});
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper

View File

@@ -18,9 +18,31 @@ import { detectFileExtension } from '../../utils/fileUtils';
vi.mock('axios');
const mockedAxios = vi.mocked(axios);
// Mock utility modules
vi.mock('../../utils/thumbnailUtils', () => ({
generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
// Mock only essential services that are actually called by the tests
vi.mock('../../services/fileStorage', () => ({
fileStorage: {
init: vi.fn().mockResolvedValue(undefined),
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
return Promise.resolve({
id: `mock-id-${file.name}`,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
thumbnail: thumbnail
});
}),
getAllFileMetadata: vi.fn().mockResolvedValue([]),
cleanup: vi.fn().mockResolvedValue(undefined)
}
}));
vi.mock('../../services/thumbnailGenerationService', () => ({
thumbnailGenerationService: {
generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
cleanup: vi.fn(),
destroy: vi.fn()
}
}));
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (