From b2d3d6fcedc8ced81e987d65e9f7421f2e0b1ebf Mon Sep 17 00:00:00 2001 From: ralf Date: Thu, 13 Jun 2024 20:19:23 +0200 Subject: [PATCH 1/6] fix infinite recursion when checking for Windows timezones without "Standard Time" prefix --- calendar/inc/class.calendar_timezones.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/calendar/inc/class.calendar_timezones.inc.php b/calendar/inc/class.calendar_timezones.inc.php index 852f31b0ca..d5373ee271 100644 --- a/calendar/inc/class.calendar_timezones.inc.php +++ b/calendar/inc/class.calendar_timezones.inc.php @@ -114,7 +114,7 @@ class calendar_timezones } } // check for a Windows timezone without "Standard Time" postfix - if (!isset($id) && strpos($tzid, '/') === false) + if (!isset($id) && stripos($tzid, ' Standard Time') === false) { $id = self::tz2id($tzid.' Standard Time'); } From c845088ebc723d795fdbfddd460c24d58e8b244d Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 13 Jun 2024 16:11:07 -0600 Subject: [PATCH 2/6] Favourites: - dispatch event when adding / removing preference - favourite widgets listen for event to update --- api/js/etemplate/Et2Favorites/Et2Favorites.ts | 21 +++--- .../Et2Favorites/Et2FavoritesMenu.ts | 71 +++++++++++++++++-- api/js/jsapi/egw_app.ts | 9 +++ 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/api/js/etemplate/Et2Favorites/Et2Favorites.ts b/api/js/etemplate/Et2Favorites/Et2Favorites.ts index 8379fdd21b..09d62e577a 100644 --- a/api/js/etemplate/Et2Favorites/Et2Favorites.ts +++ b/api/js/etemplate/Et2Favorites/Et2Favorites.ts @@ -345,20 +345,21 @@ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHea // Hide the trash trash.remove(); - // Delete preference server side - Favorite.remove(this.egw(), this.app, line.value).then(response => + // Delete preference server side, returns boolean + Favorite.remove(this.egw(), this.app, line.value).then(result => { line.classList.remove("loading"); - let result = response.response.find(r => r.type == "data"); + this.dispatchEvent(new CustomEvent("preferenceChange", { + bubbles: true, + composed: true, + detail: { + application: this.application, + preference: line.value + } + })); - // Could not find the result we want - if(!result || result.type !== "data") - { - return; - } - - if(typeof result.data == 'boolean' && result.data) + if(result) { // Remove line from list line.remove(); diff --git a/api/js/etemplate/Et2Favorites/Et2FavoritesMenu.ts b/api/js/etemplate/Et2Favorites/Et2FavoritesMenu.ts index 922e991223..c91b3d0ea1 100644 --- a/api/js/etemplate/Et2Favorites/Et2FavoritesMenu.ts +++ b/api/js/etemplate/Et2Favorites/Et2FavoritesMenu.ts @@ -46,6 +46,9 @@ export class Et2FavoritesMenu extends Et2Widget(LitElement) @property() application : string; + @property() + noAdd : boolean = false; + private favorites : { [name : string] : Favorite } = { 'blank': { name: typeof this.egw()?.lang == "function" ? this.egw().lang("No filters") : "No filters", @@ -55,24 +58,63 @@ export class Et2FavoritesMenu extends Et2Widget(LitElement) }; private loadingPromise = Promise.resolve(); + constructor() + { + super(); + this.handlePreferenceChange = this.handlePreferenceChange.bind(this); + } connectedCallback() { super.connectedCallback(); if(this.application) { - this.loadingPromise = Favorite.load(this.egw(), this.application).then((favorites) => - { - this.favorites = favorites; - }); + this._load(); + } + document.addEventListener("preferenceChange", this.handlePreferenceChange); + } + + disconnectedCallback() + { + super.disconnectedCallback(); + document.removeEventListener("preferenceChange", this.handlePreferenceChange); + } + + private _load() + { + this.loadingPromise = Favorite.load(this.egw(), this.application).then((favorites) => + { + this.favorites = favorites; + }); + } + + handlePreferenceChange(e) + { + if(e && e.detail?.application == this.application) + { + this._load(); + this.requestUpdate(); } } handleSelect(event) { + if(event.detail.item.value == Favorite.ADD_VALUE) + { + return this.handleAdd(event); + } Favorite.applyFavorite(this.egw(), this.application, event.detail.item.value); } + handleAdd(event) + { + event.stopPropagation(); + if(this.egw().window && this.egw().window.app[this.application]) + { + this.egw().window.app[this.application].add_favorite({}); + } + } + handleDelete(event) { // Don't trigger click @@ -89,6 +131,18 @@ export class Et2FavoritesMenu extends Et2Widget(LitElement) // Remove from widget delete this.favorites[favoriteName]; this.requestUpdate(); + + this.updateComplete.then(() => + { + this.dispatchEvent(new CustomEvent("preferenceChange", { + bubbles: true, + composed: true, + detail: { + application: this.application, + preference: favoriteName + } + })); + }); }); this.requestUpdate(); @@ -123,12 +177,21 @@ export class Et2FavoritesMenu extends Et2Widget(LitElement) { return html` ${this.label ? html` ${this.label}` : nothing} ${repeat(Object.keys(this.favorites), (i) => this.menuItemTemplate(i, this.favorites[i]))} + ${this.noAdd ? nothing : html` + + + ${this.egw().lang("Current view as favourite")} + ` + } `; }); diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts index cbb13095cd..a056605004 100644 --- a/api/js/jsapi/egw_app.ts +++ b/api/js/jsapi/egw_app.ts @@ -1230,6 +1230,15 @@ export abstract class EgwApp this.egw.set_preference(this.appname, favorite_pref, favorite); } + // Trigger event so widgets can update + document.dispatchEvent(new CustomEvent("preferenceChange", { + bubbles: true, + detail: { + application: this.appname, + preference: favorite_pref + } + })); + // Add to list immediately if(this.sidebox) { From 0ba2946ed41d3f3210476fd196e4bbeca3d2dfbb Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 13 Jun 2024 16:11:57 -0600 Subject: [PATCH 3/6] Framework WIP: - Put favorites in left slot when the user has some defined --- kdots/js/EgwFrameworkApp.styles.ts | 10 +++++ kdots/js/EgwFrameworkApp.ts | 67 ++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/kdots/js/EgwFrameworkApp.styles.ts b/kdots/js/EgwFrameworkApp.styles.ts index 0091090120..9d3ddd2d69 100644 --- a/kdots/js/EgwFrameworkApp.styles.ts +++ b/kdots/js/EgwFrameworkApp.styles.ts @@ -148,6 +148,7 @@ export default css` overflow-x: hidden; overflow-y: auto; display: flex; + flex-direction: column; } .egw_fw_app__main_content { @@ -221,4 +222,13 @@ export default css` padding: var(--sl-spacing-small); } + sl-details.favorites::part(content) { + padding: 0px; + } + + sl-details.favorites et2-favorites-menu::part(menu) { + border: none; + + } + ` \ No newline at end of file diff --git a/kdots/js/EgwFrameworkApp.ts b/kdots/js/EgwFrameworkApp.ts index d191e3a624..d14011363d 100644 --- a/kdots/js/EgwFrameworkApp.ts +++ b/kdots/js/EgwFrameworkApp.ts @@ -13,6 +13,7 @@ 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 @@ -139,6 +140,7 @@ export class EgwFrameworkApp extends LitElement /** The application's content must be in an iframe instead of handled normally */ protected useIframe = false; protected _sideboxData : any; + private _hasFavorites = false; connectedCallback() { @@ -261,6 +263,17 @@ export class EgwFrameworkApp extends LitElement 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(); } @@ -500,12 +513,13 @@ export class EgwFrameworkApp extends LitElement `; } - protected _asideTemplate(parentSlot, side, label?) + protected _asideTemplate(parentSlot, side : "left" | "right", label?) { const asideClassMap = classMap({ "egw_fw_app__aside": true, - "egw_fw_app__left": true, - "egw_fw_app__aside-collapsed": this.leftCollapsed, + "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>} @@ -563,14 +609,21 @@ export class EgwFrameworkApp extends LitElement ` : nothing} - ${this._sideboxMenuTemplate()} + ${this._threeDotsMenuTemplate()} `; } - protected _sideboxMenuTemplate() + /** + * 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) { @@ -635,7 +688,7 @@ export class EgwFrameworkApp extends LitElement render() { - const hasLeftSlots = this.hasSideContent("left"); + const hasLeftSlots = this.hasSideContent("left") || this._hasFavorites; const hasRightSlots = this.hasSideContent("right"); const leftWidth = this.leftCollapsed || !hasLeftSlots ? this.leftPanelInfo.hiddenWidth : From 525bdccd911eabfeee65a31b434dc33a1fada7da Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 14 Jun 2024 10:34:09 -0600 Subject: [PATCH 4/6] Framework WIP: - Fix app side slot initial width did not load / incorrect preferences --- kdots/js/EgwFrameworkApp.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/kdots/js/EgwFrameworkApp.ts b/kdots/js/EgwFrameworkApp.ts index d14011363d..7fc35c3848 100644 --- a/kdots/js/EgwFrameworkApp.ts +++ b/kdots/js/EgwFrameworkApp.ts @@ -442,27 +442,27 @@ export class EgwFrameworkApp extends LitElement return; } - // Skip if there's no side-content - if(!this.hasSideContent(event.target.panelInfo.side)) - { - return; - } + let panelInfo = event.target.panelInfo; + + await this.loadingPromise; // Left side is in pixels, round to 2 decimals - let newPosition = Math.round(event.target.panelInfo.side == "left" ? event.target.positionInPixels * 100 : event.target.position * 100) / 100; + let newPosition = Math.round(panelInfo.side == "left" ? event.target.positionInPixels * 100 : Math.max(100, event.target.position) * 100) / 100; // Update collapsed - this[`${event.target.panelInfo.side}Collapsed`] = newPosition == event.target.panelInfo.hiddenWidth; + this[`${panelInfo.side}Collapsed`] = newPosition == panelInfo.hiddenWidth; - let preferenceName = event.target.panelInfo.preference; - if(newPosition != event.target.panelInfo.preferenceWidth && !isNaN(newPosition)) + let preferenceName = panelInfo.preference; + let currentPreference = parseFloat("" + await this.egw.preference(preferenceName, this.name, true)); + + if(newPosition != currentPreference && !isNaN(newPosition)) { - event.target.panelInfo.preferenceWidth = newPosition; - if(this.resizeTimeout) + panelInfo.preferenceWidth = newPosition; + if(panelInfo.resizeTimeout) { - window.clearTimeout(this.resizeTimeout); + window.clearTimeout(panelInfo.resizeTimeout); } - this.resizeTimeout = window.setTimeout(() => + panelInfo.resizeTimeout = window.setTimeout(() => { this.egw.set_preference(this.name, preferenceName, newPosition); From ccccf95b3914e8614f68dd2dbbdce95744ed802c Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 14 Jun 2024 16:09:19 -0600 Subject: [PATCH 5/6] Framework WIP: - Set preferred font size & system fonts --- kdots/inc/class.kdots_framework.inc.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/kdots/inc/class.kdots_framework.inc.php b/kdots/inc/class.kdots_framework.inc.php index 8ea34d3ca0..5ded64cacc 100644 --- a/kdots/inc/class.kdots_framework.inc.php +++ b/kdots/inc/class.kdots_framework.inc.php @@ -147,4 +147,26 @@ class kdots_framework extends Api\Framework\Ajax $title . ''; } + + /** + * Set site-wide CSS like preferred font-size + * + * @return array + * @see Api\Framework::_get_css() + */ + public function _get_css() + { + $ret = parent::_get_css(); + + $textsize = $GLOBALS['egw_info']['user']['preferences']['common']['textsize'] ?? '12'; + $ret['app_css'] .= " + :root, :host, body, input { + font-size: {$textsize}px; + font-family: egroupware, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + } + "; + + return $ret; + } } From 45881e0505dc3853e370b0c271014d162c50de35 Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 14 Jun 2024 16:22:31 -0600 Subject: [PATCH 6/6] Add egw menu implementation using shoelace, use it for kdots framework --- api/js/egw_action/EgwMenuShoelace.ts | 176 +++++++++++++++++++++++++++ api/js/egw_action/egw_menu.ts | 21 +++- 2 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 api/js/egw_action/EgwMenuShoelace.ts diff --git a/api/js/egw_action/EgwMenuShoelace.ts b/api/js/egw_action/EgwMenuShoelace.ts new file mode 100644 index 0000000000..5cd72ed2e9 --- /dev/null +++ b/api/js/egw_action/EgwMenuShoelace.ts @@ -0,0 +1,176 @@ +import {css, html, LitElement, nothing} from "lit"; +import {SlMenu, SlMenuItem} from "@shoelace-style/shoelace"; +import {egwMenuItem} from "./egw_menu"; +import {customElement} from "lit/decorators/custom-element.js"; +import {repeat} from "lit/directives/repeat.js"; +import {classMap} from "lit/directives/class-map.js"; + +@customElement("egw-menu-shoelace") +export class EgwMenuShoelace extends LitElement +{ + static get styles() + { + return [ + css` + :host { + display: block; + + /* Fit in popup, scroll if not enough height */ + max-height: var(--auto-size-available-height, auto); + overflow-y: auto; + } + + .default-item::part(label) { + font-weight: var(--sl-font-weight-bold, bold); + } + + et2-image { + width: 1.5em; + } + ` + ] + } + + private structure = []; + private popup = null; + private removeCallback = null; + + private get menu() : SlMenu { return this.shadowRoot?.querySelector("sl-menu");} + + constructor(_structure : egwMenuItem[]) + { + super(); + this.structure = _structure; + } + + connectedCallback() + { + super.connectedCallback(); + } + + disconnectedCallback() + { + super.disconnectedCallback(); + if(this.popup) + { + this.popup.remove(); + this.popup = null; + } + if(this.removeCallback) + { + this.removeCallback.call(); + } + } + + public showAt(_x, _y, _onHide) + { + this.removeCallback = _onHide; + if(this.popup == null) + { + this.popup = document.createElement("sl-popup"); + this.popup.placement = "bottom"; + this.popup.autoSize = "vertical"; + this.popup.flip = true; + this.popup.shift = true; + this.popup.classList.add("egw_menu") + document.body.append(this.popup); + this.popup.append(this); + } + this.popup.anchor = { + getBoundingClientRect() + { + return { + x: _x, + y: _y, + width: 0, + height: 0, + top: _y, + left: _x, + right: _x, + bottom: _y + } + } + }; + this.popup.active = true; + Promise.all([this.updateComplete, this.popup.updateComplete]).then(() => + { + // Causes scroll issues if we don't position + this.popup.popup.style = "top: 0px"; + (this.menu.querySelector('sl-menu-item')).focus(); + }); + } + + public hide() + { + this.popup.active = false; + } + + handleSelect(event) + { + if(!this.popup) + { + return; + } + + if(event.detail.item.value) + { + const item = event.detail.item.value; + if(item.checkbox || typeof item.checked !== "undefined") + { + item.checked = event.detail.item.checked; + return; + } + if(typeof item.onClick == "function") + { + this.hide(); + item.onClick.call(event.detail.item, item, event); + } + } + } + + private itemTemplate(item : egwMenuItem) + { + if(item.caption == "-") + { + return html` + `; + } + + return html` + + ${item.iconUrl ? html` + ` : nothing} + ${item.caption} + ${item.shortcutCaption ? html` + ${item.shortcutCaption} + ` : nothing} + ${item.children.length == 0 ? nothing : html` + + ${repeat(item.children, i => this.itemTemplate(i))} + + `} + + `; + } + + + render() + { + return html` + + ${repeat(this.structure, i => this.itemTemplate(i))} + `; + } +} \ No newline at end of file diff --git a/api/js/egw_action/egw_menu.ts b/api/js/egw_action/egw_menu.ts index ebc6d00cf6..3306ae24e3 100755 --- a/api/js/egw_action/egw_menu.ts +++ b/api/js/egw_action/egw_menu.ts @@ -9,14 +9,18 @@ * */ import {egwMenuImpl} from './egw_menu_dhtmlx'; +import {EgwMenuShoelace} from "./EgwMenuShoelace"; import {egw_registeredShortcuts, egw_shortcutIdx} from './egw_keymanager'; import { EGW_KEY_ARROW_DOWN, EGW_KEY_ARROW_LEFT, EGW_KEY_ARROW_RIGHT, - EGW_KEY_ARROW_UP, EGW_KEY_ENTER, + EGW_KEY_ARROW_UP, + EGW_KEY_ENTER, EGW_KEY_ESCAPE } from "./egw_action_constants"; +import {EgwFramework} from "../../../kdots/js/EgwFramework"; + //Global variable which is used to store the currently active menu so that it //may be closed when another menu opens export var _egw_active_menu: egwMenu = null; @@ -209,7 +213,14 @@ export class egwMenu if (this.instance == null && this._checkImpl) { //Obtain a new egwMenuImpl object and pass this instance to it - this.instance = new egwMenuImpl(this.children); + if(window.framework instanceof EgwFramework) + { + this.instance = new EgwMenuShoelace(this.children); + } + else + { + this.instance = new egwMenuImpl(this.children); + } _egw_active_menu = this; @@ -238,6 +249,12 @@ export class egwMenu return false; } + // Shoelace does its own keyboard navigation + if(!this.instance.dhtmlxmenu) + { + return false; + } + //TODO change with shoelace let current = this.instance.dhtmlxmenu.menuSelected; if (current !== -1)