From 531cc473e267d14aa613b4a19acb8d3d794ce170 Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 10 Jun 2022 10:11:34 -0600 Subject: [PATCH] Et2Select: Implement allowFreeEntries & editModeEnabled properties Also added Et2SelectEmail, which uses them --- api/js/etemplate/Et2Select/Et2SelectEmail.ts | 132 +++++++++ api/js/etemplate/Et2Select/SearchMixin.ts | 277 ++++++++++++++++++- api/js/etemplate/Et2Select/Tag/Et2Tag.ts | 84 ++++++ api/js/etemplate/etemplate2.ts | 2 + api/src/Etemplate/Widget/Taglist.php | 24 +- 5 files changed, 502 insertions(+), 17 deletions(-) create mode 100644 api/js/etemplate/Et2Select/Et2SelectEmail.ts create mode 100644 api/js/etemplate/Et2Select/Tag/Et2Tag.ts diff --git a/api/js/etemplate/Et2Select/Et2SelectEmail.ts b/api/js/etemplate/Et2Select/Et2SelectEmail.ts new file mode 100644 index 0000000000..9902cdf91b --- /dev/null +++ b/api/js/etemplate/Et2Select/Et2SelectEmail.ts @@ -0,0 +1,132 @@ +/** + * EGroupware eTemplate2 - Email-selection WebComponent + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ + +import {Et2Select} from "./Et2Select"; +import {css, html} from "@lion/core"; +import {IsEmail} from "../Validators/IsEmail"; + +export class Et2SelectEmail extends Et2Select +{ + static get styles() + { + return [ + ...super.styles, + css` + :host { + display: block; + flex: 1 1 auto; + min-width: 200px; + } + ::part(icon), .select__icon { + display: none; + } + ::slotted(sl-icon[slot="suffix"]) { + display: none; + } + ` + ]; + } + + constructor(...args : any[]) + { + super(...args); + this.search = true; + this.searchUrl = "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_email"; + this.allowFreeEntries = true; + this.editModeEnabled = true; + this.multiple = true; + this.defaultValidators.push(new IsEmail()); + } + + connectedCallback() + { + super.connectedCallback(); + } + + /** + * Actually query the server. + * + * Overridden to change request to match server + * + * @param {string} search + * @param {object} options + * @returns {any} + * @protected + */ + protected remoteQuery(search : string, options : object) + { + return this.egw().json(this.searchUrl, [search]).sendRequest().then((result) => + { + this.processRemoteResults(result); + }); + } + + /** + * Add in remote results + * + * Overridden to get results in a format parent expects. + * Current server-side gives { + * icon: "/egroupware/api/avatar.php?contact_id=5&etag=1" + * id: "ng@egroupware.org" + * label: "ng@egroupware.org" + * name: "" + * title: "ng@egroupware.org" + * } + * Parent expects value instead of id + * + * @param results + * @protected + */ + protected processRemoteResults(results) + { + results.forEach(r => r.value = r.id); + super.processRemoteResults(results); + } + + + /** + * Customise how tags are rendered. This overrides what SlSelect + * does in syncItemsFromValue(). + * This is a copy+paste from SlSelect.syncItemsFromValue(). + * + * @param item + * @protected + */ + protected _tagTemplate(item) + { + let image = item.querySelector("et2-image"); + if(image) + { + image = image.clone(); + image.slot = "prefix"; + } + return html` + + { + event.stopPropagation(); + if(!this.disabled) + { + item.checked = false; + this.syncValueFromItems(); + } + }} + > + ${image} + ${this.getItemLabel(item)} + + `; + } +} + +// @ts-ignore TypeScript is not recognizing that this widget is a LitElement +customElements.define("et2-select-email", Et2SelectEmail); \ No newline at end of file diff --git a/api/js/etemplate/Et2Select/SearchMixin.ts b/api/js/etemplate/Et2Select/SearchMixin.ts index 177bdef9ea..82f8be3f52 100644 --- a/api/js/etemplate/Et2Select/SearchMixin.ts +++ b/api/js/etemplate/Et2Select/SearchMixin.ts @@ -8,9 +8,13 @@ */ -import {css, dedupeMixin, html, LitElement, render, repeat, SlotMixin} from "@lion/core"; +import {css, html, LitElement, render, repeat, SlotMixin} from "@lion/core"; import {cleanSelectOptions, SelectOption} from "./FindSelectOptions"; +import {Validator} from "@lion/form-core"; +import {Et2Tag} from "./Tag/Et2Tag"; +// Otherwise import gets stripped +let keep_import : Et2Tag; // Export the Interface for TypeScript type Constructor = new (...args : any[]) => T; @@ -67,7 +71,7 @@ export declare class SearchMixinInterface * * Currently I assume we're extending an Et2Select, so changes may need to be made for better abstraction */ -export const Et2WithSearchMixin = dedupeMixin((superclass) => +export const Et2WithSearchMixin = >(superclass : T) => { class Et2WidgetWithSearch extends SlotMixin(superclass) { @@ -79,9 +83,18 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => searchUrl: {type: String}, - allowFreeEntries: {type: Boolean}, + /** + * Allow custom entries that are not in the options + */ + allowFreeEntries: {type: Boolean, reflect: true}, - searchOptions: {type: Object} + searchOptions: {type: Object}, + + /** + * Allow editing tags by clicking on them. + * allowFreeEntries must be true + */ + editModeEnabled: {type: Boolean} } } @@ -104,12 +117,18 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => // @ts-ignore ...(super.styles ? (Symbol.iterator in Object(super.styles) ? super.styles : [super.styles]) : []), css` + /* Show / hide SlSelect icons - dropdown arrow, etc */ ::slotted([slot="suffix"]) { display: none; } :host([search]) ::slotted([slot="suffix"]) { display: initial; } + :host([allowFreeEntries]) ::slotted([slot="suffix"]) { + display: none; + } + + /* Make textbox take full width */ ::slotted([name="search_input"]:focus ){ width: 100%; } @@ -117,21 +136,35 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => flex: 2 1 auto; width: 100%; } + + /* Search textbox general styling, starts hidden */ .select__prefix ::slotted(.search_input) { display: none; margin-left: 0px; width: 100%; } + /* Search UI active - show textbox & stuff */ ::slotted(.search_input.active) { display: flex; } + + /* Hide options that do not match current search text */ ::slotted(.no-match) { display: none; } + + /* Keep overflow tag right-aligned. It's the only sl-tag. */ + .select__tags sl-tag { + margin-left: auto; + } ` ] } + // Borrowed from Lion ValidatorMixin, but we don't want the whole thing + protected defaultValidators : Validator[]; + protected validators : Validator[]; + private _searchTimeout : number; protected static SEARCH_TIMEOUT = 500; protected static MIN_CHARS = 2; @@ -144,9 +177,25 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => this.searchUrl = ""; this.searchOptions = {}; + this.allowFreeEntries = false; + this.editModeEnabled = false; + + this.validators = []; + /** + * Used by Subclassers to add default Validators. + * A email input for instance, always needs the isEmail validator. + * @example + * ```js + * this.defaultValidators.push(new IsDate()); + * ``` + * @type {Validator[]} + */ + this.defaultValidators = []; + this._handleSearchButtonClick = this._handleSearchButtonClick.bind(this); this._handleSearchAbort = this._handleSearchAbort.bind(this); this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this); + this.handleTagInteraction = this.handleTagInteraction.bind(this); } connectedCallback() @@ -241,6 +290,35 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => return this.querySelectorAll("sl-menu-item.remote"); } + get value() + { + return super.value; + } + + set value(new_value : string | string[]) + { + super.value = new_value; + + // Overridden to add options if allowFreeEntries=true + if(this.allowFreeEntries) + { + if(typeof this.value == "string" && !this.select_options.find(o => o.value == value)) + { + this.createFreeEntry(value); + } + else + { + this.value.forEach((e) => + { + if(!this.select_options.find(o => o.value == e)) + { + this.createFreeEntry(e); + } + }); + } + } + } + getItems() { return [...this.querySelectorAll("sl-menu-item:not(.no-match)")]; @@ -295,6 +373,33 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => } } + handleTagInteraction(event : KeyboardEvent | MouseEvent) + { + let result = super.handleTagInteraction(event); + + // Check if remove button was clicked + const path = event.composedPath(); + const clearButton = path.find((el) => + { + if(el instanceof HTMLElement) + { + const element = el as HTMLElement; + return element.classList.contains('tag__remove'); + } + return false; + }); + + // No edit, or removed tag + if(!this.editModeEnabled || clearButton) + { + return; + } + + // Find the tag + const tag = path.find((el) => el instanceof Et2Tag); + this.startEdit(tag); + } + /** * Value was cleared */ @@ -325,7 +430,18 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => if(event.key === "Enter") { event.preventDefault(); - this.startSearch(); + if(this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value)) + { + this._searchInputNode.value = ""; + if(!this.multiple) + { + this.dropdown.hide(); + } + } + else + { + this.startSearch(); + } } // Start the search automatically if they have enough letters @@ -403,9 +519,19 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => return promise; } + /** + * Actually query the server. + * + * This can be overridden to change request parameters + * + * @param {string} search + * @param {object} options + * @returns {any} + * @protected + */ protected remoteQuery(search : string, options : object) { - return this.egw().request(this.searchUrl, [search]).sendRequest().then((result) => + return this.egw().request(this.searchUrl, [search]).then((result) => { this.processRemoteResults(result); }); @@ -460,11 +586,146 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => return item.value == search; } + /** + * Create an entry that is not in the options and add it to the value + * + * @param {string} text Used as both value and label + */ + public createFreeEntry(text : string) : boolean + { + if(!this.validateFreeEntry(text)) + { + return false; + } + // Make sure not to double-add + if(!this.select_options.find(o => o.value == text)) + { + this.select_options.push({ + value: text, + label: text + }); + } + // Make sure not to double-add + if(this.multiple && this.value.indexOf(text) == -1) + { + this.value.push(text); + } + else if(!this.multiple) + { + this.value = text; + } + this.requestUpdate('select_options'); + return true; + } + + /** + * Check if a free entry value is acceptable. + * We use validators directly using the proposed value + * + * @param text + * @returns {boolean} + */ + public validateFreeEntry(text) : boolean + { + let validators = [...this.validators, ...this.defaultValidators]; + let result = validators.filter(v => + v.execute(text, v.param, {node: this}), + ); + return result.length == 0; + } + + public startEdit(tag : Et2Tag) + { + // Turn on edit UI + this.handleMenuShow(); + + // but hide the menu + this.updateComplete.then(() => this.dropdown.hide()); + + // Pre-set value to tag value + this._searchInputNode.value = tag.textContent.trim(); + + // Remove from value & DOM. If they finish the edit, the new one will be added. + this.value = this.value.filter(v => v !== this._searchInputNode.value); + tag.remove(); + } + protected _handleSearchButtonClick(e) { this.handleMenuShow(); } + /** + * Override this method from SlSelect to stick our own tags in there + */ + syncItemsFromValue() + { + if(typeof super.syncItemsFromValue === "function") + { + super.syncItemsFromValue(); + } + + // Only applies to multiple + if(typeof this.displayTags !== "object" || !this.multiple) + { + return; + } + + let overflow = null; + if(this.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible) + { + overflow = this.displayTags.pop(); + } + const checkedItems = Object.values(this.menuItems).filter(item => this.value.includes(item.value)); + this.displayTags = checkedItems.map(item => this._tagTemplate(item)); + + // Re-slice & add overflow tag + if(overflow) + { + this.displayTags = this.displayTags.slice(0, this.maxTagsVisible); + this.displayTags.push(overflow); + } + } + + /** + * Customise how tags are rendered. This overrides what SlSelect + * does in syncItemsFromValue(). + * This is a copy+paste from SlSelect.syncItemsFromValue(). + * + * @param item + * @protected + */ + protected _tagTemplate(item) + { + return html` + + { + event.stopPropagation(); + if(!this.disabled) + { + item.checked = false; + this.syncValueFromItems(); + } + }} + > + ${this.getItemLabel(item)} + + `; + } + protected _handleSearchAbort(e) { this._activeControls.classList.remove("active"); @@ -481,5 +742,5 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) => } } - return Et2WidgetWithSearch as Constructor & T & LitElement; -}); \ No newline at end of file + return Et2WidgetWithSearch as unknown as Constructor & T; +} \ No newline at end of file diff --git a/api/js/etemplate/Et2Select/Tag/Et2Tag.ts b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts new file mode 100644 index 0000000000..e71e241a04 --- /dev/null +++ b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts @@ -0,0 +1,84 @@ +/** + * EGroupware eTemplate2 - Tag WebComponent + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Nathan Gray + */ +import {Et2Widget} from "../../Et2Widget/Et2Widget"; +import {SlTag} from "@shoelace-style/shoelace"; +import {classMap, css, html} from "@lion/core"; +import shoelace from "../../Styles/shoelace"; + +/** + * Tag is usually used in a Select with multiple=true, but there's no reason it can't go anywhere + */ +export class Et2Tag extends Et2Widget(SlTag) +{ + static get styles() + { + return [ + super.styles, + shoelace, css` + ::slotted(et2-image) + { + height: 20px; + width: 20px; + } + `]; + } + + constructor(...args : []) + { + super(...args); + } + + render() + { + return html` + + + + + + + + ${this.removable + ? html` + + ` + : ''} + + `; + } +} + +customElements.define("et2-tag", Et2Tag); \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 0dd8f2f5e4..3084c05fce 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -51,7 +51,9 @@ import './Et2Link/Et2LinkString'; import './Et2Link/Et2LinkTo'; import './Et2Select/Et2Select'; import './Et2Select/Et2SelectAccount'; +import './Et2Select/Et2SelectEmail'; import './Et2Select/Et2SelectReadonly'; +import './Et2Select/Tag/Et2Tag'; import './Et2Textarea/Et2Textarea'; import './Et2Textbox/Et2Textbox'; import './Et2Textbox/Et2TextboxReadonly'; diff --git a/api/src/Etemplate/Widget/Taglist.php b/api/src/Etemplate/Widget/Taglist.php index 43a83210c2..3eefb99565 100644 --- a/api/src/Etemplate/Widget/Taglist.php +++ b/api/src/Etemplate/Widget/Taglist.php @@ -111,8 +111,9 @@ class Taglist extends Etemplate\Widget * * Uses the mail application if available, or addressbook */ - public static function ajax_email() + public static function ajax_email($search) { + $_REQUEST['query'] = $_REQUEST['query'] ?: $search; // If no mail app access, use link system -> addressbook if(!$GLOBALS['egw_info']['apps']['mail']) { @@ -171,21 +172,24 @@ class Taglist extends Etemplate\Widget self::set_validation_error($form_name,lang("'%1' is NOT allowed ('%2')!",$val,implode("','",array_keys($allowed))),''); unset($value[$key]); } - if($this->type == 'taglist-email' && $this->attrs['include_lists'] && is_numeric($val)) + if(str_contains($this->type, 'email') && $this->attrs['include_lists'] && is_numeric($val)) { $lists = $GLOBALS['egw']->contacts->get_lists(Api\Acl::READ); if(!array_key_exists($val, $lists)) { - self::set_validation_error($form_name,lang("'%1' is NOT allowed ('%2')!",$val,implode("','",array_keys($lists))),''); + self::set_validation_error($form_name, lang("'%1' is NOT allowed ('%2')!", $val, implode("','", array_keys($lists))), ''); } } - else if($this->type == 'taglist-email' && !preg_match(Url::EMAIL_PREG, $val) && - !($this->attrs['domainOptional'] && preg_match (Taglist::EMAIL_PREG_NO_DOMAIN, $val)) && - // Allow merge placeholders. Might be a better way to do this though. - !preg_match('/{{.+}}|\$\$.+\$\$/',$val) - ) + else { - self::set_validation_error($form_name,lang("'%1' has an invalid format",$val),''); + if(str_contains($this->type, 'email') && !preg_match(Url::EMAIL_PREG, $val) && + !($this->attrs['domainOptional'] && preg_match(Taglist::EMAIL_PREG_NO_DOMAIN, $val)) && + // Allow merge placeholders. Might be a better way to do this though. + !preg_match('/{{.+}}|\$\$.+\$\$/', $val) + ) + { + self::set_validation_error($form_name, lang("'%1' has an invalid format", $val), ''); + } } } if ($ok && $value === '' && $this->attrs['needed']) @@ -204,3 +208,5 @@ class Taglist extends Etemplate\Widget } } } + +Etemplate\Widget::registerWidget(__NAMESPACE__ . '\\Taglist', array('taglist', 'et2-select-email')); \ No newline at end of file