papegaaij commented on code in PR #1671:
URL: https://github.com/apache/wicket/pull/1671#discussion_r4315320352


##########
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:
   **[P2] Prevent an obsolete upload instance from clearing the replacement 
field's selection**
   
   With `FileListBehavior`, select `a.txt` and start uploading, Ajax-rerender 
the upload field while the resource request is pending, then select `b.txt` in 
the replacement field. When the original request succeeds, its XHR still 
invokes `uploaded()` on the old instance. This removes `a.txt` from the old 
instance's selection, and `updateInput()` looks up the current input by ID and 
assigns that now-empty selection to it. `ended()` also redraws the current list 
using the obsolete state.
   
   As a result, `b.txt` disappears from the input and list. Although the 
replacement instance still holds `b.txt` internally, the next upload reads the 
emptied input and sends no files. The previous completion handler did not 
overwrite the replacement input, so this is introduced by the new success 
cleanup.
   
   Please give instances an explicit replacement/disposal lifecycle, or verify 
that an instance still owns the current input before changing either the input 
or its lists, while preserving completion reporting. A regression case should 
replace and reinitialize the input during an upload, select another file, and 
assert that the old response does not change the replacement input or list.



##########
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:
   **[P2] Clear the active request after an XHR timeout so uploads can be 
retried**
   
   An application can set `xhr.timeout` in the public before-send callback. 
When that timeout expires, native XHR dispatches `timeout` and `loadend`, not 
`load`, `error`, or `abort`. Only those latter terminal events are handled 
here, so neither `failed()` nor `ended()` runs and `this.xhr` remains set even 
though the request has finished.
   
   This new guard then rejects every subsequent upload attempt. No failure 
callback is reported, and file-removal buttons remain disabled until the field 
is explicitly canceled or recreated. Missing timeout reporting predates the PR, 
but preventing subsequent retries is a regression introduced by this guard. 
This affects applications configuring a timeout, rather than the default 
no-timeout configuration.
   
   Please handle `ontimeout` through the normal failure-and-cleanup path so the 
failure is reported once, selected files are preserved, list controls are 
restored, and another upload can start. A timeout-followed-by-retry regression 
case would cover the terminal event currently missing from the load/error/abort 
coverage.



-- 
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]

Reply via email to