You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@wicket.apache.org by re...@apache.org on 2026/10/02 20:45:09 UTC
(wicket) branch master updated (fa0b0a2d6c -> ee62ec8c01)
This is an automated email from the ASF dual-hosted git repository.
reiern70 pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/wicket.git
from fa0b0a2d6c Merge pull request #1617 from apache/fix-async-pagestore-test
new cdb40317e9 Add Ajax veil behaviors that block the page or a component during a request
new 6848a4fd59 Fix the Ajax veil for replaced, scrolled and positioned hosts and redirects
new f10ecbed58 Click through the veil like a user in the veil Selenium test
new b9a8243e30 Lower the Ajax veil on a redirect again, and leave core unchanged
new ee62ec8c01 Show the reviewed veil cases in the examples
The 5 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
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 | 90 ++++
.../wicket/examples/ajax/builtin/VeilPage.html | 115 +++++
.../wicket/examples/ajax/builtin/VeilPage.java | 255 ++++++++++
.../examples/ajax/builtin/VeilPage.properties | 17 +
.../examples/websocket/JSR356Application.java | 1 +
.../WebSocketVeilDemoPage.css} | 50 +-
.../examples/websocket/WebSocketVeilDemoPage.html | 24 +
.../examples/websocket/WebSocketVeilDemoPage.java | 334 +++++++++++++
.../websocket/WebSocketVeilDemoPage.properties | 17 +
.../ajax/builtin/VeilPageSeleniumTest.java | 545 +++++++++++++++++++++
wicket-extensions/pom.xml | 1 +
wicket-extensions/src/main/java/module-info.java | 2 +
.../extensions/ajax/veil/AbstractVeilBehavior.java | 152 ++++++
.../extensions/ajax/veil/LocalVeilBehavior.java | 160 ++++++
.../extensions/ajax/veil/PageVeilBehavior.java | 86 ++++
.../NoFilter.java => ajax/veil/wicket-veil.css} | 63 ++-
.../wicket/extensions/ajax/veil/wicket-veil.js | 374 ++++++++++++++
.../extensions/ajax/veil/VeilBehaviorTest.java | 269 ++++++++++
wicket-extensions/src/test/js/veil-test.js | 535 ++++++++++++++++++++
.../src/test/js/{trap-focus.html => veil.html} | 20 +-
.../src/main/asciidoc/ajax/ajax_12.adoc | 105 ++++
wicket-user-guide/src/main/asciidoc/single.adoc | 4 +
27 files changed, 3211 insertions(+), 54 deletions(-)
create mode 100644 wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.css
create mode 100644 wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.html
create mode 100644 wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.java
create mode 100644 wicket-examples/src/main/java/org/apache/wicket/examples/ajax/builtin/VeilPage.properties
copy wicket-examples/src/main/java/org/apache/wicket/examples/{csp/delayedVisible.css => websocket/WebSocketVeilDemoPage.css} (87%)
create mode 100644 wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html
create mode 100644 wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.java
create mode 100644 wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.properties
create mode 100644 wicket-examples/src/test/java/org/apache/wicket/examples/ajax/builtin/VeilPageSeleniumTest.java
create mode 100644 wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/AbstractVeilBehavior.java
create mode 100644 wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/LocalVeilBehavior.java
create mode 100644 wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/PageVeilBehavior.java
copy wicket-extensions/src/main/java/org/apache/wicket/extensions/{markup/html/repeater/data/table/filter/NoFilter.java => ajax/veil/wicket-veil.css} (53%)
create mode 100644 wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.js
create mode 100644 wicket-extensions/src/test/java/org/apache/wicket/extensions/ajax/veil/VeilBehaviorTest.java
create mode 100644 wicket-extensions/src/test/js/veil-test.js
copy wicket-extensions/src/test/js/{trap-focus.html => veil.html} (76%)
create mode 100644 wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
(wicket) 04/05: Lower the Ajax veil on a redirect again, and leave core unchanged
Posted by re...@apache.org.
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 b9a8243e304ba99bd75593f0a79993590fa3874b
Author: reiern70 <re...@gmail.com>
AuthorDate: Fri Oct 2 10:49:36 2026 -0500
Lower the Ajax veil on a redirect again, and leave core unchanged
The previous commit kept the veil up when the response redirected the
browser, and made both Ajax engines pass whether the call is
redirecting to /ajax/call/done for that. But a redirect does not always
leave the page: a redirect to a download, a mailto: link or a URL
answering 204, or a navigation the user cancels at a beforeunload
prompt, all keep the page, and the browser fires no event to tell. The
veil then never came down, and with a PageVeilBehavior on a base page
the whole page stayed blocked until it was reloaded. No timeout can tell
these cases from a slow page load.
The slow part the veil is there for, the server's work, is covered
either way, and the window between the response and the next page is
the one every Wicket application already has. The veil comes down at
/ajax/call/done for a redirect too, as before, and the change to the
Ajax engines, its tests and its documentation are reverted, so the veil
needs nothing from core.
See GitHub issue #1631.
---
.../wicket/ajax/res/js/wicket-ajax-jquery.js | 6 ++--
.../org/apache/wicket/ajax/res/js/wicket-ajax.js | 6 ++--
wicket-core/src/test/js/ajax.js | 40 ----------------------
.../extensions/ajax/veil/AbstractVeilBehavior.java | 5 ---
.../wicket/extensions/ajax/veil/wicket-veil.js | 15 ++------
wicket-extensions/src/test/js/veil-test.js | 22 ++----------
.../src/main/asciidoc/ajax/ajax_12.adoc | 2 --
.../src/main/asciidoc/ajax/ajax_6.adoc | 2 +-
8 files changed, 12 insertions(+), 86 deletions(-)
diff --git a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js
index 11540e767e..288dcd004c 100644
--- a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js
+++ b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js
@@ -742,7 +742,7 @@
self._executeHandlers(attrs.coh, attrs, jqXHR, textStatus);
we.publish(topic.AJAX_CALL_COMPLETE, attrs, jqXHR, textStatus);
- self.done(attrs, context.isRedirecting === true);
+ self.done(attrs);
return FunctionsExecuter.DONE;
}, self));
@@ -929,9 +929,9 @@
}, this));
},
- done: function (attrs, isRedirecting) {
+ done: function (attrs) {
this._executeHandlers(attrs.dh, attrs);
- Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs, isRedirecting === true);
+ Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs);
Wicket.channelManager.done(attrs.ch);
},
diff --git a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js
index d24e0a5f20..befb85810a 100644
--- a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js
+++ b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js
@@ -956,7 +956,7 @@
self._executeHandlers(attrs.coh, attrs, xhr, textStatus);
we.publish(topic.AJAX_CALL_COMPLETE, attrs, xhr, textStatus);
- self.done(attrs, context.isRedirecting === true);
+ self.done(attrs);
return FunctionsExecuter.DONE;
}).bind(self));
@@ -1143,9 +1143,9 @@
}).bind(this));
},
- done: function (attrs, isRedirecting) {
+ done: function (attrs) {
this._executeHandlers(attrs.dh, attrs);
- Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs, isRedirecting === true);
+ Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs);
Wicket.channelManager.done(attrs.ch);
},
diff --git a/wicket-core/src/test/js/ajax.js b/wicket-core/src/test/js/ajax.js
index 1937e3bbc4..8a173f6358 100644
--- a/wicket-core/src/test/js/ajax.js
+++ b/wicket-core/src/test/js/ajax.js
@@ -1327,46 +1327,6 @@ Wicket.Event.add(window, 'domready', function() {
Wicket.Event.remove(window, "event1");
});
- test('The done topic tells its subscribers that the response redirects.', assert => {
- const done = assert.async();
- assert.expect(1);
-
- var oldRedirect = Wicket.Ajax.redirect;
- Wicket.Ajax.redirect = function() {};
-
- Wicket.Event.subscribe('/ajax/call/done', function(jqEvent, attributes, isRedirecting) {
- assert.strictEqual(isRedirecting, true, 'Done: isRedirecting');
- Wicket.Event.unsubscribe();
- Wicket.Ajax.redirect = oldRedirect;
- done();
- });
-
- Wicket.Ajax.ajax({
- u: 'data/ajax/redirectAjaxResponse.xml',
- e: 'event1'
- });
- Wicket.Event.fire(window, "event1");
- Wicket.Event.remove(window, "event1");
- });
-
- test('The done topic tells its subscribers that the response does not redirect.', assert => {
- const done = assert.async();
- assert.expect(1);
-
- Wicket.Event.subscribe('/ajax/call/done', function(jqEvent, attributes, isRedirecting) {
- assert.strictEqual(isRedirecting, false, 'Done: isRedirecting');
- Wicket.Event.unsubscribe();
- done();
- });
-
- Wicket.Ajax.ajax({
- u: 'data/ajax/emptyAjaxResponse.xml',
- e: 'event1'
- });
- Wicket.Event.fire(window, "event1");
- Wicket.Event.remove(window, "event1");
- });
-
test('processAjaxResponse, normal HTTP case.', assert => {
const done = assert.async();
assert.expect(2);
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
index 5f197d0e23..8f77ebdb29 100644
--- 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
@@ -44,11 +44,6 @@ import org.apache.wicket.util.lang.Args;
* alike, and can be changed per behavior with {@link #setSpinnerDelay(Duration)} and
* {@link #setMinimumSpinnerTime(Duration)}. The veil does not intercept the keyboard.
* <p>
- * When the response redirects the browser to another page, the veil stays up until the browser
- * leaves the page, so the request cannot be sent again meanwhile. A redirect that does not leave
- * the page, such as one to a file download, leaves the veil up too; offer downloads through
- * {@link org.apache.wicket.extensions.ajax.AjaxDownloadBehavior} instead.
- * <p>
* The look comes from {@code wicket-veil.css} and can be overridden with the classes
* {@code wicket-veil}, {@code wicket-veil-busy}, {@code wicket-veil-host} and
* {@code wicket-veil-host-static}.
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
index 718c433dbb..ce14e34060 100644
--- 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
@@ -21,8 +21,7 @@
* 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 request whose response
- * redirects the browser keeps its veil until the page is left.
+ * 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,
@@ -247,9 +246,9 @@
acquire(target);
}
- function onDone(jqEvent, attrs, isRedirecting) {
+ function onDone(jqEvent, attrs) {
const target = attrs && attrs.wicketVeil;
- if (!target || isRedirecting === true) {
+ if (!target) {
return;
}
delete attrs.wicketVeil;
@@ -262,13 +261,6 @@
}
}
- function onPageShow(event) {
- // a page restored from the back-forward cache may still carry the veil of a redirect
- if (event.persisted) {
- lowerAll();
- }
- }
-
function onWebSocketMessage(jqEvent, message) {
if (typeof(message) !== "string" || message.indexOf(MESSAGE_PREFIX) !== 0) {
return;
@@ -293,7 +285,6 @@
Wicket.Event.subscribe(Wicket.Event.Topic.AJAX_CALL_DONE, onDone);
Wicket.Event.subscribe(Wicket.Event.Topic.DOM_NODE_ADDED, onDomNodeAdded);
Wicket.Event.subscribe(WEBSOCKET_MESSAGE_TOPIC, onWebSocketMessage);
- window.addEventListener('pageshow', onPageShow);
}
}
diff --git a/wicket-extensions/src/test/js/veil-test.js b/wicket-extensions/src/test/js/veil-test.js
index 7b9cc6a5fd..7420b03ad9 100644
--- a/wicket-extensions/src/test/js/veil-test.js
+++ b/wicket-extensions/src/test/js/veil-test.js
@@ -71,8 +71,8 @@ Wicket.Event.add(window, 'domready', function() {
return attrs;
}
- function done(attrs, isRedirecting) {
- Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs, isRedirecting === true);
+ function done(attrs) {
+ Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs);
}
function push(message) {
@@ -504,24 +504,6 @@ Wicket.Event.add(window, 'domready', function() {
assert.equal(veils().length, 0, "the veil stayed after it was hidden");
});
- test("a response redirecting the browser keeps the veil until the page is left", assert => {
- Wicket.Veil.page(OPTIONS);
-
- const attrs = send({ c: 'veilPageLink' });
- done(attrs, true);
- clock.tick(1000);
- assert.ok(veilOf(document.body), "the veil came down although the browser is leaving the page");
- assert.ok(isBusy(veilOf(document.body)), "the spinner did not show while the browser is leaving the page");
-
- window.dispatchEvent(new window.PageTransitionEvent('pageshow', { persisted: true }));
- assert.equal(veils().length, 0, "the veil stayed on the page restored from the back-forward cache");
-
- const next = send({ c: 'veilPageLink' });
- assert.ok(veilOf(document.body), "the next request was not veiled");
- done(next);
- assert.equal(veils().length, 0, "the veil stayed after the next request finished");
- });
-
test("an unmatched hide leaves the veil of a running request alone", assert => {
Wicket.Veil.local('veilOuter', OPTIONS);
const outer = document.getElementById('veilOuter');
diff --git a/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
index baec6c6e92..23bb4a5bf0 100644
--- a/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
+++ b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
@@ -18,8 +18,6 @@ public class BasePage extends WebPage {
Adding it to any other component throws an _IllegalArgumentException_.
-When the response redirects the browser to another page, for example after _setResponsePage()_, the veil stays up until the browser leaves the page, so a second click cannot send the request again in the meantime. A redirect that does not leave the page, such as one to a file download, leaves the veil up as well; offer downloads through _AjaxDownloadBehavior_ instead.
-
=== 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:
diff --git a/wicket-user-guide/src/main/asciidoc/ajax/ajax_6.adoc b/wicket-user-guide/src/main/asciidoc/ajax/ajax_6.adoc
index e7d964fb18..a940d01aeb 100644
--- a/wicket-user-guide/src/main/asciidoc/ajax/ajax_6.adoc
+++ b/wicket-user-guide/src/main/asciidoc/ajax/ajax_6.adoc
@@ -153,7 +153,7 @@ Global AJAX call events are handled with JavaScript. We can register a callback
* '/ajax/call/success': called if the AJAX call has successfully returned.
* '/ajax/call/failure': called if the AJAX call has returned with a failure.
* '/ajax/call/complete': called when the AJAX call has completed.
-* '/ajax/call/done': called when the AJAX call is done. Its callback gets, after attrs, a boolean telling whether the response is redirecting the browser to another page, in which case the page is about to be left and anything blocking it can stay up.
+* '/ajax/call/done': called when the AJAX call is done.
* '/dom/node/removing': called when a component is about to be removed via AJAX. This happens when component markup is updated via AJAX (i.e. the component itself or one of its containers has been added to _AjaxRequestTarget_)
* '/dom/node/added': called when a component has been added via AJAX. Just like '/dom/node/removing', this event is triggered when a component is added to _AjaxRequestTarget_.
(wicket) 02/05: Fix the Ajax veil for replaced, scrolled and positioned hosts and redirects
Posted by re...@apache.org.
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 6848a4fd598098619bb4177fb7dd6851f51ef78a
Author: reiern70 <re...@gmail.com>
AuthorDate: Fri Oct 2 09:57:57 2026 -0500
Fix the Ajax veil for replaced, scrolled and positioned hosts and redirects
Review of the veil behaviors (GitHub issue #1631) found cases where the
veil did not hold, or disturbed the page it covers:
- A host replaced while still veiled, by a pushed progress update or by
a request on another channel, lost its veil until the request ended.
The veil now listens to /dom/node/added and moves onto the new element
at once.
- On an Ajax redirect /ajax/call/done still fired, so the veil came down
and a second click could send the request again before the next page
arrived. Core keeps its activity indicator up in that case, but done
subscribers could not tell. Both Ajax engines now pass whether the
call is redirecting as an extra argument of /ajax/call/done, and the
veil stays up until the page is left. It is lowered when the page is
restored from the back-forward cache.
- Wicket.Veil.hide() shared one counter with Ajax requests, so a hide
whose show never arrived lowered the veil of a running request. Server
raises are counted on their own now.
- The veil was chosen from attrs.c, which for a delegated behavior is
the container, not the element clicked. It now starts from the event
target, as core does, and falls back to attrs.c when that element is
gone.
- Local veils were kept in a plain object, so a markup id such as
"constructor" resolved to an Object member. They are kept in a Map.
- In a scrolling host the veil scrolled away with the content. It is now
kept over the visible part while the host scrolls.
- The host was forced to position: relative, so an absolute, fixed or
sticky host jumped on every request. Only a static host gets it now,
through the new class wicket-veil-host-static. wicket-veil-host isolates
the host instead, so the veil no longer paints over a sticky header or
a modal dialog in front of it.
- The options script was formatted with the default locale, which can
localize digits and break the script. It uses Locale.ROOT.
An application subscribing to /ajax/call/done sees no difference unless
its callback reads the new argument, a boolean that follows attrs.
Wicket.Ajax.Call.done() takes it as an optional second parameter.
---
.../wicket/ajax/res/js/wicket-ajax-jquery.js | 6 +-
.../org/apache/wicket/ajax/res/js/wicket-ajax.js | 6 +-
wicket-core/src/test/js/ajax.js | 40 +++++
.../extensions/ajax/veil/AbstractVeilBehavior.java | 13 +-
.../extensions/ajax/veil/LocalVeilBehavior.java | 11 +-
.../wicket/extensions/ajax/veil/wicket-veil.css | 4 +
.../wicket/extensions/ajax/veil/wicket-veil.js | 133 +++++++++++-----
.../extensions/ajax/veil/VeilBehaviorTest.java | 20 +++
wicket-extensions/src/test/js/veil-test.js | 169 ++++++++++++++++++++-
wicket-extensions/src/test/js/veil.html | 1 +
.../src/main/asciidoc/ajax/ajax_12.adoc | 9 +-
.../src/main/asciidoc/ajax/ajax_6.adoc | 2 +-
12 files changed, 358 insertions(+), 56 deletions(-)
diff --git a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js
index 288dcd004c..11540e767e 100644
--- a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js
+++ b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax-jquery.js
@@ -742,7 +742,7 @@
self._executeHandlers(attrs.coh, attrs, jqXHR, textStatus);
we.publish(topic.AJAX_CALL_COMPLETE, attrs, jqXHR, textStatus);
- self.done(attrs);
+ self.done(attrs, context.isRedirecting === true);
return FunctionsExecuter.DONE;
}, self));
@@ -929,9 +929,9 @@
}, this));
},
- done: function (attrs) {
+ done: function (attrs, isRedirecting) {
this._executeHandlers(attrs.dh, attrs);
- Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs);
+ Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs, isRedirecting === true);
Wicket.channelManager.done(attrs.ch);
},
diff --git a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js
index befb85810a..d24e0a5f20 100644
--- a/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js
+++ b/wicket-core/src/main/java/org/apache/wicket/ajax/res/js/wicket-ajax.js
@@ -956,7 +956,7 @@
self._executeHandlers(attrs.coh, attrs, xhr, textStatus);
we.publish(topic.AJAX_CALL_COMPLETE, attrs, xhr, textStatus);
- self.done(attrs);
+ self.done(attrs, context.isRedirecting === true);
return FunctionsExecuter.DONE;
}).bind(self));
@@ -1143,9 +1143,9 @@
}).bind(this));
},
- done: function (attrs) {
+ done: function (attrs, isRedirecting) {
this._executeHandlers(attrs.dh, attrs);
- Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs);
+ Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs, isRedirecting === true);
Wicket.channelManager.done(attrs.ch);
},
diff --git a/wicket-core/src/test/js/ajax.js b/wicket-core/src/test/js/ajax.js
index 8a173f6358..1937e3bbc4 100644
--- a/wicket-core/src/test/js/ajax.js
+++ b/wicket-core/src/test/js/ajax.js
@@ -1327,6 +1327,46 @@ Wicket.Event.add(window, 'domready', function() {
Wicket.Event.remove(window, "event1");
});
+ test('The done topic tells its subscribers that the response redirects.', assert => {
+ const done = assert.async();
+ assert.expect(1);
+
+ var oldRedirect = Wicket.Ajax.redirect;
+ Wicket.Ajax.redirect = function() {};
+
+ Wicket.Event.subscribe('/ajax/call/done', function(jqEvent, attributes, isRedirecting) {
+ assert.strictEqual(isRedirecting, true, 'Done: isRedirecting');
+ Wicket.Event.unsubscribe();
+ Wicket.Ajax.redirect = oldRedirect;
+ done();
+ });
+
+ Wicket.Ajax.ajax({
+ u: 'data/ajax/redirectAjaxResponse.xml',
+ e: 'event1'
+ });
+ Wicket.Event.fire(window, "event1");
+ Wicket.Event.remove(window, "event1");
+ });
+
+ test('The done topic tells its subscribers that the response does not redirect.', assert => {
+ const done = assert.async();
+ assert.expect(1);
+
+ Wicket.Event.subscribe('/ajax/call/done', function(jqEvent, attributes, isRedirecting) {
+ assert.strictEqual(isRedirecting, false, 'Done: isRedirecting');
+ Wicket.Event.unsubscribe();
+ done();
+ });
+
+ Wicket.Ajax.ajax({
+ u: 'data/ajax/emptyAjaxResponse.xml',
+ e: 'event1'
+ });
+ Wicket.Event.fire(window, "event1");
+ Wicket.Event.remove(window, "event1");
+ });
+
test('processAjaxResponse, normal HTTP case.', assert => {
const done = assert.async();
assert.expect(2);
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
index 1acf840c5a..5f197d0e23 100644
--- 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
@@ -17,6 +17,7 @@
package org.apache.wicket.extensions.ajax.veil;
import java.time.Duration;
+import java.util.Locale;
import org.apache.wicket.Component;
import org.apache.wicket.behavior.Behavior;
@@ -43,8 +44,14 @@ import org.apache.wicket.util.lang.Args;
* alike, and can be changed per behavior with {@link #setSpinnerDelay(Duration)} and
* {@link #setMinimumSpinnerTime(Duration)}. The veil does not intercept the keyboard.
* <p>
+ * When the response redirects the browser to another page, the veil stays up until the browser
+ * leaves the page, so the request cannot be sent again meanwhile. A redirect that does not leave
+ * the page, such as one to a file download, leaves the veil up too; offer downloads through
+ * {@link org.apache.wicket.extensions.ajax.AjaxDownloadBehavior} instead.
+ * <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}.
+ * {@code wicket-veil}, {@code wicket-veil-busy}, {@code wicket-veil-host} and
+ * {@code wicket-veil-host-static}.
* <p>
* A request is left unveiled when it carries the extra parameter
* {@value PageVeilBehavior#NO_VEIL_PARAMETER}, see {@link PageVeilBehavior#noVeil}.
@@ -144,7 +151,7 @@ public abstract class AbstractVeilBehavior extends Behavior
*/
protected final String getOptions()
{
- return String.format("{\"delay\":%d,\"minimum\":%d}", getSpinnerDelay().toMillis(),
- getMinimumSpinnerTime().toMillis());
+ return String.format(Locale.ROOT, "{\"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
index cbd0854aa8..197c96e381 100644
--- 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
@@ -30,10 +30,13 @@ import com.github.openjson.JSONObject;
* 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.
+ * The veil is appended to the component's element, so the component has to render an element
+ * that can hold a {@code div}. For the duration, the element gets the class
+ * {@code wicket-veil-host}, which isolates it so the veil stays within its stacking context, and,
+ * unless it is positioned already, {@code wicket-veil-host-static} with
+ * {@code position: relative}. When the element scrolls, the veil covers its visible part. If an
+ * Ajax update or a push replaces the element while it is veiled, the veil moves onto the new
+ * element. 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
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
index 6736ec668e..c545ca40d1 100644
--- 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
@@ -27,6 +27,10 @@
}
.wicket-veil-host {
+ isolation: isolate;
+}
+
+.wicket-veil-host-static {
position: relative;
}
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
index c207dfe3f9..718c433dbb 100644
--- 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
@@ -21,7 +21,8 @@
* 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 request carrying the extra parameter 'wicket_nb' is never veiled. A request whose response
+ * redirects the browser keeps its veil until the page is left.
*
* 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,
@@ -38,25 +39,42 @@
const VEIL_CLASS = 'wicket-veil';
const BUSY_CLASS = 'wicket-veil-busy';
const HOST_CLASS = 'wicket-veil-host';
+ const STATIC_HOST_CLASS = 'wicket-veil-host-static';
const WEBSOCKET_MESSAGE_TOPIC = '/websocket/message';
const MESSAGE_PREFIX = '{"wicketVeil"';
let pageTarget = null;
- let localTargets = {};
+ const localTargets = new Map();
let subscribed = false;
+ // the veil scrolls with the content of its host, so it is moved back over the visible part
+ function follow(target) {
+ const style = target.veil.style;
+ const top = target.host.scrollTop;
+ const left = target.host.scrollLeft;
+ style.top = top ? top + 'px' : '';
+ style.bottom = top ? -top + 'px' : '';
+ style.left = left ? left + 'px' : '';
+ style.right = left ? -left + 'px' : '';
+ }
+
function createTarget(id, options) {
- return {
+ const target = {
id: id,
delay: options.delay,
minimum: options.minimum,
count: 0,
+ raised: 0,
host: null,
veil: null,
shownAt: -1,
spinnerTimer: null,
hideTimer: null
};
+ target.onScroll = function () {
+ follow(target);
+ };
+ return target;
}
function configure(target, options) {
@@ -76,24 +94,43 @@
}
function findTarget(attrs) {
- let node = attrs.c ? document.getElementById(attrs.c) : null;
+ let node = attrs.event && attrs.event.target;
+ if (!node || !node.isConnected) {
+ node = typeof(attrs.c) === "string" ? document.getElementById(attrs.c) : null;
+ }
for (; node && node !== document; node = node.parentNode) {
- if (node.id && localTargets[node.id]) {
- return localTargets[node.id];
+ const target = node.id && localTargets.get(node.id);
+ if (target) {
+ return target;
}
}
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];
+ for (const [id, target] of localTargets) {
+ if (target.count === 0 && !document.getElementById(id)) {
+ localTargets.delete(id);
}
}
}
+ function attach(target, host) {
+ host.classList.add(HOST_CLASS);
+ if (getComputedStyle(host).position === 'static') {
+ host.classList.add(STATIC_HOST_CLASS);
+ }
+ host.appendChild(target.veil);
+ host.addEventListener('scroll', target.onScroll);
+ target.host = host;
+ follow(target);
+ }
+
+ function detach(target) {
+ target.host.classList.remove(HOST_CLASS, STATIC_HOST_CLASS);
+ target.host.removeEventListener('scroll', target.onScroll);
+ }
+
function hide(target) {
const clock = Wicket.Veil._clock;
clock.clearTimeout(target.spinnerTimer);
@@ -105,7 +142,7 @@
target.veil.parentNode.removeChild(target.veil);
}
if (target.host && target !== pageTarget) {
- target.host.classList.remove(HOST_CLASS);
+ detach(target);
}
target.veil = null;
target.host = null;
@@ -123,15 +160,15 @@
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;
+ if (target === pageTarget) {
+ document.body.appendChild(veil);
+ target.host = document.body;
+ } else {
+ attach(target, document.getElementById(target.id));
+ }
target.spinnerTimer = clock.setTimeout(function () {
target.spinnerTimer = null;
@@ -146,9 +183,8 @@
}
const host = document.getElementById(target.id);
if (host) {
- host.classList.add(HOST_CLASS);
- host.appendChild(target.veil);
- target.host = host;
+ detach(target);
+ attach(target, host);
}
}
@@ -186,6 +222,18 @@
}
}
+ function lowerAll() {
+ const targets = Array.from(localTargets.values());
+ if (pageTarget !== null) {
+ targets.push(pageTarget);
+ }
+ targets.forEach(function (target) {
+ target.count = 0;
+ target.raised = 0;
+ hide(target);
+ });
+ }
+
function onBeforeSend(jqEvent, attrs) {
if (!attrs || isOptedOut(attrs)) {
return;
@@ -199,15 +247,28 @@
acquire(target);
}
- function onDone(jqEvent, attrs) {
+ function onDone(jqEvent, attrs, isRedirecting) {
const target = attrs && attrs.wicketVeil;
- if (!target) {
+ if (!target || isRedirecting === true) {
return;
}
delete attrs.wicketVeil;
releaseOne(target);
}
+ function onDomNodeAdded() {
+ for (const target of localTargets.values()) {
+ reattach(target);
+ }
+ }
+
+ function onPageShow(event) {
+ // a page restored from the back-forward cache may still carry the veil of a redirect
+ if (event.persisted) {
+ lowerAll();
+ }
+ }
+
function onWebSocketMessage(jqEvent, message) {
if (typeof(message) !== "string" || message.indexOf(MESSAGE_PREFIX) !== 0) {
return;
@@ -230,7 +291,9 @@
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(Wicket.Event.Topic.DOM_NODE_ADDED, onDomNodeAdded);
Wicket.Event.subscribe(WEBSOCKET_MESSAGE_TOPIC, onWebSocketMessage);
+ window.addEventListener('pageshow', onPageShow);
}
}
@@ -260,11 +323,11 @@
*/
local: function (id, options) {
subscribe();
- const target = localTargets[id];
+ const target = localTargets.get(id);
if (target) {
configure(target, options);
} else {
- localTargets[id] = createTarget(id, options);
+ localTargets.set(id, createTarget(id, options));
}
},
@@ -275,21 +338,24 @@
* @param id {String} - the markup id of a component with a local veil
*/
show: function (id) {
- const target = localTargets[id];
+ const target = localTargets.get(id);
if (target && document.getElementById(id)) {
+ target.raised++;
acquire(target);
}
},
/**
* Lowers the local veil raised by show(), respecting the spinner's minimum time. Calls
- * without a matching show() are ignored.
+ * without a matching show() are ignored, and leave the veil of a running Ajax request
+ * alone.
*
* @param id {String} - the markup id of a component with a local veil
*/
hide: function (id) {
- const target = localTargets[id];
- if (target) {
+ const target = localTargets.get(id);
+ if (target && target.raised > 0) {
+ target.raised--;
releaseOne(target);
}
},
@@ -309,16 +375,9 @@
},
_reset: function () {
- if (pageTarget !== null) {
- hide(pageTarget);
- }
- for (const id in localTargets) {
- if (Object.prototype.hasOwnProperty.call(localTargets, id)) {
- hide(localTargets[id]);
- }
- }
+ lowerAll();
pageTarget = null;
- localTargets = {};
+ localTargets.clear();
}
};
})();
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
index f714e49d2f..d4fba2d224 100644
--- 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
@@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
+import java.util.Locale;
import org.apache.wicket.MarkupContainer;
import org.apache.wicket.ajax.AjaxRequestTarget;
@@ -105,6 +106,25 @@ class VeilBehaviorTest extends WicketTestCase
"\", {\"delay\":0,\"minimum\":2000});"), response);
}
+ @Test
+ void timingsDoNotDependOnTheDefaultLocale()
+ {
+ Locale defaultLocale = Locale.getDefault();
+ Locale.setDefault(Locale.forLanguageTag("th-TH-u-nu-thai"));
+ try
+ {
+ tester.startPage(new TestPage(new PageVeilBehavior(), null, false));
+ }
+ finally
+ {
+ Locale.setDefault(defaultLocale);
+ }
+
+ String response = tester.getLastResponseAsString();
+ assertTrue(response.contains("Wicket.Veil.page({\"delay\":300,\"minimum\":500});"),
+ response);
+ }
+
@Test
void negativeTimingsAreRejected()
{
diff --git a/wicket-extensions/src/test/js/veil-test.js b/wicket-extensions/src/test/js/veil-test.js
index 57e342d822..7b9cc6a5fd 100644
--- a/wicket-extensions/src/test/js/veil-test.js
+++ b/wicket-extensions/src/test/js/veil-test.js
@@ -71,8 +71,8 @@ Wicket.Event.add(window, 'domready', function() {
return attrs;
}
- function done(attrs) {
- Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs);
+ function done(attrs, isRedirecting) {
+ Wicket.Event.publish(Wicket.Event.Topic.AJAX_CALL_DONE, attrs, isRedirecting === true);
}
function push(message) {
@@ -239,6 +239,66 @@ Wicket.Event.add(window, 'domready', function() {
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");
+ assert.notOk(outer.classList.contains('wicket-veil-host-static'), "the static host class stayed");
+ });
+
+ test("a static host is positioned and isolated while it is veiled", assert => {
+ Wicket.Veil.local('veilOuter', OPTIONS);
+ const outer = document.getElementById('veilOuter');
+
+ const attrs = send({ c: 'veilOuterLink' });
+ assert.ok(outer.classList.contains('wicket-veil-host-static'), "the static host is not marked");
+ assert.equal(getComputedStyle(outer).position, 'relative', "the static host is not positioned");
+ assert.equal(getComputedStyle(outer).isolation, 'isolate', "the host is not isolated");
+
+ done(attrs);
+ assert.equal(getComputedStyle(outer).position, 'static', "the host stayed positioned");
+ });
+
+ test("a host positioned by a stylesheet rule keeps its position", assert => {
+ const rule = document.createElement('style');
+ rule.textContent = '.veil-test-absolute { position: absolute; }';
+ document.head.insertBefore(rule, document.head.firstChild);
+ Wicket.Veil.local('veilOuter', OPTIONS);
+ const outer = document.getElementById('veilOuter');
+ outer.classList.add('veil-test-absolute');
+
+ const attrs = send({ c: 'veilOuterLink' });
+ assert.notOk(outer.classList.contains('wicket-veil-host-static'), "the positioned host was marked static");
+ assert.equal(getComputedStyle(outer).position, 'absolute', "the host lost its position");
+
+ done(attrs);
+ rule.remove();
+ });
+
+ test("a local veil covers the visible part of a scrolled host", assert => {
+ Wicket.Veil.local('veilOuter', OPTIONS);
+ const outer = document.getElementById('veilOuter');
+ outer.style.height = '40px';
+ outer.style.overflow = 'auto';
+ const filler = document.createElement('div');
+ filler.style.height = '400px';
+ outer.appendChild(filler);
+ outer.scrollTop = 100;
+
+ const attrs = send({ c: 'veilOuterLink' });
+ const veil = veilOf(outer);
+ const covers = function () {
+ const veilBox = veil.getBoundingClientRect();
+ const hostBox = outer.getBoundingClientRect();
+ return Math.round(veilBox.top) === Math.round(hostBox.top + outer.clientTop) &&
+ Math.round(veilBox.height) === outer.clientHeight;
+ };
+ assert.ok(covers(), "the veil does not cover the visible part of the host");
+
+ outer.scrollTop = 150;
+ outer.dispatchEvent(new Event('scroll'));
+ assert.ok(covers(), "the veil did not follow the host's scrolling");
+
+ done(attrs);
+ outer.scrollTop = 0;
+ outer.dispatchEvent(new Event('scroll'));
+ assert.equal(veils().length, 0, "the local veil stayed after the request finished");
});
test("a local veil shows the spinner after its delay and keeps it for its minimum time", assert => {
@@ -293,6 +353,44 @@ Wicket.Event.add(window, 'domready', function() {
done(attrs);
});
+ test("a delegated request is taken by the innermost veil around the element that was clicked", assert => {
+ Wicket.Veil.page(OPTIONS);
+ Wicket.Veil.local('veilOuter', OPTIONS);
+ Wicket.Veil.local('veilInner', OPTIONS);
+
+ const attrs = send({ c: 'veilOuter', event: { target: document.getElementById('veilInnerLink') } });
+ assert.ok(veilOf(document.getElementById('veilInner')), "the veil around the clicked element was not raised");
+ assert.equal(veils().length, 1, "more than the innermost veil was raised");
+
+ done(attrs);
+ });
+
+ test("a request whose clicked element is gone is taken by the veil around its component", assert => {
+ Wicket.Veil.local('veilOuter', OPTIONS);
+ Wicket.Veil.local('veilInner', OPTIONS);
+
+ const attrs = send({ c: 'veilOuterLink', event: { target: document.createElement('a') } });
+ assert.ok(veilOf(document.getElementById('veilOuter')), "the component's veil was not raised");
+ assert.equal(veils().length, 1, "more than the component's veil was raised");
+
+ done(attrs);
+ });
+
+ test("markup ids naming Object members do not break the veils", assert => {
+ Wicket.Veil.page(OPTIONS);
+ Wicket.Veil.local('constructor', OPTIONS);
+ const link = document.createElement('a');
+ link.id = 'toString';
+ document.getElementById('qunit-fixture').appendChild(link);
+
+ const attrs = send({ c: 'toString' });
+ assert.ok(veilOf(document.body), "the page was not veiled");
+ assert.strictEqual(Object.delay, undefined, "the options were written onto Object");
+
+ done(attrs);
+ assert.equal(veils().length, 0, "the veil stayed after the request finished");
+ });
+
test("a request from outside a local veil falls back to the page veil", assert => {
Wicket.Veil.page(OPTIONS);
Wicket.Veil.local('veilInner', OPTIONS);
@@ -370,6 +468,73 @@ Wicket.Event.add(window, 'domready', function() {
assert.notOk(replacement.classList.contains('wicket-veil-host'), "the host class stayed");
});
+ test("a veil whose component is replaced during the request moves onto the new element at once", assert => {
+ Wicket.Veil.local('veilOuter', OPTIONS);
+
+ const attrs = send({ c: 'veilOuterLink' });
+ clock.tick(300);
+ Wicket.DOM.replace(document.getElementById('veilOuter'),
+ '<div id="veilOuter"><a id="veilOuterLink" href="#outer">outer</a></div>');
+ const replacement = document.getElementById('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");
+
+ done(attrs);
+ clock.tick(500);
+ assert.equal(veils().length, 0, "the veil stayed beyond the spinner's minimum time");
+ });
+
+ test("a pushed veil follows its component when a progress update replaces it", assert => {
+ Wicket.Veil.local('veilOuter', OPTIONS);
+
+ push('{"wicketVeil":"show","id":"veilOuter"}');
+ Wicket.DOM.replace(document.getElementById('veilOuter'),
+ '<div id="veilOuter"><a id="veilOuterLink" href="#outer">outer</a></div>');
+ clock.tick(300);
+
+ const veil = veilOf(document.getElementById('veilOuter'));
+ assert.ok(veil, "the veil did not move onto the new element");
+ assert.ok(isBusy(veil), "the spinner was not shown on the moved veil");
+
+ Wicket.Veil.hide('veilOuter');
+ clock.tick(500);
+ assert.equal(veils().length, 0, "the veil stayed after it was hidden");
+ });
+
+ test("a response redirecting the browser keeps the veil until the page is left", assert => {
+ Wicket.Veil.page(OPTIONS);
+
+ const attrs = send({ c: 'veilPageLink' });
+ done(attrs, true);
+ clock.tick(1000);
+ assert.ok(veilOf(document.body), "the veil came down although the browser is leaving the page");
+ assert.ok(isBusy(veilOf(document.body)), "the spinner did not show while the browser is leaving the page");
+
+ window.dispatchEvent(new window.PageTransitionEvent('pageshow', { persisted: true }));
+ assert.equal(veils().length, 0, "the veil stayed on the page restored from the back-forward cache");
+
+ const next = send({ c: 'veilPageLink' });
+ assert.ok(veilOf(document.body), "the next request was not veiled");
+ done(next);
+ assert.equal(veils().length, 0, "the veil stayed after the next request finished");
+ });
+
+ test("an unmatched hide leaves the veil of a running request alone", assert => {
+ Wicket.Veil.local('veilOuter', OPTIONS);
+ const outer = document.getElementById('veilOuter');
+
+ const attrs = send({ c: 'veilOuterLink' });
+ Wicket.Veil.hide('veilOuter');
+ push('{"wicketVeil":"hide","id":"veilOuter"}');
+ assert.ok(veilOf(outer), "an unmatched hide lowered the veil of a running request");
+
+ done(attrs);
+ assert.equal(veils().length, 0, "the veil stayed after the request finished");
+ });
+
test("other WebSocket messages, unknown ids and unmatched hides are ignored", assert => {
Wicket.Veil.page(OPTIONS);
Wicket.Veil.local('veilOuter', OPTIONS);
diff --git a/wicket-extensions/src/test/js/veil.html b/wicket-extensions/src/test/js/veil.html
index f9deee8f02..4510d13148 100644
--- a/wicket-extensions/src/test/js/veil.html
+++ b/wicket-extensions/src/test/js/veil.html
@@ -21,6 +21,7 @@
<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" />
+ <link rel="stylesheet" href="/wicket-extensions/src/main/java/org/apache/wicket/extensions/ajax/veil/wicket-veil.css" type="text/css" />
</head>
<body>
diff --git a/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
index 74f9383789..baec6c6e92 100644
--- a/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
+++ b/wicket-user-guide/src/main/asciidoc/ajax/ajax_12.adoc
@@ -18,6 +18,8 @@ public class BasePage extends WebPage {
Adding it to any other component throws an _IllegalArgumentException_.
+When the response redirects the browser to another page, for example after _setResponsePage()_, the veil stays up until the browser leaves the page, so a second click cannot send the request again in the meantime. A redirect that does not leave the page, such as one to a file download, leaves the veil up as well; offer downloads through _AjaxDownloadBehavior_ instead.
+
=== 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:
@@ -28,7 +30,7 @@ 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_.
+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; for a behavior that listens on a container for events of its children, it is the veil around the element that was clicked. 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_. When [...]
=== Veiling a component during a WebSocket push
@@ -99,6 +101,7 @@ The look comes from the stylesheet _wicket-veil.css_, contributed by the behavio
* _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.
+* _wicket-veil-host_: added to the element of a component with a local veil while it is veiled. It gives the element _isolation: isolate_, so the veil stays within the element's stacking context and does not paint over a sticky header or a modal dialog in front of it.
+* _wicket-veil-host-static_: added together with _wicket-veil-host_ when the element is not positioned already. It gives the element _position: relative_, so the veil covers exactly that element; an element that is _absolute_, _fixed_ or _sticky_ keeps its position.
-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.
+On the client side the veil is implemented by _Wicket.Veil_, which subscribes to the global AJAX topics '/ajax/call/beforeSend', '/ajax/call/done' and '/dom/node/added' 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/ajax/ajax_6.adoc b/wicket-user-guide/src/main/asciidoc/ajax/ajax_6.adoc
index a940d01aeb..e7d964fb18 100644
--- a/wicket-user-guide/src/main/asciidoc/ajax/ajax_6.adoc
+++ b/wicket-user-guide/src/main/asciidoc/ajax/ajax_6.adoc
@@ -153,7 +153,7 @@ Global AJAX call events are handled with JavaScript. We can register a callback
* '/ajax/call/success': called if the AJAX call has successfully returned.
* '/ajax/call/failure': called if the AJAX call has returned with a failure.
* '/ajax/call/complete': called when the AJAX call has completed.
-* '/ajax/call/done': called when the AJAX call is done.
+* '/ajax/call/done': called when the AJAX call is done. Its callback gets, after attrs, a boolean telling whether the response is redirecting the browser to another page, in which case the page is about to be left and anything blocking it can stay up.
* '/dom/node/removing': called when a component is about to be removed via AJAX. This happens when component markup is updated via AJAX (i.e. the component itself or one of its containers has been added to _AjaxRequestTarget_)
* '/dom/node/added': called when a component has been added via AJAX. Just like '/dom/node/removing', this event is triggered when a component is added to _AjaxRequestTarget_.
(wicket) 01/05: Add Ajax veil behaviors that block the page or a component during a request
Posted by re...@apache.org.
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 <re...@gmail.com>
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]
(wicket) 05/05: Show the reviewed veil cases in the examples
Posted by re...@apache.org.
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 ee62ec8c0149d19a9cb7e32d863be103c25cc0c1
Author: reiern70 <re...@gmail.com>
AuthorDate: Fri Oct 2 11:07:49 2026 -0500
Show the reviewed veil cases in the examples
The review of the veil behaviors found cases where the veil did not hold
or disturbed the page it covers, but the examples showed none of them,
so the fixes could not be seen or tried out.
ajax/veil gets a section for each case an Ajax request can show: one
behavior on a list handling the clicks of its rows through a child
selector, with only the clicked row veiled; a panel whose markup id is
"constructor"; a scrolling box whose veil covers its visible part; an
absolutely positioned card that stays in place while veiled; and a
panel under a sticky header, whose veil stays behind the header.
VeilPageSeleniumTest drives each of them in headless Chrome, with both
Ajax engines.
websockets/veil pushes a progress update halfway through its long
rounds, which redraws the veiled panel while the veil stays on it, and
gets a link that sends a slow Ajax request from the panel while the
server sends an unveil message no veil message preceded; the panel stays
veiled until the request is done.
A host replaced by another Ajax request while it is veiled is shown
through the WebSocket push only: requests to the same page are
serialized by the page lock, so a second Ajax request cannot re-render
the host while the first one runs.
See GitHub issue #1631.
---
.../wicket/examples/ajax/builtin/VeilPage.css | 60 +++++++++
.../wicket/examples/ajax/builtin/VeilPage.html | 67 ++++++++++
.../wicket/examples/ajax/builtin/VeilPage.java | 117 +++++++++++++++-
.../examples/ajax/builtin/VeilPage.properties | 2 +-
.../examples/websocket/WebSocketVeilDemoPage.html | 6 +
.../examples/websocket/WebSocketVeilDemoPage.java | 126 +++++++++++++++---
.../websocket/WebSocketVeilDemoPage.properties | 2 +-
.../ajax/builtin/VeilPageSeleniumTest.java | 147 +++++++++++++++++++++
8 files changed, 503 insertions(+), 24 deletions(-)
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
index 54f8963082..09e588d62d 100644
--- 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
@@ -28,3 +28,63 @@
margin-top: 1em;
background: #f7f7f7;
}
+
+.veil-rows {
+ max-width: 40em;
+ padding: 0;
+ list-style: none;
+}
+
+.veil-row {
+ margin: 0.5em 0;
+ padding: 0.5em;
+ border: 1px solid #ccc;
+ cursor: pointer;
+}
+
+.veil-scroller {
+ max-width: 40em;
+ height: 12em;
+ overflow: auto;
+}
+
+.veil-stage {
+ position: relative;
+ max-width: 40em;
+ height: 7em;
+ padding: 0 14em 0 1em;
+ border: 1px dashed #ccc;
+}
+
+.veil-positioned {
+ position: absolute;
+ top: 1em;
+ right: 1em;
+ width: 11em;
+ background: #f7f7f7;
+}
+
+.veil-sticky-scroller {
+ max-width: 40em;
+ height: 12em;
+ overflow: auto;
+ padding-top: 0;
+}
+
+.veil-sticky-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ padding: 0.5em;
+ background: #336;
+ color: #fff;
+}
+
+.veil-under-header {
+ margin-top: 4em;
+ background: #f7f7f7;
+}
+
+.veil-filler {
+ height: 20em;
+}
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
index 0509383d71..d91f53c107 100644
--- 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
@@ -45,4 +45,71 @@
</div>
</div>
+<h2>Delegated clicks</h2>
+
+<p>
+ One <code>AjaxEventBehavior</code> on the list handles the clicks on all of its rows through a
+ child selector, and every row has a local veil of its own. A click on a row takes a second and
+ veils only the row that was clicked, not the list or the page.
+</p>
+<ul wicket:id="rows" class="veil-rows">
+ <li wicket:id="row" class="veil-row">
+ Row <span wicket:id="rowNumber"></span>, clicked
+ <span wicket:id="rowCounter" class="row-counter"></span> times. Click anywhere on the row.
+ </li>
+</ul>
+
+<h2>A markup id named after an object member</h2>
+
+<div wicket:id="constructor" class="veil-panel">
+ This panel's markup id is <code>constructor</code>, the name of a member every JavaScript
+ object has. It is veiled like any other:
+ <a href="#" wicket:id="constructorSlow">Slow request</a>, clicked
+ <span wicket:id="constructorCounter" class="constructor-counter"></span> times.
+</div>
+
+<h2>Scrolling host</h2>
+
+<p>
+ This box scrolls and has a local veil. Scroll down and click a request: the veil covers the
+ part of the box you see, with the spinner in its middle, and follows the box while you scroll
+ it. Requests sent: <span wicket:id="scrollerCounter" class="scroller-counter"></span>.
+</p>
+<div wicket:id="scroller" class="veil-panel veil-scroller">
+ <p wicket:id="line">
+ Line <span wicket:id="lineNumber"></span>:
+ <a href="#" wicket:id="lineSlow">slow request</a>
+ </p>
+</div>
+
+<h2>Positioned host</h2>
+
+<div class="veil-stage">
+ <p>
+ The card on the right is absolutely positioned and has a local veil. A request from it
+ takes two seconds, and the card stays where it is.
+ </p>
+ <div wicket:id="positioned" class="veil-panel veil-positioned">
+ <a href="#" wicket:id="positionedSlow">Slow request</a>, clicked
+ <span wicket:id="positionedCounter" class="positioned-counter"></span> times
+ </div>
+</div>
+
+<h2>Under a sticky header</h2>
+
+<p>
+ The panel in this box has a local veil. Click its request, which takes three seconds, then
+ scroll the box so the panel slides under the sticky header: the header stays in front of the
+ veil and its spinner.
+</p>
+<div class="veil-panel veil-sticky-scroller">
+ <div class="veil-sticky-header">Sticky header</div>
+ <p>Scroll down to the panel.</p>
+ <div wicket:id="underHeader" class="veil-panel veil-under-header">
+ <a href="#" wicket:id="underHeaderSlow">Slow request</a>, clicked
+ <span wicket:id="underHeaderCounter" class="under-header-counter"></span> times
+ </div>
+ <p class="veil-filler">Scroll on, so the panel can move under the header.</p>
+</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
index 1d1d279d71..7f46c88c82 100644
--- 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
@@ -18,7 +18,9 @@ package org.apache.wicket.examples.ajax.builtin;
import java.time.Duration;
+import org.apache.wicket.AttributeModifier;
import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.ajax.AjaxEventBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.ajax.markup.html.AjaxLink;
@@ -28,18 +30,25 @@ 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.markup.html.list.Loop;
+import org.apache.wicket.markup.html.list.LoopItem;
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.
+ * veils with their own timings, and the cases a veil has to hold in: a delegated behavior, and
+ * hosts that scroll, are positioned or sit under a sticky header.
*/
public class VeilPage extends BasePage
{
private static final long serialVersionUID = 1L;
+ private static final int ROWS = 3;
+
+ private final int[] rowClicks = new int[ROWS];
+
/**
* Constructor.
*/
@@ -68,6 +77,112 @@ public class VeilPage extends BasePage
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));
+
+ addDelegatedRows();
+ addObjectMemberId();
+ addScroller();
+ addPositionedHost();
+ addStickyHeader();
+ }
+
+ private void addDelegatedRows()
+ {
+ WebMarkupContainer rows = new WebMarkupContainer("rows");
+ add(rows);
+ Loop rowLoop = new Loop("row", ROWS)
+ {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void populateItem(LoopItem item)
+ {
+ int index = item.getIndex();
+ item.add(new LocalVeilBehavior());
+ item.add(AttributeModifier.replace("data-row", index));
+ item.add(new Label("rowNumber", index + 1));
+ item.add(new Label("rowCounter", () -> rowClicks[index]));
+ }
+ };
+ rows.add(rowLoop);
+
+ rows.add(new AjaxEventBehavior("click")
+ {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void updateAjaxAttributes(AjaxRequestAttributes attributes)
+ {
+ super.updateAjaxAttributes(attributes);
+ attributes.setChildSelector("li");
+ attributes.getDynamicExtraParameters()
+ .add("return {row: attrs.event.target.closest('li').dataset.row};");
+ }
+
+ @Override
+ protected void onEvent(AjaxRequestTarget target)
+ {
+ int index = getRequest().getRequestParameters()
+ .getParameterValue("row")
+ .toInt(-1);
+ if (index >= 0 && index < ROWS)
+ {
+ sleep(Duration.ofSeconds(1));
+ rowClicks[index]++;
+ target.add(rowLoop.get(Integer.toString(index)));
+ }
+ }
+ });
+ }
+
+ private void addObjectMemberId()
+ {
+ WebMarkupContainer constructor = new WebMarkupContainer("constructor");
+ constructor.setMarkupId("constructor");
+ constructor.add(new LocalVeilBehavior());
+ add(constructor);
+
+ Label counter = counter(constructor, "constructorCounter");
+ constructor.add(new SleepingLink("constructorSlow", Duration.ofSeconds(1), counter, false));
+ }
+
+ private void addScroller()
+ {
+ WebMarkupContainer scroller = new WebMarkupContainer("scroller");
+ scroller.add(new LocalVeilBehavior());
+ add(scroller);
+
+ Label counter = counter(this, "scrollerCounter");
+ scroller.add(new Loop("line", 30)
+ {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void populateItem(LoopItem item)
+ {
+ item.add(new Label("lineNumber", item.getIndex() + 1));
+ item.add(new SleepingLink("lineSlow", Duration.ofSeconds(2), counter, false));
+ }
+ });
+ }
+
+ private void addPositionedHost()
+ {
+ WebMarkupContainer card = new WebMarkupContainer("positioned");
+ card.add(new LocalVeilBehavior());
+ add(card);
+
+ Label counter = counter(card, "positionedCounter");
+ card.add(new SleepingLink("positionedSlow", Duration.ofSeconds(2), counter, false));
+ }
+
+ private void addStickyHeader()
+ {
+ WebMarkupContainer underHeader = new WebMarkupContainer("underHeader");
+ underHeader.add(new LocalVeilBehavior());
+ add(underHeader);
+
+ Label counter = counter(underHeader, "underHeaderCounter");
+ underHeader.add(new SleepingLink("underHeaderSlow", Duration.ofSeconds(3), counter, false));
}
@Override
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
index 51fd11848e..a7f52f1149 100644
--- 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
@@ -14,4 +14,4 @@
# 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 [...]
+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/WebSocketVeilDemoPage.html b/wicket-examples/src/main/java/org/apache/wicket/examples/websocket/WebSocketVeilDemoPage.html
index 77e6994e59..4d375c41bf 100644
--- 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
@@ -12,6 +12,12 @@
Pushed counter: <span wicket:id="counter" class="push-counter"></span>
</p>
<p wicket:id="lastWork" class="push-last-work"></p>
+ <p>
+ <a href="#" wicket:id="strayUnveil">Slow request with a stray unveil</a>: an Ajax
+ request from this panel that takes two seconds. Half a second in, the server sends an
+ unveil message that no veil message preceded. The panel stays veiled until the request
+ is done. Requests sent: <span wicket:id="panelRequests" class="panel-requests"></span>
+ </p>
</div>
</wicket:extend>
</body>
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
index 3c9b001fc0..6273209bb1 100644
--- 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
@@ -66,6 +66,8 @@ public class WebSocketVeilDemoPage extends WicketExamplePage
private final Label lastWork;
+ private int panelRequests;
+
/**
* Constructor.
*/
@@ -84,6 +86,20 @@ public class WebSocketVeilDemoPage extends WicketExamplePage
counterPanel.add(counter);
lastWork = new Label("lastWork", Model.of("No update pushed yet."));
counterPanel.add(lastWork);
+ counterPanel.add(new Label("panelRequests", () -> panelRequests));
+ counterPanel.add(new AjaxLink<Void>("strayUnveil")
+ {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target)
+ {
+ sendStrayUnveil();
+ sleep(Duration.ofSeconds(2));
+ panelRequests++;
+ target.add(counterPanel);
+ }
+ });
add(new AjaxLink<Void>("start")
{
@@ -123,6 +139,49 @@ public class WebSocketVeilDemoPage extends WicketExamplePage
}
}
+ private void sendStrayUnveil()
+ {
+ String applicationName = getApplication().getName();
+ String sessionId = getSession().getId();
+ int pageId = getPageId();
+ String unveilMessage = veil.getUnveilMessage();
+ JSR356Application.get().getScheduledExecutorService().schedule(() -> {
+ IWebSocketConnection connection = connection(applicationName, sessionId, pageId);
+ if (connection != null && connection.isOpen())
+ {
+ try
+ {
+ connection.sendMessage(unveilMessage);
+ }
+ catch (IOException e)
+ {
+ LOGGER.error("Sending the unveil message failed", e);
+ }
+ }
+ }, 500, TimeUnit.MILLISECONDS);
+ }
+
+ private static IWebSocketConnection connection(String applicationName, String sessionId,
+ int pageId)
+ {
+ Application application = Application.get(applicationName);
+ return WebSocketSettings.Holder.get(application)
+ .getConnectionRegistry()
+ .getConnection(application, sessionId, new PageIdKey(pageId));
+ }
+
+ private static void sleep(Duration duration)
+ {
+ try
+ {
+ Thread.sleep(duration.toMillis());
+ }
+ catch (InterruptedException e)
+ {
+ Thread.currentThread().interrupt();
+ }
+ }
+
private String taskKey()
{
return getSession().getId() + "#" + getPageId();
@@ -133,15 +192,23 @@ public class WebSocketVeilDemoPage extends WicketExamplePage
{
super.onEvent(event);
- if (event.getPayload() instanceof WebSocketPushPayload payload &&
- payload.getMessage() instanceof CounterUpdate update)
+ if (event.getPayload() instanceof WebSocketPushPayload payload)
{
- 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());
+ if (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());
+ }
+ else if (payload.getMessage() instanceof Progress progress)
+ {
+ lastWork.setDefaultModelObject(String.format(
+ "Working on update %d of %d, halfway through.", progress.round, ROUNDS));
+ payload.getHandler().add(counterPanel);
+ }
}
}
@@ -170,10 +237,24 @@ public class WebSocketVeilDemoPage extends WicketExamplePage
}
}
+ /**
+ * Reports halfway through the work, without lifting the veil.
+ */
+ private static class Progress implements IWebSocketPushMessage
+ {
+ private final int round;
+
+ Progress(int round)
+ {
+ this.round = round;
+ }
+ }
+
/**
* 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.
+ * spinner delay; the long work reports its progress halfway through, which redraws the veiled
+ * panel; 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
{
@@ -211,15 +292,26 @@ public class WebSocketVeilDemoPage extends WicketExamplePage
{
for (int round = 1; round <= ROUNDS && !canceled; round++)
{
- IWebSocketConnection connection = connection();
+ IWebSocketConnection connection = connection(applicationName, sessionId,
+ pageId);
if (connection == null || !connection.isOpen())
{
return;
}
connection.sendMessage(veilMessage);
- Duration work = Duration.ofMillis(round % 2 == 1 ? 1500 : 600);
- TimeUnit.MILLISECONDS.sleep(work.toMillis());
+ boolean longWork = round % 2 == 1;
+ Duration work = Duration.ofMillis(longWork ? 3000 : 600);
+ if (longWork)
+ {
+ TimeUnit.MILLISECONDS.sleep(work.toMillis() / 2);
+ connection.sendMessage(new Progress(round));
+ TimeUnit.MILLISECONDS.sleep(work.toMillis() / 2);
+ }
+ else
+ {
+ TimeUnit.MILLISECONDS.sleep(work.toMillis());
+ }
connection.sendMessage(new CounterUpdate(round, work));
TimeUnit.SECONDS.sleep(1);
@@ -238,13 +330,5 @@ public class WebSocketVeilDemoPage extends WicketExamplePage
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
index 9bf32492af..a97b76bb88 100644
--- 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
@@ -14,4 +14,4 @@
# 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 [...]
+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 3 s: the spinner shows after 300 ms, a progress update halfway through redraws the panel, which stays veiled, and the spinner goes away with the final update. Even rounds ta [...]
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
index d114cb5b38..277a797d4e 100644
--- 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
@@ -57,6 +57,8 @@ class VeilPageSeleniumTest extends JettyTestCaseDecorator
private static final String INNER_VEIL = ".veil-inner > .wicket-veil";
+ private static final String ROW_VEIL = ".veil-row > .wicket-veil";
+
/**
* Logs when a veil is added, gets its spinner and is removed, with the time of each, into
* {@code window.veilLog}.
@@ -308,6 +310,151 @@ class VeilPageSeleniumTest extends JettyTestCaseDecorator
"the spinner was shown for " + shown + " ms instead of the configured 1 s");
}
+ @ParameterizedTest
+ @EnumSource(Engine.class)
+ void aDelegatedClickVeilsTheRowThatWasClicked(Engine engine)
+ {
+ open(engine);
+
+ List<WebElement> rows = driver.findElements(By.cssSelector(".veil-row"));
+ String rowId = rows.get(1).getDomAttribute("id");
+ rows.get(1).click();
+
+ wait.until(ExpectedConditions.presenceOfElementLocated(
+ By.cssSelector("#" + rowId + " > .wicket-veil")));
+ assertEquals(1, driver.findElements(By.cssSelector(ROW_VEIL)).size(),
+ "another row was veiled too");
+ assertTrue(driver.findElements(By.cssSelector(PAGE_VEIL)).isEmpty(),
+ "the page was veiled");
+
+ wait.until(ExpectedConditions.textToBe(
+ By.cssSelector("[data-row='1'] .row-counter"), "1"));
+ awaitNoVeil();
+ }
+
+ @ParameterizedTest
+ @EnumSource(Engine.class)
+ void aHostWithTheIdOfAnObjectMemberIsVeiled(Engine engine)
+ {
+ open(engine);
+
+ driver.findElement(By.cssSelector("#constructor a")).click();
+
+ wait.until(ExpectedConditions.presenceOfElementLocated(
+ By.cssSelector("#constructor > .wicket-veil")));
+ assertTrue(driver.findElements(By.cssSelector(PAGE_VEIL)).isEmpty(),
+ "the page was veiled instead");
+ wait.until(ExpectedConditions.textToBe(By.cssSelector(".constructor-counter"), "1"));
+ awaitNoVeil();
+ }
+
+ @ParameterizedTest
+ @EnumSource(Engine.class)
+ void theVeilOfAScrolledHostCoversItsVisiblePart(Engine engine)
+ {
+ open(engine);
+
+ WebElement scroller = driver.findElement(By.cssSelector(".veil-scroller"));
+ js().executeScript("arguments[0].scrollIntoView({ block: 'center' });" +
+ "arguments[0].scrollTop = arguments[0].scrollHeight;", scroller);
+ List<WebElement> links = scroller.findElements(By.tagName("a"));
+ new Actions(driver).moveToElement(links.get(links.size() - 1)).click().perform();
+ wait.until(ExpectedConditions.presenceOfElementLocated(
+ By.cssSelector(".veil-scroller > .wicket-veil.wicket-veil-busy")));
+
+ assertCoveredByItsVeil(scroller);
+ js().executeScript("arguments[0].scrollTop = 100;", scroller);
+ assertCoveredByItsVeil(scroller);
+
+ wait.until(ExpectedConditions.textToBe(By.cssSelector(".scroller-counter"), "1"));
+ awaitNoVeil();
+ }
+
+ @ParameterizedTest
+ @EnumSource(Engine.class)
+ void aPositionedHostStaysWhereItIs(Engine engine)
+ {
+ open(engine);
+
+ WebElement card = driver.findElement(By.cssSelector(".veil-positioned"));
+ Object before = offsetInStage(card);
+ card.findElement(By.tagName("a")).click();
+ wait.until(ExpectedConditions.presenceOfElementLocated(
+ By.cssSelector(".veil-positioned > .wicket-veil")));
+
+ assertEquals(before, offsetInStage(card), "the host moved while it was veiled");
+ assertEquals("absolute", card.getCssValue("position"));
+
+ wait.until(ExpectedConditions.textToBe(By.cssSelector(".positioned-counter"), "1"));
+ awaitNoVeil();
+ }
+
+ @ParameterizedTest
+ @EnumSource(Engine.class)
+ void theVeilStaysBehindAStickyHeaderInFrontOfItsHost(Engine engine)
+ {
+ open(engine);
+
+ WebElement panel = driver.findElement(By.cssSelector(".veil-under-header"));
+ js().executeScript("arguments[0].scrollIntoView({ block: 'center' });", panel);
+ panel.findElement(By.tagName("a")).click();
+ wait.until(ExpectedConditions.presenceOfElementLocated(
+ By.cssSelector(".veil-under-header > .wicket-veil.wicket-veil-busy")));
+
+ Object hit = js().executeAsyncScript("""
+ var callback = arguments[arguments.length - 1];
+ var scroller = document.querySelector('.veil-sticky-scroller');
+ var header = document.querySelector('.veil-sticky-header');
+ var panel = document.querySelector('.veil-under-header');
+ scroller.scrollTop += panel.getBoundingClientRect().top -
+ scroller.getBoundingClientRect().top;
+ requestAnimationFrame(function () {
+ var rect = header.getBoundingClientRect();
+ var x = rect.left + rect.width / 2;
+ var y = rect.top + rect.height / 2;
+ var under = panel.getBoundingClientRect();
+ callback({
+ panelUnderHeader: under.top <= y && y <= under.bottom,
+ hit: document.elementFromPoint(x, y) === header
+ });
+ });
+ """);
+ assertEquals(Map.of("panelUnderHeader", true, "hit", true), hit,
+ "the veil paints over the sticky header");
+
+ wait.until(ExpectedConditions.textToBe(By.cssSelector(".under-header-counter"), "1"));
+ awaitNoVeil();
+ }
+
+ private void assertCoveredByItsVeil(WebElement host)
+ {
+ Object result = js().executeAsyncScript("""
+ var host = arguments[0];
+ var callback = arguments[arguments.length - 1];
+ requestAnimationFrame(function () {
+ var veil = host.querySelector(':scope > .wicket-veil');
+ var rect = host.getBoundingClientRect();
+ var points = [[0.1, 0.1], [0.5, 0.5], [0.9, 0.9]].filter(function (point) {
+ var x = rect.left + host.clientWidth * point[0];
+ var y = rect.top + host.clientHeight * point[1];
+ return document.elementFromPoint(x, y) !== veil;
+ });
+ callback(points.length === 0 ? 'covered' :
+ 'not covered at ' + JSON.stringify(points) + ', scrollTop ' + host.scrollTop);
+ });
+ """, host);
+ assertEquals("covered", result);
+ }
+
+ private Object offsetInStage(WebElement element)
+ {
+ return js().executeScript("""
+ var r = arguments[0].getBoundingClientRect();
+ var stage = arguments[0].parentNode.getBoundingClientRect();
+ return [r.left - stage.left, r.top - stage.top, r.width, r.height].join(',');
+ """, element);
+ }
+
private WebElement outerPanel()
{
return driver.findElement(By.cssSelector(".veil-outer"));
(wicket) 03/05: Click through the veil like a user in the veil Selenium test
Posted by re...@apache.org.
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 f10ecbed58250bd19a75ffb95420d311fa92ce47
Author: reiern70 <re...@gmail.com>
AuthorDate: Fri Oct 2 09:58:16 2026 -0500
Click through the veil like a user in the veil Selenium test
theVeilSwallowsClicks expected WebElement.click() on a veiled link to
throw ElementClickInterceptedException. Current ChromeDriver does not
throw: it waits until nothing covers the element any more, then clicks
it. The click therefore landed after the veil came down, sent a second
request, and the test failed on both engines although the veil does
swallow clicks. It only showed when the test was run, which needs
-Dwicket.selenium=true.
The test now clicks at the link's position with the Actions API, as a
user would, and checks that the click had no effect.
---
.../wicket/examples/ajax/builtin/VeilPageSeleniumTest.java | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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
index 424f5d624d..d114cb5b38 100644
--- 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
@@ -18,7 +18,6 @@ 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;
@@ -34,12 +33,12 @@ 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.interactions.Actions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
@@ -198,11 +197,12 @@ class VeilPageSeleniumTest extends JettyTestCaseDecorator
click("Slow request");
wait.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector(PAGE_VEIL)));
+ WebElement fastRequest = driver.findElement(By.linkText("Fast request"));
@SuppressWarnings("unchecked")
- Map<String, Object> hit = (Map<String, Object>)js().executeScript(HIT_TEST,
- driver.findElement(By.linkText("Fast request")));
+ Map<String, Object> hit = (Map<String, Object>)js().executeScript(HIT_TEST, fastRequest);
assertEquals(Boolean.TRUE, hit.get("veiled"), "the veil does not cover the link: " + hit);
- assertThrows(ElementClickInterceptedException.class, () -> click("Fast request"));
+ // WebElement.click() would wait for the veil to come down before clicking
+ new Actions(driver).moveToElement(fastRequest).click().perform();
awaitPageCounter("1");
awaitNoVeil();