From 6624beadd0cca62c3e0fdead0285a0691b1cbfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Herna=CC=81ndez?= Date: Mon, 10 Nov 2025 09:41:06 +0100 Subject: [PATCH 1/7] Refactor dropzone controller to support multiple files, improve test coverage, and adjust styles. --- src/Dropzone/assets/dist/controller.d.ts | 25 ++- src/Dropzone/assets/dist/controller.js | 131 ++++++++++----- src/Dropzone/assets/dist/style.min.css | 2 +- src/Dropzone/assets/package.json | 2 +- src/Dropzone/assets/src/controller.ts | 157 ++++++++++++------ src/Dropzone/assets/src/style.css | 3 +- .../assets/test/unit/controller.test.ts | 55 +++--- src/Dropzone/assets/test/vitest.setup.js | 32 ++++ src/Dropzone/assets/vitest.config.mjs | 12 +- 9 files changed, 297 insertions(+), 122 deletions(-) create mode 100644 src/Dropzone/assets/test/vitest.setup.js diff --git a/src/Dropzone/assets/dist/controller.d.ts b/src/Dropzone/assets/dist/controller.d.ts index 075cf96c110..5f5952fb0bf 100644 --- a/src/Dropzone/assets/dist/controller.d.ts +++ b/src/Dropzone/assets/dist/controller.d.ts @@ -3,19 +3,30 @@ import { Controller } from '@hotwired/stimulus'; declare class export_default extends Controller { readonly inputTarget: HTMLInputElement; readonly placeholderTarget: HTMLDivElement; - readonly previewTarget: HTMLDivElement; - readonly previewClearButtonTarget: HTMLButtonElement; - readonly previewFilenameTarget: HTMLDivElement; - readonly previewImageTarget: HTMLDivElement; + readonly previewTargets: HTMLDivElement[]; + readonly previewContainerTarget: HTMLDivElement; static targets: string[]; + files: Map; initialize(): void; connect(): void; disconnect(): void; - clear(): void; - onInputChange(event: any): void; - _populateImagePreview(file: Blob): void; + clear(event?: { + target?: HTMLElement; + params?: { + filename?: string; + }; + }): void; + onInputChange(): void; + private renderPreview; + private clearPreviewContainer; + private buildPreview; + _populateImagePreview(element: HTMLElement, file: File): void; onDragEnter(): void; onDragLeave(event: any): void; + private updateFileInput; + private addFiles; + private isImage; + private get isMultiple(); private dispatchEvent; } diff --git a/src/Dropzone/assets/dist/controller.js b/src/Dropzone/assets/dist/controller.js index e08d8371e7e..2dad90a8e17 100644 --- a/src/Dropzone/assets/dist/controller.js +++ b/src/Dropzone/assets/dist/controller.js @@ -1,6 +1,10 @@ // src/controller.ts import { Controller } from "@hotwired/stimulus"; var controller_default = class extends Controller { + constructor() { + super(...arguments); + this.files = /* @__PURE__ */ new Map(); + } initialize() { this.clear = this.clear.bind(this); this.onInputChange = this.onInputChange.bind(this); @@ -9,72 +13,125 @@ var controller_default = class extends Controller { } connect() { this.clear(); - this.previewClearButtonTarget.addEventListener("click", this.clear); this.inputTarget.addEventListener("change", this.onInputChange); this.element.addEventListener("dragenter", this.onDragEnter); this.element.addEventListener("dragleave", this.onDragLeave); this.dispatchEvent("connect"); } disconnect() { - this.previewClearButtonTarget.removeEventListener("click", this.clear); this.inputTarget.removeEventListener("change", this.onInputChange); this.element.removeEventListener("dragenter", this.onDragEnter); this.element.removeEventListener("dragleave", this.onDragLeave); } - clear() { - this.inputTarget.value = ""; - this.inputTarget.style.display = "block"; - this.placeholderTarget.style.display = "block"; - this.previewTarget.style.display = "none"; - this.previewImageTarget.style.display = "none"; - this.previewImageTarget.style.backgroundImage = "none"; - this.previewFilenameTarget.textContent = ""; + clear(event) { + if (event?.params) { + const filename = event.params.filename; + if (filename && this.files.has(filename)) { + this.files.delete(filename); + this.updateFileInput(); + this.renderPreview(); + } + } + if (!this.inputTarget || !this.inputTarget.files || this.inputTarget?.files?.length === 0) { + this.placeholderTarget.style.display = "block"; + if (!this.isMultiple) { + this.inputTarget.style.display = "block"; + } + } this.dispatchEvent("clear"); } - onInputChange(event) { - const file = event.target.files[0]; - if (typeof file === "undefined") { + onInputChange() { + const files = this.inputTarget.files; + if (!files || files.length <= 0) { return; } - this.inputTarget.style.display = "none"; - this.placeholderTarget.style.display = "none"; - this.previewFilenameTarget.textContent = file.name; - this.previewTarget.style.display = "flex"; - this.previewImageTarget.style.display = "none"; - if (file.type && file.type.indexOf("image") !== -1) { - this._populateImagePreview(file); + if (!this.isMultiple && this.files.size > 0) { + this.inputTarget.style.display = "none"; } - this.dispatchEvent("change", file); + const selectedFiles = this.isMultiple ? Array.from(files) : Array.from(files).slice(0, 1); + this.addFiles(selectedFiles); + this.updateFileInput(); + this.renderPreview(); + this.dispatchEvent("change", files); } - _populateImagePreview(file) { - if (typeof FileReader === "undefined") { - return; + renderPreview() { + this.clearPreviewContainer(); + for (const file of this.files.values()) { + const preview = this.buildPreview(file); + if (preview) { + this.previewContainerTarget.appendChild(preview); + } + } + if (this.previewTargets.length > 1) { + this.placeholderTarget.style.display = "none"; + if (!this.isMultiple) { + this.inputTarget.style.display = "none"; + } else { + this.inputTarget.style.display = "block"; + } + } + } + clearPreviewContainer() { + const previews = this.previewTargets; + previews.slice(1).forEach((el) => el.remove()); + } + buildPreview(file, element) { + if (!element) { + element = this.previewContainerTarget.firstElementChild?.cloneNode(true); + } + element.style.display = "flex"; + const fileName = element.querySelector(".dropzone-preview-filename"); + if (fileName) { + fileName.textContent = file.name; + } + const button = element.querySelector(".dropzone-preview-button"); + if (button) { + button.setAttribute("data-symfony--ux-dropzone--dropzone-filename-param", file.name); + } + this._populateImagePreview(element, file); + return element; + } + _populateImagePreview(element, file) { + const image = element.querySelector(".dropzone-preview-image"); + if (image && this.isImage(file) && typeof FileReader !== "undefined") { + const reader = new FileReader(); + reader.addEventListener("load", (event) => { + image.querySelector(".dropzone-preview-image")?.remove(); + image.style.backgroundImage = `url('${event.target.result}')`; + image.style.display = "block"; + }); + reader.readAsDataURL(file); } - const reader = new FileReader(); - reader.addEventListener("load", (event) => { - this.previewImageTarget.style.display = "block"; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; - }); - reader.readAsDataURL(file); } onDragEnter() { this.inputTarget.style.display = "block"; - this.placeholderTarget.style.display = "block"; - this.previewTarget.style.display = "none"; } onDragLeave(event) { event.preventDefault(); - if (!this.element.contains(event.relatedTarget)) { - this.inputTarget.style.display = "none"; - this.placeholderTarget.style.display = "none"; - this.previewTarget.style.display = "block"; + } + updateFileInput() { + const dataTransfer = new DataTransfer(); + for (const file of this.files.values()) { + dataTransfer.items.add(file); + } + this.inputTarget.files = dataTransfer.files; + } + addFiles(files) { + for (const file of files) { + this.files.set(file.name, file); } } + isImage(file) { + return typeof file.type !== "undefined" && file.type.indexOf("image") !== -1; + } + get isMultiple() { + return this.inputTarget.multiple; + } dispatchEvent(name, payload = {}) { this.dispatch(name, { detail: payload, prefix: "dropzone" }); } }; -controller_default.targets = ["input", "placeholder", "preview", "previewClearButton", "previewFilename", "previewImage"]; +controller_default.targets = ["input", "placeholder", "preview", "previewClearButton", "previewFilename", "previewImage", "previewContainer"]; export { controller_default as default }; diff --git a/src/Dropzone/assets/dist/style.min.css b/src/Dropzone/assets/dist/style.min.css index 4c1e49daedb..9ac03adcf14 100644 --- a/src/Dropzone/assets/dist/style.min.css +++ b/src/Dropzone/assets/dist/style.min.css @@ -1 +1 @@ -.dropzone-container{border:2px dashed #bbb;align-items:center;min-height:100px;padding:20px 10px;display:flex;position:relative}.dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.dropzone-preview{align-items:center;max-width:100%;display:flex}.dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px}.dropzone-preview-filename{word-wrap:anywhere}.dropzone-preview-button{z-index:1;width:auto;color:inherit;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;background:0 0;border:none;margin:0;padding:0;line-height:normal;position:absolute;top:0;right:0;overflow:visible}.dropzone-preview-button:before{content:"×";cursor:pointer;padding:3px 7px}.dropzone-placeholder{text-align:center;color:#999;flex-grow:1} \ No newline at end of file +.dropzone-container{border:2px dashed #bbb;align-items:center;min-height:100px;padding:20px 10px;display:flex;position:relative}.dropzone-input{opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;display:block;position:absolute;top:0;left:0}.dropzone-preview{align-items:center;max-width:100%;padding:10px 0;display:flex}.dropzone-preview-image{background-position:50%;background-repeat:no-repeat;background-size:contain;flex-basis:0;min-width:50px;max-width:50px;height:50px;margin-right:10px}.dropzone-preview-filename{word-wrap:anywhere}.dropzone-preview-button{z-index:1;width:auto;color:inherit;font:inherit;-webkit-font-smoothing:inherit;-moz-osx-font-smoothing:inherit;-webkit-appearance:none;background:0 0;border:none;margin:0;padding:0;line-height:normal;position:relative;top:0;right:0;overflow:visible}.dropzone-preview-button:before{content:"×";cursor:pointer;padding:3px 7px}.dropzone-placeholder{text-align:center;color:#999;flex-grow:1} \ No newline at end of file diff --git a/src/Dropzone/assets/package.json b/src/Dropzone/assets/package.json index a59e0740666..bf08c07d716 100644 --- a/src/Dropzone/assets/package.json +++ b/src/Dropzone/assets/package.json @@ -2,7 +2,7 @@ "name": "@symfony/ux-dropzone", "description": "File input dropzones for Symfony Forms", "license": "MIT", - "version": "2.31.0", + "version": "2.30.0", "keywords": [ "symfony-ux" ], diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index b2533329388..220d593c7dd 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -7,17 +7,17 @@ * file that was distributed with this source code. */ -import { Controller } from '@hotwired/stimulus'; +import {Controller} from '@hotwired/stimulus'; export default class extends Controller { declare readonly inputTarget: HTMLInputElement; declare readonly placeholderTarget: HTMLDivElement; - declare readonly previewTarget: HTMLDivElement; - declare readonly previewClearButtonTarget: HTMLButtonElement; - declare readonly previewFilenameTarget: HTMLDivElement; - declare readonly previewImageTarget: HTMLDivElement; + declare readonly previewTargets: HTMLDivElement[]; + declare readonly previewContainerTarget: HTMLDivElement; - static targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage']; + static targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage', 'previewContainer']; + + files: Map = new Map(); initialize() { this.clear = this.clear.bind(this); @@ -30,9 +30,6 @@ export default class extends Controller { // Reset when connecting to work with Turbolinks this.clear(); - // Clear on click on clear button - this.previewClearButtonTarget.addEventListener('click', this.clear); - // Listen on input change and display preview this.inputTarget.addEventListener('change', this.onInputChange); @@ -46,81 +43,139 @@ export default class extends Controller { } disconnect() { - this.previewClearButtonTarget.removeEventListener('click', this.clear); this.inputTarget.removeEventListener('change', this.onInputChange); this.element.removeEventListener('dragenter', this.onDragEnter); this.element.removeEventListener('dragleave', this.onDragLeave); } - clear() { - this.inputTarget.value = ''; - this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; - this.previewImageTarget.style.display = 'none'; - this.previewImageTarget.style.backgroundImage = 'none'; - this.previewFilenameTarget.textContent = ''; + clear(event?: { target?: HTMLElement; params?: { filename?: string } }) { + if (event?.params) { + const filename = event.params.filename; + if (filename && this.files.has(filename)) { + this.files.delete(filename); + this.updateFileInput(); + this.renderPreview(); + } + } + if (!this.inputTarget || !this.inputTarget.files || this.inputTarget?.files?.length === 0) { + this.placeholderTarget.style.display = "block"; + if (!this.isMultiple) { + this.inputTarget.style.display = "block"; + } + } - this.dispatchEvent('clear'); + this.dispatchEvent("clear"); } - onInputChange(event: any) { - const file = event.target.files[0]; - if (typeof file === 'undefined') { + onInputChange() { + const files = this.inputTarget.files; + + if (!files || files.length <= 0) { return; } - // Hide the input and placeholder - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; + if (!this.isMultiple && this.files.size > 0) { + this.inputTarget.style.display = "none"; + } - // Show the filename in preview - this.previewFilenameTarget.textContent = file.name; - this.previewTarget.style.display = 'flex'; + const selectedFiles = this.isMultiple ? Array.from(files) : Array.from(files).slice(0, 1); + this.addFiles(selectedFiles); + this.updateFileInput(); + this.renderPreview(); + this.dispatchEvent("change", files); + } - // If the 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); + private renderPreview() { + this.clearPreviewContainer(); + for (const file of this.files.values()) { + const preview = this.buildPreview(file); + if (preview) { + this.previewContainerTarget.appendChild(preview); + } } - this.dispatchEvent('change', file); + if (this.previewTargets.length > 1) { + this.placeholderTarget.style.display = 'none'; + if (!this.isMultiple) { + this.inputTarget.style.display = 'none'; + } else { + this.inputTarget.style.display = 'block'; + } + } } - _populateImagePreview(file: Blob) { - if (typeof FileReader === 'undefined') { - // FileReader API not available, skip - return; + private clearPreviewContainer() { + const previews = this.previewTargets; + previews.slice(1).forEach(el => el.remove()); + } + + private buildPreview(file: File, element?: HTMLElement): HTMLElement { + if (!element) { + element = this.previewContainerTarget.firstElementChild?.cloneNode(true) as HTMLElement; + } + element.style.display = 'flex'; + + const fileName = element.querySelector('.dropzone-preview-filename'); + if (fileName) { + fileName.textContent = file.name } - const reader = new FileReader(); + const button = element.querySelector('.dropzone-preview-button'); + if (button) { + button.setAttribute('data-symfony--ux-dropzone--dropzone-filename-param', file.name); + } + + this._populateImagePreview(element, file); + return element; + } + + _populateImagePreview(element: HTMLElement, file: File) { + const image = element.querySelector('.dropzone-preview-image'); - reader.addEventListener('load', (event: any) => { - this.previewImageTarget.style.display = 'block'; - this.previewImageTarget.style.backgroundImage = `url("${event.target.result}")`; - }); + if (image && this.isImage(file) && typeof FileReader !== 'undefined') { + const reader = new FileReader(); - reader.readAsDataURL(file); + reader.addEventListener('load', (event: any) => { + image.querySelector('.dropzone-preview-image')?.remove(); + image.style.backgroundImage = `url('${event.target.result}')`; + image.style.display = 'block'; + }); + + reader.readAsDataURL(file as Blob); + } } onDragEnter() { this.inputTarget.style.display = 'block'; - this.placeholderTarget.style.display = 'block'; - this.previewTarget.style.display = 'none'; } onDragLeave(event: any) { event.preventDefault(); + } - // Check if we really leave the main drag area - if (!this.element.contains(event.relatedTarget as Node)) { - this.inputTarget.style.display = 'none'; - this.placeholderTarget.style.display = 'none'; - this.previewTarget.style.display = 'block'; + private updateFileInput() { + const dataTransfer = new DataTransfer(); + for (const file of this.files.values()) { + dataTransfer.items.add(file); } + this.inputTarget.files = dataTransfer.files; + } + + private addFiles(files: File[]) { + for (const file of files) { + this.files.set(file.name, file); + } + } + + private isImage(file: File): boolean { + return typeof file.type !== 'undefined' && file.type.indexOf('image') !== -1; + } + + private get isMultiple(): boolean { + return this.inputTarget.multiple; } private dispatchEvent(name: string, payload: any = {}) { - this.dispatch(name, { detail: payload, prefix: 'dropzone' }); + this.dispatch(name, {detail: payload, prefix: 'dropzone'}); } } diff --git a/src/Dropzone/assets/src/style.css b/src/Dropzone/assets/src/style.css index d942b59cb60..1df9e519303 100644 --- a/src/Dropzone/assets/src/style.css +++ b/src/Dropzone/assets/src/style.css @@ -23,6 +23,7 @@ display: flex; align-items: center; max-width: 100%; + padding: 10px 0; } .dropzone-preview-image { @@ -41,7 +42,7 @@ } .dropzone-preview-button { - position: absolute; + position: relative; top: 0; right: 0; z-index: 1; diff --git a/src/Dropzone/assets/test/unit/controller.test.ts b/src/Dropzone/assets/test/unit/controller.test.ts index 522a22414cc..6645a1508bc 100644 --- a/src/Dropzone/assets/test/unit/controller.test.ts +++ b/src/Dropzone/assets/test/unit/controller.test.ts @@ -45,25 +45,27 @@ describe('DropzoneController', () => { data-testid="placeholder"> Placeholder - - `); @@ -96,7 +98,10 @@ describe('DropzoneController', () => { getByTestId(container, 'preview').style.display = 'block'; // Click the clear button - getByTestId(container, 'button').click(); + const button = getByTestId(container, 'button'); + await waitFor(() => expect(button).toBeInTheDocument()); + + button.click(); await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' })); await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'block' })); @@ -120,7 +125,13 @@ describe('DropzoneController', () => { const input = getByTestId(container, 'input'); const file = new File(['hello'], 'hello.png', { type: 'image/png' }); - await user.upload(input, file); + Object.defineProperty(input, 'files', { + configurable: true, + writable: true, + value: [file], + }); + input.dispatchEvent(new Event('change', { bubbles: true })); + expect(input.files[0]).toStrictEqual(file); // The dropzone should be in preview mode @@ -129,7 +140,7 @@ describe('DropzoneController', () => { // The event should have been dispatched expect(dispatched).not.toBeNull(); - expect(dispatched.detail).toStrictEqual(file); + expect(dispatched.detail).toStrictEqual(input.files); }); it('on drag', async () => { diff --git a/src/Dropzone/assets/test/vitest.setup.js b/src/Dropzone/assets/test/vitest.setup.js new file mode 100644 index 00000000000..d3d87616ef7 --- /dev/null +++ b/src/Dropzone/assets/test/vitest.setup.js @@ -0,0 +1,32 @@ +class MockDataTransferItemList { + constructor(files) { + this._files = files; + } + + add(file) { + this._files.push(file); + } + + remove(index) { + this._files.splice(index, 1); + } + + clear() { + this._files.length = 0; + } +} + +class MockDataTransfer { + constructor() { + this.files = []; + this.items = new MockDataTransferItemList(this.files); + } + + setData() {} + getData() { return ''; } + clearData() { + this.files.length = 0; + } +} + +globalThis.DataTransfer = MockDataTransfer; diff --git a/src/Dropzone/assets/vitest.config.mjs b/src/Dropzone/assets/vitest.config.mjs index 3331ac1d8a2..7f8663b9415 100644 --- a/src/Dropzone/assets/vitest.config.mjs +++ b/src/Dropzone/assets/vitest.config.mjs @@ -1,4 +1,12 @@ -import { mergeConfig } from 'vitest/config'; +import { mergeConfig, defineConfig } from 'vitest/config'; import configShared from '../../../vitest.config.base.mjs'; -export default mergeConfig(configShared, {}); +export default mergeConfig( + configShared, + defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./test/vitest.setup.js'], + }, + }) +); From bff77eca443a394a5e8f878851fbec0114816bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Herna=CC=81ndez?= Date: Mon, 10 Nov 2025 10:01:42 +0100 Subject: [PATCH 2/7] Update Dropzone CHANGELOG with v2.31 changes. --- src/Dropzone/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index 69917fc4921..9200a60c866 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.31 + +- Suport for multiple files drag and drop and peviews + ## 2.30 - Ensure compatibility with PHP 8.5 From 3fca53a2c0a0f6fa960e4567389c576aa599ac93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Herna=CC=81ndez?= Date: Mon, 10 Nov 2025 11:39:47 +0100 Subject: [PATCH 3/7] - Fix inconsistent quote styles to maintain coding standards. - Format array declarations for better readability in `controller.ts`. - Correct typos in `CHANGELOG.md`. - Adjust imports for consistency in `vitest.config.mjs`. - Refactor `vitest.setup.js` for improved code structure. --- src/Dropzone/CHANGELOG.md | 2 +- src/Dropzone/assets/src/controller.ts | 28 +++++++++++++++--------- src/Dropzone/assets/test/vitest.setup.js | 4 +++- src/Dropzone/assets/vitest.config.mjs | 2 +- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/Dropzone/CHANGELOG.md b/src/Dropzone/CHANGELOG.md index 9200a60c866..0d2149859a0 100644 --- a/src/Dropzone/CHANGELOG.md +++ b/src/Dropzone/CHANGELOG.md @@ -2,7 +2,7 @@ ## 2.31 -- Suport for multiple files drag and drop and peviews +- Support for multiple files drag and drop and peviews ## 2.30 diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index 220d593c7dd..3c2ec6db1bf 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import {Controller} from '@hotwired/stimulus'; +import { Controller } from '@hotwired/stimulus'; export default class extends Controller { declare readonly inputTarget: HTMLInputElement; @@ -15,7 +15,15 @@ export default class extends Controller { declare readonly previewTargets: HTMLDivElement[]; declare readonly previewContainerTarget: HTMLDivElement; - static targets = ['input', 'placeholder', 'preview', 'previewClearButton', 'previewFilename', 'previewImage', 'previewContainer']; + static targets = [ + 'input', + 'placeholder', + 'preview', + 'previewClearButton', + 'previewFilename', + 'previewImage', + 'previewContainer' + ]; files: Map = new Map(); @@ -58,13 +66,13 @@ export default class extends Controller { } } if (!this.inputTarget || !this.inputTarget.files || this.inputTarget?.files?.length === 0) { - this.placeholderTarget.style.display = "block"; + this.placeholderTarget.style.display = 'block'; if (!this.isMultiple) { - this.inputTarget.style.display = "block"; + this.inputTarget.style.display = 'block'; } } - this.dispatchEvent("clear"); + this.dispatchEvent('clear'); } onInputChange() { @@ -75,14 +83,14 @@ export default class extends Controller { } if (!this.isMultiple && this.files.size > 0) { - this.inputTarget.style.display = "none"; + this.inputTarget.style.display = 'none'; } const selectedFiles = this.isMultiple ? Array.from(files) : Array.from(files).slice(0, 1); this.addFiles(selectedFiles); this.updateFileInput(); this.renderPreview(); - this.dispatchEvent("change", files); + this.dispatchEvent('change', files); } private renderPreview() { @@ -106,7 +114,7 @@ export default class extends Controller { private clearPreviewContainer() { const previews = this.previewTargets; - previews.slice(1).forEach(el => el.remove()); + previews.slice(1).forEach((el) => el.remove()); } private buildPreview(file: File, element?: HTMLElement): HTMLElement { @@ -117,7 +125,7 @@ export default class extends Controller { const fileName = element.querySelector('.dropzone-preview-filename'); if (fileName) { - fileName.textContent = file.name + fileName.textContent = file.name; } const button = element.querySelector('.dropzone-preview-button'); @@ -176,6 +184,6 @@ export default class extends Controller { } private dispatchEvent(name: string, payload: any = {}) { - this.dispatch(name, {detail: payload, prefix: 'dropzone'}); + this.dispatch(name, { detail: payload, prefix: 'dropzone' }); } } diff --git a/src/Dropzone/assets/test/vitest.setup.js b/src/Dropzone/assets/test/vitest.setup.js index d3d87616ef7..8fb1483269d 100644 --- a/src/Dropzone/assets/test/vitest.setup.js +++ b/src/Dropzone/assets/test/vitest.setup.js @@ -23,7 +23,9 @@ class MockDataTransfer { } setData() {} - getData() { return ''; } + getData() { + return ''; + } clearData() { this.files.length = 0; } diff --git a/src/Dropzone/assets/vitest.config.mjs b/src/Dropzone/assets/vitest.config.mjs index 7f8663b9415..d55633a367d 100644 --- a/src/Dropzone/assets/vitest.config.mjs +++ b/src/Dropzone/assets/vitest.config.mjs @@ -1,4 +1,4 @@ -import { mergeConfig, defineConfig } from 'vitest/config'; +import { defineConfig, mergeConfig } from 'vitest/config'; import configShared from '../../../vitest.config.base.mjs'; export default mergeConfig( From 24a594a4582519cff181f884a03e4fcc28ba30fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Herna=CC=81ndez?= Date: Tue, 11 Nov 2025 09:28:56 +0100 Subject: [PATCH 4/7] Remove unused imports and fix syntax formatting in DropzoneController files. --- src/Dropzone/assets/src/controller.ts | 2 +- src/Dropzone/assets/test/unit/controller.test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Dropzone/assets/src/controller.ts b/src/Dropzone/assets/src/controller.ts index 3c2ec6db1bf..9f5c7667039 100644 --- a/src/Dropzone/assets/src/controller.ts +++ b/src/Dropzone/assets/src/controller.ts @@ -22,7 +22,7 @@ export default class extends Controller { 'previewClearButton', 'previewFilename', 'previewImage', - 'previewContainer' + 'previewContainer', ]; files: Map = new Map(); diff --git a/src/Dropzone/assets/test/unit/controller.test.ts b/src/Dropzone/assets/test/unit/controller.test.ts index 6645a1508bc..9254000f336 100644 --- a/src/Dropzone/assets/test/unit/controller.test.ts +++ b/src/Dropzone/assets/test/unit/controller.test.ts @@ -9,7 +9,6 @@ import { Application, Controller } from '@hotwired/stimulus'; import { getByTestId, waitFor } from '@testing-library/dom'; -import user from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { clearDOM, mountDOM } from '../../../../../test/stimulus-helpers'; import DropzoneController from '../../src/controller'; From cf213e44d0a627fb2ff8a25c4ab8f3e4a090fd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Herna=CC=81ndez?= Date: Tue, 11 Nov 2025 09:47:56 +0100 Subject: [PATCH 5/7] Adjust Dropzone tests, templates, and controller to align with updated UI logic for preview visibility and structure. --- src/Dropzone/assets/dist/controller.js | 10 ++++++++- .../assets/test/unit/controller.test.ts | 12 +++++------ src/Dropzone/templates/form_theme.html.twig | 21 +++++++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/Dropzone/assets/dist/controller.js b/src/Dropzone/assets/dist/controller.js index 2dad90a8e17..9b3b728124a 100644 --- a/src/Dropzone/assets/dist/controller.js +++ b/src/Dropzone/assets/dist/controller.js @@ -131,7 +131,15 @@ var controller_default = class extends Controller { this.dispatch(name, { detail: payload, prefix: "dropzone" }); } }; -controller_default.targets = ["input", "placeholder", "preview", "previewClearButton", "previewFilename", "previewImage", "previewContainer"]; +controller_default.targets = [ + "input", + "placeholder", + "preview", + "previewClearButton", + "previewFilename", + "previewImage", + "previewContainer" +]; export { controller_default as default }; diff --git a/src/Dropzone/assets/test/unit/controller.test.ts b/src/Dropzone/assets/test/unit/controller.test.ts index 9254000f336..dfa145ac829 100644 --- a/src/Dropzone/assets/test/unit/controller.test.ts +++ b/src/Dropzone/assets/test/unit/controller.test.ts @@ -102,9 +102,9 @@ describe('DropzoneController', () => { button.click(); - await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' })); - await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'block' })); - await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); // The event should have been dispatched expect(dispatched).toBe(true); @@ -160,8 +160,8 @@ describe('DropzoneController', () => { getByTestId(container, 'container').dispatchEvent(dragLeaveEvent); // Check that the input and placeholder are hidden, and preview shown - await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' })); - await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' })); - await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'none' })); }); }); diff --git a/src/Dropzone/templates/form_theme.html.twig b/src/Dropzone/templates/form_theme.html.twig index 1dbdda0b8f0..1b5c11d7770 100644 --- a/src/Dropzone/templates/form_theme.html.twig +++ b/src/Dropzone/templates/form_theme.html.twig @@ -10,15 +10,18 @@ {{- translation_domain is same as(false) ? attr.placeholder : attr.placeholder|trans({}, translation_domain) -}} {%- endif -%} - - {%- endblock %} From 2e7f15129914efab4370dbb3b24aed6b4210f069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Herna=CC=81ndez?= Date: Tue, 11 Nov 2025 09:51:23 +0100 Subject: [PATCH 6/7] Update controller tests to reflect clear button behavior changes --- src/Dropzone/assets/test/unit/controller.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Dropzone/assets/test/unit/controller.test.ts b/src/Dropzone/assets/test/unit/controller.test.ts index dfa145ac829..c2373eb3964 100644 --- a/src/Dropzone/assets/test/unit/controller.test.ts +++ b/src/Dropzone/assets/test/unit/controller.test.ts @@ -54,6 +54,8 @@ describe('DropzoneController', () => {
{ button.click(); - await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' })); - await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' })); + await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' })); + await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'block' })); await waitFor(() => expect(getByTestId(container, 'preview')).toHaveStyle({ display: 'block' })); // The event should have been dispatched From de227faa5d883ec9a7a528d8595131fe9dc49b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Herna=CC=81ndez?= Date: Tue, 11 Nov 2025 10:09:19 +0100 Subject: [PATCH 7/7] Refactor Dropzone preview structure for better container organization and added button attributes. --- src/Dropzone/templates/form_theme.html.twig | 4 ++-- src/Dropzone/tests/Form/DropzoneTypeTest.php | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Dropzone/templates/form_theme.html.twig b/src/Dropzone/templates/form_theme.html.twig index 1b5c11d7770..14bad358c64 100644 --- a/src/Dropzone/templates/form_theme.html.twig +++ b/src/Dropzone/templates/form_theme.html.twig @@ -10,6 +10,7 @@ {{- translation_domain is same as(false) ? attr.placeholder : attr.placeholder|trans({}, translation_domain) -}} {%- endif -%}
+
-
{%- endblock %} diff --git a/src/Dropzone/tests/Form/DropzoneTypeTest.php b/src/Dropzone/tests/Form/DropzoneTypeTest.php index cbd447776ab..93e23b4de0d 100644 --- a/src/Dropzone/tests/Form/DropzoneTypeTest.php +++ b/src/Dropzone/tests/Form/DropzoneTypeTest.php @@ -43,14 +43,17 @@ public function testRenderForm()
- ',