/*! * Ajax Bootstrap Select * * Extends existing [Bootstrap Select] implementations by adding the ability to search via AJAX requests as you type. Originally for CROSCON. * * @version 1.4.1 * @author Adam Heim - https://github.com/truckingsim * @link https://github.com/truckingsim/Ajax-Bootstrap-Select * @copyright 2017 Adam Heim * @license Released under the MIT license. * * Contributors: * Mark Carver - https://github.com/markcarver * * Last build: 2017-07-21 1:08:54 PM GMT-0400 */ !(function ($, window) { /** * @class AjaxBootstrapSelect * * @param {jQuery|HTMLElement} element * The select element this plugin is to affect. * @param {Object} [options={}] * The options used to affect the desired functionality of this plugin. * * @return {AjaxBootstrapSelect|null} * A new instance of this class or null if unable to instantiate. */ var AjaxBootstrapSelect = function (element, options) { var i, l, plugin = this; options = options || {}; /** * The select element this plugin is being attached to. * @type {jQuery} */ this.$element = $(element); /** * The merged default and passed options. * @type {Object} */ this.options = $.extend(true, {}, $.fn.ajaxSelectPicker.defaults, options); /** * Used for logging error messages. * @type {Number} */ this.LOG_ERROR = 1; /** * Used for logging warning messages. * @type {Number} */ this.LOG_WARNING = 2; /** * Used for logging informational messages. * @type {Number} */ this.LOG_INFO = 3; /** * Used for logging debug messages. * @type {Number} */ this.LOG_DEBUG = 4; /** * The jqXHR object of the last request, false if there was none. * @type {jqXHR|Boolean} */ this.lastRequest = false; /** * The previous query that was requested. * @type {String} */ this.previousQuery = ''; /** * The current query being requested. * @type {String} */ this.query = ''; /** * The jqXHR object of the current request, false if there is none. * @type {jqXHR|Boolean} */ this.request = false; // Maps deprecated options to new ones between releases. var deprecatedOptionsMap = [ // @todo Remove these options in next minor release. { from: 'ajaxResultsPreHook', to: 'preprocessData' }, { from: 'ajaxSearchUrl', to: { ajax: { url: '{{{value}}}' } } }, { from: 'ajaxOptions', to: 'ajax' }, { from: 'debug', to: function (map) { var _options = {}; _options.log = Boolean(plugin.options[map.from]) ? plugin.LOG_DEBUG : 0; plugin.options = $.extend(true, {}, plugin.options, _options); delete plugin.options[map.from]; plugin.log(plugin.LOG_WARNING, 'Deprecated option "' + map.from + '". Update code to use:', _options); } }, { from: 'mixWithCurrents', to: 'preserveSelected' }, { from: 'placeHolderOption', to: { locale: { emptyTitle: '{{{value}}}' } } } ]; if (deprecatedOptionsMap.length) { $.map(deprecatedOptionsMap, function (map) { // Depreciated option detected. if (plugin.options[map.from]) { // Map with an object. Use "{{{value}}}" anywhere in the object to // replace it with the passed value. if ($.isPlainObject(map.to)) { plugin.replaceValue(map.to, '{{{value}}}', plugin.options[map.from]); plugin.options = $.extend(true, {}, plugin.options, map.to); plugin.log(plugin.LOG_WARNING, 'Deprecated option "' + map.from + '". Update code to use:', map.to); delete plugin.options[map.from]; } // Map with a function. Functions are silos. They are responsible // for deleting the original option and displaying debug info. else if ($.isFunction(map.to)) { map.to.apply(plugin, [map]); } // Map normally. else { var _options = {}; _options[map.to] = plugin.options[map.from]; plugin.options = $.extend(true, {}, plugin.options, _options); plugin.log(plugin.LOG_WARNING, 'Deprecated option "' + map.from + '". Update code to use:', _options); delete plugin.options[map.from]; } } }); } // Retrieve the element data attributes. var data = this.$element.data(); // @todo Deprecated. Remove this in the next minor release. if (data['searchUrl']) { plugin.log(plugin.LOG_WARNING, 'Deprecated attribute name: "data-search-url". Update markup to use: \' data-abs-ajax-url="' + data['searchUrl'] + '" \''); this.options.ajax.url = data['searchUrl']; } // Helper functions. var matchToLowerCase = function (match, p1) { return p1.toLowerCase(); }; var expandObject = function (keys, value, obj) { var k = [].concat(keys), l = k.length, o = obj || {}; if (l) { var key = k.shift(); o[key] = expandObject(k, value, o[key]); } return l ? o : value; }; // Filter out only the data attributes prefixed with 'data-abs-'. var dataKeys = Object.keys(data).filter(/./.test.bind(new RegExp('^abs[A-Z]'))); // Map the data attributes to their respective place in the options object. if (dataKeys.length) { // Object containing the data attribute options. var dataOptions = {}; var flattenedOptions = ['locale']; for (i = 0, l = dataKeys.length; i < l; i++) { var name = dataKeys[i].replace(/^abs([A-Z])/, matchToLowerCase).replace(/([A-Z])/g, '-$1').toLowerCase(); var keys = name.split('-'); // Certain options should be flattened to a single object // and not fully expanded (such as Locale). if (keys[0] && keys.length > 1 && flattenedOptions.indexOf(keys[0]) !== -1) { var newKeys = [keys.shift()]; var property = ''; // Combine the remaining keys as a single property. for (var ii = 0; ii < keys.length; ii++) { property += (ii === 0 ? keys[ii] : keys[ii].charAt(0).toUpperCase() + keys[ii].slice(1)); } newKeys.push(property); keys = newKeys; } this.log(this.LOG_DEBUG, 'Processing data attribute "data-abs-' + name + '":', data[dataKeys[i]]); expandObject(keys, data[dataKeys[i]], dataOptions); } this.options = $.extend(true, {}, this.options, dataOptions); this.log(this.LOG_DEBUG, 'Merged in the data attribute options: ', dataOptions, this.options); } /** * Reference to the selectpicker instance. * @type {Selectpicker} */ this.selectpicker = data['selectpicker']; if (!this.selectpicker) { this.log(this.LOG_ERROR, 'Cannot instantiate an AjaxBootstrapSelect instance without selectpicker first being initialized!'); return null; } // Ensure there is a URL. if (!this.options.ajax.url) { this.log(this.LOG_ERROR, 'Option "ajax.url" must be set! Options:', this.options); return null; } // Initialize the locale strings. this.locale = $.extend(true, {}, $.fn.ajaxSelectPicker.locale); // Ensure the langCode is properly set. this.options.langCode = this.options.langCode || window.navigator.userLanguage || window.navigator.language || 'en'; if (!this.locale[this.options.langCode]) { var langCode = this.options.langCode; // Reset the language code. this.options.langCode = 'en'; // Check for both the two and four character language codes, using // the later first. var langCodeArray = langCode.split('-'); for (i = 0, l = langCodeArray.length; i < l; i++) { var code = langCodeArray.join('-'); if (code.length && this.locale[code]) { this.options.langCode = code; break; } langCodeArray.pop(); } this.log(this.LOG_WARNING, 'Unknown langCode option: "' + langCode + '". Using the following langCode instead: "' + this.options.langCode + '".'); } // Allow options to override locale specific strings. this.locale[this.options.langCode] = $.extend(true, {}, this.locale[this.options.langCode], this.options.locale); /** * The select list. * @type {AjaxBootstrapSelectList} */ this.list = new window.AjaxBootstrapSelectList(this); this.list.refresh(); // We need for selectpicker to be attached first. Putting the init in a // setTimeout is the easiest way to ensure this. // @todo Figure out a better way to do this (hopefully listen for an event). setTimeout(function () { plugin.init(); }, 500); }; /** * Initializes this plugin on a selectpicker instance. */ AjaxBootstrapSelect.prototype.init = function () { var requestDelayTimer, plugin = this; // Rebind select/deselect to process preserved selections. if (this.options.preserveSelected) { this.selectpicker.$menu.off('click', '.actions-btn').on('click', '.actions-btn', function (e) { if (plugin.selectpicker.options.liveSearch) { plugin.selectpicker.$searchbox.focus(); } else { plugin.selectpicker.$button.focus(); } e.preventDefault(); e.stopPropagation(); if ($(this).is('.bs-select-all')) { if (plugin.selectpicker.$lis === null) { plugin.selectpicker.$lis = plugin.selectpicker.$menu.find('li'); } plugin.$element.find('option:enabled').prop('selected', true); $(plugin.selectpicker.$lis).not('.disabled').addClass('selected'); plugin.selectpicker.render(); } else { if (plugin.selectpicker.$lis === null) { plugin.selectpicker.$lis = plugin.selectpicker.$menu.find('li'); } plugin.$element.find('option:enabled').prop('selected', false); $(plugin.selectpicker.$lis).not('.disabled').removeClass('selected'); plugin.selectpicker.render(); } plugin.selectpicker.$element.change(); }); } // Add placeholder text to the search input. this.selectpicker.$searchbox .attr('placeholder', this.t('searchPlaceholder')) // Remove selectpicker events on the search input. .off('input propertychange'); // Bind this plugin's event. this.selectpicker.$searchbox.on(this.options.bindEvent, function (e) { var query = plugin.selectpicker.$searchbox.val(); plugin.log(plugin.LOG_DEBUG, 'Bind event fired: "' + plugin.options.bindEvent + '", keyCode:', e.keyCode, e); // Dynamically ignore the "enter" key (13) so it doesn't // create an additional request if the "cache" option has // been disabled. if (!plugin.options.cache) { plugin.options.ignoredKeys[13] = 'enter'; } // Don't process ignored keys. if (plugin.options.ignoredKeys[e.keyCode]) { plugin.log(plugin.LOG_DEBUG, 'Key ignored.'); return; } // Don't process if below minimum query length if (query.length < plugin.options.minLength) { plugin.list.setStatus(plugin.t('statusTooShort')); return; } // Clear out any existing timer. clearTimeout(requestDelayTimer); // Process empty search value. if (!query.length) { // Clear the select list. if (plugin.options.clearOnEmpty) { plugin.list.destroy(); } // Don't invoke a request. if (!plugin.options.emptyRequest) { return; } } // Store the query. plugin.previousQuery = plugin.query; plugin.query = query; // Return the cached results, if any. if (plugin.options.cache && e.keyCode !== 13) { var cache = plugin.list.cacheGet(plugin.query); if (cache) { plugin.list.setStatus(!cache.length ? plugin.t('statusNoResults') : ''); plugin.list.replaceOptions(cache); plugin.log(plugin.LOG_INFO, 'Rebuilt options from cached data.'); return; } } requestDelayTimer = setTimeout(function () { // Abort any previous requests. if (plugin.lastRequest && plugin.lastRequest.jqXHR && $.isFunction(plugin.lastRequest.jqXHR.abort)) { plugin.lastRequest.jqXHR.abort(); } // Create a new request. plugin.request = new window.AjaxBootstrapSelectRequest(plugin); // Store as the previous request once finished. plugin.request.jqXHR.always(function () { plugin.lastRequest = plugin.request; plugin.request = false; }); }, plugin.options.requestDelay || 300); }); }; /** * Wrapper function for logging messages to window.console. * * @param {Number} type * The type of message to log. Must be one of: * * - AjaxBootstrapSelect.LOG_ERROR * - AjaxBootstrapSelect.LOG_WARNING * - AjaxBootstrapSelect.LOG_INFO * - AjaxBootstrapSelect.LOG_DEBUG * * @param {String|Object|*...} message * The message(s) to log. Multiple arguments can be passed. * * @return {void} */ AjaxBootstrapSelect.prototype.log = function (type, message) { if (window.console && this.options.log) { // Ensure the logging level is always an integer. if (typeof this.options.log !== 'number') { if (typeof this.options.log === 'string') { this.options.log = this.options.log.toLowerCase(); } switch (this.options.log) { case true: case 'debug': this.options.log = this.LOG_DEBUG; break; case 'info': this.options.log = this.LOG_INFO; break; case 'warn': case 'warning': this.options.log = this.LOG_WARNING; break; default: case false: case 'error': this.options.log = this.LOG_ERROR; break; } } if (type <= this.options.log) { var args = [].slice.apply(arguments, [2]); // Determine the correct console method to use. switch (type) { case this.LOG_DEBUG: type = 'debug'; break; case this.LOG_INFO: type = 'info'; break; case this.LOG_WARNING: type = 'warn'; break; default: case this.LOG_ERROR: type = 'error'; break; } // Prefix the message. var prefix = '[' + type.toUpperCase() + '] AjaxBootstrapSelect:'; if (typeof message === 'string') { args.unshift(prefix + ' ' + message); } else { args.unshift(message); args.unshift(prefix); } // Display the message(s). window.console[type].apply(window.console, args); } } }; /** * Replaces an old value in an object or array with a new value. * * @param {Object|Array} obj * The object (or array) to iterate over. * @param {*} needle * The value to search for. * @param {*} value * The value to replace with. * @param {Object} [options] * Additional options for restricting replacement: * - recursive: {boolean} Whether or not to iterate over the entire * object or array, defaults to true. * - depth: {int} The number of level this method is to search * down into child elements, defaults to false (no limit). * - limit: {int} The number of times a replacement should happen, * defaults to false (no limit). * * @return {void} */ AjaxBootstrapSelect.prototype.replaceValue = function (obj, needle, value, options) { var plugin = this; options = $.extend({ recursive: true, depth: false, limit: false }, options); // The use of $.each() opposed to native loops here is beneficial // since obj can be either an array or an object. This helps reduce // the amount of duplicate code needed. $.each(obj, function (k, v) { if (options.limit !== false && typeof options.limit === 'number' && options.limit <= 0) { return false; } if ($.isArray(obj[k]) || $.isPlainObject(obj[k])) { if ((options.recursive && options.depth === false) || (options.recursive && typeof options.depth === 'number' && options.depth > 0)) { plugin.replaceValue(obj[k], needle, value, options); } } else { if (v === needle) { if (options.limit !== false && typeof options.limit === 'number') { options.limit--; } obj[k] = value; } } }); }; /** * Generates a translated {@link $.fn.ajaxSelectPicker.locale locale string} for a given locale key. * * @param {String} key * The translation key to use. * @param {String} [langCode] * Overrides the currently set {@link $.fn.ajaxSelectPicker.defaults#langCode langCode} option. * * @return * The translated string. */ AjaxBootstrapSelect.prototype.t = function (key, langCode) { langCode = langCode || this.options.langCode; if (this.locale[langCode] && this.locale[langCode].hasOwnProperty(key)) { return this.locale[langCode][key]; } this.log(this.LOG_WARNING, 'Unknown translation key:', key); return key; }; /** * Use an existing definition in the Window object or create a new one. * * Note: This must be the last statement of this file. * * @type {AjaxBootstrapSelect} * @ignore */ window.AjaxBootstrapSelect = window.AjaxBootstrapSelect || AjaxBootstrapSelect; /** * @class AjaxBootstrapSelectList * Maintains the select options and selectpicker menu. * * @param {AjaxBootstrapSelect} plugin * The plugin instance. * * @return {AjaxBootstrapSelectList} * A new instance of this class. */ var AjaxBootstrapSelectList = function (plugin) { var that = this; /** * DOM element used for updating the status of requests and list counts. * @type {jQuery} */ this.$status = $(plugin.options.templates.status).hide().appendTo(plugin.selectpicker.$menu); var statusInitialized = plugin.t('statusInitialized'); if (statusInitialized && statusInitialized.length) { this.setStatus(statusInitialized); } /** * Container for cached data. * @type {Object} */ this.cache = {}; /** * Reference the plugin for internal use. * @type {AjaxBootstrapSelect} */ this.plugin = plugin; /** * Container for current selections. * @type {Array} */ this.selected = []; /** * Containers for previous titles. */ this.title = null; this.selectedTextFormat = plugin.selectpicker.options.selectedTextFormat; // Save initial options var initial_options = []; plugin.$element.find('option').each(function() { var $option = $(this); var value = $option.attr('value'); initial_options.push({ value: value, text: $option.text(), 'class': $option.attr('class') || '', data: $option.data() || {}, preserved: plugin.options.preserveSelected, selected: !!$option.attr('selected') }); }); this.cacheSet(/*query=*/'', initial_options); // Preserve selected options. if (plugin.options.preserveSelected) { that.selected = initial_options; plugin.$element.on('change.abs.preserveSelected', function (e) { var $selected = plugin.$element.find(':selected'); that.selected = []; // If select does not have multiple selection, ensure that only the // last selected option is preserved. if (!plugin.selectpicker.multiple) { $selected = $selected.last(); } $selected.each(function () { var $option = $(this); var value = $option.attr('value'); that.selected.push({ value: value, text: $option.text(), 'class': $option.attr('class') || '', data: $option.data() || {}, preserved: true, selected: true }); }); that.replaceOptions(that.cacheGet(that.plugin.query)); }); } }; /** * Builds the options for placing into the element. * * @param {Array} data * The data to use when building options for the select list. Each * array item must be an Object structured as follows: * - {int|string} value: Required, a unique value identifying the * item. Optionally not required if divider is passed instead. * - {boolean} [divider]: Optional, if passed all other values are * ignored and this item becomes a divider. * - {string} [text]: Optional, the text to display for the item. * If none is provided, the value will be used. * - {String} [class]: Optional, the classes to apply to the option. * - {boolean} [disabled]: Optional, flag that determines if the * option is disabled. * - {boolean} [selected]: Optional, flag that determines if the * option is selected. Useful only for select lists that have the * "multiple" attribute. If it is a single select list, each item * that passes this property as true will void the previous one. * - {Object} [data]: Optional, the additional data attributes to * attach to the option element. These are processed by the * bootstrap-select plugin. * * @return {String} * HTML containing the