diff --git a/kdots/assets/styles/framework.css b/kdots/assets/styles/framework.css index 8c4191a2c6..008fa57197 100644 --- a/kdots/assets/styles/framework.css +++ b/kdots/assets/styles/framework.css @@ -5,6 +5,21 @@ body { overflow: clip; padding: 0px; margin: 0px; + /** Messages **/ + /** end messages **/ +} +html .sl-toast-stack, +body .sl-toast-stack { + top: auto; + bottom: 0px; +} +html .sl-toast-stack sl-alert et2-checkbox, +body .sl-toast-stack sl-alert et2-checkbox { + padding-top: var(--sl-spacing-large); +} +html #egw_message, +body #egw_message { + display: none; } .egw_menu::part(popup) { z-index: var(--sl-z-index-dropdown); diff --git a/kdots/assets/styles/framework.less b/kdots/assets/styles/framework.less index ffdbe18b27..92a02637c3 100644 --- a/kdots/assets/styles/framework.less +++ b/kdots/assets/styles/framework.less @@ -4,6 +4,23 @@ html, body { overflow: clip; padding: 0px; margin: 0px; + + /** Messages **/ + + .sl-toast-stack { + top: auto; + bottom: 0px; + + sl-alert et2-checkbox { + padding-top: var(--sl-spacing-large); + } + } + + #egw_message { + display: none + } + + /** end messages **/ } .egw_menu::part(popup) diff --git a/kdots/js/EgwFramework.ts b/kdots/js/EgwFramework.ts index fc99c5b999..6ac7dc3369 100644 --- a/kdots/js/EgwFramework.ts +++ b/kdots/js/EgwFramework.ts @@ -3,12 +3,13 @@ import {customElement} from "lit/decorators/custom-element.js"; import {property} from "lit/decorators/property.js"; import {classMap} from "lit/directives/class-map.js"; import {repeat} from "lit/directives/repeat.js"; +import {until} from "lit/directives/until.js"; import "@shoelace-style/shoelace/dist/components/split-panel/split-panel.js"; import styles from "./EgwFramework.styles"; import {egw} from "../../api/js/jsapi/egw_global"; -import {SlDropdown, SlTab, SlTabGroup} from "@shoelace-style/shoelace"; +import {SlAlert, SlDropdown, SlTab, SlTabGroup} from "@shoelace-style/shoelace"; import {EgwFrameworkApp} from "./EgwFrameworkApp"; -import {until} from "lit/directives/until.js"; +import {EgwFrameworkMessage} from "./EgwFrameworkMessage"; /** * @summary Accessable, webComponent-based EGroupware framework @@ -105,6 +106,9 @@ export class EgwFramework extends LitElement // Keep track of open popups private _popups : Window[] = []; + // Keep track of open messages + private _messages : SlAlert[] = []; + private get tabs() : SlTabGroup { return this.shadowRoot.querySelector("sl-tab-group");} connectedCallback() @@ -143,6 +147,18 @@ export class EgwFramework extends LitElement // These need egw fully loaded this.getEgwComplete().then(() => { + // Regisert the "message" plugin + this.egw.registerJSONPlugin((type, res, req) => + { + //Check whether all needed parameters have been passed and call the alertHandler function + if((typeof res.data.message != 'undefined')) + { + this.message(res.data.message, res.data.type) + return true; + } + throw 'Invalid parameters'; + }, null, 'message'); + // Quick add this.egw.link_quick_add('topmenu_info_quick_add'); @@ -493,6 +509,67 @@ export class EgwFramework extends LitElement app.setSidebox(sideboxData, hash); } + /** + * Show a message, with an optional type + * + * @param {string} message + * @param {"" | "help" | "info" | "error" | "warning" | "success"} type + * @param {number} duration The length of time, seconds, the alert will show before closing itself. Success + * messages are shown for 5s, other messages require manual closing by the user. + * @param {boolean} closable=true Message can be closed by the user + * @param {string} _discardID unique string id (appname:id) in order to register + * the message as discardable. Discardable messages offer a checkbox to never be shown again. + * If no appname given, the id will be prefixed with current app. The discardID will be stored in local storage. + * @returns {Promise} SlAlert element + */ + public async message(message : string, type : "" | "help" | "info" | "error" | "warning" | "success" = "", duration : null | number = null, closable = true, _discardID : null | string = null) + { + if(message && !type) + { + const error_reg_exp = new RegExp('(error|' + egw.lang('error') + ')', 'i'); + type = message.match(error_reg_exp) ? 'error' : 'success'; + } + + // Do not add a same message twice if it's still not dismissed + const hash = await this.egw.hashString(message); + if(typeof this._messages[hash] !== "undefined") + { + return this._messages[hash]; + } + + // Already discarded, just stop + if(_discardID && EgwFrameworkMessage.isDiscarded(_discardID)) + { + return; + } + + const attributes = { + type: type, + closable: closable, + duration: duration * 1000, + discard: _discardID, + message: message, + "data-hash": hash + } + if(!duration) + { + delete attributes.duration; + } + + const alert = Object.assign(document.createElement("egw-message"), attributes); + alert.addEventListener("sl-hide", (e) => + { + delete this._messages[e.target["data-hash"] ?? ""]; + }); + this._messages[hash] = alert; + document.body.append(alert); + await alert.updateComplete; + + alert.toast(); + + return alert; + } + protected getBaseUrl() {return "";} /** diff --git a/kdots/js/EgwFrameworkApp.styles.ts b/kdots/js/EgwFrameworkApp.styles.ts index d127896d5b..e82c304f91 100644 --- a/kdots/js/EgwFrameworkApp.styles.ts +++ b/kdots/js/EgwFrameworkApp.styles.ts @@ -158,6 +158,7 @@ export default css` .egw_fw_app__loading { text-align: center; + margin: auto; sl-spinner { --track-width: 1rem; diff --git a/kdots/js/EgwFrameworkMessage.ts b/kdots/js/EgwFrameworkMessage.ts new file mode 100644 index 0000000000..3745d40cd4 --- /dev/null +++ b/kdots/js/EgwFrameworkMessage.ts @@ -0,0 +1,202 @@ +import {html, LitElement, nothing, TemplateResult} from "lit"; +import {customElement} from "lit/decorators/custom-element.js"; +import {property} from "lit/decorators/property.js"; +import {SlAlert} from "@shoelace-style/shoelace"; +import {egw} from "../../api/js/jsapi/egw_global"; +import {Et2Checkbox} from "../../api/js/etemplate/Et2Checkbox/Et2Checkbox"; + +/** + * @summary System message + * + * @dependency sl-alert + * @dependency sl-icon + * + * @slot - Content + * @slot icon - An icon to show in the message + * + * @csspart base - Wraps it all. + * @csspart icon - + */ +@customElement('egw-message') +export class EgwFrameworkMessage extends LitElement +{ + /** + * The type of message + * @type { "help" | "info" | "error" | "warning" | "success"} + */ + @property() + type : "help" | "info" | "error" | "warning" | "success" = "success"; + + /** + * Message + * + * @type {string} + */ + @property() + message : string = ""; + /** + * Enables a close button that allows the user to dismiss the message + * @type {boolean} + */ + @property() + closable = true; + + /** + * Length of time, in seconds, before the message closes automatically. + * Success messages close in 5s, for other types the default is never close. + * @type {number} + */ + @property() + duration : number = null; + + /** + * Unique string id (appname:id) in order to register the message as discardable. + * Discardable messages offer a checkbox to never be shown again. + * If no appname given, the id will be prefixed with current app. The discardID will be stored in local storage. + * + * @type {string} + */ + @property() + discard : string = ""; + + // Map types to icons + private static ICON_MAP = { + help: "question-circle", + error: "exclamation-octagon", + warning: "exclamation-triangle", + success: "check2-circle", + info: "info-circle", + }; + + // Map our message types to shoelace variants + private static TYPE_MAP = {info: "primary", error: "danger"}; + + private __alert : SlAlert; + + /** + * Check if a message has been discarded + * @param {string} discardId + * @returns {boolean} + */ + public static isDiscarded(discardId : string) : boolean + { + let discardAppName = ""; + if(discardId) + { + let discardID = discardId.split(':'); + if(discardID.length < 2) + { + discardId = window.egw.app_name() + ":" + discardID.pop(); + } + discardAppName = discardID.length > 1 ? discardID[0] : window.egw.app_name(); + } + + const discarded = JSON.parse(window.egw.getLocalStorageItem(discardAppName, 'discardedMsgs')); + if(Array.isArray(discarded)) + { + return discarded.includes(discardId); + } + return false; + }; + + /** + * Display the alert as a toast notification + * The returned promise will resolve after the message is hidden. + * + * @returns {Promise} + */ + public toast() : Promise + { + this.__alert = this.alert; + this.updateComplete.then(() => + { + this.remove(); + }) + return this.alert.toast(); + } + + /** + * Show the message + * @returns {() => Promise} + */ + public show() : () => Promise + { + return this.alert.show; + } + + /** + * Hide the message + * @returns {Promise} + */ + public hide() : Promise + { + return this.alert.hide(); + } + + get alert() : SlAlert { return this.shadowRoot?.querySelector("sl-alert") as SlAlert ?? this.__alert; } + + get egw() : typeof egw + { + return window.egw + } + + protected handleHide(e) + { + // Store user's discard choice, if it was offered + const check = this.alert.querySelector("#discard"); + if(this.discard && check && check.value && !EgwFrameworkMessage.isDiscarded(this.discard)) + { + const discardAppName = this.discard.split(":").shift(); + let discarded : string | string[] = this.egw.getLocalStorageItem(discardAppName, 'discardedMsgs'); + if(!discarded) + { + discarded = [this.discard]; + } + else + { + discarded = JSON.parse(discarded) + discarded.push(this.discard); + } + this.egw.setLocalStorageItem(discardAppName, 'discardedMsgs', JSON.stringify(discarded)); + } + this.updateComplete.then(() => + { + this.dispatchEvent(new CustomEvent("sl-hide")); + }); + } + + render() + { + const icon = EgwFrameworkMessage.ICON_MAP[this.type] ?? "info-circle"; + const variant = EgwFrameworkMessage.TYPE_MAP[this.type] ?? "success"; + const duration = this.type == "success" && !this.duration ? 5000 : this.duration; + + let discard : symbol | TemplateResult = nothing; + if(this.discard && EgwFrameworkMessage.isDiscarded(this.discard)) + { + // Don't show discarded messages + return nothing; + } + else if(this.discard) + { + discard = html` + + `; + } + return html` + + + + ${discard} + + `; + } +} \ No newline at end of file