Skip to content

Commit 05ffafc

Browse files
committed
feat(dropzone): enable multiple file uploads
1 parent b376eca commit 05ffafc

File tree

8 files changed

+123
-38
lines changed

8 files changed

+123
-38
lines changed

src/Dropzone/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## Unreleased
4+
5+
- Support added for selecting multiple files (#512) - @yassinehamouten & @daFish
6+
37
## 2.0
48

59
- Support for `stimulus` version 2 was removed and support for `@hotwired/stimulus`

src/Dropzone/Form/DropzoneType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public function configureOptions(OptionsResolver $resolver)
3030
'attr' => [
3131
'placeholder' => 'Drag and drop or browse',
3232
],
33+
'multiple' => false,
3334
]);
3435
}
3536

src/Dropzone/Resources/assets/dist/controller.js

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,25 @@ class default_1 extends Controller {
1515
this.previewImageTarget.style.display = 'none';
1616
this.previewImageTarget.style.backgroundImage = 'none';
1717
this.previewFilenameTarget.textContent = '';
18+
document.querySelectorAll('.dropzone-preview-image-container').forEach((e) => e.remove());
1819
this._dispatchEvent('dropzone:clear');
1920
}
2021
onInputChange(event) {
21-
const file = event.target.files[0];
22-
if (typeof file === 'undefined') {
23-
return;
24-
}
25-
this.inputTarget.style.display = 'none';
26-
this.placeholderTarget.style.display = 'none';
27-
this.previewFilenameTarget.textContent = file.name;
28-
this.previewTarget.style.display = 'flex';
29-
this.previewImageTarget.style.display = 'none';
30-
if (file.type && file.type.indexOf('image') !== -1) {
31-
this._populateImagePreview(file);
22+
for (const fileItem in event.target.files) {
23+
const file = event.target.files[fileItem];
24+
if (typeof file === 'undefined') {
25+
return;
26+
}
27+
this.inputTarget.style.display = 'none';
28+
this.placeholderTarget.style.display = 'none';
29+
this.previewFilenameTarget.textContent = file.name;
30+
this.previewTarget.style.display = 'flex';
31+
this.previewImageTarget.style.display = 'none';
32+
if (file.type && file.type.indexOf('image') !== -1) {
33+
this._populateImagePreview(file);
34+
}
3235
}
33-
this._dispatchEvent('dropzone:change', file);
36+
this._dispatchEvent('dropzone:change', event.target.files);
3437
}
3538
_populateImagePreview(file) {
3639
if (typeof FileReader === 'undefined') {

src/Dropzone/Resources/assets/src/controller.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,31 +42,34 @@ export default class extends Controller {
4242
this.previewImageTarget.style.display = 'none';
4343
this.previewImageTarget.style.backgroundImage = 'none';
4444
this.previewFilenameTarget.textContent = '';
45+
document.querySelectorAll('.dropzone-preview-image-container').forEach((e) => e.remove());
4546

4647
this._dispatchEvent('dropzone:clear');
4748
}
4849

4950
onInputChange(event: any) {
50-
const file = event.target.files[0];
51-
if (typeof file === 'undefined') {
52-
return;
53-
}
54-
55-
// Hide the input and placeholder
56-
this.inputTarget.style.display = 'none';
57-
this.placeholderTarget.style.display = 'none';
58-
59-
// Show the filename in preview
60-
this.previewFilenameTarget.textContent = file.name;
61-
this.previewTarget.style.display = 'flex';
62-
63-
// If the file is an image, load it and display it as preview
64-
this.previewImageTarget.style.display = 'none';
65-
if (file.type && file.type.indexOf('image') !== -1) {
66-
this._populateImagePreview(file);
51+
for (const fileItem in event.target.files) {
52+
const file = event.target.files[fileItem];
53+
if (typeof file === 'undefined') {
54+
return;
55+
}
56+
57+
// Hide the input and placeholder
58+
this.inputTarget.style.display = 'none';
59+
this.placeholderTarget.style.display = 'none';
60+
61+
// Show the filename in preview
62+
this.previewFilenameTarget.textContent = file.name;
63+
this.previewTarget.style.display = 'flex';
64+
65+
// If the file is an image, load it and display it as preview
66+
this.previewImageTarget.style.display = 'none';
67+
if (file.type && file.type.indexOf('image') !== -1) {
68+
this._populateImagePreview(file);
69+
}
6770
}
6871

69-
this._dispatchEvent('dropzone:change', file);
72+
this._dispatchEvent('dropzone:change', event.target.files);
7073
}
7174

7275
_populateImagePreview(file: Blob) {

src/Dropzone/Resources/assets/src/style.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@
2626
}
2727

2828
.dropzone-preview-image {
29+
margin: auto;
2930
flex-basis: 0;
3031
min-width: 50px;
3132
max-width: 50px;
3233
height: 50px;
33-
margin-right: 10px;
3434
background-size: contain;
3535
background-position: 50% 50%;
3636
background-repeat: no-repeat;
@@ -70,3 +70,7 @@
7070
text-align: center;
7171
color: #999;
7272
}
73+
74+
.dropzone-preview-image-container {
75+
margin-right: 1em;
76+
}

src/Dropzone/Resources/assets/test/controller.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('DropzoneController', () => {
3939
<input type="file"
4040
style="display: none"
4141
data-dropzone-target="input"
42-
data-testid="input" />
42+
data-testid="input" multiple />
4343
4444
<div class="dropzone-placeholder"
4545
data-dropzone-target="placeholder"
@@ -105,7 +105,7 @@ describe('DropzoneController', () => {
105105
expect(dispatched).toBe(true);
106106
});
107107

108-
it('file chosen', async () => {
108+
it('single file chosen', async () => {
109109
startStimulus();
110110
await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' }));
111111

@@ -126,6 +126,35 @@ describe('DropzoneController', () => {
126126

127127
// The event should have been dispatched
128128
expect(dispatched).not.toBeNull();
129-
expect(dispatched.detail).toStrictEqual(file);
129+
expect(dispatched.detail[0]).toStrictEqual(file);
130+
});
131+
132+
it('multiple files chosen', async () => {
133+
startStimulus();
134+
await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'block' }));
135+
136+
// Attach a listener to ensure the event is dispatched
137+
let dispatched = null;
138+
getByTestId(container, 'container').addEventListener('dropzone:change', (event) => (dispatched = event));
139+
140+
// Select the file
141+
const input = getByTestId(container, 'input');
142+
const files = [
143+
new File(['hello'], 'hello.png', { type: 'image/png' }),
144+
new File(['again'], 'again.png', { type: 'image/png' }),
145+
]
146+
147+
user.upload(input, files);
148+
expect(input.files[0]).toStrictEqual(files[0]);
149+
expect(input.files[1]).toStrictEqual(files[1]);
150+
151+
// The dropzone should be in preview mode
152+
await waitFor(() => expect(getByTestId(container, 'input')).toHaveStyle({ display: 'none' }));
153+
await waitFor(() => expect(getByTestId(container, 'placeholder')).toHaveStyle({ display: 'none' }));
154+
155+
// The event should have been dispatched
156+
expect(dispatched).not.toBeNull();
157+
expect(dispatched.detail[0]).toStrictEqual(files[0]);
158+
expect(dispatched.detail[1]).toStrictEqual(files[1]);
130159
});
131160
});

src/Dropzone/Resources/views/form_theme.html.twig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
{%- set dataController = (attr['data-controller']|default('') ~ ' symfony--ux-dropzone--dropzone')|trim -%}
33
{%- set attr = attr|merge({ 'data-controller': '', class: (attr.class|default('') ~ ' dropzone-input')|trim}) -%}
44

5+
56
<div class="dropzone-container" data-controller="{{ dataController }}">
67
<input type="file" {{ block('widget_attributes') }} data-symfony--ux-dropzone--dropzone-target="input" />
78

src/Dropzone/Tests/Form/DropzoneTypeTest.php

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\UX\Dropzone\Tests;
1313

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\DependencyInjection\ContainerInterface;
1516
use Symfony\Component\Form\FormFactoryInterface;
1617
use Symfony\UX\Dropzone\Form\DropzoneType;
1718
use Symfony\UX\Dropzone\Tests\Kernel\TwigAppKernel;
@@ -24,18 +25,27 @@
2425
*/
2526
class DropzoneTypeTest extends TestCase
2627
{
27-
public function testRenderForm()
28+
/**
29+
* @var ContainerInterface
30+
*/
31+
private $container;
32+
33+
protected function setUp(): void
2834
{
2935
$kernel = new TwigAppKernel('test', true);
3036
$kernel->boot();
31-
$container = $kernel->getContainer()->get('test.service_container');
3237

33-
$form = $container->get(FormFactoryInterface::class)->createBuilder()
38+
$this->container = $kernel->getContainer()->get('test.service_container');
39+
}
40+
41+
public function testRenderForm()
42+
{
43+
$form = $this->container->get(FormFactoryInterface::class)->createBuilder()
3444
->add('photo', DropzoneType::class, ['attr' => ['data-controller' => 'mydropzone']])
3545
->getForm()
3646
;
3747

38-
$rendered = $container->get(Environment::class)->render('dropzone_form.html.twig', ['form' => $form->createView()]);
48+
$rendered = $this->container->get(Environment::class)->render('dropzone_form.html.twig', ['form' => $form->createView()]);
3949

4050
$this->assertSame(
4151
'<form name="form" method="post" enctype="multipart/form-data"><div id="form"><div><label for="form_photo" class="required">Photo</label><div class="dropzone-container" data-controller="mydropzone symfony--ux-dropzone--dropzone">
@@ -57,4 +67,34 @@ public function testRenderForm()
5767
str_replace(' >', '>', $rendered)
5868
);
5969
}
70+
71+
public function testRenderFormWithMultiFileUploads(): void
72+
{
73+
$form = $this->container->get(FormFactoryInterface::class)->createBuilder()
74+
->add('photo', DropzoneType::class, ['attr' => ['data-controller' => 'mydropzone'], 'multiple' => true])
75+
->getForm()
76+
;
77+
78+
$rendered = $this->container->get(Environment::class)->render('dropzone_form.html.twig', ['form' => $form->createView()]);
79+
80+
$this->assertSame(
81+
'<form name="form" method="post" enctype="multipart/form-data"><div id="form"><div><label for="form_photo" class="required">Photo</label><div class="dropzone-container" data-controller="mydropzone symfony--ux-dropzone--dropzone">
82+
<input type="file" id="form_photo" name="form[photo][]" required="required" data-controller="" multiple="multiple" class="dropzone-input" data-symfony--ux-dropzone--dropzone-target="input" />
83+
84+
<div class="dropzone-placeholder" data-symfony--ux-dropzone--dropzone-target="placeholder"></div>
85+
86+
<div class="dropzone-preview" data-symfony--ux-dropzone--dropzone-target="preview" style="display: none">
87+
<button class="dropzone-preview-button" type="button"
88+
data-symfony--ux-dropzone--dropzone-target="previewClearButton"></button>
89+
90+
<div class="dropzone-preview-image" style="display: none"
91+
data-symfony--ux-dropzone--dropzone-target="previewImage"></div>
92+
93+
<div data-symfony--ux-dropzone--dropzone-target="previewFilename" class="dropzone-preview-filename"></div>
94+
</div>
95+
</div></div></div></form>
96+
',
97+
str_replace(' >', '>', $rendered)
98+
);
99+
}
60100
}

0 commit comments

Comments
 (0)