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