mirror of
https://github.com/EGroupware/egroupware.git
synced 2024-12-22 23:00:56 +01:00
Et2Select: Implement allowFreeEntries & editModeEnabled properties
Also added Et2SelectEmail, which uses them
This commit is contained in:
parent
d98978ddd3
commit
531cc473e2
132
api/js/etemplate/Et2Select/Et2SelectEmail.ts
Normal file
132
api/js/etemplate/Et2Select/Et2SelectEmail.ts
Normal 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);
|
@ -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,8 +430,19 @@ export const Et2WithSearchMixin = dedupeMixin((superclass) =>
|
||||
if(event.key === "Enter")
|
||||
{
|
||||
event.preventDefault();
|
||||
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
|
||||
clearTimeout(this._searchTimeout);
|
||||
@ -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;
|
||||
}
|
84
api/js/etemplate/Et2Select/Tag/Et2Tag.ts
Normal file
84
api/js/etemplate/Et2Select/Tag/Et2Tag.ts
Normal 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);
|
@ -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';
|
||||
|
@ -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)) &&
|
||||
else
|
||||
{
|
||||
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)
|
||||
!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'])
|
||||
@ -204,3 +208,5 @@ class Taglist extends Etemplate\Widget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Etemplate\Widget::registerWidget(__NAMESPACE__ . '\\Taglist', array('taglist', 'et2-select-email'));
|
Loading…
Reference in New Issue
Block a user