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';