This patch adds a modal confirmation dialog for HTML forms.
The dialog can be invoked by setting the "data-confirm-message" attribute on the form. Default texts can be overridden with the "data-confirm-title" and "data-confirm-subheading" attributes.
Signed-off-by: Leo-Andres Hofmann hofmann@leo-andres.de --- html/html/include/wui.js | 6 ++ html/html/include/wui_core.mjs | 77 ++++++++++++++- html/html/include/wui_dialogs.mjs | 97 +++++++++++++++++++ html/html/themes/ipfire/include/css/style.css | 75 ++++++++++++++ html/html/themes/ipfire/include/functions.pl | 28 ++++++ langs/en/cgi-bin/en.pl | 4 + 6 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 html/html/include/wui_dialogs.mjs
diff --git a/html/html/include/wui.js b/html/html/include/wui.js index e65924e29..e219f5ca4 100644 --- a/html/html/include/wui.js +++ b/html/html/include/wui.js @@ -23,6 +23,7 @@
import {WUIcore_i18n as WUI_i18n} from "./wui_core.mjs";
+import {WUIdialog_confirm as WUI_confirm} from "./wui_dialogs.mjs"; import {WUImodule_rrdimage as WUI_rrdimage} from "./wui_rrdimage.mjs";
//--- WUI main class --- @@ -33,11 +34,16 @@ class WUImain { this.i18n = new WUI_i18n();
//- Modules - + // Dialogs + this.dialogs = {}; + this.dialogs.confirm = new WUI_confirm(this.i18n); + // RRDtool graph images this.rrdimage = new WUI_rrdimage(this.i18n);
//- Defaults - // These modules are available on every page: + this.dialogs.confirm.enabled = true; this.rrdimage.enabled = true; } } diff --git a/html/html/include/wui_core.mjs b/html/html/include/wui_core.mjs index b7b729396..ab5338f12 100644 --- a/html/html/include/wui_core.mjs +++ b/html/html/include/wui_core.mjs @@ -27,7 +27,7 @@ export class WUIcore_moduleBase { //- Private properties - #enabled; // Activation state, disabled by default #readyState; // Loading state similar to Document.readyState - #namespace; // Namespace derived from the class name (without "WUImod_" prefix) + #namespace; // Namespace derived from the class name (without "WUImodule_"/"WUIdialog_" prefix)
//- Class constructor - constructor(translations) { @@ -101,6 +101,81 @@ export class WUIcore_moduleBase { } }
+//--- Modal dialog template --- +// Make sure that overridden functions are still executed with super()! +// Text fields and buttons are identified by their data-... attributes: +// data-textbox="foo", data-action="bar". See setText for reference. +// Events should only be managed by the modules, there is no public interface by design. +export class WUIcore_dialogBase extends WUIcore_moduleBase { + //- Private properties - + #modalId; // Element ID of the overlay div box with dialog window + + //- Class constructor - + constructor(translations) { + super(translations); + + //- Protected properties - + // jQuery object, reference to dialog div box + this._$modal = $(); + } + + // DOMContentLoaded/jQuery.ready event handler + _handleDOMReady() { + super._handleDOMReady(); + + // modalId was set before, but the document was not ready yet + if(this.modalId && (! this.hasModal)) { + this._$modal = $(`#${this.modalId}`).first(); + } + } + + // Element ID of dialog overlay div box + // This element must be hidden by default and render a modal dialog when activated. + set modalId(id) { + this.#modalId = id; + + // Delay attaching element until DOM is ready + if(this.readyState === "complete") { + this._$modal = $(`#${id}`).first(); + } + } + get modalId() { + return this.#modalId; + } + + // Check if modal dialog element has been attached + get hasModal() { + return (this._$modal.length === 1); + } + + // Show/hide modal overlay by setting CSS "display" property + // The top modal element should always be a simple overlay, so that display=block does not disturb the layout. + showModal() { + this._$modal.css("display", "block"); + } + hideModal() { + this._$modal.css("display", "none"); + } + get isVisible() { + return (this._$modal.css("display") === "block"); + } + + // Set text field content. Fields are identified by their data-textbox attribute: + // <span data-textbox="message"></span> -> field "message" + setText(field, value) { + this._$modal.find(`[data-textbox="${field}"]`).text(value); + } + + //### Protected properties ### + + // Get a dialog window button as a jQuery object. Buttons are identified by their data-action attribute: + // <button type="button" data-action="submit">OK</button> -> action "submit" + // Button actions must be unique within the modal window. Events are managed internally only, do not add custom handlers. + _$getButton(action) { + return this._$modal.find(`button[data-action="${action}"]`).first(); + } +} + //--- Simple translation strings helper --- export class WUIcore_i18n { //- Private properties - diff --git a/html/html/include/wui_dialogs.mjs b/html/html/include/wui_dialogs.mjs new file mode 100644 index 000000000..a2b3bdbb4 --- /dev/null +++ b/html/html/include/wui_dialogs.mjs @@ -0,0 +1,97 @@ +/*############################################################################# +# # +# IPFire.org - A linux based firewall # +# Copyright (C) 2007-2023 IPFire Team info@ipfire.org # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see http://www.gnu.org/licenses/. # +# # +#############################################################################*/ + +// IPFire Web User Interface - JavaScript module + +import {WUIcore_dialogBase as WUI_dialog} from "./wui_core.mjs"; + +//--- Form submit confirm dialog --- +// Text fields: data-textbox "title" "subheading" "message" +// Buttons: data-action "submit" "cancel" "close" +export class WUIdialog_confirm extends WUI_dialog { + + // DOMContentLoaded/jQuery.ready event handler + _handleDOMReady() { + super._handleDOMReady(); + + // Process all forms with confirmation request, attach submit events + $("form[data-confirm-message]").each((i, formElem) => { + const $form = $(formElem); + $form.on(`submit.${this.namespace}`, {"form": $form, "message": $form.data("confirmMessage")}, this.#handleFormSubmit.bind(this)); + }); + } + + // Form with confirmation "submit" event handler + async #handleFormSubmit(event) { + event.preventDefault(); + + const $form = event.data["form"]; + this.prepareModal(event.data["message"], $form.data("confirmTitle"), $form.data("confirmSubheading")); + + // Show the dialog and wait for user interaction + try { + const response = await this.requestAsync(); + if(response === true) { + // Trigger native HTML submit() method, since it does not raise another submit event that would cause a loop + event.currentTarget.submit(); + } + } catch(error) { + // User closed the window, do nothing + } + } + + // Show modal confirmation request dialog + // Returns a promise that resolves true/false upon user interaction and rejects when the window is closed. + requestAsync() { + // Attach promise to submit/cancel button "click" events + const whenConfirm = new Promise((resolve, reject) => { + this._$getButton("submit").on(`click.${this.namespace}`, () => { resolve(true); }); + this._$getButton("cancel").on(`click.${this.namespace}`, () => { resolve(false); }); + + this._$getButton("close").on(`click.${this.namespace}`, () => { reject(new Error("dialog window closed")); }); + }).finally(() => { + // Always hide the window and detach all button events after any click, + // so that the dialog can be used multiple times without side effects. + this.hideModal(); + this.#clearButtonEvents(); + }); + + // Show dialog, default action is "close" + this.showModal(); + this._$getButton("close").focus(); + + return whenConfirm; + } + + // Prepare dialog window: set texts, but leave hidden and without events + prepareModal(message, title = undefined, subheading = undefined) { + this.hideModal(); + this.#clearButtonEvents(); + + this.setText("message", message); + this.setText("title", title ?? this._i18n("title")); + this.setText("subheading", subheading ?? this._i18n("subheading")); + } + + // Remove all active events from dialog window buttons + #clearButtonEvents() { + this._$modal.find(`button[data-action]`).off(`.${this.namespace}`); + } +} diff --git a/html/html/themes/ipfire/include/css/style.css b/html/html/themes/ipfire/include/css/style.css index 96d0519f5..f4ef1769c 100644 --- a/html/html/themes/ipfire/include/css/style.css +++ b/html/html/themes/ipfire/include/css/style.css @@ -377,3 +377,78 @@ div.rrdimage > img { max-width: 100%; min-height: 290px; } + +/* Modal dialog box */ + +div.dialog-overlay { + display: none; + z-index: 99; + + left: 0; + top: 0; + width: 100%; + height: 100%; + position: fixed; + overflow: auto; + + background-color: rgba(0, 0, 0, 0.5); + animation: fadeIn 0.5s; +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +div.dialog-window { + top: 15%; + width: 550px; + margin: auto; + padding: 0; + position: relative; + + border: 1px solid black; + background: #fff url('../../images/n2.gif') 0px 0px repeat-x; + border-radius: 3px; + box-shadow: 3px 3px 15px rgba(0, 0, 0, 0.7); +} + +.dialog-window .section { + box-sizing: border-box; + padding: 1em 1.5em; + text-align: left; +} +.dialog-window .section:not(:last-child) { + border-bottom: 1px solid silver; +} + +.dialog-window .header > h3 { + display: inline-block; + color: #66000F; + font-size: 1.6em; +} +.dialog-window .header > button[data-action="close"] { + border: none; + background: none; + font-size: 1.2em; + font-weight: bold; + cursor: pointer; +} + +.dialog-window .content { + line-height: 150%; +} + +.dialog-window .header, +.dialog-window .controls { + display:flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} +.dialog-window .controls > button { + padding: 0.3em; + min-width: 35%; +} +.dialog-window .controls > button[data-action="submit"] { + font-weight: bold; +} diff --git a/html/html/themes/ipfire/include/functions.pl b/html/html/themes/ipfire/include/functions.pl index bc66d7fdb..784b2f398 100644 --- a/html/html/themes/ipfire/include/functions.pl +++ b/html/html/themes/ipfire/include/functions.pl @@ -217,6 +217,34 @@ print <<END;
<strong>$system_release</strong> </div> + + <!-- Modal confirm dialog --> + <div class="dialog-overlay" id="modal-dialog_confirm"> + <div class="dialog-window"> + <div class="section header"> + <h3 data-textbox="title"></h3> + <button type="button" data-action="close">×</button> + </div> + <div class="section content"> + <span data-textbox="subheading"></span><br> + <strong data-textbox="message"></strong> + </div> + <div class="section controls"> + <button type="button" data-action="cancel">$Lang::tr{'dialog confirm cancel'}</button> + <button type="button" data-action="submit">$Lang::tr{'dialog confirm submit'}</button> + </div> + </div> + </div> + <script type="module"> + import wui from "/include/wui.js"; + + wui.i18n.load({ + "title": "$Lang::tr{'dialog confirm title'}", + "subheading": "$Lang::tr{'dialog confirm subheading'}" + }, "confirm"); + + wui.dialogs.confirm.modalId = "modal-dialog_confirm"; + </script> </body> </html> END diff --git a/langs/en/cgi-bin/en.pl b/langs/en/cgi-bin/en.pl index 729516538..df9deda53 100644 --- a/langs/en/cgi-bin/en.pl +++ b/langs/en/cgi-bin/en.pl @@ -84,6 +84,10 @@ 'ConnSched up' => 'Up', 'ConnSched weekdays' => 'Days of the week:', 'Daily' => 'Daily', +'dialog confirm title' => 'Permanently save changes?', +'dialog confirm subheading' => 'The following action cannot be undone:', +'dialog confirm submit' => 'Yes, continue', +'dialog confirm cancel' => 'No, cancel', 'Disabled' => 'Disabled', 'Edit an existing route' => 'Edit an existing route', 'Enter TOS' => 'Activate or deactivate TOS-bits <br /> and then press <i>Save</i>.',