Skip to content

Commit 8ce3ece

Browse files
authored
feat: support multiple part upload (#240)
1 parent d705d64 commit 8ce3ece

File tree

18 files changed

+633
-95
lines changed

18 files changed

+633
-95
lines changed

cypress.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ export default defineConfig({
5252

5353
// Force disable fullscreen
5454
launchOptions.args.push('--force-device-scale-factor=1');
55+
56+
// Enable clipboard permissions for testing
57+
launchOptions.preferences = {
58+
...launchOptions.preferences,
59+
'profile.content_settings.exceptions.clipboard': {
60+
'*': { setting: 1 },
61+
},
62+
};
5563
}
5664

5765
return launchOptions;
Lines changed: 84 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { v4 as uuidv4 } from 'uuid';
22
import { AuthTestUtils } from '../../../support/auth-utils';
3-
import { EditorSelectors, waitForReactUpdate, SlashCommandSelectors, AddPageSelectors } from '../../../support/selectors';
3+
import { EditorSelectors, waitForReactUpdate, AddPageSelectors } from '../../../support/selectors';
44

55
describe('Copy Image Test', () => {
66
const authUtils = new AuthTestUtils();
77
const testEmail = `${uuidv4()}@appflowy.io`;
88

99
beforeEach(() => {
1010
cy.on('uncaught:exception', () => false);
11-
11+
1212
// Mock the image fetch
1313
cy.intercept('GET', '**/logo.png', {
1414
statusCode: 200,
@@ -18,91 +18,95 @@ describe('Copy Image Test', () => {
1818
},
1919
}).as('getImage');
2020

21-
// We need to mock the clipboard write
22-
cy.window().then((win) => {
23-
// Check if clipboard exists
24-
if (win.navigator.clipboard) {
25-
cy.stub(win.navigator.clipboard, 'write').as('clipboardWrite');
26-
} else {
27-
// Mock clipboard if it doesn't exist or is not writable directly
28-
// In some browsers, we might need to redefine the property
29-
const clipboardMock = {
30-
write: cy.stub().as('clipboardWrite')
31-
};
32-
try {
33-
// @ts-ignore
34-
win.navigator.clipboard = clipboardMock;
35-
} catch (e) {
36-
Object.defineProperty(win.navigator, 'clipboard', {
37-
value: clipboardMock,
38-
configurable: true,
39-
writable: true
40-
});
41-
}
42-
}
43-
});
44-
4521
cy.visit('/login', { failOnStatusCode: false });
4622
authUtils.signInWithTestUrl(testEmail).then(() => {
4723
cy.url({ timeout: 30000 }).should('include', '/app');
4824
waitForReactUpdate(1000);
25+
26+
// Mock the clipboard write AFTER navigation to /app
27+
cy.window().then((win) => {
28+
// Stub the clipboard.write to capture what's being written
29+
const writeStub = cy.stub().as('clipboardWrite').resolves();
30+
if (win.navigator.clipboard) {
31+
cy.stub(win.navigator.clipboard, 'write').callsFake(writeStub);
32+
} else {
33+
Object.defineProperty(win.navigator, 'clipboard', {
34+
value: { write: writeStub },
35+
configurable: true,
36+
writable: true
37+
});
38+
}
39+
});
4940
});
5041
});
5142

5243
it('should copy image to clipboard when clicking copy button', () => {
53-
// Create a new page
54-
AddPageSelectors.inlineAddButton().first().click();
55-
waitForReactUpdate(500);
56-
cy.get('[role="menuitem"]').first().click(); // Create Doc
57-
waitForReactUpdate(1000);
58-
59-
// Focus editor
60-
EditorSelectors.firstEditor().should('exist').click({ force: true });
61-
waitForReactUpdate(1000);
62-
63-
// Ensure focus
64-
EditorSelectors.firstEditor().focus();
65-
waitForReactUpdate(500);
66-
67-
// Type '/' to open slash menu
68-
EditorSelectors.firstEditor().type('/', { force: true });
69-
waitForReactUpdate(1000);
70-
71-
// Check if slash panel exists
72-
cy.get('[data-testid="slash-panel"]').should('exist').should('be.visible');
73-
74-
// Type 'image' to filter
75-
EditorSelectors.firstEditor().type('image', { force: true });
76-
waitForReactUpdate(1000);
77-
78-
// Click Image item
79-
cy.get('[data-testid^="slash-menu-"]').contains(/^Image$/).click({ force: true });
80-
waitForReactUpdate(1000);
81-
82-
// Upload image directly
83-
cy.get('input[type="file"]').attachFile('appflowy.png');
84-
waitForReactUpdate(2000);
85-
86-
waitForReactUpdate(2000);
87-
88-
// The image should now be rendered.
89-
// We need to hover or click it to see the toolbar.
90-
// The toolbar is only visible when the block is selected/focused or hovered.
91-
// ImageToolbar.tsx uses useSlateStatic, suggesting it's part of the slate render.
92-
93-
// Find the image block.
94-
cy.get('[data-block-type="image"]').first().should('exist').trigger('mouseover', { force: true }).click({ force: true });
95-
waitForReactUpdate(1000);
96-
97-
// Click the copy button
98-
cy.get('[data-testid="copy-image-button"]').should('exist').click({ force: true });
99-
100-
// Verify clipboard write
101-
cy.get('@clipboardWrite').should('have.been.called');
102-
cy.get('@clipboardWrite').should((stub: any) => {
103-
const clipboardItem = stub.args[0][0][0];
104-
expect(clipboardItem).to.be.instanceOf(ClipboardItem);
105-
expect(clipboardItem.types).to.include('image/png');
106-
});
44+
// Create a new page
45+
AddPageSelectors.inlineAddButton().first().click();
46+
waitForReactUpdate(500);
47+
cy.get('[role="menuitem"]').first().click(); // Create Doc
48+
waitForReactUpdate(1000);
49+
50+
// Close the modal that opens after creating a page
51+
cy.get('[role="dialog"]').should('exist');
52+
cy.get('[role="dialog"]').find('button').filter(':visible').last().click({ force: true });
53+
waitForReactUpdate(1000);
54+
55+
// Focus editor
56+
EditorSelectors.firstEditor().should('exist').click({ force: true });
57+
waitForReactUpdate(1000);
58+
59+
// Ensure focus
60+
EditorSelectors.firstEditor().focus();
61+
waitForReactUpdate(500);
62+
63+
// Type '/' to open slash menu
64+
EditorSelectors.firstEditor().type('/', { force: true });
65+
waitForReactUpdate(1000);
66+
67+
// Check if slash panel exists
68+
cy.get('[data-testid="slash-panel"]').should('exist').should('be.visible');
69+
70+
// Type 'image' to filter
71+
EditorSelectors.firstEditor().type('image', { force: true });
72+
waitForReactUpdate(1000);
73+
74+
// Click Image item
75+
cy.get('[data-testid^="slash-menu-"]').contains(/^Image$/).click({ force: true });
76+
waitForReactUpdate(1000);
77+
78+
// Upload image directly
79+
cy.get('input[type=\"file\"]').attachFile('appflowy.png');
80+
waitForReactUpdate(2000);
81+
82+
waitForReactUpdate(2000);
83+
84+
// Verify we have at least 1 image block
85+
cy.get('[data-block-type="image"]').should('have.length.at.least', 1);
86+
87+
// Find the image block and hover to show toolbar
88+
cy.get('[data-block-type="image"]').first().should('exist');
89+
// Use realHover from cypress-real-events for proper mouse enter behavior
90+
cy.get('[data-block-type="image"]').first().realHover();
91+
waitForReactUpdate(1000);
92+
93+
// Click the copy button
94+
cy.get('[data-testid="copy-image-button"]').should('exist').click({ force: true });
95+
waitForReactUpdate(1000);
96+
97+
// Verify clipboard write was called with image data
98+
cy.get('@clipboardWrite').should('have.been.called');
99+
cy.get('@clipboardWrite').then((stub: any) => {
100+
// The clipboard.write is called with an array of ClipboardItems
101+
// Each ClipboardItem has a types property
102+
expect(stub.called).to.be.true;
103+
const args = stub.args[0];
104+
expect(args).to.have.length(1);
105+
const clipboardItems = args[0];
106+
expect(clipboardItems).to.have.length(1);
107+
const clipboardItem = clipboardItems[0];
108+
// Verify it's writing image/png
109+
expect(clipboardItem.types).to.include('image/png');
110+
});
107111
});
108112
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { v4 as uuidv4 } from 'uuid';
2+
import { AuthTestUtils } from '../../../support/auth-utils';
3+
import { EditorSelectors, waitForReactUpdate, AddPageSelectors } from '../../../support/selectors';
4+
5+
describe('Download Image Test', () => {
6+
const authUtils = new AuthTestUtils();
7+
const testEmail = `${uuidv4()}@appflowy.io`;
8+
9+
beforeEach(() => {
10+
cy.on('uncaught:exception', () => false);
11+
12+
// Mock the image fetch
13+
cy.intercept('GET', '**/logo.png', {
14+
statusCode: 200,
15+
fixture: 'appflowy.png',
16+
headers: {
17+
'content-type': 'image/png',
18+
},
19+
}).as('getImage');
20+
21+
cy.visit('/login', { failOnStatusCode: false });
22+
authUtils.signInWithTestUrl(testEmail).then(() => {
23+
cy.url({ timeout: 30000 }).should('include', '/app');
24+
waitForReactUpdate(1000);
25+
});
26+
});
27+
28+
it('should download image when clicking download button', () => {
29+
// Create a new page
30+
AddPageSelectors.inlineAddButton().first().click();
31+
waitForReactUpdate(500);
32+
cy.get('[role="menuitem"]').first().click(); // Create Doc
33+
waitForReactUpdate(1000);
34+
35+
// Close the modal that opens after creating a page
36+
cy.get('[role="dialog"]').should('exist');
37+
cy.get('[role="dialog"]').find('button').filter(':visible').last().click({ force: true });
38+
waitForReactUpdate(1000);
39+
40+
// Focus editor
41+
EditorSelectors.firstEditor().should('exist').click({ force: true });
42+
waitForReactUpdate(1000);
43+
44+
// Ensure focus
45+
EditorSelectors.firstEditor().focus();
46+
waitForReactUpdate(500);
47+
48+
// Type '/' to open slash menu
49+
EditorSelectors.firstEditor().type('/', { force: true });
50+
waitForReactUpdate(1000);
51+
52+
// Check if slash panel exists
53+
cy.get('[data-testid="slash-panel"]').should('exist').should('be.visible');
54+
55+
// Type 'image' to filter
56+
EditorSelectors.firstEditor().type('image', { force: true });
57+
waitForReactUpdate(1000);
58+
59+
// Click Image item
60+
cy.get('[data-testid^="slash-menu-"]').contains(/^Image$/).click({ force: true });
61+
waitForReactUpdate(1000);
62+
63+
// Upload image directly
64+
cy.get('input[type="file"]').attachFile('appflowy.png');
65+
waitForReactUpdate(2000);
66+
67+
waitForReactUpdate(2000);
68+
69+
// Find the image block and hover to show toolbar
70+
cy.get('[data-block-type="image"]').first().should('exist');
71+
// Use realHover from cypress-real-events for proper mouse enter behavior
72+
cy.get('[data-block-type="image"]').first().realHover();
73+
waitForReactUpdate(1000);
74+
75+
// Click the download button
76+
cy.get('[data-testid="download-image-button"]').should('exist').click({ force: true });
77+
78+
// Verify success notification appears
79+
cy.contains('Image downloaded successfully').should('exist');
80+
});
81+
});

src/@types/translations/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2141,7 +2141,7 @@
21412141
},
21422142
"image": {
21432143
"addAnImage": "Add images",
2144-
"copiedToPasteBoard": "The image link has been copied to the clipboard",
2144+
"copiedToPasteBoard": "Image copied to clipboard",
21452145
"addAnImageDesktop": "Add an image",
21462146
"addAnImageMobile": "Click to add one or more images",
21472147
"dropImageToInsert": "Drop images to insert",

src/application/services/js-services/http/http_api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,7 @@ export async function uploadFile(
16661666
file: File,
16671667
onProgress?: (progress: number) => void
16681668
) {
1669+
Log.debug('[UploadFile] starting', { fileName: file.name, fileSize: file.size });
16691670
const url = getAppFlowyFileUploadUrl(workspaceId, viewId);
16701671

16711672
// Check file size, if over 7MB, check subscription plan
@@ -1701,6 +1702,7 @@ export async function uploadFile(
17011702
});
17021703

17031704
if (response?.data.code === 0) {
1705+
Log.debug('[UploadFile] completed', { url });
17041706
return getAppFlowyFileUrl(workspaceId, viewId, response?.data.data.file_id);
17051707
}
17061708

@@ -1718,6 +1720,10 @@ export async function uploadFile(
17181720
}
17191721
}
17201722

1723+
export { uploadFileMultipart } from './multipart-upload';
1724+
export { MULTIPART_THRESHOLD } from './multipart-upload.types';
1725+
export type { MultipartUploadProgress } from './multipart-upload.types';
1726+
17211727
export async function inviteMembers(workspaceId: string, emails: string[]) {
17221728
const url = `/api/workspace/${workspaceId}/invite`;
17231729

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
export * as APIService from './http_api';
2+
export { uploadFileMultipart } from './multipart-upload';
3+
export { MULTIPART_THRESHOLD } from './multipart-upload.types';
4+
export type { MultipartUploadProgress } from './multipart-upload.types';

0 commit comments

Comments
 (0)