This is an automated email from the ASF dual-hosted git repository. reiern70 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/wicket.git
commit cdb40317e90fe1163a628f7475ee6dbe060e3e52 Author: reiern70 <[email protected]> AuthorDate: Thu Oct 1 15:16:23 2026 -0500 Add Ajax veil behaviors that block the page or a component during a request Wicket had no built-in way to stop the user from interacting with a page while an Ajax request was running. An activity indicator shows that something is happening, but a second click on a slow button still sent the request again, and a click elsewhere could act on markup the pending response was about to replace. AjaxDisableComponentListener covers only the component that fired the request, so applications wrote their own veil on top of the global Ajax topics. See GitHub issue #1631. wicket-extensions now has a package org.apache.wicket.extensions.ajax.veil with two behaviors sharing the base class AbstractVeilBehavior: - PageVeilBehavior, added to a page, veils the whole page during every Ajax request fired from it. Adding it to anything but a Page throws IllegalArgumentException. - LocalVeilBehavior, added to any component, veils only that component, and only during requests fired from it or from a component nested in it; such a request does not veil the page. Local veils nest: the innermost one takes the request, each with its own timings. The veil goes up on /ajax/call/beforeSend and comes down on /ajax/call/done, so it also lifts on failure. It is transparent and swallows clicks, so a fast request does not make the page flash. After 300 ms it gets the class wicket-veil-busy, which dims the region and shows a CSS spinner that then stays for at least 500 ms so a response arriving just after it does not make it flicker. Both timings are settable per behavior. Concurrent requests share one veil, and the keyboard is not intercepted. A request opts out with PageVeilBehavior.noVeil(attributes), which adds the extra parameter wicket_nb; the page veil and every local veil leave it alone. Background requests (timers, lazy loading panels) are veiled unless they opt out. A component updated by a WebSocket push is recomputed with no Ajax request the browser could notice, so the server raises its veil: LocalVeilBehavior.getVeilMessage() is a text message to send through the page's WebSocket connection when the work starts, unveil(handler) lowers the veil with the pushed update, and getUnveilMessage() lowers it when the work fails. wicket-extensions does not depend on the WebSocket module; the client listens on /websocket/message. wicket-veil.js subscribes to the global Ajax topics, so it works with both the jQuery-based and the plain JavaScript Ajax engine, and nothing changes in wicket-core. This is new API only: nothing is changed or removed, and an application that does not add one of the behaviors sees no difference, so no migration is needed. Also adds the ajax/veil and websockets/veil examples, a user guide section in the Ajax chapter, Java tests for the behaviors, QUnit tests for Wicket.Veil run against both engines, and Selenium tests driving ajax/veil in headless Chrome. The Selenium tests need Chrome and run only with -Dwicket.selenium=true; JAVASCRIPTTESTING.md says how. --- JAVASCRIPTTESTING.md | 22 ++ pom.xml | 7 + testing/wicket-js-tests/Gruntfile.js | 12 +- wicket-examples/pom.xml | 4 + .../examples/ajax/builtin/AjaxApplication.java | 1 + .../wicket/examples/ajax/builtin/VeilPage.css | 30 ++ .../wicket/examples/ajax/builtin/VeilPage.html | 48 +++ .../wicket/examples/ajax/builtin/VeilPage.java | 140 ++++++++ .../examples/ajax/builtin/VeilPage.properties | 17 + .../examples/websocket/JSR356Application.java | 1 + .../examples/websocket/WebSocketVeilDemoPage.css | 27 ++ .../examples/websocket/WebSocketVeilDemoPage.html | 18 + .../examples/websocket/WebSocketVeilDemoPage.java | 250 +++++++++++++ .../websocket/WebSocketVeilDemoPage.properties | 17 + .../ajax/builtin/VeilPageSeleniumTest.java | 398 +++++++++++++++++++++ wicket-extensions/pom.xml | 1 + wicket-extensions/src/main/java/module-info.java | 2 + .../extensions/ajax/veil/AbstractVeilBehavior.java | 150 ++++++++ .../extensions/ajax/veil/LocalVeilBehavior.java | 157 ++++++++ .../extensions/ajax/veil/PageVeilBehavior.java | 86 +++++ .../wicket/extensions/ajax/veil/wicket-veil.css | 61 ++++ .../wicket/extensions/ajax/veil/wicket-veil.js | 324 +++++++++++++++++ .../extensions/ajax/veil/VeilBehaviorTest.java | 249 +++++++++++++ wicket-extensions/src/test/js/veil-test.js | 388 ++++++++++++++++++++ wicket-extensions/src/test/js/veil.html | 65 ++++ .../src/main/asciidoc/ajax/ajax_12.adoc | 104 ++++++ wicket-user-guide/src/main/asciidoc/single.adoc | 4 + 27 files changed, 2579 insertions(+), 4 deletions(-) diff --git a/JAVASCRIPTTESTING.md b/JAVASCRIPTTESTING.md index cf0fac9b82..19edaa1801 100644 --- a/JAVASCRIPTTESTING.md +++ b/JAVASCRIPTTESTING.md @@ -88,8 +88,30 @@ engine/jQuery version combination by hand in a real browser. suite (`jshint:testsJs` / `qunit:all` / `qunit:vanilla` targets in `testing/wicket-js-tests/Gruntfile.js`), covering both Ajax engines plus `Wicket.DOM`, `Wicket.Event`, `Wicket.Form`, `Wicket.Head`, and the channel manager. +- `wicket-extensions/src/test/js/*-test.js` - QUnit tests for `wicket-extensions`' own scripts + (`Wicket.Palette`, `Wicket.trapFocus`, `Wicket.Veil`), each with its own page next to it + (`palette.html`, `trap-focus.html`, `veil.html`), served on `http://localhost:38888` and run + against both engines. - `jshint:core` / `jshint:extensions` / `jshint:nativeWebSocket` - lint-only coverage (ES6+, no test runtime) for the rest of the framework's JS: `wicket-extensions` Ajax components (autocomplete, palette, upload progress bar, ajax download, trap focus), the dev debug bar, and native WebSocket support. - `jshint:gymTestsJs` - the `wicket-examples` JS tests. + +## Browser tests with Selenium + +The QUnit tests exercise the scripts in isolation, with the Ajax topics published by hand. +Behaviour that only shows in a real page - a veil that swallows clicks, timings measured +against real requests - is tested with Selenium in `wicket-examples`, against the examples +webapp started in Jetty by `JettyTestCaseDecorator`. `VeilPageSeleniumTest` drives +`ajax/veil` in headless Chrome, once per Ajax engine. + +They need Chrome, so they do not run by default. Enable them with a system property: + +```bash +mvn install -DskipTests -Pfast +mvn verify -pl wicket-examples -Dwicket.selenium=true -Dtest=VeilPageSeleniumTest +``` + +Selenium Manager, which ships with Selenium, finds a matching ChromeDriver, and downloads +Chrome for Testing too when no Chrome is installed, so the first run needs network access. diff --git a/pom.xml b/pom.xml index 9f62a1ef79..10ff50ba8b 100644 --- a/pom.xml +++ b/pom.xml @@ -195,6 +195,7 @@ <mockito.version>5.24.0</mockito.version> <objenesis.version>3.6</objenesis.version> <openjson.version>1.0.13</openjson.version> + <selenium.version>4.35.0</selenium.version> <slf4j.version>2.0.20</slf4j.version> <spring.version>7.0.9</spring.version> <wagon-ssh-external.version>3.5.3</wagon-ssh-external.version> @@ -726,6 +727,12 @@ <version>${mockito.version}</version> <scope>test</scope> </dependency> + <dependency> + <groupId>org.seleniumhq.selenium</groupId> + <artifactId>selenium-java</artifactId> + <version>${selenium.version}</version> + <scope>test</scope> + </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> diff --git a/testing/wicket-js-tests/Gruntfile.js b/testing/wicket-js-tests/Gruntfile.js index 1a6893b9da..848722e083 100644 --- a/testing/wicket-js-tests/Gruntfile.js +++ b/testing/wicket-js-tests/Gruntfile.js @@ -33,7 +33,8 @@ module.exports = function(grunt) { "../../wicket-extensions/src/main/java/org/apache/wicket/extensions/markup/html/form/palette/palette.js", "../../wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/markup/html/autocomplete/wicket-autocomplete.js", "../../wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/markup/html/modal/res/modal.js", - "../../wicket-extensions/src/main/java/org/apache/wicket/extensions/markup/html/repeater/data/table/filter/wicket-filterform.js" + "../../wicket-extensions/src/main/java/org/apache/wicket/extensions/markup/html/repeater/data/table/filter/wicket-filterform.js", + "../../wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js" ], nativeWebSocketJs = [ "../../wicket-native-websocket/wicket-native-websocket-core/src/main/java/org/apache/wicket/protocol/ws/api/res/js/wicket-websocket-jquery.js" @@ -49,7 +50,8 @@ module.exports = function(grunt) { ], extensionsTestsJs = [ "../../wicket-extensions/src/test/js/palette-test.js", - "../../wicket-extensions/src/test/js/trapfocus-test.js" + "../../wicket-extensions/src/test/js/trapfocus-test.js", + "../../wicket-extensions/src/test/js/veil-test.js" ], gymTestsJs = [ "../../wicket-examples/src/main/webapp/js-test/tests/ajax/form.js", @@ -115,7 +117,8 @@ module.exports = function(grunt) { urls: [ 'http://localhost:38887/test/js/all.html?4.0.0', 'http://localhost:38888/wicket-extensions/src/test/js/palette.html?4.0.0', - 'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?4.0.0' + 'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?4.0.0', + 'http://localhost:38888/wicket-extensions/src/test/js/veil.html?4.0.0' ], puppeteer: { headless: true, @@ -133,7 +136,8 @@ module.exports = function(grunt) { urls: [ 'http://localhost:38887/test/js/all.html?vanilla', 'http://localhost:38888/wicket-extensions/src/test/js/palette.html?vanilla', - 'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?vanilla' + 'http://localhost:38888/wicket-extensions/src/test/js/trap-focus.html?vanilla', + 'http://localhost:38888/wicket-extensions/src/test/js/veil.html?vanilla' ], puppeteer: { headless: true, diff --git a/wicket-examples/pom.xml b/wicket-examples/pom.xml index 4463cdb7ff..cfc9bcf466 100644 --- a/wicket-examples/pom.xml +++ b/wicket-examples/pom.xml @@ -185,6 +185,10 @@ <groupId>org.httpunit</groupId> <artifactId>httpunit</artifactId> </dependency> + <dependency> + <groupId>org.seleniumhq.selenium</groupId> + <artifactId>selenium-java</artifactId> + </dependency> </dependencies> <build> <resources> diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java index 68e670ae15..075d3c1c65 100644 --- a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/AjaxApplication.java @@ -68,6 +68,7 @@ public class AjaxApplication extends WicketExampleApplication mountPage("todo-list", TodoList.class); mountPage("world-clock", WorldClockPage.class); mountPage("upload", FileUploadPage.class); + mountPage("veil", VeilPage.class); mountPage("download", AjaxDownloadPage.class); mountResource("dynamic-text-file", AjaxDownloadPage.DynamicTextFileResource.instance); diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.css b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.css new file mode 100644 index 0000000000..54f8963082 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.css @@ -0,0 +1,30 @@ +/* + * 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. + */ + +.veil-panel { + padding: 1em; + border: 1px solid #ccc; +} + +.veil-outer { + max-width: 40em; +} + +.veil-inner { + margin-top: 1em; + background: #f7f7f7; +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.html b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.html new file mode 100644 index 0000000000..0509383d71 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.html @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<wicket:extend xmlns:wicket="http://wicket.apache.org"> + +<h2>Page veil</h2> + +<p> + Page clicks: <span wicket:id="pageCounter" class="page-counter"></span> +</p> + +<ul> + <li><a href="#" wicket:id="slow">Slow request</a>: takes a second, so the spinner shows.</li> + <li><a href="#" wicket:id="medium">Medium request</a>: takes 400 ms, so the spinner shows, and stays for its minimum time of 500 ms.</li> + <li><a href="#" wicket:id="fast">Fast request</a>: takes 100 ms, the page is veiled but no spinner shows.</li> + <li><a href="#" wicket:id="unveiled">Unveiled request</a>: takes a second, but opts out of the veil, so the page stays usable.</li> +</ul> + +<h2>Nested local veils</h2> + +<div wicket:id="outer" class="veil-panel veil-outer"> + <p> + This panel has its own veil with the default timings: the spinner shows after 300 ms and + stays for at least 500 ms. Requests fired from inside it veil only the panel, so the rest + of the page stays usable. + </p> + <p> + Outer panel clicks: <span wicket:id="outerCounter" class="outer-counter"></span> + </p> + <p> + <a href="#" wicket:id="outerSlow">Slow request from the outer panel</a>: takes two seconds and veils the whole outer panel, inner panel included. + </p> + + <div wicket:id="inner" class="veil-panel veil-inner"> + <p> + This nested panel has a veil of its own, configured with + <code>setSpinnerDelay(100 ms)</code> and <code>setMinimumSpinnerTime(1 s)</code>. + Requests fired from inside it veil only this panel: the innermost veil takes the request. + </p> + <p> + Inner panel clicks: <span wicket:id="innerCounter" class="inner-counter"></span> + </p> + <ul> + <li><a href="#" wicket:id="innerSlow">Slow request from the inner panel</a>: takes two seconds.</li> + <li><a href="#" wicket:id="innerShort">Short request from the inner panel</a>: takes 200 ms, but the spinner shows after 100 ms and stays for a second.</li> + </ul> + </div> +</div> + +</wicket:extend> diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.java b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.java new file mode 100644 index 0000000000..1d1d279d71 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.java @@ -0,0 +1,140 @@ +/* + * 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.examples.ajax.builtin; + +import java.time.Duration; + +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; +import org.apache.wicket.ajax.markup.html.AjaxLink; +import org.apache.wicket.extensions.ajax.veil.LocalVeilBehavior; +import org.apache.wicket.extensions.ajax.veil.PageVeilBehavior; +import org.apache.wicket.markup.head.CssHeaderItem; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.request.resource.CssResourceReference; + +/** + * Demonstrates {@link PageVeilBehavior} and {@link LocalVeilBehavior}, including nested local + * veils with their own timings. + */ +public class VeilPage extends BasePage +{ + private static final long serialVersionUID = 1L; + + /** + * Constructor. + */ + public VeilPage() + { + add(new PageVeilBehavior()); + + Label pageCounter = counter(this, "pageCounter"); + add(new SleepingLink("slow", Duration.ofSeconds(1), pageCounter, false)); + add(new SleepingLink("medium", Duration.ofMillis(400), pageCounter, false)); + add(new SleepingLink("fast", Duration.ofMillis(100), pageCounter, false)); + add(new SleepingLink("unveiled", Duration.ofSeconds(1), pageCounter, true)); + + WebMarkupContainer outer = new WebMarkupContainer("outer"); + outer.add(new LocalVeilBehavior()); + add(outer); + + Label outerCounter = counter(outer, "outerCounter"); + outer.add(new SleepingLink("outerSlow", Duration.ofSeconds(2), outerCounter, false)); + + WebMarkupContainer inner = new WebMarkupContainer("inner"); + inner.add(new LocalVeilBehavior().setSpinnerDelay(Duration.ofMillis(100)) + .setMinimumSpinnerTime(Duration.ofSeconds(1))); + outer.add(inner); + + Label innerCounter = counter(inner, "innerCounter"); + inner.add(new SleepingLink("innerSlow", Duration.ofSeconds(2), innerCounter, false)); + inner.add(new SleepingLink("innerShort", Duration.ofMillis(200), innerCounter, false)); + } + + @Override + public void renderHead(IHeaderResponse response) + { + super.renderHead(response); + + response.render(CssHeaderItem.forReference(new CssResourceReference(VeilPage.class, + "VeilPage.css"))); + } + + private static Label counter(MarkupContainer parent, String id) + { + Label counter = new Label(id, Model.of(0)); + counter.setOutputMarkupId(true); + parent.add(counter); + return counter; + } + + private static class SleepingLink extends AjaxLink<Void> + { + private static final long serialVersionUID = 1L; + + private final Duration duration; + + private final Label counter; + + private final boolean noVeil; + + SleepingLink(String id, Duration duration, Label counter, boolean noVeil) + { + super(id); + this.duration = duration; + this.counter = counter; + this.noVeil = noVeil; + } + + @Override + protected void updateAjaxAttributes(AjaxRequestAttributes attributes) + { + super.updateAjaxAttributes(attributes); + if (noVeil) + { + PageVeilBehavior.noVeil(attributes); + } + } + + @Override + @SuppressWarnings("unchecked") + public void onClick(AjaxRequestTarget target) + { + sleep(duration); + IModel<Integer> clicks = (IModel<Integer>)counter.getDefaultModel(); + clicks.setObject(clicks.getObject() + 1); + target.add(counter); + } + } + + private static void sleep(Duration duration) + { + try + { + Thread.sleep(duration.toMillis()); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.properties b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.properties new file mode 100644 index 0000000000..51fd11848e --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.properties @@ -0,0 +1,17 @@ +# 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. +title=Veil Example +description=blocks the page, or a part of it, while an Ajax request is running. +explanation=<p> PageVeilBehavior puts a transparent veil over the page as soon as an Ajax request starts, so nothing can be clicked until it is done. A spinner shows only if the request takes longer than 300 ms, and then stays for at least 500 ms. LocalVeilBehavior does the same for a single component, for the requests fired from inside it. Local veils can be nested, the innermost one taking the request, and each can have its own timings. A request opts out with PageVeilBehavior.noVeil(a [...] diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java index 660a91a17c..0b089149e7 100644 --- a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/JSR356Application.java @@ -61,6 +61,7 @@ public class JSR356Application extends WicketExampleApplication mountPage("/push", WebSocketPushUpdateProgressDemoPage.class); mountPage("/resource", WebSocketResourceDemoPage.class); mountPage("/resource-multi-tab", WebSocketMultiTabResourceDemoPage.class); + mountPage("/veil", WebSocketVeilDemoPage.class); getSharedResources().add(ChartWebSocketResource.NAME, new ChartWebSocketResource()); diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.css b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.css new file mode 100644 index 0000000000..05315f90d9 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.css @@ -0,0 +1,27 @@ +/* + * 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. + */ + +.push-counter-panel { + max-width: 30em; + padding: 1em; + border: 1px solid #ccc; +} + +.push-counter { + font-size: 2em; + font-weight: bold; +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html new file mode 100644 index 0000000000..77e6994e59 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org"> +<body> +<wicket:extend> + <p> + <a href="#" wicket:id="start">Start pushes</a> | + <a href="#" wicket:id="stop">Stop</a> + </p> + + <div wicket:id="counterPanel" class="push-counter-panel"> + <p> + Pushed counter: <span wicket:id="counter" class="push-counter"></span> + </p> + <p wicket:id="lastWork" class="push-last-work"></p> + </div> +</wicket:extend> +</body> +</html> diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.java b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.java new file mode 100644 index 0000000000..3c9b001fc0 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.java @@ -0,0 +1,250 @@ +/* + * 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.examples.websocket; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.apache.wicket.Application; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.markup.html.AjaxLink; +import org.apache.wicket.event.IEvent; +import org.apache.wicket.examples.WicketExamplePage; +import org.apache.wicket.extensions.ajax.veil.LocalVeilBehavior; +import org.apache.wicket.markup.head.CssHeaderItem; +import org.apache.wicket.markup.head.IHeaderResponse; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.model.Model; +import org.apache.wicket.protocol.ws.WebSocketSettings; +import org.apache.wicket.protocol.ws.api.IWebSocketConnection; +import org.apache.wicket.protocol.ws.api.WebSocketBehavior; +import org.apache.wicket.protocol.ws.api.event.WebSocketPushPayload; +import org.apache.wicket.protocol.ws.api.message.IWebSocketPushMessage; +import org.apache.wicket.protocol.ws.api.registry.PageIdKey; +import org.apache.wicket.request.resource.CssResourceReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Veils a panel while the server recomputes it, then pushes the result through a WebSocket + * connection, see {@link LocalVeilBehavior#getVeilMessage()}. + */ +public class WebSocketVeilDemoPage extends WicketExamplePage +{ + private static final long serialVersionUID = 1L; + + private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketVeilDemoPage.class); + + private static final int ROUNDS = 10; + + /** The running tasks, by session and page, so a page runs at most one. */ + private static final Map<String, PushTask> TASKS = new ConcurrentHashMap<>(); + + private final WebMarkupContainer counterPanel; + + private final LocalVeilBehavior veil = new LocalVeilBehavior(); + + private final Label counter; + + private final Label lastWork; + + /** + * Constructor. + */ + public WebSocketVeilDemoPage() + { + add(new WebSocketBehavior() + { + private static final long serialVersionUID = 1L; + }); + + counterPanel = new WebMarkupContainer("counterPanel"); + counterPanel.add(veil); + add(counterPanel); + + counter = new Label("counter", Model.of(0)); + counterPanel.add(counter); + lastWork = new Label("lastWork", Model.of("No update pushed yet.")); + counterPanel.add(lastWork); + + add(new AjaxLink<Void>("start") + { + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) + { + startPushes(); + } + }); + + add(new AjaxLink<Void>("stop") + { + private static final long serialVersionUID = 1L; + + @Override + public void onClick(AjaxRequestTarget target) + { + PushTask task = TASKS.get(taskKey()); + if (task != null) + { + task.cancel(); + } + } + }); + } + + private void startPushes() + { + String key = taskKey(); + PushTask task = new PushTask(key, getApplication().getName(), getSession().getId(), + getPageId(), veil.getVeilMessage()); + if (TASKS.putIfAbsent(key, task) == null) + { + JSR356Application.get().getScheduledExecutorService().execute(task); + } + } + + private String taskKey() + { + return getSession().getId() + "#" + getPageId(); + } + + @Override + public void onEvent(IEvent<?> event) + { + super.onEvent(event); + + if (event.getPayload() instanceof WebSocketPushPayload payload && + payload.getMessage() instanceof CounterUpdate update) + { + counter.setDefaultModelObject(update.value); + lastWork.setDefaultModelObject(String.format( + "Update %d of %d took %d ms on the server.", update.value, ROUNDS, + update.work.toMillis())); + payload.getHandler().add(counterPanel); + veil.unveil(payload.getHandler()); + } + } + + @Override + public void renderHead(IHeaderResponse response) + { + super.renderHead(response); + + response.render(CssHeaderItem.forReference( + new CssResourceReference(WebSocketVeilDemoPage.class, "WebSocketVeilDemoPage.css"))); + } + + /** + * Pushes the new counter value, and how long it took to compute. + */ + private static class CounterUpdate implements IWebSocketPushMessage + { + private final int value; + + private final Duration work; + + CounterUpdate(int value, Duration work) + { + this.value = value; + this.work = work; + } + } + + /** + * Recomputes the counter a number of times, alternating long and short work. Both outlast the + * spinner delay; the short work ends within the spinner's minimum time, so the spinner stays + * on the redrawn panel for the rest of it. + */ + private static class PushTask implements Runnable + { + private final String key; + + private final String applicationName; + + private final String sessionId; + + private final int pageId; + + private final String veilMessage; + + private volatile boolean canceled; + + PushTask(String key, String applicationName, String sessionId, int pageId, + String veilMessage) + { + this.key = key; + this.applicationName = applicationName; + this.sessionId = sessionId; + this.pageId = pageId; + this.veilMessage = veilMessage; + } + + void cancel() + { + canceled = true; + } + + @Override + public void run() + { + try + { + for (int round = 1; round <= ROUNDS && !canceled; round++) + { + IWebSocketConnection connection = connection(); + if (connection == null || !connection.isOpen()) + { + return; + } + + connection.sendMessage(veilMessage); + Duration work = Duration.ofMillis(round % 2 == 1 ? 1500 : 600); + TimeUnit.MILLISECONDS.sleep(work.toMillis()); + connection.sendMessage(new CounterUpdate(round, work)); + + TimeUnit.SECONDS.sleep(1); + } + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + catch (IOException | RuntimeException e) + { + LOGGER.error("Pushing to the counter panel failed", e); + } + finally + { + TASKS.remove(key); + } + } + + private IWebSocketConnection connection() + { + Application application = Application.get(applicationName); + return WebSocketSettings.Holder.get(application) + .getConnectionRegistry() + .getConnection(application, sessionId, new PageIdKey(pageId)); + } + } +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.properties b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.properties new file mode 100644 index 0000000000..9bf32492af --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.properties @@ -0,0 +1,17 @@ +# 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. +title=Veil a component while the server recomputes it +description=raises a local veil from the server and lifts it with the pushed update. +explanation=<p> "Start pushes" starts a background task bound to this page. Ten times, it tells the browser through the WebSocket connection that the counter panel is being recomputed, which raises the panel's LocalVeilBehavior, works for a while, and pushes the new counter. The update lifts the veil. Odd rounds take 1.5 s: the spinner shows after 300 ms and goes away with the update. Even rounds take 600 ms: the spinner shows after 300 ms, and since the update arrives within its minimum [...] diff --git a/wicket-examples/src/test/java/org/apache/wicket/examples/ajax/builtin/VeilPageSeleniumTest.java b/wicket-examples/src/test/java/org/apache/wicket/examples/ajax/builtin/VeilPageSeleniumTest.java new file mode 100644 index 0000000000..424f5d624d --- /dev/null +++ b/wicket-examples/src/test/java/org/apache/wicket/examples/ajax/builtin/VeilPageSeleniumTest.java @@ -0,0 +1,398 @@ +/* + * 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.examples.ajax.builtin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.wicket.examples.AjaxEngineSelector.Engine; +import org.apache.wicket.examples.JettyTestCaseDecorator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openqa.selenium.By; +import org.openqa.selenium.ElementClickInterceptedException; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +/** + * Drives {@link VeilPage} in a headless Chrome, with either Ajax engine. + * <p> + * Needs Chrome, so it only runs with {@code -Dwicket.selenium=true}. Selenium Manager resolves + * the driver, and the browser too when none is installed. + */ +@EnabledIfSystemProperty(named = "wicket.selenium", matches = "true") +class VeilPageSeleniumTest extends JettyTestCaseDecorator +{ + private static final String PAGE_VEIL = "body > .wicket-veil"; + + private static final String OUTER_VEIL = ".veil-outer > .wicket-veil"; + + private static final String INNER_VEIL = ".veil-inner > .wicket-veil"; + + /** + * Logs when a veil is added, gets its spinner and is removed, with the time of each, into + * {@code window.veilLog}. + */ + private static final String RECORD_VEILS = """ + window.veilLog = []; + function isVeil(node) { + return node.classList && node.classList.contains('wicket-veil'); + } + function log(event, veil, host) { + window.veilLog.push({ + event: event, + target: host === document.body ? 'page' : + host.classList.contains('veil-inner') ? 'inner' : 'outer', + time: performance.now() + }); + } + new MutationObserver(function (records) { + records.forEach(function (record) { + if (record.type === 'childList') { + record.addedNodes.forEach(function (node) { + if (isVeil(node)) { log('added', node, record.target); } + }); + record.removedNodes.forEach(function (node) { + if (isVeil(node)) { log('removed', node, record.target); } + }); + } else if (isVeil(record.target) && + record.target.classList.contains('wicket-veil-busy') && + (record.oldValue || '').indexOf('wicket-veil-busy') < 0) { + log('busy', record.target, record.target.parentNode); + } + }); + }).observe(document.body, { childList: true, subtree: true, attributes: true, + attributeOldValue: true, attributeFilter: ['class'] }); + """; + + /** + * Calls back once no veil has been on the page for 300 ms, so the requests the page fires by + * itself on load are over. + */ + private static final String AWAIT_QUIET = """ + var callback = arguments[arguments.length - 1]; + var quietSince = null; + (function poll() { + var now = Date.now(); + if (document.querySelector('.wicket-veil')) { + quietSince = null; + } else if (quietSince === null) { + quietSince = now; + } else if (now - quietSince >= 300) { + callback(true); + return; + } + setTimeout(poll, 20); + })(); + """; + + /** + * Tells whether the page veil is the topmost element at the centre of the given element, and + * describes the veil's geometry when it is not. + */ + private static final String HIT_TEST = """ + var target = arguments[0]; + target.scrollIntoView({ block: 'center' }); + var rect = target.getBoundingClientRect(); + var x = rect.left + rect.width / 2; + var y = rect.top + rect.height / 2; + var hit = document.elementFromPoint(x, y); + var veil = document.querySelector('body > .wicket-veil'); + var style = getComputedStyle(veil); + var veilRect = veil.getBoundingClientRect(); + return { + veiled: hit === veil, + hit: hit ? hit.tagName + '.' + hit.className : null, + point: x + ',' + y, + position: style.position, + zIndex: style.zIndex, + veilRect: [veilRect.left, veilRect.top, veilRect.width, veilRect.height].join(','), + stylesheet: Array.prototype.some.call(document.styleSheets, function (sheet) { + return (sheet.href || '').indexOf('wicket-veil') >= 0; + }) + }; + """; + + private WebDriver driver; + + private WebDriverWait wait; + + @Override + @BeforeEach + public void before() throws Exception + { + super.before(); + + ChromeOptions options = new ChromeOptions(); + options.addArguments("--headless=new", "--window-size=1280,1024"); + driver = new ChromeDriver(options); + driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(10)); + wait = new WebDriverWait(driver, Duration.ofSeconds(10), Duration.ofMillis(20)); + } + + @Override + @AfterEach + public void after() throws Exception + { + if (driver != null) + { + driver.quit(); + } + super.after(); + } + + @ParameterizedTest + @EnumSource(Engine.class) + void slowRequestVeilsThePageAndShowsTheSpinnerAfterTheDelay(Engine engine) + { + open(engine); + + click("Slow request"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(PAGE_VEIL))); + wait.until(ExpectedConditions.presenceOfElementLocated( + By.cssSelector(PAGE_VEIL + ".wicket-veil-busy"))); + awaitPageCounter("1"); + awaitNoVeil(); + + List<Map<String, Object>> log = veilLog(); + assertEquals(List.of("added", "busy", "removed"), events(log), log.toString()); + assertEquals("page", log.get(0).get("target")); + double spinnerDelay = time(log, "busy") - time(log, "added"); + assertTrue(spinnerDelay >= 290, "the spinner showed after " + spinnerDelay + " ms"); + } + + @ParameterizedTest + @EnumSource(Engine.class) + void theVeilSwallowsClicks(Engine engine) + { + open(engine); + + click("Slow request"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(PAGE_VEIL))); + @SuppressWarnings("unchecked") + Map<String, Object> hit = (Map<String, Object>)js().executeScript(HIT_TEST, + driver.findElement(By.linkText("Fast request"))); + assertEquals(Boolean.TRUE, hit.get("veiled"), "the veil does not cover the link: " + hit); + assertThrows(ElementClickInterceptedException.class, () -> click("Fast request")); + + awaitPageCounter("1"); + awaitNoVeil(); + awaitQuiet(); + assertEquals("1", pageCounter(), "the click on the veiled page went through"); + } + + @ParameterizedTest + @EnumSource(Engine.class) + void fastRequestShowsNoSpinner(Engine engine) + { + open(engine); + + click("Fast request"); + awaitPageCounter("1"); + awaitNoVeil(); + + List<Map<String, Object>> log = veilLog(); + assertEquals(List.of("added", "removed"), events(log), log.toString()); + } + + @ParameterizedTest + @EnumSource(Engine.class) + void theSpinnerStaysForItsMinimumTime(Engine engine) + { + open(engine); + + click("Medium request"); + awaitPageCounter("1"); + awaitNoVeil(); + + List<Map<String, Object>> log = veilLog(); + assertEquals(List.of("added", "busy", "removed"), events(log), log.toString()); + double shown = time(log, "removed") - time(log, "busy"); + assertTrue(shown >= 480, "the spinner was shown for " + shown + " ms only"); + } + + @ParameterizedTest + @EnumSource(Engine.class) + void anOptedOutRequestLeavesThePageUsable(Engine engine) + { + open(engine); + + click("Unveiled request"); + sleep(Duration.ofMillis(400)); + assertTrue(veilLog().isEmpty(), "the opted-out request was veiled: " + veilLog()); + + click("Fast request"); + awaitPageCounter("2"); + } + + @ParameterizedTest + @EnumSource(Engine.class) + void aLocalVeilCoversOnlyItsComponentAndShowsTheSpinner(Engine engine) + { + open(engine); + + click("Slow request from the outer panel"); + wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(OUTER_VEIL))); + assertTrue(outerPanel().getDomAttribute("class").contains("wicket-veil-host")); + assertTrue(driver.findElements(By.cssSelector(PAGE_VEIL)).isEmpty(), + "the page was veiled too"); + assertTrue(driver.findElements(By.cssSelector(INNER_VEIL)).isEmpty(), + "the nested panel got a veil of its own"); + wait.until(ExpectedConditions.presenceOfElementLocated( + By.cssSelector(OUTER_VEIL + ".wicket-veil-busy"))); + + click("Fast request"); + + wait.until(ExpectedConditions.textToBe(By.cssSelector(".outer-counter"), "1")); + awaitPageCounter("1"); + awaitNoVeil(); + assertFalse(outerPanel().getDomAttribute("class").contains("wicket-veil-host"), + "the host class stayed"); + + List<Map<String, Object>> outerLog = veilLog().stream() + .filter(entry -> "outer".equals(entry.get("target"))) + .collect(Collectors.toList()); + assertEquals(List.of("added", "busy", "removed"), events(outerLog), outerLog.toString()); + double spinnerDelay = time(outerLog, "busy") - time(outerLog, "added"); + assertTrue(spinnerDelay >= 290, "the spinner showed after " + spinnerDelay + " ms"); + } + + @ParameterizedTest + @EnumSource(Engine.class) + void aNestedLocalVeilTakesTheRequestWithItsOwnTimings(Engine engine) + { + open(engine); + + click("Short request from the inner panel"); + wait.until(ExpectedConditions.textToBe(By.cssSelector(".inner-counter"), "1")); + awaitNoVeil(); + + List<Map<String, Object>> log = veilLog(); + assertEquals(List.of("added", "busy", "removed"), events(log), log.toString()); + assertTrue(log.stream().allMatch(entry -> "inner".equals(entry.get("target"))), + "a veil other than the inner one was involved: " + log); + double spinnerDelay = time(log, "busy") - time(log, "added"); + assertTrue(spinnerDelay >= 90 && spinnerDelay < 290, + "the spinner showed after " + spinnerDelay + " ms instead of the configured 100 ms"); + double shown = time(log, "removed") - time(log, "busy"); + assertTrue(shown >= 980, + "the spinner was shown for " + shown + " ms instead of the configured 1 s"); + } + + private WebElement outerPanel() + { + return driver.findElement(By.cssSelector(".veil-outer")); + } + + private void open(Engine engine) + { + driver.get(String.format("http://localhost:%d/wicket-examples/ajax/veil", localPort)); + if (isJQueryLoaded() != (engine == Engine.JQUERY)) + { + WebElement toggle = driver.findElement(By.partialLinkText("switch to")); + toggle.click(); + wait.until(ExpectedConditions.stalenessOf(toggle)); + wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(".veil-outer"))); + } + assertEquals(engine == Engine.JQUERY, isJQueryLoaded(), "the Ajax engine"); + + awaitQuiet(); + js().executeScript(RECORD_VEILS); + } + + private boolean isJQueryLoaded() + { + return (Boolean)js().executeScript("return typeof window.jQuery === 'function';"); + } + + private void click(String linkText) + { + driver.findElement(By.linkText(linkText)).click(); + } + + private String pageCounter() + { + return driver.findElement(By.cssSelector(".page-counter")).getText(); + } + + private void awaitPageCounter(String value) + { + wait.until(ExpectedConditions.textToBe(By.cssSelector(".page-counter"), value)); + } + + private void awaitNoVeil() + { + wait.until(ExpectedConditions.numberOfElementsToBe(By.cssSelector(".wicket-veil"), 0)); + } + + private void awaitQuiet() + { + js().executeAsyncScript(AWAIT_QUIET); + } + + @SuppressWarnings("unchecked") + private List<Map<String, Object>> veilLog() + { + return (List<Map<String, Object>>)js().executeScript("return window.veilLog;"); + } + + private static List<Object> events(List<Map<String, Object>> log) + { + return log.stream().map(entry -> entry.get("event")).collect(Collectors.toList()); + } + + private static double time(List<Map<String, Object>> log, String event) + { + return log.stream() + .filter(entry -> event.equals(entry.get("event"))) + .map(entry -> ((Number)entry.get("time")).doubleValue()) + .findFirst() + .orElseThrow(() -> new AssertionError("no '" + event + "' in " + log)); + } + + private JavascriptExecutor js() + { + return (JavascriptExecutor)driver; + } + + private static void sleep(Duration duration) + { + try + { + Thread.sleep(duration.toMillis()); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } +} diff --git a/wicket-extensions/pom.xml b/wicket-extensions/pom.xml index 8e858528a9..ac63c5cf8b 100644 --- a/wicket-extensions/pom.xml +++ b/wicket-extensions/pom.xml @@ -40,6 +40,7 @@ org.apache.wicket.extensions.ajax.markup.html.repeater;-noimport:=true, org.apache.wicket.extensions.ajax.markup.html.repeater.data.sort;-noimport:=true, org.apache.wicket.extensions.ajax.markup.html.repeater.data.table;-noimport:=true, org.apache.wicket.extensions.ajax.markup.html.tabs;-noimport:=true, +org.apache.wicket.extensions.ajax.veil;-noimport:=true, org.apache.wicket.extensions.breadcrumb;-noimport:=true, org.apache.wicket.extensions.breadcrumb.panel;-noimport:=true, org.apache.wicket.extensions.captcha.kittens;-noimport:=true, diff --git a/wicket-extensions/src/main/java/module-info.java b/wicket-extensions/src/main/java/module-info.java index 33e4303fc0..cc2554a6fe 100644 --- a/wicket-extensions/src/main/java/module-info.java +++ b/wicket-extensions/src/main/java/module-info.java @@ -40,6 +40,7 @@ module org.apache.wicket.extensions { exports org.apache.wicket.extensions.ajax.markup.html.repeater.data.sort; exports org.apache.wicket.extensions.ajax.markup.html.repeater.data.table; exports org.apache.wicket.extensions.ajax.markup.html.tabs; + exports org.apache.wicket.extensions.ajax.veil; exports org.apache.wicket.extensions.breadcrumb; exports org.apache.wicket.extensions.breadcrumb.panel; exports org.apache.wicket.extensions.captcha.kittens; @@ -82,6 +83,7 @@ module org.apache.wicket.extensions { opens org.apache.wicket.extensions.ajax.markup.html.repeater.data.sort; opens org.apache.wicket.extensions.ajax.markup.html.modal; opens org.apache.wicket.extensions.ajax.markup.html.modal.theme; + opens org.apache.wicket.extensions.ajax.veil; opens org.apache.wicket.extensions.breadcrumb; opens org.apache.wicket.extensions.captcha.kittens; opens org.apache.wicket.extensions.captcha.kittens.images; diff --git a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/AbstractVeilBehavior.java b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/AbstractVeilBehavior.java new file mode 100644 index 0000000000..1acf840c5a --- /dev/null +++ b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/AbstractVeilBehavior.java @@ -0,0 +1,150 @@ +/* + * 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.extensions.ajax.veil; + +import java.time.Duration; + +import org.apache.wicket.Component; +import org.apache.wicket.behavior.Behavior; +import org.apache.wicket.markup.head.CssHeaderItem; +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.request.resource.CssResourceReference; +import org.apache.wicket.request.resource.JavaScriptResourceReference; +import org.apache.wicket.request.resource.ResourceReference; +import org.apache.wicket.resource.CoreLibrariesContributor; +import org.apache.wicket.util.lang.Args; + +/** + * Base class of the behaviors that put a veil over a region of the page while Ajax requests are + * in flight. + * <p> + * The veil appears as soon as a request is sent. It is transparent and swallows mouse clicks, so + * the user cannot fire further requests or change what the pending one is about. Only if the + * request is still running after {@link #getSpinnerDelay()} does the veil get the CSS class + * {@code wicket-veil-busy}, which dims the region and shows a spinner; once shown, the spinner + * stays for at least {@link #getMinimumSpinnerTime()}, so a response arriving just after it + * appeared does not make it flicker. Both timings apply to the page veil and to local veils + * alike, and can be changed per behavior with {@link #setSpinnerDelay(Duration)} and + * {@link #setMinimumSpinnerTime(Duration)}. The veil does not intercept the keyboard. + * <p> + * The look comes from {@code wicket-veil.css} and can be overridden with the classes + * {@code wicket-veil}, {@code wicket-veil-busy} and {@code wicket-veil-host}. + * <p> + * A request is left unveiled when it carries the extra parameter + * {@value PageVeilBehavior#NO_VEIL_PARAMETER}, see {@link PageVeilBehavior#noVeil}. + * + * @see PageVeilBehavior + * @see LocalVeilBehavior + * @since 11.0.0 + */ +public abstract class AbstractVeilBehavior extends Behavior +{ + private static final long serialVersionUID = 1L; + + private static final ResourceReference JS = new JavaScriptResourceReference( + AbstractVeilBehavior.class, "wicket-veil.js"); + + private static final ResourceReference CSS = new CssResourceReference( + AbstractVeilBehavior.class, "wicket-veil.css"); + + private Duration spinnerDelay = Duration.ofMillis(300); + + private Duration minimumSpinnerTime = Duration.ofMillis(500); + + /** + * @return how long a request has to run before the spinner is shown; 300 ms by default + */ + protected Duration getSpinnerDelay() + { + return spinnerDelay; + } + + /** + * Sets how long a request has to run before the spinner is shown. {@link Duration#ZERO} + * shows it as soon as the request is sent. + * + * @param spinnerDelay + * the delay, not negative + * @return this, for chaining + */ + public AbstractVeilBehavior setSpinnerDelay(Duration spinnerDelay) + { + this.spinnerDelay = checkNotNegative(spinnerDelay, "spinnerDelay"); + return this; + } + + /** + * @return how long the spinner stays at least, once it is shown; 500 ms by default + */ + protected Duration getMinimumSpinnerTime() + { + return minimumSpinnerTime; + } + + /** + * Sets how long the spinner stays at least, once it is shown, even when the request is over + * sooner. {@link Duration#ZERO} removes it together with the request. + * + * @param minimumSpinnerTime + * the minimum time, not negative + * @return this, for chaining + */ + public AbstractVeilBehavior setMinimumSpinnerTime(Duration minimumSpinnerTime) + { + this.minimumSpinnerTime = checkNotNegative(minimumSpinnerTime, "minimumSpinnerTime"); + return this; + } + + private static Duration checkNotNegative(Duration duration, String name) + { + Args.notNull(duration, name); + if (duration.isNegative()) + { + throw new IllegalArgumentException(name + " must not be negative: " + duration); + } + return duration; + } + + @Override + public void renderHead(Component component, IHeaderResponse response) + { + super.renderHead(component, response); + + CoreLibrariesContributor.contributeAjax(component.getApplication(), response); + response.render(JavaScriptHeaderItem.forReference(JS)); + response.render(CssHeaderItem.forReference(CSS)); + response.render(OnDomReadyHeaderItem.forScript(getInitScript(component))); + } + + /** + * @param component + * the component this behavior is bound to + * @return the script registering the veil with {@code Wicket.Veil} + */ + protected abstract CharSequence getInitScript(Component component); + + /** + * @return the timings as the options object {@code Wicket.Veil} expects + */ + protected final String getOptions() + { + return String.format("{\"delay\":%d,\"minimum\":%d}", getSpinnerDelay().toMillis(), + getMinimumSpinnerTime().toMillis()); + } +} diff --git a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/LocalVeilBehavior.java b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/LocalVeilBehavior.java new file mode 100644 index 0000000000..cbd0854aa8 --- /dev/null +++ b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/LocalVeilBehavior.java @@ -0,0 +1,157 @@ +/* + * 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.extensions.ajax.veil; + +import org.apache.wicket.Component; +import org.apache.wicket.core.request.handler.IPartialPageRequestHandler; + +import com.github.openjson.JSONObject; + +/** + * Veils a single component while an Ajax request fired from inside it is in flight, see + * {@link AbstractVeilBehavior}. + * <p> + * Requests fired by the component itself or by any component nested in it are covered; when + * local veils are nested, the innermost one takes the request. Such a request does not veil the + * page, even when it has a {@link PageVeilBehavior}. Requests fired from elsewhere leave the + * component alone. + * <p> + * The veil is appended to the component's element, which gets the class + * {@code wicket-veil-host} with {@code position: relative} for the duration, so the component + * has to render an element that can hold a {@code div}. The behavior makes the component output + * its markup id. + * <p> + * The server can raise the veil too, for an update it is about to push to the component, for + * example through a WebSocket connection: send {@link #getVeilMessage()} as a text message when + * the work for the component starts, and call {@link #unveil(IPartialPageRequestHandler)} with + * the handler that pushes the updated component. The spinner follows the same timings as for an + * Ajax request. When the work fails and no update follows, send {@link #getUnveilMessage()} + * instead. The text messages are understood by the client of Wicket's native WebSocket support, + * which hands them to {@code Wicket.Veil}: + * + * <pre> + * // while handling a request, e.g. the click that schedules the work + * String veilMessage = veil.getVeilMessage(); + * + * // when the work starts, from any thread + * connection.sendMessage(veilMessage); + * + * // when the result is pushed, e.g. in onEvent() for a WebSocketPushPayload + * handler.add(component); + * veil.unveil(handler); + * </pre> + * + * A behavior instance belongs to a single component. + * + * @since 11.0.0 + */ +public class LocalVeilBehavior extends AbstractVeilBehavior +{ + private static final long serialVersionUID = 1L; + + private Component component; + + @Override + public void bind(Component component) + { + super.bind(component); + + if (this.component != null && this.component != component) + { + throw new IllegalStateException(LocalVeilBehavior.class.getSimpleName() + + " is already bound to " + this.component.getPageRelativePath() + + " and cannot be bound to another component"); + } + this.component = component; + component.setOutputMarkupId(true); + } + + @Override + public void unbind(Component component) + { + this.component = null; + + super.unbind(component); + } + + /** + * A text message that raises this veil on the client when it is sent through a WebSocket + * connection of the component's page. Each one has to be matched by + * {@link #unveil(IPartialPageRequestHandler)} or {@link #getUnveilMessage()}. + * <p> + * Like any component access, call it while handling a request; the message itself is a plain + * string that can then be sent from any thread. + * + * @return the message + * @throws IllegalStateException + * if the behavior is not bound to a component + */ + public String getVeilMessage() + { + return message("show"); + } + + /** + * A text message that lowers this veil on the client when it is sent through a WebSocket + * connection of the component's page, for when no update follows a + * {@link #getVeilMessage()}. + * + * @return the message + * @throws IllegalStateException + * if the behavior is not bound to a component + */ + public String getUnveilMessage() + { + return message("hide"); + } + + /** + * Lowers this veil on the client once the given handler's update has been applied, matching + * an earlier {@link #getVeilMessage()}. + * + * @param handler + * the handler pushing the update of the component + * @throws IllegalStateException + * if the behavior is not bound to a component + */ + public void unveil(IPartialPageRequestHandler handler) + { + handler.appendJavaScript("Wicket.Veil.hide(" + quotedMarkupId() + ");"); + } + + private String message(String command) + { + return "{\"wicketVeil\":\"" + command + "\",\"id\":" + quotedMarkupId() + "}"; + } + + private String quotedMarkupId() + { + if (component == null) + { + throw new IllegalStateException( + LocalVeilBehavior.class.getSimpleName() + " is not bound to a component"); + } + return JSONObject.quote(component.getMarkupId()); + } + + @Override + protected CharSequence getInitScript(Component component) + { + return "Wicket.Veil.local(" + JSONObject.quote(component.getMarkupId()) + ", " + + getOptions() + ");"; + } +} diff --git a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/PageVeilBehavior.java b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/PageVeilBehavior.java new file mode 100644 index 0000000000..fa1047d541 --- /dev/null +++ b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/PageVeilBehavior.java @@ -0,0 +1,86 @@ +/* + * 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.extensions.ajax.veil; + +import org.apache.wicket.Component; +import org.apache.wicket.Page; +import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; + +/** + * Veils the whole page while an Ajax request is in flight, see {@link AbstractVeilBehavior}. + * <p> + * Add it to a page, typically a base page, to cover every Ajax request fired from it. A request + * fired from inside a component with a {@link LocalVeilBehavior} veils only that component + * instead. Requests the user did not start, such as those of an Ajax timer, are veiled as well. + * To leave a request unveiled, call {@link #noVeil(AjaxRequestAttributes)} from the + * {@code updateAjaxAttributes} of its behavior: + * + * <pre> + * @Override + * protected void updateAjaxAttributes(AjaxRequestAttributes attributes) + * { + * super.updateAjaxAttributes(attributes); + * PageVeilBehavior.noVeil(attributes); + * } + * </pre> + * + * @since 11.0.0 + */ +public class PageVeilBehavior extends AbstractVeilBehavior +{ + private static final long serialVersionUID = 1L; + + /** + * The extra request parameter exempting an Ajax request from any veil, page or local. + */ + public static final String NO_VEIL_PARAMETER = "wicket_nb"; + + /** + * Exempts an Ajax request from being veiled, by the page veil as well as by any + * {@link LocalVeilBehavior}. It adds the extra parameter {@value #NO_VEIL_PARAMETER}, so the + * request carries it to the server too. + * + * @param attributes + * the attributes of the request to exempt + */ + public static void noVeil(AjaxRequestAttributes attributes) + { + attributes.getExtraParameters().put(NO_VEIL_PARAMETER, "true"); + } + + /** + * @throws IllegalArgumentException + * if the component is not a {@link Page} + */ + @Override + public void bind(Component component) + { + super.bind(component); + + if (component instanceof Page == false) + { + throw new IllegalArgumentException(PageVeilBehavior.class.getSimpleName() + + " can only be added to a page, not to " + component.getClass().getName()); + } + } + + @Override + protected CharSequence getInitScript(Component component) + { + return "Wicket.Veil.page(" + getOptions() + ");"; + } +} diff --git a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.css b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.css new file mode 100644 index 0000000000..6736ec668e --- /dev/null +++ b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.css @@ -0,0 +1,61 @@ +/* + * 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. + */ + +.wicket-veil { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 10000; + background: transparent; + cursor: wait; +} + +.wicket-veil-host { + position: relative; +} + +.wicket-veil-host > .wicket-veil { + position: absolute; + z-index: 1000; +} + +.wicket-veil-busy { + background: rgba(255, 255, 255, 0.5); +} + +.wicket-veil-busy::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + box-sizing: border-box; + width: 2.5em; + height: 2.5em; + margin: -1.25em 0 0 -1.25em; + border: 0.3em solid rgba(0, 0, 0, 0.15); + border-top-color: rgba(0, 0, 0, 0.6); + border-radius: 50%; + animation: wicket-veil-spin 0.8s linear infinite; +} + +@keyframes wicket-veil-spin { + to { + transform: rotate(360deg); + } +} diff --git a/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js new file mode 100644 index 0000000000..c207dfe3f9 --- /dev/null +++ b/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js @@ -0,0 +1,324 @@ +/* + * 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. + */ + +/* + * Veils the page, or a single component, while Ajax requests are in flight. + * + * The veil is transparent and only blocks the mouse. If a request is still running after the + * target's spinner delay, the veil gets the 'wicket-veil-busy' class, which shows a spinner; + * once shown, the spinner stays for at least the target's minimum time, so it does not flicker. + * A request carrying the extra parameter 'wicket_nb' is never veiled. + * + * A local veil can also be raised by the server, for a component it is about to update through a + * WebSocket push: a WebSocket text message {"wicketVeil":"show","id":"<markup id>"} raises it, + * Wicket.Veil.hide(id) - evaluated after the pushed update - or {"wicketVeil":"hide",...} lowers it. + */ +;(function (undefined) { + 'use strict'; + + if (typeof(Wicket.Veil) === "object") { + return; + } + + const NO_VEIL_PARAMETER = 'wicket_nb'; + const VEIL_CLASS = 'wicket-veil'; + const BUSY_CLASS = 'wicket-veil-busy'; + const HOST_CLASS = 'wicket-veil-host'; + const WEBSOCKET_MESSAGE_TOPIC = '/websocket/message'; + const MESSAGE_PREFIX = '{"wicketVeil"'; + + let pageTarget = null; + let localTargets = {}; + let subscribed = false; + + function createTarget(id, options) { + return { + id: id, + delay: options.delay, + minimum: options.minimum, + count: 0, + host: null, + veil: null, + shownAt: -1, + spinnerTimer: null, + hideTimer: null + }; + } + + function configure(target, options) { + target.delay = options.delay; + target.minimum = options.minimum; + } + + function isOptedOut(attrs) { + const ep = attrs.ep; + if (Array.isArray(ep)) { + return ep.some(function (parameter) { + return parameter && parameter.name === NO_VEIL_PARAMETER; + }); + } + return !!ep && typeof(ep) === "object" && + Object.prototype.hasOwnProperty.call(ep, NO_VEIL_PARAMETER); + } + + function findTarget(attrs) { + let node = attrs.c ? document.getElementById(attrs.c) : null; + for (; node && node !== document; node = node.parentNode) { + if (node.id && localTargets[node.id]) { + return localTargets[node.id]; + } + } + return pageTarget; + } + + function dropStaleTargets() { + for (const id in localTargets) { + if (Object.prototype.hasOwnProperty.call(localTargets, id) && + localTargets[id].count === 0 && !document.getElementById(id)) { + delete localTargets[id]; + } + } + } + + function hide(target) { + const clock = Wicket.Veil._clock; + clock.clearTimeout(target.spinnerTimer); + clock.clearTimeout(target.hideTimer); + target.spinnerTimer = null; + target.hideTimer = null; + target.shownAt = -1; + if (target.veil && target.veil.parentNode) { + target.veil.parentNode.removeChild(target.veil); + } + if (target.host && target !== pageTarget) { + target.host.classList.remove(HOST_CLASS); + } + target.veil = null; + target.host = null; + } + + function show(target) { + const clock = Wicket.Veil._clock; + if (target.hideTimer !== null) { + if (document.body.contains(target.veil)) { + // the previous request's spinner is still on its minimum time: carry on with it + clock.clearTimeout(target.hideTimer); + target.hideTimer = null; + return; + } + hide(target); + } + + const host = target === pageTarget ? document.body : document.getElementById(target.id); + const veil = document.createElement('div'); + veil.className = VEIL_CLASS; + if (target !== pageTarget) { + host.classList.add(HOST_CLASS); + } + host.appendChild(veil); + target.host = host; + target.veil = veil; + + target.spinnerTimer = clock.setTimeout(function () { + target.spinnerTimer = null; + veil.classList.add(BUSY_CLASS); + target.shownAt = clock.now(); + }, target.delay); + } + + function reattach(target) { + if (target === pageTarget || !target.veil || document.body.contains(target.veil)) { + return; + } + const host = document.getElementById(target.id); + if (host) { + host.classList.add(HOST_CLASS); + host.appendChild(target.veil); + target.host = host; + } + } + + function release(target) { + const clock = Wicket.Veil._clock; + if (target.shownAt < 0) { + hide(target); + return; + } + const remaining = target.minimum - (clock.now() - target.shownAt); + if (remaining > 0) { + // the update may have replaced the element, and the veil with it + reattach(target); + target.hideTimer = clock.setTimeout(function () { + hide(target); + }, remaining); + } else { + hide(target); + } + } + + function acquire(target) { + target.count++; + if (target.count === 1) { + show(target); + } + } + + function releaseOne(target) { + if (target.count > 0) { + target.count--; + if (target.count === 0) { + release(target); + } + } + } + + function onBeforeSend(jqEvent, attrs) { + if (!attrs || isOptedOut(attrs)) { + return; + } + dropStaleTargets(); + const target = findTarget(attrs); + if (target === null) { + return; + } + attrs.wicketVeil = target; + acquire(target); + } + + function onDone(jqEvent, attrs) { + const target = attrs && attrs.wicketVeil; + if (!target) { + return; + } + delete attrs.wicketVeil; + releaseOne(target); + } + + function onWebSocketMessage(jqEvent, message) { + if (typeof(message) !== "string" || message.indexOf(MESSAGE_PREFIX) !== 0) { + return; + } + let command; + try { + command = JSON.parse(message); + } catch (e) { + return; + } + if (command.wicketVeil === 'show') { + Wicket.Veil.show(command.id); + } else if (command.wicketVeil === 'hide') { + Wicket.Veil.hide(command.id); + } + } + + function subscribe() { + if (subscribed === false) { + subscribed = true; + Wicket.Event.subscribe(Wicket.Event.Topic.AJAX_CALL_BEFORE_SEND, onBeforeSend); + Wicket.Event.subscribe(Wicket.Event.Topic.AJAX_CALL_DONE, onDone); + Wicket.Event.subscribe(WEBSOCKET_MESSAGE_TOPIC, onWebSocketMessage); + } + } + + Wicket.Veil = { + + /** + * Veils the whole page during every Ajax request that no local veil claims. + * + * @param options {Object} - 'delay': milliseconds before the spinner shows, + * 'minimum': milliseconds the spinner stays once shown + */ + page: function (options) { + subscribe(); + if (pageTarget === null) { + pageTarget = createTarget(null, options); + } else { + configure(pageTarget, options); + } + }, + + /** + * Veils only the element with the given id, during the Ajax requests fired by + * components inside it. + * + * @param id {String} - the markup id of the element to veil + * @param options {Object} - as for page() + */ + local: function (id, options) { + subscribe(); + const target = localTargets[id]; + if (target) { + configure(target, options); + } else { + localTargets[id] = createTarget(id, options); + } + }, + + /** + * Raises the local veil registered for the given id, as an Ajax request from inside it + * would, with the same timings. Each call has to be matched by a call to hide(). + * + * @param id {String} - the markup id of a component with a local veil + */ + show: function (id) { + const target = localTargets[id]; + if (target && document.getElementById(id)) { + acquire(target); + } + }, + + /** + * Lowers the local veil raised by show(), respecting the spinner's minimum time. Calls + * without a matching show() are ignored. + * + * @param id {String} - the markup id of a component with a local veil + */ + hide: function (id) { + const target = localTargets[id]; + if (target) { + releaseOne(target); + } + }, + + _clock: { + now: function () { + return Date.now(); + }, + setTimeout: function (fn, delay) { + return window.setTimeout(fn, delay); + }, + clearTimeout: function (timer) { + if (timer !== null) { + window.clearTimeout(timer); + } + } + }, + + _reset: function () { + if (pageTarget !== null) { + hide(pageTarget); + } + for (const id in localTargets) { + if (Object.prototype.hasOwnProperty.call(localTargets, id)) { + hide(localTargets[id]); + } + } + pageTarget = null; + localTargets = {}; + } + }; +})(); diff --git a/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/veil/VeilBehaviorTest.java b/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/veil/VeilBehaviorTest.java new file mode 100644 index 0000000000..f714e49d2f --- /dev/null +++ b/wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/veil/VeilBehaviorTest.java @@ -0,0 +1,249 @@ +/* + * 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.extensions.ajax.veil; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; + +import org.apache.wicket.MarkupContainer; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; +import org.apache.wicket.ajax.markup.html.AjaxLink; +import org.apache.wicket.behavior.Behavior; +import org.apache.wicket.markup.IMarkupResourceStreamProvider; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.markup.html.WebPage; +import org.apache.wicket.util.resource.IResourceStream; +import org.apache.wicket.util.resource.StringResourceStream; +import org.apache.wicket.util.tester.WicketTestCase; +import org.danekja.java.util.function.serializable.SerializableConsumer; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link PageVeilBehavior} and {@link LocalVeilBehavior}. + */ +class VeilBehaviorTest extends WicketTestCase +{ + @Test + void pageVeilContributesResourcesAndInitScript() + { + tester.startPage(new TestPage(new PageVeilBehavior(), null, false)); + + String response = tester.getLastResponseAsString(); + assertTrue(response.contains("wicket-veil.js"), response); + assertTrue(response.contains("wicket-veil.css"), response); + assertTrue(response.contains("Wicket.Veil.page({\"delay\":300,\"minimum\":500});"), + response); + } + + @Test + void localVeilContributesInitScriptForItsComponent() + { + TestPage page = tester.startPage(new TestPage(null, new LocalVeilBehavior(), false)); + + String response = tester.getLastResponseAsString(); + String markupId = page.container.getMarkupId(); + assertTrue(response.contains("id=\"" + markupId + "\""), response); + assertTrue(response.contains("wicket-veil.js"), response); + assertTrue(response.contains( + "Wicket.Veil.local(\"" + markupId + "\", {\"delay\":300,\"minimum\":500});"), + response); + } + + @Test + void timingsCanBeOverridden() + { + tester.startPage(new TestPage(new PageVeilBehavior() + { + private static final long serialVersionUID = 1L; + + @Override + protected Duration getSpinnerDelay() + { + return Duration.ofMillis(100); + } + + @Override + protected Duration getMinimumSpinnerTime() + { + return Duration.ofSeconds(1); + } + }, null, false)); + + String response = tester.getLastResponseAsString(); + assertTrue(response.contains("Wicket.Veil.page({\"delay\":100,\"minimum\":1000});"), + response); + } + + @Test + void localVeilTimingsCanBeSet() + { + LocalVeilBehavior veil = new LocalVeilBehavior(); + veil.setSpinnerDelay(Duration.ZERO).setMinimumSpinnerTime(Duration.ofSeconds(2)); + TestPage page = tester.startPage(new TestPage(null, veil, false)); + + String response = tester.getLastResponseAsString(); + assertTrue(response.contains("Wicket.Veil.local(\"" + page.container.getMarkupId() + + "\", {\"delay\":0,\"minimum\":2000});"), response); + } + + @Test + void negativeTimingsAreRejected() + { + LocalVeilBehavior veil = new LocalVeilBehavior(); + + assertThrows(IllegalArgumentException.class, + () -> veil.setSpinnerDelay(Duration.ofMillis(-1))); + assertThrows(IllegalArgumentException.class, + () -> veil.setMinimumSpinnerTime(Duration.ofMillis(-1))); + } + + @Test + void veilMessagesNameTheComponent() + { + LocalVeilBehavior veil = new LocalVeilBehavior(); + TestPage page = tester.startPage(new TestPage(null, veil, false)); + String markupId = page.container.getMarkupId(); + + assertEquals("{\"wicketVeil\":\"show\",\"id\":\"" + markupId + "\"}", + veil.getVeilMessage()); + assertEquals("{\"wicketVeil\":\"hide\",\"id\":\"" + markupId + "\"}", + veil.getUnveilMessage()); + } + + @Test + void unveilLowersTheVeilAfterTheUpdate() + { + LocalVeilBehavior veil = new LocalVeilBehavior(); + TestPage page = tester.startPage(new TestPage(null, veil, false)); + page.onClick = target -> { + target.add(page.container); + veil.unveil(target); + }; + + tester.clickLink("container:link"); + + String response = tester.getLastResponseAsString(); + assertTrue(response.contains("Wicket.Veil.hide(\"" + page.container.getMarkupId() + "\");"), + response); + } + + @Test + void anUnboundLocalVeilHasNoMessages() + { + LocalVeilBehavior veil = new LocalVeilBehavior(); + + assertThrows(IllegalStateException.class, veil::getVeilMessage); + } + + @Test + void aLocalVeilBelongsToOneComponent() + { + LocalVeilBehavior veil = new LocalVeilBehavior(); + new WebMarkupContainer("first").add(veil); + + assertThrows(IllegalStateException.class, + () -> new WebMarkupContainer("second").add(veil)); + } + + @Test + void pageVeilRejectsNonPageComponents() + { + WebMarkupContainer container = new WebMarkupContainer("container"); + + assertThrows(IllegalArgumentException.class, () -> container.add(new PageVeilBehavior())); + } + + @Test + void noVeilAddsTheExtraParameter() + { + tester.startPage(new TestPage(new PageVeilBehavior(), null, true)); + + String response = tester.getLastResponseAsString(); + assertTrue(response.contains("\"ep\":[{\"name\":\"wicket_nb\",\"value\":\"true\"}]"), + response); + } + + @Test + void requestsAreNotOptedOutByDefault() + { + tester.startPage(new TestPage(new PageVeilBehavior(), null, false)); + + assertFalse(tester.getLastResponseAsString().contains("wicket_nb")); + } + + /** + * A page with a container holding an Ajax link. + */ + public static class TestPage extends WebPage implements IMarkupResourceStreamProvider + { + private static final long serialVersionUID = 1L; + + final WebMarkupContainer container; + + SerializableConsumer<AjaxRequestTarget> onClick = target -> { + }; + + TestPage(Behavior pageBehavior, Behavior containerBehavior, boolean noVeil) + { + if (pageBehavior != null) + { + add(pageBehavior); + } + + add(container = new WebMarkupContainer("container")); + if (containerBehavior != null) + { + container.add(containerBehavior); + } + + container.add(new AjaxLink<Void>("link") + { + private static final long serialVersionUID = 1L; + + @Override + protected void updateAjaxAttributes(AjaxRequestAttributes attributes) + { + super.updateAjaxAttributes(attributes); + if (noVeil) + { + PageVeilBehavior.noVeil(attributes); + } + } + + @Override + public void onClick(AjaxRequestTarget target) + { + onClick.accept(target); + } + }); + } + + @Override + public IResourceStream getMarkupResourceStream(MarkupContainer container, + Class<?> containerClass) + { + return new StringResourceStream("<html><head></head><body>" + + "<div wicket:id=\"container\"><a wicket:id=\"link\">link</a></div>" + + "</body></html>"); + } + } +} diff --git a/wicket-extensions/src/test/js/veil-test.js b/wicket-extensions/src/test/js/veil-test.js new file mode 100644 index 0000000000..57e342d822 --- /dev/null +++ b/wicket-extensions/src/test/js/veil-test.js @@ -0,0 +1,388 @@ +/* + * 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. + */ + +/*global QUnit: true */ + +Wicket.Event.add(window, 'domready', function() { + "use strict"; + + const { module, test } = QUnit; + + const OPTIONS = { delay: 300, minimum: 500 }; + + let realClock; + let clock; + + function fakeClock() { + let now = 0; + let nextId = 1; + let timers = []; + + return { + now: function() { + return now; + }, + setTimeout: function(fn, delay) { + const id = nextId++; + timers.push({ id: id, at: now + delay, fn: fn }); + return id; + }, + clearTimeout: function(id) { + timers = timers.filter(function(timer) { + return timer.id !== id; + }); + }, + tick: function(ms) { + const until = now + ms; + for (;;) { + const due = timers.filter(function(timer) { + return timer.at <= until; + }).sort(function(a, b) { + return a.at - b.at; + })[0]; + if (!due) { + break; + } + timers.splice(timers.indexOf(due), 1); + now = due.at; + due.fn(); + } + now = until; + } + }; + } + + function send(attrs) { + Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_BEFORE_SEND, attrs, {}, {}); + return attrs; + } + + function done(attrs) { + Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs); + } + + function push(message) { + Wicket.Event.publish('/websocket/message', message); + } + + function veils() { + return document.querySelectorAll('.wicket-veil'); + } + + function veilOf(element) { + return Array.prototype.filter.call(element.children, function(child) { + return child.classList.contains('wicket-veil'); + })[0]; + } + + function isBusy(veil) { + return veil.classList.contains('wicket-veil-busy'); + } + + module("Wicket.Veil", { + beforeEach: function() { + Wicket.Veil._reset(); + realClock = Wicket.Veil._clock; + clock = fakeClock(); + Wicket.Veil._clock = clock; + }, + afterEach: function() { + Wicket.Veil._reset(); + Wicket.Veil._clock = realClock; + } + }); + + test("without a registered veil a request is not veiled", assert => { + const attrs = send({ c: 'veilPageLink' }); + + assert.equal(veils().length, 0, "a veil appeared although none is registered"); + done(attrs); + }); + + test("the page veil appears at once, transparent, and gets the spinner after the delay", assert => { + Wicket.Veil.page(OPTIONS); + + const attrs = send({ c: 'veilPageLink' }); + const veil = veilOf(document.body); + assert.ok(veil, "the page veil was not appended to the body"); + assert.notOk(isBusy(veil), "the spinner showed before the delay"); + + clock.tick(299); + assert.notOk(isBusy(veil), "the spinner showed before the delay"); + + clock.tick(1); + assert.ok(isBusy(veil), "the spinner did not show after the delay"); + + done(attrs); + }); + + test("a request finishing before the delay removes the veil at once, without a spinner", assert => { + Wicket.Veil.page(OPTIONS); + + const attrs = send({ c: 'veilPageLink' }); + const veil = veilOf(document.body); + clock.tick(100); + done(attrs); + + assert.equal(veils().length, 0, "the veil stayed after the request finished"); + clock.tick(1000); + assert.notOk(isBusy(veil), "the spinner showed after the request finished"); + }); + + test("once shown, the spinner stays for the minimum time", assert => { + Wicket.Veil.page(OPTIONS); + + const attrs = send({ c: 'veilPageLink' }); + clock.tick(350); + done(attrs); + + assert.equal(veils().length, 1, "the spinner was removed before its minimum time"); + clock.tick(449); + assert.equal(veils().length, 1, "the spinner was removed before its minimum time"); + clock.tick(1); + assert.equal(veils().length, 0, "the spinner stayed beyond its minimum time"); + }); + + test("a spinner shown longer than the minimum time is removed at once", assert => { + Wicket.Veil.page(OPTIONS); + + const attrs = send({ c: 'veilPageLink' }); + clock.tick(900); + done(attrs); + + assert.equal(veils().length, 0, "the veil stayed after the request finished"); + }); + + test("the configured timings are honoured", assert => { + Wicket.Veil.page({ delay: 50, minimum: 1000 }); + + const attrs = send({ c: 'veilPageLink' }); + clock.tick(50); + assert.ok(isBusy(veilOf(document.body)), "the spinner did not show after the delay"); + + done(attrs); + clock.tick(999); + assert.equal(veils().length, 1, "the spinner was removed before its minimum time"); + clock.tick(1); + assert.equal(veils().length, 0, "the spinner stayed beyond its minimum time"); + }); + + test("concurrent requests share one veil, removed when the last one finishes", assert => { + Wicket.Veil.page(OPTIONS); + + const first = send({ c: 'veilPageLink' }); + const second = send({ c: 'veilPageLink' }); + assert.equal(veils().length, 1, "each request got its own veil"); + + done(first); + assert.equal(veils().length, 1, "the veil was removed while a request was still running"); + + done(second); + assert.equal(veils().length, 0, "the veil stayed after the last request finished"); + }); + + test("a request during the minimum time keeps the spinner up", assert => { + Wicket.Veil.page(OPTIONS); + + const first = send({ c: 'veilPageLink' }); + clock.tick(400); + done(first); + const veil = veilOf(document.body); + + const second = send({ c: 'veilPageLink' }); + clock.tick(1000); + assert.equal(veilOf(document.body), veil, "the running spinner was replaced"); + assert.ok(isBusy(veil), "the running spinner was hidden"); + + done(second); + assert.equal(veils().length, 0, "the veil stayed after the last request finished"); + }); + + test("a request with the wicket_nb extra parameter is not veiled", assert => { + Wicket.Veil.page(OPTIONS); + Wicket.Veil.local('veilOuter', OPTIONS); + + const pageRequest = send({ c: 'veilPageLink', ep: [{ name: 'wicket_nb', value: 'true' }] }); + const localRequest = send({ c: 'veilOuterLink', ep: { wicket_nb: 'true' } }); + assert.equal(veils().length, 0, "an opted-out request was veiled"); + + done(pageRequest); + done(localRequest); + assert.equal(veils().length, 0, "finishing an opted-out request added a veil"); + }); + + test("a request from inside a local veil veils only that component", assert => { + Wicket.Veil.page(OPTIONS); + Wicket.Veil.local('veilOuter', OPTIONS); + const outer = document.getElementById('veilOuter'); + + const attrs = send({ c: 'veilOuterLink' }); + assert.ok(veilOf(outer), "the local veil was not appended to its component"); + assert.ok(outer.classList.contains('wicket-veil-host'), "the component is not marked as host"); + assert.notOk(veilOf(document.body), "the page was veiled too"); + assert.equal(veils().length, 1, "more than one veil appeared"); + + done(attrs); + assert.equal(veils().length, 0, "the local veil stayed after the request finished"); + assert.notOk(outer.classList.contains('wicket-veil-host'), "the host class stayed"); + }); + + test("a local veil shows the spinner after its delay and keeps it for its minimum time", assert => { + Wicket.Veil.local('veilOuter', OPTIONS); + const outer = document.getElementById('veilOuter'); + + const attrs = send({ c: 'veilOuterLink' }); + const veil = veilOf(outer); + clock.tick(299); + assert.notOk(isBusy(veil), "the spinner showed before the delay"); + clock.tick(1); + assert.ok(isBusy(veil), "the spinner did not show after the delay"); + + done(attrs); + clock.tick(499); + assert.ok(veilOf(outer), "the spinner was removed before its minimum time"); + clock.tick(1); + assert.notOk(veilOf(outer), "the spinner stayed beyond its minimum time"); + }); + + test("nested local veils keep their own timings", assert => { + Wicket.Veil.local('veilOuter', OPTIONS); + Wicket.Veil.local('veilInner', { delay: 0, minimum: 1000 }); + const outer = document.getElementById('veilOuter'); + const inner = document.getElementById('veilInner'); + + const innerRequest = send({ c: 'veilInnerLink' }); + clock.tick(0); + assert.ok(isBusy(veilOf(inner)), "the inner spinner did not show at once"); + done(innerRequest); + clock.tick(999); + assert.ok(veilOf(inner), "the inner spinner was removed before its minimum time"); + clock.tick(1); + assert.notOk(veilOf(inner), "the inner spinner stayed beyond its minimum time"); + + const outerRequest = send({ c: 'veilOuterLink' }); + clock.tick(299); + assert.notOk(isBusy(veilOf(outer)), "the outer spinner used the inner delay"); + clock.tick(1); + assert.ok(isBusy(veilOf(outer)), "the outer spinner did not show after its delay"); + done(outerRequest); + }); + + test("nested local veils: the innermost takes the request", assert => { + Wicket.Veil.local('veilOuter', OPTIONS); + Wicket.Veil.local('veilInner', OPTIONS); + + const attrs = send({ c: 'veilInnerLink' }); + assert.ok(veilOf(document.getElementById('veilInner')), "the inner component was not veiled"); + assert.equal(veils().length, 1, "more than the innermost component was veiled"); + + done(attrs); + }); + + test("a request from outside a local veil falls back to the page veil", assert => { + Wicket.Veil.page(OPTIONS); + Wicket.Veil.local('veilInner', OPTIONS); + + const attrs = send({ c: 'veilOuterLink' }); + assert.ok(veilOf(document.body), "the page was not veiled"); + assert.notOk(veilOf(document.getElementById('veilInner')), "an unrelated component was veiled"); + + done(attrs); + }); + + test("a component replaced by an Ajax update is veiled again once re-registered", assert => { + Wicket.Veil.local('veilOuter', OPTIONS); + const old = document.getElementById('veilOuter'); + const replacement = old.cloneNode(true); + old.parentNode.replaceChild(replacement, old); + Wicket.Veil.local('veilOuter', OPTIONS); + + const attrs = send({ c: 'veilOuterLink' }); + assert.ok(veilOf(replacement), "the replacement component was not veiled"); + + done(attrs); + assert.equal(veils().length, 0, "the local veil stayed after the request finished"); + }); + + test("a pushed veil message raises a local veil, with its timings, until it is hidden", assert => { + Wicket.Veil.local('veilOuter', OPTIONS); + const outer = document.getElementById('veilOuter'); + + push('{"wicketVeil":"show","id":"veilOuter"}'); + const veil = veilOf(outer); + assert.ok(veil, "the push did not raise the local veil"); + assert.ok(outer.classList.contains('wicket-veil-host'), "the component is not marked as host"); + clock.tick(299); + assert.notOk(isBusy(veil), "the spinner showed before the delay"); + clock.tick(1); + assert.ok(isBusy(veil), "the spinner did not show after the delay"); + + Wicket.Veil.hide('veilOuter'); + clock.tick(499); + assert.ok(veilOf(outer), "the spinner was removed before its minimum time"); + clock.tick(1); + assert.equal(veils().length, 0, "the veil stayed after it was hidden"); + }); + + test("a pushed unveil message lowers the veil", assert => { + Wicket.Veil.local('veilOuter', OPTIONS); + + push('{"wicketVeil":"show","id":"veilOuter"}'); + push('{"wicketVeil":"hide","id":"veilOuter"}'); + + assert.equal(veils().length, 0, "the unveil message did not lower the veil"); + }); + + test("a veil whose component is replaced by the pushed update stays for the spinner's minimum time", assert => { + Wicket.Veil.local('veilOuter', OPTIONS); + + push('{"wicketVeil":"show","id":"veilOuter"}'); + clock.tick(400); + const old = document.getElementById('veilOuter'); + const replacement = old.cloneNode(false); + replacement.className = ''; + old.parentNode.replaceChild(replacement, old); + Wicket.Veil.local('veilOuter', OPTIONS); + Wicket.Veil.hide('veilOuter'); + + const veil = veilOf(replacement); + assert.ok(veil, "the veil did not move onto the new element"); + assert.ok(isBusy(veil), "the moved veil lost its spinner"); + assert.ok(replacement.classList.contains('wicket-veil-host'), "the new element is not marked as host"); + clock.tick(399); + assert.ok(veilOf(replacement), "the spinner was removed before its minimum time"); + clock.tick(1); + assert.equal(veils().length, 0, "the veil stayed beyond the spinner's minimum time"); + assert.notOk(replacement.classList.contains('wicket-veil-host'), "the host class stayed"); + }); + + test("other WebSocket messages, unknown ids and unmatched hides are ignored", assert => { + Wicket.Veil.page(OPTIONS); + Wicket.Veil.local('veilOuter', OPTIONS); + + push('hello'); + push('{"wicketVeil":'); + push('{"wicketVeil":"show","id":"veilGone"}'); + Wicket.Veil.hide('veilOuter'); + assert.equal(veils().length, 0, "a veil appeared"); + + const attrs = send({ c: 'veilOuterLink' }); + assert.ok(veilOf(document.getElementById('veilOuter')), + "an unmatched hide broke the next request's veil"); + done(attrs); + }); +}); diff --git a/wicket-extensions/src/test/js/veil.html b/wicket-extensions/src/test/js/veil.html new file mode 100644 index 0000000000..f9deee8f02 --- /dev/null +++ b/wicket-extensions/src/test/js/veil.html @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<!-- + 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. +--> +<html> + +<head> + <title id="titleId">Wicket.Veil JavaScript tests</title> + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> + <link rel="stylesheet" href="/wicket-core/src/test/js/qunit/qunit.css" type="text/css" media="screen" /> +</head> + +<body> + <div id="qunit"></div> + + <div id="qunit-fixture"> + <a id="veilPageLink" href="#page">page</a> + <div id="veilOuter"> + <a id="veilOuterLink" href="#outer">outer</a> + <div id="veilInner"> + <a id="veilInnerLink" href="#inner">inner</a> + </div> + </div> + </div> + + <script> + // version lies between question mark and first ampersand (or end). + // 'vanilla' (veil.html?vanilla) selects the jQuery-free wicket-ajax.js + // engine instead of the default JQuery + wicket-ajax-jquery.js combination + // (veil.html?4.0.0, veil.html?3.7.1, ...). + var version = location.search.match(/\?(.*?)(&|$)/)[1]; + var useVanilla = (version === 'vanilla'); + + if (!useVanilla) { + document.write("<scr"+"ipt src='/wicket-core/src/main/java/org/apache/wicket/resource/jquery/jquery-"+version+".js'></scr"+"ipt>"); + } + </script> + <script> + if (!useVanilla) { + document.write("<scr"+"ipt src='/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js'></scr"+"ipt>"); + } else { + document.write("<scr"+"ipt src='/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js'></scr"+"ipt>"); + } + </script> + <script type="text/javascript" src="/wicket-core/src/test/js/qunit/qunit.js"></script> + + <!-- the module under test --> + <script type="text/javascript" src="/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js"></script> + + <script type="text/javascript" src="veil-test.js"></script> +</body> +</html> diff --git a/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc new file mode 100644 index 0000000000..74f9383789 --- /dev/null +++ b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc @@ -0,0 +1,104 @@ + +An activity indicator tells the user that an AJAX request is running, but it does not stop them from clicking again while it runs. A second click on a slow button can send the same request twice, and a click elsewhere can act on a page the pending response is about to replace. Package _org.apache.wicket.extensions.ajax.veil_ addresses this with two behaviors that put a veil over the page, or over a part of it, for the duration of an AJAX request. + +The veil appears as soon as a request is sent. It is transparent and swallows mouse clicks, so a request that is over quickly does not make the page flash. Only if the request is still running after a delay (300 ms by default) does the veil dim its region and show a spinner. Once shown, the spinner stays for a minimum time (500 ms by default), so a response that arrives just after it appeared does not make it flicker. The veil does not intercept the keyboard. + +=== Veiling the whole page + +_PageVeilBehavior_ veils the whole page during every AJAX request fired from it. It can only be added to a page, typically a base page shared by the whole application: + +[source,java] +---- +public class BasePage extends WebPage { + public BasePage() { + add(new PageVeilBehavior()); + } +} +---- + +Adding it to any other component throws an _IllegalArgumentException_. + +=== Veiling a single component + +_LocalVeilBehavior_ veils only the component it is added to, and only during the AJAX requests fired by that component or by a component nested in it. The rest of the page stays usable: + +[source,java] +---- +WebMarkupContainer searchPanel = new WebMarkupContainer("search"); +searchPanel.add(new LocalVeilBehavior()); +---- + +A request fired from inside a component with a local veil does not veil the page, even when the page has a _PageVeilBehavior_. When local veils are nested, the innermost one takes the request. The behavior makes the component output its markup id, and it appends the veil to the component's element, so the component has to render an element that can hold a _div_. + +=== Veiling a component during a WebSocket push + +A component updated through a WebSocket push is recomputed on the server, without any AJAX request the browser could notice. _LocalVeilBehavior_ lets the server raise the veil itself: _getVeilMessage()_ returns a text message that raises the veil when it is sent through a WebSocket connection of the component's page, and _unveil(IPartialPageRequestHandler)_ lowers it once the pushed update has been applied. The spinner follows the same timings as for an AJAX request. + +[source,java] +---- +LocalVeilBehavior veil = new LocalVeilBehavior(); +counterPanel.add(veil); + +// while handling a request, e.g. the click that schedules the work +String veilMessage = veil.getVeilMessage(); + +// in the background thread, when the work starts +connection.sendMessage(veilMessage); +// ... compute ... +connection.sendMessage(new CounterUpdate(value)); + +// in onEvent(), for the WebSocketPushPayload carrying the CounterUpdate +handler.add(counterPanel); +veil.unveil(handler); +---- + +_getVeilMessage()_ reads the component's markup id, so like any component access it is called while handling a request; the message is a plain string that can then be sent from any thread. When the work fails and no update follows, send _getUnveilMessage()_ instead. If the update replaces the component while its spinner is still within its minimum time, the veil moves onto the new element and stays for the rest of that time. + +=== Leaving a request unveiled + +Every AJAX request is veiled, including the ones the user did not start, such as the requests of an _AbstractAjaxTimerBehavior_ or of a lazy loading panel. A request that should leave the page usable opts out with the static method _PageVeilBehavior.noVeil(AjaxRequestAttributes)_, called from the _updateAjaxAttributes_ method of its component or behavior: + +[source,java] +---- +add(new AjaxLink<Void>("refresh") { + @Override + protected void updateAjaxAttributes(AjaxRequestAttributes attributes) { + super.updateAjaxAttributes(attributes); + PageVeilBehavior.noVeil(attributes); + } + + @Override + public void onClick(AjaxRequestTarget target) { + // ... + } +}); +---- + +_noVeil_ adds the extra parameter `wicket_nb` (`PageVeilBehavior.NO_VEIL_PARAMETER`) to the request. A request carrying it is left alone by the page veil and by every local veil, and the parameter reaches the server as well. + +=== Timings and appearance + +Both timings can be set on each behavior, for the page veil and for local veils alike, with _setSpinnerDelay(Duration)_ and _setMinimumSpinnerTime(Duration)_. A delay of _Duration.ZERO_ shows the spinner as soon as the request is sent. Nested local veils keep their own timings, so a part of the page that is known to be slow can show its spinner sooner than the rest: + +[source,java] +---- +WebMarkupContainer outer = new WebMarkupContainer("outer"); +outer.add(new LocalVeilBehavior()); +add(outer); + +WebMarkupContainer inner = new WebMarkupContainer("inner"); +inner.add(new LocalVeilBehavior() + .setSpinnerDelay(Duration.ofMillis(100)) + .setMinimumSpinnerTime(Duration.ofSeconds(1))); +outer.add(inner); +---- + +A request fired from inside _inner_ veils only _inner_, with its timings; one fired from elsewhere in _outer_ veils _outer_, inner panel included. Subclasses can also override _getSpinnerDelay()_ and _getMinimumSpinnerTime()_, both declared on the common base class _AbstractVeilBehavior_. + +The look comes from the stylesheet _wicket-veil.css_, contributed by the behaviors, and can be overridden with the following CSS classes: + +* _wicket-veil_: the veil itself, a _div_ covering the page (_position: fixed_) or the component (_position: absolute_). +* _wicket-veil-busy_: added to the veil once the spinner delay has passed. It gives the veil its background, and its `::after` pseudo-element draws the spinner. +* _wicket-veil-host_: added to the element of a component with a local veil while it is veiled. It gives the element _position: relative_, so the veil covers exactly that element. + +On the client side the veil is implemented by _Wicket.Veil_, which subscribes to the global AJAX topics '/ajax/call/beforeSend' and '/ajax/call/done' listed among the global AJAX call listeners later in this chapter. It works with the JQuery-based and with the plain JavaScript AJAX implementation alike. diff --git a/wicket-user-guide/src/main/asciidoc/single.adoc b/wicket-user-guide/src/main/asciidoc/single.adoc index 347547e44d..11c5056aa6 100644 --- a/wicket-user-guide/src/main/asciidoc/single.adoc +++ b/wicket-user-guide/src/main/asciidoc/single.adoc @@ -567,6 +567,10 @@ include::ajax/ajax_3.adoc[leveloffset=+1] include::ajax/ajax_4.adoc[leveloffset=+1] +=== Blocking the page while an AJAX request is running + +include::ajax/ajax_12.adoc[leveloffset=+1] + === AJAX request attributes and call listeners include::ajax/ajax_5.adoc[leveloffset=+1]
