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 <[email protected]> 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_.
