reiern70 commented on code in PR #1671:
URL: https://github.com/apache/wicket/pull/1671#discussion_r4316895806
##########
wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js:
##########
@@ -41,79 +52,413 @@
this.clientSideSuccessCallBack = clientSideSuccessCallBack;
this.clientSideCancelCallBack = clientSideCancelCallBack;
this.uploadErrorCallBack = uploadErrorCallBack;
+ // the files chosen for the next upload, which the input holds as well
+ this.files = Array.from(this.input.files);
+ const self = this;
+ // in the capture phase, so the other listeners of the input see all
the chosen files
+ this.input.addEventListener('change', function () {
+ if (!self.dispatching) {
+ self.choose(Array.from(self.input.files));
+ }
+ }, true);
+ this.renderLists();
+ };
+
+ /**
+ * Makes a file list of a field show the files chosen for its next upload.
With a list, the
+ * files chosen are added to those chosen before if the input is multiple,
and leave the list
+ * once they are uploaded.
+ *
+ * @param fieldId the id of the field
+ * @param listId the id of the list, a ul or an ol
+ * @param removeText the text of the buttons removing a file
+ */
+ Wicket.FileUploadToResourceField.addList = function (fieldId, listId,
removeText)
+ {
+ lists[fieldId] = lists[fieldId] || {};
+ lists[fieldId][listId] = removeText;
+ const field = Wicket.Timer[fieldId];
+ if (field) {
+ field.renderLists();
+ }
};
+ /**
+ * Makes an element a zone files are dropped on to choose them for the
upload of a field.
+ *
+ * @param zoneId the id of the element
+ * @param fieldId the id of the field
+ * @param dragOverClass the CSS class of the element while files are
dragged over it
+ */
+ Wicket.FileUploadToResourceField.addDropZone = function (zoneId, fieldId,
dragOverClass)
+ {
+ const zone = document.getElementById(zoneId);
+ if (!zone || zone.wicketUploadDropZone) {
+ return;
+ }
+ zone.wicketUploadDropZone = true;
+ // dragenter and dragleave fire for every element within the zone the
files pass over
+ let depth = 0;
+ const draggingFiles = function (event) {
+ return event.dataTransfer &&
Array.prototype.indexOf.call(event.dataTransfer.types, 'Files') >= 0;
+ };
+ const leave = function () {
+ depth = 0;
+ zone.classList.remove(dragOverClass);
+ };
+ zone.addEventListener('dragenter', function (event) {
+ if (draggingFiles(event)) {
+ event.preventDefault();
+ depth++;
+ zone.classList.add(dragOverClass);
+ }
+ });
+ zone.addEventListener('dragover', function (event) {
+ if (draggingFiles(event)) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ });
+ zone.addEventListener('dragleave', function (event) {
+ if (draggingFiles(event) && --depth <= 0) {
+ leave();
+ }
+ });
+ zone.addEventListener('drop', function (event) {
+ if (draggingFiles(event)) {
+ event.preventDefault();
+ leave();
+ const field = Wicket.Timer[fieldId];
+ if (field) {
+ field.drop(event.dataTransfer);
+ }
+ }
+ });
+ };
+
+ /**
+ * @param dataTransfer the data of a drop
+ * @returns {File[]} the files dropped, without folders
+ */
+ function droppedFiles(dataTransfer)
+ {
+ if (!dataTransfer.items) {
+ return Array.from(dataTransfer.files);
+ }
+ const files = [];
+ for (let index = 0; index < dataTransfer.items.length; index++) {
+ const item = dataTransfer.items[index];
+ const entry = item.kind === 'file' && item.webkitGetAsEntry ?
item.webkitGetAsEntry() : null;
+ const file = item.kind === 'file' && !(entry && entry.isDirectory)
? item.getAsFile() : null;
+ if (file) {
+ files.push(file);
+ }
+ }
+ return files;
+ }
+
+ /**
+ * @param accept the accept attribute of an input
+ * @param file a file
+ * @returns {boolean} whether the input accepts the file, by its extension
or its type
+ */
+ function accepts(accept, file)
+ {
+ if (!accept) {
+ return true;
+ }
+ const name = file.name.toLowerCase();
+ const type = (file.type || '').toLowerCase();
+ return accept.split(',').some(function (token) {
+ token = token.trim().toLowerCase();
+ if (token.charAt(0) === '.') {
+ return name.endsWith(token);
+ }
+ if (token.endsWith('/*')) {
+ return type.startsWith(token.slice(0, -1));
+ }
+ return token !== '' && type === token;
+ });
+ }
+
+ /**
+ * Chooses files for the next upload: in addition to those chosen before
if the field lists its
+ * files and its input is multiple, else instead of them.
+ *
+ * @param files the files
+ */
+ Wicket.FileUploadToResourceField.prototype.choose = function (files)
+ {
+ const input = document.getElementById(this.inputName);
+ this.files = this.hasLists() && input.multiple ?
this.files.concat(files) : files;
+ this.updateInput();
+ this.renderLists();
+ };
+
+ /**
+ * Chooses the files dropped on a drop zone of the field as if they were
chosen in the
+ * browser's dialog, and fires the input and change events of the input:
those the input
+ * accepts, and only the first of them unless the input is multiple.
+ *
+ * @param dataTransfer the data of the drop
+ */
+ Wicket.FileUploadToResourceField.prototype.drop = function (dataTransfer)
+ {
+ const input = document.getElementById(this.inputName);
+ let files = droppedFiles(dataTransfer).filter(function (file) {
+ return accepts(input.accept, file);
+ });
+ if (!input.multiple) {
+ files = files.slice(0, 1);
+ }
+ if (files.length === 0) {
+ return;
+ }
+ this.choose(files);
+ this.dispatching = true;
+ try {
+ input.dispatchEvent(new Event('input', {'bubbles': true}));
+ input.dispatchEvent(new Event('change', {'bubbles': true}));
+ } finally {
+ this.dispatching = false;
+ }
+ };
+
+ /**
+ * Takes a file out of those chosen for the next upload.
+ *
+ * @param file the file
+ */
+ Wicket.FileUploadToResourceField.prototype.remove = function (file)
+ {
+ this.files = this.files.filter(function (chosen) {
+ return chosen !== file;
+ });
+ this.updateInput();
+ this.renderLists();
+ };
+
+ Wicket.FileUploadToResourceField.prototype.updateInput = function ()
+ {
+ const dataTransfer = new window.DataTransfer();
+ this.files.forEach(function (file) {
+ dataTransfer.items.add(file);
+ });
+ document.getElementById(this.inputName).files = dataTransfer.files;
+ };
+
+ /**
+ * @returns {boolean} whether a file list of the field is in the page
+ */
+ Wicket.FileUploadToResourceField.prototype.hasLists = function ()
+ {
+ return Object.keys(lists[this.inputName] || {}).some(function (listId)
{
+ return document.getElementById(listId) !== null;
+ });
+ };
+
+ Wicket.FileUploadToResourceField.prototype.renderLists = function ()
+ {
+ const fieldLists = lists[this.inputName] || {};
+ Object.keys(fieldLists).forEach(function (listId) {
+ const list = document.getElementById(listId);
+ if (list) {
+ this.renderList(list, fieldLists[listId]);
+ }
+ }, this);
+ };
+
+ /**
+ * Renders an item for each chosen file into a list: its name, as text,
and a button removing
+ * it, which is disabled while an upload runs.
+ *
+ * @param list the ul or ol
+ * @param removeText the text of the buttons
+ */
+ Wicket.FileUploadToResourceField.prototype.renderList = function (list,
removeText)
+ {
+ const self = this;
+ list.replaceChildren();
+ this.files.forEach(function (file, index) {
+ const name = document.createElement('span');
+ name.className = 'wicket-upload-file-name';
+ name.id = list.id + '-' + index + '-name';
+ name.textContent = file.name;
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'wicket-upload-file-remove';
+ remove.id = list.id + '-' + index + '-remove';
+ remove.textContent = removeText;
+ remove.setAttribute('aria-labelledby', remove.id + ' ' + name.id);
+ remove.disabled = !!self.xhr;
+ remove.addEventListener('click', function () {
+ self.remove(file);
+ });
+ const item = document.createElement('li');
+ item.className = 'wicket-upload-file';
+ item.append(name, ' ', remove);
+ list.appendChild(item);
+ });
+ };
+
+ /**
+ * Uploads the files of the input, unless an upload of the field is
running already, or the
+ * files break a limit of the field: the server is told of those as of an
upload it refused,
+ * without the files being sent.
+ *
+ * @returns {boolean} whether the upload was sent
+ */
Wicket.FileUploadToResourceField.prototype.upload = function()
{
+ if (this.xhr) {
+ Wicket.Log.log("An upload of field '" + this.inputName + "' is
running: it has to end before another one starts.");
+ return false;
+ }
// get a fresh reference to input
this.input = document.getElementById(this.inputName);
+ const files = this.input.files;
+ const exceededLimit = this.exceededLimit(files);
+ if (exceededLimit) {
+ this.failed(exceededLimit);
+ return false;
+ }
// we add the files to a FormData object.
const formData = new FormData();
- const totalfiles = this.input.files.length;
- for (let index = 0; index < totalfiles; index++) {
- formData.append("WICKET-FILE-UPLOAD",this.input.files[index]);
+ for (let index = 0; index < files.length; index++) {
+ formData.append("WICKET-FILE-UPLOAD", files[index]);
}
const self = this;
// we post the files to the resource (this.resourceUrl) with a plain
XMLHttpRequest
// and keep a reference to it in order to be able to cancel the upload
const xhr = new XMLHttpRequest();
- this.xhr = xhr;
xhr.open("POST", this.resourceUrl);
xhr.onload = function () {
- let res, ep;
- if (xhr.status >= 200 && xhr.status < 300) {
- try {
- res = xhr.responseText ? JSON.parse(xhr.responseText) : {};
- } catch (parseError) {
- // this error will only happen is generated JSON at server
side is faulty
- Wicket.Log.log(xhr.responseText);
- return;
- }
- // do clean up on success
- if (res.error) {
- self.uploadErrorCallBack(res);
- Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": res});
- } else {
- self.clientSideSuccessCallBack();
- ep = {'error': false, 'filesInfo': JSON.stringify(res)};
- Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep});
- }
+ if (xhr.status < 200 || xhr.status >= 300) {
+ self.failed(UPLOAD_FAILED);
+ return;
+ }
+ let res;
+ try {
+ res = xhr.responseText ? JSON.parse(xhr.responseText) : {};
+ } catch (parseError) {
+ Wicket.Log.error("The answer to the upload of field '" +
self.inputName + "' is not JSON: " + xhr.responseText);
+ self.failed(UPLOAD_FAILED);
+ return;
+ }
+ if (res.error) {
+ self.failed(res.errorMessage);
} else {
- ep = {'error': true, "errorMessage": xhr.statusText};
- self.uploadErrorCallBack(ep);
- Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep});
+ self.uploaded();
+ self.clientSideSuccessCallBack();
+ self.report({'error': false, 'filesInfo':
JSON.stringify(res)});
}
};
xhr.onerror = function () {
- const ep = {'error': true, "errorMessage": xhr.statusText};
- self.uploadErrorCallBack(ep);
- Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep});
+ self.failed(CONNECTION_LOST);
};
xhr.onabort = function () {
// user aborted the upload.
- const ep = {'error': true, 'errorMessage': 'upload.canceled'};
- Wicket.Ajax.get({"u": self.ajaxCallBackUrl, "ep": ep});
+ self.ended();
+ self.report({'error': true, 'errorMessage': UPLOAD_CANCELED});
};
- self.clientBeforeSendCallBack(xhr);
+ if (this.clientBeforeSendCallBack(xhr) === false) {
+ Wicket.Log.log("The upload of field '" + this.inputName + "' was
not sent: the before-send callback returned false.");
+ this.report({'error': true, 'errorMessage': UPLOAD_CANCELED});
+ return false;
+ }
+ this.xhr = xhr;
+ this.sending = this.files.slice();
+ this.renderLists();
xhr.send(formData);
+ return true;
+ };
+
+ Wicket.FileUploadToResourceField.prototype.ended = function ()
+ {
+ delete this.xhr;
+ this.renderLists();
+ };
+
+ /**
+ * Ends an upload that succeeded: the files sent leave the lists of the
field.
+ */
+ Wicket.FileUploadToResourceField.prototype.uploaded = function ()
+ {
+ if (this.hasLists()) {
+ const sent = this.sending;
+ this.files = this.files.filter(function (file) {
+ return sent.indexOf(file) < 0;
+ });
+ this.updateInput();
Review Comment:
Fixed in 2439b81225, along the lines of the replacement lifecycle you
suggested. The running upload no longer lives on the instance but in a registry
of the script, by field id, and a field created for the same id takes it over.
The late response is handled by the field that is current then: the files it
sent leave that field's selection, which never held them, so `b.txt` stays in
the input and the list. A field also works only with the input it was created
for, and draws the lists only while that input is in the page.
The hand-over also closes two related gaps: after a re-render, the new field
refused nothing while the old upload ran, so a second upload could start next
to it, and `cancelUpload()` could not reach it. A removed field whose list
stayed threw on `null.files` before reporting; it reports now.
Tests: "a field rendered anew during an upload keeps its own files when the
upload ends" (your case: re-render and re-init during an upload, choose another
file, check the old response leaves input and list alone), "a field rendered
anew refuses a second upload while the first one runs", "a field rendered anew
cancels the running upload", "a field rendered anew reports the end of the
running upload" and "an upload ending after its field was removed still
reports".
##########
wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js:
##########
@@ -41,79 +52,413 @@
this.clientSideSuccessCallBack = clientSideSuccessCallBack;
this.clientSideCancelCallBack = clientSideCancelCallBack;
this.uploadErrorCallBack = uploadErrorCallBack;
+ // the files chosen for the next upload, which the input holds as well
+ this.files = Array.from(this.input.files);
+ const self = this;
+ // in the capture phase, so the other listeners of the input see all
the chosen files
+ this.input.addEventListener('change', function () {
+ if (!self.dispatching) {
+ self.choose(Array.from(self.input.files));
+ }
+ }, true);
+ this.renderLists();
+ };
+
+ /**
+ * Makes a file list of a field show the files chosen for its next upload.
With a list, the
+ * files chosen are added to those chosen before if the input is multiple,
and leave the list
+ * once they are uploaded.
+ *
+ * @param fieldId the id of the field
+ * @param listId the id of the list, a ul or an ol
+ * @param removeText the text of the buttons removing a file
+ */
+ Wicket.FileUploadToResourceField.addList = function (fieldId, listId,
removeText)
+ {
+ lists[fieldId] = lists[fieldId] || {};
+ lists[fieldId][listId] = removeText;
+ const field = Wicket.Timer[fieldId];
+ if (field) {
+ field.renderLists();
+ }
};
+ /**
+ * Makes an element a zone files are dropped on to choose them for the
upload of a field.
+ *
+ * @param zoneId the id of the element
+ * @param fieldId the id of the field
+ * @param dragOverClass the CSS class of the element while files are
dragged over it
+ */
+ Wicket.FileUploadToResourceField.addDropZone = function (zoneId, fieldId,
dragOverClass)
+ {
+ const zone = document.getElementById(zoneId);
+ if (!zone || zone.wicketUploadDropZone) {
+ return;
+ }
+ zone.wicketUploadDropZone = true;
+ // dragenter and dragleave fire for every element within the zone the
files pass over
+ let depth = 0;
+ const draggingFiles = function (event) {
+ return event.dataTransfer &&
Array.prototype.indexOf.call(event.dataTransfer.types, 'Files') >= 0;
+ };
+ const leave = function () {
+ depth = 0;
+ zone.classList.remove(dragOverClass);
+ };
+ zone.addEventListener('dragenter', function (event) {
+ if (draggingFiles(event)) {
+ event.preventDefault();
+ depth++;
+ zone.classList.add(dragOverClass);
+ }
+ });
+ zone.addEventListener('dragover', function (event) {
+ if (draggingFiles(event)) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'copy';
+ }
+ });
+ zone.addEventListener('dragleave', function (event) {
+ if (draggingFiles(event) && --depth <= 0) {
+ leave();
+ }
+ });
+ zone.addEventListener('drop', function (event) {
+ if (draggingFiles(event)) {
+ event.preventDefault();
+ leave();
+ const field = Wicket.Timer[fieldId];
+ if (field) {
+ field.drop(event.dataTransfer);
+ }
+ }
+ });
+ };
+
+ /**
+ * @param dataTransfer the data of a drop
+ * @returns {File[]} the files dropped, without folders
+ */
+ function droppedFiles(dataTransfer)
+ {
+ if (!dataTransfer.items) {
+ return Array.from(dataTransfer.files);
+ }
+ const files = [];
+ for (let index = 0; index < dataTransfer.items.length; index++) {
+ const item = dataTransfer.items[index];
+ const entry = item.kind === 'file' && item.webkitGetAsEntry ?
item.webkitGetAsEntry() : null;
+ const file = item.kind === 'file' && !(entry && entry.isDirectory)
? item.getAsFile() : null;
+ if (file) {
+ files.push(file);
+ }
+ }
+ return files;
+ }
+
+ /**
+ * @param accept the accept attribute of an input
+ * @param file a file
+ * @returns {boolean} whether the input accepts the file, by its extension
or its type
+ */
+ function accepts(accept, file)
+ {
+ if (!accept) {
+ return true;
+ }
+ const name = file.name.toLowerCase();
+ const type = (file.type || '').toLowerCase();
+ return accept.split(',').some(function (token) {
+ token = token.trim().toLowerCase();
+ if (token.charAt(0) === '.') {
+ return name.endsWith(token);
+ }
+ if (token.endsWith('/*')) {
+ return type.startsWith(token.slice(0, -1));
+ }
+ return token !== '' && type === token;
+ });
+ }
+
+ /**
+ * Chooses files for the next upload: in addition to those chosen before
if the field lists its
+ * files and its input is multiple, else instead of them.
+ *
+ * @param files the files
+ */
+ Wicket.FileUploadToResourceField.prototype.choose = function (files)
+ {
+ const input = document.getElementById(this.inputName);
+ this.files = this.hasLists() && input.multiple ?
this.files.concat(files) : files;
+ this.updateInput();
+ this.renderLists();
+ };
+
+ /**
+ * Chooses the files dropped on a drop zone of the field as if they were
chosen in the
+ * browser's dialog, and fires the input and change events of the input:
those the input
+ * accepts, and only the first of them unless the input is multiple.
+ *
+ * @param dataTransfer the data of the drop
+ */
+ Wicket.FileUploadToResourceField.prototype.drop = function (dataTransfer)
+ {
+ const input = document.getElementById(this.inputName);
+ let files = droppedFiles(dataTransfer).filter(function (file) {
+ return accepts(input.accept, file);
+ });
+ if (!input.multiple) {
+ files = files.slice(0, 1);
+ }
+ if (files.length === 0) {
+ return;
+ }
+ this.choose(files);
+ this.dispatching = true;
+ try {
+ input.dispatchEvent(new Event('input', {'bubbles': true}));
+ input.dispatchEvent(new Event('change', {'bubbles': true}));
+ } finally {
+ this.dispatching = false;
+ }
+ };
+
+ /**
+ * Takes a file out of those chosen for the next upload.
+ *
+ * @param file the file
+ */
+ Wicket.FileUploadToResourceField.prototype.remove = function (file)
+ {
+ this.files = this.files.filter(function (chosen) {
+ return chosen !== file;
+ });
+ this.updateInput();
+ this.renderLists();
+ };
+
+ Wicket.FileUploadToResourceField.prototype.updateInput = function ()
+ {
+ const dataTransfer = new window.DataTransfer();
+ this.files.forEach(function (file) {
+ dataTransfer.items.add(file);
+ });
+ document.getElementById(this.inputName).files = dataTransfer.files;
+ };
+
+ /**
+ * @returns {boolean} whether a file list of the field is in the page
+ */
+ Wicket.FileUploadToResourceField.prototype.hasLists = function ()
+ {
+ return Object.keys(lists[this.inputName] || {}).some(function (listId)
{
+ return document.getElementById(listId) !== null;
+ });
+ };
+
+ Wicket.FileUploadToResourceField.prototype.renderLists = function ()
+ {
+ const fieldLists = lists[this.inputName] || {};
+ Object.keys(fieldLists).forEach(function (listId) {
+ const list = document.getElementById(listId);
+ if (list) {
+ this.renderList(list, fieldLists[listId]);
+ }
+ }, this);
+ };
+
+ /**
+ * Renders an item for each chosen file into a list: its name, as text,
and a button removing
+ * it, which is disabled while an upload runs.
+ *
+ * @param list the ul or ol
+ * @param removeText the text of the buttons
+ */
+ Wicket.FileUploadToResourceField.prototype.renderList = function (list,
removeText)
+ {
+ const self = this;
+ list.replaceChildren();
+ this.files.forEach(function (file, index) {
+ const name = document.createElement('span');
+ name.className = 'wicket-upload-file-name';
+ name.id = list.id + '-' + index + '-name';
+ name.textContent = file.name;
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'wicket-upload-file-remove';
+ remove.id = list.id + '-' + index + '-remove';
+ remove.textContent = removeText;
+ remove.setAttribute('aria-labelledby', remove.id + ' ' + name.id);
+ remove.disabled = !!self.xhr;
+ remove.addEventListener('click', function () {
+ self.remove(file);
+ });
+ const item = document.createElement('li');
+ item.className = 'wicket-upload-file';
+ item.append(name, ' ', remove);
+ list.appendChild(item);
+ });
+ };
+
+ /**
+ * Uploads the files of the input, unless an upload of the field is
running already, or the
+ * files break a limit of the field: the server is told of those as of an
upload it refused,
+ * without the files being sent.
+ *
+ * @returns {boolean} whether the upload was sent
+ */
Wicket.FileUploadToResourceField.prototype.upload = function()
{
+ if (this.xhr) {
+ Wicket.Log.log("An upload of field '" + this.inputName + "' is
running: it has to end before another one starts.");
+ return false;
+ }
Review Comment:
Fixed in 0aa724c3b2: `ontimeout` goes through `failed(UPLOAD_FAILED)`, the
same path as an HTTP error, so the failure is reported once, the files stay
chosen, the remove buttons are enabled again, and the next upload is sent.
Tests: "reports a timed-out upload as a failed upload, and another one can
start" (0aa724c3b2) and, with the lists, "a timed-out upload keeps its files in
the list, and they can be removed again" (2439b81225).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]