Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow dropdown filter UI for sem_annotation field in portal editor #2539

Merged
merged 1 commit into from
Sep 24, 2024

Conversation

robyngit
Copy link
Member

Fixes issue #2538

@@ -291,7 +291,7 @@ define([
description:
"Allow people to select a search term from a list of options",
filterTypes: ["filter"],
blockedFields: [...MetacatUI.appModel.get("querySemanticFields")],
blockedFields: [],
modelFunction: function (attrs) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [eslint] <func-names> reported by reviewdog 🐶
Unexpected unnamed method 'modelFunction'.

@@ -291,7 +291,7 @@ define([
description:
"Allow people to select a search term from a list of options",
filterTypes: ["filter"],
blockedFields: [...MetacatUI.appModel.get("querySemanticFields")],
blockedFields: [],
modelFunction: function (attrs) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.

Suggested change
modelFunction: function (attrs) {
modelFunction (attrs) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.

], function (
$,
_,
Backbone,
Filter,
ChoiceFilter,
DateFilter,
ToggleFilter,
QueryFields,
QueryFieldSelect,
FilterView,
ChoiceFilterView,
DateFilterView,
ToggleFilterView,
Template,
) {
"use strict";
/**
* @class FilterEditorView
* @classdesc Creates a view of an editor for a custom search filter
* @classcategory Views/Filters
* @screenshot views/filters/FilterEditorView.png
* @since 2.17.0
* @name FilterEditorView
* @extends Backbone.View
* @constructor
*/
var FilterEditorView = Backbone.View.extend(
/** @lends FilterEditorView.prototype */ {
/**
* A Filter model to be rendered and edited in this view. The Filter model must be
* part of a Filters collection.
// TODO: Add support for boolean and number filters
* @type {Filter|ChoiceFilter|DateFilter|ToggleFilter}
*/
model: null,
/**
* If rendering an editor for a brand new Filter model, provide the Filters
* collection instead of the Filter model. A new model will be created and, if the
* user clicks save, it will be added to this Filters collection.
* @type {Filters}
*/
collection: null,
/**
* A reference to the PortalEditorView
* @type {PortalEditorView}
*/
editorView: undefined,
/**
* Set to true if rendering an editor for a brand new Filter model that is not yet
* part of a Filters collection. If isNew is set to true, then the view requires a
* Filters model set to the view's collection property. A model will be created.
*/
isNew: false,
/**
* The HTML classes to use for this view's element
* @type {string}
*/
className: "filter-editor",
/**
* References to the template for this view. HTML files are converted to
* Underscore.js templates
* @type {Underscore.Template}
*/
template: _.template(Template),
/**
* The classes to use for various elements in this view
* @type {Object}
* @property {string} fieldsContainer - the element in the template that
* will contain the input where a user can select metadata fields for the custom
* search filter.
* @property {string} editButton - The button a user clicks to start
* editing a search filter
* @property {string} cancelButton - the element in the template that a
* user clicks to undo any changes made to the filter and close the editing modal.
* @property {string} saveButton - the element in the template that a user
* clicks to add their filter changes to the parent Filters collection and close
* the editing modal.
* @property {string} deleteButton - the element in the template that a
* user clicks to remove the Filter model from the Filters collection
* @property {string} uiBuilderChoicesContainer - The container for the
* uiBuilderChoices and the associated instruction text
* @property {string} uiBuilderChoices - The container for each "button" a
* user can click to switch the filter type
* @property {string} uiBuilderChoice - The element that acts like a
* button that switches the filter type
* @property {string} uiBuilderChoiceActive - The class to add to a
* uiBuilderChoice buttons when that option is active/selected
* @property {string} uiBuilderLabel - The label that goes along with the
* uiBuilderChoice element
* @property {string} uiBuilderContainer - The element that will be turned
* into a carousel that switches between each UI Builder view when a user switches
* the filter type
* @property {string} modalInstructions - The class to add to the
* instruction text in the editing modal window
*/
classes: {
fieldsContainer: "fields-container",
editButton: "edit-button",
cancelButton: "cancel-button",
saveButton: "save-button",
deleteButton: "delete-button",
uiBuilderChoicesContainer: "ui-builder-choices-container",
uiBuilderChoices: "ui-builder-choices",
uiBuilderChoice: "ui-builder-choice",
uiBuilderChoiceActive: "selected",
uiBuilderLabel: "ui-builder-choice-label",
uiBuilderContainer: "ui-builder-container",
modalInstructions: "modal-instructions",
},
/**
* Strings to use to display various messages to the user in this view
* @property {string} editButton - The text to show in the button a user clicks to
* open the editing modal window.
* @property {string} addFilterButton - The text to show in the button a user
* clicks to add a new search filter and open an editing modal window.
* @property {string} step1 - The instructions placed just before the fields input
* @property {string} step2 - The instructions placed after the fields input and
* before the uiBuilder select
* @property {string} filterNotAllowed - The message to show when a filter type
* doesn't work with the selected metadata fields
* @property {string} saveButton - Text for the button at the bottom of the
* editing modal that adds the filter model changes to the parent Filters
* collection and closes the modal
* @property {string} cancelButton - Text for the button at the bottom of the
* editing modal that closes the modal window without making any changes.
* @property {string} deleteButton - Text for the button at the bottom of the
* editing modal that removes the Filter model from the Filters collection.
* @property {string} validationError - The message to show at the top of the
* modal when there is at least one validation error.
* @property {string} noFilterOption - The message to show when there is no UI
* available for the selected field or combination of fields.
*/
text: {
editButton: "EDIT",
addFilterButton: "Add a search filter",
step1: "Let people filter your data by",
step2: "...using the following interface",
filterNotAllowed:
"This interface doesn't work with the metadata fields you" +
" selected. Change the 'filter data by' option to use this interface.",
saveButton: "Use these filter settings",
cancelButton: "Cancel",
deleteButton: "Remove filter",
validationError:
"Please provide the content flagged below before saving this " +
"search filter.",
noFilterOption:
"There are currently no filter options available to support " +
"this field, or this combination of fields. Change the 'filter data by' " +
"option to select an interface.",
},
/**
* A function that returns a Backbone events object
* @return {object} A Backbone events object - an object with the events this view
* will listen to and the associated function to call.
*/
events: function () {
var events = {};
events["click ." + this.classes.uiBuilderChoice] =
"handleFilterIconClick";
return events;
},
/**
* A list of query fields names to exclude from the list of options in the
* QueryFieldSelectView
* @type {string[]}
*/
excludeFields: MetacatUI.appModel.get("collectionQueryExcludeFields"),
/**
* An additional field object contains the properties for an additional query
* field to add to the QueryFieldSelectView 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.
*/
/**
* A list of additional fields which are not retrieved from the query service
* index, but which should be added to the list of options in the
* QueryFieldSelectView. 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[]}
*/
specialFields: [],
/**
* The path to the directory that contains the SVG files which are used like an
* icon to represent each UI type
* @type {string}
*/
iconDir: "templates/filters/filterIcons/",
/**
* A single type of custom search filter that a user can select. An option
* represents a specific Filter model type and uses that associated Filter View.
* @typedef {Object} UIBuilderOption
* @property {string} label - The user-facing label to show for this option
* @property {string} modelType - The name of the filter model type that that this
* UI builder should create. Only one is allowed. The model must be one of the six
* filters that are allowed in a Portal "UIFilterGroupType". See
* {@link https://github.com/DataONEorg/collections-portals-schemas/blob/master/schemas/portals.xsd}.
* @property {string} iconFileName - The file name, including extension, of the SVG
* icon used to represent this option
* @property {string} description - A very brief, user-facing description of how
* this filter works
* @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
* @property {string[]} blockedFields - An array of one or more search
* fields for which this interface should be blocked
* {@link QueryField#filterTypesMap}, and correspond to one of the four filter
* types that are allowed in a Collection definition. See
* {@link https://github.com/DataONEorg/collections-portals-schemas/blob/master/schemas/collections.xsd}.
* This property is used to help users match custom search filter UIs to
* appropriate query fields.
* @property {function} modelFunction - A function that takes an optional object
* with model properties and returns an instance of a model to use for this UI
* builder
* @property {function} uiFunction - A function that takes the model as an argument
* and returns the filter UI builder view for this option
*/
/**
* The list of UI types that a user can select from. They will appear in the
* carousel in the order they are listed here.
* @type {UIBuilderOption[]}
*/
uiBuilderOptions: [
{
label: "Free text",
modelType: "Filter",
iconFileName: "filter.svg",
description: "Allow people to search using any text they enter",
filterTypes: ["filter"],
blockedFields: [],
modelFunction: function (attrs) {
return new Filter(attrs);
},
uiFunction: function (model) {
return new FilterView({
model: model,
mode: "uiBuilder",
});
},
},
{
label: "Dropdown",
modelType: "ChoiceFilter",
iconFileName: "choice.svg",
description:
"Allow people to select a search term from a list of options",
filterTypes: ["filter"],
blockedFields: [],
modelFunction: function (attrs) {
return new ChoiceFilter(attrs);
},
uiFunction: function (model) {
return new ChoiceFilterView({
model: model,
mode: "uiBuilder",
});
},
},
{
label: "Year slider",
modelType: "DateFilter",
iconFileName: "number.svg",
description: "Let people search for a range of years",
filterTypes: ["dateFilter"],
blockedFields: [...MetacatUI.appModel.get("querySemanticFields")],
modelFunction: function (attrs) {
return new DateFilter(attrs);
},
uiFunction: function (model) {
return new DateFilterView({
model: model,
mode: "uiBuilder",
});
},
},
{
label: "Toggle",
modelType: "ToggleFilter",
iconFileName: "toggle.svg",
description:
"Let people add or remove a single, specific search term",
filterTypes: ["filter"],
blockedFields: [...MetacatUI.appModel.get("querySemanticFields")],
modelFunction: function (attrs) {
return new ToggleFilter(attrs);
},
uiFunction: function (model) {
return new ToggleFilterView({
model: model,
mode: "uiBuilder",
});
},
},
],
/**
* Executed when this view is created
* @param {object} options - A literal object of options to pass to this view
* @property {Filter|ChoiceFilter|DateFilter|ToggleFilter} options.model - The
* filter model to render an editor for. It must be part of a Filters collection.
*/
initialize: function (options) {
try {
// Ensure the query fields are cached for limitUITypes()
if (typeof MetacatUI.queryFields === "undefined") {
MetacatUI.queryFields = new QueryFields();
MetacatUI.queryFields.fetch();
}
if (!options || typeof options != "object") {
var options = {};
}
this.editorView = options.editorView || null;
if (!options.isNew) {
// If this view is an editor for an existing Filter model, check that the model
// and the Filters collection is provided.
if (!options.model) {
console.log(
"A Filter model is required to render a Filter Editor View",
);
return;
}
if (!options.model.collection) {
console.log(
"The Filter model for a FilterEditorView must be part of a" +
" Filters collection",
);
return;
}
// Set the model and collection on the view
this.model = options.model;
this.collection = options.model.collection;
} else {
// If this is an editor for a new Filter model, create a default model and
// make sure there is a Filters collection to add it to
if (!options.collection) {
console.log(
"A Filters collection is required to render a " +
"FilterEditorView for a new Filters model.",
);
return;
}
this.model = new Filter();
this.collection = options.collection;
this.isNew = true;
}
} catch (error) {
console.log(
"Error creating an FilterEditorView. Error details: " + error,
);
}
},
/**
* Render the view
*/
render: function () {
try {
// Save a reference to this view
var view = this;
// Create and insert an "edit" or a "add filter" button for the filter.
var buttonText = this.text.editButton,
buttonClasses = this.classes.editButton,
buttonIcon = "pencil";
// Text & styling is different for the "add a new filter" button
if (this.isNew) {
buttonText = this.text.addFilterButton;
buttonIcon = "plus";
buttonClasses = buttonClasses + " btn";
this.$el.addClass("new");
}
var editButton = $(
"<a class='" +
buttonClasses +
"'>" +
"<i class='icon icon-" +
buttonIcon +
" icon-on-left'></i> " +
buttonText +
"</a>",
);
this.$el.prepend(editButton);
// Render the editor modal on-the-fly to make the application load faster.
// No need to create editing modals for filters that a user doesn't edit.
editButton.on("click", function () {
view.renderEditorModal.call(view);
});
// Save a reference to this view
this.$el.data("view", this);
return this;
} catch (error) {
console.log(
"Error rendering an FilterEditorView. Error details: " + error,
);
}
},
/**
* Render and show the modal window that has all the components for editing a
* filter. This is created on-the-fly because creating these modals all at once in
* a FilterGroupsView in edit mode takes too much time.
*/
renderEditorModal: function () {
try {
// Save a reference to this view
var view = this;
// The list of UI Filter Editor options needs to be mutable. We will save the
// draft filter models, and the associated editor views to this list. Rewrite
// this.uiBuilders every time the editor modal is re-rendered.
this.uiBuilders = [];
this.uiBuilderOptions.forEach(function (opt) {
this.uiBuilders.push(_.clone(opt));
}, this);
// Create and insert the modal window that will contain the editing interface
var modalHTML = this.template({
classes: view.classes,
text: view.text,
});
this.modalEl = $(modalHTML);
this.$el.append(this.modalEl);
// Start rendering the metadata field input only after the modal is shown.
// Otherwise this step slows the rendering down, leaves too much of a delay
// before the modal appears.
this.modalEl.off();
this.modalEl.on("shown", function (event) {
view.modalEl.off("shown");
view.renderFieldInput();
});
this.modalEl.modal("show");
// Add listeners to the modal buttons save or cancel changes
this.activateModalButtons();
// Create and insert the "buttons" to switch filter type, and the elements
// that will contain the UI building interfaces for each filter type.
this.renderUIBuilders();
// Select and render the UI Filter Editor for the filter model set on this
// view.
this.switchFilterType();
// Disable any filter types that do not match the currently selected fields
this.handleFieldChange(_.clone(view.model.get("fields")));
} catch (error) {
console.log(
"There was an error rendering the modal in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Hide and destroy the filter editor modal window
*/
hideModal: function () {
try {
var view = this;
view.modalEl.off("hidden");
view.modalEl.on("hidden", function () {
view.modalEl.off();
view.modalEl.remove();
});
view.modalEl.modal("hide");
} catch (error) {
console.log(
"There was an error hiding the editing modal in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Find the save and cancel buttons in the editing modal window, and add listeners
* that close the modal and update the Filters collection on save
*/
activateModalButtons: function () {
try {
var view = this;
// The buttons at the bottom of the modal
var saveButton = this.modalEl.find("." + this.classes.saveButton),
cancelButton = this.modalEl.find("." + this.classes.cancelButton),
deleteButton = this.modalEl.find("." + this.classes.deleteButton);
// Add listeners to the modal's "delete", "save", and "cancel" buttons.
// SAVE
saveButton.on("click", function (event) {
// Don't allow user to save a filter with a field that doesn't have a
// matching UI type supported yet.
if (view.noFilterOptions) {
view.handleErrors();
// Switch the message from "warning" to "error" so that it's clear this is
// the reason the user cannot save the filter
view.showNoFilterOptionMessage(false, "error");
return;
}
var results = view.createModel();
if (results.success === false) {
view.handleErrors(results.errors);
return;
}
saveButton.off("click");
view.hideModal();
// Only update the collection after the modal has closed because adding a
// new model triggers a re-render of the FilterGroupsView which interferes
// with removing the modal.
var oldModel = view.model;
// Update the filter model in the parent Filters collection
view.model = view.collection.replaceModel(oldModel, results.model);
if (view.editorView) {
view.editorView.showControls();
}
});
// CANCEL
cancelButton.on("click", function (event) {
cancelButton.off("click");
view.currentUIBuilder = null;
view.hideModal();
});
// DELETE
deleteButton.on("click", function (event) {
deleteButton.off("click");
view.hideModal();
if (!view.isNew) {
view.collection.remove(view.model);
}
if (view.editorView) {
view.editorView.showControls();
}
});
} catch (error) {
console.log(
"There was an error activating the modal buttons in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Create and insert the "buttons" to switch filter type and the elements
* that will contain the UI building interfaces for each filter type.
*/
renderUIBuilders: function () {
try {
var view = this;
// The container for the list of filter icons that allows users to switch
// between filter types, plus the associated instruction paragraph
var uiBuilderChoicesContainer = this.modalEl.find(
"." + this.classes.uiBuilderChoicesContainer,
);
// The container for just the icons/buttons
var uiBuilderChoices = $("<div></div>").addClass(
this.classes.uiBuilderChoices,
);
uiBuilderChoicesContainer.append(uiBuilderChoices);
// uiBuilderCarousel will contain all of the UIBuilder views as slides
this.uiBuilderCarousel = this.modalEl.find(
"." + this.classes.uiBuilderContainer,
);
// The bootstrap carousel plugin requires the carousel slide times to be
// contained within an inner div with the class 'carousel-inner'
var carouselInner = $('<div class="carousel-inner"></div>');
this.uiBuilderCarousel.append(carouselInner);
// Create a container and button for each uiBuilder option
this.uiBuilders.forEach(function (uiBuilder) {
// Create a label button that allows the user to select the given UI
// Create the button label
var labelEl = $("<h5>" + uiBuilder.label + "</h5>").addClass(
view.classes.uiBuilderLabel,
);
// Create the button
var button = $("<div></div>")
.addClass(view.classes.uiBuilderChoice)
.attr("data-filter-type", uiBuilder.modelType)
.append(labelEl);
// Insert the uiBuilder icon SVG into the button
var svgPath = "text!" + this.iconDir + uiBuilder.iconFileName;
require([svgPath], function (svgString) {
button.append(svgString);
});
// Add a tooltip with description to the button
button.tooltip({
title: uiBuilder.description,
delay: {
show: 900,
hide: 50,
},
});
// Insert the button into the list of uiBuilder choices
uiBuilderChoices.append(button);
// Create and insert the container / carousel slide. The carousel plugin
// requires slides to have the class 'item'. Save the container to the
// list of uiBuilder options.
var uiBuilderContainer = $('<div class="item"></div>');
carouselInner.append(uiBuilderContainer);
// Add the button and container to the list of uiBuilders to make it
// easy to switch between filter types
uiBuilder.container = uiBuilderContainer;
uiBuilder.button = button;
}, this);
// Initialize the carousel
this.uiBuilderCarousel.addClass("slide");
this.uiBuilderCarousel.addClass("carousel");
this.uiBuilderCarousel.carousel({
interval: false,
});
// Need active class on at least one item for carousel to work properly
this.uiBuilderCarousel.find(".item").first().addClass("active");
} catch (error) {
console.log(
"There was an error rendering the UI filter builders in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Create and insert the component that is used to edit the fields attribute of a
* Filter Model. Save it to the view so that the selected fields can be accessed
* on save.
*/
renderFieldInput: function () {
try {
var view = this;
var selectedFields = _.clone(view.model.get("fields"));
view.fieldInput = new QueryFieldSelect({
selected: selectedFields,
inputLabel: "Select one or more metadata fields",
excludeFields: view.excludeFields,
addFields: view.specialFields,
separator: view.model.get("fieldsOperator"),
});
view.modalEl
.find("." + view.classes.fieldsContainer)
.append(view.fieldInput.el);
view.fieldInput.render();
this.stopListening(view.fieldInput.model, "change:selected");
this.listenTo(
view.fieldInput.model,
"change:selected",
function (_model, newSelectedFields) {
view.handleFieldChange.call(view, newSelectedFields);
},
);
} catch (error) {
console.log(
"There was an error rendering a fields input in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Run whenever the user selects or removes fields from the Query Field input.
* This function checks which filter UIs support the type of Query Field selected,
* and then blocks or enables the UIs in the editor. This is done to help prevent
* users from building mis-matched search filters, e.g. "Year Slider" filters with
* text query fields.
* @param {string[]} selectedFields The Query Field names (i.e. Solr field names)
* of the newly selected fields
*/
handleFieldChange: function (selectedFields) {
try {
var view = this;
// Enable all UI types if no field is selected yet
if (
!selectedFields ||
!selectedFields.length ||
selectedFields[0] === ""
) {
this.uiBuilders.forEach(function (uiBuilder) {
view.allowUI(uiBuilder);
});
return;
}
// If at least one field is selected, then limit the available UI types to
// those that match the type of Query Field.
var type =
MetacatUI.queryFields.getRequiredFilterType(selectedFields);
this.uiBuilders.forEach(function (uiBuilder) {
if (
uiBuilder.filterTypes.includes(type) &&
view.isBuilderAllowedForFields(uiBuilder, selectedFields)
) {
view.allowUI(uiBuilder);
} else {
view.blockUI(uiBuilder);
}
});
} catch (error) {
console.log(
"There was an error handling a field change in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Marks a UI builder is blocked (so that it can't be selected) and updates the
* tooltip with text explaining that this UI can't be used with the selected
* fields. If the UI to block is the currently selected UI, then switches to the
* next allowed UI. If there are no UIs that are allowed, then shows a message and
* hides all UI builders.
* @param {UIBuilderOption} uiBuilder - The UI builder Object to block
*/
blockUI: function (uiBuilder) {
try {
var view = this;
uiBuilder.allowed = false;
uiBuilder.button.addClass("disabled");
uiBuilder.button.tooltip("destroy");
uiBuilder.button.tooltip({
title: view.text.filterNotAllowed,
delay: {
show: 400,
hide: 50,
},
});
// If the current UI is a blocked one...
if (this.currentUIBuilder === uiBuilder) {
// ... switch to the next unblocked one.
var allowedUIBuilder = _.findWhere(this.uiBuilders, {
allowed: true,
});
if (allowedUIBuilder) {
view.switchFilterType(allowedUIBuilder.modelType);
} else {
// If there is no UI available, then show a message
this.showNoFilterOptionMessage();
}
}
} catch (error) {
console.log(
"There was an error blocking a filter UI builder in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Marks a UI builder is allowed (so that it can be selected) and updates the
* tooltip text with the description of this UI. If it's displayed, this function
* hides the message that indicates that there are no allowed UIs that match the
* selected query fields.
* @param {UIBuilderOption} uiBuilder - The UI builder Object to block
*/
allowUI: function (uiBuilder) {
try {
// If at least one UI is allowed, then make sure the "no filter message" is
// hidden.
if (this.noFilterOptions) {
this.hideNoFilterOptionMessage();
}
uiBuilder.allowed = true;
uiBuilder.button.removeClass("disabled");
uiBuilder.button.tooltip("destroy");
uiBuilder.button.tooltip({
title: uiBuilder.description,
delay: {
show: 900,
hide: 50,
},
});
} catch (error) {
console.log(
"There was an error unblocking a filter UI builder in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Hides all filter builder UIs and displays a warning message indicating that
* there are currently no UI options that support the selected fields.
* @param {string} message A message to show. If not set, then the string set in
* the view's text.noFilterOption attribute is used.
* @param {string} [type="warning"] The type of message to display (warning,
* error, or info)
*/
showNoFilterOptionMessage: function (message, type = "warning") {
try {
this.noFilterOptions = true;
if (!message) {
message = this.text.noFilterOption;
}
if (this.noFilterOptionMessageEl) {
this.noFilterOptionMessageEl.remove();
}
this.noFilterOptionMessageEl = $(
'<div class="alert alert-' + type + '">' + message + "</div>",
);
this.uiBuilderCarousel.hide();
this.uiBuilderCarousel.after(this.noFilterOptionMessageEl);
} catch (error) {
console.log(
"There was an error showing a message to indicate that no filter builder " +
"UI options are allowed in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Removes the message displayed by the
* {@link FilterEditorView#showNoFilterOptionMessage} function and un-hides all
* the filter builder UIs.
*/
hideNoFilterOptionMessage: function () {
try {
this.noFilterOptions = false;
if (this.noFilterOptionMessageEl) {
this.noFilterOptionMessageEl.remove();
console.log(this.noFilterOptionMessageEl);
}
if (this.uiBuilderCarousel.is(":hidden")) {
this.uiBuilderCarousel.show();
}
} catch (error) {
console.log(
"There was an error hiding a message in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Functions to run when a user clicks the "save" button in the editing modal
* window. Creates a new Filter model with all of the new attributes that the user
* has selected. Checks if the model is valid. If it is, then returns the model.
* If it is not, then returns the errors.
* @param {Object} event The click event
* @return {Object} Returns an object with a success property set to either true
* (if there were no errors), or false (if there were errors). If there were
* errors, then the object also has an errors property with the errors return from
* the Filter validate function. If there were no errors, then the object contains
* a model property with the new Filter to be saved to the Filters collection.
*/
createModel: function (event) {
try {
var selectedUI = this.currentUIBuilder,
newModelAttrs = selectedUI.draftModel.toJSON();
// Set the new fields
newModelAttrs.fields = _.clone(this.fieldInput.model.get("selected"));
// set the new fieldsOperator
newModelAttrs.fieldsOperator = this.fieldInput.model.get("separator");
delete newModelAttrs.objectDOM;
delete newModelAttrs.cid;
// The collection's model function identifies the type of model to create
// based on the filterType attribute. Create a model before we add it to the
// collection, so that we can make sure it's valid first, while still allowing
// a user to press the UNDO button and not add any changes to the Filters
// collection.
var newModel = this.collection.model(newModelAttrs);
// Check if the filter is valid.
var newModelErrors = newModel.validate();
if (newModelErrors) {
return {
success: false,
errors: newModelErrors,
};
} else {
return {
success: true,
model: newModel,
};
}
} catch (error) {
console.log(
"There was an error updating a Filter model in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Shows errors in the filter editor modal window.
* @param {object} errors An object where keys represent the Filter model
* attribute that has an error, and the corresponding value explains the error in
* text.
*/
handleErrors: function (errors) {
try {
var view = this;
// Show a general error message in the modal. (Don't add it twice.)
if (view.validationErrorEl) {
view.validationErrorEl.remove();
}
view.validationErrorEl = $(
'<p class="alert alert-error">' +
view.text.validationError +
"</p>",
);
this.$el.find(".modal-body").prepend(view.validationErrorEl);
if (errors) {
// Show an error for the "fields" attribute (common to all Filters)
if (errors.fields) {
view.fieldInput.showMessage(errors.fields, "error", true);
}
// Show errors for the attributes specific to each Filter type
view.currentUIBuilder.view.showValidationErrors(errors);
}
} catch (error) {
console.log(
"There was an error in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Function that takes the event when a user clicks on one of the filter type
* options, gets the name of the desired filter type, and passes it to the switch
* filter function.
* @param {object} event The click event
*/
handleFilterIconClick: function (event) {
try {
// Get the new Filter Type from the click event. The name of the new Filter
// Type is stored as a data attribute in the clicked Filter icon.
// var filterTypeIcon =
var newFilterType = event.currentTarget.dataset.filterType;
// Pass the Filter Type to the switch filter function
this.switchFilterType(newFilterType);
} catch (error) {
console.log(
"There was an error handling a click event in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Switches the current draft Filter model to a different Filter model type.
* Carries over any common attributes from the previously selected filter type.
* If no filter type is provided, defaults to type of the view's model
* @param {string} newFilterType The name of the filter type to switch to
*/
switchFilterType: function (newFilterType) {
try {
var view = this;
// Use the filter type of the model if none is provided.
if (!newFilterType) {
newFilterType = this.model.type;
}
// Get the properties of the Filter UI Editor for the new filter type.
var uiBuilder = _.findWhere(this.uiBuilders, {
modelType: newFilterType,
});
// Don't allow user to build a mis-matched filter (e.g. text filter with date
// field)
if (uiBuilder.allowed === false) {
return;
}
// (Index is used for the carousel)
var index = this.uiBuilders.indexOf(uiBuilder);
// Treat the first Filter in the list of filter UI editor options as the
// default
if (!uiBuilder) {
uiBuilder = this.uiBuilders[0];
filterType = uiBuilder.modelType;
}
// Create an object with the properties to pass on to the new draft model
var newModelAttrs = {};
// If there is a currently selected UI editor, then find the common model
// attributes that we should pass on to the new UI editor type
if (this.currentUIBuilder) {
newModelAttrs = this.getCommonAttributes(
this.currentUIBuilder.draftModel,
newFilterType,
);
}
// All search filter models are UI Filter Type
newModelAttrs.isUIFilterType = true;
// If a UI editor has already been created for this Filter Type, then just
// update the pre-existing draft model. This way, if a user has already
// selected content that is specific to a filter type (e.g. choices for a
// choiceFilter), that content will still be there when they switch back to
// it. Otherwise, use a clone of the model set on this view. We will update
// the actual model in the Filters collection only when the user clicks save.
if (!uiBuilder.draftModel) {
if (this.model.type == newFilterType) {
uiBuilder.draftModel = this.model.clone();
} else {
uiBuilder.draftModel = uiBuilder.modelFunction({
isUIFilterType: true,
});
}
}
if (Object.keys(newModelAttrs).length) {
uiBuilder.draftModel.set(newModelAttrs);
}
// Save the new selection to the view
this.currentUIBuilder = uiBuilder;
// Find the container for this filter type
var uiBuilderContainer = uiBuilder.container;
// Create or update view
this.currentUIBuilder.view = this.currentUIBuilder.uiFunction(
uiBuilder.draftModel,
);
uiBuilderContainer.html(this.currentUIBuilder.view.el);
this.currentUIBuilder.view.render();
// Add the selected/active class to the clicked FilterTypeIcon, remove it from
// the other icons.
this.uiBuilders.forEach(function (uiBuilder) {
uiBuilder.button.removeClass(view.classes.uiBuilderChoiceActive);
});
this.currentUIBuilder.button.addClass(
view.classes.uiBuilderChoiceActive,
);
// Have the carousel slide to the selected uiBuilder container.
this.uiBuilderCarousel.carousel(index);
} catch (error) {
console.log(
"There was an error switching filter types in a FilterEditorView." +
" Error details: " +
error,
);
}
},
/**
* Checks for attribute keys that are the same between a given Filter model, and a
* new Filter model type. Returns an object of model attributes that are relevant
* to the new Filter model type. The values for this object will be pulled from
* the given model. objectDOM, cid, and nodeName attributes are always excluded.
*
* @param {Filter} filterModel A filter model
* @param {string} newFilterType The name of the new filter model type
*
* @returns {Object} returns the model attributes from the given filterModel that
* are also relevant to the new Filter model type.
*/
getCommonAttributes: function (filterModel, newFilterType) {
try {
// The filter model attributes that are common to both the current Filter Model
// and the new Filter Type that we want to create.
var commonAttributes = {};
// Given the newFilterType string, get the default attribute names for a new
// model of that type.
var uiBuilder = _.findWhere(this.uiBuilders, {
modelType: newFilterType,
});
var defaultAttrs = uiBuilder.modelFunction().defaults();
var defaultAttrNames = Object.keys(defaultAttrs);
// Check if any of those attribute types exist in the current filter model.
// If they do, include them in the common attributes object.
var currentAttrs = filterModel.toJSON();
defaultAttrNames.forEach(function (attrName) {
var valueInDraftModel = currentAttrs[attrName];
if (
valueInDraftModel ||
(valueInDraftModel === 0) | (valueInDraftModel === false)
) {
commonAttributes[attrName] = valueInDraftModel;
}
}, this);
// Exclude attributes that shouldn't be passed to a new model, like the
// objectDOM and the model ID.
delete commonAttributes.objectDOM;
delete commonAttributes.cid;
delete commonAttributes.nodeName;
// Return the common attributes
return commonAttributes;
} catch (error) {
console.log(
"There was an error getting common model attributes in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Determine whether a particular UIBuilder is allowed for a set of
* search field names. For use in handleFieldChange to enable or disable
* certain UI builders using allowUI/blockUI when the user selects
* different search fields.
*
* @param {UIBuilderOption} uiBuilder The UIBuilderOption object to
* check
* @param {string[]} selectedFields An array of search field names to
* look for restrictions
*
* @return {boolean} Whether or not the uiBuilder is allowed for all
* of selectedFields. Returns true only if all selectedFields are
* allowed, not just one or more.
*/
isBuilderAllowedForFields: function (uiBuilder, selectedFields) {
// Return true early if this uiBuilder has no blockedFields
if (!uiBuilder.blockedFields || uiBuilder.blockedFields.length == 0) {
return true;
}
// Check each blockedField for presence in selectedFields
var isAllowed = uiBuilder.blockedFields.map(function (blockedField) {
return !selectedFields.includes(blockedField);
});
return isAllowed.every(function (e) {
return e;
});
},
},
);
return FilterEditorView;
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.

var FilterEditorView = Backbone.View.extend(
/** @lends FilterEditorView.prototype */ {
/**
* A Filter model to be rendered and edited in this view. The Filter model must be
* part of a Filters collection.
// TODO: Add support for boolean and number filters
* @type {Filter|ChoiceFilter|DateFilter|ToggleFilter}
*/
model: null,
/**
* If rendering an editor for a brand new Filter model, provide the Filters
* collection instead of the Filter model. A new model will be created and, if the
* user clicks save, it will be added to this Filters collection.
* @type {Filters}
*/
collection: null,
/**
* A reference to the PortalEditorView
* @type {PortalEditorView}
*/
editorView: undefined,
/**
* Set to true if rendering an editor for a brand new Filter model that is not yet
* part of a Filters collection. If isNew is set to true, then the view requires a
* Filters model set to the view's collection property. A model will be created.
*/
isNew: false,
/**
* The HTML classes to use for this view's element
* @type {string}
*/
className: "filter-editor",
/**
* References to the template for this view. HTML files are converted to
* Underscore.js templates
* @type {Underscore.Template}
*/
template: _.template(Template),
/**
* The classes to use for various elements in this view
* @type {Object}
* @property {string} fieldsContainer - the element in the template that
* will contain the input where a user can select metadata fields for the custom
* search filter.
* @property {string} editButton - The button a user clicks to start
* editing a search filter
* @property {string} cancelButton - the element in the template that a
* user clicks to undo any changes made to the filter and close the editing modal.
* @property {string} saveButton - the element in the template that a user
* clicks to add their filter changes to the parent Filters collection and close
* the editing modal.
* @property {string} deleteButton - the element in the template that a
* user clicks to remove the Filter model from the Filters collection
* @property {string} uiBuilderChoicesContainer - The container for the
* uiBuilderChoices and the associated instruction text
* @property {string} uiBuilderChoices - The container for each "button" a
* user can click to switch the filter type
* @property {string} uiBuilderChoice - The element that acts like a
* button that switches the filter type
* @property {string} uiBuilderChoiceActive - The class to add to a
* uiBuilderChoice buttons when that option is active/selected
* @property {string} uiBuilderLabel - The label that goes along with the
* uiBuilderChoice element
* @property {string} uiBuilderContainer - The element that will be turned
* into a carousel that switches between each UI Builder view when a user switches
* the filter type
* @property {string} modalInstructions - The class to add to the
* instruction text in the editing modal window
*/
classes: {
fieldsContainer: "fields-container",
editButton: "edit-button",
cancelButton: "cancel-button",
saveButton: "save-button",
deleteButton: "delete-button",
uiBuilderChoicesContainer: "ui-builder-choices-container",
uiBuilderChoices: "ui-builder-choices",
uiBuilderChoice: "ui-builder-choice",
uiBuilderChoiceActive: "selected",
uiBuilderLabel: "ui-builder-choice-label",
uiBuilderContainer: "ui-builder-container",
modalInstructions: "modal-instructions",
},
/**
* Strings to use to display various messages to the user in this view
* @property {string} editButton - The text to show in the button a user clicks to
* open the editing modal window.
* @property {string} addFilterButton - The text to show in the button a user
* clicks to add a new search filter and open an editing modal window.
* @property {string} step1 - The instructions placed just before the fields input
* @property {string} step2 - The instructions placed after the fields input and
* before the uiBuilder select
* @property {string} filterNotAllowed - The message to show when a filter type
* doesn't work with the selected metadata fields
* @property {string} saveButton - Text for the button at the bottom of the
* editing modal that adds the filter model changes to the parent Filters
* collection and closes the modal
* @property {string} cancelButton - Text for the button at the bottom of the
* editing modal that closes the modal window without making any changes.
* @property {string} deleteButton - Text for the button at the bottom of the
* editing modal that removes the Filter model from the Filters collection.
* @property {string} validationError - The message to show at the top of the
* modal when there is at least one validation error.
* @property {string} noFilterOption - The message to show when there is no UI
* available for the selected field or combination of fields.
*/
text: {
editButton: "EDIT",
addFilterButton: "Add a search filter",
step1: "Let people filter your data by",
step2: "...using the following interface",
filterNotAllowed:
"This interface doesn't work with the metadata fields you" +
" selected. Change the 'filter data by' option to use this interface.",
saveButton: "Use these filter settings",
cancelButton: "Cancel",
deleteButton: "Remove filter",
validationError:
"Please provide the content flagged below before saving this " +
"search filter.",
noFilterOption:
"There are currently no filter options available to support " +
"this field, or this combination of fields. Change the 'filter data by' " +
"option to select an interface.",
},
/**
* A function that returns a Backbone events object
* @return {object} A Backbone events object - an object with the events this view
* will listen to and the associated function to call.
*/
events: function () {
var events = {};
events["click ." + this.classes.uiBuilderChoice] =
"handleFilterIconClick";
return events;
},
/**
* A list of query fields names to exclude from the list of options in the
* QueryFieldSelectView
* @type {string[]}
*/
excludeFields: MetacatUI.appModel.get("collectionQueryExcludeFields"),
/**
* An additional field object contains the properties for an additional query
* field to add to the QueryFieldSelectView 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.
*/
/**
* A list of additional fields which are not retrieved from the query service
* index, but which should be added to the list of options in the
* QueryFieldSelectView. 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[]}
*/
specialFields: [],
/**
* The path to the directory that contains the SVG files which are used like an
* icon to represent each UI type
* @type {string}
*/
iconDir: "templates/filters/filterIcons/",
/**
* A single type of custom search filter that a user can select. An option
* represents a specific Filter model type and uses that associated Filter View.
* @typedef {Object} UIBuilderOption
* @property {string} label - The user-facing label to show for this option
* @property {string} modelType - The name of the filter model type that that this
* UI builder should create. Only one is allowed. The model must be one of the six
* filters that are allowed in a Portal "UIFilterGroupType". See
* {@link https://github.com/DataONEorg/collections-portals-schemas/blob/master/schemas/portals.xsd}.
* @property {string} iconFileName - The file name, including extension, of the SVG
* icon used to represent this option
* @property {string} description - A very brief, user-facing description of how
* this filter works
* @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
* @property {string[]} blockedFields - An array of one or more search
* fields for which this interface should be blocked
* {@link QueryField#filterTypesMap}, and correspond to one of the four filter
* types that are allowed in a Collection definition. See
* {@link https://github.com/DataONEorg/collections-portals-schemas/blob/master/schemas/collections.xsd}.
* This property is used to help users match custom search filter UIs to
* appropriate query fields.
* @property {function} modelFunction - A function that takes an optional object
* with model properties and returns an instance of a model to use for this UI
* builder
* @property {function} uiFunction - A function that takes the model as an argument
* and returns the filter UI builder view for this option
*/
/**
* The list of UI types that a user can select from. They will appear in the
* carousel in the order they are listed here.
* @type {UIBuilderOption[]}
*/
uiBuilderOptions: [
{
label: "Free text",
modelType: "Filter",
iconFileName: "filter.svg",
description: "Allow people to search using any text they enter",
filterTypes: ["filter"],
blockedFields: [],
modelFunction: function (attrs) {
return new Filter(attrs);
},
uiFunction: function (model) {
return new FilterView({
model: model,
mode: "uiBuilder",
});
},
},
{
label: "Dropdown",
modelType: "ChoiceFilter",
iconFileName: "choice.svg",
description:
"Allow people to select a search term from a list of options",
filterTypes: ["filter"],
blockedFields: [],
modelFunction: function (attrs) {
return new ChoiceFilter(attrs);
},
uiFunction: function (model) {
return new ChoiceFilterView({
model: model,
mode: "uiBuilder",
});
},
},
{
label: "Year slider",
modelType: "DateFilter",
iconFileName: "number.svg",
description: "Let people search for a range of years",
filterTypes: ["dateFilter"],
blockedFields: [...MetacatUI.appModel.get("querySemanticFields")],
modelFunction: function (attrs) {
return new DateFilter(attrs);
},
uiFunction: function (model) {
return new DateFilterView({
model: model,
mode: "uiBuilder",
});
},
},
{
label: "Toggle",
modelType: "ToggleFilter",
iconFileName: "toggle.svg",
description:
"Let people add or remove a single, specific search term",
filterTypes: ["filter"],
blockedFields: [...MetacatUI.appModel.get("querySemanticFields")],
modelFunction: function (attrs) {
return new ToggleFilter(attrs);
},
uiFunction: function (model) {
return new ToggleFilterView({
model: model,
mode: "uiBuilder",
});
},
},
],
/**
* Executed when this view is created
* @param {object} options - A literal object of options to pass to this view
* @property {Filter|ChoiceFilter|DateFilter|ToggleFilter} options.model - The
* filter model to render an editor for. It must be part of a Filters collection.
*/
initialize: function (options) {
try {
// Ensure the query fields are cached for limitUITypes()
if (typeof MetacatUI.queryFields === "undefined") {
MetacatUI.queryFields = new QueryFields();
MetacatUI.queryFields.fetch();
}
if (!options || typeof options != "object") {
var options = {};
}
this.editorView = options.editorView || null;
if (!options.isNew) {
// If this view is an editor for an existing Filter model, check that the model
// and the Filters collection is provided.
if (!options.model) {
console.log(
"A Filter model is required to render a Filter Editor View",
);
return;
}
if (!options.model.collection) {
console.log(
"The Filter model for a FilterEditorView must be part of a" +
" Filters collection",
);
return;
}
// Set the model and collection on the view
this.model = options.model;
this.collection = options.model.collection;
} else {
// If this is an editor for a new Filter model, create a default model and
// make sure there is a Filters collection to add it to
if (!options.collection) {
console.log(
"A Filters collection is required to render a " +
"FilterEditorView for a new Filters model.",
);
return;
}
this.model = new Filter();
this.collection = options.collection;
this.isNew = true;
}
} catch (error) {
console.log(
"Error creating an FilterEditorView. Error details: " + error,
);
}
},
/**
* Render the view
*/
render: function () {
try {
// Save a reference to this view
var view = this;
// Create and insert an "edit" or a "add filter" button for the filter.
var buttonText = this.text.editButton,
buttonClasses = this.classes.editButton,
buttonIcon = "pencil";
// Text & styling is different for the "add a new filter" button
if (this.isNew) {
buttonText = this.text.addFilterButton;
buttonIcon = "plus";
buttonClasses = buttonClasses + " btn";
this.$el.addClass("new");
}
var editButton = $(
"<a class='" +
buttonClasses +
"'>" +
"<i class='icon icon-" +
buttonIcon +
" icon-on-left'></i> " +
buttonText +
"</a>",
);
this.$el.prepend(editButton);
// Render the editor modal on-the-fly to make the application load faster.
// No need to create editing modals for filters that a user doesn't edit.
editButton.on("click", function () {
view.renderEditorModal.call(view);
});
// Save a reference to this view
this.$el.data("view", this);
return this;
} catch (error) {
console.log(
"Error rendering an FilterEditorView. Error details: " + error,
);
}
},
/**
* Render and show the modal window that has all the components for editing a
* filter. This is created on-the-fly because creating these modals all at once in
* a FilterGroupsView in edit mode takes too much time.
*/
renderEditorModal: function () {
try {
// Save a reference to this view
var view = this;
// The list of UI Filter Editor options needs to be mutable. We will save the
// draft filter models, and the associated editor views to this list. Rewrite
// this.uiBuilders every time the editor modal is re-rendered.
this.uiBuilders = [];
this.uiBuilderOptions.forEach(function (opt) {
this.uiBuilders.push(_.clone(opt));
}, this);
// Create and insert the modal window that will contain the editing interface
var modalHTML = this.template({
classes: view.classes,
text: view.text,
});
this.modalEl = $(modalHTML);
this.$el.append(this.modalEl);
// Start rendering the metadata field input only after the modal is shown.
// Otherwise this step slows the rendering down, leaves too much of a delay
// before the modal appears.
this.modalEl.off();
this.modalEl.on("shown", function (event) {
view.modalEl.off("shown");
view.renderFieldInput();
});
this.modalEl.modal("show");
// Add listeners to the modal buttons save or cancel changes
this.activateModalButtons();
// Create and insert the "buttons" to switch filter type, and the elements
// that will contain the UI building interfaces for each filter type.
this.renderUIBuilders();
// Select and render the UI Filter Editor for the filter model set on this
// view.
this.switchFilterType();
// Disable any filter types that do not match the currently selected fields
this.handleFieldChange(_.clone(view.model.get("fields")));
} catch (error) {
console.log(
"There was an error rendering the modal in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Hide and destroy the filter editor modal window
*/
hideModal: function () {
try {
var view = this;
view.modalEl.off("hidden");
view.modalEl.on("hidden", function () {
view.modalEl.off();
view.modalEl.remove();
});
view.modalEl.modal("hide");
} catch (error) {
console.log(
"There was an error hiding the editing modal in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Find the save and cancel buttons in the editing modal window, and add listeners
* that close the modal and update the Filters collection on save
*/
activateModalButtons: function () {
try {
var view = this;
// The buttons at the bottom of the modal
var saveButton = this.modalEl.find("." + this.classes.saveButton),
cancelButton = this.modalEl.find("." + this.classes.cancelButton),
deleteButton = this.modalEl.find("." + this.classes.deleteButton);
// Add listeners to the modal's "delete", "save", and "cancel" buttons.
// SAVE
saveButton.on("click", function (event) {
// Don't allow user to save a filter with a field that doesn't have a
// matching UI type supported yet.
if (view.noFilterOptions) {
view.handleErrors();
// Switch the message from "warning" to "error" so that it's clear this is
// the reason the user cannot save the filter
view.showNoFilterOptionMessage(false, "error");
return;
}
var results = view.createModel();
if (results.success === false) {
view.handleErrors(results.errors);
return;
}
saveButton.off("click");
view.hideModal();
// Only update the collection after the modal has closed because adding a
// new model triggers a re-render of the FilterGroupsView which interferes
// with removing the modal.
var oldModel = view.model;
// Update the filter model in the parent Filters collection
view.model = view.collection.replaceModel(oldModel, results.model);
if (view.editorView) {
view.editorView.showControls();
}
});
// CANCEL
cancelButton.on("click", function (event) {
cancelButton.off("click");
view.currentUIBuilder = null;
view.hideModal();
});
// DELETE
deleteButton.on("click", function (event) {
deleteButton.off("click");
view.hideModal();
if (!view.isNew) {
view.collection.remove(view.model);
}
if (view.editorView) {
view.editorView.showControls();
}
});
} catch (error) {
console.log(
"There was an error activating the modal buttons in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Create and insert the "buttons" to switch filter type and the elements
* that will contain the UI building interfaces for each filter type.
*/
renderUIBuilders: function () {
try {
var view = this;
// The container for the list of filter icons that allows users to switch
// between filter types, plus the associated instruction paragraph
var uiBuilderChoicesContainer = this.modalEl.find(
"." + this.classes.uiBuilderChoicesContainer,
);
// The container for just the icons/buttons
var uiBuilderChoices = $("<div></div>").addClass(
this.classes.uiBuilderChoices,
);
uiBuilderChoicesContainer.append(uiBuilderChoices);
// uiBuilderCarousel will contain all of the UIBuilder views as slides
this.uiBuilderCarousel = this.modalEl.find(
"." + this.classes.uiBuilderContainer,
);
// The bootstrap carousel plugin requires the carousel slide times to be
// contained within an inner div with the class 'carousel-inner'
var carouselInner = $('<div class="carousel-inner"></div>');
this.uiBuilderCarousel.append(carouselInner);
// Create a container and button for each uiBuilder option
this.uiBuilders.forEach(function (uiBuilder) {
// Create a label button that allows the user to select the given UI
// Create the button label
var labelEl = $("<h5>" + uiBuilder.label + "</h5>").addClass(
view.classes.uiBuilderLabel,
);
// Create the button
var button = $("<div></div>")
.addClass(view.classes.uiBuilderChoice)
.attr("data-filter-type", uiBuilder.modelType)
.append(labelEl);
// Insert the uiBuilder icon SVG into the button
var svgPath = "text!" + this.iconDir + uiBuilder.iconFileName;
require([svgPath], function (svgString) {
button.append(svgString);
});
// Add a tooltip with description to the button
button.tooltip({
title: uiBuilder.description,
delay: {
show: 900,
hide: 50,
},
});
// Insert the button into the list of uiBuilder choices
uiBuilderChoices.append(button);
// Create and insert the container / carousel slide. The carousel plugin
// requires slides to have the class 'item'. Save the container to the
// list of uiBuilder options.
var uiBuilderContainer = $('<div class="item"></div>');
carouselInner.append(uiBuilderContainer);
// Add the button and container to the list of uiBuilders to make it
// easy to switch between filter types
uiBuilder.container = uiBuilderContainer;
uiBuilder.button = button;
}, this);
// Initialize the carousel
this.uiBuilderCarousel.addClass("slide");
this.uiBuilderCarousel.addClass("carousel");
this.uiBuilderCarousel.carousel({
interval: false,
});
// Need active class on at least one item for carousel to work properly
this.uiBuilderCarousel.find(".item").first().addClass("active");
} catch (error) {
console.log(
"There was an error rendering the UI filter builders in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Create and insert the component that is used to edit the fields attribute of a
* Filter Model. Save it to the view so that the selected fields can be accessed
* on save.
*/
renderFieldInput: function () {
try {
var view = this;
var selectedFields = _.clone(view.model.get("fields"));
view.fieldInput = new QueryFieldSelect({
selected: selectedFields,
inputLabel: "Select one or more metadata fields",
excludeFields: view.excludeFields,
addFields: view.specialFields,
separator: view.model.get("fieldsOperator"),
});
view.modalEl
.find("." + view.classes.fieldsContainer)
.append(view.fieldInput.el);
view.fieldInput.render();
this.stopListening(view.fieldInput.model, "change:selected");
this.listenTo(
view.fieldInput.model,
"change:selected",
function (_model, newSelectedFields) {
view.handleFieldChange.call(view, newSelectedFields);
},
);
} catch (error) {
console.log(
"There was an error rendering a fields input in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Run whenever the user selects or removes fields from the Query Field input.
* This function checks which filter UIs support the type of Query Field selected,
* and then blocks or enables the UIs in the editor. This is done to help prevent
* users from building mis-matched search filters, e.g. "Year Slider" filters with
* text query fields.
* @param {string[]} selectedFields The Query Field names (i.e. Solr field names)
* of the newly selected fields
*/
handleFieldChange: function (selectedFields) {
try {
var view = this;
// Enable all UI types if no field is selected yet
if (
!selectedFields ||
!selectedFields.length ||
selectedFields[0] === ""
) {
this.uiBuilders.forEach(function (uiBuilder) {
view.allowUI(uiBuilder);
});
return;
}
// If at least one field is selected, then limit the available UI types to
// those that match the type of Query Field.
var type =
MetacatUI.queryFields.getRequiredFilterType(selectedFields);
this.uiBuilders.forEach(function (uiBuilder) {
if (
uiBuilder.filterTypes.includes(type) &&
view.isBuilderAllowedForFields(uiBuilder, selectedFields)
) {
view.allowUI(uiBuilder);
} else {
view.blockUI(uiBuilder);
}
});
} catch (error) {
console.log(
"There was an error handling a field change in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Marks a UI builder is blocked (so that it can't be selected) and updates the
* tooltip with text explaining that this UI can't be used with the selected
* fields. If the UI to block is the currently selected UI, then switches to the
* next allowed UI. If there are no UIs that are allowed, then shows a message and
* hides all UI builders.
* @param {UIBuilderOption} uiBuilder - The UI builder Object to block
*/
blockUI: function (uiBuilder) {
try {
var view = this;
uiBuilder.allowed = false;
uiBuilder.button.addClass("disabled");
uiBuilder.button.tooltip("destroy");
uiBuilder.button.tooltip({
title: view.text.filterNotAllowed,
delay: {
show: 400,
hide: 50,
},
});
// If the current UI is a blocked one...
if (this.currentUIBuilder === uiBuilder) {
// ... switch to the next unblocked one.
var allowedUIBuilder = _.findWhere(this.uiBuilders, {
allowed: true,
});
if (allowedUIBuilder) {
view.switchFilterType(allowedUIBuilder.modelType);
} else {
// If there is no UI available, then show a message
this.showNoFilterOptionMessage();
}
}
} catch (error) {
console.log(
"There was an error blocking a filter UI builder in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Marks a UI builder is allowed (so that it can be selected) and updates the
* tooltip text with the description of this UI. If it's displayed, this function
* hides the message that indicates that there are no allowed UIs that match the
* selected query fields.
* @param {UIBuilderOption} uiBuilder - The UI builder Object to block
*/
allowUI: function (uiBuilder) {
try {
// If at least one UI is allowed, then make sure the "no filter message" is
// hidden.
if (this.noFilterOptions) {
this.hideNoFilterOptionMessage();
}
uiBuilder.allowed = true;
uiBuilder.button.removeClass("disabled");
uiBuilder.button.tooltip("destroy");
uiBuilder.button.tooltip({
title: uiBuilder.description,
delay: {
show: 900,
hide: 50,
},
});
} catch (error) {
console.log(
"There was an error unblocking a filter UI builder in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Hides all filter builder UIs and displays a warning message indicating that
* there are currently no UI options that support the selected fields.
* @param {string} message A message to show. If not set, then the string set in
* the view's text.noFilterOption attribute is used.
* @param {string} [type="warning"] The type of message to display (warning,
* error, or info)
*/
showNoFilterOptionMessage: function (message, type = "warning") {
try {
this.noFilterOptions = true;
if (!message) {
message = this.text.noFilterOption;
}
if (this.noFilterOptionMessageEl) {
this.noFilterOptionMessageEl.remove();
}
this.noFilterOptionMessageEl = $(
'<div class="alert alert-' + type + '">' + message + "</div>",
);
this.uiBuilderCarousel.hide();
this.uiBuilderCarousel.after(this.noFilterOptionMessageEl);
} catch (error) {
console.log(
"There was an error showing a message to indicate that no filter builder " +
"UI options are allowed in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Removes the message displayed by the
* {@link FilterEditorView#showNoFilterOptionMessage} function and un-hides all
* the filter builder UIs.
*/
hideNoFilterOptionMessage: function () {
try {
this.noFilterOptions = false;
if (this.noFilterOptionMessageEl) {
this.noFilterOptionMessageEl.remove();
console.log(this.noFilterOptionMessageEl);
}
if (this.uiBuilderCarousel.is(":hidden")) {
this.uiBuilderCarousel.show();
}
} catch (error) {
console.log(
"There was an error hiding a message in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Functions to run when a user clicks the "save" button in the editing modal
* window. Creates a new Filter model with all of the new attributes that the user
* has selected. Checks if the model is valid. If it is, then returns the model.
* If it is not, then returns the errors.
* @param {Object} event The click event
* @return {Object} Returns an object with a success property set to either true
* (if there were no errors), or false (if there were errors). If there were
* errors, then the object also has an errors property with the errors return from
* the Filter validate function. If there were no errors, then the object contains
* a model property with the new Filter to be saved to the Filters collection.
*/
createModel: function (event) {
try {
var selectedUI = this.currentUIBuilder,
newModelAttrs = selectedUI.draftModel.toJSON();
// Set the new fields
newModelAttrs.fields = _.clone(this.fieldInput.model.get("selected"));
// set the new fieldsOperator
newModelAttrs.fieldsOperator = this.fieldInput.model.get("separator");
delete newModelAttrs.objectDOM;
delete newModelAttrs.cid;
// The collection's model function identifies the type of model to create
// based on the filterType attribute. Create a model before we add it to the
// collection, so that we can make sure it's valid first, while still allowing
// a user to press the UNDO button and not add any changes to the Filters
// collection.
var newModel = this.collection.model(newModelAttrs);
// Check if the filter is valid.
var newModelErrors = newModel.validate();
if (newModelErrors) {
return {
success: false,
errors: newModelErrors,
};
} else {
return {
success: true,
model: newModel,
};
}
} catch (error) {
console.log(
"There was an error updating a Filter model in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Shows errors in the filter editor modal window.
* @param {object} errors An object where keys represent the Filter model
* attribute that has an error, and the corresponding value explains the error in
* text.
*/
handleErrors: function (errors) {
try {
var view = this;
// Show a general error message in the modal. (Don't add it twice.)
if (view.validationErrorEl) {
view.validationErrorEl.remove();
}
view.validationErrorEl = $(
'<p class="alert alert-error">' +
view.text.validationError +
"</p>",
);
this.$el.find(".modal-body").prepend(view.validationErrorEl);
if (errors) {
// Show an error for the "fields" attribute (common to all Filters)
if (errors.fields) {
view.fieldInput.showMessage(errors.fields, "error", true);
}
// Show errors for the attributes specific to each Filter type
view.currentUIBuilder.view.showValidationErrors(errors);
}
} catch (error) {
console.log(
"There was an error in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Function that takes the event when a user clicks on one of the filter type
* options, gets the name of the desired filter type, and passes it to the switch
* filter function.
* @param {object} event The click event
*/
handleFilterIconClick: function (event) {
try {
// Get the new Filter Type from the click event. The name of the new Filter
// Type is stored as a data attribute in the clicked Filter icon.
// var filterTypeIcon =
var newFilterType = event.currentTarget.dataset.filterType;
// Pass the Filter Type to the switch filter function
this.switchFilterType(newFilterType);
} catch (error) {
console.log(
"There was an error handling a click event in a FilterEditorView" +
" Error details: " +
error,
);
}
},
/**
* Switches the current draft Filter model to a different Filter model type.
* Carries over any common attributes from the previously selected filter type.
* If no filter type is provided, defaults to type of the view's model
* @param {string} newFilterType The name of the filter type to switch to
*/
switchFilterType: function (newFilterType) {
try {
var view = this;
// Use the filter type of the model if none is provided.
if (!newFilterType) {
newFilterType = this.model.type;
}
// Get the properties of the Filter UI Editor for the new filter type.
var uiBuilder = _.findWhere(this.uiBuilders, {
modelType: newFilterType,
});
// Don't allow user to build a mis-matched filter (e.g. text filter with date
// field)
if (uiBuilder.allowed === false) {
return;
}
// (Index is used for the carousel)
var index = this.uiBuilders.indexOf(uiBuilder);
// Treat the first Filter in the list of filter UI editor options as the
// default
if (!uiBuilder) {
uiBuilder = this.uiBuilders[0];
filterType = uiBuilder.modelType;
}
// Create an object with the properties to pass on to the new draft model
var newModelAttrs = {};
// If there is a currently selected UI editor, then find the common model
// attributes that we should pass on to the new UI editor type
if (this.currentUIBuilder) {
newModelAttrs = this.getCommonAttributes(
this.currentUIBuilder.draftModel,
newFilterType,
);
}
// All search filter models are UI Filter Type
newModelAttrs.isUIFilterType = true;
// If a UI editor has already been created for this Filter Type, then just
// update the pre-existing draft model. This way, if a user has already
// selected content that is specific to a filter type (e.g. choices for a
// choiceFilter), that content will still be there when they switch back to
// it. Otherwise, use a clone of the model set on this view. We will update
// the actual model in the Filters collection only when the user clicks save.
if (!uiBuilder.draftModel) {
if (this.model.type == newFilterType) {
uiBuilder.draftModel = this.model.clone();
} else {
uiBuilder.draftModel = uiBuilder.modelFunction({
isUIFilterType: true,
});
}
}
if (Object.keys(newModelAttrs).length) {
uiBuilder.draftModel.set(newModelAttrs);
}
// Save the new selection to the view
this.currentUIBuilder = uiBuilder;
// Find the container for this filter type
var uiBuilderContainer = uiBuilder.container;
// Create or update view
this.currentUIBuilder.view = this.currentUIBuilder.uiFunction(
uiBuilder.draftModel,
);
uiBuilderContainer.html(this.currentUIBuilder.view.el);
this.currentUIBuilder.view.render();
// Add the selected/active class to the clicked FilterTypeIcon, remove it from
// the other icons.
this.uiBuilders.forEach(function (uiBuilder) {
uiBuilder.button.removeClass(view.classes.uiBuilderChoiceActive);
});
this.currentUIBuilder.button.addClass(
view.classes.uiBuilderChoiceActive,
);
// Have the carousel slide to the selected uiBuilder container.
this.uiBuilderCarousel.carousel(index);
} catch (error) {
console.log(
"There was an error switching filter types in a FilterEditorView." +
" Error details: " +
error,
);
}
},
/**
* Checks for attribute keys that are the same between a given Filter model, and a
* new Filter model type. Returns an object of model attributes that are relevant
* to the new Filter model type. The values for this object will be pulled from
* the given model. objectDOM, cid, and nodeName attributes are always excluded.
*
* @param {Filter} filterModel A filter model
* @param {string} newFilterType The name of the new filter model type
*
* @returns {Object} returns the model attributes from the given filterModel that
* are also relevant to the new Filter model type.
*/
getCommonAttributes: function (filterModel, newFilterType) {
try {
// The filter model attributes that are common to both the current Filter Model
// and the new Filter Type that we want to create.
var commonAttributes = {};
// Given the newFilterType string, get the default attribute names for a new
// model of that type.
var uiBuilder = _.findWhere(this.uiBuilders, {
modelType: newFilterType,
});
var defaultAttrs = uiBuilder.modelFunction().defaults();
var defaultAttrNames = Object.keys(defaultAttrs);
// Check if any of those attribute types exist in the current filter model.
// If they do, include them in the common attributes object.
var currentAttrs = filterModel.toJSON();
defaultAttrNames.forEach(function (attrName) {
var valueInDraftModel = currentAttrs[attrName];
if (
valueInDraftModel ||
(valueInDraftModel === 0) | (valueInDraftModel === false)
) {
commonAttributes[attrName] = valueInDraftModel;
}
}, this);
// Exclude attributes that shouldn't be passed to a new model, like the
// objectDOM and the model ID.
delete commonAttributes.objectDOM;
delete commonAttributes.cid;
delete commonAttributes.nodeName;
// Return the common attributes
return commonAttributes;
} catch (error) {
console.log(
"There was an error getting common model attributes in a FilterEditorView" +
". Error details: " +
error,
);
}
},
/**
* Determine whether a particular UIBuilder is allowed for a set of
* search field names. For use in handleFieldChange to enable or disable
* certain UI builders using allowUI/blockUI when the user selects
* different search fields.
*
* @param {UIBuilderOption} uiBuilder The UIBuilderOption object to
* check
* @param {string[]} selectedFields An array of search field names to
* look for restrictions
*
* @return {boolean} Whether or not the uiBuilder is allowed for all
* of selectedFields. Returns true only if all selectedFields are
* allowed, not just one or more.
*/
isBuilderAllowedForFields: function (uiBuilder, selectedFields) {
// Return true early if this uiBuilder has no blockedFields
if (!uiBuilder.blockedFields || uiBuilder.blockedFields.length == 0) {
return true;
}
// Check each blockedField for presence in selectedFields
var isAllowed = uiBuilder.blockedFields.map(function (blockedField) {
return !selectedFields.includes(blockedField);
});
return isAllowed.every(function (e) {
return e;
});
},
},
);

@robyngit robyngit linked an issue Sep 24, 2024 that may be closed by this pull request
@robyngit
Copy link
Member Author

Self-reviewing because this is very minor code change

@robyngit robyngit merged commit 10ff1db into develop Sep 24, 2024
1 of 2 checks passed
@robyngit robyngit deleted the feature-2538-annotation-dropdowns-in-portals branch September 24, 2024 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow dropdowns for sem_annotation field in the Filter Editor
1 participant