Support multiple part upload (#241)

* feat: support multiple part upload

* chore: add test

* chore: add test
This commit is contained in:
Nathan.fooo
2026-02-01 08:07:01 +08:00
committed by GitHub
parent c0fa72ee79
commit 17d18e8792
5 changed files with 337 additions and 6 deletions

View 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');
});
});

View File

@@ -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> {

View File

@@ -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,
]
);

View 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
View 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);
}
};
}