Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Dropzone/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Add a `data-state` attribute on the container: `empty`, `filled`, `dragging` or `processing`
- Show and hide the parts of the Dropzone with the `hidden` attribute instead of inline styles, see [UPGRADE.md](UPGRADE.md)
- Add a `dropzone_preview_item_template` form theme block and a `previewItemTemplate` target to render the preview of each file in `multiple` mode from a `<template>`
- Make the `previewClearButton`, `previewFilename` and `previewImage` targets optional in single-file mode

## 3.5.0

Expand Down
4 changes: 4 additions & 0 deletions src/Dropzone/assets/dist/controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ export default class export_default extends Controller {
readonly previewTarget: HTMLDivElement;
readonly previewClearButtonTarget: HTMLButtonElement;
readonly previewFilenameTarget: HTMLDivElement;
readonly hasPreviewFilenameTarget: boolean;
readonly previewImageTarget: HTMLDivElement;
readonly hasPreviewImageTarget: boolean;
readonly previewListTarget: HTMLUListElement;
readonly hasPreviewListTarget: boolean;
readonly previewItemTemplateTarget: HTMLTemplateElement;
Expand All @@ -30,6 +32,8 @@ export default class export_default extends Controller {
initialize(): void;
connect(): void;
disconnect(): void;
previewClearButtonTargetConnected(button: HTMLButtonElement): void;
previewClearButtonTargetDisconnected(button: HTMLButtonElement): void;
clear(): void;
private reset;
onInputChange(event: any): void;
Expand Down
26 changes: 17 additions & 9 deletions src/Dropzone/assets/dist/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ var controller_default = class extends Controller {
this.dispatchEvent("connect");
return;
}
for (const target of [this.previewTarget, this.previewImageTarget]) if (target.style.display === "none") target.style.removeProperty("display");
for (const target of [this.previewTarget, ...this.hasPreviewImageTarget ? [this.previewImageTarget] : []]) if (target.style.display === "none") target.style.removeProperty("display");
this.clear();
this.previewClearButtonTarget.addEventListener("click", this.clear);
this.inputTarget.addEventListener("change", this.onInputChange);
this.dispatchEvent("connect");
}
Expand All @@ -57,18 +56,25 @@ var controller_default = class extends Controller {
this.inputTarget.removeEventListener("change", this.onMultipleChange);
return;
}
this.previewClearButtonTarget.removeEventListener("click", this.clear);
this.inputTarget.removeEventListener("change", this.onInputChange);
}
previewClearButtonTargetConnected(button) {
if (!this.multipleValue) button.addEventListener("click", this.clear);
}
previewClearButtonTargetDisconnected(button) {
button.removeEventListener("click", this.clear);
}
clear() {
this.generation++;
this.reset();
}
reset() {
this.inputTarget.value = "";
this.previewImageTarget.hidden = true;
this.previewImageTarget.style.removeProperty("background-image");
this.previewFilenameTarget.textContent = "";
if (this.hasPreviewImageTarget) {
this.previewImageTarget.hidden = true;
this.previewImageTarget.style.removeProperty("background-image");
}
if (this.hasPreviewFilenameTarget) this.previewFilenameTarget.textContent = "";
this.filled = false;
this.updateState();
this.dispatchEvent("clear");
Expand Down Expand Up @@ -135,9 +141,11 @@ var controller_default = class extends Controller {
});
}
showFile(file) {
this.previewFilenameTarget.textContent = file.name;
this.previewImageTarget.hidden = true;
if (file.type && file.type.indexOf("image") !== -1) this._populateImagePreview(this.previewImageTarget, file);
if (this.hasPreviewFilenameTarget) this.previewFilenameTarget.textContent = file.name;
if (this.hasPreviewImageTarget) {
this.previewImageTarget.hidden = true;
if (file.type && file.type.indexOf("image") !== -1) this._populateImagePreview(this.previewImageTarget, file);
}
this.filled = true;
this.updateState();
this.dispatchEvent("change", file);
Expand Down
42 changes: 30 additions & 12 deletions src/Dropzone/assets/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export default class extends Controller {
declare readonly previewTarget: HTMLDivElement;
declare readonly previewClearButtonTarget: HTMLButtonElement;
declare readonly previewFilenameTarget: HTMLDivElement;
declare readonly hasPreviewFilenameTarget: boolean;
declare readonly previewImageTarget: HTMLDivElement;
declare readonly hasPreviewImageTarget: boolean;
declare readonly previewListTarget: HTMLUListElement;
declare readonly hasPreviewListTarget: boolean;
declare readonly previewItemTemplateTarget: HTMLTemplateElement;
Expand Down Expand Up @@ -72,7 +74,7 @@ export default class extends Controller {
}

// Themes copied from older versions hide these parts with an inline style, which would win over the hidden attribute
for (const target of [this.previewTarget, this.previewImageTarget]) {
for (const target of [this.previewTarget, ...(this.hasPreviewImageTarget ? [this.previewImageTarget] : [])]) {
if (target.style.display === 'none') {
target.style.removeProperty('display');
}
Expand All @@ -81,9 +83,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);

Expand All @@ -101,10 +100,21 @@ export default class extends Controller {
return;
}

this.previewClearButtonTarget.removeEventListener('click', this.clear);
this.inputTarget.removeEventListener('change', this.onInputChange);
}

// Bound when the button appears, as it is optional and can be rendered after the controller connects
previewClearButtonTargetConnected(button: HTMLButtonElement) {
// In multiple mode, files are removed one by one, never cleared together
if (!this.multipleValue) {
button.addEventListener('click', this.clear);
}
}

previewClearButtonTargetDisconnected(button: HTMLButtonElement) {
button.removeEventListener('click', this.clear);
}

clear() {
// The selections still processed must not bring a file back
this.generation++;
Expand All @@ -113,9 +123,13 @@ export default class extends Controller {

private reset() {
this.inputTarget.value = '';
this.previewImageTarget.hidden = true;
this.previewImageTarget.style.removeProperty('background-image');
this.previewFilenameTarget.textContent = '';
if (this.hasPreviewImageTarget) {
this.previewImageTarget.hidden = true;
this.previewImageTarget.style.removeProperty('background-image');
}
if (this.hasPreviewFilenameTarget) {
this.previewFilenameTarget.textContent = '';
}
this.filled = false;
this.updateState();

Expand Down Expand Up @@ -220,12 +234,16 @@ export default class extends Controller {
}

private showFile(file: File) {
this.previewFilenameTarget.textContent = file.name;
if (this.hasPreviewFilenameTarget) {
this.previewFilenameTarget.textContent = file.name;
}

// If the file is an image, load it and display it as preview
this.previewImageTarget.hidden = true;
if (file.type && file.type.indexOf('image') !== -1) {
this._populateImagePreview(this.previewImageTarget, file);
if (this.hasPreviewImageTarget) {
this.previewImageTarget.hidden = true;
if (file.type && file.type.indexOf('image') !== -1) {
this._populateImagePreview(this.previewImageTarget, file);
}
}

this.filled = true;
Expand Down
109 changes: 109 additions & 0 deletions src/Dropzone/assets/test/unit/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,92 @@ describe('DropzoneController', () => {
pending.resolve([a]);
await waitFor(() => expectFilled());
});

it.each(['button', 'preview-filename', 'preview-image'])('works without the %s target', async (testId) => {
getByTestId(container, testId).remove();
startStimulus();
await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected'));

let changed: File | null = null;
getByTestId(container, 'container').addEventListener('dropzone:change', (event: any) => {
changed = event.detail;
});

const input = getByTestId(container, 'input') as HTMLInputElement;
const file = new File(['png'], 'a.png', { type: 'image/png' });
Object.defineProperty(input, 'files', { configurable: true, writable: true, value: [file] });
input.dispatchEvent(new Event('change'));

expect(changed).toBe(file);
expect(getByTestId(container, 'preview')).not.toHaveAttribute('hidden');
expect(getByTestId(container, 'placeholder')).toHaveAttribute('hidden');
});

const pick = (file: File) => {
const input = getByTestId(container, 'input') as HTMLInputElement;
Object.defineProperty(input, 'files', { configurable: true, writable: true, value: [file] });
input.dispatchEvent(new Event('change'));
};

it('shows the file name without the image preview target', async () => {
getByTestId(container, 'preview-image').remove();
startStimulus();
await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected'));

pick(new File(['png'], 'a.png', { type: 'image/png' }));

expect(getByTestId(container, 'preview-filename')).toHaveTextContent('a.png');
});

it('shows the image preview without the file name target', async () => {
getByTestId(container, 'preview-filename').remove();
startStimulus();
await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected'));

pick(new File(['png'], 'a.png', { type: 'image/png' }));

await waitFor(() =>
expect(getByTestId(container, 'preview-image').style.backgroundImage).toBe(
'url("data:image/png;base64,cG5n")'
)
);
});

it('disconnects without the clear button target', async () => {
getByTestId(container, 'button').remove();
startStimulus();
await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected'));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

for (const application of applications.splice(0)) {
application.stop();
}

expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();
});

it('clears with a clear button added after connect', async () => {
getByTestId(container, 'button').remove();
startStimulus();
await waitFor(() => expect(getByTestId(container, 'container')).toHaveClass('connected'));
pick(new File(['hello'], 'hello.txt'));

const button = document.createElement('button');
button.type = 'button';
button.setAttribute('data-dropzone-target', 'previewClearButton');
getByTestId(container, 'preview').append(button);
await new Promise((resolve) => setTimeout(resolve));
let cleared = false;
getByTestId(container, 'container').addEventListener('dropzone:clear', () => {
cleared = true;
});

button.click();

expect(cleared).toBe(true);
expect(getByTestId(container, 'preview')).toHaveAttribute('hidden');
});
});

describe('DropzoneController (multiple)', () => {
Expand Down Expand Up @@ -795,6 +881,29 @@ describe('DropzoneController (multiple)', () => {
expect(consoleError).toHaveBeenCalledWith(new Error('Cannot decode the image'));
consoleError.mockRestore();
});

it('does not clear the files with a clear button', async () => {
// The theme also renders the preview of the single-file mode in multiple mode
const preview = document.createElement('div');
preview.setAttribute('data-dropzone-target', 'preview');
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('data-dropzone-target', 'previewClearButton');
preview.append(button);
getByTestId(container, 'container').append(preview);
await start();
const input = getByTestId(container, 'input') as HTMLInputElement;
selectFiles(input, [make('a.txt')]);
let cleared = false;
getByTestId(container, 'container').addEventListener('dropzone:clear', () => {
cleared = true;
});

button.click();

expect(cleared).toBe(false);
expect(getByTestId(container, 'list').children).toHaveLength(1);
});
});

describe('DropzoneController (multiple, preview item template)', () => {
Expand Down
13 changes: 13 additions & 0 deletions src/Dropzone/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,19 @@ When several states apply, the last one of this list wins:
opacity: 0.5;
}

If you override the ``dropzone_widget`` block in your form theme, the clear button, the file name and the image preview of the single-file mode are optional: leave out the elements with the ``previewClearButton``, ``previewFilename`` or ``previewImage`` target that your design does not need.

Without the clear button, the user cannot deselect the file: once a file is chosen, the input is hidden behind the preview.
To clear the Dropzone from another element inside it, use the ``clear`` action of the controller:

.. code-block:: html

<button type="button" data-action="symfony--ux-dropzone--dropzone#clear">Remove</button>

.. versionadded:: 3.7

These targets became optional in Dropzone 3.7.

Extend the default behavior
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
Loading