import {css, html, LitElement, nothing, PropertyValues} from "lit"; 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 {SlAlert, SlDropdown, SlTab, SlTabGroup} from "@shoelace-style/shoelace"; import {EgwFrameworkApp} from "./EgwFrameworkApp"; import {EgwFrameworkMessage} from "./EgwFrameworkMessage"; /** * @summary Accessable, webComponent-based EGroupware framework * * @dependency sl-dropdown * @dependency sl-icon-button * * @slot - Current application * @slot banner - Very top, used for things like persistant, system wide messages. Normally hidden. * @slot header - Top of page, contains logo, app icons. * @slot header-right - Top right, contains user info / actions. * @slot status - Home of the status app, it is limited in size and can be resized and hidden. * @slot footer - Very bottom. Normally hidden. * * * @csspart base - Wraps it all. * @csspart banner - * @csspart header - * @csspart open-applications - Tab group that has the currently open applications * @csspart status-split - Status splitter * @csspart main * @csspart status * @csspart footer * * @cssproperty [--icon-size=32] - Height of icons used in the framework * @cssproperty [--tab-icon-size=32] - Height of application icons in header bar * @cssproperty [--tab-icon-size-active=40] - Height of active application icon */ @customElement('egw-framework') //@ts-ignore export class EgwFramework extends LitElement { static get styles() { return [ styles, // TEMP STUFF css` :host .placeholder { display: none; } :host(.placeholder) .placeholder { width: 100%; display: block; font-size: 200%; text-align: center; background-color: var(--placeholder-background-color, silver); } .placeholder:after { content: " (placeholder)"; } .egw_fw__base { --placeholder-background-color: #75bd20; } .egw_fw__status .placeholder { writing-mode: vertical-rl; text-orientation: mixed; height: 100%; } :host(.placeholder) [class*="left"] .placeholder { background-color: color-mix(in lch, var(--placeholder-background-color), rgba(.5, .5, 1, .5)); } :host(.placeholder) [class*="right"] .placeholder { background-color: color-mix(in lch, var(--placeholder-background-color), rgba(.5, 1, .5, .5)); } :host(.placeholder) [class*="footer"] .placeholder { background-color: color-mix(in lch, var(--placeholder-background-color), rgba(1, 1, 1, .05)); } ::slotted(div#egw_fw_sidebar_r) { position: relative; } ` ]; } @property() layout = "default"; /** * This is the list of all applications we know about */ @property({type: Array, attribute: "application-list"}) applicationList : ApplicationInfo[] = []; // 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");} constructor() { super(); this.handleDarkmodeChange = this.handleDarkmodeChange.bind(this); } connectedCallback() { super.connectedCallback(); if(this.egw.window && this.egw.window.opener == null && !this.egw.window.framework) { // This works, but stops a lot else from working this.egw.window.framework = this; } if(this.egw.window?.framework && this.egw.window?.framework !== this) { // Override framework setSidebox, use arrow function to force context this.egw.framework.setSidebox = (applicationName, sideboxData, hash?) => this.setSidebox(applicationName, sideboxData, hash); } document.body.addEventListener("egw-darkmode-change", this.handleDarkmodeChange); } disconnectedCallback() { super.disconnectedCallback(); document.body.removeEventListener("egw-darkmode-change", this.handleDarkmodeChange); } protected firstUpdated(_changedProperties : PropertyValues) { super.firstUpdated(_changedProperties); // Load hidden apps like status, as long as they can be loaded this.applicationList.forEach((app) => { if(app.status == "5" && app.url && !app.url.match(/menuaction\=none/)) { this.loadApp(app.name); } }); // Load additional tabs Object.values(this.tabApps).forEach(app => this.loadApp(app.name)); // Init timer this.egw.add_timer('topmenu_info_timer'); // These need egw fully loaded this.getEgwComplete().then(() => { // Register 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'); // Ask about timer before logout const logout = this.querySelector('#topmenu_logout'); logout.addEventListener('click', async(e) => { e.preventDefault(); e.stopImmediatePropagation(); await this.egw.onLogout_timer(); this.egw.open_link(e.target.value); }); // Deal with bug where avatar menu does not position correctly (this.querySelector("#topmenu_info_user_avatar"))?.popup?.dispatchEvent(new Event("slotchange")); }); } /** * Special tabs that are not directly associated with an application (CRM) * @type {[]} * @private */ protected get tabApps() : { [id : string] : ApplicationInfo } { return JSON.parse(egw.getSessionItem('api', 'fw_tab_apps') || null) || {}; } protected set tabApps(apps : { [id : string] : ApplicationInfo }) { egw.setSessionItem('api', 'fw_tab_apps', JSON.stringify(apps)); } get egw() : typeof egw { return window.egw ?? { // Dummy egw so we don't get failures from missing methods lang: (t) => t, preference: (n, app, promise? : Function | boolean | undefined) => Promise.resolve(""), set_preference(_app : string, _name : string, _val : any, _callback? : Function) {} }; } /** * A promise for if egw is loaded * * @returns {Promise} */ getEgwComplete() { let egwLoading = Promise.resolve(); if(typeof this.egw.window['egw_ready'] !== "undefined") { egwLoading = this.egw.window['egw_ready']; } return egwLoading; } /** * * @param _function Framework function to be called on the server. * @param _ajax_exec_url Actual function we want to call. * @returns {string} */ public getMenuaction(_fun, _ajax_exec_url, appName = 'home') { let baseUrl = this.getBaseUrl(); // Check whether the baseurl is actually set. If not, then this application // resides inside the same egw instance as the jdots framework. We'll simply // return a menu action and not a full featured url here. if(baseUrl != '') { baseUrl = baseUrl + 'json.php?menuaction='; } const menuaction = _ajax_exec_url ? _ajax_exec_url.match(/menuaction=([^&]+)/) : null; // use template handler to call current framework, eg. pixelegg return baseUrl + appName + '.kdots_framework.' + _fun + '.template' + (menuaction ? '.' + menuaction[1] : ''); }; public getApplicationByName(appName) { return this.querySelector(`egw-app[name="${appName}"]`); } /** * Load an application into the framework * * Loading is done by name, and we look up everything we need in the applicationList. * If already loaded, this just returns the existing EgwFrameworkApp, optionally activated & with new URL loaded. * * @param {string} appname * @param {boolean} active * @param {string} url * @returns {EgwFrameworkApp} */ public loadApp(appname : string, active = false, url = null) : EgwFrameworkApp { const existing : EgwFrameworkApp = this.querySelector(`egw-app[id="${appname}"]`); if(existing) { if(active) { this.tabs.show(appname); } if(url) { existing.load(url); } return existing; } const app = this.applicationList.find(a => a.name == appname) ?? this.tabApps[appname]; if(!app) { console.log("Cannot load unknown app '" + appname + "'"); return null; } let appComponent = document.createElement("egw-app"); appComponent.setAttribute("id", appname); appComponent.setAttribute("name", app.internalName || appname); appComponent.url = url ?? app?.url; if(app.title) { appComponent.title = app.title; } this.append(appComponent); // App was not in the tab list if(typeof app.opened == "undefined") { app.opened = this.shadowRoot.querySelectorAll("sl-tab").length; // Need to update tabApps directly, reference doesn't work if(typeof this.tabApps[appname] == "object") { let tabApps = {...this.tabApps}; tabApps[appname] = app; this.tabApps = tabApps; } this.requestUpdate("applicationList"); } // Wait until new tab is there to activate it if(active || app.active) { // Wait for egw this.getEgwComplete().then(() => { // Wait for redraw after getEgwComplete promise this.updateComplete.then(() => { // Tabs present this.updateComplete.then(() => { this.showTab(appname); }); }); }); } return appComponent; } public get activeApp() : EgwFrameworkApp { return this.querySelector("egw-app[active]"); } /** * Load a link into the framework * * @param {string} _link * @param {string} _app * @returns {undefined} */ public linkHandler(_link : string, _app : string) { // Determine the app string from the application parameter let app = null; if(_app && typeof _app == 'string') { app = this.applicationList.find(a => a.name == _app); } if(!app) { //The app parameter was false or not a string or the application specified did not exists. //Determine the target application from the link that had been passed to this function app = this.parseAppFromUrl(_link); } if(app) { if(_app == '_tab') { // add target flag _link += '&target=_tab'; const appname = app.appName + ":" + btoa(_link); this.applicationList.push({ ...app, name: appname, url: _link, title: 'view' }); app = this.applicationList[this.applicationList.length - 1]; } this.loadApp(app.name, true, _link); } else if(typeof _app == 'string') { //Display some error messages to have visible feedback egw_alertHandler('Application "' + _app + '" not found.', 'The application "' + _app + '" the link "' + _link + '" points to is not registered.'); } else { this.egw.window.location.replace(_link); } } public tabLinkHandler(_link : string, _extra = { id: "" }) { const app = this.parseAppFromUrl(_link); if(app) { const appname = app.name + "-" + btoa(_extra.id ? _extra.id : _link).replace(/=/g, 'i'); if(this.getApplicationByName(appname)) { this.loadApp(appname, true, _link); return appname; } // add target flag _link += '&fw_target=' + appname; // create an actual clone of existing app object let clone = { ...app, ..._extra, //isFrameworkTab: true, ?? name: appname, internalName: app.name, url: _link, // Need to override to open, base app might already be opened opened: undefined }; // Store only in session let tabApps = {...this.tabApps}; tabApps[appname] = clone; this.tabApps = tabApps; this.loadApp(appname, true); return appname; } else { egw_alertHandler("No appropriate target application has been found.", "Target link: " + _link); } } /** * Open a (centered) popup window with given size and url * * @param {string} _url * @param {number} _width * @param {number} _height * @param {string} _windowName or "_blank" * @param {string|boolean} _app app-name for framework to set correct opener or false for current app * @param {boolean} _returnID true: return window, false: return undefined * @param {type} _status "yes" or "no" to display status bar of popup * @param {DOMWindow} _parentWnd parent window * @returns {DOMWindow|undefined} */ public openPopup(_url, _width, _height, _windowName, _app, _returnID, _status, _parentWnd) { const windowID = this.egw.openPopup(_url, _width, _height, _windowName, _app, true, _status, true); windowID.framework = this; this._popups.push(windowID); if(_returnID !== false) { return windowID; } } /** * Collect and close all already closed windows * egw.open_link expects it from the framework */ public popups_garbage_collector() { for(var i = 0; i < this._popups.length; i++) { if(this._popups[i].closed) { this._popups.splice(i, 1); } } } /** * Tries to obtain the application from a menuaction * @param {string} _url */ protected parseAppFromUrl(_url : string) { let _app = null; // Check the menuaction parts from the url let matches = _url.match(/menuaction=([a-z0-9_-]+)\./i) || // Check the url for a scheme of "/app/something.php" _url.match(/\/([^\/]+)\/[^\/]+\.php/i); if(matches) { // check if this is a regular app-name _app = this.applicationList.find(a => a.name == matches[1]); } return _app; } /** * Print */ public async print() { const appElement : EgwFrameworkApp = this.activeApp; try { if(appElement) { await appElement.print(); } const appWindow = this.egw.window; appWindow.setTimeout(appWindow.print, 0); } catch { // Ignore rejected } } public async setSidebox(appname, sideboxData, hash) { const app = this.loadApp(appname); 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 "";} protected handleDarkmodeChange(event) { // Update CSS classes this.classList.toggle("sl-theme-light", !event.target.darkmode); this.classList.toggle("sl-theme-dark", event.target.darkmode); // Update preference this.egw.set_preference("common", "darkmode", (event.target.darkmode ? "1" : "0")); } /** * An application tab is chosen, show the app * * @param e * @protected */ protected handleApplicationTabShow(event) { // Create & show app this.showTab(event.target.activeTab.panel); } public showTab(appname) { this.querySelectorAll("egw-app").forEach(app => app.removeAttribute("active")); let appComponent = this.querySelector(`egw-app#${appname}`); if(!appComponent) { appComponent = this.loadApp(appname); } appComponent.setAttribute("active", ""); // Update the list on the server const tabGroup : SlTabGroup = this.shadowRoot.querySelector("sl-tab-group.egw_fw__open_applications"); tabGroup.updateComplete.then(() => { this.updateTabs(appComponent); }); } /** * An application tab is closed */ protected handleApplicationTabClose(event) { const tabGroup : SlTabGroup = this.shadowRoot.querySelector("sl-tab-group.egw_fw__open_applications"); const tab = event.target; const panel = tabGroup.querySelector(`sl-tab-panel[name="${tab.panel}"]`); // Show the previous tab if the tab is currently active if(tab.active) { tabGroup.show(tab.previousElementSibling.panel); } else { // Show will update, but closing in the background we call directly tabGroup.updateComplete.then(() => { this.updateTabs(tabGroup.querySelector("sl-tab[active]")); }); } // Remove the tab + panel tab.remove(); if(panel) { panel.remove(); } } /** * Store last status of tabs * tab status being used in order to open all previous opened * tabs and to activate the last active tab */ private updateTabs(activeTab) { //Send the current tab list to the server let data = this.assembleTabList(activeTab); // Update session tabs let tabs = {}; Object.keys(this.tabApps).forEach((t) => { if(data.some(d => d.appName == t)) { tabs[t] = this.tabApps[t]; tabs[t].active = t == activeTab.id; } }); this.tabApps = tabs; //Serialize the tab list and check whether it really has changed since the last //submit var serialized = egw.jsonEncode(data); if(serialized != this.serializedTabState) { this.serializedTabState = serialized; if(this.tabApps) { this._setTabAppsSession(this.tabApps); } egw.jsonq('EGroupware\\Api\\Framework\\Ajax::ajax_tab_changed_state', [data]); } } private assembleTabList(activeTab) { let appList = [] Array.from(this.shadowRoot.querySelectorAll("sl-tab-group.egw_fw__open_applications sl-tab")).forEach((tab : SlTab) => { appList.push({appName: tab.panel, active: activeTab.id == tab.panel}) }); return appList; } private _setTabAppsSession(_tabApps) { if(_tabApps) { egw.setSessionItem('api', 'fw_tab_apps', JSON.stringify(_tabApps)); } } /** * Renders one application into the 9-dots application menu * * @param app * @returns {TemplateResult<1>} * @protected */ protected _applicationListAppTemplate(app) { if(app.status !== "1") { return nothing; } return html` { this.loadApp(app.name, true); (this.shadowRoot.querySelector(".egw_fw__app_list")).hide(); }} > `; } protected _applicationTabTemplate(app) { return html` `; } render() { const iconSize = getComputedStyle(this).getPropertyValue("--icon-size"); const statusPosition = this.egw?.preference("statusPosition", "common") ?? parseInt(iconSize) ?? "36"; const classes = { "egw_fw__base": true } classes[`egw_fw__layout-${this.layout}`] = true; return html`${until(this.getEgwComplete().then(() => html`
${repeat(this.applicationList, (app) => this._applicationListAppTemplate(app))} ${repeat([...this.applicationList, ...Object.values(this.tabApps)] .filter(app => typeof app.opened !== "undefined" && app.status !== "5") .sort((a, b) => a.opened - b.opened), (app) => this._applicationTabTemplate(app))} header header-right
footer
`), html`Waiting for egw... `)}`; } } /** * Information we keep and use about each app on the client side * This might not be limited to actual EGw apps, */ export interface ApplicationInfo { /* Internal name, used for reference & indexing. Might not be an egw app, might have extra bits */ name : string, /* Must be an egw app, used for the EgwFrameworkApp, preferences, etc. */ internalName? : string, icon : string title : string, url : string, /* What type of application (1: normal, 5: loaded but no tab) */ status : string,// = "1", /* Is the app open, and at what place in the tab list */ opened? : number, /* Is the app currently active */ active? : boolean// = false }