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 {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 // Export the Interface for TypeScript
type Constructor<T = {}> = new (...args : any[]) => T; 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 * 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) class Et2WidgetWithSearch extends SlotMixin(superclass)
{ {
@ -79,9 +83,18 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
searchUrl: {type: String}, 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 // @ts-ignore
...(super.styles ? (Symbol.iterator in Object(super.styles) ? super.styles : [super.styles]) : []), ...(super.styles ? (Symbol.iterator in Object(super.styles) ? super.styles : [super.styles]) : []),
css` css`
/* Show / hide SlSelect icons - dropdown arrow, etc */
::slotted([slot="suffix"]) { ::slotted([slot="suffix"]) {
display: none; display: none;
} }
:host([search]) ::slotted([slot="suffix"]) { :host([search]) ::slotted([slot="suffix"]) {
display: initial; display: initial;
} }
:host([allowFreeEntries]) ::slotted([slot="suffix"]) {
display: none;
}
/* Make textbox take full width */
::slotted([name="search_input"]:focus ){ ::slotted([name="search_input"]:focus ){
width: 100%; width: 100%;
} }
@ -117,21 +136,35 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
flex: 2 1 auto; flex: 2 1 auto;
width: 100%; width: 100%;
} }
/* Search textbox general styling, starts hidden */
.select__prefix ::slotted(.search_input) { .select__prefix ::slotted(.search_input) {
display: none; display: none;
margin-left: 0px; margin-left: 0px;
width: 100%; width: 100%;
} }
/* Search UI active - show textbox & stuff */
::slotted(.search_input.active) { ::slotted(.search_input.active) {
display: flex; display: flex;
} }
/* Hide options that do not match current search text */
::slotted(.no-match) { ::slotted(.no-match) {
display: none; 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; private _searchTimeout : number;
protected static SEARCH_TIMEOUT = 500; protected static SEARCH_TIMEOUT = 500;
protected static MIN_CHARS = 2; protected static MIN_CHARS = 2;
@ -144,9 +177,25 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
this.searchUrl = ""; this.searchUrl = "";
this.searchOptions = {}; 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._handleSearchButtonClick = this._handleSearchButtonClick.bind(this);
this._handleSearchAbort = this._handleSearchAbort.bind(this); this._handleSearchAbort = this._handleSearchAbort.bind(this);
this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this); this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this);
this.handleTagInteraction = this.handleTagInteraction.bind(this);
} }
connectedCallback() connectedCallback()
@ -241,6 +290,35 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
return this.querySelectorAll("sl-menu-item.remote"); 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() getItems()
{ {
return [...this.querySelectorAll("sl-menu-item:not(.no-match)")]; 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 * Value was cleared
*/ */
@ -325,8 +430,19 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
if(event.key === "Enter") if(event.key === "Enter")
{ {
event.preventDefault(); event.preventDefault();
if(this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value))
{
this._searchInputNode.value = "";
if(!this.multiple)
{
this.dropdown.hide();
}
}
else
{
this.startSearch(); this.startSearch();
} }
}
// Start the search automatically if they have enough letters // Start the search automatically if they have enough letters
clearTimeout(this._searchTimeout); clearTimeout(this._searchTimeout);
@ -403,9 +519,19 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
return promise; 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) 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); this.processRemoteResults(result);
}); });
@ -460,11 +586,146 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
return item.value == search; 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) protected _handleSearchButtonClick(e)
{ {
this.handleMenuShow(); 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) protected _handleSearchAbort(e)
{ {
this._activeControls.classList.remove("active"); 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 './Et2Link/Et2LinkTo';
import './Et2Select/Et2Select'; import './Et2Select/Et2Select';
import './Et2Select/Et2SelectAccount'; import './Et2Select/Et2SelectAccount';
import './Et2Select/Et2SelectEmail';
import './Et2Select/Et2SelectReadonly'; import './Et2Select/Et2SelectReadonly';
import './Et2Select/Tag/Et2Tag';
import './Et2Textarea/Et2Textarea'; import './Et2Textarea/Et2Textarea';
import './Et2Textbox/Et2Textbox'; import './Et2Textbox/Et2Textbox';
import './Et2Textbox/Et2TextboxReadonly'; import './Et2Textbox/Et2TextboxReadonly';

View File

@ -111,8 +111,9 @@ class Taglist extends Etemplate\Widget
* *
* Uses the mail application if available, or addressbook * 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 no mail app access, use link system -> addressbook
if(!$GLOBALS['egw_info']['apps']['mail']) 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))),''); self::set_validation_error($form_name,lang("'%1' is NOT allowed ('%2')!",$val,implode("','",array_keys($allowed))),'');
unset($value[$key]); 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); $lists = $GLOBALS['egw']->contacts->get_lists(Api\Acl::READ);
if(!array_key_exists($val, $lists)) 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) && else
!($this->attrs['domainOptional'] && preg_match (Taglist::EMAIL_PREG_NO_DOMAIN, $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. // Allow merge placeholders. Might be a better way to do this though.
!preg_match('/{{.+}}|\$\$.+\$\$/',$val) !preg_match('/{{.+}}|\$\$.+\$\$/', $val)
) )
{ {
self::set_validation_error($form_name,lang("'%1' has an invalid format",$val),''); self::set_validation_error($form_name, lang("'%1' has an invalid format", $val), '');
}
} }
} }
if ($ok && $value === '' && $this->attrs['needed']) 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'));