/** * 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_button} from "../et2_widget_button"; import {LionDialog} from "@lion/dialog"; import {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"; import {IegwAppLocal} from "../../jsapi/egw_global"; import interact from "@interactjs/interactjs"; import type {InteractEvent} from "@interactjs/core/InteractEvent"; export interface DialogButton { id : string, button_id? : number, label : string, image? : string, default? : boolean align? : string, disabled? : boolean } /** * Et2Dialog widget * * A common dialog widget that makes it easy to inform users or prompt for information. * * It is possible to have a custom dialog by using a template, but you can also use * the static method Et2Dialog.show_dialog(). At its simplest, you can just use: * ```ts * Et2Dialog.show_dialog(false, "Operation completed"); * ``` * * Or a more complete example: * ```js * let callback = function (button_id) * { * if(button_id == Et2Dialog.YES_BUTTON) * { * // Do stuff * } * else if (button_id == Et2Dialog.NO_BUTTON) * { * // Other stuff * } * else if (button_id == Et2Dialog.CANCEL_BUTTON) * { * // Abort * } * }. * let dialog = Et2Dialog.show_dialog( * callback, "Erase the entire database?","Break things", {} // value * et2_dialog.BUTTONS_YES_NO_CANCEL, et2_dialog.WARNING_MESSAGE * ); * ``` * * Or, using Promises instead of a callback: * ```ts * let result = await Et2Dialog.show_prompt(null, "Name").getComplete(); * if(result.button_id == Et2Dialog.OK_BUTTON) * { * // Do stuff with result.value * } * ``` * * The parameters for the above are all optional, except callback (which can be null) and message: * callback - function called when the dialog closes, or false/null. * The ID of the button will be passed. Button ID will be one of the Et2Dialog.*_BUTTON constants. * The callback is _not_ called if the user closes the dialog with the X in the corner, or presses ESC. * message - (plain) text to display * title - Dialog title * value (for prompt) * buttons - Et2Dialog BUTTONS_* constant, or an array of button settings. Use DialogButton interface. * dialog_type - Et2Dialog *_MESSAGE constant * icon - URL of icon * * Note that these methods will _not_ block program flow while waiting for user input unless you use "await" on getComplete(). * The user's input will be provided to the callback. * * You can also create a custom dialog using an etemplate, even setting all the buttons yourself. * ```ts * // Pass egw in the constructor * let dialog = new Et2Dialog(my_egw_reference); * * // Set attributes. They can be set in any way, but this is convenient. * dialog.transformAttributes({ * // If you use a template, the second parameter will be the value of the template, as if it were submitted. * callback: function(button_id, value) {...}, // return false to prevent dialog closing * buttons: [ * // These ones will use the callback, just like normal. Use DialogButton interface. * {label: egw.lang("OK"),id:"OK", default: true}, * {label: egw.lang("Yes"),id:"Yes"}, * {label: egw.lang("Sure"),id:"Sure"}, * {label: egw.lang("Maybe"),click: function() { * // If you override, 'this' will be the dialog DOMNode. * // Things get more complicated. * // Do what you like here * }}, * * ], * title: 'Why would you want to do this?', * template:"/egroupware/addressbook/templates/default/edit.xet", * value: { content: {...default values}, sel_options: {...}...} * }); * // Add to DOM, dialog will auto-open * document.body.appendChild(dialog); * // If you want, wait for close * let result = await dialog.getComplete(); *``` * * 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. You should interact with the * Et2Dialog though, and ignore the Et2DialogOverlay & Et2DialogContent. */ 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; static get properties() { return { ...super.properties, callback: Function, /** * Allow other controls to be accessed while the dialog is visible */ modal: Boolean, /** * Title for the dialog, goes in the header */ title: String, /** * Pre-defined group of buttons, one of the BUTTONS_* */ buttons: Number, /** * Instead of a message, show this template file instead */ template: String, // Force size on the dialog. Normally it sizes to content. width: Number, height: Number, // We just pass these on to Et2DialogContent message: String, dialog_type: Number, icon: String, value: Object, /** * Automatically destroy the dialog when it closes. Set to false to keep the dialog around. */ destroyOnClose: Boolean, /** * Legacy-option for appending dialog into a specific dom node */ appendTo: String, /** * When it's set to false dialog won't get closed by hitting Esc */ hideOnEscape: Boolean, /** * When set to true it removes the close button from dialog's header */ noCloseButton: Boolean } } 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 }; } /* * List of properties that get translated * Done separately to not interfere with properties - if we re-define label property, * labels go missing. */ static get translate() { return { ...super.translate, title: true, message: true } } private readonly _buttons : DialogButton[][] = [ /* Pre-defined Button combos */ //BUTTONS_OK: 0, [{"button_id": Et2Dialog.OK_BUTTON, label: 'ok', id: 'dialog[ok]', image: 'check', "default": true}], //BUTTONS_OK_CANCEL: 1, [ {"button_id": Et2Dialog.OK_BUTTON, label: 'ok', id: 'dialog[ok]', image: 'check', "default": true}, { "button_id": Et2Dialog.CANCEL_BUTTON, label: 'cancel', id: 'dialog[cancel]', image: 'cancel', align: "right" } ], //BUTTONS_YES_NO: 2, [ {"button_id": Et2Dialog.YES_BUTTON, label: 'yes', id: 'dialog[yes]', image: 'check', "default": true}, {"button_id": Et2Dialog.NO_BUTTON, label: 'no', id: 'dialog[no]', image: 'cancel'} ], //BUTTONS_YES_NO_CANCEL: 3, [ {"button_id": Et2Dialog.YES_BUTTON, label: 'yes', id: 'dialog[yes]', image: 'check', "default": true}, {"button_id": Et2Dialog.NO_BUTTON, label: 'no', id: 'dialog[no]', image: 'cancelled'}, { "button_id": Et2Dialog.CANCEL_BUTTON, label: 'cancel', id: 'dialog[cancel]', image: 'cancel', align: "right" } ] ]; constructor(parent_egw? : string | IegwAppLocal) { super(); if(parent_egw) { this._setApiInstance(parent_egw); } this.modal = true; this.dialog_type = Et2Dialog.PLAIN_MESSAGE; this.destroyOnClose = true; this.hideOnEscape = this.hideOnEscape === false ? false : true; this.__value = {}; this._onOpen = this._onOpen.bind(this); this._onClose = this._onClose.bind(this); this._onClick = this._onClick.bind(this); this._onMoveResize = this._onMoveResize.bind(this); this._adoptTemplateButtons = this._adoptTemplateButtons.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); this._setupMoveResize(this._overlayContentNode); // Now consumers can listen for "open" event, though getUpdateComplete().then(...) also works this.dispatchEvent(new Event('open')); } _onClose(ev : PointerEvent) { this._completeResolver([this._button_id, this.value]); this._button_id = null; this._complete_promise = undefined; this.dispatchEvent(new Event('close')); if(this.destroyOnClose) { if(this._template_widget) { this._template_widget.clear(); } 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(); } /** * Handle moving and resizing * * @param event */ _onMoveResize(event : InteractEvent) { let target = event.target let x = (parseFloat(target.getAttribute('data-x')) || 0) let y = (parseFloat(target.getAttribute('data-y')) || 0) // update the element's style target.style.width = event.rect.width + 'px' target.style.height = event.rect.height + 'px' // translate when resizing from top or left edges if(event.type == "resizemove") { x += event.deltaRect.left y += event.deltaRect.top } else { x += event.delta.x; y += event.delta.y; } target.style.transform = 'translate(' + x + 'px,' + y + 'px)' target.setAttribute('data-x', x) target.setAttribute('data-y', y) } /** * 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 && this._template_widget.widgetContainer) { 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() { // Can't return undefined or requestUpdate() will not notice a change return this._template_widget || null; } updated(changedProperties) { super.updated(changedProperties); if(changedProperties.has("template")) { this._loadTemplate(); } } _loadTemplate() { if(this._template_widget) { this._template_widget.clear(); } // Etemplate wants a content if(typeof this.__value.content === "undefined") { this.__value.content = {}; } this._template_widget = new etemplate2(this._overlayContentNode._contentNode); // Fire an event so consumers can do their thing - etemplate will fire its own load event when its done if(!this.dispatchEvent(new CustomEvent("before-load", { bubbles: true, cancelable: true, detail: this._template_widget }))) { return; } if(this.__template.indexOf('.xet') > 0) { let template = this.__template; // inject preprocessor, if not already in template-url const webserverUrl = this.egw().webserverUrl; if(!template.match(new RegExp(webserverUrl + '/api/etemplate.php'))) { template = template.replace(new RegExp(webserverUrl), webserverUrl + '/api/etemplate.php'); } // if we have no cache-buster, reload daily if(template.indexOf('?') === -1) { template += '?' + ((new Date).valueOf() / 86400 | 0).toString(); } // File name provided, fetch from server this._template_promise = this._template_widget.load("", 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._template_widget.DOMContainer.setAttribute('id', this.__template.replace(/^(.*\/)?([^/]+?)(\.xet)?(\?.*)?$/, '$2').replace(/\./g, '-')); // Look for buttons after load this._overlayContentNode._contentNode.addEventListener("load", this._adoptTemplateButtons); } render() { return this._overlayTemplate(); } /** * Don't allow any children here, pass them on to the content node * * @param {HTMLElement} node * @returns {any} */ appendChild(node : HTMLElement) { return this._overlayContentNode.appendChild(node); } /** * 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)) { // Translate as needed, since we're not calling transformAttributes() on the buttons // when we create them in a render() return this._buttons[this.buttons].map((but) => { but.label = this.egw().lang(but.label); return but }); } else if(Array.isArray(this.buttons)) { return this.buttons; } } /** * Search for buttons in the template, and try to slot them * * We don't want to just grab them all, as there may be other buttons. */ _adoptTemplateButtons() { // Check for something with buttons slot set let search_in = this._template_widget?.DOMContainer || this._overlayContentNode._contentNode; let template_buttons = search_in.querySelectorAll('[slot="buttons"]') || // Look for a dialog footer, which will contain several buttons and possible other widgets search_in.querySelectorAll(".dialogFooterToolbar") || // Look for buttons anywhere search_in.querySelectorAll("et2-button"); if(template_buttons) { template_buttons.forEach((button) => { button.setAttribute("slot", "buttons"); this._overlayContentNode.appendChild(button); }) } return template_buttons; } /** * @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: this.hideOnEscape, ...(this.modal ? {} : not_modal) } } _setupMoveResize(element) { // Quick calculation of min size - dialog is made up of header, content & buttons let minHeight = 0; for(let e of element.shadowRoot.querySelector('.overlay').children) { minHeight += e.getBoundingClientRect().height + parseFloat(getComputedStyle(e).marginTop) + parseFloat(getComputedStyle(e).marginBottom) } interact(element) .resizable({ edges: {bottom: true, right: true}, listeners: { move: this._onMoveResize }, modifiers: [ // keep the edges inside the parent interact.modifiers.restrictEdges({ outer: 'parent' }), // minimum size interact.modifiers.restrictSize({ min: {width: 100, height: minHeight} }) ] }) .draggable({ allowFrom: ".overlay__heading", listeners: { move: this._onMoveResize }, }); } /** * 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 }); 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: Et2Dialog.BUTTONS_OK, dialog_type: _type || Et2Dialog.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({ // Wrap callback to _only_ return _value.value, not the whole object like we normally would callback: function(_button_id, _value) { if(typeof _callback == "function") { _callback.call(this, _button_id, _value.value); } }, title: _title || 'Input required', buttons: _buttons || Et2Dialog.BUTTONS_OK_CANCEL, value: { content: { value: _value, message: _message } }, template: egw.webserverUrl + '/api/etemplate.php/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 {boolean} _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, {}, Et2Dialog.BUTTONS_YES_NO, Et2Dialog.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) { // Special action for cancel let buttons = [ // OK starts disabled {"button_id": Et2Dialog.OK_BUTTON, label: 'ok', "default": true, "disabled": true, image: "check"}, { "button_id": Et2Dialog.CANCEL_BUTTON, label: 'cancel', image: "cancel", click: function() { // Cancel run cancel = true; jQuery("button[button_id=" + Et2Dialog.CANCEL_BUTTON + "]", dialog.div.parent()).button("disable"); update.call(_list.length, ''); } } ]; let dialog = new Et2Dialog(_egw_or_appname); dialog.transformAttributes({ template: dialog.egw().webserverUrl + '/api/etemplate.php/api/templates/default/long_task.xet', value: { content: { message: _message } }, callback: function(_button_id, _value) { if(_button_id == Et2Dialog.CANCEL_BUTTON) { cancel = true; } if(typeof _callback == "function") { _callback.call(this, _button_id, _value.value); } }, title: _title || 'please wait...', buttons: buttons }); dialog.egw().window.document.body.appendChild(dialog); 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': let div = document.createElement("DIV"); div.className = "message error"; div.textContent = response.data log.appendChild(div); totals.failed++; // Ask to retry / ignore / abort let retry = new Et2Dialog(dialog.egw()); retry.transformAttributes({ callback: function(button) { debugger; 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 {label: dialog.egw().lang("Abort"), id: 'dialog[cancel]'}, {label: dialog.egw().lang("Retry"), id: 'dialog[retry]'}, {label: dialog.egw().lang("Skip"), id: 'dialog[skip]', default: true} ], dialog_type: Et2Dialog.ERROR_MESSAGE }); dialog.egw().window.document.body.appendChild(retry); // Early exit return; default: if(response && typeof response === "string") { totals.success++; let div = document.createElement("DIV"); div.className = "message"; div.textContent = response log.appendChild(div); } else if(response) { let div = document.createElement("DIV"); div.className = "message error"; div.textContent = JSON.stringify(response) log.appendChild(div); } } // Scroll to bottom let height = log.scrollHeight; log.scrollTop = height; // Update totals totals.widget.set_value(dialog.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 dialog.egw().json(_menuaction, parameters, update, index + 1, true, index + 1).sendRequest(); } else { // All done if(!cancel) { progressbar.set_value(100); } // Disable cancel (it's too late), enable OK dialog._overlayContentNode.querySelector('et2-button[button_id="' + Et2Dialog.CANCEL_BUTTON + '"]').setAttribute("disabled") dialog._overlayContentNode.querySelector('et2-button[button_id="' + Et2Dialog.OK_BUTTON + '"]').removeAttribute("disabled") if(!cancel && typeof _callback == "function") { _callback.call(dialog, true, response); } } }; // Wait for dialog, then start the process dialog.getUpdateComplete().then(function() { // Get access to template widgets log = dialog.template.widgetContainer.getDOMWidgetById('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; } } //@ts-ignore TS doesn't recognize Et2Dialog as HTMLEntry 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; }