mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2026-03-13 10:00:26 +08:00
Support multiple part upload (#241)
* feat: support multiple part upload * chore: add test * chore: add test
This commit is contained in:
89
cypress/e2e/database/database-file-upload.cy.ts
Normal file
89
cypress/e2e/database/database-file-upload.cy.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
120
src/utils/__tests__/upload-tracker.test.ts
Normal file
120
src/utils/__tests__/upload-tracker.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
109
src/utils/upload-tracker.ts
Normal file
109
src/utils/upload-tracker.ts
Normal file
@@ -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<string>();
|
||||
|
||||
// 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<T extends unknown[], R>(
|
||||
uploadFn: (...args: T) => Promise<R>
|
||||
): (...args: T) => Promise<R> {
|
||||
return async (...args: T): Promise<R> => {
|
||||
const uploadId = registerUpload();
|
||||
|
||||
try {
|
||||
return await uploadFn(...args);
|
||||
} finally {
|
||||
unregisterUpload(uploadId);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user