This is an automated email from the ASF dual-hosted git repository. reiern70 pushed a commit to branch upload-to-resource in repository https://gitbox.apache.org/repos/asf/wicket.git
commit 2439b8122591cf106c997d9763582410b28257f6 Author: reiern70 <[email protected]> AuthorDate: Thu Oct 15 11:08:00 2026 -0500 Add a drop zone and a file list to uploads to a resource GitHub issue #1669 FileUploadToResourceField uploaded the files of its input as the browser's dialog left them: each choice replaced the one before, files could not be dropped, and a file could not be taken out again before the upload. Two behaviors add that: - FileDropZoneBehavior makes a component a zone files are dropped on. The input receives the dropped files it accepts by its accept attribute, all of them if it is multiple, else the first; folders are left out. It then fires its input and change events, as for files chosen in the dialog. While files are dragged over the zone, it has the CSS class wicket-upload-dragover. - FileListBehavior, on a <ul> or <ol>, lists the files chosen for the next upload, each with a button removing it, whose text is the resource uploadToResourceRemove. With a list the field collects files: if its input is multiple, files chosen or dropped are added to the listed ones instead of replacing them. The files an upload sent leave the list once it succeeded, while files chosen meanwhile stay; during an upload the remove buttons are disabled. A file name is written as text, never as markup. The script keeps the chosen files and writes them back into the input through a DataTransfer, so the upload, the progress bar and other listeners of the input see all of them. It hears of a choice in the capture phase, before the input's other change listeners run. A field works with the input it was created for, and draws the lists only while that input is in the page. The running upload of a field is kept by the script, not by the field: an Ajax rendering of the field creates a new field, which takes the upload over. It refuses a second upload until the first one ended, cancelUpload() cancels it, and when it ends, the files chosen in the new input stay and the remove buttons are enabled again. Before, the new field knew nothing of the running upload: cancelUpload() did not reach it, and a second upload could run next to it. A field without these behaviors works as before: a choice replaces the files of the input, and the input keeps them after the upload. The script's guard against being evaluated twice tested for an object, but Wicket.FileUploadToResourceField is a function, so a second evaluation replaced it. It tests for a function now, as the registries of file lists and of running uploads live in the script. A test runs the script a second time and checks that the field and its lists survive it. The upload-to-resource example shows a drop zone and a file list. Its styles never applied: they were in a <head> the page ignores, since it extends WicketExamplePage, and inline, which the Content Security Policy of the examples blocks. They are in UploadToResourcePage.css now, under a class name that does not clash with the drop-zone of the examples' style.css. Its guard against leaving the page during an upload used jQuery, which the plain JavaScript engine does not load; it uses Wicket.Event now. Its explanation also says that the limits of the field are checked before anything is sent, and that files of the same name are kept apart, each stored under a name of its own. migration-to-11.md lists the two behaviors among the changes of Wicket 11 that need no migration, and says that a field rendered anew during an upload refuses a second one and that cancelUpload() cancels it. --- migration-to-11.md | 17 +- .../upload/resource/FileDropZoneBehaviorTest.java | 70 ++++ .../form/upload/resource/FileListBehaviorTest.java | 90 ++++++ .../form/upload/resource/FileDropZoneBehavior.java | 84 +++++ .../form/upload/resource/FileListBehavior.java | 107 +++++++ .../upload/resource/FileUploadToResourceField.java | 5 +- .../upload/resource/FileUploadToResourceField.js | 355 +++++++++++++++++++-- .../resource/FileUploadToResourceField.properties | 1 + wicket-core/src/test/js/all.html | 3 + wicket-core/src/test/js/upload-to-resource.js | 320 ++++++++++++++++++- .../examples/upload/UploadToResourcePage.css | 22 ++ .../examples/upload/UploadToResourcePage.html | 18 +- .../examples/upload/UploadToResourcePage.java | 22 +- .../upload/UploadToResourcePage.properties | 2 +- 14 files changed, 1062 insertions(+), 54 deletions(-) diff --git a/migration-to-11.md b/migration-to-11.md index d80ab57180..78b4bf9e31 100644 --- a/migration-to-11.md +++ b/migration-to-11.md @@ -11,8 +11,9 @@ of the migration. Its comment lists the other Wicket 11 changes that need manual ## Uploads to a resource (`FileUploadToResourceField`) -Pull request [#1671](https://github.com/apache/wicket/pull/1671), issue -[#1670](https://github.com/apache/wicket/issues/1670). All classes are in +Pull request [#1671](https://github.com/apache/wicket/pull/1671), issues +[#1670](https://github.com/apache/wicket/issues/1670) and +[#1669](https://github.com/apache/wicket/issues/1669). All classes are in `org.apache.wicket.markup.html.form.upload.resource`. None of these changes is mechanical, so there is no OpenRewrite recipe for them. @@ -200,6 +201,7 @@ New resource keys, in `FileUploadToResourceField.properties`: |---|---| | `uploadToResourceFailed` | The upload failed. | | `uploadConnectionLost` | The upload failed: the connection to the server was lost. | +| `uploadToResourceRemove` | Remove | ### `FileUploadToResourceField`: browser side @@ -207,7 +209,8 @@ New resource keys, in `FileUploadToResourceField.properties`: |---|---|---| | `getClientBeforeSendCallBack()` default | `function (xhr, settings) { return true; }`. The return value was ignored and `settings` never passed | `function (xhr) { return true; }`. Returning `false` keeps the upload from being sent and calls `onUploadCanceled()` | | `errorMessage` given to `getClientSideUploadErrorCallBack()` | A resource key, an HTTP status text, or an exception message | Only keys: `uploadTooLarge`, `uploadSingleFileTooLarge`, `uploadTooManyFiles`, `wicket.no.files.selected`, `uploadFailed`, `uploadConnectionLost` | -| Upload started while one runs | A second request was sent, and the two broke each other | Refused: `upload()` returns `false` and logs it | +| Upload started while one runs | A second request was sent, and the two broke each other | Refused: `upload()` returns `false` and logs it. This holds as well for a field rendered anew by Ajax during the upload, which takes the upload over | +| `cancelUpload()` after an Ajax rendering of the field during an upload | Did nothing: the upload went on | Cancels the upload | | Files beyond a limit of the field | Sent, then refused by the server, often with a reset connection | Not sent. The error callback gets the key of the limit, `onUploadFailure()` its text | | Telling the field which files arrived | `GET` with the files in the URL. Beyond about 65 files the server answered 414, and `onUploadSuccess()` was never called | `POST` | | `cancel()` after the upload ended | Called the cancel callback and logged "canceled" | Only logs that there is nothing to cancel | @@ -216,3 +219,11 @@ New resource keys, in `FileUploadToResourceField.properties`: To migrate, check a before-send callback that returns `false`: it used to be ignored, and now cancels the upload. Also check an error callback that expects HTTP status texts. + +### New in 11, without migration + +- `FileDropZoneBehavior` makes any component a drop zone for the files of a field. +- `FileListBehavior`, on a `<ul>` or `<ol>`, lists the files chosen for the next upload, each with + a remove button. With it, a `multiple` input collects files over several choices. + +Without these behaviors, the field works as before: a new choice replaces the files of the input. diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehaviorTest.java b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehaviorTest.java new file mode 100644 index 0000000000..1a90e6795d --- /dev/null +++ b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehaviorTest.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.wicket.markup.html.form.upload.resource; + +import java.util.regex.Pattern; +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestField; +import org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestFileUploadResourceReference; +import org.apache.wicket.util.file.File; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTestCase; +import org.junit.jupiter.api.Test; + +class FileDropZoneBehaviorTest extends WicketTestCase +{ + @Test + void makesTheComponentADropZoneOfTheField() + { + DropZonePage page = tester.startPage(new DropZonePage()); + + tester.assertContains(Pattern.quote("Wicket.FileUploadToResourceField.addDropZone(\"" + + page.zone.getMarkupId() + "\", \"" + page.field.getMarkupId() + "\", \"" + + FileDropZoneBehavior.DRAG_OVER_CSS_CLASS + "\");")); + } + + static class DropZonePage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + final TestField field; + + final WebMarkupContainer zone; + + DropZonePage() + { + zone = new WebMarkupContainer("zone"); + add(zone); + field = new TestField("upload", new TestFileUploadResourceReference( + new FolderUploadsFileManager(new File(System.getProperty("java.io.tmpdir"))))); + zone.add(field); + zone.add(new FileDropZoneBehavior(field)); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><body><div wicket:id=\"zone\">" + + "<input type=\"file\" wicket:id=\"upload\" multiple/></div></body></html>"); + } + } +} diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehaviorTest.java b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehaviorTest.java new file mode 100644 index 0000000000..6707725e5e --- /dev/null +++ b/wicket-core-tests/src/test/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehaviorTest.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.wicket.markup.html.form.upload.resource; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.regex.Pattern; +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.MarkupException; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestField; +import org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceFieldTest.TestFileUploadResourceReference; +import org.apache.wicket.util.file.File; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTestCase; +import org.junit.jupiter.api.Test; + +class FileListBehaviorTest extends WicketTestCase +{ + @Test + void listsTheFilesOfTheField() + { + ListPage page = tester.startPage(new ListPage("ul")); + + tester.assertContains(Pattern.quote("Wicket.FileUploadToResourceField.addList(\"" + + page.field.getMarkupId() + "\", \"" + page.list.getMarkupId() + "\", \"Remove\");")); + } + + @Test + void canBeAttachedToAnOrderedList() + { + ListPage page = tester.startPage(new ListPage("ol")); + + tester.assertContains(Pattern.quote("Wicket.FileUploadToResourceField.addList(\"" + + page.field.getMarkupId() + "\", \"" + page.list.getMarkupId() + "\", \"Remove\");")); + } + + @Test + void canOnlyBeAttachedToAList() + { + assertThrows(MarkupException.class, () -> tester.startPage(new ListPage("div"))); + } + + static class ListPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + private final String listTag; + + final TestField field; + + final WebMarkupContainer list; + + ListPage(String listTag) + { + this.listTag = listTag; + field = new TestField("upload", new TestFileUploadResourceReference( + new FolderUploadsFileManager(new File(System.getProperty("java.io.tmpdir"))))); + add(field); + list = new WebMarkupContainer("list"); + list.add(new FileListBehavior(field)); + add(list); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><body><input type=\"file\" wicket:id=\"upload\"/><" + + listTag + " wicket:id=\"list\"></" + listTag + "></body></html>"); + } + } +} diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehavior.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehavior.java new file mode 100644 index 0000000000..686e79a87a --- /dev/null +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileDropZoneBehavior.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.wicket.markup.html.form.upload.resource; + +import org.apache.wicket.Component; +import org.apache.wicket.behavior.Behavior; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.head.JavaScriptHeaderItem; +import org.apache.wicket.markup.head.OnDomReadyHeaderItem; +import org.apache.wicket.resource.CoreLibrariesContributor; +import org.apache.wicket.util.lang.Args; +import com.github.openjson.JSONObject; + +/** + * Makes a component a zone files are dropped on to choose them for the next upload of a + * {@link FileUploadToResourceField}, as if they were chosen in the browser's dialog of its input. + * The input receives the dropped files it accepts by its {@code accept} attribute: all of them if + * it is {@code multiple}, else the first. Folders are left out. The input then fires its + * {@code input} and {@code change} events, as it does for files chosen in the dialog. + * <p> + * With a {@link FileListBehavior}, dropped files are added to those chosen before. While files are + * dragged over the component, it has the CSS class {@value #DRAG_OVER_CSS_CLASS}. + * + * <pre> + * FileUploadToResourceField field = ...; + * add(new WebMarkupContainer("dropZone").add(new FileDropZoneBehavior(field))); + * </pre> + * + * @since 11.0.0 + */ +public class FileDropZoneBehavior extends Behavior +{ + private static final long serialVersionUID = 1L; + + /** + * The CSS class of the component while files are dragged over it. + */ + public static final String DRAG_OVER_CSS_CLASS = "wicket-upload-dragover"; + + private final FileUploadToResourceField field; + + /** + * @param field + * the field the dropped files are chosen for + */ + public FileDropZoneBehavior(FileUploadToResourceField field) + { + this.field = Args.notNull(field, "field"); + } + + @Override + public void bind(Component component) + { + super.bind(component); + component.setOutputMarkupId(true); + } + + @Override + public void renderHead(Component component, IHeaderResponse response) + { + super.renderHead(component, response); + CoreLibrariesContributor.contributeAjax(component.getApplication(), response); + response.render(JavaScriptHeaderItem.forReference(FileUploadToResourceField.JS)); + response.render(OnDomReadyHeaderItem.forScript( + "Wicket.FileUploadToResourceField.addDropZone(" + + JSONObject.quote(component.getMarkupId()) + ", " + + JSONObject.quote(field.getMarkupId()) + ", " + + JSONObject.quote(DRAG_OVER_CSS_CLASS) + ");")); + } +} diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehavior.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehavior.java new file mode 100644 index 0000000000..34139e0f4a --- /dev/null +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileListBehavior.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.wicket.markup.html.form.upload.resource; + +import org.apache.wicket.Component; +import org.apache.wicket.behavior.Behavior; +import org.apache.wicket.markup.ComponentTag; +import org.apache.wicket.markup.MarkupException; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.head.JavaScriptHeaderItem; +import org.apache.wicket.markup.head.OnDomReadyHeaderItem; +import org.apache.wicket.resource.CoreLibrariesContributor; +import org.apache.wicket.util.lang.Args; +import com.github.openjson.JSONObject; + +/** + * Lists the files chosen for the next upload of a {@link FileUploadToResourceField}, each with a + * button removing it. It is attached to a {@code <ul>} or an {@code <ol>}, whose items it replaces + * in the browser with one for each file: + * + * <pre> + * <li class="wicket-upload-file"> + * <span class="wicket-upload-file-name">report.pdf</span> + * <button type="button" class="wicket-upload-file-remove">Remove</button> + * </li> + * </pre> + * + * The name of a file is written as text, never as markup. The text of the buttons is the resource + * {@value #REMOVE_RESOURCE_KEY} of the field. + * <p> + * With a list, the field collects the files of several choices: if its input is + * {@code multiple}, files chosen in the browser's dialog or dropped on a + * {@link FileDropZoneBehavior drop zone} are added to the listed ones instead of replacing them. + * Once an upload succeeded, the files it sent leave the list. While an upload runs, no file can be + * removed. + * + * <pre> + * FileUploadToResourceField field = ...; + * add(new WebMarkupContainer("files").add(new FileListBehavior(field))); + * </pre> + * + * @since 11.0.0 + */ +public class FileListBehavior extends Behavior +{ + private static final long serialVersionUID = 1L; + + /** + * The resource key of the text of the buttons removing a file. + */ + public static final String REMOVE_RESOURCE_KEY = "uploadToResourceRemove"; + + private final FileUploadToResourceField field; + + /** + * @param field + * the field whose files are listed + */ + public FileListBehavior(FileUploadToResourceField field) + { + this.field = Args.notNull(field, "field"); + } + + @Override + public void bind(Component component) + { + super.bind(component); + component.setOutputMarkupId(true); + } + + @Override + public void onComponentTag(Component component, ComponentTag tag) + { + super.onComponentTag(component, tag); + if (!tag.getName().equalsIgnoreCase("ul") && !tag.getName().equalsIgnoreCase("ol")) + { + throw new MarkupException("A FileListBehavior can only be attached to <ul> or <ol>, " + + "not to <" + tag.getName() + "> of " + component); + } + } + + @Override + public void renderHead(Component component, IHeaderResponse response) + { + super.renderHead(component, response); + CoreLibrariesContributor.contributeAjax(component.getApplication(), response); + response.render(JavaScriptHeaderItem.forReference(FileUploadToResourceField.JS)); + response.render(OnDomReadyHeaderItem.forScript("Wicket.FileUploadToResourceField.addList(" + + JSONObject.quote(field.getMarkupId()) + ", " + + JSONObject.quote(component.getMarkupId()) + ", " + + JSONObject.quote(field.getString(REMOVE_RESOURCE_KEY)) + ");")); + } +} diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java index 5ce7eec0cd..ea002d8231 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.java @@ -55,6 +55,9 @@ import com.github.openjson.JSONObject; * Implementation of FileUploadField capable to uploading a file into a wicket mounted resource. * This field does not require a {@link org.apache.wicket.markup.html.form.Form}! * The upload of the file id done via the {@link #startUpload(IPartialPageRequestHandler)} method. + * <p> + * A {@link FileDropZoneBehavior} makes a component a zone the files to upload can be dropped on, + * and a {@link FileListBehavior} lists the files chosen, which then add up over several choices. */ public abstract class FileUploadToResourceField extends FileUploadField { @@ -199,7 +202,7 @@ public abstract class FileUploadToResourceField extends FileUploadField } } - private static final JavaScriptResourceReference JS = new JavaScriptResourceReference(FileUploadToResourceField.class, "FileUploadToResourceField.js"); + static final JavaScriptResourceReference JS = new JavaScriptResourceReference(FileUploadToResourceField.class, "FileUploadToResourceField.js"); public static String UPLOAD_CANCELED = "upload.canceled"; diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js index 4ae7b50823..276fc56dd1 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.js @@ -19,10 +19,25 @@ 'use strict'; - if (typeof(Wicket.FileUploadToResourceField) === 'object') { + if (typeof(Wicket.FileUploadToResourceField) === 'function') { return; } + // the keys of the errors, as the field knows them + const UPLOAD_TOO_LARGE = 'uploadTooLarge'; + const UPLOAD_SINGLE_FILE_TOO_LARGE = 'uploadSingleFileTooLarge'; + const UPLOAD_TOO_MANY_FILES = 'uploadTooManyFiles'; + const UPLOAD_FAILED = 'uploadFailed'; + const CONNECTION_LOST = 'uploadConnectionLost'; + const UPLOAD_CANCELED = 'upload.canceled'; + + // the file lists of the fields, by field id: the text of the remove buttons, by list id + const lists = {}; + + // the running uploads, by field id: the request, the files it sends and the field it belongs + // to, which a new rendering of the field takes over + const uploads = {}; + Wicket.FileUploadToResourceField = function (settings, clientBeforeSendCallBack, clientSideSuccessCallBack, clientSideCancelCallBack, uploadErrorCallBack) { this.settings = settings; @@ -41,15 +56,268 @@ 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); + if (uploads[this.inputName]) { + uploads[this.inputName].field = this; + } + 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(); }; - // the keys of the errors, as the field knows them - const UPLOAD_TOO_LARGE = 'uploadTooLarge'; - const UPLOAD_SINGLE_FILE_TOO_LARGE = 'uploadSingleFileTooLarge'; - const UPLOAD_TOO_MANY_FILES = 'uploadTooManyFiles'; - const UPLOAD_FAILED = 'uploadFailed'; - const CONNECTION_LOST = 'uploadConnectionLost'; - const UPLOAD_CANCELED = 'upload.canceled'; + /** + * 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) + { + this.files = this.hasLists() && this.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 = this.input; + 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); + }); + this.input.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; + }); + }; + + /** + * @returns {boolean} whether the input of the field is still in the page: once a new rendering + * replaced it, its lists belong to the field created for the new input + */ + Wicket.FileUploadToResourceField.prototype.isCurrent = function () + { + return document.getElementById(this.inputName) === this.input; + }; + + Wicket.FileUploadToResourceField.prototype.renderLists = function () + { + if (!this.isCurrent()) { + return; + } + 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.isUploading(); + 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 @@ -60,12 +328,10 @@ */ Wicket.FileUploadToResourceField.prototype.upload = function() { - if (this.xhr) { + if (this.isUploading()) { 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) { @@ -77,47 +343,48 @@ 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(); + const upload = {'xhr': xhr, 'files': this.files.slice(), 'field': this}; xhr.open("POST", this.resourceUrl); xhr.onload = function () { + const field = upload.field; if (xhr.status < 200 || xhr.status >= 300) { - self.failed(UPLOAD_FAILED); + field.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); + Wicket.Log.error("The answer to the upload of field '" + field.inputName + "' is not JSON: " + xhr.responseText); + field.failed(UPLOAD_FAILED); return; } if (res.error) { - self.failed(res.errorMessage); + field.failed(res.errorMessage); } else { - delete self.xhr; - self.clientSideSuccessCallBack(); - self.report({'error': false, 'filesInfo': JSON.stringify(res)}); + field.uploaded(upload.files); + field.clientSideSuccessCallBack(); + field.report({'error': false, 'filesInfo': JSON.stringify(res)}); } }; xhr.onerror = function () { - self.failed(CONNECTION_LOST); + upload.field.failed(CONNECTION_LOST); }; xhr.ontimeout = function () { - self.failed(UPLOAD_FAILED); + upload.field.failed(UPLOAD_FAILED); }; xhr.onabort = function () { // user aborted the upload. - delete self.xhr; - self.report({'error': true, 'errorMessage': UPLOAD_CANCELED}); + upload.field.ended(); + upload.field.report({'error': true, 'errorMessage': UPLOAD_CANCELED}); }; if (this.clientBeforeSendCallBack(xhr) === false) { @@ -126,11 +393,43 @@ return false; } - this.xhr = xhr; + uploads[this.inputName] = upload; + this.renderLists(); xhr.send(formData); return true; }; + /** + * @returns {boolean} whether an upload of the field runs, which it may have taken over from a + * rendering of the field before + */ + Wicket.FileUploadToResourceField.prototype.isUploading = function () + { + return uploads[this.inputName] !== undefined; + }; + + Wicket.FileUploadToResourceField.prototype.ended = function () + { + delete uploads[this.inputName]; + this.renderLists(); + }; + + /** + * Ends an upload that succeeded: the files it sent leave the lists of the field. + * + * @param sent the files the upload sent + */ + Wicket.FileUploadToResourceField.prototype.uploaded = function (sent) + { + if (this.hasLists()) { + this.files = this.files.filter(function (file) { + return sent.indexOf(file) < 0; + }); + this.updateInput(); + } + this.ended(); + }; + /** * @param files the files to upload * @returns {string|null} the key of the first limit of the field the files break, if any @@ -163,7 +462,7 @@ */ Wicket.FileUploadToResourceField.prototype.failed = function (errorMessage) { - delete this.xhr; + this.ended(); const ep = {'error': true, 'errorMessage': errorMessage}; this.uploadErrorCallBack(ep); this.report(ep); @@ -182,10 +481,8 @@ // cancel the upload Wicket.FileUploadToResourceField.prototype.cancel = function () { - // we have a reference to the request we can cancel it. - if (this.xhr) { - this.xhr.abort(); - delete (this.xhr); + if (this.isUploading()) { + uploads[this.inputName].xhr.abort(); this.clientSideCancelCallBack(); Wicket.Log.log("The upload associated with field '" + this.inputName + "' has been canceled!"); } else { diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties index c614ece583..8242a41596 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/form/upload/resource/FileUploadToResourceField.properties @@ -15,3 +15,4 @@ wicket.no.files.selected = Nothing to upload: no files were selected. uploadToResourceFailed = The upload failed. uploadConnectionLost = The upload failed: the connection to the server was lost. +uploadToResourceRemove = Remove diff --git a/wicket-core/src/test/js/all.html b/wicket-core/src/test/js/all.html index 83f4085202..ef2f3ab0e1 100644 --- a/wicket-core/src/test/js/all.html +++ b/wicket-core/src/test/js/all.html @@ -160,6 +160,9 @@ FileUploadToResourceField --> <input type="file" id="uploadToResourceInput" multiple="multiple"/> + <div id="uploadToResourceDropZone"><input type="file" id="uploadToResourceListedInput" multiple="multiple"/></div> + <ul id="uploadToResourceList"></ul> + <input type="file" id="uploadToResourceSingleInput" accept=".txt,image/*"/> <div id="ajaxIndicator"></div> </div> diff --git a/wicket-core/src/test/js/upload-to-resource.js b/wicket-core/src/test/js/upload-to-resource.js index baac2feb0e..4b9b784b05 100644 --- a/wicket-core/src/test/js/upload-to-resource.js +++ b/wicket-core/src/test/js/upload-to-resource.js @@ -15,7 +15,7 @@ * limitations under the License. */ -/*global QUnit: true, DataTransfer: true */ +/*global QUnit: true, DataTransfer: true, DragEvent: true */ ;(function (undefined) { "use strict"; @@ -37,20 +37,35 @@ }; FakeXhr.prototype.send = function (body) { this.body = body; + this.running = true; + }; + FakeXhr.prototype.end = function (handler) { + this.running = false; + this[handler](); }; FakeXhr.prototype.abort = function () { - this.aborted = true; - this.onabort(); + if (this.running) { + this.aborted = true; + this.end('onabort'); + } }; FakeXhr.prototype.respond = function (status, responseText) { this.status = status; this.responseText = responseText; - this.onload(); + this.end('onload'); + }; + FakeXhr.prototype.fail = function () { + this.end('onerror'); }; FakeXhr.prototype.timeOut = function () { - this.ontimeout(); + this.end('ontimeout'); }; + // the field keeps a running upload beyond the test that started it + function abortUploads() { + FakeXhr.instances.forEach(xhr => xhr.abort()); + } + let originalXhr, originalPost, reports, calls; function chooseFiles(files) { @@ -59,6 +74,14 @@ document.getElementById(INPUT_ID).files = dataTransfer.files; } + // replaces an input with an empty copy, as an Ajax rendering of its field does + function rerender(inputId) { + const input = document.getElementById(inputId); + const copy = input.cloneNode(false); + copy.files = new DataTransfer().files; + input.replaceWith(copy); + } + function newFile(name, size) { return new File([new Uint8Array(size)], name, {type: 'text/plain'}); } @@ -91,6 +114,7 @@ }; }, afterEach: function () { + abortUploads(); window.XMLHttpRequest = originalXhr; Wicket.Ajax.post = originalPost; } @@ -144,7 +168,7 @@ chooseFiles([newFile('a.txt', 3)]); newField().upload(); - FakeXhr.instances[0].onerror(); + FakeXhr.instances[0].fail(); assert.deepEqual(calls, ['error:uploadConnectionLost']); assert.deepEqual(reports[0].ep, {'error': true, 'errorMessage': 'uploadConnectionLost'}); @@ -236,5 +260,289 @@ field.upload(); assert.equal(FakeXhr.instances.length, 2, 'the field is not left busy'); }); + + test('a field rendered anew refuses a second upload while the first one runs', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + rerender(INPUT_ID); + const field = newField(); + chooseFiles([newFile('b.txt', 3)]); + + assert.false(field.upload(), 'refused while the first one runs'); + assert.equal(FakeXhr.instances.length, 1); + FakeXhr.instances[0].respond(200, '[]'); + assert.true(field.upload(), 'sent once the first one ended'); + }); + + test('a field rendered anew cancels the running upload', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + rerender(INPUT_ID); + newField({ajaxCallBackUrl: 'newCallback'}).cancel(); + + assert.true(FakeXhr.instances[0].aborted); + assert.deepEqual(calls, ['cancel']); + assert.deepEqual(reports.map(report => [report.u, report.ep.errorMessage]), + [['newCallback', 'upload.canceled']]); + }); + + test('a field rendered anew reports the end of the running upload', assert => { + chooseFiles([newFile('a.txt', 3)]); + newField().upload(); + + rerender(INPUT_ID); + newField({ajaxCallBackUrl: 'newCallback'}); + FakeXhr.instances[0].respond(200, '[]'); + + assert.deepEqual(calls, ['success']); + assert.deepEqual(reports.map(report => [report.u, report.ep.error]), [['newCallback', false]]); + }); + + const LISTED_ID = 'uploadToResourceListedInput'; + const SINGLE_ID = 'uploadToResourceSingleInput'; + const LIST_ID = 'uploadToResourceList'; + const ZONE_ID = 'uploadToResourceDropZone'; + + function newFieldOf(inputId) { + const field = newField({inputName: inputId}); + Wicket.Timer[inputId] = field; + return field; + } + + function pick(inputId, files) { + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + const input = document.getElementById(inputId); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', {bubbles: true})); + } + + function drop(files) { + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + document.getElementById(ZONE_ID).dispatchEvent(new DragEvent('drop', + {dataTransfer: dataTransfer, bubbles: true, cancelable: true})); + } + + function listed() { + return Array.from(document.querySelectorAll('#' + LIST_ID + ' .wicket-upload-file-name')) + .map(name => name.textContent); + } + + function inputFiles(inputId) { + return Array.from(document.getElementById(inputId).files).map(file => file.name); + } + + function removeButtons() { + return Array.from(document.querySelectorAll('#' + LIST_ID + ' .wicket-upload-file-remove')); + } + + module('Wicket.FileUploadToResourceField file lists and drop zones', { + beforeEach: function () { + originalXhr = window.XMLHttpRequest; + originalPost = Wicket.Ajax.post; + FakeXhr.instances = []; + window.XMLHttpRequest = FakeXhr; + reports = []; + calls = []; + Wicket.Ajax.post = function (attrs) { + reports.push(attrs); + }; + }, + afterEach: function () { + abortUploads(); + window.XMLHttpRequest = originalXhr; + Wicket.Ajax.post = originalPost; + delete Wicket.Timer[LISTED_ID]; + delete Wicket.Timer[SINGLE_ID]; + } + }); + + test('without a list, a choice replaces the files chosen before', assert => { + newFieldOf(LISTED_ID); + + pick(LISTED_ID, [newFile('a.txt', 1)]); + pick(LISTED_ID, [newFile('b.txt', 1)]); + + assert.deepEqual(inputFiles(LISTED_ID), ['b.txt']); + }); + + test('a list shows the files of several choices, which add up', assert => { + newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + + pick(LISTED_ID, [newFile('a.txt', 1)]); + pick(LISTED_ID, [newFile('b.txt', 1), newFile('c.txt', 1)]); + + assert.deepEqual(listed(), ['a.txt', 'b.txt', 'c.txt']); + assert.deepEqual(inputFiles(LISTED_ID), ['a.txt', 'b.txt', 'c.txt']); + }); + + test('a list added after the field shows the files chosen before', assert => { + newFieldOf(LISTED_ID); + pick(LISTED_ID, [newFile('a.txt', 1)]); + + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + + assert.deepEqual(listed(), ['a.txt']); + }); + + test('the name of a file is listed as text', assert => { + newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + + pick(LISTED_ID, [newFile('<b>bold</b>.txt', 1)]); + + assert.deepEqual(listed(), ['<b>bold</b>.txt']); + assert.equal(document.querySelectorAll('#' + LIST_ID + ' b').length, 0); + }); + + test('a button removes its file', assert => { + newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + pick(LISTED_ID, [newFile('a.txt', 1), newFile('b.txt', 1)]); + + const button = removeButtons()[1]; + assert.equal(button.textContent, 'Remove'); + assert.equal(button.getAttribute('aria-labelledby'), + button.id + ' ' + document.querySelectorAll('#' + LIST_ID + ' .wicket-upload-file-name')[1].id); + button.click(); + + assert.deepEqual(listed(), ['a.txt']); + assert.deepEqual(inputFiles(LISTED_ID), ['a.txt']); + }); + + test('the files sent leave the list once uploaded, those chosen meanwhile stay', assert => { + const field = newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + pick(LISTED_ID, [newFile('a.txt', 1)]); + + field.upload(); + assert.true(removeButtons().every(button => button.disabled), 'no removing while uploading'); + pick(LISTED_ID, [newFile('b.txt', 1)]); + FakeXhr.instances[0].respond(200, '[]'); + + assert.deepEqual(listed(), ['b.txt']); + assert.deepEqual(inputFiles(LISTED_ID), ['b.txt']); + assert.true(removeButtons().every(button => !button.disabled), 'removing once the upload ended'); + }); + + test('a failed upload keeps its files in the list', assert => { + const field = newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + pick(LISTED_ID, [newFile('a.txt', 1)]); + + field.upload(); + FakeXhr.instances[0].respond(500, 'Internal Server Error'); + + assert.deepEqual(listed(), ['a.txt']); + }); + + test('a timed-out upload keeps its files in the list, and they can be removed again', assert => { + const field = newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + pick(LISTED_ID, [newFile('a.txt', 1)]); + field.upload(); + + FakeXhr.instances[0].timeOut(); + + assert.deepEqual(listed(), ['a.txt']); + assert.true(removeButtons().every(button => !button.disabled)); + }); + + test('a field rendered anew during an upload keeps its own files when the upload ends', assert => { + const field = newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + pick(LISTED_ID, [newFile('a.txt', 1)]); + field.upload(); + + rerender(LISTED_ID); + newFieldOf(LISTED_ID); + pick(LISTED_ID, [newFile('b.txt', 1)]); + assert.true(removeButtons().every(button => button.disabled), 'no removing while the upload runs'); + FakeXhr.instances[0].respond(200, '[]'); + + assert.deepEqual(inputFiles(LISTED_ID), ['b.txt']); + assert.deepEqual(listed(), ['b.txt']); + assert.true(removeButtons().every(button => !button.disabled), 'removing once it ended'); + assert.deepEqual(calls, ['success'], 'the upload is reported'); + assert.deepEqual(reports.map(report => report.ep.error), [false]); + }); + + test('an upload ending after its field was removed still reports', assert => { + const field = newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + pick(LISTED_ID, [newFile('a.txt', 1)]); + field.upload(); + + document.getElementById(LISTED_ID).remove(); + FakeXhr.instances[0].respond(200, '[]'); + + assert.deepEqual(calls, ['success']); + assert.deepEqual(reports.map(report => report.ep.error), [false]); + }); + + test('dropped files are chosen, and the input fires its events', assert => { + newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + Wicket.FileUploadToResourceField.addDropZone(ZONE_ID, LISTED_ID, 'dragging'); + pick(LISTED_ID, [newFile('a.txt', 1)]); + const events = []; + const input = document.getElementById(LISTED_ID); + input.addEventListener('input', () => events.push('input:' + input.files.length)); + input.addEventListener('change', () => events.push('change:' + input.files.length)); + + drop([newFile('b.txt', 1), newFile('c.txt', 1)]); + + assert.deepEqual(listed(), ['a.txt', 'b.txt', 'c.txt']); + assert.deepEqual(events, ['input:3', 'change:3']); + }); + + test('a drop zone chooses only the files the input accepts, one for a single input', assert => { + newFieldOf(SINGLE_ID); + Wicket.FileUploadToResourceField.addDropZone(ZONE_ID, SINGLE_ID, 'dragging'); + + drop([new File(['x'], 'a.pdf', {type: 'application/pdf'}), + new File(['x'], 'b.png', {type: 'image/png'}), + new File(['x'], 'c.txt', {type: 'text/plain'})]); + + assert.deepEqual(inputFiles(SINGLE_ID), ['b.png']); + }); + + test('a drop zone is marked while files are dragged over it', assert => { + newFieldOf(LISTED_ID); + Wicket.FileUploadToResourceField.addDropZone(ZONE_ID, LISTED_ID, 'dragging'); + const zone = document.getElementById(ZONE_ID); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(newFile('a.txt', 1)); + + zone.dispatchEvent(new DragEvent('dragenter', {dataTransfer: dataTransfer, bubbles: true, cancelable: true})); + assert.true(zone.classList.contains('dragging'), 'marked'); + zone.dispatchEvent(new DragEvent('dragleave', {dataTransfer: dataTransfer, bubbles: true, cancelable: true})); + assert.false(zone.classList.contains('dragging'), 'unmarked'); + }); + + test('a second run of the script keeps the field and its lists', assert => { + const done = assert.async(); + const constructor = Wicket.FileUploadToResourceField; + Wicket.FileUploadToResourceField.addList(LISTED_ID, LIST_ID, 'Remove'); + const script = document.createElement('script'); + script.src = document.querySelector('script[src$="FileUploadToResourceField.js"]').src; + script.onload = function () { + assert.strictEqual(Wicket.FileUploadToResourceField, constructor, 'not replaced'); + + newFieldOf(LISTED_ID); + pick(LISTED_ID, [newFile('a.txt', 1)]); + pick(LISTED_ID, [newFile('b.txt', 1)]); + + assert.deepEqual(inputFiles(LISTED_ID), ['a.txt', 'b.txt'], 'the files add up'); + assert.deepEqual(listed(), ['a.txt', 'b.txt'], 'the list shows them'); + script.remove(); + done(); + }; + document.head.appendChild(script); + }); }); })(); diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.css b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.css new file mode 100644 index 0000000000..e6ee56bf73 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.css @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +legend { border: 1px solid #e9601a; background-color: #bbb; color: #fff; padding: 4px; } +fieldset { border: 1px solid #e9601a; padding: 10px; margin-top: 10px; } +.upload-drop-zone { border: 2px dashed #bbb; padding: 10px; margin-bottom: 10px; } +.upload-drop-zone.wicket-upload-dragover { border-color: #e9601a; background-color: #fdf0e8; } +.file-list:empty { display: none; } +.wicket-upload-file-remove { margin-left: 0.5em; } diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html index bcccdf6a5b..b3536c16b5 100644 --- a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.html @@ -1,20 +1,18 @@ <html xmlns:wicket="http://wicket.apache.org"> -<head> - <style> - legend { border: 1px solid #e9601a; background-color: #bbb; color: #fff; padding: 4px;} - fieldset { border: 1px solid #e9601a; padding: 10px; margin-top: 10px;} - </style> -</head> <body> <wicket:extend> <div> <fieldset> <legend>Upload a file(s) to a <em>mounted resource</em></legend> - <p> - <label wicket:for="fileInput">File</label> - <input wicket:id="fileInput" type="file" multiple="multiple"/> - </p> + <div wicket:id="dropZone" class="upload-drop-zone"> + <p>Drop files here, or choose them, as often as you like:</p> + <p> + <label wicket:for="fileInput">Files</label> + <input wicket:id="fileInput" type="file" multiple="multiple"/> + </p> + <ul wicket:id="fileList" class="file-list"></ul> + </div> <form wicket:id="form"> <p> <input wicket:id="allowToLeavePageWhileUploading" type="checkbox"/> diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java index f929b17401..fc5f4af59e 100644 --- a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.java @@ -26,17 +26,21 @@ import org.apache.wicket.core.request.handler.IPartialPageRequestHandler; import org.apache.wicket.event.IEvent; import org.apache.wicket.examples.WicketExamplePage; import org.apache.wicket.extensions.ajax.markup.html.form.upload.UploadProgressBar; +import org.apache.wicket.markup.head.CssHeaderItem; import org.apache.wicket.markup.head.IHeaderResponse; import org.apache.wicket.markup.head.OnDomReadyHeaderItem; import org.apache.wicket.markup.head.OnEventHeaderItem; import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.form.Form; -import org.apache.wicket.markup.html.form.upload.resource.IUploadsFileManager; +import org.apache.wicket.markup.html.form.upload.resource.FileDropZoneBehavior; +import org.apache.wicket.markup.html.form.upload.resource.FileListBehavior; import org.apache.wicket.markup.html.form.upload.resource.FileUploadToResourceField; +import org.apache.wicket.markup.html.form.upload.resource.IUploadsFileManager; import org.apache.wicket.markup.html.panel.FeedbackPanel; import org.apache.wicket.model.IModel; import org.apache.wicket.model.Model; import org.apache.wicket.request.mapper.parameter.PageParameters; +import org.apache.wicket.request.resource.CssResourceReference; import org.apache.wicket.util.lang.Bytes; /** @@ -156,7 +160,13 @@ public class UploadToResourcePage extends WicketExamplePage fileUploadToResourceField.setMaxSize(Bytes.kilobytes(100)); fileUploadToResourceField.setFileMaxSize(Bytes.kilobytes(90)); fileUploadToResourceField.setFileCountMax(5L); - add(fileUploadToResourceField); + + WebMarkupContainer dropZone = new WebMarkupContainer("dropZone"); + dropZone.add(new FileDropZoneBehavior(fileUploadToResourceField)); + add(dropZone); + dropZone.add(fileUploadToResourceField); + dropZone.add(new WebMarkupContainer("fileList") + .add(new FileListBehavior(fileUploadToResourceField))); Form<Void> form = new Form<>("form"); add(form); @@ -254,8 +264,12 @@ public class UploadToResourcePage extends WicketExamplePage @Override public void renderHead(IHeaderResponse response) { super.renderHead(response); - response.render( - OnDomReadyHeaderItem.forScript("$(window).bind('beforeunload', function(){ if (Wicket.CurrentUpload != null) { return 'leave?'; }});")); + response.render(CssHeaderItem.forReference( + new CssResourceReference(UploadToResourcePage.class, "UploadToResourcePage.css"))); + response.render(OnDomReadyHeaderItem.forScript( + "Wicket.Event.add(window, 'beforeunload', function (event) {" + + " if (Wicket.CurrentUpload != null) { event.preventDefault(); event.returnValue = 'leave?'; }" + + "});")); } @Override diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties index 25e21b65ea..6d1d7b0388 100644 --- a/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/upload/UploadToResourcePage.properties @@ -13,5 +13,5 @@ # See the License for the specific language governing permissions and # limitations under the License. title=Multiple (AJAX) Upload to a Resource -explanation=<p>Wicket can upload to a <em>mounted resource.</em></p> <p>This upload is done via jQuery AJAX and <em>does not</em> block Wicket AJAX</p> <p><em>Upload progressbar</em> has been updated to work with this component</p> +explanation=<p>Wicket can upload to a <em>mounted resource.</em></p> <p>This upload is done with a plain XMLHttpRequest and <em>does not</em> block Wicket AJAX</p> <p>Files chosen several times or dropped on the zone add up in a list, from which each can be removed before the upload</p> <p>The limits of the field, 100 KB in all, 90 KB a file and 5 files, are checked before anything is sent: files beyond them are refused without being uploaded</p> <p>Files of the same name are kept apart: [...] order=3
