/** * EGroupware eTemplate2 - JS Favorite widget * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package etemplate * @subpackage api * @link https://www.egroupware.org * @author Nathan Gray * @copyright Nathan Gray 2022 */ import {Et2DropdownButton} from "../Et2DropdownButton/Et2DropdownButton"; import {css, html, PropertyValues, TemplateResult} from "lit"; import {SelectOption} from "../Et2Select/FindSelectOptions"; import {et2_INextmatchHeader, et2_nextmatch} from "../et2_extension_nextmatch"; import {Et2Image} from "../Et2Image/Et2Image"; import {Et2Dialog} from "../Et2Dialog/Et2Dialog"; import {SlMenuItem} from "@shoelace-style/shoelace"; import {cssImage} from "../Et2Widget/Et2Widget"; /** * Favorites widget, designed for use in the nextmatch header * * The primary control is a split/dropdown button. Clicking on the left side of the button filters the * nextmatch list by the user's default filter. The right side of the button gives a list of * saved filters, pulled from preferences. Clicking a filter from the dropdown list sets the * filters as saved. * * Favorites can also automatically be shown in the sidebox, using the special ID favorite_sidebox. * Use the following code to generate the sidebox section: * display_sidebox($appname,lang('Favorites'),array( * array( * 'no_lang' => true, * 'text'=>'', * 'link'=>false, * 'icon' => false * ) * )); * This sidebox list will be automatically generated and kept up to date. * * * Favorites are implemented by saving the values for [column] filters. Filters are stored * in preferences, with the name favorite_. The favorite favorite used for clicking on * the filter button is stored in nextmatch--favorite. * */ export class Et2Favorites extends Et2DropdownButton implements et2_INextmatchHeader { static get styles() { return [ ...super.styles, css` :host { min-width: 8ex; } et2-image { display: flex; height: 100%; width: ${egwIsMobile() ? css`4ex` : css`2ex`} } et2-image[src="trash"] { display: none; } sl-menu { min-width: 15em; } sl-menu-item:hover et2-image[src="trash"] { display: initial; } /* Add star icons - radio button is already in prefix */ sl-menu-item::part(base) { background-image: ${cssImage("fav_filter")}; background-repeat: no-repeat; background-size: 16px 16px; background-position: 5px center; } sl-menu-item[checked]::part(base) { background-image: ${cssImage("favorites")}; } sl-menu-item:last-child::part(base) { background-image: none; } `, ]; } static get properties() { return { ...super.properties, // Where we keep the "default" preference defaultPref: {type: String}, // Application to show favorites for app: {type: String}, // Extra filters to include in the saved favorite filters: {type: Object} }; } // Favorites are prefixed in preferences public static readonly PREFIX = "favorite_"; protected static readonly ADD_VALUE = "~add~"; private favSortedList : any = []; private _preferred : string; private _nextmatch : et2_nextmatch; constructor() { super(); this.__statustext = "Favorite queries"; this._handleRadio = this._handleRadio.bind(this); this._handleDelete = this._handleDelete.bind(this); } connectedCallback() { super.connectedCallback(); if(!this.id) { this.id = "favorite"; } this._preferred = this.egw().preference(this.defaultPref, this.app); // Need to wait until update is done and these exist this.updateComplete.then(() => { if(this.buttonNode) { let img = new Et2Image(); img.src = "fav_filter"; this.buttonNode.append(img); } }); } set select_options(_new_options : SelectOption[]) { // We don't actually want your options, thanks. } get select_options() : SelectOption[] { if(this.__select_options.length) { return this.__select_options; } } get preferred() : string { return this._preferred; } _optionTemplate(option : SelectOption) : TemplateResult { let radio = html``; //@ts-ignore TS doesn't know about window.app let is_admin = (typeof this.egw().app('admin') != "undefined"); //@ts-ignore option.group does not exist let icon = (option.group !== false && !is_admin || ['blank', '~add~'].includes(option.value)) ? "" : html` `; return html` ${option.value !== Et2Favorites.ADD_VALUE ? radio : ""} ${icon} ${option.label} `; } /** @param changedProperties */ updated(changedProperties : PropertyValues) { super.updated(changedProperties); if(changedProperties.has("app")) { this._preferred = this.egw().preference(this.defaultPref, this.app); this.__select_options = this._load_favorites(this.app); this.requestUpdate("select_options"); } } /** * Load favorites from preferences * * @param app String Load favorites from this application */ _load_favorites(app) { // Default blank filter let favorites : any = { 'blank': { name: this.egw().lang("No filters"), state: {} } }; // Load saved favorites this.favSortedList = []; let preferences : any = this.egw().preference("*", app); for(let pref_name in preferences) { if(pref_name.indexOf(Et2Favorites.PREFIX) == 0 && typeof preferences[pref_name] == 'object') { let name = pref_name.substr(Et2Favorites.PREFIX.length); favorites[name] = preferences[pref_name]; // Keep older favorites working - they used to store nm filters in 'filters',not state if(preferences[pref_name]["filters"]) { favorites[pref_name]["state"] = preferences[pref_name]["filters"]; } } if(pref_name == 'fav_sort_pref') { this.favSortedList = preferences[pref_name]; //Make sure sorted list is always an array, seems some old fav are not array if(!Array.isArray(this.favSortedList)) { this.favSortedList = this.favSortedList.split(','); } } } for(let name in favorites) { if(this.favSortedList.indexOf(name) < 0) { this.favSortedList.push(name); } } this.egw().set_preference(this.app, 'fav_sort_pref', this.favSortedList); if(this.favSortedList.length > 0) { let sortedListObj = {}; for(let i = 0; i < this.favSortedList.length; i++) { if(typeof favorites[this.favSortedList[i]] != 'undefined') { sortedListObj[this.favSortedList[i]] = favorites[this.favSortedList[i]]; } else { this.favSortedList.splice(i, 1); this.egw().set_preference(this.app, 'fav_sort_pref', this.favSortedList); } } favorites = Object.assign(sortedListObj, favorites); } let options = []; Object.keys(favorites).forEach((name) => { options.push(Object.assign({value: name, label: favorites[name].name || name}, favorites[name])); }) // Only add 'Add current' if we have a nextmatch if(this._nextmatch) { options.push({value: Et2Favorites.ADD_VALUE, label: this.egw().lang('Add current')}); } this.requestUpdate("select_options"); return options; } public load_favorites(app) { this.__select_options = this._load_favorites(app); this.requestUpdate("select_options"); } /** * Add the current settings as a new favorite */ _add_current() { // Get current filters let current_filters = Object.assign({}, this._nextmatch.activeFilters); // Add in extras for(let extra in this.filters) { // Don't overwrite what nm has, chances are nm has more up-to-date value if(typeof current_filters == 'undefined') { // @ts-ignore current_filters[extra] = this._nextmatch.options.settings[extra]; } } // Skip columns delete current_filters.selectcols; // Add in application's settings if(this.filters != true) { for(let i = 0; i < this.filters.length; i++) { current_filters[this.filters[i]] = this._nextmatch.options.settings[this.filters[i]]; } } // Call framework //@ts-ignore TS doesn't know about window.app window.app[this.app].add_favorite(current_filters); } /** * Get a favorite from the list by id */ favoriteByID(id : string) : any { if(!id) { return null; } return this.__select_options.find(f => f.value == id) } /** * Clicked on an option * * @param ev * @protected */ protected _handleSelect(ev) { if(ev.detail.item.value == Et2Favorites.ADD_VALUE) { return this._add_current(); } this._value = ev.detail.item.value; this._apply_favorite(ev.detail.item.value); } /** * Handle the click from the main button * * @param {MouseEvent} event * @protected */ protected _handleClick(event : MouseEvent) { this._apply_favorite(this.preferred); } /** * Clicked a radio button * * @param _ev * @protected */ protected _handleRadio(_ev) { // Don't do the menu _ev.stopImmediatePropagation(); // Save as default favorite - used when you click the button let pref = _ev.target.value; this.egw().set_preference(this.app, this.defaultPref, pref); this._preferred = pref; this.dropdownNode.hide(); this.requestUpdate("select_options"); this.dispatchEvent(new Event("change", {bubbles: true})); } _handleDelete(_ev : MouseEvent) { // Don't do the menu _ev.stopImmediatePropagation(); let trash = (_ev.target).parentNode; let line = trash.parentNode; let fav = this.favoriteByID(line.value); line.classList.add("loading"); // Make sure first let do_delete = function(button_id) { if(button_id != Et2Dialog.YES_BUTTON) { line.classList.remove('loading'); return; } // Hide the trash trash.remove(); // Delete preference server side let request = this.egw().json("EGroupware\\Api\\Framework::ajax_set_favorite", [this.app, fav.name, "delete", "" + fav.group, '']).sendRequest(); request.then(response => { line.classList.remove("loading"); let result = response.response.find(r => r.type == "data"); // Could not find the result we want if(!result || result.type !== "data") { return; } if(typeof result.data == 'boolean' && result.data) { // Remove line from list line.remove(); // Remove favorite from options this.__select_options = this.__select_options.filter(f => f.value != fav.value); } else { // Something went wrong server side line.classList.add('error'); } }); }.bind(this); Et2Dialog.show_dialog(do_delete, (this.egw().lang("Delete") + " " + fav.name + "?"), "Delete", null, Et2Dialog.BUTTONS_YES_NO, Et2Dialog.QUESTION_MESSAGE); return false; } /** * Apply a favorite to the app or nextmatch * * @param {string} favorite_name * @protected */ protected _apply_favorite(favorite_name : string) { let fav = favorite_name == "blank" ? {} : this.favoriteByID(favorite_name); // use app[appname].setState if available to allow app to overwrite it (eg. change to non-listview in calendar) //@ts-ignore TS doesn't know about window.app if(typeof window.app[this.app] != 'undefined') { //@ts-ignore TS doesn't know about window.app window.app[this.app].setState(fav); } } /** * Set the nextmatch to filter * From et2_INextmatchHeader interface * * @param {et2_nextmatch} nextmatch */ setNextmatch(nextmatch) { this._nextmatch = nextmatch; if(this.nm_filter) { this.set_value(this.nm_filter); this.nm_filter = false; } // Re-generate filter list so we can add 'Add current' this.__select_options = this._load_favorites(this.app); this.requestUpdate("select_options"); } } customElements.define("et2-favorites", Et2Favorites);