define([
"jquery",
"underscore",
"backbone",
"views/searchSelect/SearchableSelectView",
"collections/queryFields/QueryFields",
], function ($, _, Backbone, SearchableSelect, QueryFields) {
/**
* @class QueryFieldSelectView
* @classdesc A select interface that allows the user to search for and
* select metadata field(s).
* @classcategory Views/SearchSelect
* @extends SearchableSelect
* @constructor
* @since 2.14.0
* @screenshot views/searchSelect/QueryFieldSelectView.png
*/
var QueryFieldSelectView = SearchableSelect.extend(
/** @lends QueryFieldSelectView.prototype */
{
/**
* The type of View this is
* @type {string}
*/
type: "QueryFieldSelect",
/**
* className - the class names for this view element
*
* @type {string}
*/
className: SearchableSelect.prototype.className + " query-field-select",
/**
* Text to show in the input field before any value has been entered
* @type {string}
*/
placeholderText: "Search for or select a field",
/**
* Label for the input element
* @type {string}
*/
inputLabel: "Select one or more metadata fields to query",
/**
* @see SearchableSelectView#submenuStyle
* @default "accordion"
*/
submenuStyle: "accordion",
/**
* A list of query fields names to exclude from the list of options
* @type {string[]}
*/
excludeFields: [],
/**
* An additional field object contains the properties an additional query field to
* add that are required to render it correctly. An additional query field is one
* that does not actually exist in the query service index.
*
* @typedef {Object} AdditionalField
*
* @property {string} name - A unique ID to represent this field. It must not
* match the name of any other query fields.
* @property {string[]} fields - The list of real query fields that this
* abstracted field will represent. It must exactly match the names of the query
* fields that actually exist.
* @property {string} label - A user-facing label to display.
* @property {string} description - A description for this field.
* @property {string} category - The name of the category under which to place
* this field. It must match one of the category names for an existing query
* field.
*
* @since 2.15.0
*/
/**
* A list of additional fields which are not retrieved from the query service
* index, but which should be added to the list of options. This can be used to
* add abstracted fields which are a combination of multiple query fields, or to
* add a duplicate field that has a different label.
*
* @type {AdditionalField[]}
* @since 2.15.0
*/
addFields: [],
/**
* A list of query fields names to display at the top of the menu, above
* all other category headers
* @type {string[]}
*/
commonFields: ["text", "documents-special-field"],
/**
* The names of categories that should have items sorted alphabetically. Names
* must exactly match those in the
* {@link QueryField#categoriesMap Query Field model}
* @type {string[]}
* @since 2.15.0
*/
categoriesToAlphabetize: ["General"],
/**
* Whether or not to exclude fields which are not searchable. Set to
* false to keep query fields that are not searchable in the returned list
* @type {boolean}
*/
excludeNonSearchable: true,
/**
* Creates a new QueryFieldSelectView
* @param {Object} options - A literal object with options to pass to the view
*/
initialize: function (options) {
try {
// Ensure the query fields are cached
if (typeof MetacatUI.queryFields === "undefined") {
MetacatUI.queryFields = new QueryFields();
MetacatUI.queryFields.fetch();
}
SearchableSelect.prototype.initialize.call(this, options);
} catch (e) {
console.log(
"Failed to initialize a Query Field Select View, error message: " +
e,
);
}
},
/**
* postRender - Updates the view once the dropdown UI has loaded. Processes the
* QueryFields given the options passed to this view, then updates the menu and
* selection. Processing the fields takes some time, which is why we allow the
* view to render before starting that process. This prevents slowing down the
* rendering of parent views.
*/
postRender: function () {
try {
var view = this;
_.defer(function () {
view.processFields();
view.updateMenu();
// With the new menu in place, show the pre-selected values. Silent is set
// to true so that it doesn't trigger an update of the model. Defer to make
// sure the menu elements are attached.
_.defer(function () {
view.changeSelection(view.selected, true);
});
SearchableSelect.prototype.postRender.call(view);
});
} catch (error) {
console.log(
"Post-render failed in a QueryFieldSelectView." +
" Error details: " +
error,
);
}
},
/**
* Retrieves the queryFields collection if not already fetched, then organizes the
* fields based on the options passed to this view.
* @since 2.17.0
*/
processFields: function () {
try {
var view = this;
// Ensure the query fields are cached for the Query Field Select
// View and the Query Rule View
if (
typeof MetacatUI.queryFields === "undefined" ||
MetacatUI.queryFields.length === 0
) {
if (typeof MetacatUI.queryFields === "undefined") {
MetacatUI.queryFields = new QueryFields();
}
this.listenToOnce(
MetacatUI.queryFields,
"sync",
this.processFields,
);
MetacatUI.queryFields.fetch();
return;
}
// Convert the queryFields collection to an object formatted for the
// SearchableSelect view.
var fieldsJSON = MetacatUI.queryFields.toJSON();
// Process & add additional fields set on this view (these are fields not
// retrieved from the query service API)
if (this.addFields && Array.isArray(this.addFields)) {
// For each added field, find the icon and category order from the already
// existing fields with the same category.
this.addFields = _.map(
this.addFields,
function (field) {
if (field.category) {
var categoryInfo = _.findWhere(fieldsJSON, {
category: field.category,
});
["icon", "categoryOrder"].forEach(function (prop) {
if (!field[prop]) {
field[prop] = categoryInfo[prop];
}
});
}
return field;
},
this,
);
// Add the additional fields to the array of fields fetched from the
// query service API
fieldsJSON = fieldsJSON.concat(this.addFields);
}
// Move common fields to the top of the menu, outside of any
// category headers, so that they are easy to find
if (this.commonFields && Array.isArray(this.commonFields)) {
this.commonFields.forEach(function (commonFieldName) {
var i = _.findIndex(fieldsJSON, { name: commonFieldName });
if (i > 0) {
// If the category name is an empty string, no header will
// be created in the menu
fieldsJSON[i].category = "";
// The min categoryOrder in the QueryFields collection is 1
fieldsJSON[i].categoryOrder = 0;
fieldsJSON[i].icon = "star";
}
});
}
// Filter out non-searchable fields (if option is true),
// and fields that should be excluded
var processedFields = _(fieldsJSON)
.chain()
.sortBy("categoryOrder")
.filter(function (filter) {
if (this.excludeNonSearchable) {
if (["false", false].includes(filter.searchable)) {
return false;
}
}
if (this.excludeFields && this.excludeFields.length) {
if (this.excludeFields.includes(filter.name)) {
return false;
}
}
return true;
}, this)
.map(view.fieldToOption)
.groupBy("categoryOrder")
.value();
// Rename the grouped categories
for (const [key, value] of Object.entries(processedFields)) {
processedFields[value[0].category] = value;
delete processedFields[key];
}
// Sort items alphabetically for the specified categories
if (
this.categoriesToAlphabetize &&
this.categoriesToAlphabetize.length
) {
this.categoriesToAlphabetize.forEach(function (categoryName) {
// Sort by category label
processedFields[categoryName].sort(function (a, b) {
// Ignore upper and lowercase
var nameA = a.label.toUpperCase();
var nameB = b.label.toUpperCase();
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
});
});
}
// Set the formatted fields on the view
this.options = processedFields;
} catch (error) {
console.log(
"There was an error organizing the Fields in a QueryFieldSelectView" +
" Error details: " +
error,
);
}
},
/**
* fieldToOption - Converts an object that represents a QueryField model in the
* format specified by the SearchableSelectView.options
*
* @param {object} field An object with properties corresponding to a QueryField
* model
* @return {object} An object in the format specified by
* SearchableSelectView.options
*/
fieldToOption: function (field) {
return {
label: field.label ? field.label : field.name,
value: field.name,
description: field.friendlyDescription
? field.friendlyDescription
: field.description,
icon: field.icon,
category: field.category,
categoryOrder: field.categoryOrder,
type: field.type,
};
},
/**
* addTooltip - Add a tooltip to a given element using the description in the
* options object that's set on the view. This overwrites the prototype addTooltip
* function so that we can use popovers with more details for query select fields.
*
* @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") {
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"),
opt = _.chain(this.options)
.values()
.flatten()
.find(function (option) {
return (
option.label == valueOrLabel || option.value == valueOrLabel
);
})
.value();
var label = opt.label,
value = opt.value,
type = opt.type,
description = opt.description ? opt.description : "";
// For added fields, the value set on the options.value element is just a
// unique identifier. The values that should be used to build a query are saved
// in the addFields array set on this view.
if (this.addFields && Array.isArray(this.addFields)) {
var specialField = _.findWhere(this.addFields, {
name: valueOrLabel,
});
if (specialField) {
value = specialField.fields;
type = [];
specialField.fields.forEach(function (fieldName) {
var realField = MetacatUI.queryFields.findWhere({
name: fieldName,
});
if (realField) {
type.push(realField.get("type"));
} else {
type.push("special field");
}
}, this);
type = type.join(", ");
}
}
var contentEl = $(document.createElement("div")),
titleEl = $("<div>" + label + "</div>"),
valueEl = $("<code class='pull-right'>" + value + "</code>"),
typeEl = $(
"<span class='muted pull-right'><b>Type: " + type + "</b></span>",
),
descriptionEl = $("<p>" + description + "</p>");
titleEl.append(valueEl);
contentEl.append(descriptionEl, typeEl);
$(element)
.popover({
title: titleEl,
content: contentEl,
html: true,
trigger: "hover",
placement: position,
container: "body",
delay: {
show: 1100,
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 some css rules, and a special class to identify
// these popups if they need to be removed.
$el
.data("popover")
.$tip.css({
maxWidth: "400px",
pointerEvents: "none",
})
.addClass("search-select-tooltip");
}, 10);
});
return $(element);
},
/**
* 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;
// First check if the value is one of the fields that's excluded.
if (view.excludeFields.includes(value)) {
// If it is, then add it to the list of options
var newField = MetacatUI.queryFields.findWhere({
name: value,
});
if (newField) {
newField = view.fieldToOption(newField.toJSON());
}
view.options[newField.category].push(newField);
view.updateMenu();
// Make sure the new menu is attached before updating the selections
setTimeout(function () {
// If the selected value has been removed, re-add it.
if (!view.selected.includes(value)) {
view.selected.push(value);
}
view.changeSelection(view.selected, (silent = true));
}, 25);
return true;
} else {
var isValid = SearchableSelect.prototype.isValidOption.call(
view,
value,
);
return isValid;
}
} catch (e) {
console.log(
"Failed to check if option is valid in a Query Field Select View, error message: " +
e,
);
}
},
},
);
return QueryFieldSelectView;
});