Skip to content

Commit 17d18e8

Browse files
authored
Support multiple part upload (#241)
* feat: support multiple part upload * chore: add test * chore: add test
1 parent c0fa72e commit 17d18e8

File tree

5 files changed

+337
-6
lines changed

5 files changed

+337
-6
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { FieldType, waitForReactUpdate } from '../../support/selectors';
2+
import {
3+
generateRandomEmail,
4+
loginAndCreateGrid,
5+
addNewProperty,
6+
setupFieldTypeTest,
7+
getCellsForField,
8+
getLastFieldId,
9+
} from '../../support/field-type-test-helpers';
10+
11+
/**
12+
* Database File Upload Tests
13+
*
14+
* Tests for file upload in database file/media field:
15+
* - Create a grid database
16+
* - Add a file & media field
17+
* - Upload a file and verify it appears
18+
* - Verify upload tracker is working
19+
*/
20+
describe('Database File Upload', () => {
21+
beforeEach(() => {
22+
setupFieldTypeTest();
23+
});
24+
25+
/**
26+
* Test: Create grid, add file/media field, upload file, verify upload tracking
27+
*/
28+
it('should upload file to database file/media field and track progress', () => {
29+
const testEmail = generateRandomEmail();
30+
31+
cy.task('log', `[TEST] Database file upload - Email: ${testEmail}`);
32+
33+
loginAndCreateGrid(testEmail);
34+
35+
// Step 1: Add a File & Media field
36+
cy.task('log', '[STEP 1] Adding File & Media field');
37+
addNewProperty(FieldType.FileMedia);
38+
waitForReactUpdate(1000);
39+
40+
// Verify the field was added (should see a new column header)
41+
cy.get('[data-testid^="grid-field-header-"]').should('have.length.at.least', 2);
42+
cy.task('log', '[STEP 1] File & Media field added');
43+
44+
// Set up console log spy to verify upload tracker logs
45+
cy.window().then((win) => {
46+
cy.spy(win.console, 'info').as('consoleInfo');
47+
});
48+
49+
// Step 2: Click on a cell in the file/media column to open upload dialog
50+
cy.task('log', '[STEP 2] Opening file upload dialog');
51+
52+
// Get the last field (file/media field) and click on its first cell
53+
getLastFieldId().then((fieldId) => {
54+
getCellsForField(fieldId).first().click({ force: true });
55+
});
56+
waitForReactUpdate(2000);
57+
58+
// The popover should open with the file dropzone
59+
cy.get('[data-testid="file-dropzone"]', { timeout: 15000 }).should('be.visible');
60+
cy.task('log', '[STEP 2] File upload dialog opened');
61+
62+
// Step 3: Upload multiple files
63+
cy.task('log', '[STEP 3] Uploading multiple files');
64+
65+
// The file dropzone contains a hidden input, attach multiple files to it
66+
cy.get('[data-testid="file-dropzone"]').within(() => {
67+
cy.get('input[type="file"]').attachFile(['appflowy.png', 'test-icon.png'], { force: true });
68+
});
69+
waitForReactUpdate(8000);
70+
71+
// Step 4: Verify the files were uploaded
72+
cy.task('log', '[STEP 4] Verifying file uploads');
73+
74+
// The cell should now show the uploaded files (image thumbnails)
75+
getLastFieldId().then((fieldId) => {
76+
getCellsForField(fieldId).first().within(() => {
77+
// Should have 2 image thumbnails
78+
cy.get('img', { timeout: 10000 }).should('have.length', 2);
79+
});
80+
});
81+
82+
// Step 5: Verify upload tracker was called
83+
cy.task('log', '[STEP 5] Verifying upload tracking');
84+
cy.get('@consoleInfo').should('have.been.calledWithMatch', /\[UploadTracker\] Upload started/);
85+
cy.get('@consoleInfo').should('have.been.calledWithMatch', /\[UploadTracker\] Upload completed/);
86+
87+
cy.task('log', '[TEST COMPLETE] File uploaded successfully with tracking verified');
88+
});
89+
});

src/application/services/js-services/index.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363
} from '@/application/types';
6464
import { applyYDoc } from '@/application/ydoc/apply';
6565
import { RepeatedChatMessage } from '@/components/chat';
66+
import { registerUpload, unregisterUpload } from '@/utils/upload-tracker';
6667

