/** * EGroupware eTemplate2 - Box widget * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package etemplate * @subpackage api * @link https://www.egroupware.org * @author Nathan Gray */ import {Et2Widget} from "../Et2Widget/Et2Widget"; import {et2_dialog} from "../et2_widget_dialog"; import {et2_button} from "../et2_widget_button"; import {LionDialog} from "@lion/dialog"; import {et2_createWidget, et2_widget} from "../et2_core_widget"; import {html, LitElement, ScopedElementsMixin, SlotMixin} from "@lion/core"; import {Et2DialogOverlay} from "./Et2DialogOverlay"; import {Et2DialogContent} from "./Et2DialogContent"; import {et2_template} from "../et2_widget_template"; import {etemplate2} from "../etemplate2"; export interface DialogButton { id : string, button_id? : number, text : string, image? : string, default? : boolean align? : string } /** * Et2Dialog widget * * You can use the static methods to get a dialog. * * Because of how LionDialog does its layout and rendering, it's easiest to separate the dialog popup from * the dialog content. This allows us to easily preserve the WebComponent styling. */ export class Et2Dialog extends Et2Widget(ScopedElementsMixin(SlotMixin(LionDialog))) { /** * Dialogs don't always get added to an etemplate, so we keep our own egw * * @type {IegwAppLocal} * @protected */ protected __egw : IegwAppLocal /** * As long as the template is a legacy widget, we want to hold on to the widget * When it becomes a WebComponent, we can just include it in render() * * @type {et2_template | null} * @protected */ protected _template_widget : etemplate2 | null; protected _template_promise : Promise; /** * Treat the dialog as an atomic operation, and use this promise to notify when * "done" instead of (or in addition to) using the callback function. * It gives the button ID and the dialog value. */ protected _complete_promise : Promise<[number, Object]>; /** * The ID of the button that was clicked. Always one of the button constants, * unless custom buttons were used * * @type {number|null} * @protected */ protected _button_id : number | null; /** * Types * @constant */ public static readonly PLAIN_MESSAGE : number = 0; public static readonly INFORMATION_MESSAGE : number = 1; public static readonly QUESTION_MESSAGE : number = 2; public static readonly WARNING_MESSAGE : number = 3; public static readonly ERROR_MESSAGE : number = 4; /* Pre-defined Button combos */ public static readonly BUTTONS_OK : number = 0; public static readonly BUTTONS_OK_CANCEL : number = 1; public static readonly BUTTONS_YES_NO : number = 2; public static readonly BUTTONS_YES_NO_CANCEL : number = 3; /* Button constants */ public static readonly CANCEL_BUTTON : number = 0; public static readonly OK_BUTTON : number = 1; public static readonly YES_BUTTON : number = 2; public static readonly NO_BUTTON : number = 3; get properties() { return { ...super.properties(), callback: Function, // There's an issue with Et2DialogContent.style being undefined, so this has to stay false until it gets // figured out modal: Boolean, title: String, buttons: Number, template: String, width: Number, height: Number, // We just pass these on to Et2DialogContent message: String, dialog_type: Number, icon: String, value: Object, } } get slots() { return { ...super.slots } } // Still not sure what this does, but it's important. // Seems to be related to the constructor, and what's available during the "creation" static get scopedElements() { return { ...super.scopedElements, 'et2-dialog-overlay-frame': Et2DialogOverlay, 'et2-dialog-content': Et2DialogContent }; } private readonly _buttons : DialogButton[][] = [ /* Pre-defined Button combos */ //BUTTONS_OK: 0, [{"button_id": Et2Dialog.OK_BUTTON, "text": 'ok', id: 'dialog[ok]', image: 'check', "default": true}], //BUTTONS_OK_CANCEL: 1, [ {"button_id": Et2Dialog.OK_BUTTON, "text": 'ok', id: 'dialog[ok]', image: 'check', "default": true}, { "button_id": Et2Dialog.CANCEL_BUTTON, "text": 'cancel', id: 'dialog[cancel]', image: 'cancel', align: "right" } ], //BUTTONS_YES_NO: 2, [ {"button_id": Et2Dialog.YES_BUTTON, "text": 'yes', id: 'dialog[yes]', image: 'check', "default": true}, {"button_id": Et2Dialog.NO_BUTTON, "text": 'no', id: 'dialog[no]', image: 'cancelled'} ], //BUTTONS_YES_NO_CANCEL: 3, [ {"button_id": Et2Dialog.YES_BUTTON, "text": 'yes', id: 'dialog[yes]', image: 'check', "default": true}, {"button_id": Et2Dialog.NO_BUTTON, "text": 'no', id: 'dialog[no]', image: 'cancelled'}, { "button_id": Et2Dialog.CANCEL_BUTTON, "text": 'cancel', id: 'dialog[cancel]', image: 'cancel', align: "rignt" } ] ]; constructor(parent_egw? : string | IegwAppLocal) { super(); if(parent_egw) { this._setApiInstance(parent_egw); } // Needs to not be modal until the style thing is figured out this.modal = false; this.__value = {}; this._onOpen = this._onOpen.bind(this); this._onClose = this._onClose.bind(this); this._onClick = this._onClick.bind(this); // Create this here so we have something, otherwise the creator might continue with undefined while we // wait for the dialog to complete & open this._complete_promise = new Promise<[number, Object]>((resolve) => { this._completeResolver = value => resolve(value); }); } connectedCallback() { super.connectedCallback(); // Wait for everything to complete, then auto-open this.getUpdateComplete().then(() => { // _overlayCtrl is not available earlier this._overlayCtrl?.addEventListener("show", this._onOpen); this._overlayCtrl.addEventListener('hide', this._onClose); window.setTimeout(this.open, 0); }); } disconnectedCallback() { super.disconnectedCallback(); this._overlayCtrl.removeEventListener("hide", this._onClose); this._overlayCtrl.removeEventListener("show", this._onOpen); } // Need to wait for Overlay async getUpdateComplete() { await super.getUpdateComplete(); await this._overlayContentNode.getUpdateComplete(); // Wait for template to finish loading if(this._template_widget) { await this._template_promise; } // This calls _onClose() when the dialog is closed this._overlayContentNode.querySelectorAll("et2-button").forEach((button) => button.addEventListener("click", this._onClick)); } getComplete() : Promise<[number, Object]> { return this._complete_promise; } _onOpen() { this._button_id = null; this._complete_promise = this._complete_promise || new Promise<[number, Object]>((resolve) => this._completeResolver); } _onClose(ev : PointerEvent) { this._completeResolver([this._button_id, this.value]); this._button_id = null; this._complete_promise = undefined; // No real need to do this automatically, dialog could be reused without this this._overlayCtrl.teardown(); this.remove(); } _onClick(ev : MouseEvent) { // @ts-ignore this._button_id = ev.target?.getAttribute("button_id") ? parseInt(ev.target?.getAttribute("button_id")) : (ev.target?.getAttribute("id") || null); // Handle anything bound via et2 onclick property try { let et2_widget_result = super._handleClick(ev); if(!et2_widget_result) { ev.preventDefault(); ev.stopPropagation(); return false; } } catch(e) { console.log(e); } // Callback expects (button_id, value) try { let callback_result = this.callback ? this.callback(this._button_id, this.value) : true; if(callback_result === false) { ev.preventDefault(); ev.stopPropagation(); return false; } } catch(e) { console.log(e); } this.close(); } /** * Returns the values of any widgets in the dialog. This does not include * the buttons, which are only supplied for the callback. */ get value() : Object { let value = this.__value; if(this._template_widget) { value = this._template_widget.getValues(this._template_widget.widgetContainer); } return value; } /** * Fire the close-overlay event to use all registered listeners * @deprecated */ destroy() { this._overlayContentNode.dispatchEvent(new Event('close-overlay')); } /** * @deprecated * @returns {Object} */ get_value() : Object { console.warn("Deprecated get_value() called"); return this.value; } set value(new_value) { this.__value = new_value; } set template(new_template_name) { let old_template = this.template; this.__template = new_template_name; this.requestUpdate("template", old_template); } get template() { return this.__template; } updated(changedProperties) { super.updated(changedProperties); if(changedProperties.has("template")) { this._loadTemplate(); } } _loadTemplate() { if(this._template_widget) { this._template_widget.clear(); } this._template_widget = new etemplate2(this._overlayContentNode._contentNode); if(this.template.indexOf('.xet') > 0) { // File name provided, fetch from server this._template_promise = this._template_widget.load("", this.template, this.__value || {content: {}},); } else { // Just template name, it better be loaded already this._template_promise = this._template_widget.load(this.template, '', this.__value || {}, // true: do NOT call et2_ready, as it would overwrite this.et2 in app.js undefined, undefined, true); } // Don't let dialog closing destroy the parent session if(this._template_widget.etemplate_exec_id && this._template_widget.app) { for(let et of etemplate2.getByApplication(this._template_widget.app)) { if(et !== this._template_widget && et.etemplate_exec_id === this._template_widget.etemplate_exec_id) { // Found another template using that exec_id, don't destroy when dialog closes. this._template_widget.unbind_unload(); break; } } } // set template-name as id, to allow to style dialogs //this.div.children().attr('id', new_template_name.replace(/^(.*\/)?([^/]+)(\.xet)?$/, '$2').replace(/\./g, '-')); } render() { return this._overlayTemplate(); } /** * Defining this overlay as a templates from OverlayMixin * this is our source to give as .contentNode to OverlayController. * @protected */ protected _overlayTemplate() { return html`
${this.title} ${this._contentTemplate()}
`; } /** * @override Configures OverlayMixin */ get _overlayContentNode() { if(this._cachedOverlayContentNode) { return this._cachedOverlayContentNode; } this._cachedOverlayContentNode = /** @type {HTMLElement} */ ( /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('.dialog__overlay-frame') ); return this._cachedOverlayContentNode; } _contentTemplate() { if(this.template) { return html`
`; } else { return html` ${this.message} `; } } _getButtons() { if(Number.isInteger(this.buttons)) { return this._buttons[this.buttons]; } else if(Array.isArray(this.buttons)) { return this.buttons; } else { // TODO: Find buttons in template } } /** * @override Configures OverlayMixin * @desc overrides default configuration options for this component * @returns {Object} */ _defineOverlayConfig() { let not_modal = { hasBackdrop: false, preventsScroll: false, trapsKeyboardFocus: false, } return { ...super._defineOverlayConfig(), hidesOnEscape: true, ...(this.modal ? {} : not_modal) } } /** * Inject application specific egw object with loaded translations into the dialog * * @param {string|egw} _egw_or_appname egw object with already loaded translations or application name to load translations for */ _setApiInstance(_egw_or_appname ? : string | IegwAppLocal) { if(typeof _egw_or_appname == 'undefined') { // @ts-ignore _egw_or_appname = egw_appName; } // if egw object is passed in because called from et2, just use it if(typeof _egw_or_appname != 'string') { this.__egw = _egw_or_appname; } // otherwise use given appname to create app-specific egw instance and load default translations else { this.__egw = egw(_egw_or_appname); this.egw().langRequireApp(this.egw().window, _egw_or_appname); } } egw() : IegwAppLocal { if(this.__egw) { return this.__egw; } else { return super.egw(); } } /** * Show a confirmation dialog * * @param {function} _callback Function called when the user clicks a button. The context will be the et2_dialog widget, and the button constant is passed in. * @param {string} _message Message to be place in the dialog. * @param {string} _title Text in the top bar of the dialog. * @param _value passed unchanged to callback as 2. parameter * @param {integer|array} _buttons One of the BUTTONS_ constants defining the set of buttons at the bottom of the box * @param {integer} _type One of the message constants. This defines the style of the message. * @param {string} _icon URL of an icon to display. If not provided, a type-specific icon will be used. * @param {string|egw} _egw_or_appname egw object with already laoded translations or application name to load translations for * * @return {Et2Dialog} You can use dialog.getComplete().then(...) to wait for the dialog to close. */ static show_dialog(_callback? : Function, _message? : string, _title? : string, _value? : object, _buttons?, _type? : number, _icon? : string, _egw_or_appname? : string | IegwAppLocal) { // Just pass them along, widget handles defaults & missing let dialog = document.createElement('et2-dialog'); dialog._setApiInstance(_egw_or_appname); dialog.transformAttributes({ callback: _callback || function() {}, message: _message, title: _title || dialog.egw().lang('Confirmation required'), buttons: typeof _buttons != 'undefined' ? _buttons : Et2Dialog.BUTTONS_YES_NO, dialog_type: typeof _type != 'undefined' ? _type : Et2Dialog.QUESTION_MESSAGE, icon: _icon, value: _value, width: 'auto', /* TODO: There's something going on with our Et2Widgets that they don't get a proper .style property This lack of .style causes problems when we go modal. Non-modal works. */ modal: false }); document.body.appendChild(dialog); return dialog; }; /** * Show an alert message with OK button * * @param {string} _message Message to be place in the dialog. * @param {string} _title Text in the top bar of the dialog. * @param {integer} _type One of the message constants. This defines the style of the message. * * @return Promise<[ button_id : number, value : Object ]> will resolve when the dialog closes */ static alert(_message? : string, _title? : string, _type?) { let dialog = document.createElement('et2-dialog'); dialog._setApiInstance(); dialog.transformAttributes({ callback: function() {}, message: _message, title: _title, buttons: et2_dialog.BUTTONS_OK, dialog_type: _type || et2_dialog.INFORMATION_MESSAGE }); document.body.appendChild(dialog); return dialog.getComplete(); } /** * Show a prompt dialog * * @param {function} _callback Function called when the user clicks a button. The button constant is passed in along with the value. * @param {string} _message Message to be place in the dialog. * @param {string} _title Text in the top bar of the dialog. * @param {string} _value for prompt, passed to callback as 2. parameter * @param {integer|array} _buttons One of the BUTTONS_ constants defining the set of buttons at the bottom of the box * @param {string|egw} _egw_or_appname egw object with already laoded translations or application name to load translations for * * @return {Et2Dialog} You can use dialog.getComplete().then(...) to wait for the dialog to close. */ static show_prompt(_callback, _message, _title?, _value?, _buttons?, _egw_or_appname?) { let dialog = document.createElement('et2-dialog'); dialog._setApiInstance(); dialog.transformAttributes({ callback: _callback, title: _title || 'Input required', buttons: _buttons || et2_dialog.BUTTONS_OK_CANCEL, value: { content: { value: _value, message: _message } }, template: egw.webserverUrl + '/api/templates/default/prompt.xet', class: "et2_prompt" }); document.body.appendChild(dialog); return dialog } /** * Method to build a confirmation dialog only with * YES OR NO buttons and submit content back to server * * @param {widget} _senders widget that has been clicked * @param {String} _dialogMsg message shows in dialog box * @param {String} _titleMsg message shows as a title of the dialog box * @param {Bool} _postSubmit true: use postSubmit instead of submit * * @description submit the form contents including the button that has been pressed */ static confirm(_senders, _dialogMsg, _titleMsg, _postSubmit?) { let senders = _senders; let buttonId = _senders.id; let dialogMsg = (typeof _dialogMsg != "undefined") ? _dialogMsg : ''; let titleMsg = (typeof _titleMsg != "undefined") ? _titleMsg : ''; let egw = _senders instanceof et2_widget ? _senders.egw() : undefined; let callbackDialog = function(button_id) { if(button_id == Et2Dialog.YES_BUTTON) { if(_postSubmit) { senders.getRoot().getInstanceManager().postSubmit(buttonId); } else if(senders.instanceOf(et2_button) && senders.getType() !== "buttononly") { senders.clicked = true; senders.getInstanceManager().submit(senders, false, senders.options.novalidate); senders.clicked = false; } else { senders.getRoot().getInstanceManager().submit(buttonId); } } }; Et2Dialog.show_dialog(callbackDialog, dialogMsg, titleMsg, {}, et2_dialog.BUTTONS_YES_NO, et2_dialog.WARNING_MESSAGE, undefined, egw); }; /** * Show a dialog for a long-running, multi-part task * * Given a server url and a list of parameters, this will open a dialog with * a progress bar, asynchronously call the url with each parameter, and update * the progress bar. * Any output from the server will be displayed in a box. * * When all tasks are done, the callback will be called with boolean true. It will * also be called if the user clicks a button (OK or CANCEL), so be sure to * check to avoid executing more than intended. * * @param {function} _callback Function called when the user clicks a button, * or when the list is done processing. The context will be the et2_dialog * widget, and the button constant is passed in. * @param {string} _message Message to be place in the dialog. Usually just * text, but DOM nodes will work too. * @param {string} _title Text in the top bar of the dialog. * @param {string} _menuaction the menuaction function which should be called and * which handles the actual request. If the menuaction is a full featured * url, this one will be used instead. * @param {Array[]} _list - List of parameters, one for each call to the * address. Multiple parameters are allowed, in an array. * @param {string|egw} _egw_or_appname egw object with already laoded translations or application name to load translations for * * @return {et2_dialog} */ static long_task(_callback, _message, _title, _menuaction, _list, _egw_or_appname) { let parent = et2_dialog._create_parent(_egw_or_appname); let egw = parent.egw(); // Special action for cancel let buttons = [ {"button_id": et2_dialog.OK_BUTTON, "text": egw.lang('ok'), "default": true, "disabled": true}, { "button_id": et2_dialog.CANCEL_BUTTON, "text": egw.lang('cancel'), click: function() { // Cancel run cancel = true; jQuery("button[button_id=" + et2_dialog.CANCEL_BUTTON + "]", dialog.div.parent()).button("disable"); update.call(_list.length, ''); } } ]; let dialog = et2_createWidget("dialog", { template: egw.webserverUrl + '/api/templates/default/long_task.xet', value: { content: { message: _message } }, callback: function(_button_id, _value) { if(_button_id == et2_dialog.CANCEL_BUTTON) { cancel = true; } if(typeof _callback == "function") { _callback.call(this, _button_id, _value.value); } }, title: _title || egw.lang('please wait...'), buttons: buttons }, parent); // OK starts disabled jQuery("button[button_id=" + et2_dialog.OK_BUTTON + "]", dialog.div.parent()).button("disable"); let log = null; let progressbar = null; let cancel = false; let totals = { success: 0, skipped: 0, failed: 0, widget: null }; // Updates progressbar & log, calls next step let update = function(response) { // context is index let index = this || 0; progressbar.set_value(100 * (index / _list.length)); progressbar.set_label(index + ' / ' + _list.length); // Display response information switch(response.type) { case 'error': jQuery("
") .text(response.data) .appendTo(log); totals.failed++; // Ask to retry / ignore / abort et2_createWidget("dialog", { callback: function(button) { switch(button) { case 'dialog[cancel]': cancel = true; return update.call(index, ''); case 'dialog[skip]': // Continue with next index totals.skipped++; return update.call(index, ''); default: // Try again with previous index return update.call(index - 1, ''); } }, message: response.data, title: '', buttons: [ // These ones will use the callback, just like normal {text: egw.lang("Abort"), id: 'dialog[cancel]'}, {text: egw.lang("Retry"), id: 'dialog[retry]'}, {text: egw.lang("Skip"), id: 'dialog[skip]', class: "ui-priority-primary", default: true} ], dialog_type: et2_dialog.ERROR_MESSAGE }, parent); // Early exit return; default: if(response && typeof response === "string") { totals.success++; jQuery("
") .text(response) .appendTo(log); } else { jQuery("
") .text(JSON.stringify(response)) .appendTo(log); } } // Scroll to bottom let height = log[0].scrollHeight; log.scrollTop(height); // Update totals totals.widget.set_value(egw.lang( "Total: %1 Successful: %2 Failed: %3 Skipped: %4", _list.length, totals.success, totals.failed, totals.skipped )); // Fire next step if(!cancel && index < _list.length) { var parameters = _list[index]; if(typeof parameters != 'object') { parameters = [parameters]; } // Async request, we'll take the next step in the callback // We can't pass index = 0, it looks like false and causes issues egw.json(_menuaction, parameters, update, index + 1, true, index + 1).sendRequest(); } else { // All done if(!cancel) { progressbar.set_value(100); } jQuery("button[button_id=" + et2_dialog.CANCEL_BUTTON + "]", dialog.div.parent()).button("disable"); jQuery("button[button_id=" + et2_dialog.OK_BUTTON + "]", dialog.div.parent()).button("enable"); if(!cancel && typeof _callback == "function") { _callback.call(dialog, true, response); } } }; jQuery(dialog.template.DOMContainer).on('load', function() { // Get access to template widgets log = jQuery(dialog.template.widgetContainer.getWidgetById('log').getDOMNode()); progressbar = dialog.template.widgetContainer.getWidgetById('progressbar'); progressbar.set_label('0 / ' + _list.length); totals.widget = dialog.template.widgetContainer.getWidgetById('totals'); // Start window.setTimeout(function() { update.call(0, ''); }, 0); }); return dialog; } } customElements.define("et2-dialog", Et2Dialog); // make et2_dialog publicly available as we need to call it from templates { window['et2_dialog'] = Et2Dialog; window['Et2Dialog'] = Et2Dialog; }