import {css, html, LitElement, nothing, render} from "lit"; import {customElement} from "lit/decorators/custom-element.js"; import {property} from "lit/decorators/property.js"; import {state} from "lit/decorators/state.js"; import {classMap} from "lit/directives/class-map.js"; import {unsafeHTML} from "lit/directives/unsafe-html.js"; import styles from "./EgwFrameworkApp.styles"; import {SlSplitPanel} from "@shoelace-style/shoelace"; import {HasSlotController} from "../../api/js/etemplate/Et2Widget/slot"; import type {EgwFramework} from "./EgwFramework"; import {etemplate2} from "../../api/js/etemplate/etemplate2"; import {et2_IPrint} from "../../api/js/etemplate/et2_core_interfaces"; import {repeat} from "lit/directives/repeat.js"; import {until} from "lit/directives/until.js"; import {Favorite} from "../../api/js/etemplate/Et2Favorites/Favorite"; /** * @summary Application component inside EgwFramework * * Contain an EGroupware application inside the main framework. It consists of left, main and right areas. Each area * has a header, content and footer. Side content areas are not shown when there is no content. * * @dependency sl-split-panel * * @slot - Main application content. Other slots are normally hidden if they have no content * @slot header - Top of app, contains logo, app icons. * @slot footer - Very bottom of the main content. * @slot left - Optional content to the left. Use for application navigation. * @slot left-header - Top of left side * @slot left-footer - bottom of left side * @slot right - Optional content to the right. Use for application context details. * @slot right-header - Top of right side * @slot right-footer - bottom of right side * * @csspart name - Top left, holds the application name. * @csspart header - Top main application header, optional application toolbar goes here. * @csspart content-header - Top of center, optional. * @csspart main - Main application content. * @csspart left - Left optional content. * @csspart right - Right optional content. * @csspart footer - Very bottom of the main content. * * @cssproperty [--application-color=--primary-background-color] - Color to use for this application * @cssproperty [--application-header-text-color=white] - Text color in the application header * @cssproperty [--left-min=0] - Minimum width of the left content * @cssproperty [--left-max=20%] - Maximum width of the left content * @cssproperty [--right-min=0] - Minimum width of the right content * @cssproperty [--right-max=50%] - Maximum width of the right content */ @customElement('egw-app') //@ts-ignore export class EgwFrameworkApp extends LitElement { static get styles() { return [ styles, // TEMP STUFF css` :host .placeholder { display: none; } :host(.placeholder) .placeholder { display: block; --placeholder-background-color: #e97234; } .placeholder { width: 100%; font-size: 200%; text-align: center; background-color: var(--placeholder-background-color); } .placeholder:after, .placeholder:before { content: " ⌖ "; } :host(.placeholder) [class*="left"] .placeholder { background-color: color-mix(in lch, var(--placeholder-background-color), rgba(.5, .5, 1, .1)); } :host(.placeholder) [class*="right"] .placeholder { background-color: color-mix(in lch, var(--placeholder-background-color), rgba(.5, 1, .5, .1)); } :host(.placeholder) [class*="footer"] .placeholder { background-color: color-mix(in lch, var(--placeholder-background-color), rgba(1, 1, 1, .05)); } ` ]; } @property({reflect: true}) name = "Application name"; @property() title : string = ""; @property() url = ""; @state() leftCollapsed = false; @state() rightCollapsed = false; get leftSplitter() { return this.shadowRoot.querySelector(".egw_fw_app__outerSplit");} get rightSplitter() { return this.shadowRoot.querySelector(".egw_fw_app__innerSplit");} protected readonly hasSlotController = new HasSlotController(this, 'left', 'left-header', 'left-footer', 'right', 'right-header', 'right-footer', ); // Left is in pixels private leftPanelInfo : PanelInfo = { side: "left", preference: "jdotssideboxwidth", defaultWidth: 200, hiddenWidth: 0, preferenceWidth: 200 }; // Right is in percentage private rightPanelInfo : PanelInfo = { side: "right", preference: "app_right_width", defaultWidth: 50, hiddenWidth: 100, preferenceWidth: 50 }; private resizeTimeout : number; protected loadingPromise = Promise.resolve(); /** The application's content must be in an iframe instead of handled normally */ protected useIframe = false; protected _sideboxData : any; private _hasFavorites = false; connectedCallback() { super.connectedCallback(); (>this.egw.preference(this.leftPanelInfo.preference, this.name, true)).then((width) => { this.leftPanelInfo.preferenceWidth = typeof width !== "undefined" ? parseInt(width) : this.leftPanelInfo.defaultWidth; }); (>this.egw.preference(this.rightPanelInfo.preference, this.name, true)).then((width) => { this.rightPanelInfo.preferenceWidth = typeof width !== "undefined" ? parseInt(width) : this.rightPanelInfo.defaultWidth; }); this.addEventListener("load", this.handleEtemplateLoad); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("load", this.handleEtemplateLoad); } firstUpdated() { this.load(this.url); } protected async getUpdateComplete() : Promise { const result = await super.updateComplete; await this.loadingPromise; return result } public load(url) { if(!url) { while(this.firstChild) { this.removeChild(this.lastChild); } return; } this.url = url; let targetUrl = ""; this.useIframe = true; let matches = url.match(/\/index.php\?menuaction=([A-Za-z0-9_\.]*.*&ajax=true.*)$/); if(matches) { // Matches[1] contains the menuaction which should be executed - replace // the given url with the following line. This will be evaluated by the // jdots_framework ajax_exec function which will be called by the code // below as we set useIframe to false. targetUrl = "index.php?menuaction=" + matches[1]; this.useIframe = false; } // Destroy application js if(window.app[this.name] && window.app[this.name].destroy) { window.app[this.name].destroy(); delete window.app[this.name]; // really delete it, so new object get constructed and registered for push } if(!this.useIframe) { return this.loadingPromise = this.egw.request( this.framework.getMenuaction('ajax_exec', targetUrl, this.name), [targetUrl] ).then((data : string | string[] | { DOMNodeID? : string } | { DOMNodeID? : string }[]) => { if(!data) { return; } // Load request returns HTML. Shove it in. if(typeof data == "string" || typeof data == "object" && typeof data[0] == "string") { render(html`${unsafeHTML((data).join(""))}`, this); } else { // We got some data, use it const items = (Array.isArray(data) ? data : [data]) .filter(data => (typeof data.DOMNodeID == "string" && document.querySelector("[id='" + data.DOMNodeID + "']") == null)); render(html`${repeat(items, i => i.DOMNodeID, (item) => html`
`)}`, this); } // Might have just slotted aside content, hasSlotController will requestUpdate() // but we need to do it anyway for translation this.requestUpdate(); }); } else { this.loadingPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(this.name + " load failed"), 5000); render(this._iframeTemplate(), this); this.querySelector("iframe").addEventListener("load", () => { clearTimeout(timeout); resolve() }, {once: true}); }); // Might have just changed useIFrame, need to update to show that this.requestUpdate(); return this.loadingPromise; } } public getMenuaction(_fun, _ajax_exec_url, appName = "") { return this.framework.getMenuaction(_fun, _ajax_exec_url, appName || this.name); } public setSidebox(sideboxData, hash?) { this._sideboxData = sideboxData; if(this._sideboxData?.some(s => s.title == "Favorites" || s.title == this.egw.lang("favorites"))) { // This might be a little late, but close enough for rendering Favorite.load(this.egw, this.name).then((favorites) => { this._hasFavorites = (Object.values(favorites).length > 1) this.requestUpdate(); }); } this.requestUpdate(); } public showLeft() { this.showSide("left"); } public hideLeft() { this.hideSide("left"); } public showRight() { this.showSide("right"); } public hideRight() { this.hideSide("right"); } public async print() { let template; let deferred = []; let et2_list = []; const appWindow = this.framework.egw.window; if((template = appWindow.etemplate2.getById(this.id)) && this == template.DOMContainer) { deferred = deferred.concat(template.print()); et2_list.push(template); } else { // et2 inside, let its widgets prepare this.querySelectorAll(":scope > *").forEach((domNode : HTMLElement) => { let et2 = appWindow.etemplate2.getById(domNode.id); if(et2 && (domNode.offsetWidth > 0 || domNode.offsetHeight > 0 || domNode.getClientRects().length > 0)) { deferred = deferred.concat(et2.print()); et2_list.push(et2); } }); } if(et2_list.length) { // Try to clean up after - not guaranteed let afterPrint = () => { this.egw.loading_prompt(this.name, true, this.egw.lang('please wait...'), this, egwIsMobile() ? 'horizental' : 'spinner'); // Give framework a chance to deal, then reset the etemplates appWindow.setTimeout(() => { for(let i = 0; i < et2_list.length; i++) { et2_list[i].widgetContainer.iterateOver(function(_widget) { _widget.afterPrint(); }, et2_list[i], et2_IPrint); } this.egw.loading_prompt(this.name, false); }, 100); appWindow.onafterprint = null; }; /* Not sure what this did, it triggers while preview is still up if(appWindow.matchMedia) { var mediaQueryList = appWindow.matchMedia('print'); var listener = function(mql) { if(!mql.matches) { mediaQueryList.removeListener(listener); afterPrint(); } }; mediaQueryList.addListener(listener); } */ appWindow.addEventListener("afterprint", afterPrint, {once: true}); // Wait for everything to be ready return Promise.all(deferred).catch((e) => { afterPrint(); if(typeof e == "undefined") { throw "rejected"; } }); } } protected showSide(side) { const attribute = `${side}Collapsed`; this[attribute] = false; this[`${side}Splitter`].position = this[`${side}PanelInfo`].preferenceWidth || this[`${side}PanelInfo`].defaultWidth; } protected hideSide(side : "left" | "right") { const attribute = `${side}Collapsed`; const oldValue = this[attribute]; this[attribute] = true; this[`${side}Splitter`].position = this[`${side}PanelInfo`].hiddenWidth; this.requestUpdate(attribute, oldValue); } get egw() { return window.egw(this.name) ?? (this.parentElement).egw ?? null; } get framework() : EgwFramework { return this.closest("egw-framework"); } get appName() : string { return this.name; } private hasSideContent(side : "left" | "right") { return this.hasSlotController.test(`${side}-header`) || this.hasSlotController.test(side) || this.hasSlotController.test(`${side}-footer`); } /** * An etemplate has loaded inside * Move anything top-level that has a slot */ protected handleEtemplateLoad(event) { const etemplate = etemplate2.getById(event.target.id); if(!etemplate || !event.composedPath().includes(this)) { return; } // Move top level slotted components (slot watcher will requestUpdate) etemplate.widgetContainer.getDOMNode().querySelectorAll(":scope > [slot]").forEach(node => {this.appendChild(node);}); } /** * User adjusted side slider, update preference * * @param event * @protected */ protected async handleSlide(event) { // Skip if there's no panelInfo - event is from the wrong place if(typeof event.target?.panelInfo != "object") { return; } let panelInfo = event.target.panelInfo; await this.loadingPromise; // Left side is in pixels, round to 2 decimals let newPosition = Math.round(panelInfo.side == "left" ? event.target.positionInPixels * 100 : Math.max(100, event.target.position) * 100) / 100; // Update collapsed this[`${panelInfo.side}Collapsed`] = newPosition == panelInfo.hiddenWidth; let preferenceName = panelInfo.preference; let currentPreference = parseFloat("" + await this.egw.preference(preferenceName, this.name, true)); if(newPosition != currentPreference && !isNaN(newPosition)) { panelInfo.preferenceWidth = newPosition; if(panelInfo.resizeTimeout) { window.clearTimeout(panelInfo.resizeTimeout); } panelInfo.resizeTimeout = window.setTimeout(() => { this.egw.set_preference(this.name, preferenceName, newPosition); // Tell etemplates to resize this.querySelectorAll("[id]").forEach(e => { if(etemplate2.getById(e.id)) { etemplate2.getById(e.id).resize(new Event("resize")); } }); }, 500); } } /** * Displayed for the time between when the application is added and when the server responds with content * * @returns {TemplateResult<1>} * @protected */ protected _loadingTemplate() { // Don't show loader for iframe, it will not resolve if(this.useIframe) { return nothing; } return html`
`; } /** * If we have to use an iframe, this is where it is made * @returns {typeof nothing | typeof nothing} * @protected */ protected _iframeTemplate() { if(!this.useIframe) { return nothing; } return html` `; } protected _asideTemplate(parentSlot, side : "left" | "right", label?) { const asideClassMap = classMap({ "egw_fw_app__aside": true, "egw_fw_app__left": side == "left", "egw_fw_app__right": side == "right", "egw_fw_app__aside-collapsed": side == "left" ? this.leftCollapsed : this.rightCollapsed, }); return html` `; } /** * Left sidebox automatic content * * @protected */ protected _leftMenuTemplate() { // Put favorites in left sidebox if any are set if(!this._hasFavorites) { return nothing; } return html`${until(Favorite.load(this.egw, this.name).then((favorites) => { // If more than the blank favorite is found, add favorite menu to sidebox if(Object.values(favorites).length > 1) { const favSidebox = this._sideboxData.find(s => s.title.toLowerCase() == "favorites" || s.title == this.egw.lang("favorites")); return html` {this.egw.set_preference(this.name, 'jdots_sidebox_' + favSidebox.menu_name, true);}} @sl-hide=${() => {this.egw.set_preference(this.name, 'jdots_sidebox_' + favSidebox.menu_name, false);}} > `; } }), nothing)}`; } /** * Top right header, contains application action buttons (reload, print, config) * @returns {TemplateResult<1>} * @protected */ protected _rightHeaderTemplate() { return html` { this.egw.refresh("", this.name); /* Could also be this.load(false); this.load(this.url) */ }} > this.framework.print()} >
${this.egw.lang("Menu")}
${this.egw.user('apps')['admin'] !== undefined ? html` { // @ts-ignore egw_link_handler(`/egroupware/index.php?menuaction=admin.admin_ui.index&load=admin.uiconfig.index&appname=${this.name}&ajax=true`, 'admin'); }} > ${this.egw.lang("App configuration")} ` : nothing} ${this._threeDotsMenuTemplate()}
`; } /** * This is the "three dots menu" in the top-right corner. * Most of what was in the sidebox now goes here. * * @returns {TemplateResult<1> | typeof nothing } * @protected */ protected _threeDotsMenuTemplate() { if(!this._sideboxData) { return nothing; } return html`${repeat(this._sideboxData, (menu) => menu['menu_name'], (menu) => { // No favorites here if(menu["title"] == "Favorites" || menu["title"] == this.egw.lang("favorites")) { return html` ${menu["title"]} `; } // Just one thing, don't bother with submenu if(menu["entries"].length == 1) { return this._sideboxMenuItemTemplate({...menu["entries"][0], lang_item: menu["title"]}) } return html` ${menu["title"]} ${repeat(menu["entries"], (entry) => { return this._sideboxMenuItemTemplate(entry); })} `; })}`; } /** * An individual sub-item in the 3-dots menu * @param item * @returns {TemplateResult<1>} */ _sideboxMenuItemTemplate(item) { if(item["lang_item"] == "
") { return html` `; } return html` this.egw.open_link(item["item_link"])} > ${typeof item["icon_or_star"] == "string" && item["icon_or_star"].endsWith("bullet.svg") ? nothing : html` `} ${item["lang_item"]} `; } render() { const hasLeftSlots = this.hasSideContent("left") || this._hasFavorites; const hasRightSlots = this.hasSideContent("right"); const leftWidth = this.leftCollapsed || !hasLeftSlots ? this.leftPanelInfo.hiddenWidth : this.leftPanelInfo.preferenceWidth; const rightWidth = this.rightCollapsed || !hasRightSlots ? this.rightPanelInfo.hiddenWidth : this.rightPanelInfo.preferenceWidth; return html`
${hasLeftSlots ? html` { this.leftCollapsed = !this.leftCollapsed; // Just in case they collapsed it manually, reset this.leftPanelInfo.preferenceWidth = this.leftPanelInfo.preferenceWidth || this.leftPanelInfo.defaultWidth; this.requestUpdate("leftCollapsed") }} >` : nothing }

${this.title || this.egw?.lang(this.name) || this.name}

${this.name} main-header
${until(this.framework.getEgwComplete().then(() => this._rightHeaderTemplate()), html` `)}
this.handleSlide(e)} > { this.hideLeft(); }}> ${this._asideTemplate("start", "left")} this.handleSlide(e)} > { this.hideRight(); }}>
header
${this._loadingTemplate()} main
${this._asideTemplate("end", "right", this.egw.lang("%1 application details", this.egw.lang(this.name)))}
`; } } type PanelInfo = { side : "left" | "right", preference : "jdotssideboxwidth" | "app_right_width", hiddenWidth : number, defaultWidth : number, preferenceWidth : number | string }