/** * EGroupware eTemplate2 - JS Tag list object * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package etemplate * @subpackage api * @link: https://www.egroupware.org * @author Nathan Gray * @copyright Nathan Gray 2013 */ /*egw:uses et2_core_inputWidget; /vendor/egroupware/magicsuggest/magicsuggest.js; */ import {et2_selectbox} from "./et2_widget_selectbox"; import {et2_register_widget, WidgetConfig} from "./et2_core_widget"; import {ClassWithAttributes} from "./et2_core_inheritance"; /** * Tag list widget * * A cross between auto complete, selectbox and chosen multiselect * * Uses MagicSuggest library * @see http://nicolasbize.github.io/magicsuggest/ * @augments et2_selectbox */ export class et2_taglist extends et2_selectbox implements et2_IResizeable { static readonly _attributes : any = { "empty_label": { "name": "Empty label", "type": "string", "default": "", "description": "Textual label for when nothing is selected", translate: true }, "select_options": { "type": "any", "name": "Select options", "default": {}, //[{id: "a", label: "Alpha"},{id:"b", label: "Beta"}], "description": "Internaly used to hold the select options." }, // Value can be CSV String or Array "value": { "type": "any" }, // These default parameters set it to read the addressbook via the link system "autocomplete_url": { "name": "Autocomplete source", "type": "string", "default": "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_search", "description": "Menuaction (app.class.function) for autocomplete data source. Must return actual JSON, and nothing more." }, "autocomplete_params": { "name": "Autocomplete parameters", "type": "any", "default": {app:"addressbook"}, "description": "Extra parameters passed to autocomplete URL. It should be a stringified JSON object." }, allowFreeEntries: { "name": "Allow free entries", "type": "boolean", "default": true, "description": "Restricts or allows the user to type in arbitrary entries" }, "onchange": { "description": "Callback when tags are changed. Argument is the new selection.", "type":"js" }, "onclick": { "description": "Callback when a tag is clicked. The clicked tag is passed.", "type":"js" }, "tagRenderer": { "name": "Tag renderer", "type": "js", "default": et2_no_init, "description": "Callback to provide a custom renderer for what's _inside_ each tag. Function parameter is the select_option data for that ID." }, "listRenderer": { "name": "List renderer", "type": "js", "default": et2_no_init, "description": "Callback to provide a custom renderer for each suggested item. Function parameter is the select_option data for that ID." }, "width": { "default": "100%" }, "height": { "description": "Maximum allowed height of the result list in pixels" }, "maxSelection": { "name": "max Selection", "type": "integer", "default": null, "description": "The maximum number of items the user can select if multiple selection is allowed." }, // Selectbox attributes that are not applicable "multiple": { type: 'any', // boolean or 'toggle' default: true, description: "True, false or 'toggle'" }, "rows": { "name": "Rows", "type": "integer", "default": et2_no_init, "description": "Number of rows to display" }, "tags": { ignore: true}, useCommaKey: { name: "comma will start validation", type: "boolean", "default": true, description: "Set to false to allow comma in entered content" }, groupBy: { name: "group results", type: "string", default: null, description: "group results by given JSON attribute" }, minChars: { name: "chars to start query", type: "integer", default: 3, description: "minimum number of characters before expanding the combo" }, editModeEnabled: { name: "Enable edit mode for tags", type: "boolean", "default": true, description: "Allow to modify a tag by clicking on edit icon. It only can be enabled if only allowFreeEntries is true." } }; // Allows sub-widgets to override options to the library lib_options : any = {}; protected div : JQuery = null; private multiple_toggle : JQuery = null; private _multiple : boolean = false; protected taglist : any = null; private _hide_timeout : number; private $taglist : JQuery = null; private taglist_options : any; private _query_server : any; /** * Construtor */ constructor(_parent, _attrs? : WidgetConfig, _child? : object) { // Call the inherited constructor super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_taglist._attributes, _child || {})); // Parent sets multiple based on rows, we just re-set it this.options.multiple = _attrs.multiple; // jQuery wrapped DOM node this.div = jQuery("
").addClass('et2_taglist'); this.multiple_toggle = jQuery("") .addClass('toggle') .on('click', jQuery.proxy(function() { this._set_multiple(!this._multiple); },this)) .appendTo(this.div); this._multiple = false; // magicSuggest object this.taglist = null; this.setDOMNode(this.div[0]); } destroy() { if(this.div != null) { // Undo the plugin } if(this._hide_timeout) { window.clearTimeout(this._hide_timeout); } super.destroy.apply(this, arguments); } transformAttributes(_attrs) { super.transformAttributes.apply(this, arguments); // Handle url parameters - they should be an object if(typeof _attrs.autocomplete_params == 'string') { try { _attrs.autocomplete_params = JSON.parse(_attrs.autocomplete_params); } catch (e) { this.egw().debug('warn', 'Invalid autocomplete_params: '+_attrs.autocomplete_params ); } } if(_attrs.multiple !== 'toggle') { _attrs.multiple = et2_evalBool(_attrs.multiple); } } doLoadingFinished() { super.doLoadingFinished(); let widget = this; // Initialize magicSuggest here if(this.taglist != null) return; // If no options or ajax url, try the array mgr if(this.options.select_options === null && !this.options.autocomplete_url) { this.set_select_options(this.getArrayMgr("sel_options").getEntry(this.id)); } // MagicSuggest would replaces our div, so add a wrapper instead this.taglist = jQuery('').appendTo(this.div); this.taglist_options = jQuery.extend( { // magisuggest can NOT work setting an empty autocomplete url, it will then call page url! // --> setting an empty options list instead data: this.options.autocomplete_url ? this.options.autocomplete_url : this.options.select_options || {}, dataUrlParams: this.options.autocomplete_params, method: 'GET', displayField: "label", invalidCls: 'invalid ui-state-error', placeholder: this.options.empty_label, hideTrigger: this.options.multiple === true, noSuggestionText: this.egw().lang("No suggestions"), required: this.options.required, allowFreeEntries: this.options.allowFreeEntries, useTabKey: true, useCommaKey: this.options.useCommaKey, disabled: this.options.disabled || this.options.readonly, editable: !(this.options.disabled || this.options.readonly), // If there are select options, enable toggle on click so user can see them toggleOnClick: !jQuery.isEmptyObject(this.options.select_options), selectionRenderer: jQuery.proxy(this.options.tagRenderer || this.selectionRenderer,this), renderer: jQuery.proxy(this.options.listRenderer || this.selectionRenderer,this), maxSelection: this.options.multiple ? this.options.maxSelection : 1, maxSelectionRenderer: jQuery.proxy(function(v) { this.egw().lang('You can not choose more then %1 item(s)!', v); }, this), minCharsRenderer: jQuery.proxy(function(v){ this.egw().lang(v == 1 ? 'Please type 1 more character' : 'Please type %1 more characters',v); }, this), width: this.options.width, // propagate width highlight: false, // otherwise renderer have to return strings selectFirst: true, groupBy: this.options.groupBy && typeof this.options.groupBy == 'string' ? this.options.groupBy : null, minChars: parseInt(this.options.minChars) ? parseInt(this.options.minChars) : 0, editModeEnabled: this.options.editModeEnabled }, this.lib_options); if(this.options.height) { this.div.css('height',''); this.taglist_options.maxDropHeight = parseInt(this.options.height); } // If only one, do not require minimum chars or the box won't drop down if(this.options.multiple !== true) { this.taglist_options.minChars = 0; } this.taglist = this.taglist.magicSuggest(this.taglist_options); this.$taglist = jQuery(this.taglist); if(this.options.value) { this.taglist.addToSelection(this.options.value,true); } this._set_multiple(this.options.multiple); // AJAX _and_ select options - use custom function if(this.options.autocomplete_url && !jQuery.isEmptyObject(this.options.select_options)) { this.taglist.setData(function(query, cfg) { return widget._data.call(widget,query, cfg); }); } this.$taglist // Display / hide a loading icon while fetching .on("beforeload", function() {this.container.prepend('');}) .on("load", function() {jQuery('.loading',this.container).remove();}) // Keep focus when selecting from the list .on("selectionchange", function() { if(document.activeElement === document.body || widget.div.has(document.activeElement).length > 0) { jQuery('input',this.container).focus(); } widget.resize(); }) // Bind keyup so we can start ajax search when we like .on('keyup.start_search', jQuery.proxy(this._keyup, this)) .on('blur', jQuery.proxy(function() { if (this.div) { this.div.removeClass('ui-state-active'); this.resize(); } }, this)) // Hide tooltip when you're editing, it can get annoying otherwise .on('focus', function() { jQuery('.egw_tooltip').hide(); widget.div.addClass('ui-state-active'); }) // If not using autoSelect, avoid some errors with selection starting // with the group .on('load expand', function() { if(widget.taglist && widget.taglist_options.groupBy) { window.setTimeout(function() { if(widget && widget.taglist.combobox) { widget.taglist.combobox.find('.ms-res-group.ms-res-item-active') .removeClass('ms-res-item-active'); } },1); } }) // Position absolute to break out of containers .on('expand', jQuery.proxy(function() { let taglist = this.taglist; this.div.addClass('expanded'); let background = this.taglist.combobox.css('background'); let wrapper = jQuery(document.createElement('div')) // Keep any additional classes .addClass(this.div.attr('class')) .css({ 'position': 'absolute', 'width': 'auto' }) .appendTo('body') .position({my: 'left top', at: 'left bottom', of: this.taglist.container}); this.taglist.combobox .width(this.taglist.container.innerWidth()) .appendTo(wrapper) .css('background', background); // Make sure it doesn't go out of the window let bottom = (wrapper.offset().top + this.taglist.combobox.outerHeight(true)); if(bottom > jQuery(window).height()) { this.taglist.combobox.height(this.taglist.combobox.height() - (bottom - jQuery(window).height()) - 5); } // Close dropdown if click elsewhere or scroll the sidebox, // but wait until after or it will close immediately window.setTimeout(function() { jQuery('.egw_fw_ui_scrollarea').one('scroll mousewheel', function() { taglist.collapse(); }); jQuery('body').one('click',function() { taglist.collapse(); if(document.activeElement === document.body || widget.div.has(document.activeElement).length > 0) { if (widget.options.allowFreeEntries) taglist.container.blur(); taglist.input.focus(); } else if (widget.options.allowFreeEntries) { taglist.container.blur(); } });},100 ); this.$taglist.one('collapse', function() { wrapper.remove(); }); },this)) .on('collapse', function() { widget.div.removeClass('expanded'); }); jQuery('.ms-trigger',this.div).on('click', function(e) { e.stopPropagation(); }); // Unbind change handler of widget's ancestor to stop it from bubbling // taglist has its own onchange jQuery(this.getDOMNode()).unbind('change.et2_inputWidget'); // This handles clicking on a suggestion from the list in an et2 friendly way // onChange if(this.options.onchange && typeof this.onchange === 'function') { this.$taglist.on("selectionchange", jQuery.proxy(this.change,this)); } // onClick - pass more than baseWidget, so unbind it to avoid double callback if(typeof this.onclick == 'function') { this.div.unbind("click.et2_baseWidget") .on("click.et2_baseWidget", '.ms-sel-item', jQuery.proxy(function(event) { // Pass the target as expected, but also the data for that tag this.click(/*event.target,*/ jQuery(event.target).parent().data("json")); },this)); } // onFocus if (typeof this.onfocus == 'function') { this.$taglist.focus(function(e) { widget.onfocus.call(widget.taglist, e, widget); }); } // Do size limit checks this.resize(); // Make sure magicsuggest loses focus class to prevent issues with // multiple on the page this.div.on('blur', 'input', function() { jQuery('.ms-ctn-focus', widget.div).removeClass('ms-ctn-focus'); }); this.resetDirty(); return true; } /** * convert _options to taglist data [{id:...,label:...},...] format * * @param {(object|array)} _options id: label or id: {label: ..., title: ...} pairs, or array if id's are 0, 1, ... */ protected _options2data(_options) { let options = jQuery.isArray(_options) ? jQuery.extend({}, _options) : _options; let data = []; for(let id in options) { let option = {id: id}; if (typeof options[id] == 'object') { jQuery.extend(option, options[id]); if(option["value"]) option["id"] = option["value"]; option["label"] = this.options.no_lang ? option["label"] : this.egw().lang(option["label"]); } else { option["label"] = this.options.no_lang ? options[id] : this.egw().lang(options[id]); } data.push(option); } return data; } /** * Custom data function to return local options if there is nothing * typed, or query via AJAX if user typed something * * @param {string} query * @param {Object} cfg Magicsuggest's internal configuration * @returns {Array} */ private _data(query, cfg) { let return_value = this.options.select_options || {}; if (!jQuery.isEmptyObject(this.options.select_options) && !this._query_server || query.trim().length < this.taglist_options.minChars || !this.options.autocomplete_url) { // Check options, if there's a match there (that is not already // selected), do not ask server let filtered = []; let selected = this.taglist.getSelection(); jQuery.each(this.options.select_options, function(index, obj) { var name = obj.label; if(selected.indexOf(obj) < 0 && name.toLowerCase().indexOf(query.toLowerCase()) > -1) { filtered.push(obj); } }); return_value = filtered.length > 0 ? filtered : this.options.autocomplete_url; } else if (query.trim().length >= this.taglist_options.minChars || this._query_server) { // No options - ask server return_value = this.options.autocomplete_url; } this._query_server = false; // Make sure we don't give an empty string, that fails when we try to query if(return_value == this.options.autocomplete_url && !return_value) { return_value = []; } // Turn on local filtering, or trust server to do it cfg.mode = typeof return_value === 'string' ? 'remote' : 'local'; return return_value; } /** * Handler for keyup, used to start ajax search when we like * * @param {DOMEvent} e * @param {Object} taglist * @param {jQueryEvent} event * @returns {Boolean} */ private _keyup(e, taglist, event) { if(event.which === jQuery.ui.keyCode.ENTER && taglist.combobox.find('.ms-res-item.ms-res-item-active').length==0 && this.getType() !== 'taglist-email') { // Change keycode to abort the validation process // This means enter does not add a tag event.keyCode = jQuery.ui.keyCode.DOWN; this._query_server = true; this.taglist.collapse(); this.taglist.expand(); this._query_server = false; this.div.find('.ms-res-item-active') .removeClass('ms-res-item-active'); event.preventDefault(); return false; } } /** * Set all options from current static select_options list */ select_all() { let all = []; for(let id in this.options.select_options) all.push(id); this.set_value(all); } /** * Render a single item, taking care of correctly escaping html special chars * * @param item * @returns {String} */ selectionRenderer(item) { let label = jQuery('').text(item.label); if (typeof item.title != 'undefined') label.attr('title', item.title); if (typeof item.icon != 'undefined') { let wrapper = jQuery('