1/**
2 * EGroupware eTemplate2 - JS Tag list object
3 *
4 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
5 * @package etemplate
6 * @subpackage api
7 * @link: https://www.egroupware.org
8 * @author Nathan Gray
9 * @copyright Nathan Gray 2013
10 */
11
12/*egw:uses
13	et2_core_inputWidget;
14	/vendor/egroupware/magicsuggest/magicsuggest.js;
15*/
16
17import {et2_selectbox} from "./et2_widget_selectbox";
18import {et2_register_widget, WidgetConfig} from "./et2_core_widget";
19import {ClassWithAttributes} from "./et2_core_inheritance";
20
21/**
22 * Tag list widget
23 *
24 * A cross between auto complete, selectbox and chosen multiselect
25 *
26 * Uses MagicSuggest library
27 * @see http://nicolasbize.github.io/magicsuggest/
28 * @augments et2_selectbox
29 */
30export class et2_taglist extends et2_selectbox implements et2_IResizeable
31{
32	static readonly _attributes : any = {
33		"empty_label": {
34			"name": "Empty label",
35			"type": "string",
36			"default": "",
37			"description": "Textual label for when nothing is selected",
38			translate: true
39		},
40		"select_options": {
41			"type": "any",
42			"name": "Select options",
43			"default": {}, //[{id: "a", label: "Alpha"},{id:"b", label: "Beta"}],
44			"description": "Internaly used to hold the select options."
45		},
46
47		// Value can be CSV String or Array
48		"value": {
49			"type": "any"
50		},
51
52		// These default parameters set it to read the addressbook via the link system
53		"autocomplete_url": {
54			"name": "Autocomplete source",
55			"type": "string",
56			"default": "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_search",
57			"description": "Menuaction (app.class.function) for autocomplete data source.  Must return actual JSON, and nothing more."
58		},
59		"autocomplete_params": {
60			"name": "Autocomplete parameters",
61			"type": "any",
62			"default": {app:"addressbook"},
63			"description": "Extra parameters passed to autocomplete URL.  It should be a stringified JSON object."
64		},
65
66		allowFreeEntries: {
67			"name": "Allow free entries",
68			"type": "boolean",
69			"default": true,
70			"description": "Restricts or allows the user to type in arbitrary entries"
71		},
72
73		"onchange": {
74			"description": "Callback when tags are changed.  Argument is the new selection.",
75			"type":"js"
76		},
77		"onclick": {
78			"description": "Callback when a tag is clicked.  The clicked tag is passed.",
79			"type":"js"
80		},
81		"tagRenderer": {
82			"name": "Tag renderer",
83			"type": "js",
84			"default": et2_no_init,
85			"description": "Callback to provide a custom renderer for what's _inside_ each tag.  Function parameter is the select_option data for that ID."
86		},
87		"listRenderer": {
88			"name": "List renderer",
89			"type": "js",
90			"default": et2_no_init,
91			"description": "Callback to provide a custom renderer for each suggested item.  Function parameter is the select_option data for that ID."
92		},
93		"width": {
94			"default": "100%"
95		},
96		"height": {
97			"description": "Maximum allowed height of the result list in pixels"
98		},
99		"maxSelection": {
100			"name": "max Selection",
101			"type": "integer",
102			"default": null,
103			"description": "The maximum number of items the user can select if multiple selection is allowed."
104		},
105		// Selectbox attributes that are not applicable
106		"multiple": {
107			type: 'any', // boolean or 'toggle'
108			default: true,
109			description: "True, false or 'toggle'"
110		},
111		"rows": {
112			"name": "Rows",
113			"type": "integer",
114			"default": et2_no_init,
115			"description": "Number of rows to display"
116		},
117		"tags": { ignore: true},
118		useCommaKey: {
119			name: "comma will start validation",
120			type: "boolean",
121			"default": true,
122			description: "Set to false to allow comma in entered content"
123		},
124		groupBy: {
125			name: "group results",
126			type: "string",
127			default: null,
128			description: "group results by given JSON attribute"
129		},
130		minChars: {
131			name: "chars to start query",
132			type: "integer",
133			default: 3,
134			description: "minimum number of characters before expanding the combo"
135		},
136		editModeEnabled: {
137			name: "Enable edit mode for tags",
138			type: "boolean",
139			"default": true,
140			description: "Allow to modify a tag by clicking on edit icon. It only can be enabled if only allowFreeEntries is true."
141		}
142	};
143
144	// Allows sub-widgets to override options to the library
145	lib_options : any = {};
146	protected div : JQuery = null;
147	private multiple_toggle : JQuery = null;
148	private _multiple : boolean = false;
149	protected taglist : any = null;
150	private _hide_timeout : number;
151	private $taglist : JQuery = null;
152	private taglist_options : any;
153	private _query_server : any;
154
155	/**
156	 * Construtor
157	 */
158	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
159	{
160		// Call the inherited constructor
161		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist._attributes, _child || {}));
162
163		// Parent sets multiple based on rows, we just re-set it
164		this.options.multiple = _attrs.multiple;
165
166		// jQuery wrapped DOM node
167		this.div = jQuery("<div></div>").addClass('et2_taglist');
168
169		this.multiple_toggle = jQuery("<div></div>")
170			.addClass('toggle')
171			.on('click', jQuery.proxy(function() {
172				this._set_multiple(!this._multiple);
173			},this))
174			.appendTo(this.div);
175		this._multiple = false;
176
177		// magicSuggest object
178		this.taglist = null;
179
180		this.setDOMNode(this.div[0]);
181	}
182
183	destroy() {
184		if(this.div != null)
185		{
186			// Undo the plugin
187		}
188		if(this._hide_timeout)
189		{
190			window.clearTimeout(this._hide_timeout);
191		}
192		super.destroy.apply(this, arguments);
193
194	}
195
196	transformAttributes(_attrs) {
197		super.transformAttributes.apply(this, arguments);
198
199		// Handle url parameters - they should be an object
200		if(typeof _attrs.autocomplete_params == 'string')
201		{
202			try
203			{
204				_attrs.autocomplete_params = JSON.parse(_attrs.autocomplete_params);
205			}
206			catch (e)
207			{
208				this.egw().debug('warn', 'Invalid autocomplete_params: '+_attrs.autocomplete_params );
209			}
210		}
211
212		if(_attrs.multiple !== 'toggle')
213		{
214			_attrs.multiple = et2_evalBool(_attrs.multiple);
215		}
216	}
217
218	doLoadingFinished() {
219		super.doLoadingFinished();
220		let widget = this;
221		// Initialize magicSuggest here
222		if(this.taglist != null) return;
223
224		// If no options or ajax url, try the array mgr
225		if(this.options.select_options === null && !this.options.autocomplete_url)
226		{
227			this.set_select_options(this.getArrayMgr("sel_options").getEntry(this.id));
228		}
229
230		// MagicSuggest would replaces our div, so add a wrapper instead
231		this.taglist = jQuery('<div/>').appendTo(this.div);
232
233		this.taglist_options = jQuery.extend( {
234			// magisuggest can NOT work setting an empty autocomplete url, it will then call page url!
235			// --> setting an empty options list instead
236			data: this.options.autocomplete_url ? this.options.autocomplete_url :
237				this.options.select_options || {},
238			dataUrlParams: this.options.autocomplete_params,
239			method: 'GET',
240			displayField: "label",
241			invalidCls: 'invalid ui-state-error',
242			placeholder: this.options.empty_label,
243			hideTrigger: this.options.multiple === true,
244			noSuggestionText: this.egw().lang("No suggestions"),
245			required: this.options.required,
246			allowFreeEntries: this.options.allowFreeEntries,
247			useTabKey: true,
248			useCommaKey: this.options.useCommaKey,
249			disabled: this.options.disabled || this.options.readonly,
250			editable: !(this.options.disabled || this.options.readonly),
251			// If there are select options, enable toggle on click so user can see them
252			toggleOnClick: !jQuery.isEmptyObject(this.options.select_options),
253			selectionRenderer: jQuery.proxy(this.options.tagRenderer || this.selectionRenderer,this),
254			renderer: jQuery.proxy(this.options.listRenderer || this.selectionRenderer,this),
255			maxSelection: this.options.multiple ? this.options.maxSelection : 1,
256			maxSelectionRenderer: jQuery.proxy(function(v) { this.egw().lang('You can not choose more then %1 item(s)!', v); }, this),
257			minCharsRenderer: jQuery.proxy(function(v){
258				this.egw().lang(v == 1 ? 'Please type 1 more character' : 'Please type %1 more characters',v);
259			}, this),
260			width: this.options.width,	// propagate width
261			highlight: false,	// otherwise renderer have to return strings
262			selectFirst: true,
263			groupBy: this.options.groupBy && typeof this.options.groupBy == 'string' ? this.options.groupBy : null,
264			minChars: parseInt(this.options.minChars) ? parseInt(this.options.minChars) : 0,
265			editModeEnabled: this.options.editModeEnabled
266		}, this.lib_options);
267
268		if(this.options.height) {
269			this.div.css('height','');
270			this.taglist_options.maxDropHeight = parseInt(this.options.height);
271		}
272
273		// If only one, do not require minimum chars or the box won't drop down
274		if(this.options.multiple !== true)
275		{
276			this.taglist_options.minChars = 0;
277		}
278
279		this.taglist = this.taglist.magicSuggest(this.taglist_options);
280		this.$taglist = jQuery(this.taglist);
281		if(this.options.value)
282		{
283			this.taglist.addToSelection(this.options.value,true);
284		}
285
286		this._set_multiple(this.options.multiple);
287
288		// AJAX _and_ select options - use custom function
289		if(this.options.autocomplete_url && !jQuery.isEmptyObject(this.options.select_options))
290		{
291			this.taglist.setData(function(query, cfg) {
292				return widget._data.call(widget,query, cfg);
293			});
294		}
295
296		this.$taglist
297		// Display / hide a loading icon while fetching
298			.on("beforeload", function() {this.container.prepend('<div class="ui-icon loading"/>');})
299			.on("load", function() {jQuery('.loading',this.container).remove();})
300		// Keep focus when selecting from the list
301			.on("selectionchange", function() {
302				if(document.activeElement === document.body ||
303					widget.div.has(document.activeElement).length > 0)
304				{
305					jQuery('input',this.container).focus();
306				}
307				widget.resize();
308			})
309		// Bind keyup so we can start ajax search when we like
310			.on('keyup.start_search', jQuery.proxy(this._keyup, this))
311			.on('blur', jQuery.proxy(function() {
312				if (this.div) {
313					this.div.removeClass('ui-state-active');
314					this.resize();
315				}
316			}, this))
317		// Hide tooltip when you're editing, it can get annoying otherwise
318			.on('focus', function() {
319				jQuery('.egw_tooltip').hide();
320				widget.div.addClass('ui-state-active');
321			})
322			// If not using autoSelect, avoid some errors with selection starting
323			// with the group
324			.on('load expand', function() {
325				if(widget.taglist && widget.taglist_options.groupBy)
326				{
327					window.setTimeout(function() {
328						if(widget && widget.taglist.combobox)
329						{
330							widget.taglist.combobox.find('.ms-res-group.ms-res-item-active')
331								.removeClass('ms-res-item-active');
332						}
333					},1);
334				}
335			})
336		// Position absolute to break out of containers
337			.on('expand', jQuery.proxy(function() {
338				let taglist = this.taglist;
339				this.div.addClass('expanded');
340				let background = this.taglist.combobox.css('background');
341				let wrapper = jQuery(document.createElement('div'))
342					// Keep any additional classes
343					.addClass(this.div.attr('class'))
344
345					.css({
346						'position': 'absolute',
347						'width': 'auto'
348					})
349					.appendTo('body')
350					.position({my: 'left top', at: 'left bottom', of: this.taglist.container});
351
352				this.taglist.combobox
353					.width(this.taglist.container.innerWidth())
354					.appendTo(wrapper)
355					.css('background', background);
356
357				// Make sure it doesn't go out of the window
358				let bottom = (wrapper.offset().top + this.taglist.combobox.outerHeight(true));
359				if(bottom > jQuery(window).height())
360				{
361					this.taglist.combobox.height(this.taglist.combobox.height() - (bottom - jQuery(window).height()) - 5);
362				}
363
364				// Close dropdown if click elsewhere or scroll the sidebox,
365				// but wait until after or it will close immediately
366				window.setTimeout(function() {
367					jQuery('.egw_fw_ui_scrollarea').one('scroll mousewheel', function() {
368						taglist.collapse();
369					});
370					jQuery('body').one('click',function() {
371						taglist.collapse();
372
373						if(document.activeElement === document.body ||
374							widget.div.has(document.activeElement).length > 0)
375						{
376							if (widget.options.allowFreeEntries) taglist.container.blur();
377							taglist.input.focus();
378						}
379						else if (widget.options.allowFreeEntries)
380						{
381							taglist.container.blur();
382						}
383					});},100
384				);
385				this.$taglist.one('collapse', function() {
386					wrapper.remove();
387				});
388			},this))
389			.on('collapse', function() {
390				widget.div.removeClass('expanded');
391			});
392
393		jQuery('.ms-trigger',this.div).on('click', function(e) {
394			e.stopPropagation();
395		});
396		// Unbind change handler of widget's ancestor to stop it from bubbling
397		// taglist has its own onchange
398		jQuery(this.getDOMNode()).unbind('change.et2_inputWidget');
399
400		// This handles clicking on a suggestion from the list in an et2 friendly way
401		// onChange
402		if(this.options.onchange && typeof this.onchange === 'function')
403		{
404			this.$taglist.on("selectionchange", jQuery.proxy(this.change,this));
405		}
406
407		// onClick - pass more than baseWidget, so unbind it to avoid double callback
408		if(typeof this.onclick == 'function')
409		{
410			this.div.unbind("click.et2_baseWidget")
411				.on("click.et2_baseWidget", '.ms-sel-item', jQuery.proxy(function(event) {
412				// Pass the target as expected, but also the data for that tag
413				this.click(/*event.target,*/ jQuery(event.target).parent().data("json"));
414			},this));
415		}
416
417		// onFocus
418		if (typeof this.onfocus == 'function')
419		{
420			this.$taglist.focus(function(e) {
421				widget.onfocus.call(widget.taglist, e, widget);
422			});
423		}
424
425		// Do size limit checks
426		this.resize();
427
428		// Make sure magicsuggest loses focus class to prevent issues with
429		// multiple on the page
430		this.div.on('blur', 'input', function() {
431			jQuery('.ms-ctn-focus', widget.div).removeClass('ms-ctn-focus');
432		});
433
434		this.resetDirty();
435		return true;
436	}
437
438	/**
439	 * convert _options to taglist data [{id:...,label:...},...] format
440	 *
441	 * @param {(object|array)} _options id: label or id: {label: ..., title: ...} pairs, or array if id's are 0, 1, ...
442	 */
443	protected _options2data(_options)
444	{
445		let options = jQuery.isArray(_options) ? jQuery.extend({}, _options) : _options;
446		let data = [];
447		for(let id in options)
448		{
449			let option = {id: id};
450			if (typeof options[id] == 'object')
451			{
452				jQuery.extend(option, options[id]);
453				if(option["value"]) option["id"] = option["value"];
454				option["label"] = this.options.no_lang ? option["label"] : this.egw().lang(option["label"]);
455			}
456			else
457			{
458				option["label"] = this.options.no_lang ? options[id] : this.egw().lang(options[id]);
459			}
460			data.push(option);
461		}
462		return data;
463	}
464
465	/**
466	 * Custom data function to return local options if there is nothing
467	 * typed, or query via AJAX if user typed something
468	 *
469	 * @param {string} query
470	 * @param {Object} cfg Magicsuggest's internal configuration
471	 * @returns {Array}
472	 */
473	private _data(query, cfg) {
474
475		let return_value = this.options.select_options || {};
476
477		if (!jQuery.isEmptyObject(this.options.select_options) && !this._query_server
478			|| query.trim().length < this.taglist_options.minChars
479			|| !this.options.autocomplete_url)
480		{
481			// Check options, if there's a match there (that is not already
482			// selected), do not ask server
483			let filtered = [];
484			let selected = this.taglist.getSelection();
485			jQuery.each(this.options.select_options, function(index, obj) {
486				var name = obj.label;
487				if(selected.indexOf(obj) < 0 && name.toLowerCase().indexOf(query.toLowerCase()) > -1)
488				{
489					filtered.push(obj);
490				}
491			});
492			return_value = filtered.length > 0 ? filtered : this.options.autocomplete_url;
493		}
494		else if (query.trim().length >= this.taglist_options.minChars || this._query_server)
495		{
496			// No options - ask server
497			return_value = this.options.autocomplete_url;
498		}
499		this._query_server = false;
500
501		// Make sure we don't give an empty string, that fails when we try to query
502		if(return_value == this.options.autocomplete_url && !return_value)
503		{
504			return_value = [];
505		}
506
507		// Turn on local filtering, or trust server to do it
508		cfg.mode = typeof return_value === 'string' ? 'remote' : 'local';
509
510		return return_value;
511	}
512
513	/**
514	* Handler for keyup, used to start ajax search when we like
515	*
516	* @param {DOMEvent} e
517	* @param {Object} taglist
518	* @param {jQueryEvent} event
519	* @returns {Boolean}
520	*/
521	private _keyup(e, taglist, event)
522	{
523		if(event.which === jQuery.ui.keyCode.ENTER
524				&& taglist.combobox.find('.ms-res-item.ms-res-item-active').length==0
525				&& this.getType() !== 'taglist-email')
526		{
527			// Change keycode to abort the validation process
528			// This means enter does not add a tag
529			event.keyCode = jQuery.ui.keyCode.DOWN;
530
531			this._query_server = true;
532			this.taglist.collapse();
533			this.taglist.expand();
534			this._query_server = false;
535
536			this.div.find('.ms-res-item-active')
537				.removeClass('ms-res-item-active');
538
539			event.preventDefault();
540			return false;
541		}
542	}
543
544	/**
545	 * Set all options from current static select_options list
546	 */
547	select_all()
548	{
549		let all = [];
550		for(let id in this.options.select_options) all.push(id);
551		this.set_value(all);
552	}
553
554	/**
555	 * Render a single item, taking care of correctly escaping html special chars
556	 *
557	 * @param item
558	 * @returns {String}
559	 */
560	selectionRenderer(item)
561	{
562		let label = jQuery('<span>').text(item.label);
563		if (typeof item.title != 'undefined') label.attr('title', item.title);
564		if (typeof item.icon != 'undefined')
565		{
566			let wrapper = jQuery('<div>').addClass('et2_taglist_tags_icon_wrapper');
567			jQuery('<span/>')
568					.addClass('et2_taglist_tags_icon')
569					.css({"background-image": "url("+(item.icon && item.icon.match(/^(http|https|\/)/) ? item.icon : egw.image(item.icon?item.icon:'no-image-shown', item.app))+")"})
570					.appendTo(wrapper);
571			label.appendTo(wrapper);
572			return wrapper;
573		}
574		return label;
575	}
576
577	set_autocomplete_url(source)
578	{
579		if(source && source[0] != '/' && source.indexOf('http') != 0)
580		{
581			source = this.egw().ajaxUrl(source);
582		}
583		if(this.options.autocomplete_url != source)
584		{
585			this.options.autocomplete_url = source;
586
587			// do NOT set an empty autocomplete_url, magicsuggest would use page url instead!
588			if(this.taglist == null || !source) return;
589
590			let widget = this;
591			this.taglist.setData(function(query, cfg) {
592				return widget._data.call(widget,query, cfg);
593			});
594		}
595	}
596
597	/**
598	 * Set autocomplete parameters
599	 *
600	 * @param {object} _params
601	 */
602	set_autocomplete_params(_params)
603	{
604		if (this.options.autocomplete_params != _params)
605		{
606			this.options.autocomplete_params = _params;
607
608			if (this.taglist) this.taglist.setDataUrlParams(_params);
609		}
610	}
611
612	/**
613	 * Set the list of suggested options to a static list.
614	 *
615	 * @param {array} _options usual format see _options2data
616	 */
617	set_select_options(_options)
618	{
619		this.options.select_options = this._options2data(_options);
620
621		if(this.taglist == null) return;
622		let widget = this;
623		this.taglist.setData(function(query, cfg) {
624			return widget._data.call(widget,query, cfg);
625		});
626	}
627
628	set_disabled(disabled)
629	{
630		this.options.disabled = disabled;
631
632		if(this.taglist == null) return;
633		disabled ? this.taglist.disable() : this.taglist.enable();
634	}
635
636	set_width(_width)
637	{
638		this.div.width(_width);
639		this.options.width = _width;
640	}
641
642	/**
643	 * Normally the widget will display 1 row (multiple off) or expand as needed
644	 * based on selected entries.  Setting row will limit the max height.
645	 * @param {number} _rows
646	 */
647	set_rows(_rows)
648	{
649		_rows = parseInt(_rows);
650		let css = {
651			'max-height': '',
652			'height': 'auto'
653		};
654		if(_rows)
655		{
656			let border = this.taglist !== null ?
657				this.div.outerHeight(true) - this.taglist.container.innerHeight() :
658				0;
659
660			let max = (25 * _rows) + _rows + border;
661			css['max-height'] = max+'px';
662			if(this._multiple)
663			{
664				css.height = max+'px';
665			}
666			this.div.addClass('et2_taglist_small');
667		}
668		this.div.css(css);
669		if(this.options.rows != _rows)
670		{
671			if(this.taglist !== null)
672			{
673				this.resize();
674			}
675			this.options.rows = _rows;
676		}
677	}
678
679	/**
680	 * Set whether the widget accepts only 1 value, many, or allows the user
681	 * to toggle between the two.
682	 *
683	 * @param {boolean|string} multiple true, false, or 'toggle'
684	 */
685	set_multiple(multiple)
686	{
687		if(multiple != this.options.multiple)
688		{
689			this.options.multiple = multiple;
690			this._multiple = multiple === true;
691			if(this.taglist !== null)
692			{
693				this._set_multiple(multiple);
694			}
695		}
696	}
697
698	private _set_multiple(multiple) {
699		this._multiple = multiple === true;
700		this.div.toggleClass('et2_taglist_single', !this._multiple)
701			.toggleClass('et2_taglist_toggle', this.options.multiple === 'toggle')
702			.removeClass('ui-state-hover')
703			.off('click.single');
704		if(this._multiple == false)
705		{
706			this.div.on('click.single', jQuery.proxy(function() {
707				this.taglist.expand();
708			},this));
709		}
710		this.taglist.setMaxSelection(this._multiple ? this.options.maxSelection : 1);
711		if(!this._multiple && this.taglist.getValue().length > 1)
712		{
713			this.set_value(multiple?this.taglist.getValue():this.taglist.getValue()[0]);
714		}
715
716		// This changes sizes, so
717		this.set_rows(this.options.rows);
718		this.resize();
719	}
720
721	/**
722	 * Set up this widget as size-restricted, so it cannot be as large as needed.
723	 * Therefore, we hide some things if the user is not interacting.
724	 */
725	_setup_small() {
726		this.div.addClass('et2_taglist_small');
727		let value_count = this.taglist.getValue().length;
728		if(value_count)
729		{
730			this.div.attr('data-content', value_count > 1 ? egw.lang('%1 selected',value_count) : '...');
731		}
732		else
733		{
734			this.div.attr('data-content','');
735		}
736
737		this.div
738		// Listen to hover on size restricted taglists
739			.on('mouseenter.small_size', jQuery.proxy(function() {
740				this.div.addClass('ui-state-hover');
741
742				if(this._hide_timeout)
743				{
744					window.clearTimeout(this._hide_timeout);
745				}
746				jQuery('.egw_tooltip').hide();
747			},this))
748			.on('mouseleave.small_size', jQuery.proxy(function(event) {
749					// Ignore tooltip
750					if(event.toElement && jQuery(event.toElement).hasClass('egw_tooltip')) return;
751
752					if(this._hide_timeout)
753					{
754						window.clearTimeout(this._hide_timeout);
755					}
756					this._hide_timeout = window.setTimeout(
757					jQuery.proxy(function() {
758						this.div.removeClass('ui-state-hover');
759						this.taglist.container[0].scrollIntoView();
760						// Re-enable tooltip
761						this.set_statustext(this.options.statustext);
762						this._hide_timeout = null;
763					},this), 500);
764				},this)
765			);
766		this.$taglist
767			.on('blur.small_size', jQuery.proxy(function() {
768				this.div.trigger('mouseleave');
769				this.taglist.container[0].scrollIntoView();
770			},this))
771			.on('focus.small_size', jQuery.proxy(function() {
772				this.div.addClass('ui-state-active');
773
774				if(this._hide_timeout)
775				{
776					window.clearTimeout(this._hide_timeout);
777				}
778			},this));
779		this.taglist.container[0].scrollIntoView();
780	}
781
782	/**
783	 * Set value(s) of taglist, add them automatic to select-options, if allowFreeEntries
784	 *
785	 * @param value (array of) ids
786	 */
787	set_value(value)
788	{
789
790		if (value === '' || value === null)
791		{
792			value = [];
793		}
794		else if (typeof value === 'string' && this.options.multiple)
795		{
796			value = value.split(',');
797		}
798
799		let values = jQuery.isArray(value) ? jQuery.extend([],value) : [value];
800
801		if(!value && this.taglist)
802		{
803			this.taglist.clear(true);
804			return;
805		}
806
807		let result = [];
808		for(let i=0; i < values.length; ++i)
809		{
810			let v = values[i];
811			if (v && typeof v == 'object' && typeof v.id != 'undefined' && typeof v.label != 'undefined')
812			{
813				// already in correct format
814			}
815			else if (this.options.select_options &&
816				// Check options
817				(result = jQuery.grep(this.options.select_options, function(e) {
818					return e.id == v;
819				})) && result.length
820			)
821			{
822				// Options should have been provided, but they weren't
823				// This can happen for ajax source with an existing value
824				if(this.options.select_options == null)
825				{
826					this.options.select_options = [];
827				}
828				values[i] = result[0] ? result[0] : {
829					id: v,
830					label: v
831				};
832			}
833			else if (this.taglist &&
834				// Check current selection to avoid going back to server
835				(result = jQuery.grep(this.taglist.getSelection(), function(e) {
836					return e.id == v;
837				})) && result.length)
838			{
839				values[i] = result[0] ? result[0] : {
840					id: v,
841					label: v
842				};
843			}
844			else
845			{
846				if (typeof values[i].id == 'undefined')
847				{
848					values[i] = {
849						id: v,
850						label: v
851					};
852				}
853			}
854		}
855
856		this.options.value = values;
857
858		if(this.taglist == null) return;
859
860		// Switch multiple according to attribute and more than 1 value
861		if(this.options.multiple !== true)
862		{
863			let multiple = this.options.multiple ? values.length > 1 : false;
864			if(multiple !== this._multiple)
865			{
866				this._set_multiple(multiple);
867			}
868		}
869
870		this.taglist.clear(true);
871		this.taglist.addToSelection(values,true);
872	}
873
874	getValue()
875	{
876		if(this.taglist == null) return null;
877		// trigger blur on taglist to not loose just typed value
878		jQuery(this.taglist.container).trigger('blur');
879		return this.taglist.getValue();
880	}
881
882	/**
883	 * Resize lets us toggle the 'small' handling
884	 */
885	resize() {
886
887		this.div.off('.small_size');
888
889		this.div.removeClass('et2_taglist_small');
890
891		// How much space is needed for first one?
892		let min_width = jQuery('.ms-sel-item',this.div ).first().outerWidth() || this.div.children().first().width();
893
894		// Not ready yet
895		if(min_width === null || !this.taglist) return;
896
897		min_width += (this.options.multiple === 'toggle' ? jQuery('.toggle',this.div).outerWidth() : 0);
898		min_width += this.taglist.trigger ? this.taglist.trigger.outerWidth(true) : 0;
899
900		// Not enough for one
901		if(this.options.multiple && (min_width > this.div.width() ||
902			this.taglist.container.width() > this.div.width() || this.taglist.container.height() > this.div.height()
903		))
904		{
905			this._setup_small();
906		}
907	}
908}
909et2_register_widget(et2_taglist, ["taglist"]);
910
911/**
912 * Taglist customized specificlly for egw acccounts, fetches accounts and groups list,
913 * free entries are allowed
914 *
915 */
916class et2_taglist_account extends et2_taglist
917{
918	static readonly _attributes : any = {
919		"autocomplete_url": {
920			"default": "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_search"
921		},
922		allowFreeEntries: {
923			"default": false,
924			ignore: true
925		},
926		account_type: {
927			name: 'Account type',
928			'default': 'accounts',
929			type: 'string',
930			description: 'Limit type of accounts.  One of {accounts,groups,both,owngroups}.'
931		}
932	};
933
934	lib_options : any = {
935		minChars: 2
936	};
937	int_reg_exp : RegExp = /^-?[0-9]+$/;
938
939	private deferred_loading : number = 0;
940
941	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
942	{
943		// Call the inherited constructor
944		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist_account._attributes, _child || {}));
945
946		// Counter to prevent infinite looping while fetching account names
947		this.deferred_loading = 0;
948
949		this.options.autocomplete_params.type = "account";
950	}
951
952	/**
953	 * Set if accounts, groups or both are supported
954	 *
955	 * Option get's passed on to autocomplete_params.
956	 *
957	 * @param {string} value "accounts" (default), "groups", "both", "owngroups"
958	 */
959	set_account_type(value)
960	{
961		if(value != this.options.account_type)
962		{
963			this.options.select_options = [];
964		}
965		this.options.autocomplete_params.account_type = this.options.account_type = value;
966
967		this.set_select_options(this._get_accounts());
968	}
969
970	/**
971	 * Get account info for select options from common client-side account cache
972	 *
973	 * @return {Array} select options
974	 */
975	private _get_accounts()
976	{
977		let existing = [];
978		if (!jQuery.isArray(this.options.select_options))
979		{
980			let options = jQuery.extend({}, this.options.select_options);
981			this.options.select_options = [];
982			for(let key in options)
983			{
984				if (typeof options[key] == 'object')
985				{
986					if (typeof(options[key].key) == 'undefined')
987					{
988						options[key].value = key;
989					}
990					this.options.select_options.push(options[key]);
991				}
992				else
993				{
994					this.options.select_options.push({value: key, label: options[key]});
995				}
996				existing.push(key);
997			}
998		}
999		else
1000		{
1001			for(let i = 0; i < this.options.select_options.length; i++)
1002			{
1003				existing.push(this.options.select_options[i].value);
1004			}
1005		}
1006		let type = this.egw().preference('account_selection', 'common');
1007		let accounts = [];
1008		// for primary_group we only display owngroups == own memberships, not other groups
1009		if (type == 'primary_group' && this.options.account_type != 'accounts')
1010		{
1011			if (this.options.account_type == 'both')
1012			{
1013				accounts = this.egw().accounts('accounts');
1014			}
1015			accounts = accounts.concat(this.egw().accounts('owngroups'));
1016		}
1017		else
1018		{
1019			accounts = this.egw().accounts(this.options.account_type);
1020		}
1021		for(let i = 0; i < accounts.length; i++)
1022		{
1023			if(existing.indexOf(accounts[i].value) === -1)
1024			{
1025				this.options.select_options.push(accounts[i]);
1026			}
1027		}
1028
1029		return this.options.select_options;
1030	}
1031
1032
1033
1034	/**
1035	 * Set value(s) of taglist, reimplemented to automatic resolve numerical account_id's
1036	 *
1037	 * @param value (array of) ids
1038	 */
1039	set_value(value)
1040	{
1041		if(!value) return super.set_value(value || []);
1042
1043		let values = jQuery.isArray(value) ? jQuery.extend([], value) : [value];
1044		for(let i=0; i < values.length; ++i)
1045		{
1046			let v = values[i];
1047			let result = [];
1048			if (typeof v == 'object' && v.id === v.label) v = v.id;
1049			if (this.options.select_options && (
1050				// Check options
1051				((result = jQuery.grep(this.options.select_options, function(e) {
1052					return e.id == v;
1053				})) && result.length) ||
1054				// Check current selection to avoid going back to server
1055				(this.taglist && (result = jQuery.grep(this.taglist.getSelection(), function(e) {
1056					return e.id == v;
1057				})) && result.length)
1058			))
1059			{
1060				// Options should have been provided, but they weren't
1061				// This can happen for ajax source with an existing value
1062				if(this.options.select_options == null)
1063				{
1064					this.options.select_options = [];
1065				}
1066				values[i] = result[0] ? result[0] : {
1067					id: v,
1068					label: v
1069				};
1070			}
1071			else if (typeof v != 'object'  && !isNaN(v) && (typeof v != 'string' || v.match(this.int_reg_exp)))
1072			{
1073				v = parseInt(v);
1074				let label = this.egw().link_title('api-accounts', v);
1075				if (label)	// already cached on client-side --> replace it
1076				{
1077					values[i] = {
1078						id: v,
1079						label: label || '#'+v
1080					};
1081				}
1082				else
1083				{
1084					delete this.options.value;
1085					this.deferred_loading++;
1086					this.egw().link_title('api-accounts', v, jQuery.proxy(function(idx, id, label) {
1087						this.deferred_loading--;
1088						values[idx] = {
1089							id: id,
1090							label: label || '#'+id
1091						};
1092						if (!this.deferred_loading)
1093						{
1094							// Seems the magic suggest can not deal with broken taglist value
1095							// like selected value with no label set and maxSelection set. This
1096							// trys to first unset maxSelection and set value to magix suggest taglist
1097							// object then calls set_value of the widget taglist, and at the end
1098							// re-set the maxSelection option again.
1099							if (this.options.maxSelection) this.taglist.setMaxSelection(null);
1100							this.taglist.setValue([{id:id, label:label || '#'+id}]);
1101							this.set_value(values);
1102							this.taglist.setMaxSelection(this.options.maxSelection);
1103						}
1104					}, this, i, v));
1105				}
1106			}
1107		}
1108		// Don't proceed if waiting for labels
1109		if(this.deferred_loading <=0)
1110		{
1111			super.set_value(values);
1112		}
1113	}
1114}
1115et2_register_widget(et2_taglist_account, ["taglist-account"]);
1116
1117/**
1118 * Taglist customized specifically for emails, which it pulls from the mail application,
1119 * or addressbook if mail is not available.  Free entries are allowed, and we render
1120 * invalid free entries differently (but still accept them).
1121 *
1122 * @augments et2_taglist
1123 */
1124class et2_taglist_email extends et2_taglist
1125{
1126	static readonly _attributes : any = {
1127		"autocomplete_url": {
1128			"default": "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_email"
1129		},
1130		"autocomplete_params": {
1131			"default": {}
1132		},
1133		allowFreeEntries: {
1134			"default": true,
1135			ignore: true
1136		},
1137		include_lists: {
1138			name: "Include lists",
1139			description:"Include mailing lists in search results",
1140			default: false,
1141			type: "boolean"
1142		},
1143		useCommaKey: {
1144			name: "comma will start validation",
1145			type: "boolean",
1146			"default": false,
1147			description: "Set to false to allow comma in entered content"
1148		},
1149		domainOptional: {
1150			name: "Domain optional",
1151			description:"Allows domain part of an email address to be optional",
1152			default: false,
1153			type: "boolean"
1154		}
1155	};
1156
1157	lib_options : any = {
1158		// Search function limits to 3 anyway
1159		minChars: 3
1160	};
1161
1162	static EMAIL_PREG : RegExp = et2_url.EMAIL_PREG;
1163
1164	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
1165	{
1166		// Call the inherited constructor
1167		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist_email._attributes, _child || {}));
1168
1169
1170		if(this.options.include_lists)
1171		{
1172			this.options.autocomplete_params.include_lists = true;
1173		}
1174
1175		// Make domain name optional for EMAIL_PREG if it's requested
1176		if (this.options.domainOptional)
1177		{
1178			et2_taglist_email.EMAIL_PREG = new RegExp(/^(([^\042',<][^,<]+|\042[^\042]+\042|\'[^\']+\'|)\s?<)?[^\x00-\x20()\xe2\x80\x8b<>@,;:\042\[\]\x80-\xff]+(@([a-z0-9ÄÖÜäöüß](|[a-z0-9ÄÖÜäöüß_-]*[a-z0-9ÄÖÜäöüß])\.)+[a-z]{2,})?>?$/i);
1179		}
1180	}
1181
1182	// PREG for validation comes from et2_url
1183	//EMAIL_PREG: new RegExp(/^(([^\042',<][^,<]+|\042[^\042]+\042|\'[^\']+\'|)\s?<)?[^\x00-\x20()\xe2\x80\x8b<>@,;:\042\[\]\x80-\xff]+@([a-z0-9ÄÖÜäöüß](|[a-z0-9ÄÖÜäöüß_-]*[a-z0-9ÄÖÜäöüß])\.)+[a-z]{2,}>?$/i),
1184	//
1185	// REGEXP with domain part to be optional
1186	// new RegExp(/^(([^\042',<][^,<]+|\042[^\042]+\042|\'[^\']+\'|)\s?<)?[^\x00-\x20()\xe2\x80\x8b<>@,;:\042\[\]\x80-\xff]+(@([a-z0-9ÄÖÜäöüß](|[a-z0-9ÄÖÜäöüß_-]*[a-z0-9ÄÖÜäöüß])\.)+[a-z]{2,})?>?$/i)
1187	selectionRenderer(item)
1188	{
1189		// Trim
1190		if(typeof item.id == 'string')
1191		{
1192			item.id = item.id.trim();
1193		}
1194		if(typeof item.label == 'string')
1195		{
1196			item.label = item.label.trim();
1197		}
1198		// We check free entries for valid email, and render as invalid if it's not.
1199		let valid = item.id != item.label || et2_taglist_email.EMAIL_PREG.test(item.id || '');
1200
1201		if (!valid && item.id)
1202		{
1203			// automatic quote 'Becker, Ralf <rb@stylite.de>' as '"Becker, Ralf" <rb@stylite.de>'
1204			let matches = item.id.match(/^(.*) ?<(.*)>$/);
1205			if (matches && et2_taglist_email.EMAIL_PREG.test('"'+matches[1].trim()+'" <'+matches[2].trim()+'>'))
1206			{
1207				item.id = item.label = '"'+matches[1].trim()+'" <'+matches[2].trim()+'>';
1208				valid = true;
1209			}
1210			// automatic insert multiple comma-separated emails like "rb@stylite.de, hn@stylite.de"
1211			if (!valid)
1212			{
1213				let parts = item.id.split(/, */);
1214				let items = [], errors = [];
1215				if (parts.length > 1)
1216				{
1217					for(let i=0; i < parts.length; ++i)
1218					{
1219						parts[i] = parts[i].trim();
1220						if (!et2_taglist_email.EMAIL_PREG.test(parts[i]))
1221						{
1222							errors.push(parts[i]);
1223						}
1224						else
1225						{
1226							items.push({id: parts[i], label: parts[i]});
1227						}
1228					}
1229					item.id = item.label = errors.length ? errors.join(', ') : items.shift().id;
1230					valid = !errors.length;
1231					// insert further parts into taglist, after validation first one
1232					if (items.length)
1233					{
1234						// a bit ugly but unavoidable
1235						if (valid)
1236						{
1237							// if no error, we need to delay insert, as taglist gets into wired state and shows first item twice
1238							var taglist = this.taglist;
1239							window.setTimeout(function()
1240							{
1241								taglist.addToSelection(items);
1242							}, 10);
1243						}
1244						else
1245						{
1246							// if we have an error, we need to insert items now, to not overwrite the error
1247							this.taglist.addToSelection(items);
1248						}
1249					}
1250				}
1251			}
1252		}
1253
1254		let label = jQuery('<span>').text(item.label);
1255		if (item.class) label.addClass(item.class);
1256		if (typeof item.title != 'undefined') label.attr('title', item.title);
1257		if (typeof item.data != 'undefined') label.attr('data', item.data);
1258		if (!valid) {
1259			label.addClass('ui-state-error');
1260			window.setTimeout(jQuery.proxy(function() {
1261				this.taglist.removeFromSelection(item);
1262				this.set_validation_error(egw.lang("'%1' has an invalid format",item.label));
1263				this.taglist.input.val(item.label).focus();
1264			},this),1);
1265			return null;
1266		}
1267		if (typeof item.icon != 'undefined' && item.icon)
1268		{
1269			let wrapper = jQuery('<div>').addClass('et2_taglist_tags_icon_wrapper');
1270			jQuery('<span/>')
1271					.addClass('et2_taglist_tags_icon')
1272					.css({"background-image": "url("+(item.icon.match(/^(http|https|\/)/) ? item.icon : egw.image(item.icon, item.app))+")"})
1273					.appendTo(wrapper);
1274			label.appendTo(wrapper);
1275			return wrapper;
1276		}
1277		return label;
1278	}
1279}
1280et2_register_widget(et2_taglist_email, ["taglist-email"]);
1281
1282
1283/**
1284 * Taglist customized specifically for categories, with colors
1285 *
1286 * Free entries are not allowed.
1287 *
1288 * @augments et2_taglist
1289 */
1290class et2_taglist_category extends et2_taglist
1291{
1292	static readonly _attributes : any = {
1293		"minChars": {
1294			default: 0
1295		},
1296		"autocomplete_url": {
1297			"default": ""
1298		},
1299		"autocomplete_params": {
1300			"default": {}
1301		},
1302		allowFreeEntries: {
1303			"default": false,
1304			ignore: true
1305		}
1306	};
1307
1308	lib_options : any = {
1309	};
1310
1311	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
1312	{
1313		// Call the inherited constructor
1314		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist_email._attributes, _child || {}));
1315		this.div.addClass('et2_taglist_category');
1316	}
1317
1318	/**
1319	 * Get options automatically from select option cache
1320	 * @param {type} _attrs
1321	 */
1322	transformAttributes(_attrs) {
1323		// Pretend to be a select box so it works
1324		var type = this.getType();
1325		this.setType('select-cat');
1326		super.transformAttributes(_attrs);
1327		this.setType(type);
1328	}
1329
1330	/**
1331	 * convert selectbox options from the cache to taglist data [{id:...,label:...},...] format
1332	 *
1333	 * @param {(object|array)} _options id: label or id: {label: ..., title: ...} pairs, or array if id's are 0, 1, ...
1334	 *
1335	 * @return {Object[]} Returns an array of objects with ID and label
1336	 */
1337	protected _options2data(_options)
1338	{
1339		let options = jQuery.isArray(_options) ? jQuery.extend({}, _options) : _options;
1340		let data = [];
1341		for(let id in options)
1342		{
1343			let option = {};
1344			if (typeof options[id] == 'object')
1345			{
1346				jQuery.extend(option, options[id]);
1347				if(option["value"]) option["id"] = option["value"];
1348			}
1349			else
1350			{
1351				option["label"] = options[id];
1352			}
1353			data.push(option);
1354		}
1355		return data;
1356	}
1357
1358	selectionRenderer(item)
1359	{
1360		let label = jQuery('<span>').text(item.label);
1361
1362		if (item.class) label.addClass(item.class);
1363		jQuery('<span class="cat_'+item.id+'"/>').prependTo(label);
1364		if (typeof item.title != 'undefined') label.attr('title', item.title);
1365		if (typeof item.data != 'undefined') label.attr('data', item.data);
1366
1367		return label;
1368	}
1369}
1370et2_register_widget(et2_taglist_category, ["taglist-cat"]);
1371
1372/**
1373 * Taglist customized specificlly for image url shown as thumbnail,
1374 *
1375 */
1376class et2_taglist_thumbnail extends et2_taglist
1377{
1378	static readonly _attributes : any = {
1379		"minChars": {
1380			default: 0
1381		},
1382		"autocomplete_url": {
1383			"default": ""
1384		},
1385		"autocomplete_params": {
1386			"default": {}
1387		}
1388	};
1389
1390	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
1391	{
1392		// Call the inherited constructor
1393		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist_thumbnail._attributes, _child || {}));
1394		this.div.addClass('et2_taglist_thumbnail');
1395	}
1396
1397	selectionRenderer(item)
1398	{
1399		let tag = jQuery('<span>').attr('title',item.label);
1400		jQuery('<img class="et2_taglist_thumbnail_img"/>').attr('src', item.label).prependTo(tag);
1401		return tag;
1402	}
1403}
1404et2_register_widget(et2_taglist_thumbnail, ["taglist-thumbnail"]);
1405
1406
1407/**
1408 * Taglist represents list of states of a country,
1409 *
1410 */
1411class et2_taglist_state extends et2_taglist
1412{
1413	static readonly _attributes : any = {
1414		"minChars": {
1415			default: 0
1416		},
1417		"autocomplete_url": {
1418			"default": ""
1419		},
1420		"autocomplete_params": {
1421			"default": {}
1422		},
1423		"country_code": {
1424			name: "country code to fetch states for",
1425			default: "de",
1426			type: "string",
1427			description: "Defines country code to fetch list of states for it"
1428		}
1429	};
1430
1431	private country_code : string;
1432
1433	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
1434	{
1435		// Call the inherited constructor
1436		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist_state._attributes, _child || {}));
1437		this.div.addClass('et2_taglist_state');
1438	}
1439
1440	/**
1441	 * Get options automatically from select option cache
1442	 * @param {type} _attrs
1443	 */
1444	transformAttributes(_attrs) {
1445		// Pretend to be a select box so it works
1446		let type = this.getType();
1447		this.setType('select-state');
1448		super.transformAttributes(_attrs);
1449		this.setType(type);
1450	}
1451
1452	/**
1453	 * convert selectbox options from the cache to taglist data [{id:...,label:...},...] format
1454	 *
1455	 * @param {(object|array)} _options id: label or id: {label: ..., title: ...} pairs, or array if id's are 0, 1, ...
1456	 *
1457	 * @return {Object[]} Returns an array of objects with ID and label
1458	 */
1459	protected _options2data(_options)
1460	{
1461		let options = jQuery.isArray(_options) ? jQuery.extend({}, _options) : _options;
1462		let data = [];
1463		for(let id in options)
1464		{
1465			let option = {};
1466			if (typeof options[id] == 'object')
1467			{
1468				jQuery.extend(option, options[id]);
1469				if(option["value"]) option["id"] = option["value"];
1470			}
1471			else
1472			{
1473				option["label"] = options[id];
1474			}
1475			data.push(option);
1476		}
1477		return data;
1478	}
1479
1480	set_country_code(_country_code)
1481	{
1482		let country_code = _country_code || '';
1483		let old_code = this.options.country_code;
1484		this.country_code = country_code;
1485		this.options.country_code = country_code;
1486
1487		// Reload if needed
1488		if(this.options.country_code !== old_code && this.isInTree())
1489		{
1490			var sel_options = et2_selectbox.find_select_options(this, {}, this.options);
1491			this.set_select_options(sel_options);
1492		}
1493	}
1494}
1495et2_register_widget(et2_taglist_state, ["taglist-state"]);
1496
1497/**
1498 * et2_taglist_ro is the readonly implementation of the taglist.
1499 *
1500 * @augments et2_selectbox
1501 */
1502class et2_taglist_ro extends et2_selectbox_ro
1503{
1504	/**
1505	 * Constructor
1506	 *
1507	 * @memberOf et2_selectbox_ro
1508	 */
1509	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
1510	{
1511		// Call the inherited constructor
1512		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist_ro._attributes, _child || {}));
1513
1514		this.span = jQuery('<div><ul /></div>')
1515			.addClass('et2_taglist_ro');
1516		this.setDOMNode(this.span[0]);
1517		this.span = jQuery('ul',this.span)
1518			.addClass('ms-sel-ctn');
1519	}
1520
1521	set_value(_value) {
1522		super.set_value.apply(this, arguments);
1523		jQuery('li',this.span).addClass('ms-sel-item');
1524	}
1525}
1526et2_register_widget(et2_taglist_ro, ["taglist_ro","taglist_email_ro", "taglist_account_ro" ]);
1527
1528// Require css
1529// included via etemplate2.css
1530//if(typeof egw == 'function') egw(window).includeCSS(egw.webserverUrl + "/api/js/jquery/magicsuggest/magicsuggest.css");
1531