import {SlTreeItem} from "@shoelace-style/shoelace"; import {egw} from "../../jsapi/egw_global"; import {find_select_options, SelectOption} from "../Et2Select/FindSelectOptions"; import {Et2WidgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin"; import {css, html, LitElement, nothing, PropertyValues, TemplateResult} from "lit"; import {repeat} from "lit/directives/repeat.js"; import shoelace from "../Styles/shoelace"; import {property} from "lit/decorators/property.js"; import {state} from "lit/decorators/state.js"; import {egw_getActionManager, egw_getAppObjectManager} from "../../egw_action/egw_action"; import {et2_action_object_impl} from "../et2_core_DOMWidget"; import {EgwActionObject} from "../../egw_action/EgwActionObject"; import {EgwAction} from "../../egw_action/EgwAction"; import {EgwDragDropShoelaceTree} from "../../egw_action/EgwDragDropShoelaceTree"; export type TreeItemData = SelectOption & { focused?: boolean; // Has children, but they may not be provided in item child: Boolean | 1, data?: Object,//{sieve:true,...} or {acl:true} or other id: string, im0: String, im1: String, im2: String, // Child items item: TreeItemData[], checked?: Boolean, nocheckbox: number | Boolean, open: 0 | 1, parent: String, text: String, tooltip: String, userdata: any[] //here we can store the number of unread messages, if there are any badge?: string; } /** * checks if the event has an Element in its composedPath that satisfies the Tag, className or both * @param _ev * @param tag * @param className * @returns true iff tag and classname are satisfied on the same Element somewhere in the composedPath and false otherwise */ export const composedPathContains = (_ev: any, tag?: string, className?: string) => { // Tag and classname is given // check if one element has given tag with given class if(tag && className) { return _ev.composedPath().some((el) => { return el?.classList?.contains(className) && el?.tagName?.toLowerCase() === tag.toLowerCase() }) } // only classname is given // check if one element has given class if(className && !tag) return _ev.composedPath().some((el) => { return el?.classList?.contains(className) }) // only tag is given // check if one element has given tag if(tag && !className) return _ev.composedPath().some((el) => { return el?.tagName?.toLowerCase() === tag.toLowerCase() }) return false } /** * @event {{id: String, item:SlTreeItem}} sl-expand emmited when tree item expands * //TODO add for other events * @since 23.1.x */ export class Et2Tree extends Et2WidgetWithSelectMixin(LitElement) { //does not work because it would need to be run on the shadow root //@query("sl-tree-item[selected]") selected: SlTreeItem; /** * get the first selected node using attributes on the shadow root elements */ private get selected(){ return this.shadowRoot.querySelector("sl-tree-item[selected]") } @property({type: Boolean}) multiple: Boolean = false; @property({type: String}) leafIcon: String; @property({type: String}) collapsedIcon: String; @property({type: String}) openIcon: String; @property({type: Function}) onclick;// description: "JS code which gets executed when clicks on text of a node" //onselect and oncheck only appear in multiselectTree // @property() // onselect // description: "Javascript executed when user selects a node" // @property() // oncheck // description: "Javascript executed when user checks a node" @property({type: Boolean}) highlighting: Boolean = false // description: "Add highlighting class on hovered over item, highlighting is disabled by default" @property({type: String}) autoloading: string = "" //description: "JSON URL or menuaction to be called for nodes marked with child=1, but not having children, getSelectedNode() contains node-id" @property({type: Function}) onopenstart //description: "Javascript function executed when user opens a node: function(_id, _widget, _hasChildren) returning true to allow opening!" @property({type: Function}) onopenend //description: "Javascript function executed when opening a node is finished: function(_id, _widget, _hasChildren)" @property({type: String}) imagePath: String = egw().webserverUrl + "/api/templates/default/images/dhtmlxtree/" //TODO we will need a different path here! maybe just rename the path? // description: "Directory for tree structure images, set on server-side to 'dhtmlx' subdir of templates image-directory" @property() value = [] protected autoloading_url: any; // private selectOptions: TreeItemData[] = []; @state() protected _selectOptions: TreeItemData[] @state() protected _currentOption: TreeItemData @state() protected _previousOption: TreeItemData @state() protected _currentSlTreeItem: SlTreeItem; @state() selectedNodes: SlTreeItem[] private _actionManager: EgwAction; private get _tree() { return this.shadowRoot.querySelector('sl-tree') ?? null}; constructor() { super(); this._selectOptions = []; this._optionTemplate = this._optionTemplate.bind(this); this.selectedNodes = []; } private _initCurrent() { this._currentSlTreeItem = this.selected; this._currentOption = this._currentSlTreeItem?this.getNode(this._currentSlTreeItem?.id):null } firstUpdated() { if (this.autoloading) { // @ts-ignore from static get properties let url = this.autoloading; if (url.charAt(0) != '/' && url.substr(0, 4) != 'http') { url = '/json.php?menuaction=' + url; } this.autoloading = url; } // Check if top level should be autoloaded if(this.autoloading && !this._selectOptions?.length) { this.handleLazyLoading({item: this._selectOptions}).then((results) => { this._selectOptions = results?.item ?? []; this._initCurrent() this.requestUpdate("_selectOptions"); this.updateComplete.then((value) => { if (value) { this._link_actions(this.actions) } }) }) } if (this._selectOptions?.length) this._initCurrent() // Actions can't be initialized without being connected to InstanceManager this._initActions(); this._link_actions(this.actions) } protected updated(_changedProperties: PropertyValues) { super.updated(_changedProperties); } //Sl-Trees handle their own onClick events _handleClick(_ev) { // check if not expand icon (> or v) was clicked, we have an onclick handler and a string value if (!(_ev.composedPath()[0].tagName === 'svg' && (_ev.composedPath()[0].classList.contains('bi-chevron-right') || _ev.composedPath()[0].classList.contains('bi-chevron-down') ) ) && typeof this.onclick === "function" && typeof _ev.target.value === "string") { this.onclick(_ev.target.value, this, _ev.target.value) } } static get styles() { return [ shoelace, // @ts-ignore ...super.styles, css` :host { --sl-spacing-large: 1rem; } ::part(expand-button) { rotate: none; padding: 0 0.2em 0 5em; margin-left: -5em; } /* Stop icon from shrinking if there's not enough space */ /* increase font size by 2px this was previously done in pixelegg css but document css can not reach shadow root*/ sl-tree-item sl-icon { flex: 0 0 1em; font-size: calc(100% + 2px); } ::part(label) { overflow: hidden; } ::part(label):hover { text-decoration: underline; } .tree-item__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } sl-tree-item { background-color: white; } sl-tree-item.drop-hover { background-color: #0a5ca5; } sl-tree-item.drop-hover sl-tree-item { background-color: white; } sl-badge::part(base) { background-color: rgb(130, 130, 130); font-size: 0.75em; font-weight: 700; position: absolute; top: 0; right: 0.5em; } @media only screen and (max-device-width: 768px) { :host { --sl-font-size-medium: 1.2rem; } sl-tree-item { padding: 0.1em; } } ` ] } private _actions: object get actions() { return this._actions } /** * Set Actions on the widget * * Each action is defined as an object: * * move: { * type: "drop", * acceptedTypes: "mail", * icon: "move", * caption: "Move to" * onExecute: javascript:mail_move" * } * * This will turn the widget into a drop target for "mail" drag types. When "mail" drag types are dropped, * the global function mail_move(egwAction action, egwActionObject sender) will be called. The ID of the * dragged "mail" will be in sender.id, some information about the sender will be in sender.context. The * etemplate2 widget involved can typically be found in action.parent.data.widget, so your handler * can operate in the widget context easily. The location varies depending on your action though. It * might be action.parent.parent.data.widget * * To customise how the actions are handled for a particular widget, override _link_actions(). It handles * the more widget-specific parts. * * @param {object} actions {ID: {attributes..}+} map of egw action information * @see api/src/Etemplate/Widget/Nextmatch.php egw_actions() method */ @property({type: Object}) set actions(actions: object) { this._actions = actions if (this.id == "" || typeof this.id == "undefined") { if(this.isConnected) { window.egw().debug("warn", "Widget should have an ID if you want actions", this); } // No id because we're not done yet, try again later return; } if(this.isConnected) { this._initActions(); } } public loadFromXML() { let new_options = []; if(this.id) { new_options = find_select_options(this)[1]; } if(new_options?.length) { this._selectOptions = new_options; } } /** * Initialize the action manager and add some actions to it * @private */ private _initActions() { // Only look 1 level deep // @ts-ignore exists from Et2Widget var gam = egw_getActionManager(this.egw().appName, true, 1); if(typeof this._actionManager != "object") { // @ts-ignore exists from Et2Widget if(this.getInstanceManager() && gam.getActionById(this.getInstanceManager().uniqueId, 1) !== null) { // @ts-ignore exists from Et2Widget gam = gam.getActionById(this.getInstanceManager().uniqueId, 1); } if(gam.getActionById(this.id, 1) != null) { this._actionManager = gam.getActionById(this.id, 1); } else { this._actionManager = gam.addAction("actionManager", this.id); } } // @ts-ignore egw() exists on this this._actionManager.updateActions(this.actions, this.egw().appName); // @ts-ignore if(this.options.default_execute) { this._actionManager.setDefaultExecute(this.options.default_execute); } // Put a reference to the widget into the action stuff, so we can // easily get back to widget context from the action handler this._actionManager.data = {widget: this}; } /** Sets focus on the control. */ focus(options? : FocusOptions) { this._tree.focus(); } /** Removes focus from the control. */ blur() { this._tree.blur(); } /** * @deprecated assign to onopenstart * @param _handler */ public set_onopenstart(_handler: any) { this.onopenstart = _handler } /** * @deprecated assign to onopenend * @param _handler */ public set_onopenend(_handler: any) { this.onopenend = _handler } /** * @deprecated assign to onclick * @param _handler */ public set_onclick(_handler: Function) { this.installHandler('onclick', _handler); } /** * @deprecated assign to onselect * @param _handler */ public set_onselect(_handler: any) { this.onselect = _handler; } public set_badge(_id: string, _value: string) { this.getNode(_id).badge = _value; this.requestUpdate(); } /** * @return currently selected Item or First Item, if no selection was made yet */ public getSelectedItem(): TreeItemData { return this._currentOption || (this._selectOptions ? this._selectOptions[0] : null); } /** * getSelectedNode, retrieves the full node of the selected Item * @return {SlTreeItem} full SlTreeItem */ getSelectedNode(): SlTreeItem { return this._currentSlTreeItem } getDomNode(_id): SlTreeItem|null { return this.shadowRoot.querySelector("sl-tree-item[id='" + _id + "'"); } /** * return the Item with given _id, was called getDomNode(_id) in dhtmlxTree * @param _id */ public getNode(_id: string): TreeItemData { if(_id == undefined){debugger;} // TODO: Look into this._search(), find out why it doesn't always succeed return this._search(_id, this._selectOptions) ?? this.optionSearch(_id, this._selectOptions, 'value', 'children') } /** * set the text of item with given id to new label * @param _id * @param _label * @param _tooltip */ setLabel(_id, _label, _tooltip?) { let tooltip = _tooltip || (this.getNode(_id) && this.getNode(_id).tooltip ? this.getNode(_id).tooltip : ""); let i = this.getNode(_id) i.tooltip = tooltip i.text = _label } /** * getLabel, gets the Label of of an item by id * @param _id ID of the node * @return _label */ getLabel(_id) { return this.getNode(_id)?.text; } /** * getSelectedLabel, retrieves the Label of the selected Item * @return string or null */ getSelectedLabel() { return this.getSelectedItem()?.text } /** * deleteItem, deletes an item by id * @param _id ID of the node * @param _selectParent select the parent node true/false TODO unused atm * @return void */ deleteItem(_id, _selectParent) { this._deleteItem(_id, this._selectOptions) // Update action // since the action ID has to = this.id, getObjectById() won't work let treeObj = (egw_getAppObjectManager(false)).getObjectById(this.id); for (let i = 0; i < treeObj.children.length; i++) { if (treeObj.children[i].id == _id) { treeObj.children.splice(i, 1); } } this.requestUpdate(); } /** * Updates a leaf of the tree by requesting new information from the server using the * autoloading attribute. * * @param {string} _id ID of the node * @param {Object} [data] If provided, the item is refreshed directly with * the provided data instead of asking the server * @return Promise */ refreshItem(_id, data) { /* TODO currently always ask the sever if (typeof data != "undefined" && data != null) { //data seems never to be used this.refreshItem(_id, null) } else*/ { let item = this.getNode(_id) return this.handleLazyLoading(item).then((result) => { item.item = [...result.item] this.requestUpdate("_selectOptions") }) } } /** * Does nothing * @param _id * @param _style */ setStyle(_id, _style) { const temp = this.getDomNode(_id).defaultSlot; if (!temp) return 0; temp.setAttribute("style", _style); } /** * getTreeNodeOpenItems TODO * * @param {string} _nodeID the nodeID where to start from (initial node) 0 means for all items * @param {string} mode the mode to run in: "forced" fakes the initial node openState to be open * @return {object} structured array of node ids: array(message-ids) */ getTreeNodeOpenItems(_nodeID: string | 0, mode?: string) { //let z:string[] = this.input.getSubItems(_nodeID).split(this.input.dlmtr); let subItems = (_nodeID == 0) ? this._selectOptions.map(option => this.getDomNode(option.id)) ://NodeID == 0 means that we want all tree Items this.getDomNode(_nodeID).getChildrenItems();// otherwise get the subItems of the given Node let oS: boolean; let PoS: 0 | 1 | -1; let rv: string[]; let returnValue = (_nodeID == 0) ? [] : [_nodeID]; // do not keep 0 in the return value... // it is not needed and only throws a php warning later TODO check with ralf what happens in mail_ui.inc.php with ajax_setFolderStatus let modetorun = "none"; if (mode) { modetorun = mode; } PoS = (_nodeID == 0) ? 1 : (this.getDomNode(_nodeID).expanded ? 1 : 0) if (modetorun == "forced") PoS = 1; if (PoS == 1) { for (const item of subItems) { //oS = this.input.getOpenState(z[i]); oS = item.expanded // iff current item is expanded go deeper //if (oS == -1) {returnValue.push(z[i]);} //if (oS == 0) {returnValue.push(z[i]);} if (!oS) { returnValue.push(item.id) } //if (oS == 1) else { rv = this.getTreeNodeOpenItems(item.id); for (const recId of rv) { returnValue.push(recId); } } } } //alert(returnValue.join('#,#')); return returnValue; } /** * @param _id * @param _newItemId * @param _label * @return Promise */ public renameItem(_id, _newItemId, _label) { this.getNode(_id).id = _newItemId // Update action // since the action ID has to = this.id, getObjectById() won't work let treeObj: EgwActionObject = egw_getAppObjectManager(false).getObjectById(this.id); for (const actionObject of treeObj.children) { if (actionObject.id == _id) { actionObject.id = _newItemId; if (actionObject.iface) { actionObject.iface.id = _newItemId } break } } if (typeof _label != 'undefined') this.setLabel(_newItemId, _label); this.requestUpdate() return this.updatedComplete(); } public focusItem(_id) { let item = this.getNode(_id) item.focused = true } /** * Open an item, which might trigger lazy-loading * * @param string _id * @return Promise */ public openItem(_id : string) { let item = this.getNode(_id); if(item) { item.open = 1; } this.requestUpdate(); return this.updateComplete; } /** * hasChildren * * @param _id ID of the node * @return the number of childelements */ hasChildren(_id) { return this.getNode(_id).item?.length; } /** * reSelectItem, reselects an item by id * @param _id ID of the node */ reSelectItem(_id) { this._previousOption = this._currentOption this._currentOption = this.getNode(_id); const node: SlTreeItem = this.getDomNode(_id) if (node) { this._currentSlTreeItem = node; node.selected = true } } /** * Set or unset checkbox of given node and all it's children based on given value * * @param _id * @param _value "toggle" means the current nodes value, as the toggle already happened by default * @return boolean false if _id was not found */ setSubChecked(_id : string, _value : boolean|"toggle") { const node = this.getDomNode(_id); if (!node) return false; if (_value !== 'toggle') { node.selected = _value; } Array.from(node.querySelectorAll('sl-tree-item')).forEach((item : SlTreeItem) => { item.selected = node.selected; }); // set selectedNodes and value this.selectedNodes = []; this.value = []; Array.from(this._tree.querySelectorAll('sl-tree-item')).forEach((item : SlTreeItem) => { if (item.selected) { this.selectedNodes.push(item); this.value.push(item.id); } }); return true; } getUserData(_nodeId, _name) { return this.getNode(_nodeId)?.userdata?.find(elem => elem.name === _name)?.content } /** * Overridable, add style * @returns {TemplateResult<1>} */ styleTemplate() { return html``; } //this.selectOptions = find_select_options(this)[1]; _optionTemplate(selectOption: TreeItemData): TemplateResult<1> { // Check to see if node is marked as open with no children. If autoloadable, load the children const expandState = (this.calculateExpandState(selectOption)); //mail sends multiple image options depending on folder state let img: String; if (selectOption.open) //if item is a folder and it is opened use im1 { img = selectOption.im1; } else if (selectOption.child || selectOption.item?.length > 0)// item is a folder and closed use im2 { img = selectOption.im2; } else// item is a leaf use im0 { img = selectOption.im0; } //fallback to try and set icon if everything else failed if (!img) img = selectOption.icon ?? selectOption.im0 ?? selectOption.im1 ?? selectOption.im2; if (img?.endsWith(".png")) { //sl-icon images need to be svgs if there is a png try to find the corresponding svg img = img.replace(".png", ".svg"); } // lazy iff "child" is set and "item" is empty or item does not exist in the first place const lazy = (selectOption.item?.length === 0 && selectOption.child) || (selectOption.child && !selectOption.item) if(expandState && this.autoloading && lazy) { this.updateComplete.then(() => { this.getDomNode(selectOption.id)?.dispatchEvent(new CustomEvent("sl-lazy-load")); }) } const value = selectOption.value ?? selectOption.id; return html` { // No need for this to bubble up, we'll handle it (otherwise the parent leaf will load too) event.stopPropagation(); this.handleLazyLoading(selectOption).then((result) => { // TODO: We already have the right option in context. Look into this.getNode(), find out why it's there. It doesn't do a deep search. const parentNode = selectOption ?? this.getNode(selectOption.id) ?? this.optionSearch(selectOption.id, this._selectOptions, 'id', 'item'); parentNode.item = [...result.item] this.requestUpdate("_selectOptions") }) } } @sl-expand=${event => { if (event.target.id === selectOption.id) { selectOption.open = 1; this.requestUpdate("_selectOptions") } }} @sl-collapse=${event => { if (event.target.id === selectOption.id) { selectOption.open = 0; this.requestUpdate("_selectOptions") } }} > ${selectOption.label ?? selectOption.text} ${(selectOption.badge) ? html` ${selectOption.badge} ` : nothing} ${selectOption.children ? repeat(selectOption.children, this._optionTemplate) : (selectOption.item ? repeat(selectOption.item, this._optionTemplate) : nothing)} ` } public render(): unknown { return html` ${this.styleTemplate()} { this._previousOption = this._currentOption ?? (this.value.length ? this.getNode(this.value[0]) : null); this._currentOption = this.getNode(event.detail.selection[0].id) ?? this.optionSearch(event.detail.selection[0].id, this._selectOptions, 'id', 'item'); const ids = event.detail.selection.map(i => i.id); // implemented unlinked multiple if (this.multiple) { const idx = this.value.indexOf(ids[0]); if (idx < 0) { this.value.push(ids[0]); } else { this.value.splice(idx, 1); } // sync tree-items selected attribute with this.value this.selectedNodes = []; Array.from(this._tree.querySelectorAll('sl-tree-item')).forEach((item : SlTreeItem) => { if(this.value.includes(item.id)) { item.setAttribute("selected", ""); this.selectedNodes.push(item); } else { item.removeAttribute("selected"); } }); this._tree.requestUpdate(); } else { this.value = this.multiple ? ids ?? [] : ids[0] ?? ""; } event.detail.previous = this._previousOption?.id; this._currentSlTreeItem = event.detail.selection[0]; /* implemented unlinked-multiple if(this.multiple) { this.selectedNodes = event.detail.selection }*/ if(typeof this.onclick == "function") { // wait for the update, so app founds DOM in the expected state this._tree.updateComplete.then(() => { this.onclick(event.detail.selection[0].id, this, event.detail.previous) }); } } } @sl-expand=${ (event) => { event.detail.id = event.target.id event.detail.item = event.target if (this.onopenstart) { this.onopenstart(event.detail.id, this, 1) } } } @sl-after-expand=${ (event) => { event.detail.id = event.target.id event.detail.item = event.target if (this.onopenend) { this.onopenend(event.detail.id, this, -1) } this._link_actions(this.actions) } } > ${repeat(this._selectOptions, this._optionTemplate)} `; } handleLazyLoading(_item: TreeItemData) { let requestLink = egw().link(egw().ajaxUrl(egw().decodePath(this.autoloading)), { id: _item.id }) let result: Promise = egw().request(requestLink, []) return result .then((results) => { _item = results; return results; }); } /** * * */ _link_actions(actions) { if(this.actions && !this._actionManager) { // ActionManager creation was missed this.actions = this._actions; } // Get the top level element for the tree let objectManager = egw_getAppObjectManager(true); let widget_object = objectManager.getObjectById(this.id); if (widget_object == null) { // Add a new container to the object manager which will hold the widget // objects widget_object = objectManager.insertObject(false, new EgwActionObject( //@ts-ignore this.id, objectManager, (new et2_action_object_impl(this, this)).getAOI(), this._actionManager || objectManager.manager.getActionById(this.id) || objectManager.manager )); } else { // @ts-ignore widget_object.setAOI((new et2_action_object_impl(this, this)).getAOI()); } // Delete all old objects widget_object.clear(); widget_object.unregisterActions(); // Go over the widget & add links - this is where we decide which actions are // 'allowed' for this widget at this time var action_links = this._get_action_links(actions); //Drop target enabeling if (typeof this._selectOptions != 'undefined') { let self: Et2Tree = this // Iterate over the options (leaves) and add action to each one let apply_actions = function (treeObj: EgwActionObject, option: TreeItemData) { // Add a new action object to the object manager let id = option.value ?? (typeof option.value == 'number' ? String(option.id) : option.id); // @ts-ignore let obj : EgwActionObject = treeObj.addObject(id, new EgwDragDropShoelaceTree(self, id)); obj.updateActionLinks(action_links); const children = option.children ?? option.item ?? []; for(let i = 0; i < children.length; i++) { apply_actions.call(this, treeObj, children[i]); } }; for (const selectOption of this._selectOptions) { apply_actions.call(this, widget_object, selectOption) } } widget_object.updateActionLinks(action_links); } /** * Get all action-links / id's of 1.-level actions from a given action object * * This can be overwritten to not allow all actions, by not returning them here. * * @param actions * @returns {Array} */ _get_action_links(actions) { var action_links = []; for (var i in actions) { var action = actions[i]; action_links.push(typeof action.id != 'undefined' ? action.id : i); } return action_links; } /** * * @param _id to search for * @param data{TreeItemData[]} structure to search in * @return {TreeItemData} node with the given _id or null * @private */ private _search(_id: string|number, data: TreeItemData[]): TreeItemData { let res: TreeItemData = null if (_id == undefined) { return null } if (typeof _id === "number") { _id = _id + ""; } for (const value of data) { if (value.id === _id) { res = value return res } else if(_id?.startsWith(value.id) && typeof value.item !== "undefined") { res = this._search(_id, value.item) } } return res } /** * checks whether item should be drawn open or closed * also sets selectOption.open if necessary * @param selectOption * @returns true iff item is in expanded state */ private calculateExpandState = (selectOption: TreeItemData) => { if (selectOption.open) { return true } // TODO: Move this mail-specific stuff into mail if(selectOption.id && (selectOption.id.endsWith("INBOX") || selectOption.id == window.egw.preference("ActiveProfileID", "mail"))) { selectOption.open = 1 return true } return false; } private _deleteItem(_id, list) { for (let i = 0; i < list.length; i++) { const value = list[i]; if (value.id === _id) { list.splice(i, 1) } else if (_id.startsWith(value.id)) { this._deleteItem(_id, value.item) } } } } customElements.define("et2-tree", Et2Tree);