From cc876ebdd18c08f64e40a250efdd31a6b56edc1c Mon Sep 17 00:00:00 2001 From: Pierre SOUVIGNET Date: Tue, 18 Mar 2025 14:27:54 +0100 Subject: [PATCH] feat: support multiple files preview --- src/Dropzone/CHANGELOG.md | 4 ++ src/Dropzone/assets/dist/controller.js | 18 +++-- src/Dropzone/assets/src/controller.ts | 22 +++--- src/Dropzone/assets/test/controller.test.ts | 74 ++++++++++++++++++++- 4 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index 77389fe0f19..3d9929f2cb1 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.23 + +- Support multiple files preview + ## 2.20 - Enable file replacement via "drag-and-drop" diff --git a/src/Dropzone/assets/dist/controller.js b/src/Dropzone/assets/dist/controller.js index ebfb380d12a..2d08ae81661 100644 --- a/src/Dropzone/assets/dist/controller.js +++ b/src/Dropzone/assets/dist/controller.js @@ -32,19 +32,25 @@ class default_1 extends Controller { this.dispatchEvent('clear'); } onInputChange(event) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + const files = event.target.files; + if (!files.length) { return; } this.inputTarget.style.display = 'none'; this.placeholderTarget.style.display = 'none'; - this.previewFilenameTarget.textContent = file.name; + const firstFile = files[0]; + let displayText = firstFile.name; + if (files.length > 1) { + const additionalFiles = files.length - 1; + displayText += ` +${additionalFiles} ${additionalFiles === 1 ? 'file' : 'files'}`; + } + this.previewFilenameTarget.textContent = displayText; this.previewTarget.style.display = 'flex'; this.previewImageTarget.style.display = 'none'; - if (file.type && file.type.indexOf('image') !== -1) { - this._populateImagePreview(file); + if (firstFile.type && firstFile.type.indexOf('image') !== -1) { + this._populateImagePreview(firstFile); } - this.dispatchEvent('change', file); + this.dispatchEvent('change', files); } _populateImagePreview(file) { if (typeof FileReader === 'undefined') { diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index b2533329388..ed52ea4b2cb 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -65,8 +65,8 @@ export default class extends Controller { } onInputChange(event: any) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + const files = event.target.files; + if (!files.length) { return; } @@ -74,17 +74,23 @@ export default class extends Controller { this.inputTarget.style.display = 'none'; this.placeholderTarget.style.display = 'none'; - // Show the filename in preview - this.previewFilenameTarget.textContent = file.name; + // Show the filename in preview with additional files count if needed + const firstFile = files[0]; + let displayText = firstFile.name; + if (files.length > 1) { + const additionalFiles = files.length - 1; + displayText += ` +${additionalFiles} ${additionalFiles === 1 ? 'file' : 'files'}`; + } + this.previewFilenameTarget.textContent = displayText; this.previewTarget.style.display = 'flex'; - // If the file is an image, load it and display it as preview + // If the first file is an image, load it and display it as preview this.previewImageTarget.style.display = 'none'; - if (file.type && file.type.indexOf('image') !== -1) { - this._populateImagePreview(file); + if (firstFile.type && firstFile.type.indexOf('image') !== -1) { + this._populateImagePreview(firstFile); } - this.dispatchEvent('change', file); + this.dispatchEvent('change', files); } _populateImagePreview(file: Blob) { diff --git a/src/Dropzone/assets/test/controller.test.ts b/src/Dropzone/assets/test/controller.test.ts index b37dadf4bbb..352d8b5e3b0 100644 --- a/src/Dropzone/assets/test/controller.test.ts +++ b/src/Dropzone/assets/test/controller.test.ts @@ -65,6 +65,39 @@ describe('DropzoneController', () => { data-testid="preview-filename"> +
+ + +
+ Placeholder +
+ + +
`); }); @@ -120,7 +153,7 @@ describe('DropzoneController', () => { const file = new File(['hello'], 'hello.png', { type: 'image/png' }); user.upload(input, file); - expect(input.files[0]).toStrictEqual(file); + await waitFor(() => expect(input.files[0]).toStrictEqual(file)); // The dropzone should be in preview mode await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' })); @@ -128,7 +161,7 @@ describe('DropzoneController', () => { // The event should have been dispatched expect(dispatched).not.toBeNull(); - expect(dispatched.detail).toStrictEqual(file); + expect(dispatched.detail[0]).toStrictEqual(file); }); it('on drag', async () => { @@ -153,4 +186,41 @@ describe('DropzoneController', () => { await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' })); await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); }); + + it('multiple files chosen', async () => { + startStimulus(); + await waitFor(() => expect(getByTestId(container, 'container-multiple')).toHaveClass('connected')); + + // Attach a listener to ensure the event is dispatched + let dispatched = null; + getByTestId(container, 'container-multiple').addEventListener('dropzone:change', (event) => { + dispatched = event; + }); + + // Select multiple files + const input = getByTestId(container, 'input-multiple'); + const file1 = new File(['hello1'], 'hello1.png', { type: 'image/png' }); + const file2 = new File(['hello2'], 'hello2.txt', { type: 'text/plain' }); + const files = [file1, file2]; + + user.upload(input, files); + + // The dropzone should be in preview mode + await waitFor(() => expect(getByTestId(container, 'input-multiple')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'placeholder-multiple')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'preview-multiple')).toHaveStyle({ display: 'flex' })); + + // The event should have been dispatched with both files + expect(dispatched).not.toBeNull(); + expect(dispatched.detail[0]).toStrictEqual(file1); + expect(dispatched.detail[1]).toStrictEqual(file2); + + // Check preview content shows first file name plus count + const previewFilename = getByTestId(container, 'preview-filename-multiple'); + expect(previewFilename.textContent).toBe('hello1.png +1 file'); + + // Only the first file (image) should show preview + const previewImage = getByTestId(container, 'preview-image-multiple'); + await waitFor(() => expect(previewImage).toHaveStyle({ display: 'block' })); + }); });