From 3277f85d3728ad7598ed48f040060efe7788b03b Mon Sep 17 00:00:00 2001 From: Jaifroid Date: Thu, 11 May 2023 20:39:54 +0100 Subject: [PATCH] Add Promise queue to prevent overlapping dialogue boxes #841 (#1002) --- www/js/lib/uiUtil.js | 172 ++++++++++++++++++++++--------------------- www/js/lib/util.js | 49 +++++++++++- 2 files changed, 135 insertions(+), 86 deletions(-) diff --git a/www/js/lib/uiUtil.js b/www/js/lib/uiUtil.js index 4cbc50b5..739e023c 100644 --- a/www/js/lib/uiUtil.js +++ b/www/js/lib/uiUtil.js @@ -24,14 +24,14 @@ // DEV: Put your RequireJS definition in the rqDef array below, and any function exports in the function parenthesis of the define statement // We need to do it this way in order to load WebP polyfills conditionally. The WebP polyfills are only needed by a few old browsers, so loading them // only if needed saves approximately 1MB of memory. -var rqDef = ['settingsStore']; +var rqDef = ['settingsStore', 'util']; // Add WebP polyfill only if webpHero was loaded in init.js if (webpMachine) { rqDef.push('webpHeroBundle'); } -define(rqDef, function(settingsStore) { +define(rqDef, function(settingsStore, util) { /** * Displays a Bootstrap alert or confirm dialog box depending on the options provided @@ -49,95 +49,97 @@ define(rqDef, function(settingsStore) { approveConfirmLabel = approveConfirmLabel || 'Confirm'; closeMessageLabel = closeMessageLabel || 'Okay'; label = label || (isConfirm ? 'Confirmation' : 'Message'); - return new Promise(function (resolve, reject) { - if (!message) reject('Missing body message'); - // Set the text to the modal and its buttons - document.getElementById('approveConfirm').textContent = approveConfirmLabel; - document.getElementById('declineConfirm').textContent = declineConfirmLabel; - document.getElementById('closeMessage').textContent = closeMessageLabel; - document.getElementById('modalLabel').textContent = label; - // Using innerHTML to set the message to allow HTML formatting - document.getElementById('modalText').innerHTML = message; - // Display buttons acc to the type of alert - document.getElementById('approveConfirm').style.display = isConfirm ? 'inline' : 'none'; - document.getElementById('declineConfirm').style.display = isConfirm ? 'inline' : 'none'; - document.getElementById('closeMessage').style.display = isConfirm ? 'none' : 'inline'; - // Display the modal - const modal = document.querySelector('#alertModal'); - const backdrop = document.createElement('div'); - backdrop.classList.add('modal-backdrop'); - document.body.appendChild(backdrop); + return util.PromiseQueue.enqueue(function () { + return new Promise(function (resolve, reject) { + if (!message) reject('Missing body message'); + // Set the text to the modal and its buttons + document.getElementById('approveConfirm').textContent = approveConfirmLabel; + document.getElementById('declineConfirm').textContent = declineConfirmLabel; + document.getElementById('closeMessage').textContent = closeMessageLabel; + document.getElementById('modalLabel').textContent = label; + // Using innerHTML to set the message to allow HTML formatting + document.getElementById('modalText').innerHTML = message; + // Display buttons acc to the type of alert + document.getElementById('approveConfirm').style.display = isConfirm ? 'inline' : 'none'; + document.getElementById('declineConfirm').style.display = isConfirm ? 'inline' : 'none'; + document.getElementById('closeMessage').style.display = isConfirm ? 'none' : 'inline'; + // Display the modal + const modal = document.querySelector('#alertModal'); + const backdrop = document.createElement('div'); + backdrop.classList.add('modal-backdrop'); + document.body.appendChild(backdrop); - // Show the modal - document.body.classList.add('modal-open'); - modal.classList.add('show'); - modal.style.display = 'block'; - backdrop.classList.add('show'); + // Show the modal + document.body.classList.add('modal-open'); + modal.classList.add('show'); + modal.style.display = 'block'; + backdrop.classList.add('show'); - // Set the ARIA attributes for the modal - modal.setAttribute('aria-hidden', 'false'); - modal.setAttribute('aria-modal', 'true'); - modal.setAttribute('role', 'dialog'); + // Set the ARIA attributes for the modal + modal.setAttribute('aria-hidden', 'false'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('role', 'dialog'); - // Hide modal handlers - var closeModalHandler = function () { - document.body.classList.remove('modal-open'); - modal.classList.remove('show'); - modal.style.display = 'none'; - backdrop.classList.remove('show'); - if(Array.from(document.body.children).indexOf(backdrop)>=0){ - document.body.removeChild(backdrop); - } - //remove event listeners - document.getElementById('modalCloseBtn').removeEventListener('click', close); - document.getElementById('declineConfirm').removeEventListener('click', close); - document.getElementById('closeMessage').removeEventListener('click', close); - document.getElementById('approveConfirm').removeEventListener('click', closeConfirm); - modal.removeEventListener('click', close); - document.getElementsByClassName('modal-dialog')[0].removeEventListener('click', stopOutsideModalClick); - modal.removeEventListener('keyup', keyHandler); - }; - - // function to call when modal is closed - var close = function () { - closeModalHandler(); - resolve(false); - }; - var closeConfirm = function () { - closeModalHandler(); - resolve(true); - }; - var stopOutsideModalClick = function (e) { - e.stopPropagation(); - }; - var keyHandler = function (e) { - if (/Enter/.test(e.key)) { - // We need to focus before clicking the button, because the handler above is based on document.activeElement - if (isConfirm) { - document.getElementById('approveConfirm').focus(); - document.getElementById('approveConfirm').click(); - } else { - document.getElementById('closeMessage').focus(); - document.getElementById('closeMessage').click(); + // Hide modal handlers + var closeModalHandler = function () { + document.body.classList.remove('modal-open'); + modal.classList.remove('show'); + modal.style.display = 'none'; + backdrop.classList.remove('show'); + if(Array.from(document.body.children).indexOf(backdrop)>=0){ + document.body.removeChild(backdrop); } - } else if (/Esc/.test(e.key)) { - document.getElementById('modalCloseBtn').focus(); - document.getElementById('modalCloseBtn').click(); - } - }; + //remove event listeners + document.getElementById('modalCloseBtn').removeEventListener('click', close); + document.getElementById('declineConfirm').removeEventListener('click', close); + document.getElementById('closeMessage').removeEventListener('click', close); + document.getElementById('approveConfirm').removeEventListener('click', closeConfirm); + modal.removeEventListener('click', close); + document.getElementsByClassName('modal-dialog')[0].removeEventListener('click', stopOutsideModalClick); + modal.removeEventListener('keyup', keyHandler); + }; - // When hide modal is called, resolve promise with true if hidden using approve button, false otherwise - document.getElementById('modalCloseBtn').addEventListener('click', close); - document.getElementById('declineConfirm').addEventListener('click', close); - document.getElementById('closeMessage').addEventListener('click', close); - document.getElementById('approveConfirm').addEventListener('click', closeConfirm); - - modal.addEventListener('click', close); - document.getElementsByClassName('modal-dialog')[0].addEventListener('click', stopOutsideModalClick); + // function to call when modal is closed + var close = function () { + closeModalHandler(); + resolve(false); + }; + var closeConfirm = function () { + closeModalHandler(); + resolve(true); + }; + var stopOutsideModalClick = function (e) { + e.stopPropagation(); + }; + var keyHandler = function (e) { + if (/Enter/.test(e.key)) { + // We need to focus before clicking the button, because the handler above is based on document.activeElement + if (isConfirm) { + document.getElementById('approveConfirm').focus(); + document.getElementById('approveConfirm').click(); + } else { + document.getElementById('closeMessage').focus(); + document.getElementById('closeMessage').click(); + } + } else if (/Esc/.test(e.key)) { + document.getElementById('modalCloseBtn').focus(); + document.getElementById('modalCloseBtn').click(); + } + }; - modal.addEventListener('keyup', keyHandler); - // Set focus to the first focusable element inside the modal - modal.focus(); + // When hide modal is called, resolve promise with true if hidden using approve button, false otherwise + document.getElementById('modalCloseBtn').addEventListener('click', close); + document.getElementById('declineConfirm').addEventListener('click', close); + document.getElementById('closeMessage').addEventListener('click', close); + document.getElementById('approveConfirm').addEventListener('click', closeConfirm); + + modal.addEventListener('click', close); + document.getElementsByClassName('modal-dialog')[0].addEventListener('click', stopOutsideModalClick); + + modal.addEventListener('keyup', keyHandler); + // Set focus to the first focusable element inside the modal + modal.focus(); + }); }); } diff --git a/www/js/lib/util.js b/www/js/lib/util.js index a6f69fcc..7bccc456 100644 --- a/www/js/lib/util.js +++ b/www/js/lib/util.js @@ -194,6 +194,52 @@ define([], function() { } /** + * Queues Promise Factories* to be resolved or rejected sequentially. This helps to avoid overlapping Promise functions. + * Primarily used by uiUtil.systemAlert, to prevent alerts showing while others are being displayed. + * + * *A Promise Factory is merely a Promise wrapped in a function to prevent it from executing immediately. E.g. to use + * this function with a Promise, call it like this (or, more likely, use your own pre-wrapped Promise): + * + * return util.PromiseQueue.enqueue(function () { + * return new Promise(function (resolve, reject) { ... }); + * }); + * + * Adapted from https://medium.com/@karenmarkosyan/how-to-manage-promises-into-dynamic-queue-with-vanilla-javascript-9d0d1f8d4df5 + * + * @type {Object} PromiseQueue + * @property {Function} enqueue Queues a Promise Factory. Call this function repeatedly to queue Promises sequentially + * @param {Function} promiseFactory A Promise wrapped in an ordinary function + * @returns {Promise} A Promise that resolves or rejects with the resolved/rejected value of the Promise Factory + */ + var PromiseQueue = { + _queue: [], + _working: false, + enqueue: function (promiseFactory) { + var that = this; + return new Promise(function (resolve, reject) { + that._queue.push({promise: promiseFactory, resolve: resolve, reject: reject}); + if (!that._working) that._dequeue(); + }); + }, + _dequeue: function () { + this._working = true; + var that = this; + var deferred = this._queue.shift(); + if (!deferred) { + this._working = false; + return false; + } + return deferred.promise().then(function (val) { + deferred.resolve(val); + }).catch(function (err) { + deferred.reject(err); + }).finally(function () { + return that._dequeue(); + }); + } + }; + + /* * Functions and classes exposed by this module */ return { @@ -203,6 +249,7 @@ define([], function() { readFloatFrom4Bytes: readFloatFrom4Bytes, readFileSlice: readFileSlice, binarySearch: binarySearch, - leftShift: leftShift + leftShift: leftShift, + PromiseQueue: PromiseQueue }; });