define([
"jquery",
"underscore",
"backbone",
"semanticUItransition",
"text!" + MetacatUI.root + "/components/semanticUI/transition.min.css",
"semanticUIdropdown",
"text!" + MetacatUI.root + "/components/semanticUI/dropdown.min.css",
"text!templates/selectUI/searchableSelect.html",
], function (
$,
_,
Backbone,
Transition,
TransitionCSS,
Dropdown,
DropdownCSS,
Template,
) {
/**
* @class SearchableSelectView
* @classdesc A select interface that allows the user to search from within
* the options, and optionally select multiple items. Also allows the items
* to be grouped, and to display an icon or image for each item.
* @classcategory Views/SearchSelect
* @extends Backbone.View
* @constructor
* @since 2.14.0
* @screenshot views/searchSelect/SearchableSelectView.png
*/
return Backbone.View.extend(
/** @lends SearchableSelectView.prototype */
{
/**
* The type of View this is
* @type {string}
*/
type: "SearchableSelect",
/**
* The HTML class names for this view element
* @type {string}
*/
className: "searchable-select",
/**
* Text to show in the input field before any value has been entered
* @type {string}
*/
placeholderText: "Search for or select a value",
/**
* Label for the input element
* @type {string}
*/
inputLabel: "Select a value",
/**
* Whether to allow users to select more than one value
* @type {boolean}
*/
allowMulti: true,
/**
* Setting to true gives users the ability to add their own options that
* are not listed in this.options. This can work with either single
* or multiple search select dropdowns
* @type {boolean}
*/
allowAdditions: false,
/**
* Whether the dropdown value can be cleared by the user after being
* selected.
* @type {boolean}
*/
clearable: true,
/**
* When items are grouped within categories, this attribute determines how to display the items
* within each category.
* @type {string}
* @example
* // display the items in a traditional, non-interactive list below category titles
* "list"
* @example
* // initially show only a list of category titles, and popout
* // a submenu on the left or right when the user hovers over
* // or touches a category (can lead to the sub-menu being hidden
* // on mobile devices if the element is wide)
* "popout"
* @example
* // initially show only a list of category titles, and expand
* // the list of items below each category when a user clicks
* // on the category title, much like an "accordion" element.
* "accordion"
* @default "list"
*/
submenuStyle: "list",
/**
* Set to false to always display category headers in the dropdown,
* even if there are no results in that category when a user is searching.
* @type {boolean}
*/
hideEmptyCategoriesOnSearch: true,
/**
* The maximum width of images used for each option, in pixels
* @type {number}
*/
imageWidth: 30,
/**
* The maximum height of images used for each option, in pixels
* @type {number}
*/
imageHeight: 30,
/**
* For select inputs where multiple values are allowed
* ({@link SearchableSelectView#allowMulti} is true), optional text to insert
* between labels. Separator text is useful for indicating operators in filter
* fields or values.
* @type {string}
* @since 2.15.0
*/
separatorText: "",
/**
* For select inputs where multiple values are allowed
* ({@link SearchableSelectView#allowMulti} is true), a list of
* {@link SearchableSelectView#separatorText} options. If a list is provided here
* (AND a value is provided for the {@link SearchableSelectView#separatorText}
* option), then a user can click on the separator text between two values to
* change the text to the next string in this list. If separatorTextOptions is
* false (or if there is no separatorText value), then changing the separator text
* is not possible. This view will trigger a "separatorChanged" event when the
* separator is updated.
* @type {string[]}
* @since 2.17.0
*/
separatorTextOptions: ["AND", "OR"],
/**
* The HTML class name to add to the separator elements that are created for this
* view.
* @type {string}
* @since 2.15.0
*/
separatorClass: "separator",
/**
* An additional HTML class to add to separator elements on hover when a user can
* click that element to switch the text.
* @type {string}
* @since 2.17.0
*/
changeableSeparatorClass: "changeable-separator",
/**
* For separators that are changeable (see
* {@link SearchableSelectView#separatorTextOptions}), optional tooltip text to
* show when a user hovers over a separator element.
* @type {string}
* @since 2.17.0
*/
changeableSeparatorTooltip: "Click to switch the operator",
/**
* The list of options that a user can select from in the dropdown menu. For
* un-categorized options, provide an array of objects, where each object is a
* single option. To create category headings, provide an object containing named
* objects, where the key for each object is the category title to display, and
* the value of each object comprises the option properties.
* @name SearchableSelectView#options
* @type {Object[]|Object}
* @property {string} icon - The name of a Font Awesome 3.2.1 icon to display to
* the left of the label (e.g. "lemon", "heart")
* @property {string} image - The complete path to an image to use instead of an
* icon. If both icon and image are provided, the icon will be used.
* @property {string} label - The label to show for the option
* @property {string} description - A description of the option, displayed as a
* tooltip when the user hovers over the label
* @property {string} value - If the value differs from the label, the value to
* return when this option is selected (otherwise label is returned)
* @example
* [
* {
* icon: "",
* image: "https://www.dataone.org/uploads/member_node_logos/bcodmo_hu707c109c683d6da57b432522b4add783_33081_300x0_resize_box_2.png",
* label: "BCO",
* description: "The The Biological and Chemical Oceanography Data Management Office (BCO-DMO) serve data from research projects funded by the Biological and Chemical Oceanography Sections and the Division of Polar Programs Antarctic Organisms & Ecosystems Program at the U.S. National Science Foundation.",
* value: "urn:node:BCODMO"
* },
* {
* icon: "",
* image: "https://www.dataone.org/uploads/member_node_logos/arctic.png",
* label: "ADC",
* description: "The US National Science Foundation Arctic Data Center operates as the primary repository supporting the NSF Arctic community for data preservation and access.",
* value: "urn:node:ARCTIC"
* },
* ]
* @example
* {
* "category A": [
* {
* icon: "flag",
* label: "Flag",
* description: "This is a flag"
* },
* {
* icon: "gift",
* label: "Gift",
* description: "This is a gift"
* }
* ],
* "category B": [
* {
* icon: "pencil",
* label: "Pencil",
* description: "This is a pencil"
* },
* {
* icon: "hospital",
* label: "Hospital",
* description: "This is a hospital"
* }
* ]
* }
*/
options: [],
/**
* The values that a user has selected. If provided to the view upon
* initialization, the values will be pre-selected. Selected values must
* exist as a label in the options {@link SearchableSelect#options}
* @type {string[]}
*/
selected: [],
/**
* Can be set to an object to specify API settings for retrieving remote selection
* menu content from an API endpoint. Details of what can be set here are
* specified by the Semantic-UI / Fomantic-UI package. Set to false if not
* retrieving remote content.
* @type {Object|booealn}
* @default false
* @since 2.15.0
* @see {@link https://fomantic-ui.com/modules/dropdown.html#remote-settings}
* @see {@link https://fomantic-ui.com/behaviors/api.html#/settings}
*/
apiSettings: false,
/**
* The primary HTML template for this view. The template follows the
* structure specified for the semanticUI dropdown module, see:
* https://semantic-ui.com/modules/dropdown.html#/definition
* @type {Underscore.template}
*/
template: _.template(Template),
/**
* Creates a new SearchableSelectView
* @param {Object} options - A literal object with options to pass to the view
*/
initialize: function (options) {
try {
// Add CSS required for this view
MetacatUI.appModel.addCSS(TransitionCSS, "semanticUItransition");
MetacatUI.appModel.addCSS(DropdownCSS, "semanticUIdropdown");
// If pre-selected values that are passed to this view are also attached to a
// model (e.g. when they were passed to this view as {selected:
// parentView.model.get("values")}), then it's important that we use a clone
// instead. Otherwise this view may silently update the model, and important
// events may not be triggered.
if (options.selected) {
options.selected = _.clone(options.selected);
}
// If pre-selected values that are passed to this view are also attached to a
// model (e.g. when they were passed to this view as {selected:
// parentView.model.get("values")}), then it's important that we use a clone
// instead. Otherwise this view may silently update the model, and important
// events may not be triggered.
if (options.selected) {
options.selected = _.clone(options.selected);
}
// Get all the options and apply them to this view
if (typeof options == "object") {
var optionKeys = Object.keys(options);
_.each(
optionKeys,
function (key, i) {
this[key] = options[key];
},
this,
);
}
} catch (e) {
console.log(
"Failed to initialize a Searchable Select view, error message:",
e,
);
}
},
/**
* Render the view
*
* @return {SearchableSelect} Returns the view
*/
render: function () {
try {
var view = this;
if (view.apiSettings && !view.semanticAPILoaded) {
require([
MetacatUI.root + "/components/semanticUI/api.min.js",
], function (SemanticAPI) {
view.semanticAPILoaded = true;
view.render();
});
return;
}
// Render the template using the view attributes
this.$el.html(this.template(this));
// Start the dropdown in a disabled state.
// This allows us to pre-select values without triggering a change
// event.
this.disable();
this.showLoading();
// Initialize the dropdown interface
// For explanations of settings, see:
// https://semantic-ui.com/modules/dropdown.html#/settings
this.$selectUI = this.$el.find(".ui.dropdown").dropdown({
keys: {
// So that a user may enter search text using a comma
delimiter: false,
},
apiSettings: this.apiSettings,
fullTextSearch: true,
duration: 90,
forceSelection: false,
ignoreDiacritics: true,
clearable: view.clearable,
allowAdditions: view.allowAdditions,
hideAdditions: false,
allowReselection: true,
onRemove: function (removedValue) {
// Callback when a value is removed *for multi-select inputs only*
// Remove the value from the selected array
view.selected = view.selected.filter(function (value) {
return value !== removedValue;
});
},
onLabelCreate: function (value, text) {
// Callback when a label is created *for multi-select inputs only*
// Add the value to the selected array (but don't add twice). Do this in
// the onLabelCreate callback instead of in the onAdd callback because
// we would like to update the selected array before we create the
// separator element (below).
if (!view.selected.includes(value)) {
view.selected.push(value);
}
// Add a separator between labels if required.
var label = this;
if (view.separatorRequired.call(view)) {
// Create the separator element.
var separator = view.createSeparator.call(view);
if (separator) {
// Attach the separator to the label so that we can easily remove it
// when the label is removed.
label.data("separator", separator);
// Add it before the label element.
label = separator.add(label);
}
}
return label;
},
onLabelRemove(value) {
// Call back when a user deletes a label *for multi-select inputs only*
var label = this;
// Remove the separator before this label if there is one.
var sep = label.data("separator");
if (sep) {
sep.remove();
}
// If this is the first label in an input of at least two, then delete
// the separator directly *after* this label - The label that's second
// will become first, and should not have an separator before it.
var allLabels = view.$selectUI.find(".label");
if (allLabels.index(label) === 0) {
var separatorAfter = label.next("." + view.separatorClass);
if (separatorAfter) {
separatorAfter.remove();
}
}
},
onChange: function (values, text, $choice) {
// Callback when values change for any type of input.
// NOTE: The "values" argument is a string that contains all the
// selected values separated by commas. We updated the view.selected
// array with the onLabelCreate and onRemove callbacks instead of using
// the values argument passed to this function in order to allow commas
// within individual values. For example, if the user selected the value
// "x" and the value "y,z", the values string would be "x,y,z" and it
// would be difficult to see that two values were selected instead of
// three.
// Update values for single-select inputs (multi-select are updated
// using the onLabelCreate and onRemove callbacks)
if (!view.allowMulti) {
view.selected = [values];
}
// Trigger an event if items are selected after the UI has been rendered
// (It is set as disabled until fully rendered).
if (!$(this).hasClass("disabled")) {
var newValues = _.clone(view.selected);
view.trigger("changeSelection", newValues);
}
// Refresh the tooltips on the labels/text
// Ensure tooltips for labels are removed
$(".search-select-tooltip").remove();
// Add a tooltip for single select elements (.text) or multi-select
// elements (.label). Delay so that to give time for DOM elements to be
// added or removed.
setTimeout(function (params) {
var textEl = view.$selectUI.find(".text:not(.default),.label");
// Single select text element will not have the value attribute, add
// it so that we can find the matching description for the tooltip
if (!textEl.data("value") && !view.allowMulti) {
textEl.data("value", values);
}
if (textEl) {
textEl.each(function (i, el) {
view.addTooltip.call(view, el, "top");
});
}
}, 50);
},
});
view.$selectUI.data("view", view);
view.postRender();
return this;
} catch (e) {
console.log("Error rendering the search select, error message: ", e);
}
},
/**
* Change the options available in the dropdown menu and re-render.
* @param {SearchableSelectView#options} options - The new options
* @since 2.24.0
*/
updateOptions: function (options) {
this.options = options;
this.render();
},
/**
* Checks whether a separator should be created for the label that was just
* created, but not yet attached to the DOM
* @return {boolean} - Returns true if a separator should be created, false
* otherwise.
* @since 2.15.0
*/
separatorRequired: function () {
try {
if (
// Separators not required if only one selection is allowed
!this.allowMulti ||
// Need separator text to create a separator element
!this.separatorText ||
// Need the list of selected values to determine the value's position
!this.selected ||
// Separator is only required between two or more values
this.selected.length <= 1 ||
// Separator is only required after the first element has been added
this.$selectUI.find(".label").length === 0
) {
return false;
} else {
return true;
}
} catch (error) {
console.log(
"Error checking if a label in a searchable select input " +
"requires a separator. Assuming that it does not need one. Error details: " +
error,
);
return false;
}
},
/**
* Create the HTML for a separator element to insert between two labels. The
* view.separatorClass is added to the separator element.
* @return {JQuery} Returns the separator as a jQuery element
* @since 2.15.0
*/
createSeparator: function () {
try {
var view = this;
var separatorText = this.separatorText;
// Text is required to create a separator.
if (!separatorText) {
return null;
}
var separator = $("<span>" + separatorText + "</span>");
separator.addClass(this.separatorClass);
// Set a listener to change the text to one of the separatorText
// options on click, and to highlight all the separators when one is hovered
var separatorElHovered = false;
if (view.separatorTextOptions && view.separatorTextOptions.length) {
// Indicate that the separator is clickable
separator.css("cursor", "pointer");
// Make sure the listeners set below are only set once
separator.off("click mouseenter mouseout");
// Change all the separator text when one is clicked
separator.on("click", function () {
view.changeSeparator();
});
// Create the tooltip
if (view.changeableSeparatorTooltip) {
$(separator).tooltip("destroy");
$(separator).tooltip({
title: view.changeableSeparatorTooltip,
trigger: "manual",
});
}
// Highlight all of the separator elements when one is hovered
separator.on("mouseenter", function () {
var separatorEls = view.$el.find("." + view.separatorClass);
separatorElHovered = true;
// Add a delay before the highlight class is added
setTimeout(function () {
if (separatorElHovered) {
separatorEls.addClass(view.changeableSeparatorClass);
if (view.changeableSeparatorTooltip) {
// Add an even longer delay before the tooltip is shown
setTimeout(function () {
if (separatorElHovered) {
$(separator).tooltip("show");
}
}, 600);
}
}
}, 285);
});
// Hide all the tooltips and remove the highlight class on mouse out
separator.on("mouseout", function () {
separatorElHovered = false;
var separatorEls = view.$el.find("." + view.separatorClass);
separatorEls.removeClass(view.changeableSeparatorClass);
separatorEls.tooltip("hide");
});
}
return separator;
} catch (error) {
console.log(
"There was an error creating a separator element in a " +
"Searchable Select View. Error details: " +
error,
);
}
},
/**
* Changes the separator text for all separator elements to the next value that's
* set in the {@link SearchableSelectView#separatorTextOptions}. Triggers a
* "separatorChanged" event that passes on the new separator value.
*/
changeSeparator: function () {
try {
var view = this;
if (
!view.separatorTextOptions ||
!view.separatorTextOptions.length ||
!view.separatorText
) {
return;
}
// Get the next separator text option
var currentIndex = view.separatorTextOptions.indexOf(
view.separatorText,
),
nextIndex = currentIndex + 1;
if (currentIndex === -1 || !view.separatorTextOptions[nextIndex]) {
nextIndex = 0;
}
// Update the current separator text on the view
view.separatorText = view.separatorTextOptions[nextIndex];
// Change the separator text for all of the separators in the view with an
// animation
var separatorEls = view.$el.find("." + view.separatorClass);
separatorEls.transition({
animation: "pulse",
displayType: "inline-block",
duration: "250ms",
onComplete: function () {
$(this).text(view.separatorText);
},
});
// Trigger an event for parent views
view.trigger("separatorChanged", view.separatorText);
} catch (error) {
console.log(
"There was an error switching the separator text in a SearchableSelectView" +
". Error details: " +
error,
);
}
},
/**
* updateMenu - Re-render the menu of options. Useful after changing
* the options that are set on the view.
*/
updateMenu: function () {
try {
var menu = $(this.template(this).trim()).find(".menu")[0].innerHTML;
this.$el.find(".menu").html(menu);
} catch (e) {
console.log(
"Failed to update a searchable select menu, error message: " + e,
);
}
},
/**
* postRender - Updates to the view once the dropdown UI has loaded
*/
postRender: function () {
try {
var view = this;
view.trigger("postRender");
// Add tool tips for the description
this.$el.find(".item").each(function () {
view.addTooltip(this);
});
// Show an error message if the pre-selected options are not in the
// list of available options (only if user additions are not allowed)
if (!view.allowAdditions) {
if (view.selected && view.selected.length) {
var invalidOptions = [];
view.selected.forEach(function (item) {
if (!view.isValidOption(item)) {
invalidOptions.push(item);
}
});
if (invalidOptions.length) {
var optionsString = '"' + invalidOptions.join(", ") + '"';
var phrase =
invalidOptions.length === 1
? "is not a valid option"
: "are not valid options";
var ending = ". Please change selection.";
var message = optionsString + " " + phrase + ending;
view.showMessage(message, "error", true);
}
}
}
// Set the selected values in the dropdown
this.$selectUI.dropdown("set exactly", view.selected);
this.$selectUI.dropdown("save defaults");
this.enable();
this.hideLoading();
// Make sub-menus if the option is configured in this view
if (this.submenuStyle === "popout") {
this.convertToPopout();
} else if (this.submenuStyle === "accordion") {
this.convertToAccordion();
}
// Convert interactive submenus to lists and hide empty categories
// when the user is searching for a term
if (
["popout", "accordion"].includes(view.submenuStyle) ||
view.hideEmptyCategoriesOnSearch
) {
this.$selectUI.find("input").on("keyup blur", function (e) {
inputVal = e.target.value;
// When the input is NOT empty
if (inputVal !== "") {
// For interactive type submenus where items are sometimes
// hidden, show all the matching items when a user is searching
if (["popout", "accordion"].includes(view.submenuStyle)) {
view.convertToList();
}
if (view.hideEmptyCategoriesOnSearch) {
view.hideEmptyCategories();
}
// When the input is EMPTY
} else {
// Convert back to sub-menus if the option is configured in this view
if (view.submenuStyle === "popout") {
view.convertToPopout();
} else if (view.submenuStyle === "accordion") {
view.convertToAccordion();
}
// Show all the category titles again, in cases some where hidden
if (view.hideEmptyCategoriesOnSearch) {
view.showAllCategories();
}
}
});
}
// Trigger an event when the user focuses in searchable inputs
var inputEl = this.$el.find("input.search");
if (inputEl) {
inputEl.off("focus");
inputEl.on("focus", function (event) {
view.trigger("inputFocus", event);
});
}
} catch (e) {
console.log(
"The searchable select post-render function failed, error message: " +
e,
);
}
},
/**
* isValidOption - Checks if a value is one of the values given in view.options
*
* @param {string} value The value to check
* @return {boolean} returns true if the value is one of the values given in
* view.options
*/
isValidOption: function (value) {
try {
var view = this;
var options = view.options;
// If there are no options set on the view, assume the value is invalid
if (!options || options.length === 0) {
return false;
}
// If the list of options doesn't have category headings, put it in the
// same format as options that do have headings.
if (Array.isArray(options)) {
options = { "": options };
}
// Reduce the options object to just an Array of value and label strings
var validValues = _(options)
.chain()
.values()
.flatten()
.map(function (item) {
var items = [];
if (item.value !== undefined) {
items.push(item.value);
}
if (item.label !== undefined) {
items.push(item.label);
}
return items;
})
.flatten()
.value();
return validValues.includes(value);
} catch (e) {
console.log(
"Failed to check if an option is valid in a Searchable Select View, error message: " +
e,
);
}
},
/**
* addTooltip - Add a tooltip to a given element using the description in the
* options object that's set on the view.
*
* @param {HTMLElement} element The HTML element a tooltip should be added
* @param {string} position how to position the tooltip - top | bottom | left |
* right
* @return {jQuery} The element with a tooltip wrapped by jQuery
*/
addTooltip: function (element, position = "bottom") {
try {
if (!element) {
return;
}
// Find the description in the options object, using the data-value
// attribute set in the template. The data-value attribute is either
// the label, or the value, depending on if a value is provided.
var valueOrLabel = $(element).data("value");
if (typeof valueOrLabel === "undefined") {
return;
}
if (typeof valueOrLabel === "boolean") {
valueOrLabel = valueOrLabel.toString();
}
var opt = _.chain(this.options)
.values()
.flatten()
.find(function (option) {
return (
option.label == valueOrLabel || option.value == valueOrLabel
);
})
.value();
if (!opt) {
return;
}
if (!opt.description) {
return;
}
$(element)
.tooltip({
title: opt.description,
placement: position,
container: "body",
delay: {
show: 900,
hide: 50,
},
})
.on("show.bs.popover", function () {
var $el = $(this);
// Allow time for the popup to be added to the DOM
setTimeout(function () {
// Then add a special class to identify
// these popups if they need to be removed.
$el.data("tooltip").$tip.addClass("search-select-tooltip");
}, 10);
});
return $(element);
} catch (e) {
console.log(
"Failed to add tooltip in a searchable select view, error message: " +
e,
);
}
},
/**
* convertToPopout - Re-arrange the HTML to display category contents
* as sub-menus that popout to the left or right of category titles
*/
convertToPopout: function () {
try {
if (!this.$selectUI) {
return;
}
if (this.currentSubmenuMode === "popout") {
return;
}
this.currentSubmenuMode = "popout";
this.$selectUI.addClass("popout-mode");
var $headers = this.$selectUI.find(".header");
if (!$headers || $headers.length === 0) {
return;
}
$headers.each(function (i) {
var $itemGroup = $().add($(this).nextUntil(".header"));
var $itemAndHeaderGroup = $(this).add($(this).nextUntil(".header"));
var $icon = $(this).next().find(".icon");
if ($icon && $icon.length > 0) {
var $headerIcon = $icon.clone().addClass("popout-mode-icon").css({
opacity: "0.9",
"margin-right": "1rem",
});
$(this).prepend($headerIcon[0]);
}
$itemAndHeaderGroup.wrapAll("<div class='item popout-mode'/>");
$itemGroup.wrapAll("<div class='menu popout-mode'/>");
$(this).append(
"<i class='popout-mode-icon dropdown icon icon-on-right icon-chevron-right'></i>",
);
});
} catch (e) {
console.log(
"Failed to convert a Searchable Select interface to sub-menu mode, error message: " +
e,
);
}
},
/**
* convertToList - Re-arrange HTML to display the full list of options
* in one static menu
*/
convertToList: function () {
try {
if (!this.$selectUI) {
return;
}
if (this.currentSubmenuMode === "list") {
return;
}
this.currentSubmenuMode = "list";
this.$selectUI.find(".popout-mode > *").unwrap();
this.$selectUI.find(".accordion-mode > *").unwrap();
this.$selectUI.find(".popout-mode-icon").remove();
this.$selectUI.find(".accordion-mode-icon").remove();
this.$selectUI.removeClass("popout-mode accordion-mode");
} catch (e) {
console.log(
"Failed to convert a Searchable Select interface to list mode, error message: " +
e,
);
}
},
/**
* convertToAccordion - Re-arrange the HTML to display category items
* with expandable sections, similar to an accordion element.
*/
convertToAccordion: function () {
try {
if (!this.$selectUI) {
return;
}
if (this.currentSubmenuMode === "accordion") {
return;
}
this.currentSubmenuMode = "accordion";
this.$selectUI.addClass("accordion-mode");
var $headers = this.$selectUI.find(".header");
if (!$headers || $headers.length === 0) {
return;
}
// Id to match the header to the
$headers.each(function (i) {
// Create an ID
var randomNum = Math.floor(Math.random() * 100000 + 1),
headerText = $(this).text().replace(/\W/g, ""),
id = headerText + randomNum;
var $itemGroup = $().add($(this).nextUntil(".header"));
var $icon = $(this).next().find(".icon");
if ($icon && $icon.length > 0) {
var $headerIcon = $icon
.clone()
.addClass("accordion-mode-icon")
.css({
opacity: "0.9",
"margin-right": "1rem",
});
$(this).prepend($headerIcon[0]);
$(this).wrap(
"<a data-toggle='collapse' data-target='#" +
id +
"' class='accordion-mode collapsed'/>",
);
}
$itemGroup.wrapAll(
"<div id='" + id + "' class='accordion-mode collapse'/>",
);
$(this).append(
"<i class='accordion-mode-icon dropdown icon icon-on-right icon-chevron-down'></i>",
);
});
} catch (e) {
console.log(
"Failed to convert a Searchable Select interface to accordion mode, error message: " +
e,
);
}
},
/**
* hideEmptyCategories - In the searchable select interface, hide
* category headers that are empty, if any
*/
hideEmptyCategories: function () {
try {
var $headers = this.$selectUI.find(".header");
if (!$headers || $headers.length === 0) {
return;
}
$headers.each(function (i) {
// this is the header
var $itemGroup = $().add($(this).nextUntil(".header"));
var $itemGroupFiltered = $().add(
$(this).nextUntil(".header", ".filtered"),
);
// If all items are filtered
if ($itemGroup.length === $itemGroupFiltered.length) {
// Then also hide the header
$(this).hide();
} else {
$(this).show();
}
});
} catch (e) {
console.log(
"Failed to hide empty categories in a dropdown, error message: " +
e,
);
}
},
/**
* showAllCategories - In the searchable select interface, show all
* category headers that were previously empty
*/
showAllCategories: function () {
try {
this.$selectUI.find(".header:hidden").show();
} catch (e) {
console.log(
"Failed to show all categories in a dropdown, error message: " + e,
);
}
},
/**
* changeSelection - Set selected values in the interface
*
* @param {string[]} newValues - An array of strings to select
*/
changeSelection: function (newValues, silent = false) {
try {
if (
!this.$selectUI ||
typeof newValues === "undefined" ||
!Array.isArray(newValues)
) {
return;
}
var view = this;
this.selected = newValues;
if (silent === true) {
view.disable();
}
this.$selectUI.dropdown("set exactly", newValues);
if (silent === true) {
view.enable();
}
} catch (e) {
console.log(
"Failed to change the selected values in a searchable select field, error message: " +
e,
);
}
},
/**
* enable - Remove the class the makes the select UI appear disabled
*/
enable: function () {
try {
this.$el.find(".ui.dropdown").removeClass("disabled");
} catch (e) {
console.log(
"Failed to enable the searchable select field, error message: " + e,
);
}
},
/**
* disable - Add the class the makes the select UI appear disabled
*/
disable: function () {
try {
this.$el.find(".ui.dropdown").addClass("disabled");
} catch (e) {
console.log(
"Failed to enable the searchable select field, error message: " + e,
);
}
},
/**
* showMessage - Show an error, warning, or informational message, and highlight
* the select interface in an appropriate colour.
*
* @param {string} message The message to display. Use an empty string to only
* highlight the select interface without showing any message text.
* @param {string} type one of "error", "warning", or "info"
* @param {boolean} removeOnChange set to true to remove the message as soon as
* the user changes the selection
*
*/
showMessage: function (message, type = "info", removeOnChange = true) {
try {
if (!this.$selectUI) {
console.warn(
"A select UI element wasn't found, can't display error.",
);
return;
}
var messageTypes = {
error: {
messageClass: "text-error",
selectUIClass: "error",
},
warning: {
messageClass: "text-warning",
selectUIClass: "warning",
},
info: {
messageClass: "text-info",
selectUIClass: "",
},
};
if (!messageTypes.hasOwnProperty(type)) {
console.log(
type +
"is not a message type for Select UI interfaces. Showing message as info type",
);
type = "info";
}
this.removeMessages();
this.$selectUI.addClass(messageTypes[type].selectUIClass);
if (message && message.length && typeof message === "string") {
this.message = $(
"<p style='margin:0.2rem' class='" +
messageTypes[type].messageClass +
"'><small>" +
message +
"</small></p>",
);
}
this.$el.append(this.message);
if (removeOnChange) {
this.listenToOnce(this, "changeSelection", this.removeMessages);
}
} catch (e) {
console.log(
"Failed to show an error state in a Searchable Select View, error message: " +
e,
);
}
},
/**
* removeMessages - Remove all messages and classes set by the
* showMessage function.
*/
removeMessages: function () {
try {
if (!this.$selectUI) {
console.warn(
"A select UI element wasn't found, can't remove error.",
);
return;
}
this.$selectUI.removeClass("error warning");
if (this.message) {
this.message.remove();
}
} catch (e) {
console.log(
"Failed to hide an error state in a Searchable Select View, error message: " +
e,
);
}
},
/**
* showLoading - Indicate that dropdown options are loading by showing
* a spinner in the select interface
*/
showLoading: function () {
try {
this.$el.find(".ui.dropdown").addClass("loading");
} catch (e) {
console.log(
"Failed to show a loading state in a Searchable Select View, error message: " +
e,
);
}
},
/**
* hideLoading - Remove the loading spinner set by the showLoading
*/
hideLoading: function () {
try {
this.$el.find(".ui.dropdown").removeClass("loading");
} catch (e) {
console.log(
"Failed to remove a loading state in a Searchable Select View, error message: " +
e,
);
}
},
},
);
});