From 776ce7202aecf1a5b80b6a3067dc898e63a730c4 Mon Sep 17 00:00:00 2001 From: nathan Date: Wed, 18 May 2022 11:01:27 -0600 Subject: [PATCH] Et2Favorites --- .../Et2DropdownButton/Et2DropdownButton.ts | 9 +- api/js/etemplate/Et2Favorites/Et2Favorites.ts | 456 ++++++++++++++++++ api/js/etemplate/etemplate2.ts | 1 + 3 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 api/js/etemplate/Et2Favorites/Et2Favorites.ts diff --git a/api/js/etemplate/Et2DropdownButton/Et2DropdownButton.ts b/api/js/etemplate/Et2DropdownButton/Et2DropdownButton.ts index 04dadbb912..660ebed04b 100644 --- a/api/js/etemplate/Et2DropdownButton/Et2DropdownButton.ts +++ b/api/js/etemplate/Et2DropdownButton/Et2DropdownButton.ts @@ -44,7 +44,8 @@ export class Et2DropdownButton extends Et2widgetWithSelectMixin(Et2Button) /* Avoid unwanted style overlap from button */ border: none; background-color: none; - + } + :host, sl-menu { /** Adapt shoelace color variables to what we want Maybe some logical variables from etemplate2.css here? @@ -53,6 +54,7 @@ export class Et2DropdownButton extends Et2widgetWithSelectMixin(Et2Button) --sl-color-primary-100: var(--gray-10); --sl-color-primary-300: var(--input-border-color); --sl-color-primary-400: var(--input-border-color); + --sl-color-primary-600: var(--primary-background-color); --sl-color-primary-700: #505050; } :host(:active), :host([active]) { @@ -129,7 +131,7 @@ export class Et2DropdownButton extends Et2widgetWithSelectMixin(Et2Button) `; } - protected _optionTemplate(option : SelectOption) : TemplateResult + _optionTemplate(option : SelectOption) : TemplateResult { let icon = option.icon ? html` ` : ''; @@ -143,7 +145,6 @@ export class Et2DropdownButton extends Et2widgetWithSelectMixin(Et2Button) protected _handleSelect(ev) { - let oldValue = this._value; this._value = ev.detail.item.value; // Trigger a change event @@ -164,7 +165,7 @@ export class Et2DropdownButton extends Et2widgetWithSelectMixin(Et2Button) this.requestUpdate("value", oldValue); } - get _optionTargetNode() + get _optionTargetNode() : HTMLElement { return this.shadowRoot.querySelector("sl-menu"); } diff --git a/api/js/etemplate/Et2Favorites/Et2Favorites.ts b/api/js/etemplate/Et2Favorites/Et2Favorites.ts new file mode 100644 index 0000000000..d69f7ba953 --- /dev/null +++ b/api/js/etemplate/Et2Favorites/Et2Favorites.ts @@ -0,0 +1,456 @@ +/** + * 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 "@lion/core"; +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"; + +/** + * 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` + et2-image { + height: 2ex; + padding: 0px; + margin-top: -5px; + vertical-align: middle; + } + et2-image[src="trash"] { + display:none; + } + sl-menu { + min-width: 15em; + } + sl-menu-item:hover et2-image[src="trash"] { + display:initial; + } + .menu-item__chevron { + display:none; + } + `, + ]; + } + + static get properties() + { + return { + ...super.properties, + // Where we keep the "default" preference + default_pref: {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 = null; + 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.default_pref, 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; + } + } + + _optionTemplate(option : SelectOption) : TemplateResult + { + let radio = html``; + + //@ts-ignore TS doesn't know about window.app + let is_admin = (typeof window.app['admin'] != "undefined"); + //@ts-ignore option.group does not exist + let icon = (option.group !== false && !is_admin || option.value == 'blank') ? "" : html` + `; + + return html` + + ${option.value !== Et2Favorites.ADD_VALUE ? radio : ""} + ${icon} + ${option.label} + `; + } + + + /** @param {import('@lion/core').PropertyValues } changedProperties */ + updated(changedProperties : PropertyValues) + { + super.updated(changedProperties); + if(changedProperties.has("app")) + { + this._preferred = this.egw().preference(this.default_pref, 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 + 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')}); + } + return 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); + } + + /** + * 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.default_pref, pref); + this._preferred = pref; + this.dropdownNode.hide(); + this.requestUpdate(); + } + + _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; + } + + /** + * Clicked the main button + * + * @param {MouseEvent} _ev + * @returns {boolean} + * @protected + */ + _handleClick(_ev : MouseEvent) : boolean + { + // Apply preferred filter - make sure it's an object, and not a reference + if(this._preferred && this.favoriteByID(this._preferred)) + { + this._apply_favorite(this._preferred); + } + _ev.stopImmediatePropagation(); + 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"); + } +} + +// @ts-ignore TypeScript is not recognizing that this is a LitElement +customElements.define("et2-favorites", Et2Favorites); \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index db5a22c614..e2eaf03ffc 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -40,6 +40,7 @@ import './Et2Dialog/Et2Dialog'; import './Et2DropdownButton/Et2DropdownButton'; import './Expose/Et2ImageExpose'; import './Expose/Et2DescriptionExpose'; +import './Et2Favorites/Et2Favorites'; import './Et2Image/Et2Image'; import './Et2Link/Et2Link'; import './Et2Link/Et2LinkList';