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 <SlSplitPanel>this.shadowRoot.querySelector(".egw_fw_app__outerSplit");} get rightSplitter() { return <SlSplitPanel>this.shadowRoot.querySelector(".egw_fw_app__innerSplit");} protected readonly hasSlotController = new HasSlotController(<LitElement><unknown>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(); (<Promise<string>>this.egw.preference(this.leftPanelInfo.preference, this.name, true)).then((width) => { this.leftPanelInfo.preferenceWidth = typeof width !== "undefined" ? parseInt(width) : this.leftPanelInfo.defaultWidth; }); (<Promise<string>>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<boolean> { 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((<string[]>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` <div id="${item.DOMNodeID}"></div>`)}`, 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) ?? (<EgwFramework>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` <div class="egw_fw_app__loading"> <sl-spinner></sl-spinner> </div>`; } /** * 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` <iframe src="${this.url}"></iframe>`; } 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` <aside slot="${parentSlot}" part="${side}" class=${asideClassMap} aria-label="${label}"> <div class="egw_fw_app__aside_header header"> <slot name="${side}-header"><span class="placeholder">${side}-header</span></slot> </div> <div class="egw_fw_app__aside_content content"> ${side == "left" ? this._leftMenuTemplate() : nothing} <slot name="${side}"><span class="placeholder">${side}</span></slot> </div> <div class="egw_fw_app__aside_footer footer"> <slot name="${side}-footer"><span class="placeholder">${side}-footer</span></slot> </div> </aside>`; } /** * 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` <sl-details class="favorites" slot="left" ?open=${favSidebox?.opened} summary=${this.egw.lang("Favorites")} @sl-show=${() => {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);}} > <et2-favorites-menu application=${this.name}></et2-favorites-menu> </sl-details> `; } }), nothing)}`; } /** * Top right header, contains application action buttons (reload, print, config) * @returns {TemplateResult<1>} * @protected */ protected _rightHeaderTemplate() { return html` <et2-button-icon nosubmit name="arrow-clockwise" label=${this.egw.lang("Reload %1", this.egw.lang(this.name))} statustext=${this.egw.lang("Reload %1", this.egw.lang(this.name))} @click=${(e) => { this.egw.refresh("", this.name); /* Could also be this.load(false); this.load(this.url) */ }} ></et2-button-icon> <et2-button-icon nosubmit name="printer" label=${this.egw.lang("Print")} statustext=${this.egw.lang("Print")} @click=${(e) => this.framework.print()} ></et2-button-icon> <sl-dropdown class="egw_fw_app__menu"> <div slot="trigger">${this.egw.lang("Menu")} <sl-icon-button name="chevron-double-down"></sl-icon-button> </div> <sl-menu> ${this.egw.user('apps')['admin'] !== undefined ? html` <sl-menu-item @click=${(e) => { // @ts-ignore egw_link_handler(`/egroupware/index.php?menuaction=admin.admin_ui.index&load=admin.uiconfig.index&appname=${this.name}&ajax=true`, 'admin'); }} > <sl-icon slot="prefix" name="gear-wide"></sl-icon> ${this.egw.lang("App configuration")} </sl-menu-item> <sl-divider></sl-divider> ` : nothing} ${this._threeDotsMenuTemplate()} </sl-menu> </sl-dropdown> `; } /** * 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` <sl-menu-item> <et2-image style="width:1em;" src="fav_filter" slot="prefix"></et2-image> ${menu["title"]} <et2-favorites-menu slot="submenu" application="${this.appName}"></et2-favorites-menu> </sl-menu-item> `; } // 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` <sl-menu-item> ${menu["title"]} <sl-menu slot="submenu"> ${repeat(menu["entries"], (entry) => { return this._sideboxMenuItemTemplate(entry); })} </sl-menu> </sl-menu-item>`; })}`; } /** * An individual sub-item in the 3-dots menu * @param item * @returns {TemplateResult<1>} */ _sideboxMenuItemTemplate(item) { if(item["lang_item"] == "<hr />") { return html` <sl-divider></sl-divider>`; } return html` <sl-menu-item ?disabled=${!item["item_link"]} @click=${() => this.egw.open_link(item["item_link"])} > ${typeof item["icon_or_star"] == "string" && item["icon_or_star"].endsWith("bullet.svg") ? nothing : html` <et2-image name=${item["icon_or_star"]}></et2-image> `} ${item["lang_item"]} </sl-menu-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` <div class="egw_fw_app__header"> <div class="egw_fw_app__name" part="name"> ${hasLeftSlots ? html` <sl-icon-button name="${this.leftCollapsed ? "chevron-double-right" : "chevron-double-left"}" label="${this.leftCollapsed ? this.egw.lang("Show left area") : this.egw?.lang("Hide left area")}" @click=${() => { this.leftCollapsed = !this.leftCollapsed; // Just in case they collapsed it manually, reset this.leftPanelInfo.preferenceWidth = this.leftPanelInfo.preferenceWidth || this.leftPanelInfo.defaultWidth; this.requestUpdate("leftCollapsed") }} ></sl-icon-button>` : nothing } <h2>${this.title || this.egw?.lang(this.name) || this.name}</h2> </div> <header class="egw_fw_app__header" part="header"> <slot name="main-header"><span class="placeholder"> ${this.name} main-header</span></slot> </header> ${until(this.framework.getEgwComplete().then(() => this._rightHeaderTemplate()), html` <sl-spinner></sl-spinner>`)} </div> <div class="egw_fw_app__main" part="main"> <sl-split-panel class=${classMap({"egw_fw_app__outerSplit": true, "no-content": !hasLeftSlots})} primary="start" position-in-pixels="${leftWidth}" snap="0px 20%" snap-threshold="50" .panelInfo=${this.leftPanelInfo} @sl-reposition=${(e) => this.handleSlide(e)} > <sl-icon slot="divider" name="grip-vertical" @dblclick=${() => { this.hideLeft(); }}></sl-icon> ${this._asideTemplate("start", "left")} <sl-split-panel slot="end" class=${classMap({"egw_fw_app__innerSplit": true, "no-content": !hasRightSlots})} primary="start" position=${rightWidth} snap="50% 80% 100%" snap-threshold="50" .panelInfo=${this.rightPanelInfo} @sl-reposition=${(e) => this.handleSlide(e)} > <sl-icon slot="divider" name="grip-vertical" @dblclick=${() => { this.hideRight(); }}></sl-icon> <header slot="start" class="egw_fw_app__header header" part="content-header"> <slot name="header"><span class="placeholder">header</span></slot> </header> <div slot="start" class="egw_fw_app__main_content content" part="content" aria-label="${this.name}" tabindex="0"> <slot> ${this._loadingTemplate()} <span class="placeholder">main</span> </slot> </div> <footer slot="start" class="egw_fw_app__footer footer" part="footer"> <slot name="footer"><span class="placeholder">main-footer</span></slot> </footer> ${this._asideTemplate("end", "right", this.egw.lang("%1 application details", this.egw.lang(this.name)))} </sl-split-panel> </sl-split-panel> </div> `; } } type PanelInfo = { side : "left" | "right", preference : "jdotssideboxwidth" | "app_right_width", hiddenWidth : number, defaultWidth : number, preferenceWidth : number | string }