define([
"jquery",
"underscore",
"backbone",
"views/searchSelect/SearchableSelectView",
"views/searchSelect/QueryFieldSelectView",
"views/searchSelect/NodeSelectView",
"views/searchSelect/AccountSelectView",
"views/filters/NumericFilterView",
"views/filters/DateFilterView",
"views/searchSelect/ObjectFormatSelectView",
"views/searchSelect/AnnotationFilterView",
"models/filters/Filter",
"models/filters/BooleanFilter",
"models/filters/NumericFilter",
"models/filters/DateFilter",
], function (
$,
_,
Backbone,
SearchableSelect,
QueryFieldSelect,
NodeSelect,
AccountSelect,
NumericFilterView,
DateFilterView,
ObjectFormatSelect,
AnnotationFilter,
Filter,
BooleanFilter,
NumericFilter,
DateFilter,
) {
/**
* @class QueryRuleView
* @classdesc A view that provides an UI for a user to construct a single filter that
* is part of a complex query
* @classcategory Views/QueryBuilder
* @screenshot views/QueryRuleView.png
* @extends Backbone.View
* @constructor
* @since 2.14.0
*/
return Backbone.View.extend(
/** @lends QueryRuleView.prototype */
{
/**
* The type of View this is
* @type {string}
*/
type: "QueryRule",
/**
* The HTML class names for this view element
* @type {string}
*/
className: "query-rule",
/**
* The class to add to the rule number and other information on the left
* @type {string}
*/
ruleInfoClass: "rule-info",
/**
* The class to add to the field select element
* @type {string}
*/
fieldsClass: "field",
/**
* The class to add to the operator select element
* @type {string}
*/
operatorClass: "operator",
/**
* The class to add to the value select element
* @type {string}
*/
valuesClass: "value",
/**
* The class to add to the element that a user should click to remove a rule.
* @type {string}
*/
removeClass: "remove-rule",
/**
* An ID for the element that a user should click to remove a rule. A unique ID
* will be appended to this ID, and the ID will be added to the template.
* @type {string}
*/
removeRuleID: "remove-rule-",
/**
* The maximum number of levels of nested Rule Groups (i.e. nested FilterGroup
* models) that a user is permitted to build in the Query Builder that contains
* this rule. This value should be passed to the rule by the parent Query Builder.
* This value minus one will be passed on to any child Query Builders (those that
* render nested FilterGroup models).
* @type {number}
* @since 2.17.0
*/
nestedLevelsAllowed: 1,
/**
* An array of hex color codes used to help distinguish between different rules.
* If this is a nested Query Rule, and the rule should inherit its colour from
* the parent Query Rule, then set ruleColorPalette to "inherit".
* @type {string[]|string}
*/
ruleColorPalette: [
"#44AA99",
"#137733",
"#c9a538",
"#CC6677",
"#882355",
"#AA4499",
"#332288",
],
/**
* Search index fields to exclude in the metadata field selector
* @type {string[]}
*/
excludeFields: [],
/**
* Query fields to exclude in the metadata field selector for any Query Rules that
* are in nested Query Builders (i.e. in nested Filter Groups). This is a list of
* field names that exist in the query service index (i.e. Solr), but which should
* be hidden in nested Query Builders
* @type {string[]}
*/
nestedExcludeFields: [],
/**
* A single Filter model that is part of a Filters collection, such as the
* definition filters for a Collection or Portal or the filters for a Search
* model. The Filter model must be part of a Filters collection (i.e. there must
* be a model.collection property)
* @type {Filter|BooleanFilter|NumericFilter|DateFilter|FilterGroup}
*/
model: undefined,
/**
* A function that creates and returns the Backbone events object.
* @return {Object} Returns a Backbone events object
*/
events: function () {
var events = {};
var removeID = "#" + this.removeRuleID + this.cid;
events["click " + removeID] = "removeSelf";
events["mouseover " + removeID] = "previewRemove";
events["mouseout " + removeID] = "previewRemove";
return events;
},
/**
* A list of additional fields which are not retrieved from the query API, 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. These special fields are passed on
* to {@link QueryFieldSelectView#addFields}.
*
* @type {SpecialField[]}
*
* @since 2.15.0
*/
specialFields: [
{
name: "documents-special-field",
fields: ["documents"],
label: "Contains Data Files",
description:
"Limit results to packages that include data files. Without" +
" this rule, results may include packages with metadata but no data.",
category: "General",
values: ["*"],
},
{
name: "year-data-collection",
fields: ["beginDate", "endDate"],
label: "Year of Data Collection",
description:
"The temporal range of content described by the metadata",
category: "Dates",
},
],
/**
* An operator option is an object that lists the properties of one of the
* operators that will be displayed to the user in the Query Rule "operator"
* dropdown list. The operator properties are used to pre-select the correct
* operator based on attributes in the associated
* {@link Filter#defaults Filter model}, as well as to update the Filter model
* when a user selects a new operator. Operators can set the exclude and
* matchSubstring properties of the model, and sometimes the values as well.
* Either the types property OR the fields property must be set, not both.
*
* @typedef {Object} OperatorOption
* @property {string} label - The label to display to the user
* @property {string} icon - An icon that represents the operator
* @property {boolean} matchSubstring - Whether the matchSubstring attribute is
* true or false in the filter model that matches this operator
* @property {boolean} exclude - Whether the exclude attribute is true or false in
* the filter model that matches this operator
* @property {boolean} hasMax - Whether the filter model that matches this
* operator must have a max attribute
* @property {boolean} hasMin - Whether the filter model that matches this
* operator must have a min attribute
* @property {string[]} values - For this operator to work as desired, the values
* that should be set in the filter (e.g. ["true"] for the operator "is true")
* @property {string[]} [types] - The node names of the filters that this operator
* is used for (e.g. "filter", "booleanFilter")
* @property {string[]} [fields] - The query field names of the filters that this
* operator is used for. If this is used for a
* {@link QueryRuleView#specialFields special field}, then list the special field
* name (id), and not the real query field names. If this fields property is set,
* then the types property will be ignored. (i.e. fields is more specific than
* types.)
*/
/**
* The list of operators that will be available in the dropdown list that connects
* the query fields to the values. Each operator must be unique.
*
* @type {OperatorOption[]}
*/
operatorOptions: [
{
label: "is true",
description:
"The data package includes data files (and not only metadata)",
icon: "ok-circle",
matchSubstring: false,
exclude: false,
values: ["*"],
fields: ["documents-special-field"],
},
{
label: "is false",
description:
"The data package only contains metadata; it contains no data files.",
icon: "ban-circle",
matchSubstring: false,
exclude: true,
values: ["*"],
fields: ["documents-special-field"],
},
{
label: "equals",
description:
"The text in the metadata field is an exact match to the" +
" selected value",
icon: "equal",
matchSubstring: false,
exclude: false,
types: ["filter"],
},
{
label: "does not equal",
description:
"The text in the metadata field is anything except an exact" +
" match to the selected value",
icon: "not-equal",
matchSubstring: false,
exclude: true,
types: ["filter"],
},
{
label: "contains",
description:
"The text in the metadata field matches or contains the words" +
" or phrase selected",
icon: "ok-circle",
matchSubstring: true,
exclude: false,
types: ["filter"],
},
{
label: "does not contain",
description:
"The words or phrase selected are not contained within the" +
" metadata field",
icon: "ban-circle",
matchSubstring: true,
exclude: true,
types: ["filter"],
},
{
label: "is empty",
description: "The metadata field contains no text or value",
icon: "circle-blank",
matchSubstring: false,
exclude: true,
values: ["*"],
types: ["filter"],
},
{
label: "is not empty",
description: "The metadata field is filled in with any text at all",
icon: "circle",
matchSubstring: false,
exclude: false,
values: ["*"],
types: ["filter"],
},
{
label: "is true",
description: "The metadata field is set to true",
icon: "ok-circle",
matchSubstring: false,
exclude: false,
values: [true],
types: ["booleanFilter"],
},
{
label: "is false",
description: "The metadata field is set to false",
icon: "ban-circle",
matchSubstring: false,
exclude: false,
values: [false],
types: ["booleanFilter"],
},
{
label: "is between",
description:
"The metadata field is a value between the range selected" +
" (inclusive of both values)",
icon: "resize-horizontal",
matchSubstring: false,
exclude: false,
hasMin: true,
hasMax: true,
types: ["numericFilter", "dateFilter"],
},
{
label: "is less than or equal to",
description:
"The metadata field is a number less than the value selected",
icon: "less-than-or-eq",
matchSubstring: false,
exclude: false,
hasMin: false,
hasMax: true,
types: ["numericFilter"],
},
{
label: "is greater than or equal to",
description:
"The metadata field is a number greater than the value selected",
icon: "greater-than-or-eq",
matchSubstring: false,
exclude: false,
hasMin: true,
hasMax: false,
types: ["numericFilter"],
},
{
label: "is exactly",
description: "The metadata field exactly equals the value selected",
icon: "equal",
matchSubstring: false,
exclude: false,
hasMin: false,
hasMax: false,
types: ["numericFilter"],
},
// TODO: The dateFilter model & view need to be updated for these to work:
// {
// label: "is during or before", icon: "less-than-or-eq", matchSubstring:
// false, exclude: false, hasMin: false, hasMax: true, types: ["dateFilter"]
// },
// {
// label: "is during or after", icon: "greater-than", matchSubstring: false,
// exclude: false, hasMin: true, hasMax: false, types: ["dateFilter"]
// },
// {
// label: "is in the year", icon: "equal", matchSubstring: false, exclude:
// false, hasMin: false, hasMax: false, types: ["dateFilter"]
// }
],
/**
* The third input in each Query Rule is where the user enters a value, minimum,
* or maximum for the filter model. Different types of values are appropriate for
* different solr query fields, and so we display different interfaces depending
* on the type and category of the selected query fields. A Value Input Option
* object defines a of interface to show for a given type and category.
*
* @typedef {Object} ValueInputOption
* @property {string[]} filterTypes - An array of one or more filter types that
* are allowed for this interface. If none are provided then any filter type is
* allowed. Filter types are one of the four keys defined in
* {@link QueryField#filterTypesMap}.
* @property {string[]} categories - An array of one or more categories that are
* allowed for this interface. These strings must exactly match the categories
* provided in QueryField.categoriesMap(). If none are provided then any category
* is allowed.
* @property {string[]} queryFields - Specific names of fields that are allowed in
* this interface. If none are provided, then any query fields are allowed that
* match the other properties. If this value select should be used for a
* {@link QueryRuleView#specialFields special field}, then use the name (id) of
* the special field, not the actual query fields that it represents.
* @property {string} label - If the interface does not include a label (e.g.
* number filter), include a string to display here.
* @property {function} uiFunction - A function that returns the UI view to use
* with all appropriate options set. The function will be called with this view as
* the context.
*/
/**
* This list defines which type of value input to show depending on filter type,
* category, and query fields. The value input options are ordered from *most*
* specific to *least*, since the first match will be selected. The filter model
* must match either the queryFields, or both the filterTypes AND the categories
* for a UI to be selected.
* @type {ValueInputOption[]}
*/
valueSelectUImap: [
// serviceCoupling field
{
queryFields: ["serviceCoupling"],
uiFunction: function () {
return new SearchableSelect({
options: [
{
label: "tight",
description:
"Tight coupled service work only on the data described" +
" by this metadata document.",
},
{
label: "mixed",
description:
"Mixed coupling means service works on data described" +
" by this metadata document but may work on other data.",
},
{
label: "loose",
description:
"Loose coupling means service works on any data.",
},
],
allowMulti: true,
allowAdditions: false,
inputLabel: "Select a coupling",
selected: this.model.get("values"),
separatorText: this.model.get("operator"),
});
},
},
// Metadata format IDs
{
queryFields: ["formatId"],
uiFunction: function () {
return new ObjectFormatSelect({
selected: this.model.get("values"),
separatorText: this.model.get("operator"),
});
},
},
// Semantic annotation picker
{
queryFields: ["sem_annotation"],
uiFunction: function () {
// A bioportalAPIKey is required for the Annotation Filter UI
if (MetacatUI.appModel.get("bioportalAPIKey")) {
return new AnnotationFilter({
selected: this.model.get("values").slice(),
separatorText: this.model.get("operator"),
multiselect: true,
inputLabel: "Type a value",
});
// If there's no API key, render the default UI (the last in this list)
} else {
return this.valueSelectUImap.slice(-1)[0].uiFunction.call(this);
}
},
},
// User/Organization account ID lookup
{
queryFields: [
"writePermission",
"readPermission",
"changePermission",
"rightsHolder",
"submitter",
],
uiFunction: function () {
return new AccountSelect({
selected: this.model.get("values"),
separatorText: this.model.get("operator"),
});
},
},
// Repository picker for fields that need a member node ID
{
filterTypes: ["filter"],
queryFields: [
"blockedReplicationMN",
"preferredReplicationMN",
"replicaMN",
"authoritativeMN",
"datasource",
],
uiFunction: function () {
return new NodeSelect({
selected: this.model.get("values"),
separatorText: this.model.get("operator"),
});
},
},
// Any numeric fields don't fit one of the above options
{
filterTypes: ["numericFilter"],
label: "Choose a value",
uiFunction: function () {
return new NumericFilterView({
model: this.model,
showButton: false,
separatorText: this.model.get("operator"),
});
},
},
// Any date fields that don't fit one of the above options
{
filterTypes: ["dateFilter"],
label: "Choose a year",
uiFunction: function () {
return new DateFilterView({
model: this.model,
separatorText: this.model.get("operator"),
});
},
},
// The last is the default value selection UI
{
uiFunction: function () {
return new SearchableSelect({
options: [],
allowMulti: true,
allowAdditions: true,
inputLabel: "Type a value",
selected: this.model.get("values"),
separatorText: this.model.get("operator"),
});
},
},
],
/**
* Creates a new QueryRuleView
* @param {Object} options - A literal object with options to pass to the view
*/
initialize: function (options) {
try {
// 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,
);
}
// If no model is provided in the options, we cannot render this view. A
// filter model cannot be created, because it must be part of a collection.
if (!this.model || !this.model.collection) {
console.error(
"error: A Filter model that's part of a Filters collection" +
" is required to initialize a Query Rule view.",
);
return;
}
// The model may be removed during the save process if it's empty. Remove this
// Rule Group view when that happens.
this.stopListening(this.model, "remove");
this.listenTo(this.model, "remove", function () {
this.removeSelf();
});
} catch (e) {
console.log(
"Failed to initialize a Query Builder View, error message:",
e,
);
}
},
/**
* render - Render the view
*
* @return {QueryRule} Returns the view
*/
render: function () {
try {
// Add the Rule number.
// TODO: Also add the number of datasets related to rule
this.addRuleInfo();
this.stopListening(this.model.collection, "remove");
this.listenTo(this.model.collection, "remove", this.updateRuleInfo);
// Nested rules should also listen for changes in Filters of their parent Rule
if (this.parentRule) {
this.stopListening(this.parentRule.model.collection, "remove");
this.listenTo(
this.parentRule.model.collection,
"remove",
this.updateRuleInfo,
);
}
// The remove button is needed for both FilterGroups and other Filter models
this.addRemoveButton();
// Render nested filter group views as another Query Builder.
if (this.model.type == "FilterGroup") {
this.$el.addClass("rule-group");
// We must initialize a QueryBuilderView using the inline require syntax to
// avoid the problem of circular dependencies. QueryRuleView requires
// QueryBuilderView, and QueryBuilderView requires QueryRuleView. For more
// info, see https://requirejs.org/docs/api.html#circular
var QueryBuilderView = require("views/queryBuilder/QueryBuilderView");
// The default
nestedLevelsAllowed = 1;
// If we are adding a query builer, then it is a nested level. Subtract one
// from the total levels allowed.
if (typeof this.nestedLevelsAllowed == "number") {
nestedLevelsAllowed = this.nestedLevelsAllowed - 1;
}
// If there is a special list of fields to exclude in nested Query Builders
// (i.e. in nested FilterGroup models), then pass this list on as the
// excludeFields list in the child QueryBuilder
var excludeFields = this.excludeFields;
if (
this.nestedExcludeFields &&
Array.isArray(this.nestedExcludeFields)
) {
excludeFields = this.nestedExcludeFields;
}
// Insert QueryRuleView
var ruleGroup = new QueryBuilderView({
filterGroup: this.model,
// Nested Query Rules have the same color as their parent rule
ruleColorPalette: "inherit",
excludeFields: excludeFields,
specialFields: this.specialFields,
parentRule: this,
nestedLevelsAllowed: nestedLevelsAllowed,
});
this.el.append(ruleGroup.el);
ruleGroup.render();
} else {
// For any other filter type... Add a metadata selector field whether the
// rule is new or has already been created
this.addFieldSelect();
// Operator field and value field Add an operator input only for already
// existing filters (For new filters, a metadata field needs to be selected
// first)
if (this.model.get("fields") && this.model.get("fields").length) {
this.addOperatorSelect();
this.addValueSelect();
}
}
return this;
} catch (e) {
console.error(
"Error rendering the query Rule View, error message: ",
e,
);
}
},
/**
* Insert container for the color-coded rule numbering.
*/
addRuleInfo: function () {
try {
this.$indexEl = $(document.createElement("span"));
this.$ruleInfoEl = $(document.createElement("div")).addClass(
this.ruleInfoClass,
);
this.$ruleInfoEl.append(this.$indexEl);
this.$el.append(this.$ruleInfoEl);
this.updateRuleInfo();
} catch (error) {
console.log(
"Error adding rule info container for a Query Rule, details: " +
error,
);
}
},
/**
* Selects a color from the
* {@link QueryRuleView#ruleColorPalette rule colour palette array}, given an
* index. If the index is greater than the length of the palette, then the palette
* is effectively repeated until long enough (i.e. colours will be recycled). If
* no index in provided, the first colour in the palette will be selected.
*
* @param {number} [index=0] - The position of the rule within the Filters
* collection.
* @param {string} [defaultColor="#57b39c"] - A default colour to use in case
* there is problem with this function (hex color code beginning with '#').
* @return {string} - Returns a hex color code string
*/
getPaletteColor: function (index = 0, defaultColor = "#57b39c") {
try {
// Allow the rule to inherit it's color from the parent rule within which it's
// nested
if (this.ruleColorPalette == "inherit") {
return null;
}
if (!this.ruleColorPalette || !this.ruleColorPalette.length) {
return defaultColor;
}
var numCols = this.ruleColorPalette.length;
if (index + 1 > numCols) {
var n = Math.floor(index / numCols);
index = index - numCols * n;
}
return this.ruleColorPalette[index];
} catch (error) {
console.log(
"Error getting a color for a Query Rule, using the default colour" +
" instead. Error details: " +
error,
);
return defaultColor;
}
},
/**
* Adds or updates the color-coded Query Rule information displayed to the user.
* This needs to be run when rules are added or removed. Rule information includes
* the rule number, but may one day also display information such as the number of
* results that there are for this individual rule.
*/
updateRuleInfo: function () {
try {
// Rules are numbered in the order in which they appear in the Filters
// collection, excluding any invisible filter models. Rules nested in Rule
// Groups (within Filter Models) get numbered 3A, 3B, etc.
var letter = "";
var index = "";
// If this is a filter model nested in a filter group
if (this.parentRule) {
index = this.parentRule.ruleNumber;
var letterIndex = this.model.collection.visibleIndexOf(this.model);
if (typeof letterIndex === "number") {
letter = String.fromCharCode(94 + letterIndex + 3).toUpperCase();
}
// For top-level filter models
} else {
index = this.model.collection.visibleIndexOf(this.model);
}
if (typeof index == "number") {
index = index + 1;
}
var ruleNumber = index + letter;
// Set the rule number of the parent view to be accessed by any nested child
// rules
this.ruleNumber = ruleNumber;
// if(this.model.type == "FilterGroup")
if (ruleNumber && ruleNumber.length) {
this.$indexEl.text("Rule " + ruleNumber);
} else {
this.$indexEl.text("");
return;
}
var color = this.getPaletteColor(index);
if (color) {
this.el.style.setProperty("--rule-color", color);
}
} catch (error) {
console.log(
"Error updating the rule numbering for a Query Rule. Details: " +
error,
);
}
},
/**
* addRemoveButton - Create and insert the button to remove the Query Rule
*/
addRemoveButton: function () {
try {
var removeButton = $(
"<i id='" +
this.removeRuleID +
this.cid +
"' class='" +
this.removeClass +
" icon icon-remove' title='Remove this Query Rule'></i>",
);
this.el.append(removeButton[0]);
} catch (e) {
console.error(
"Failed to create a remove button for a Query Rule, error details: " +
e,
);
}
},
/**
* Determines whether the filter model that this rule renders matches one of the
* {@link QueryRuleView#specialFields special fields} set on this view. If it
* does, returns the first special field object that matches. For a filter model
* to match to one of the special fields, it must contain all of the fields listed
* in the special field's "fields" property. If the special field has an array set
* for "values", then the model's values must also exactly match the special
* field's values.
*
* @param {string[]} [fields] - Optionally set a list of query fields to search
* with. If not set, then the fields that are set on the view's filter model are
* used.
* @returns {SpecialField|null} - The matching special field, or null if no match
* was found.
*
* @since 2.15.0
*/
getSpecialField: function (fields) {
// Get information about the filter model (or used the fields passed to this
// function)
var selectedFields = fields || this.model.get("fields");
var selectedFields = _.clone(selectedFields);
var selectedValues = this.model.get("values");
if (!this.specialFields || !Array.isArray(this.specialFields)) {
return null;
}
var matchingSpecialField = _.find(
this.specialFields,
function (specialField) {
var fieldsMatch = false,
mustMatchValues = false,
valuesMatch = false;
// If *all* the fields in the fields array are present in the list
// of fields that the special field represents, then count this as a match.
var commonFields = _.intersection(
specialField.fields,
selectedFields,
);
if (commonFields.length === specialField.fields.length) {
fieldsMatch = true;
}
// The selected value must *exactly match* if one is set in the special
// field
if (specialField.values) {
mustMatchValues = true;
valuesMatch = _.isEqual(specialField.values, selectedValues);
}
return (
fieldsMatch &&
(!mustMatchValues || (mustMatchValues && valuesMatch))
);
},
this,
);
// If this model matches one of the special fields, render it differently
return matchingSpecialField || null;
},
/**
* Takes a list of query field names, checks if the model matches any of the
* special fields, and if it does, returns the list of fields with the actual
* field names replaced with the
* {@link QueryRuleView#specialFields special field name}. This function is the
* opposite of {@link QueryRuleView#convertFromSpecialFields}
* @param {string[]} fields - The list of field names to convert
* @returns {string[]} - The converted list of field names. If there were no
* special fields detected, or if there's an error, then then the field names are
* returned unchanged.
*
* @param {string[]} fields - The list of fields to convert to special fields, if
* the model matches any of the special field objects
* @returns {string[]} - Returns the list of fields with actual query field names
* replaced with special field names, if any match
*
* @since 2.15.0
*/
convertToSpecialFields: function (fields) {
try {
var fields = _.clone(fields);
// Insert the special field name at the same position as the associated
// query fields that we will remove
var replaceWithSpecialField = function (fields, specialField) {
if (specialField) {
position = _.findIndex(
fields,
function (selectedField) {
return specialField.fields.includes(selectedField);
},
this,
);
fields.splice(position, 0, specialField.name);
fields = _.difference(fields, specialField.fields);
}
return fields;
};
// If the user selected a special field, make sure we convert those first
if (this.selectedSpecialFields && this.selectedSpecialFields.length) {
this.selectedSpecialFields.forEach(function (specialFiend) {
fields = replaceWithSpecialField(fields, specialFiend);
}, this);
}
// Search for remaining special fields given the fields and model values
var matchingSpecialField = this.getSpecialField(fields);
// There may be more than one special field in the list of fields...
while (matchingSpecialField !== null) {
fields = replaceWithSpecialField(fields, matchingSpecialField);
// Check if there are more special fields remaining
matchingSpecialField = this.getSpecialField(fields);
}
return fields;
} catch (error) {
console.log(
"Error converting query field names to special field names in" +
" a Query Rule View. Returning the list of fields unchanged." +
" Error details : " +
error,
);
return fields;
}
},
/**
* Takes a list of query field names and checks if it contains any of the
* {@link QueryRuleView#specialFields special field names}. Returns the list with
* the special field names replaced with the actual field names that those special
* fields represent. Stores the name of each special field name removed in an
* array set on the view's selectedSpecialFields property. selectedSpecialFields
* is cleared each time this function runs. This function is the opposite of
* {@link QueryRuleView#convertToSpecialFields}
* @param {string[]} fields] - The list of field names to convert
* @returns {string[]} - The converted list of field names. If there were no
* special fields detected, or if there's an error, then then the field names are
* returned unchanged.
*
* @param {string[]} fields - The list of fields to convert to actual query
* service index fields
* @returns {string[]} - Returns the list of fields with any special field
* replaced with real fields from the query service index
*
* @since 2.15.0
*/
convertFromSpecialFields: function (fields) {
try {
this.selectedSpecialFields = [];
if (this.specialFields) {
this.specialFields.forEach(function (specialField) {
var index = fields.indexOf(specialField.name);
if (index >= 0) {
// Keep a record that the user selected a special field (useful in the
// case that the special field is just a duplicate of another field)
this.selectedSpecialFields.push(specialField);
fields.splice.apply(
fields,
[index, 1].concat(specialField.fields),
);
}
}, this);
}
return fields;
} catch (error) {
console.log(
"Error converting special query fields to query fields that" +
" exist in the index in a Query Rule View. Returning the fields" +
" unchanged. Error details: " +
error,
);
return fields;
}
},
/**
* Create and insert an input that allows the user to select a metadata field to
* query
*/
addFieldSelect: function () {
try {
// Check whether the filter model set on this view contains query fields
// and values that match one of the special rules. If it does,
// convert the list of field names to special field to pass on to the
// Query Field Select View.
var selectedFields = _.clone(this.model.get("fields"));
var selectedFields = this.convertToSpecialFields(selectedFields);
this.fieldSelect = new QueryFieldSelect({
selected: selectedFields,
excludeFields: this.excludeFields,
addFields: this.specialFields,
separatorText: this.model.get("fieldsOperator"),
});
this.fieldSelect.$el.addClass(this.fieldsClass);
this.el.append(this.fieldSelect.el);
this.fieldSelect.render();
// Update the model when the fieldsOperator changes
this.stopListening(this.fieldSelect, "separatorChanged");
this.listenTo(
this.fieldSelect,
"separatorChanged",
function (newOperator) {
this.model.set("fieldsOperator", newOperator);
},
);
// Update model when the selected fields change
this.stopListening(this.fieldSelect, "changeSelection");
this.listenTo(
this.fieldSelect,
"changeSelection",
this.handleFieldChange,
);
} catch (e) {
console.error(
"Error adding a metadata selector input in the Query Rule" +
" View, error message:",
e,
);
}
},
/**
* handleFieldChange - Called when the Query Field Select View triggers a change
* event. Updates the model with the new fields, and if required,
* 1) converts the filter model to a different type based on the types of fields
* selected, 2) updates the operator select and the value select
*
* @param {string[]} newFields The list of new query fields that were selected
*/
handleFieldChange: function (newFields) {
try {
// Uncomment the following chunk to clear operator & values when the field
// input is cleared.
// if(!newFields || newFields.length === 0 || newFields[0] === ""){
// if(this.operatorSelect){
// this.operatorSelect.changeSelection([""]);
// }
// this.model.set("fields", this.model.defaults().fields);
// return
// }
// Get the selected operator before the field changed
var opBefore = this.getSelectedOperator();
// If any of the new fields are special fields, replace them with the
// actual query fields before setting them in the model...
newFields = this.convertFromSpecialFields(newFields);
// Get the current type of filter and required type given the newly selected
// fields
var typeBefore = this.model.get("nodeName"),
typeAfter = MetacatUI.queryFields.getRequiredFilterType(newFields);
// If the type has changed, then replace the model with one of the correct
// type, update the value and operator inputs, and do nothing else
if (typeBefore != typeAfter) {
this.model = this.model.collection.replaceModel(this.model, {
filterType: typeAfter,
fields: newFields,
});
this.removeInput("value");
this.removeInput("operator");
this.addOperatorSelect("");
return;
}
// If the filter model type is the same, and the operator options are the same
// for the selected fields, then update the model
this.model.set("fields", newFields);
// Get the selected operator now that we've updated the model with new fields
var opAfter = this.getSelectedOperator();
// Add an empty operator input field, if there isn't one
if (!this.operatorSelect) {
this.addOperatorSelect("");
// If the operator options have changed, refresh the operator input
} else if (opAfter !== opBefore) {
this.removeInput("operator");
// Make sure that we overwrite any values that don't apply to the new options.
this.handleOperatorChange([""]);
this.addOperatorSelect("");
return;
}
// Refresh the value select in case a different value input is required for
// the new fields
if (this.valueSelect) {
this.removeInput("value");
this.addValueSelect();
}
} catch (e) {
console.error(
"Failed to handle query field change in the Query Rule View," +
" error message: " +
e,
);
}
},
/**
* Create and insert an input field where the user can select an operator for the
* given rule. Operators will vary depending on filter model type.
*
* @param {string} selectedOperator - optional. The label of an operator to
* pre-select. Set to an empty string to render an empty operator selector.
*/
addOperatorSelect: function (selectedOperator) {
try {
var view = this;
var operatorError = false;
var options = this.getOperatorOptions();
// Identify the selected operator for existing models
if (typeof selectedOperator !== "string") {
selectedOperator = this.getSelectedOperator();
// If there was no operator found, then this is probably an unsupported
// combination of exclude + matchSubstring + filterType
if (selectedOperator === "") {
operatorError = true;
}
}
if (selectedOperator === "") {
selectedOperator = [];
} else {
selectedOperator = [selectedOperator];
}
this.operatorSelect = new SearchableSelect({
options: options,
allowMulti: false,
inputLabel: "Select an operator",
clearable: false,
placeholderText: "Select an operator",
selected: selectedOperator,
});
this.operatorSelect.$el.addClass(this.operatorClass);
this.el.append(this.operatorSelect.el);
if (operatorError) {
view.listenToOnce(view.operatorSelect, "postRender", function () {
view.operatorSelect.showMessage(
"Please select a valid operator",
"error",
true,
);
});
}
this.operatorSelect.render();
// Update model when the values change
this.stopListening(this.operatorSelect, "changeSelection");
this.listenTo(
this.operatorSelect,
"changeSelection",
this.handleOperatorChange,
);
} catch (e) {
console.error(
"Error adding an operator selector input in the Query Rule " +
"View, error message:",
e,
);
}
},
/**
* handleOperatorChange - When the operator selection is changed, update the model
* and re-set the value UI when required
*
* @param {string[]} newOperatorLabel The new operator label within an array,
* e.g. ["is greater than"]
*/
handleOperatorChange: function (newOperatorLabel) {
try {
var view = this;
if (!newOperatorLabel || newOperatorLabel[0] == "") {
var modelDefaults = this.model.defaults();
this.model.set({
min: modelDefaults.min,
max: modelDefaults.max,
values: modelDefaults.values,
});
this.removeInput("value");
return;
}
// Get the properties of the newly selected operator. The newOperatorLabel
// will be an array with one value. Select only from the available options,
// since there may be multiple options with the same label in
// this.operatorOptions.
var options = this.getOperatorOptions();
var operator = _.findWhere(options, { label: newOperatorLabel[0] });
// Gather information about which values are currently set on the model, and
// which are required
var // Type
type = view.model.get("nodeName"),
isNumeric = ["dateFilter", "numericFilter"].includes(type),
isRange = operator.hasMin && operator.hasMax,
// Values
modelValues = this.model.get("values"),
modelHasValues = modelValues
? modelValues && modelValues.length
: false,
modelFirstValue = modelHasValues ? modelValues[0] : null,
modelValueInt = parseInt(modelFirstValue)
? parseInt(modelFirstValue)
: null,
needsValue =
isNumeric &&
!modelValueInt &&
!operator.hasMin &&
!operator.hasMax,
// Min
modelMin = this.model.get("min"),
modelHasMin = modelMin === 0 || modelMin,
needsMin = operator.hasMin && !modelHasMin,
// Max
modelMax = this.model.get("max"),
modelHasMax = modelMax === 0 || modelMax,
needsMax = operator.hasMax && !modelHasMax;
// Some operator options include a specific value to be set on the model. For
// example, "is not empty", should set the model value to the "*" wildcard.
// For operators with these specific value requirements, update the filter
// model value and remove the value select input.
if (operator.values && operator.values.length) {
this.removeInput("value");
this.model.set("values", operator.values);
// If the operator does not have a default value, then ensure that there is
// a value select available.
} else {
if (!this.valueSelect) {
this.model.set("values", view.model.defaults().values);
this.addValueSelect();
}
}
// Update the model with true or false for matchSubstring and exclude
["matchSubstring", "exclude"].forEach((prop, i) => {
if (typeof operator[prop] !== "undefined") {
view.model.set(prop, operator[prop]);
} else {
view.model.set(prop, view.model.defaults()[prop]);
}
});
// Set min & max values as required by the operator
// TODO - test this strategy with dates...
// Add a minimum value if one is needed
if (needsMin) {
// Search for the min in the values, then in the max
if (modelValueInt || modelValueInt === 0) {
this.model.set("min", modelValueInt);
} else if (modelHasMax) {
this.model.set("min", modelMax);
} else {
this.model.set("min", 0);
}
}
// Add a maximum value if one is needed
if (needsMax) {
// Search for the min in the values, then in the max
if (modelValueInt || modelValueInt === 0) {
this.model.set("max", modelValueInt);
} else if (modelHasMin) {
this.model.set("max", modelMin);
} else {
this.model.set("max", 0);
}
}
// Add a value if one is needed
if (needsValue) {
if (modelHasMin) {
this.model.set("values", [modelMin]);
} else if (modelHasMax) {
this.model.set("values", [modelMax]);
} else {
this.model.set("values", [0]);
}
}
// Remove the minimum and max if they should not be included in the filter
if (modelHasMax && !operator.hasMax) {
this.model.set("max", this.model.defaults().max);
}
if (modelHasMin && !operator.hasMin) {
this.model.set("min", this.model.defaults().min);
}
if (isRange) {
this.model.set("range", true);
} else {
if (isNumeric) {
this.model.set("range", false);
} else {
this.model.unset("range");
}
}
// If the operator changed for a numeric or date field, reset the value
// select. This way it can change from a range to a single value input if
// needed.
if (isNumeric) {
this.removeInput("value");
this.addValueSelect();
}
} catch (e) {
console.error(
"Failed to handle the operator selection in a Query Rule " +
"view, error message: " +
e,
);
}
},
/**
* Get a list of {@link QueryRuleView#operatorOptions operatorOptions} that are
* allowed for this view's filter model
*
* @param {string[]} [fields] - Optional list of fields to use instead of the
* fields set on this view's Filter model
*
* @since 2.15.0
*/
getOperatorOptions: function (fields) {
try {
// Check which type of rule this is (boolean, numeric, text, date)
var type = this.model.get("nodeName");
// If this rule contains a special field, replace the real query field names
// with the special field names for the purpose of selecting operator options
var fields = fields || this.model.get("fields");
var fields = _.clone(fields);
var fields = this.convertToSpecialFields(fields);
// Get the list of options for a user to select from based on field name.
// All of the rule's fields must be contained within the operator option's
// list of allowed fields for it to be a match.
var options = _.filter(this.operatorOptions, function (option) {
if (option.fields) {
return _.every(fields, function (fieldName) {
return option.fields.includes(fieldName);
});
}
});
// Get the list of options for a user to select from based on type, if there
// were none that matched based on field names
if (!options || !options.length) {
options = _.filter(
this.operatorOptions,
function (option) {
if (option.types) {
return option.types.includes(type);
}
},
this,
);
}
return options;
} catch (error) {
console.log(
"Error getting operator options in a Query Rule View, " +
"Error details: " +
error,
);
}
},
/**
* getSelectedOperator - Based on values set on the model, get the label to show
* in the "operator" filed of the Query Rule
*
* @return {string} The operator label
*/
getSelectedOperator: function () {
try {
// This view
var view = this,
// The options that we will filter down
options = this.operatorOptions,
// The user-facing operator label that we will return
selectedOperator = "";
// --- Filter 1 - Filter options by type --- //
// Reduce list of options to only those that apply to the current filter type
var type = view.model.get("nodeName");
var options = this.getOperatorOptions();
// --- Filter 2 - filter by 'matchSubstring', 'exclude', 'min', 'max' --- //
// Create the conditions based on the model
var conditions = _.pick(
this.model.attributes,
"matchSubstring",
"exclude",
"min",
"max",
);
var isNumeric = ["dateFilter", "numericFilter"].includes(type);
if (!conditions.min && conditions.min !== 0) {
if (isNumeric) {
conditions.hasMin = false;
}
} else if (isNumeric) {
conditions.hasMin = true;
}
if (!conditions.max && conditions.max !== 0) {
if (isNumeric) {
conditions.hasMax = false;
}
} else if (isNumeric) {
conditions.hasMax = true;
}
delete conditions.min;
delete conditions.max;
var options = _.where(options, conditions);
// --- Filter 3 - filter based on the value, if there's > 1 option --- //
if (options.length > 1) {
// Model values that determine the user-facing operator eg ["*"], [true],
// [false]
var specialValues = _.compact(
_.pluck(this.operatorOptions, "values"),
),
specialValues = specialValues.map((val) => JSON.stringify(val)),
specialValues = _.uniq(specialValues);
options = options.filter(function (option) {
var modelValsStringified = JSON.stringify(
view.model.get("values"),
);
if (specialValues.includes(modelValsStringified)) {
if (JSON.stringify(option.values) === modelValsStringified) {
return true;
}
} else {
if (!option.values) {
return true;
}
}
});
}
// --- Return value --- //
if (options.length === 1) {
selectedOperator = options[0].label;
}
return selectedOperator;
} catch (e) {
console.error(
"Failed to select an operator in the Query Rule View, error" +
" message: " +
e,
);
}
},
/**
* getCategory - Given an array of query fields, get the user-facing category that
* these fields belong to. If there are fields from multiple categories, then a
* default "Text" category is returned.
*
* @param {string[]} fields An array of query (Solr) fields
* @return {string} The label for the category that the given fields belong to
*/
getCategory: function (fields) {
try {
var categories = [],
// When fields is empty or are different types
defaultCategory = "Text";
if (!fields || fields.length === 0 || fields[0] === "") {
return defaultCategory;
}
fields.forEach((field, i) => {
// Get the category of the field from the matching filter model in the Query
// Fields Collection
var fieldModel = MetacatUI.queryFields.findWhere({ name: field });
categories.push(fieldModel.get("category"));
});
// Test of all the fields are of the same type
var allEqual = categories.every((val, i, arr) => val === arr[0]);
if (allEqual) {
return categories[0];
} else {
return defaultCategory;
}
} catch (e) {
console.log(
"Failed to detect the category for a group of filters in the" +
" Query Rule View, error message: " +
e,
);
}
},
/**
* Create and insert an input field where the user can provide a search value
*/
addValueSelect: function () {
try {
var view = this;
(fields = this.model.get("fields")),
(filterType = MetacatUI.queryFields.getRequiredFilterType(fields)),
(category = this.getCategory(fields)),
(interfaces = this.valueSelectUImap),
(label = "");
// To help guide users to create valid queries, the type of value field will
// vary based on the type of field (i.e. filter nodeName), and the operator
// selected.
// Some user-facing operators (e.g. "is true") don't require a value to be set
var selectedOperator = _.findWhere(this.operatorOptions, {
label: this.getSelectedOperator(),
});
if (selectedOperator) {
if (selectedOperator.values && selectedOperator.values.length) {
return;
}
}
// Find the appropriate UI to use the the value select field. Find the first
// match in the valueSelectUImap according to the filter type and the
// categories associated with the metadata field.
var interfaceProperties = _.find(
interfaces,
function (thisInterface) {
var typesMatch = true,
categoriesMatch = true,
namesMatch = true;
if (
thisInterface.queryFields &&
thisInterface.queryFields.length
) {
fields.forEach((field, i) => {
if (thisInterface.queryFields.includes(field) === false) {
namesMatch = false;
}
});
}
if (
thisInterface.filterTypes &&
thisInterface.filterTypes.length
) {
typesMatch = thisInterface.filterTypes.includes(filterType);
}
if (thisInterface.categories && thisInterface.categories.length) {
categoriesMatch = thisInterface.categories.includes(category);
}
return typesMatch && categoriesMatch && namesMatch;
},
);
this.valueSelect = interfaceProperties.uiFunction.call(this);
if (interfaceProperties.label && interfaceProperties.label.length) {
label = $(
"<p class='subtle searchable-select-label'>" +
interfaceProperties.label +
"</p>",
);
}
// Append and render the chosen value selector
this.el.append(view.valueSelect.el);
this.valueSelect.$el.addClass(this.valuesClass);
view.valueSelect.render();
if (label) {
view.valueSelect.$el.prepend(label);
}
// Make sure the listeners set below are not set multiple times
this.stopListening(
view.valueSelect,
"changeSelection inputFocus separatorChanged",
);
// Update model when the values change - note that the date & numeric filter
// views do not trigger a 'changeSelection' event, (because they are not based
// on a SearchSelect View) but update the models directly
this.listenTo(
view.valueSelect,
"changeSelection",
this.handleValueChange,
);
// Update the model when the operator changes
this.listenTo(
view.valueSelect,
"separatorChanged",
function (newOperator) {
this.model.set("operator", newOperator);
},
);
// Show a message that reminds the user that capitalization matters when they
// are typing a value for a field that is case-sensitive.
this.listenTo(view.valueSelect, "inputFocus", function (event) {
var fields = this.model.get("fields");
var isCaseSensitive = _.some(fields, function (field) {
return MetacatUI.queryFields.findWhere({
name: field,
caseSensitive: true,
});
});
if (isCaseSensitive) {
var fieldsText = "The field";
if (fields.length > 1) {
fieldsText = "At least one of the fields";
}
var message =
"<i class='icon-lightbulb icon-on-left'></i> <b>Hint:</b> " +
fieldsText +
" you selected is case-sensitive. Capitalization matters here.";
view.valueSelect.showMessage(
message,
(type = "info"),
(removeOnChange = false),
);
} else {
view.valueSelect.removeMessages();
}
});
// Set the value to the value provided if there was one. Then validateValue()
} catch (e) {
console.error(
"Error adding a search value input in the Query Rule View," +
" error message:",
e,
);
}
},
/**
* handleValueChange - Called when the select values for rule are changed. Updates
* the model.
*
* @param {string[]} newValues The new values that were selected
*/
handleValueChange: function (newValues) {
try {
// TODO: validate values
// Don't add empty values to the model
newValues = _.reject(newValues, function (val) {
return val === "";
});
this.model.set("values", newValues);
} catch (e) {
console.error(
"Failed to handle a change in select values in the Query Ryle" +
" View, error message: " +
e,
);
}
},
// /**
// * Ensure the value entered is valid, given the metadata field selected.
// * If it's not, show an error. If it is, remove the error if there was one.
// *
// * @return {type} description
// */
// validateValue: function() {// TODO
// },
/**
* Remove one of the three input fields from the rule
*
* @param {string} inputType Which of the inputs to remove? "field", "operator",
* or "value"
*/
removeInput: function (inputType) {
try {
// TODO - what, if any, model updates should happen here?
switch (inputType) {
case "value":
if (this.valueSelect) {
this.stopListening(
this.valueSelect,
"changeSelection inputFocus",
);
this.valueSelect.remove();
this.valueSelect = null;
}
break;
case "operator":
if (this.operatorSelect) {
this.stopListening(this.operatorSelect, "changeSelection");
this.operatorSelect.remove();
this.operatorSelect = null;
}
break;
case "field":
if (this.fieldSelect) {
this.stopListening(this.fieldSelect, "changeSelection");
this.fieldSelect.remove();
this.fieldSelect = null;
}
break;
default:
console.error(
"Must specify either value, operator, or field in the" +
" removeInput function in the Query Rule View",
);
}
} catch (e) {
console.error(
"Error removing an input from the Query Rule View, error" +
" message:",
e,
);
}
},
/**
* Indicate to the user that the rule will be removed when they hover over the
* remove button.
*/
previewRemove: function (e) {
try {
var normalOpacity = 1.0,
previewOpacity = 0.2,
speed = 175;
var removeEl = e.target;
var subElements = this.$el.children().not(removeEl);
if (e.type === "mouseover") {
subElements.fadeTo(speed, previewOpacity);
$(removeEl).fadeTo(speed, normalOpacity);
}
if (e.type === "mouseout") {
subElements.fadeTo(speed, normalOpacity);
$(removeEl).fadeTo(speed, previewOpacity);
}
} catch (error) {
console.log(
"Error showing a preview of the removal of a Query Rule View," +
" details: " +
error,
);
}
},
/**
* removeSelf - When the delete button is clicked, remove this entire View and
* associated model
*/
removeSelf: function () {
try {
$("body .popover").remove();
$("body .tooltip").remove();
if (this.model && this.model.collection) {
this.model.collection.remove(this.model);
}
this.remove();
} catch (error) {
console.log("Error removing a Query Rule View, details: " + error);
}
},
},
);
});