From 17d18e879287cdbb07a0fab316eb46d81a18d022 Mon Sep 17 00:00:00 2001 From: "Nathan.fooo" <86001920+appflowy@users.noreply.github.com> Date: Sun, 1 Feb 2026 08:07:01 +0800 Subject: [PATCH] Support multiple part upload (#241) * feat: support multiple part upload * chore: add test * chore: add test --- .../e2e/database/database-file-upload.cy.ts | 89 +++++++++++++ src/application/services/js-services/index.ts | 19 ++- src/components/database/Database.tsx | 6 + src/utils/__tests__/upload-tracker.test.ts | 120 ++++++++++++++++++ src/utils/upload-tracker.ts | 109 ++++++++++++++++ 5 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 cypress/e2e/database/database-file-upload.cy.ts create mode 100644 src/utils/__tests__/upload-tracker.test.ts create mode 100644 src/utils/upload-tracker.ts diff --git a/cypress/e2e/database/database-file-upload.cy.ts b/cypress/e2e/database/database-file-upload.cy.ts new file mode 100644 index 00000000..83e24adb --- /dev/null +++ b/cypress/e2e/database/database-file-upload.cy.ts @@ -0,0 +1,89 @@ +import { FieldType, waitForReactUpdate } from '../../support/selectors'; +import { + generateRandomEmail, + loginAndCreateGrid, + addNewProperty, + setupFieldTypeTest, + getCellsForField, + getLastFieldId, +} from '../../support/field-type-test-helpers'; + +/** + * Database File Upload Tests + * + * Tests for file upload in database file/media field: + * - Create a grid database + * - Add a file & media field + * - Upload a file and verify it appears + * - Verify upload tracker is working + */ +describe('Database File Upload', () => { + beforeEach(() => { + setupFieldTypeTest(); + }); + + /** + * Test: Create grid, add file/media field, upload file, verify upload tracking + */ + it('should upload file to database file/media field and track progress', () => { + const testEmail = generateRandomEmail(); + + cy.task('log', `[TEST] Database file upload - Email: ${testEmail}`); + + loginAndCreateGrid(testEmail); + + // Step 1: Add a File & Media field + cy.task('log', '[STEP 1] Adding File & Media field'); + addNewProperty(FieldType.FileMedia); + waitForReactUpdate(1000); + + // Verify the field was added (should see a new column header) + cy.get('[data-testid^="grid-field-header-"]').should('have.length.at.least', 2); + cy.task('log', '[STEP 1] File & Media field added'); + + // Set up console log spy to verify upload tracker logs + cy.window().then((win) => { + cy.spy(win.console, 'info').as('consoleInfo'); + }); + + // Step 2: Click on a cell in the file/media column to open upload dialog + cy.task('log', '[STEP 2] Opening file upload dialog'); + + // Get the last field (file/media field) and click on its first cell + getLastFieldId().then((fieldId) => { + getCellsForField(fieldId).first().click({ force: true }); + }); + waitForReactUpdate(2000); + + // The popover should open with the file dropzone + cy.get('[data-testid="file-dropzone"]', { timeout: 15000 }).should('be.visible'); + cy.task('log', '[STEP 2] File upload dialog opened'); + + // Step 3: Upload multiple files + cy.task('log', '[STEP 3] Uploading multiple files'); + + // The file dropzone contains a hidden input, attach multiple files to it + cy.get('[data-testid="file-dropzone"]').within(() => { + cy.get('input[type="file"]').attachFile(['appflowy.png', 'test-icon.png'], { force: true }); + }); + waitForReactUpdate(8000); + + // Step 4: Verify the files were uploaded + cy.task('log', '[STEP 4] Verifying file uploads'); + + // The cell should now show the uploaded files (image thumbnails) + getLastFieldId().then((fieldId) => { + getCellsForField(fieldId).first().within(() => { + // Should have 2 image thumbnails + cy.get('img', { timeout: 10000 }).should('have.length', 2); + }); + }); + + // Step 5: Verify upload tracker was called + cy.task('log', '[STEP 5] Verifying upload tracking'); + cy.get('@consoleInfo').should('have.been.calledWithMatch', /\[UploadTracker\] Upload started/); + cy.get('@consoleInfo').should('have.been.calledWithMatch', /\[UploadTracker\] Upload completed/); + + cy.task('log', '[TEST COMPLETE] File uploaded successfully with tracking verified'); + }); +}); diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index 9a79c334..3d0ade7e 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -63,6 +63,7 @@ import { } from '@/application/types'; import { applyYDoc } from '@/application/ydoc/apply'; import { RepeatedChatMessage } from '@/components/chat'; +import { registerUpload, unregisterUpload } from '@/utils/upload-tracker'; export class AFClientService implements AFService { private clientId: number = random.uint32(); @@ -670,12 +671,18 @@ export class AFClientService implements AFService { } async uploadFile(workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) { - return uploadFileMultipart({ - workspaceId, - viewId, - file, - onProgress: (p) => onProgress?.(p.percentage / 100), - }); + const uploadId = registerUpload(); + + try { + return await uploadFileMultipart({ + workspaceId, + viewId, + file, + onProgress: (p) => onProgress?.(p.percentage / 100), + }); + } finally { + unregisterUpload(uploadId); + } } deleteWorkspace(workspaceId: string): Promise { diff --git a/src/components/database/Database.tsx b/src/components/database/Database.tsx index b8fe89da..3630be5e 100644 --- a/src/components/database/Database.tsx +++ b/src/components/database/Database.tsx @@ -95,6 +95,10 @@ export interface Database2Props { * Used by DatabaseTabs to listen for outline updates after rename/delete. */ eventEmitter?: EventEmitter; + /** + * Upload a file to storage and return the URL. + */ + uploadFile?: (file: File) => Promise; } function Database(props: Database2Props) { @@ -499,6 +503,7 @@ function Database(props: Database2Props) { variant: props.variant, calendarViewTypeMap, setCalendarViewType, + uploadFile: props.uploadFile, }), [ readOnly, @@ -527,6 +532,7 @@ function Database(props: Database2Props) { props.variant, calendarViewTypeMap, setCalendarViewType, + props.uploadFile, ] ); diff --git a/src/utils/__tests__/upload-tracker.test.ts b/src/utils/__tests__/upload-tracker.test.ts new file mode 100644 index 00000000..92a8b9da --- /dev/null +++ b/src/utils/__tests__/upload-tracker.test.ts @@ -0,0 +1,120 @@ +import { + registerUpload, + unregisterUpload, + hasActiveUploads, + getActiveUploadCount, + clearAllUploads, +} from '../upload-tracker'; + +describe('upload-tracker', () => { + let addEventListenerSpy: jest.SpyInstance; + let removeEventListenerSpy: jest.SpyInstance; + + beforeEach(() => { + // Clear all uploads before each test + clearAllUploads(); + addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + clearAllUploads(); + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); + + it('should register and unregister uploads correctly', () => { + expect(hasActiveUploads()).toBe(false); + expect(getActiveUploadCount()).toBe(0); + + const uploadId1 = registerUpload(); + expect(hasActiveUploads()).toBe(true); + expect(getActiveUploadCount()).toBe(1); + + const uploadId2 = registerUpload(); + expect(getActiveUploadCount()).toBe(2); + + unregisterUpload(uploadId1); + expect(getActiveUploadCount()).toBe(1); + expect(hasActiveUploads()).toBe(true); + + unregisterUpload(uploadId2); + expect(getActiveUploadCount()).toBe(0); + expect(hasActiveUploads()).toBe(false); + }); + + it('should add beforeunload listener when first upload is registered', () => { + addEventListenerSpy.mockClear(); + + const uploadId = registerUpload(); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'beforeunload', + expect.any(Function) + ); + + unregisterUpload(uploadId); + }); + + it('should remove beforeunload listener when last upload completes', () => { + const uploadId = registerUpload(); + + removeEventListenerSpy.mockClear(); + + unregisterUpload(uploadId); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'beforeunload', + expect.any(Function) + ); + }); + + it('should not add multiple listeners for multiple uploads', () => { + addEventListenerSpy.mockClear(); + + const uploadId1 = registerUpload(); + const uploadId2 = registerUpload(); + const uploadId3 = registerUpload(); + + // Should only add listener once + const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter( + (call) => call[0] === 'beforeunload' + ); + expect(beforeUnloadCalls.length).toBe(1); + + unregisterUpload(uploadId1); + unregisterUpload(uploadId2); + unregisterUpload(uploadId3); + }); + + it('should set returnValue on beforeunload when uploads are active', () => { + const uploadId = registerUpload(); + + // Get the handler that was registered + const beforeUnloadHandler = addEventListenerSpy.mock.calls.find( + (call) => call[0] === 'beforeunload' + )?.[1] as ((e: BeforeUnloadEvent) => string | void) | undefined; + + expect(beforeUnloadHandler).toBeDefined(); + + // Create a mock event + const mockEvent = { + preventDefault: jest.fn(), + returnValue: '', + } as unknown as BeforeUnloadEvent; + + // Call the handler + const result = beforeUnloadHandler?.(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + // Should set a non-empty message for browser compatibility + expect(mockEvent.returnValue).toContain('uploads in progress'); + expect(result).toContain('uploads in progress'); + + unregisterUpload(uploadId); + }); + + it('should handle unregistering non-existent upload ID gracefully', () => { + expect(() => unregisterUpload('non-existent-id')).not.toThrow(); + }); +}); diff --git a/src/utils/upload-tracker.ts b/src/utils/upload-tracker.ts new file mode 100644 index 00000000..a70fb8f5 --- /dev/null +++ b/src/utils/upload-tracker.ts @@ -0,0 +1,109 @@ +/** + * Upload Tracker + * + * Tracks ongoing file uploads and warns users before leaving the page + * if there are uploads in progress. + */ + +import { Log } from '@/utils/log'; + +// Set to track active upload IDs +const activeUploads = new Set(); + +// Track if beforeunload listener is attached +let listenerAttached = false; + +// Counter for generating unique upload IDs +let uploadIdCounter = 0; + +/** + * Handler for beforeunload event + */ +function handleBeforeUnload(e: BeforeUnloadEvent) { + Log.info(`[UploadTracker] beforeunload triggered, active uploads: ${activeUploads.size}`); + if (activeUploads.size > 0) { + // Standard way to show a confirmation dialog + e.preventDefault(); + // For older browsers - must be a non-empty string in some browsers + e.returnValue = 'You have uploads in progress. Are you sure you want to leave?'; + return 'You have uploads in progress. Are you sure you want to leave?'; + } +} + +/** + * Update the beforeunload listener based on active uploads + */ +function updateListener() { + if (activeUploads.size > 0 && !listenerAttached) { + window.addEventListener('beforeunload', handleBeforeUnload); + listenerAttached = true; + Log.info('[UploadTracker] beforeunload listener attached'); + } else if (activeUploads.size === 0 && listenerAttached) { + window.removeEventListener('beforeunload', handleBeforeUnload); + listenerAttached = false; + Log.info('[UploadTracker] beforeunload listener removed'); + } +} + +/** + * Register an upload as started + * @returns A unique upload ID to use when marking the upload as complete + */ +export function registerUpload(): string { + const uploadId = `upload-${++uploadIdCounter}-${Date.now()}`; + + activeUploads.add(uploadId); + Log.info(`[UploadTracker] Upload started: ${uploadId}, active uploads: ${activeUploads.size}`); + updateListener(); + return uploadId; +} + +/** + * Mark an upload as complete (success or failure) + * @param uploadId The ID returned from registerUpload + */ +export function unregisterUpload(uploadId: string): void { + activeUploads.delete(uploadId); + Log.info(`[UploadTracker] Upload completed: ${uploadId}, active uploads: ${activeUploads.size}`); + updateListener(); +} + +/** + * Check if there are any active uploads + */ +export function hasActiveUploads(): boolean { + return activeUploads.size > 0; +} + +/** + * Get the count of active uploads + */ +export function getActiveUploadCount(): number { + return activeUploads.size; +} + +/** + * Clear all active uploads (for testing purposes) + */ +export function clearAllUploads(): void { + activeUploads.clear(); + updateListener(); +} + +/** + * Higher-order function to wrap an upload function with tracking + * Automatically registers and unregisters the upload + */ +export function withUploadTracking( + uploadFn: (...args: T) => Promise +): (...args: T) => Promise { + return async (...args: T): Promise => { + const uploadId = registerUpload(); + + try { + return await uploadFn(...args); + } finally { + unregisterUpload(uploadId); + } + }; +}