Et2Select: Implement allowFreeEntries & editModeEnabled properties

Also added Et2SelectEmail, which uses them
This commit is contained in:
nathan 2022-06-10 10:11:34 -06:00
parent d98978ddd3
commit 531cc473e2
5 changed files with 502 additions and 17 deletions

View File

@ -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`
<et2-tag
removable
@click=${this.handleTagInteraction}
@keydown=${this.handleTagInteraction}
@sl-remove=${(event) =>
{
event.stopPropagation();
if(!this.disabled)
{
item.checked = false;
this.syncValueFromItems();
}
}}
>
${image}
${this.getItemLabel(item)}
</et2-tag>
`;
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-email", Et2SelectEmail);

View File

@ -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<T = {}> = 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 = <T extends Constructor<LitElement>>(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 = <Et2Tag>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(<SelectOption>{
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`
<et2-tag
part="tag"
exportparts="
base:tag__base,
content:tag__content,
remove-button:tag__remove-button
"
variant="neutral"
size=${this.size}
?pill=${this.pill}
removable
@click=${this.handleTagInteraction}
@keydown=${this.handleTagInteraction}
@sl-remove=${(event) =>
{
event.stopPropagation();
if(!this.disabled)
{
item.checked = false;
this.syncValueFromItems();
}
}}
>
${this.getItemLabel(item)}
</et2-tag>
`;
}
protected _handleSearchAbort(e)
{
this._activeControls.classList.remove("active");
@ -481,5 +742,5 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
}
}
return Et2WidgetWithSearch as Constructor<SearchMixinInterface> & T & LitElement;
});
return Et2WidgetWithSearch as unknown as Constructor<SearchMixinInterface> & T;
}

View File

@ -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`
<span
part="base"
class=${classMap({
tag: true,
// Types
'tag--primary': this.variant === 'primary',
'tag--success': this.variant === 'success',
'tag--neutral': this.variant === 'neutral',
'tag--warning': this.variant === 'warning',
'tag--danger': this.variant === 'danger',
'tag--text': this.variant === 'text',
// Sizes
'tag--small': this.size === 'small',
'tag--medium': this.size === 'medium',
'tag--large': this.size === 'large',
// Modifiers
'tag--pill': this.pill,
'tag--removable': this.removable
})}
>
<span part="prefix" class="tag__prefix">
<slot name="prefix"></slot>
</span>
<span part="content" class="tag__content">
<slot></slot>
</span>
${this.removable
? html`
<sl-icon-button
part="remove-button"
exportparts="base:remove-button__base"
name="x"
library="system"
label=${this.egw().lang('remove')}
class="tag__remove"
@click=${this.handleRemoveClick}
></sl-icon-button>
`
: ''}
</span>
`;
}
}
customElements.define("et2-tag", Et2Tag);

View File

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

View File

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