com.vaadin.flow.component.upload.Upload Maven / Gradle / Ivy
/*
* Copyright 2000-2024 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.upload;
import java.io.OutputStream;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.List;
import java.util.Objects;
import java.util.stream.IntStream;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.HasSize;
import com.vaadin.flow.component.HasStyle;
import com.vaadin.flow.component.Tag;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.shared.SlotUtils;
import com.vaadin.flow.dom.DomEventListener;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JsonSerializer;
import com.vaadin.flow.server.NoInputStreamException;
import com.vaadin.flow.server.NoOutputStreamException;
import com.vaadin.flow.server.StreamReceiver;
import com.vaadin.flow.server.StreamVariable;
import com.vaadin.flow.shared.Registration;
import elemental.json.Json;
import elemental.json.JsonArray;
import elemental.json.JsonNull;
import elemental.json.JsonObject;
import elemental.json.JsonType;
/**
* Upload is a component for uploading one or more files. It shows the upload
* progression and status of each file. Files can be uploaded using the Upload
* button or via drag and drop.
*
* @author Vaadin Ltd.
*/
@Tag("vaadin-upload")
@NpmPackage(value = "@vaadin/polymer-legacy-adapter", version = "24.4.13")
@JsModule("@vaadin/polymer-legacy-adapter/style-modules.js")
@NpmPackage(value = "@vaadin/upload", version = "24.4.13")
@JsModule("@vaadin/upload/src/vaadin-upload.js")
public class Upload extends Component implements HasSize, HasStyle {
/**
* Server-side component for the default {@code } icon.
*/
@Tag("vaadin-upload-icon")
static class UploadIcon extends Component {
public UploadIcon() {
}
}
private StreamVariable streamVariable;
private boolean interrupted = false;
private int activeUploads = 0;
private boolean uploading;
private UploadI18N i18n;
private Component uploadButton;
private Component defaultUploadButton;
private Component dropLabel;
private Component defaultDropLabel;
private Component dropLabelIcon;
private Component defaultDropLabelIcon;
/**
* The output of the upload is redirected to this receiver.
*/
private Receiver receiver;
/**
* Create a new instance of Upload.
*
* The receiver must be set before performing an upload.
*/
public Upload() {
final String eventDetailError = "event.detail.error";
final String eventDetailFileName = "event.detail.file.name";
getElement().addEventListener("file-reject", event -> {
String detailError = event.getEventData()
.getString(eventDetailError);
String detailFileName = event.getEventData()
.getString(eventDetailFileName);
fireEvent(new FileRejectedEvent(this, detailError, detailFileName));
}).addEventData(eventDetailError).addEventData(eventDetailFileName);
getElement().addEventListener("file-remove", event -> {
String detailFileName = event.getEventData()
.getString(eventDetailFileName);
fireEvent(new FileRemovedEvent(this, detailFileName));
}).addEventData(eventDetailFileName);
// If client aborts upload mark upload as interrupted on server also
getElement().addEventListener("upload-abort",
event -> interruptUpload());
runBeforeClientResponse(ui -> getElement().setAttribute("target",
new StreamReceiver(getElement().getNode(), "upload",
getStreamVariable())));
final String elementFiles = "element.files";
DomEventListener allFinishedListener = e -> {
JsonArray files = e.getEventData().getArray(elementFiles);
boolean isUploading = IntStream.range(0, files.length())
.anyMatch(index -> {
final String KEY = "uploading";
JsonObject object = files.getObject(index);
return object.hasKey(KEY) && object.getBoolean(KEY);
});
if (this.uploading && !isUploading) {
this.fireAllFinish();
}
this.uploading = isUploading;
};
getElement().addEventListener("upload-start",
e -> this.uploading = true);
getElement().addEventListener("upload-success", allFinishedListener)
.addEventData(elementFiles);
getElement().addEventListener("upload-error", allFinishedListener)
.addEventData(elementFiles);
getElement().addEventListener("upload-abort", allFinishedListener)
.addEventData(elementFiles);
defaultUploadButton = new Button();
// Ensure the flag is set before the element is added to the slot
defaultUploadButton.getElement().setProperty("_isDefault", true);
setUploadButton(defaultUploadButton);
defaultDropLabel = new Span();
// Ensure the flag is set before the element is added to the slot
defaultDropLabel.getElement().setProperty("_isDefault", true);
setDropLabel(defaultDropLabel);
defaultDropLabelIcon = new UploadIcon();
setDropLabelIcon(defaultDropLabelIcon);
}
/**
* Create a new instance of Upload with the given receiver.
*
* @param receiver
* receiver that handles the upload
*/
public Upload(Receiver receiver) {
this();
setReceiver(receiver);
}
/**
* Add listener that is informed on all uploads finished.
*
* @param listener
* all finished listener to add
* @return a {@link Registration} for removing the event listener
*/
public Registration addAllFinishedListener(
ComponentEventListener listener) {
return addListener(AllFinishedEvent.class, listener);
}
private StreamVariable getStreamVariable() {
if (streamVariable == null) {
streamVariable = new DefaultStreamVariable(this);
}
return streamVariable;
}
/**
* Limit of files to upload, by default it is unlimited. If the value is set
* to one, the native file browser will prevent selecting multiple files.
*
* @param maxFiles
* the maximum number of files allowed for the user to select
*/
public void setMaxFiles(int maxFiles) {
getElement().setProperty("maxFiles", maxFiles);
getElement().executeJs("this.maxFiles = $0", maxFiles);
}
/**
* Get the maximum number of files allowed for the user to select to upload.
*
* @return the maximum number of files
*/
public int getMaxFiles() {
return (int) getElement().getProperty("maxFiles", 0.0);
}
private void removeMaxFiles() {
getElement().removeProperty("maxFiles");
getElement().executeJs("this.maxFiles = Infinity");
}
/**
* Specify the maximum file size in bytes allowed to upload. Notice that it
* is a client-side constraint, which will be checked before sending the
* request.
*
* @param maxFileSize
* the maximum file size in bytes
*/
public void setMaxFileSize(int maxFileSize) {
getElement().setProperty("maxFileSize", maxFileSize);
}
/**
* Get the maximum allowed file size in the client-side, in bytes.
*
* @return the maximum file size in bytes
*/
public int getMaxFileSize() {
return (int) getElement().getProperty("maxFileSize", 0.0);
}
/**
* When false
, it prevents uploads from triggering immediately
* upon adding file(s). The default is true
.
*
* @param autoUpload
* true
to allow uploads to start immediately after
* selecting files, false
otherwise.
*/
public void setAutoUpload(boolean autoUpload) {
getElement().setProperty("noAuto", !autoUpload);
}
/**
* Get the auto upload status.
*
* @return true
if the upload of files should start immediately
* after they are selected, false
otherwise.
*/
public boolean isAutoUpload() {
return !getElement().getProperty("noAuto", false);
}
/**
* Define whether the element supports dropping files on it for uploading.
* By default it's enabled in desktop and disabled in touch devices because
* mobile devices do not support drag events in general. Setting it
* true
means that drop is enabled even in touch-devices, and
* false
disables drop in all devices.
*
* @param dropAllowed
* true
to allow file dropping, false
* otherwise
*/
public void setDropAllowed(boolean dropAllowed) {
getElement().setProperty("nodrop", !dropAllowed);
}
/**
* Get whether file dropping is allowed or not. By default it's enabled in
* desktop and disabled in touch devices because mobile devices do not
* support drag events in general.
*
* @return true
if file dropping is allowed, false
* otherwise.
*/
public boolean isDropAllowed() {
return !getElement().getProperty("nodrop", false);
}
/**
* Specify the types of files that the Upload web-component accepts. Syntax:
* a MIME type pattern (wildcards are allowed) or file extensions. Notice
* that MIME types are widely supported, while file extensions are only
* implemented in certain browsers, so it should be avoided.
*
* Example: "video/*","image/tiff"
or
* ".pdf","audio/mp3"
*
* File format restrictions are checked only on the client side (browser).
* They indicate the hints for users as to what file types to upload. Using
* this method won’t restrict the uploaded file’s format on the server side.
* If required, it’s the responsibility of the application developer to
* implement application-specific restrictions on the server side in one or
* more of the Upload component’s event listeners (e.g., in
* {@link #addSucceededListener}).
*
* @param acceptedFileTypes
* the allowed file types to be uploaded, or null
to
* clear any restrictions
*/
public void setAcceptedFileTypes(String... acceptedFileTypes) {
String accepted = "";
if (acceptedFileTypes != null) {
accepted = String.join(",", acceptedFileTypes);
}
getElement().setProperty("accept", accepted);
}
/**
* Get the list of accepted file types for upload.
*
* @return a list of allowed file types, never null
.
*/
public List getAcceptedFileTypes() {
String accepted = getElement().getProperty("accept");
if (accepted == null) {
return Collections.emptyList();
}
return Arrays.asList(accepted.split(","));
}
/**
* Set the component as the actionable button inside the upload component,
* that opens the dialog for choosing the files to be upload.
*
* @param button
* the component to be clicked by the user to open the dialog, or
* null
to reset to the default button
*/
public void setUploadButton(Component button) {
if (button != null) {
uploadButton = button;
} else {
uploadButton = defaultUploadButton;
}
SlotUtils.setSlot(this, "add-button", uploadButton);
}
/**
* Get the component set as the upload button for the upload.
*
* @return the actionable button, never null
.
*/
public Component getUploadButton() {
return uploadButton;
}
/**
* Set the component to show as a message to the user to drop files in the
* upload component. Despite of the name, the label can be any component.
*
* @param label
* the label to show for the users when it's possible drop files,
* or null
to reset to the default label
*/
public void setDropLabel(Component label) {
if (label != null) {
dropLabel = label;
} else {
dropLabel = defaultDropLabel;
}
SlotUtils.setSlot(this, "drop-label", dropLabel);
}
/**
* Get the component set as the drop label.
*
* @return the drop label component, never null
.
*/
public Component getDropLabel() {
return dropLabel;
}
/**
* Set the component to show as the drop label icon. The icon is visible
* when the user can drop files to this upload component. Despite of the
* name, the drop label icon can be any component.
*
* @param icon
* the label icon to show for the users when it's possible to
* drop files, or null
to reset to the default icon
*/
public void setDropLabelIcon(Component icon) {
if (icon != null) {
dropLabelIcon = icon;
} else {
dropLabelIcon = defaultDropLabelIcon;
}
SlotUtils.setSlot(this, "drop-label-icon", dropLabelIcon);
}
/**
* Get the component set as the drop label icon.
*
* @return the drop label icon component, never null
.
*/
public Component getDropLabelIcon() {
return dropLabelIcon;
}
/**
* Go into upload state. This is to prevent uploading more files than
* accepted on same component.
*/
private void startUpload() {
if (getMaxFiles() != 0 && getMaxFiles() <= activeUploads) {
throw new IllegalStateException(
"Maximum supported amount of uploads already started");
}
activeUploads++;
}
/**
* Interrupt the upload currently being received.
*
* The interruption will be done by the receiving thread so this method will
* return immediately and the actual interrupt will happen a bit later.
*
* Note! this will interrupt all uploads in multi-upload mode.
*/
public void interruptUpload() {
if (isUploading()) {
interrupted = true;
}
}
private void endUpload() {
activeUploads--;
interrupted = false;
}
/**
* Is upload in progress.
*
* @return true if receiving upload
*/
public boolean isUploading() {
return activeUploads > 0;
}
private void fireStarted(String filename, String mimeType,
long contentLength) {
fireEvent(new StartedEvent(this, filename, mimeType, contentLength));
}
private void fireUploadInterrupted(String filename, String mimeType,
long length) {
fireEvent(new FailedEvent(this, filename, mimeType, length));
}
private void fireNoInputStream(String filename, String mimeType,
long length) {
fireEvent(new NoInputStreamEvent(this, filename, mimeType, length));
}
private void fireNoOutputStream(String filename, String mimeType,
long length) {
fireEvent(new NoOutputStreamEvent(this, filename, mimeType, length));
}
private void fireUploadInterrupted(String filename, String mimeType,
long length, Exception e) {
fireEvent(new FailedEvent(this, filename, mimeType, length, e));
}
private void fireUploadSuccess(String filename, String mimeType,
long length) {
fireEvent(new SucceededEvent(this, filename, mimeType, length));
}
private void fireUploadFinish(String filename, String mimeType,
long length) {
fireEvent(new FinishedEvent(this, filename, mimeType, length));
}
private void fireAllFinish() {
fireEvent(new AllFinishedEvent(this));
}
/**
* Emit the progress event.
*
* @param totalBytes
* bytes received so far
* @param contentLength
* actual size of the file being uploaded, if known
*
* @deprecated since 24.4. Use
* {@link #fireUpdateProgress(long, long, String)}
*/
@Deprecated(since = "24.4")
protected void fireUpdateProgress(long totalBytes, long contentLength) {
fireEvent(
new ProgressUpdateEvent(this, totalBytes, contentLength, null));
}
/**
* Emit the progress event.
*
* @param totalBytes
* bytes received so far
* @param contentLength
* actual size of the file being uploaded, if known
* @param fileName
* name of the file being uploaded
*/
protected void fireUpdateProgress(long totalBytes, long contentLength,
String fileName) {
fireEvent(new ProgressUpdateEvent(this, totalBytes, contentLength,
fileName));
}
/**
* Add a progress listener that is informed on upload progress.
*
* @param listener
* progress listener to add
* @return registration for removal of listener
*/
public Registration addProgressListener(
ComponentEventListener listener) {
return addListener(ProgressUpdateEvent.class, listener);
}
/**
* Add a succeeded listener that is informed on upload failure.
*
* @param listener
* failed listener to add
* @return registration for removal of listener
*/
public Registration addFailedListener(
ComponentEventListener listener) {
return addListener(FailedEvent.class, listener);
}
/**
* Add a succeeded listener that is informed on upload finished.
*
* @param listener
* finished listener to add
* @return registration for removal of listener
*/
public Registration addFinishedListener(
ComponentEventListener listener) {
return addListener(FinishedEvent.class, listener);
}
/**
* Add a succeeded listener that is informed on upload start.
*
* @param listener
* start listener to add
* @return registration for removal of listener
*/
public Registration addStartedListener(
ComponentEventListener listener) {
return addListener(StartedEvent.class, listener);
}
/**
* Add a succeeded listener that is informed on upload succeeded.
*
* @param listener
* succeeded listener to add
* @return registration for removal of listener
*/
public Registration addSucceededListener(
ComponentEventListener listener) {
return addListener(SucceededEvent.class, listener);
}
/**
* Adds a listener for {@code file-reject} events fired when a file cannot
* be added due to some constrains:
* {@code setMaxFileSize, setMaxFiles, setAcceptedFileTypes}
*
* @param listener
* the listener
* @return a {@link Registration} for removing the event listener
*/
public Registration addFileRejectedListener(
ComponentEventListener listener) {
return addListener(FileRejectedEvent.class, listener);
}
/**
* Adds a listener for events fired when a file is removed.
*
* @param listener
* the listener
* @return a {@link Registration} for removing the event listener
*/
public Registration addFileRemovedListener(
ComponentEventListener listener) {
return addListener(FileRemovedEvent.class, listener);
}
/**
* Return the current receiver.
*
* @return the StreamVariable.
*/
public Receiver getReceiver() {
return receiver;
}
/**
* Set the receiver implementation that should be used for this upload
* component.
*
* Note! If the receiver doesn't implement {@link MultiFileReceiver} then
* the upload will be automatically set to only accept one file.
*
* @param receiver
* receiver to use for file reception
*/
public void setReceiver(Receiver receiver) {
Receiver oldReceiver = this.receiver;
this.receiver = receiver;
if (isMultiFileReceiver(receiver)) {
if (oldReceiver != null && !isMultiFileReceiver(oldReceiver)) {
removeMaxFiles();
}
} else {
setMaxFiles(1);
}
}
private boolean isMultiFileReceiver(Receiver receiver) {
return receiver instanceof MultiFileReceiver;
}
/**
* Set the internationalization properties for this component.
*
* @param i18n
* the internationalized properties, not null
*/
public void setI18n(UploadI18N i18n) {
Objects.requireNonNull(i18n,
"The I18N properties object should not be null");
this.i18n = i18n;
runBeforeClientResponse(ui -> {
if (i18n == this.i18n) {
setI18nWithJS();
}
});
}
private void setI18nWithJS() {
JsonObject i18nJson = (JsonObject) JsonSerializer.toJson(this.i18n);
// Remove null values so that we don't overwrite existing WC
// translations with empty ones
deeplyRemoveNullValuesFromJsonObject(i18nJson);
// Assign new I18N object to WC, by deeply merging the existing
// WC I18N, and the values from the new UploadI18N instance,
// into an empty object
getElement().executeJs(
"const dropFiles = Object.assign({}, this.i18n.dropFiles, $0.dropFiles);"
+ "const addFiles = Object.assign({}, this.i18n.addFiles, $0.addFiles);"
+ "const error = Object.assign({}, this.i18n.error, $0.error);"
+ "const uploadingStatus = Object.assign({}, this.i18n.uploading.status, $0.uploading && $0.uploading.status);"
+ "const uploadingRemainingTime = Object.assign({}, this.i18n.uploading.remainingTime, $0.uploading && $0.uploading.remainingTime);"
+ "const uploadingError = Object.assign({}, this.i18n.uploading.error, $0.uploading && $0.uploading.error);"
+ "const uploading = {status: uploadingStatus,"
+ " remainingTime: uploadingRemainingTime,"
+ " error: uploadingError};"
+ "const units = $0.units || this.i18n.units;"
+ "this.i18n = Object.assign({}, this.i18n, $0, {"
+ " addFiles: addFiles, dropFiles: dropFiles,"
+ " uploading: uploading, units: units});",
i18nJson);
}
@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
// Element state is not persisted across attach/detach
if (this.i18n != null) {
setI18nWithJS();
}
}
private void deeplyRemoveNullValuesFromJsonObject(JsonObject jsonObject) {
for (String key : jsonObject.keys()) {
if (jsonObject.get(key).getType() == JsonType.OBJECT) {
deeplyRemoveNullValuesFromJsonObject(jsonObject.get(key));
} else if (jsonObject.get(key).getType() == JsonType.NULL) {
jsonObject.remove(key);
}
}
}
void runBeforeClientResponse(SerializableConsumer command) {
getElement().getNode().runWhenAttached(ui -> ui
.beforeClientResponse(this, context -> command.accept(ui)));
}
/**
* Get the internationalization object previously set for this component.
*
* Note: updating the object content that is gotten from this method will
* not update the language on the component if not set back using
* {@link Upload#setI18n(UploadI18N)}
*
* @return the object with the i18n properties. If the i18n properties
* weren't set, the object will return null
.
*/
public UploadI18N getI18n() {
return i18n;
}
private String getStringObject(String propertyName, String subName) {
String result = null;
JsonObject json = (JsonObject) getElement()
.getPropertyRaw(propertyName);
if (json != null && json.hasKey(subName)
&& !(json.get(subName) instanceof JsonNull)) {
result = json.getString(subName);
}
return result;
}
private String getStringObject(String propertyName, String object,
String subName) {
String result = null;
JsonObject json = (JsonObject) getElement()
.getPropertyRaw(propertyName);
if (json != null && json.hasKey(object)
&& !(json.get(object) instanceof JsonNull)) {
json = json.getObject(object);
if (json != null && json.hasKey(subName)
&& !(json.get(subName) instanceof JsonNull)) {
result = json.getString(subName);
}
}
return result;
}
/**
* Clear the list of files being processed, or already uploaded.
*/
public void clearFileList() {
getElement().setPropertyJson("files", Json.createArray());
}
private static class DefaultStreamVariable implements StreamVariable {
private Deque lastStartedEvent = new ArrayDeque<>();
private final Upload upload;
public DefaultStreamVariable(Upload upload) {
this.upload = upload;
}
@Override
public boolean listenProgress() {
return upload.getEventBus().hasListener(ProgressUpdateEvent.class);
}
@Override
public void onProgress(StreamVariable.StreamingProgressEvent event) {
upload.fireUpdateProgress(event.getBytesReceived(),
event.getContentLength(), event.getFileName());
}
@Override
public boolean isInterrupted() {
return upload.interrupted;
}
@Override
public OutputStream getOutputStream() {
if (upload.getReceiver() == null) {
throw new IllegalStateException(
"Upload cannot be performed without a receiver set. "
+ "Please firstly set the receiver implementation with upload.setReceiver");
}
StreamVariable.StreamingStartEvent event = lastStartedEvent.pop();
OutputStream receiveUpload = upload.getReceiver()
.receiveUpload(event.getFileName(), event.getMimeType());
return receiveUpload;
}
@Override
public void streamingStarted(StreamVariable.StreamingStartEvent event) {
upload.startUpload();
try {
upload.fireStarted(event.getFileName(), event.getMimeType(),
event.getContentLength());
} finally {
lastStartedEvent.addLast(event);
}
}
@Override
public void streamingFinished(StreamVariable.StreamingEndEvent event) {
try {
upload.fireUploadSuccess(event.getFileName(),
event.getMimeType(), event.getContentLength());
} finally {
upload.endUpload();
upload.fireUploadFinish(event.getFileName(),
event.getMimeType(), event.getContentLength());
}
}
@Override
public void streamingFailed(StreamVariable.StreamingErrorEvent event) {
try {
Exception exception = event.getException();
if (exception instanceof NoInputStreamException) {
upload.fireNoInputStream(event.getFileName(),
event.getMimeType(), 0);
} else if (exception instanceof NoOutputStreamException) {
upload.fireNoOutputStream(event.getFileName(),
event.getMimeType(), 0);
} else {
upload.fireUploadInterrupted(event.getFileName(),
event.getMimeType(), event.getBytesReceived(),
exception);
}
} finally {
upload.endUpload();
upload.fireUploadFinish(event.getFileName(),
event.getMimeType(), event.getContentLength());
}
}
}
}