6768
export class AFClientService implements AFService {
6869
private clientId: number = random.uint32();
@@ -670,12 +671,18 @@ export class AFClientService implements AFService {
670671
}
671672

672673
async uploadFile(workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) {
673-
return uploadFileMultipart({
674-
workspaceId,
675-
viewId,
676-
file,
677-
onProgress: (p) => onProgress?.(p.percentage / 100),
678-
});
674+
const uploadId = registerUpload();
675+
676+
try {
677+
return await uploadFileMultipart({
678+
workspaceId,
679+
viewId,
680+
file,
681+
onProgress: (p) => onProgress?.(p.percentage / 100),
682+
});
683+
} finally {
684+
unregisterUpload(uploadId);
685+
}
679686
}
680687

681688
deleteWorkspace(workspaceId: string): Promise<void> {

src/components/database/Database.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ export interface Database2Props {
9595
* Used by DatabaseTabs to listen for outline updates after rename/delete.
9696
*/
9797
eventEmitter?: EventEmitter;
98+
/**
99+
* Upload a file to storage and return the URL.
100+
*/
101+
uploadFile?: (file: File) => Promise<string>;
98102
}
99103

100104
function Database(props: Database2Props) {
@@ -499,6 +503,7 @@ function Database(props: Database2Props) {
499503
variant: props.variant,
500504
calendarViewTypeMap,
501505
setCalendarViewType,
506+
uploadFile: props.uploadFile,
502507
}),
503508
[
504509
readOnly,
@@ -527,6 +532,7 @@ function Database(props: Database2Props) {
527532
props.variant,
528533
calendarViewTypeMap,
529534
setCalendarViewType,
535+
props.uploadFile,
530536
]
531537
);
532538

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {
2+
registerUpload,
3+
unregisterUpload,
4+
hasActiveUploads,
5+
getActiveUploadCount,
6+
clearAllUploads,
7+
} from '../upload-tracker';
8+
9+
describe('upload-tracker', () => {
10+
let addEventListenerSpy: jest.SpyInstance;
11+
let removeEventListenerSpy: jest.SpyInstance;
12+
13+
beforeEach(() => {
14+
// Clear all uploads before each test
15+
clearAllUploads();
16+
addEventListenerSpy = jest.spyOn(window, 'addEventListener');
17+
removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
18+
});
19+
20+
afterEach(() => {
21+
clearAllUploads();
22+
addEventListenerSpy.mockRestore();
23+
removeEventListenerSpy.mockRestore();
24+
});
25+
26+
it('should register and unregister uploads correctly', () => {
27+
expect(hasActiveUploads()).toBe(false);
28+
expect(getActiveUploadCount()).toBe(0);
29+
30+
const uploadId1 = registerUpload();
31+
expect(hasActiveUploads()).toBe(true);
32+
expect(getActiveUploadCount()).toBe(1);
33+
34+
const uploadId2 = registerUpload();
35+
expect(getActiveUploadCount()).toBe(2);
36+
37+
unregisterUpload(uploadId1);
38+
expect(getActiveUploadCount()).toBe(1);
39+
expect(hasActiveUploads()).toBe(true);
40+
41+
unregisterUpload(uploadId2);
42+
expect(getActiveUploadCount()).toBe(0);
43+
expect(hasActiveUploads()).toBe(false);
44+
});
45+
46+
it('should add beforeunload listener when first upload is registered', () => {
47+
addEventListenerSpy.mockClear();
48+
49+
const uploadId = registerUpload();
50+
51+
expect(addEventListenerSpy).toHaveBeenCalledWith(
52+
'beforeunload',
53+
expect.any(Function)
54+
);
55+
56+
unregisterUpload(uploadId);
57+
});
58+
59+
it('should remove beforeunload listener when last upload completes', () => {
60+
const uploadId = registerUpload();
61+
62+
removeEventListenerSpy.mockClear();
63+
64+
unregisterUpload(uploadId);
65+
66+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
67+
'beforeunload',
68+
expect.any(Function)
69+
);
70+
});
71+
72+
it('should not add multiple listeners for multiple uploads', () => {
73+
addEventListenerSpy.mockClear();
74+
75+
const uploadId1 = registerUpload();
76+
const uploadId2 = registerUpload();
77+
const uploadId3 = registerUpload();
78+
79+
// Should only add listener once
80+
const beforeUnloadCalls = addEventListenerSpy.mock.calls.filter(
81+
(call) => call[0] === 'beforeunload'
82+
);
83+
expect(beforeUnloadCalls.length).toBe(1);
84+
85+
unregisterUpload(uploadId1);
86+
unregisterUpload(uploadId2);
87+
unregisterUpload(uploadId3);
88+
});
89+
90+
it('should set returnValue on beforeunload when uploads are active', () => {
91+
const uploadId = registerUpload();
92+
93+
// Get the handler that was registered
94+
const beforeUnloadHandler = addEventListenerSpy.mock.calls.find(
95+
(call) => call[0] === 'beforeunload'
96+
)?.[1] as ((e: BeforeUnloadEvent) => string | void) | undefined;
97+
98+
expect(beforeUnloadHandler).toBeDefined();
99+
100+
// Create a mock event
101+
const mockEvent = {
102+
preventDefault: jest.fn(),
103+
returnValue: '',
104+
} as unknown as BeforeUnloadEvent;
105+
106+
// Call the handler
107+
const result = beforeUnloadHandler?.(mockEvent);
108+
109+
expect(mockEvent.preventDefault).toHaveBeenCalled();
110+
// Should set a non-empty message for browser compatibility
111+
expect(mockEvent.returnValue).toContain('uploads in progress');
112+
expect(result).toContain('uploads in progress');
113+
114+
unregisterUpload(uploadId);
115+
});
116+
117+
it('should handle unregistering non-existent upload ID gracefully', () => {
118+
expect(() => unregisterUpload('non-existent-id')).not.toThrow();
119+
});
120+
});

src/utils/upload-tracker.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Upload Tracker
3+
*
4+
* Tracks ongoing file uploads and warns users before leaving the page
5+
* if there are uploads in progress.
6+
*/
7+
8+
import { Log } from '@/utils/log';
9+
10+
// Set to track active upload IDs
11+
const activeUploads = new Set<string>();
12+
13+
// Track if beforeunload listener is attached
14+
let listenerAttached = false;
15+
16+
// Counter for generating unique upload IDs
17+
let uploadIdCounter = 0;
18+
19+
/**
20+
* Handler for beforeunload event
21+
*/
22+
function handleBeforeUnload(e: BeforeUnloadEvent) {
23+
Log.info(`[UploadTracker] beforeunload triggered, active uploads: ${activeUploads.size}`);
24+
if (activeUploads.size > 0) {
25+
// Standard way to show a confirmation dialog
26+
e.preventDefault();
27+
// For older browsers - must be a non-empty string in some browsers
28+
e.returnValue = 'You have uploads in progress. Are you sure you want to leave?';
29+
return 'You have uploads in progress. Are you sure you want to leave?';
30+
}
31+
}
32+
33+
/**
34+
* Update the beforeunload listener based on active uploads
35+
*/
36+
function updateListener() {
37+
if (activeUploads.size > 0 && !listenerAttached) {
38+
window.addEventListener('beforeunload', handleBeforeUnload);
39+
listenerAttached = true;
40+
Log.info('[UploadTracker] beforeunload listener attached');
41+
} else if (activeUploads.size === 0 && listenerAttached) {
42+
window.removeEventListener('beforeunload', handleBeforeUnload);
43+
listenerAttached = false;
44+
Log.info('[UploadTracker] beforeunload listener removed');
45+
}
46+
}
47+
48+
/**
49+
* Register an upload as started
50+
* @returns A unique upload ID to use when marking the upload as complete
51+
*/
52+
export function registerUpload(): string {
53+
const uploadId = `upload-${++uploadIdCounter}-${Date.now()}`;
54+
55+
activeUploads.add(uploadId);
56+
Log.info(`[UploadTracker] Upload started: ${uploadId}, active uploads: ${activeUploads.size}`);
57+
updateListener();
58+
return uploadId;
59+
}
60+
61+
/**
62+
* Mark an upload as complete (success or failure)
63+
* @param uploadId The ID returned from registerUpload
64+
*/
65+
export function unregisterUpload(uploadId: string): void {
66+
activeUploads.delete(uploadId);
67+
Log.info(`[UploadTracker] Upload completed: ${uploadId}, active uploads: ${activeUploads.size}`);
68+
updateListener();
69+
}
70+
71+
/**
72+
* Check if there are any active uploads
73+
*/
74+
export function hasActiveUploads(): boolean {
75+
return activeUploads.size > 0;
76+
}
77+
78+
/**
79+
* Get the count of active uploads
80+
*/
81+
export function getActiveUploadCount(): number {
82+
return activeUploads.size;
83+
}
84+
85+
/**
86+
* Clear all active uploads (for testing purposes)
87+
*/
88+
export function clearAllUploads(): void {
89+
activeUploads.clear();
90+
updateListener();
91+
}
92+
93+
/**
94+
* Higher-order function to wrap an upload function with tracking
95+
* Automatically registers and unregisters the upload
96+
*/
97+
export function withUploadTracking<T extends unknown[], R>(
98+
uploadFn: (...args: T) => Promise<R>
99+
): (...args: T) => Promise<R> {
100+
return async (...args: T): Promise<R> => {
101+
const uploadId = registerUpload();
102+
103+
try {
104+
return await uploadFn(...args);
105+
} finally {
106+
unregisterUpload(uploadId);
107+
}
108+
};
109+
}

0 commit comments

Comments
 (0)