/** * EGroupware eTemplate2 - JS Selectbox object * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package etemplate * @subpackage api * @link http://www.egroupware.org * @author Nathan Gray * @author Andreas Stöckel * @copyright Nathan Gray 2011 */ /*egw:uses /vendor/bower-asset/jquery/dist/jquery.js; /api/js/jquery/chosen/chosen.jquery.js; et2_core_xml; et2_core_DOMWidget; et2_core_inputWidget; */ import './et2_core_common'; import {ClassWithAttributes} from "./et2_core_inheritance"; import {et2_register_widget, et2_widget, WidgetConfig} from "./et2_core_widget"; import {et2_inputWidget} from './et2_core_inputWidget' import {et2_DOMWidget} from "./et2_core_DOMWidget"; // all calls to Chosen jQuery plugin as jQuery.(un)chosen() give errors which are currently suppressed with @ts-ignore // adding npm package @types/chosen-js did NOT help :( /** * et2 select(box) widget */ export class et2_selectbox extends et2_inputWidget { static readonly _attributes : any = { // todo fully implement attr[multiple] === "dynamic" to render widget with a button to switch to multiple // as it is used in account_id selection in admin >> mailaccount (app.admin.edit_multiple method client-side) "multiple": { "name": "multiple", "type": "boolean", "default": false, "description": "Allow selecting multiple options" }, "expand_multiple_rows": { "name": "Expand multiple", "type": "integer", "default": et2_no_init, "description": "Shows single select widget, with a button. If the "+ "user clicks the button, the input will toggle to a multiselect,"+ "with this many rows. " }, "rows": { "name": "Rows", "type": "any", // Old options put either rows or empty_label in first space "default": 1, "description": "Number of rows to display" }, "empty_label": { "name": "Empty label", "type": "string", "default": "", "description": "Textual label for first row, eg: 'All' or 'None'. ID will be ''", translate:true }, "select_options": { "type": "any", "name": "Select options", "default": {}, "description": "Internaly used to hold the select options." }, "selected_first": { "name": "Selected options first", "type": "boolean", "default": true, "description": "For multi-selects, put the selected options at the top of the list when first loaded" }, // Chosen options "search": { "name": "Search", "type": "boolean", "default": false, "description": "For single selects, add a search box to the drop-down list" }, "tags": { "name": "Tag style", "type": "boolean", "default": false, "description": "For multi-selects, displays selected as a list of tags instead of a big list" }, "allow_single_deselect": { "name": "Allow Single Deselect", "type": "boolean", "default": true, "description": "Allow user to unset current selected value" }, // Value can be string or integer "value": { "type": "any" }, // Type specific legacy options. Avoid using. "other": { "ignore": true, "type": "any" }, value_class: { name: "Value class", type: "string", default: "", description: "Allow to set a custom css class combined with selected value. (e.g. cat_23)" } }; public static readonly legacyOptions: string[] = ["rows","other"]; // Other is sub-type specific input: JQuery = null; value: string | string[] = ''; expand_button: JQuery; multiOptions: JQuery; selected_first: boolean = true; /** * Constructor */ constructor(_parent, _attrs? : WidgetConfig, _child? : object) { // Call the inherited constructor super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_selectbox._attributes, _child || {})); this.input = null; // Start at '' to avoid infinite loops while setting value/select options this.value = ''; // Allow no other widgets inside this one this.supportedWidgetClasses = []; // Legacy options could have row count or empty label in first slot if(typeof this.options.rows == "string") { if(isNaN(this.options.rows)) { this.options.empty_label = this.egw().lang(this.options.rows); this.options.rows = 1; } else { this.options.rows = parseInt(this.options.rows); } } if(this.options.rows > 1) { this.options.multiple = true; if(this.options.tags) { this.createInputWidget(); } else { this.createMultiSelect(); } } else { this.createInputWidget(); } if(!this.options.empty_label && !this.options.readonly && this.options.multiple) { this.options.empty_label = this.egw().lang('Select some options'); } } destroy() { if(this.input != null) { // @ts-ignore this.input.unchosen(); } if(this.expand_button) { this.expand_button.off(); this.expand_button.remove(); this.expand_button = null; } super.destroy(); this.input = null; } transformAttributes(_attrs) { super.transformAttributes(_attrs); // If select_options are already known, skip the rest if(this.options && this.options.select_options && !jQuery.isEmptyObject(this.options.select_options) || _attrs.select_options && !jQuery.isEmptyObject(_attrs.select_options) || // Allow children to skip select_options - check to make sure default got set to something (should be {}) typeof _attrs.select_options == 'undefined' || _attrs.select_options === null ) { // do not return inside nextmatch, as get_rows data might have changed select_options // for performance reasons we only do it for first row, which should have id "0[...]" if (this.getParent() && this.getParent().getType() != 'rowWidget' || !_attrs.id || _attrs.id[0] != '0') return; } var sel_options = et2_selectbox.find_select_options(this, _attrs['select_options'], _attrs); if(!jQuery.isEmptyObject(sel_options)) { _attrs['select_options'] = sel_options; } } /** * Switch instanciated widget to multi-selection and back, optionally enabeling tags too * * If you want to switch tags on too, you need to do so after switching to multiple! * * @param {boolean} _multiple * @param {integer} _size default=3 */ set_multiple(_multiple, _size) { this.options.multiple = _multiple; if (this.input) { if (_multiple) { this.input.attr('size', _size || 3); this.input.prop('multiple', true); this.input.attr('name', this.id + '[]'); if ((this.input[0]).options.length && (this.input[0]).options[0].value === '') { (this.input[0]).options[0] = null; } } else { this.input.prop('multiple', false); this.input.removeAttr('size'); this.input.attr('name', this.id); if (this.options.empty_label && (this.input[0]).options[0].value !== '') { this._appendOptionElement('', this.options.empty_label); } } if(this.expand_button) { if(_multiple) { this.expand_button.addClass('ui-icon-minus').removeClass('ui-icon-plus'); } else { this.expand_button.removeClass('ui-icon-minus').addClass('ui-icon-plus'); } } } } change(_node, _widget, _value) { var valid = super.change.apply(this, arguments); if (!this.input) return valid; var selected = this.input.siblings().find('a.chzn-single'); var val = _value && _value.selected ? _value.selected : this.input.val(); switch (this.getType()) { case 'select-country': if (selected && selected.length == 1 && val) { selected.removeClass (function (index, className) { return (className.match (/(^|\s)flag-\S+/g) || []).join(' '); }); selected.find('span.img').remove(); selected.prepend(''); selected.addClass('et2_country-select flag-'+ val.toLowerCase()); } else if(selected) { selected.removeClass('et2_country-select'); } break; } return valid; } /** * Overridden from parent to make sure tooltip handler is bound to the correct element * if tags is on. */ getTooltipElement(): HTMLElement { if(this.input && (this.options.tags || this.options.search)) { return jQuery(this.input.siblings()).get(0); } return this.getDOMNode(this); } /** * Add an option to regular drop-down select * * @param {string} _value value attribute of option * @param {string} _label label of option * @param {string} _title title attribute of option * @param {node} dom_element parent of new option * @param {string} _class specify classes of option */ _appendOptionElement(_value, _label, _title?, dom_element?, _class?) { if(_value == "" && (_label == null || _label == "")) { return; // empty_label is added in set_select_options anyway, ignoring it here to not add it twice } if(this.input == null) { return this._appendMultiOption(_value, _label, _title, dom_element); } var option = jQuery(document.createElement("option")) .attr("value", _value) .text(_label+""); option.addClass(_class); if (this.options.tags) { switch (this.getType()) { case 'select-cat': option.addClass('cat_'+_value); break; case 'select-country': // jQuery(document.createElement("span")).addClass('et2_country-select').appenTo(option); option.addClass('et2_country-select flag-'+_value.toLowerCase()); break; } if (this.options.value_class != '') option.addClass(this.options.value_class+_value); } if (typeof _title != "undefined" && _title) { option.attr("title", _title); } if(_label == this.options.empty_label || this.options.empty_label == "" && _value === "") { // Make sure empty / all option is first option.prependTo(this.input); } else { option.appendTo(dom_element || this.input); } } /** * Append a value to multi-select * * @param {string} _value value attribute of option * @param {string} _label label of option * @param {string} _title title attribute of option * @param {node} dom_element parent of new option */ _appendMultiOption(_value, _label, _title, dom_element?) { var option_data = null; if(typeof _label == "object") { option_data = _label; _label = option_data.label; } // Already in header if(_label == this.options.empty_label) return; var opt_id = this.dom_id + "_opt_" + _value; var label = jQuery(document.createElement("label")) .attr("for", opt_id) .hover( function() {jQuery(this).addClass("ui-state-hover");}, function() {jQuery(this).removeClass("ui-state-hover");} ); var option = jQuery(document.createElement("input")) .attr("type", "checkbox") .attr("id",opt_id) .attr("value", _value) .appendTo(label); if(typeof _title !== "undefined") { option.attr("title",_title); } // Some special stuff for categories if(option_data ) { if(option_data.icon) { var img = this.egw().image(option_data.icon); jQuery(document.createElement(img ? "img" : "div")) .attr("src", img) .addClass('cat_icon cat_' + _value) .appendTo(label); } if(option_data.color) { label.css("background-color",option_data.color) .addClass('cat_' + _value); } } //added tooltip to multiselect if(typeof _title == "undefined") { _title = _label; } label.append(jQuery(""+_label+"")); var li = jQuery(document.createElement("li")).append(label); if (this.options.value_class !='') li.addClass(this.options.value_class+_value); li.appendTo(dom_element || this.multiOptions); } /** * Create a regular drop-down select box */ createInputWidget() { // Create the base input widget this.input = jQuery(document.createElement("select")) .addClass("et2_selectbox") .attr("size", this.options.rows); this.setDOMNode(this.input[0]); // Add the empty label if(this.options.empty_label) { this._appendOptionElement("", this.options.empty_label); } // Set multiple if(this.options.multiple) { this.input.attr("multiple", "multiple"); } } /** * Create a list of checkboxes */ createMultiSelect() { var node = jQuery(document.createElement("div")) .addClass("et2_selectbox"); var header = jQuery(document.createElement("div")) .addClass("ui-widget-header ui-helper-clearfix") .appendTo(node); var controls = jQuery(document.createElement("ul")) .addClass('ui-helper-reset') .appendTo(header); jQuery(document.createElement("span")) .text(this.options.empty_label) .addClass("ui-multiselect-header") .appendTo(header); // Set up for options to be added later var options = this.multiOptions = jQuery(document.createElement("ul")); this.multiOptions.addClass("ui-multiselect-checkboxes ui-helper-reset") .css("height", 1.9*this.options.rows + "em") .appendTo(node); if(this.options.rows >= 5) { // Check / uncheck all var header_controls = { check: { icon_class: 'ui-icon-check', label: this.egw().lang('Check all'), click(e) { var all_off = false; jQuery("input[type='checkbox']",e.data).each(function() { if(!jQuery(this).prop("checked")) all_off = true; }); jQuery("input[type='checkbox']",e.data).prop("checked", all_off); } } }; for(var key in header_controls) { jQuery(document.createElement("li")) .addClass("et2_clickable") .click(options, header_controls[key].click) .attr("title", header_controls[key].label) .append('') .appendTo(controls); } } this.setDOMNode(node[0]); } doLoadingFinished() { super.doLoadingFinished(); this.set_tags(this.options.tags, this.options.width); // Reset dirty again here. super.doLoadingFinished() does it too, but set_tags() & others // change things. Moving set_tags() before super.doLoadingFinished() breaks tag widgets this.resetDirty(); return true; } loadFromXML(_node) { // Handle special case where legacy option for empty label is used (conflicts with rows), and rows is set as an attribute var legacy = _node.getAttribute("options"); if(legacy) { var legacy = legacy.split(","); if(legacy.length && isNaN(legacy[0])) { this.options.empty_label = legacy[0]; } } // Read the option-tags var options = et2_directChildrenByTagName(_node, "option"); if(options.length) { // Break reference to content manager, we don't want to add to it this.options.select_options = jQuery.extend([], this.options.select_options); } var egw = this.egw(); for (var i = 0; i < options.length; i++) { this.options.select_options.push({ value: et2_readAttrWithDefault(options[i], "value", options[i].textContent), // allow options to contain multiple translated sub-strings eg: {Firstname}.{Lastname} "label": options[i].textContent.replace(/{([^}]+)}/g, function(str,p1) { return egw.lang(p1); }), "title": et2_readAttrWithDefault(options[i], "title", "") }); } this.set_select_options(this.options.select_options); } /** * Regular expression, to check string-value contains multiple comma-separated values */ readonly _is_multiple_regexp = /^[,0-9A-Za-z/_ -]+$/; /** * Regular expression and replace value for escaping values in jQuery selectors used to find options */ readonly _escape_value_replace = /\\/g; readonly _escape_value_with = '\\\\'; /** * Find an option by it's value * * Taking care of escaping values correctly eg. EGroupware\Api\Mail\Smtp using above regular expression * * @param {string} _value * @return {array} */ find_option(_value) { return jQuery("option[value='"+(typeof _value === 'string' ? _value.replace(this._escape_value_replace, this._escape_value_with) : _value)+"']", this.input); } /** * Set value * * @param {string|number} _value * @param {boolean} _dont_try_set_options true: if _value is not in options, use "" instead of calling set_select_options * (which would go into an infinit loop) */ // @ts-ignore for 2nd parameter set_value(_value, _dont_try_set_options?) { if (typeof _value == "number") _value = ""+_value; // convert to string for consitent matching if(typeof _value == "string" && (this.options.multiple || this.options.expand_multiple_rows) && _value.match(this._is_multiple_regexp) !== null) { _value = _value.split(','); } if(this.input !== null && this.options.select_options && ( !jQuery.isEmptyObject(this.options.select_options) || this.options.select_options.length > 0 ) && this.input.children().length == 0) { // No options set yet this.set_select_options(this.options.select_options); } // select-cat set/unset right cat_ color for selected value if ((this.getType() == 'select-cat' || this.options.value_class) && this.options.tags) { var chosen = this.input.next(); var prefix_c = this.options.value_class ? this.options.value_class : 'cat_'; this.input.removeClass(prefix_c+this._oldValue); this.input.addClass(prefix_c+this.value); if (chosen.length > 0) { chosen.removeClass(prefix_c+this._oldValue); chosen.addClass(prefix_c+this.value); } } if (this.getType() == 'select-country' && this.options.tags) { var selected = this.input.siblings().find('a.chzn-single'); if (selected && selected.length == 1 && _value) { selected.removeClass (function (index, className) { return (className.match (/(^|\s)flag-\S+/g) || []).join(' '); }); selected.find('span.img').remove(); selected.prepend(''); selected.addClass('et2_country-select flag-'+ _value.toLowerCase()); } } if(this.getType() == "select-bitwise" && _value && !isNaN(_value) && this.options.select_options) { var new_value = []; for(var index in this.options.select_options) { var right = this.options.select_options[index].value; if(!!(_value & right)) { new_value.push(right); } } _value = new_value; } this._oldValue = this.value; if(this.input !== null && (this.options.tags || this.options.search)) { // Value must be a real Array, not an object this.input.val(typeof _value == 'object' && _value != null ? jQuery.map(_value,function(value,index){return [value];}) : _value); this.input.trigger("liszt:updated"); var self = this; if (this.getType() == 'listbox' && this.options.value_class != '') { var chosen = this.input.next(); chosen.find('.search-choice-close').each(function(i,v){ // @ts-ignore jQuery(v).parent().addClass(self.options.value_class + self.options.select_options[v.rel]['value']); }); } this.value = _value; return; } if(this.input == null) { return this.set_multi_value(_value); } // Auto-expand multiple if not yet turned on, and value has multiple if(this.options.expand_multiple_rows && !this.options.multiple && jQuery.isArray(_value) && _value.length > 1) { this.set_multiple(true, this.options.expand_multiple_rows); } jQuery("option",this.input).prop("selected", false); if (typeof _value == "object") { for(var i in _value) { this.find_option(_value[i]).prop("selected", true); } } else { if(_value && this.find_option(_value).prop("selected", true).length == 0) { if(this.options.select_options[_value] || this.options.select_options.filter && this.options.select_options.filter(function(value) {return value == _value;}) && !_dont_try_set_options) { // Options not set yet? Do that now, which will try again. return this.set_select_options(this.options.select_options); } else if (_dont_try_set_options) { this.value = ""; } else if (jQuery.isEmptyObject(this.options.select_options)) { this.egw().debug("warn", "Can't set value to '%s', widget has no options set",_value, this); } else { var debug_value = _value; if(debug_value === null) debug_value == 'NULL'; this.egw().debug("warn", "Tried to set value '%s' that isn't an option", debug_value, this); } return; } } this.value = _value; if(this.isAttached() && this._oldValue !== et2_no_init && this._oldValue !== _value) { this.input.change(); } } /** * Find an option by it's value * * Taking care of escaping values correctly eg. EGroupware\Api\Mail\Smtp * * @param {string} _value * @return {array} */ find_multi_option(_value) { return jQuery("input[value='"+ (typeof _value === 'string' ? _value.replace(this._escape_value_replace, this._escape_value_with) : _value)+ "']", this.multiOptions ); } set_multi_value(_value) { jQuery("input",this.multiOptions).prop("checked", false); if (typeof _value == "object") { for(var i in _value) { this.find_multi_option(_value[i]).prop("checked", true); } } else { if(this.find_multi_option(_value).prop("checked", true).length == 0) { var debug_value = _value; if(debug_value === null) debug_value == 'NULL'; this.egw().debug("warn", "Tried to set value '%s' that isn't an option", debug_value, this); } } // Sort selected to the top if(this.selected_first) { this.multiOptions.find("li:has(input:checked)").prependTo(this.multiOptions); } this.value = _value; } /** * Method to check all options of a multi-select, if not all are selected, or none if all where selected * * @todo: add an attribute to automatic add a button calling this method */ select_all_toggle() { var all = jQuery("input",this.multiOptions); all.prop("checked", jQuery("input:checked",this.multiOptions).length == all.length ? false : true); } /** * Add a button to toggle between single select and multi select. * * @param {number} _rows How many rows for multi-select */ set_expand_multiple_rows(_rows) { this.options.expand_multiple_rows = _rows; var surroundings = this.getSurroundings(); if(_rows <= 1 && this.expand_button ) { // Remove surroundings.removeDOMNode(this.expand_button.get(0)); } else { if (!this.expand_button) { var button_id = this.getInstanceManager().uniqueId+'_'+this.id.replace(/\./g, '-') + "_expand"; this.expand_button = jQuery("