/*global define */
define(['jquery', 'underscore', 'backbone',
'models/filters/Filter',
'models/filters/FilterGroup',
'views/filters/FilterGroupView',
'views/filters/FilterView'],
function($, _, Backbone, Filter, FilterGroup, FilterGroupView, FilterView) {
'use strict';
/**
* @class FilterGroupsView
* @classdesc Creates a view of one or more FilterGroupViews
* @classcategory Views/Filters
* @name FilterGroupsView
* @extends Backbone.View
* @constructor
*/
var FilterGroupsView = Backbone.View.extend(
/** @lends FilterGroupsView.prototype */{
/**
* The FilterGroup models to display in this view
* @type {FilterGroup[]}
*/
filterGroups: [],
/**
* The Filters Collection that contains the same Filter
* models from each FilterGroup and any additional Filter Models that may not be in
* FilterGroups because they're not displayed or applied behind the scenes.
* @type {Filters}
*/
filters: null,
/**
* @inheritdoc
*/
tagName: "div",
/**
* @inheritdoc
*/
className: "filter-groups tabbable",
/**
* If true, displays the FilterGroups in a vertical list
* @type {Boolean}
*/
vertical: false,
/**
* @inheritdoc
*/
events: {
"click .remove-filter" : "handleRemove",
"click .clear-all" : "removeAllFilters"
},
/**
* @inheritdoc
*/
initialize: function (options) {
if( !options || typeof options != "object" ){
var options = {};
}
this.filterGroups = options.filterGroups || new Array();
this.filters = options.filters || null;
if( options.vertical == true ){
this.vertical = true;
}
this.parentView = options.parentView || null;
},
/**
* @inheritdoc
*/
render: function () {
//Since this view may be re-rendered at some point, empty the element and remove listeners
this.$el.empty();
this.stopListening();
//Create an unordered list for all the filter tabs
var groupTabs = $(document.createElement("ul")).addClass("nav nav-tabs filter-group-links");
//Create a container div for the filter groups
var filterGroupContainer = $(document.createElement("div")).addClass("tab-content");
//Add the filter group elements to this view
this.$el.append(groupTabs, filterGroupContainer);
var divideIntoGroups = true;
_.each( this.filterGroups, function(filterGroup){
//If there is only one filter group specified, and there is no label or icon,
// then don't divide the filters into separate filter groups
if( this.filterGroups.length == 1 && !this.filterGroups[0].get("label") &&
!this.filterGroups[0].get("icon") ){
divideIntoGroups = false;
}
if( divideIntoGroups ){
//Create a link to the filter group
var groupTab = $(document.createElement("li")).addClass("filter-group-link");
var groupLink = $(document.createElement("a"))
.attr("href", "#" + filterGroup.get("label").replace( /([^a-zA-Z0-9])/g, "") )
.attr("data-toggle", "tab");
//Add the FilterGroup icon
if( filterGroup.get("icon") ){
groupLink.append( $(document.createElement("i")).addClass("icon icon-" + filterGroup.get("icon")) );
}
//Add the FilterGroup label
if( filterGroup.get("label") ){
groupLink.append(filterGroup.get("label"));
}
//Insert the link into the tab and add the tab to the tab list
groupTab.append(groupLink);
groupTabs.append(groupTab);
//Create a tooltip for the link
groupTab.tooltip({
placement: "top",
title: filterGroup.get("description"),
trigger: "hover",
delay: {
show: 800
}
});
//Make all the tab widths equal
groupTab.css("width", (100 / this.filterGroups.length) + "%");
}
//Create a FilterGroupView
var filterGroupView = new FilterGroupView({
model: filterGroup
});
//Render the FilterGroupView
filterGroupView.render();
//Add the FilterGroupView element to this view
filterGroupContainer.append(filterGroupView.el);
//Store a reference to the FilterGroupView in the tab link
if( divideIntoGroups ){
groupLink.data("view", filterGroupView);
}
//If a new filter is ever added to this filter group, re-render this view
this.listenTo( filterGroup.get("filters"), "add remove", this.render );
}, this);
if( divideIntoGroups ){
//Mark the first filter group as active
groupTabs.children("li").first().addClass("active");
//When each filter group tab is shown, perform any post render function, if needed.
this.$('a[data-toggle="tab"]').on('shown', function (e) {
//Get the filter group view
var filterGroupView = $(e.target).data("view");
//If there is a post render function, call it
if( filterGroupView && filterGroupView.postRender ){
filterGroupView.postRender();
}
});
}
//Mark the first filter group as active
var firstFilterGroupEl = filterGroupContainer.find(".filter-group").first();
firstFilterGroupEl.addClass("active");
var activeFilterGroup = firstFilterGroupEl.data("view");
//Call postRender() now for the active FilterGroup, since the `shown` event
// won't trigger until/unless it's hidden then shown again.
if( activeFilterGroup ){
activeFilterGroup.postRender();
}
//Add a header element above the filter groups
this.$el.prepend( $(document.createElement("div")).addClass("filters-header") );
//Render the applied filters
this.renderAppliedFiltersSection();
//Render an "All" filter
this.renderAllFilter();
if( this.vertical ){
this.$el.addClass("vertical");
}
},
/**
* Renders the section of the view that will display the currently-applied filters
*/
renderAppliedFiltersSection: function(){
//Add a title to the header
var appliedFiltersContainer = $(document.createElement("div")).addClass("applied-filters-container"),
headerText = $(document.createElement("h5"))
.addClass("filters-title")
.text("Current search")
.append( $(document.createElement("a"))
.text("Clear all")
.addClass("clear-all")
.prepend( $(document.createElement("i"))
.addClass("icon icon-remove icon-on-left") ));
//Make the applied filters list
var appliedFiltersEl = $(document.createElement("ul")).addClass("applied-filters");
//Add the applied filters element to the filters header
appliedFiltersContainer.append(headerText, appliedFiltersEl);
this.$(".filters-header").append(appliedFiltersContainer);
//Get all the nonNumeric filter models
var nonNumericFilters = this.filters.reject(function(filterModel){
return (filterModel.type == "NumericFilter" || filterModel.type == "DateFilter");
});
//Listen to changes on the "values" attribute for nonNumeric filters
_.each(nonNumericFilters, function(nonNumericFilter){
this.listenTo(nonNumericFilter, "change:values", this.updateAppliedFilters);
if( nonNumericFilter.get("values").length ){
this.updateAppliedFilters(nonNumericFilter, { displayWithoutChanges: true });
}
}, this);
//Get the numeric filters and listen to the min and max values
var numericFilters = _.where(this.filters.models, { type: "NumericFilter" });
_.each(numericFilters, function(numericFilter){
if( numericFilter.get("range") == true ){
this.listenTo(numericFilter, "change:min change:max", this.updateAppliedRangeFilters);
var filterDefaults = numericFilter.defaults();
if( numericFilter.get("min") != filterDefaults.min ||
numericFilter.get("max") != filterDefaults.max ||
numericFilter.get("values").length ){
this.updateAppliedRangeFilters(numericFilter, { displayWithoutChanges: true });
}
}
else{
this.listenTo(numericFilter, "change:values", this.updateAppliedRangeFilters);
if( numericFilter.get("values")[0] != numericFilter.defaults().values[0] ){
this.updateAppliedRangeFilters(numericFilter, { displayWithoutChanges: true });
}
}
}, this);
//Get the date filters and listen to the min and max values
var dateFilters = _.where(this.filters.models, { type: "DateFilter" });
_.each(dateFilters, function(dateFilter){
this.listenTo(dateFilter, "change:min change:max", this.updateAppliedRangeFilters);
if( dateFilter.get("min") != dateFilter.defaults().min ||
dateFilter.get("max") != dateFilter.defaults().max ){
this.updateAppliedRangeFilters(dateFilter, { displayWithoutChanges: true });
}
}, this);
//When a Filter has been removed from the Filters collection, remove it's DOM element from the page
this.listenTo(this.filters, "remove", function(removedFilter){
this.removeAppliedFilterElByModel(removedFilter);
});
},
renderAllFilter: function(){
//Create an "All" filter that will search the general `text` Solr field
var filter = new Filter({
fields: ["text"],
label: "Search",
description: "Search the datasets by typing in any keyword, topic, creator, etc.",
placeholder: "Search these datasets"
});
this.filters.add( filter );
//Create a FilterView for the All filter
var filterView = new FilterView({
model: filter
});
this.listenTo(filter, "change:values", this.updateAppliedFilters);
//Render the view and add the element to the filters header
filterView.render();
this.$(".filters-header").prepend(filterView.el);
},
postRender: function(){
var groupTabs = this.$(".filter-group-links");
//Check if there is a difference in heights
var maxHeight = 0;
_.each( groupTabs.find("a"), function(link){
if( $(link).height() > maxHeight ){
maxHeight = $(link).height();
}
});
//Set the height of each filter group link so they are all equal
_.each( groupTabs.find("a"), function(link){
if( $(link).height() < maxHeight ){
$(link).height(maxHeight + "px");
}
});
},
/**
* Renders the values of the given Filter Model in the current filter model
*
* @param {Filter} filterModel - The FilterModel to display
* @param {object} options - Additional options for this function
* @property {boolean} options.displayWithoutChanges - If true, this filter will display even if the value hasn't been changed
*/
updateAppliedFilters: function(filterModel, options){
//Create an options object if one wasn't sent
if( typeof options != "object" ){
var options = {};
}
//If the value of this filter has changed, or if the displayWithoutChanges option
// was passed, and if the filter is not invisible, then display it
if( !filterModel.get("isInvisible") &&
((filterModel.changed && filterModel.changed.values) ||
options.displayWithoutChanges) ){
//Get the new values and the previous values
var newValues = options.displayWithoutChanges? filterModel.get("values") : filterModel.changed.values,
previousValues = options.displayWithoutChanges? [] : filterModel.previousAttributes().values,
//Find the values that were removed
removedValues = _.difference(previousValues, newValues),
//Find the values that were added
addedValues = _.difference(newValues, previousValues);
//If a filter has been added, display it
_.each(addedValues, function(value){
//Add the applied filter to the view
this.$(".applied-filters").append( this.createAppliedFilter(filterModel, value) );
}, this);
//Iterate over each removed filter value and remove them
_.each(removedValues, function(value){
//Find all applied filter elements with a matching value
var matchingFilters = this.$(".applied-filter[data-value='" + value + "']");
//Iterate over each filter element with a matching value
_.each(matchingFilters, function(matchingFilter){
//If this is the filter element associated with this filter model, then remove it
if( $(matchingFilter).data("model") == filterModel ){
$(matchingFilter).remove();
}
});
}, this);
}
//Toggle the applied filters header
this.toggleAppliedFiltersHeader();
},
/**
* Hides or shows the applied filter list title/header, as well as the help
* message that lets the user know they can add filters when there are none
*/
toggleAppliedFiltersHeader: function(){
//If there is an applied filter
if( this.$(".applied-filter").length ){
// hide the "add some filters" help text
//$(this.parentView.helpTextContainer).css("display", "none");
// show the Clear All button
this.$(".filters-title").css("display", "block");
}
//If there are no applied filters
else{
// show the "add some filters" help text
// $(this.parentView.helpTextContainer).css("display", "block");
// hide the Clear All button
this.$(".filters-title").css("display", "none");
}
},
/**
* When a NumericFilter or DateFilter model is changed, update the applied filters in the UI
* @param {DateFilter|NumericFilter} filterModel - The model whose values to display
* @param {object} [options] - Additional options for this function
* @property {boolean} [options.displayWithoutChanges] - If true, this filter will display even if the value hasn't been changed
*/
updateAppliedRangeFilters: function(filterModel, options){
if( !filterModel ){
return;
}
if( typeof options === "undefined" || !options ){
var options = {};
}
//If the Filter is invisible, don't render it
if( filterModel.get("isInvisible") ){
return;
}
//If the minimum and maximum values are set to the default, remove the filter element
if( filterModel.get("min") == filterModel.get("rangeMin") &&
filterModel.get("max") == filterModel.get("rangeMax")){
//Find the applied filter element for this filter model
_.each(this.$(".applied-filter"), function(filterEl){
if( $(filterEl).data("model") == filterModel ){
//Remove the applied filter element
$(filterEl).remove();
}
}, this);
}
//If the values attribue has changed, or if the displayWithoutChanges attribute was passed
else if( (filterModel.changed && (filterModel.changed.min || filterModel.changed.max)) ||
options.displayWithoutChanges ){
//Create the filter label for ranges of numbers
var filterValue = filterModel.getReadableValue();
//Create the applied filter
var appliedFilter = this.createAppliedFilter(filterModel, filterValue);
//Keep track if this filter is already displayed and needs to be replaced
var replaced = false;
//Check if this filter model already has an applied filter in the UI
_.each(this.$(".applied-filter"), function(appliedFilterEl){
//If this applied filter already is displayed, replace it
if( $(appliedFilterEl).data("model") == filterModel ){
//Replace the applied filter element with the new one
$(appliedFilterEl).replaceWith(appliedFilter);
replaced = true;
}
}, this);
if( !replaced ){
//Add the applied filter to the view
this.$(".applied-filters").append(appliedFilter);
}
}
this.toggleAppliedFiltersHeader();
},
/**
* Creates a single applied filter element and returns it. Filters can
* have multiple values, so one value is passed to this function at a time.
* @param {Filter} filterModel - The Filter model that is being added to the display
* @param {string|number|Boolean} value - The new value set on the Filter model that is displayed in this applied filter
* @returns {jQuery} - The complete applied filter element
*/
createAppliedFilter: function(filterModel, value){
//Create the filter label
var filterLabel = filterModel.get("label"),
filterValue = value;
//If the filter type is Choice, get the choice label which can be different from the value
if( filterModel.type == "ChoiceFilter" ){
//Find the choice object with the given value
var matchingChoice = _.findWhere(filterModel.get("choices"), { "value" : value });
//Get the label for that choice
if(matchingChoice){
filterValue = matchingChoice.label;
}
}
//Create the filter label for boolean filters
else if( filterModel.type == "BooleanFilter" ){
//If the filter is set to false, remove the applied filter element
if( filterModel.get("values")[0] === false ){
//Iterate over the applied filters
_.each(this.$(".applied-filter"), function(appliedFilterEl){
//If this is the applied filter element for this model,
if( $(appliedFilterEl).data("model") == filterModel ){
//Remove the applied filter element from the page
$(appliedFilterEl).remove();
}
}, this);
//Exit the function at this point since there is nothing else to
// do for false BooleanFilters
return;
}
else if( filterModel.get("values")[0] === true ){
if( !filterLabel ){
filterLabel = filterModel.get("fields")[0];
filterValue = "";
}
}
}
else if( filterModel.type == "ToggleFilter" ){
if( filterModel.get("values")[0] == filterModel.get("trueValue") ){
if( filterModel.get("label") && filterModel.get("trueLabel") ){
filterValue = filterModel.get("trueLabel");
}
else if( !filterModel.get("label") && filterModel.get("trueLabel") ){
filterLabel = "";
filterValue = filterModel.get("trueLabel");
}
else if( filterModel.get("label") ){
filterLabel = "";
filterValue = filterModel.get("label");
}
}
else{
if( filterModel.get("label") && filterModel.get("falseLabel") ){
filterValue = filterModel.get("falseLabel");
}
else if( !filterModel.get("label") && filterModel.get("falseLabel") ){
filterLabel = "";
filterValue = filterModel.get("falseLabel");
}
else if( filterModel.get("label") ){
filterLabel = "";
filterValue = filterModel.get("label");
}
}
}
//If this Filter model is a full-text search, don't display a label
else if( filterModel.get("fields").length == 1 && filterModel.get("fields")[0] == "text"){
filterLabel = "";
}
//isPartOf filters should just display the label, not the value
else if( filterModel.get("fields").length == 1 && filterModel.get("fields")[0] == "isPartOf" ){
filterValue = "";
}
//If the filter value is just an asterisk (i.e. `match anything`), just display the label
else if( filterModel.get("values").length == 1 && filterModel.get("values")[0] == "*" ){
filterValue = "";
}
else if( !filterLabel ){
filterLabel = filterModel.get("fields")[0];
}
//Create the applied filter element
var removeIcon = $(document.createElement("a"))
.addClass("icon icon-remove remove-filter icon-on-right")
.attr("title", "Remove this filter"),
appliedFilter = $(document.createElement("li"))
.addClass("applied-filter label")
.append(removeIcon)
.data("model", filterModel)
.attr("data-value", value);
//Create an element to contain both the label and value
var filterLabelEl = $(document.createElement("span")).addClass("label");
var filterValueEl = $(document.createElement("span")).addClass("value").text(filterValue);
var filterTextContainer = $(document.createElement("span"))
.append(filterLabelEl, filterValueEl);
//If there is both a label and value, separated them with a colon
if( filterLabel && filterValue ){
filterLabelEl.text( filterLabel + ": ");
}
//Otherwise just use the label text only
else if( filterLabel ){
filterLabelEl.text(filterLabel);
}
//Add the filter text to the filter element
appliedFilter.prepend(filterTextContainer);
// Add a tooltip to the filter
if(filterModel.get("description")){
appliedFilter.tooltip({
placement: "right",
title: filterModel.get("description"),
trigger: "hover",
delay: {
show: 700
}
});
}
return appliedFilter;
},
/**
* Adds a custom filter that likely exists outside of the FilterGroups but needs
* to be displayed with these other applied fitlers.
*
* @param {Filter} filterModel - The Filter Model to display
*/
addCustomAppliedFilter: function(filterModel){
//If the Filter is invisible, don't render it
if( filterModel.get("isInvisible") ){
return;
}
//If this filter already exists in the applied filter list, exit this function
var alreadyExists = _.find( this.$(".applied-filter.custom"), function(appliedFilterEl){
return $(appliedFilterEl).data("model") == filterModel;
});
if( alreadyExists ){
return;
}
//Create the applied filter element
var removeIcon = $(document.createElement("a"))
.addClass("icon icon-remove remove-filter icon-on-right")
.attr("title", "Remove this filter"),
filterText = $(document.createElement("span")).text(filterModel.get("label")),
appliedFilter = $(document.createElement("li"))
.addClass("applied-filter label custom")
.append(filterText, removeIcon)
.data("model", filterModel)
.attr("data-value", filterModel.get("values"));
if( filterModel.type == "SpatialFilter" ){
filterText.prepend( $(document.createElement("i"))
.addClass("icon icon-on-left icon-" + filterModel.get("icon")) );
}
//Add the applied filter to the view
this.$(".applied-filters").append(appliedFilter);
//Display the filters title
this.toggleAppliedFiltersHeader();
},
/**
* Removes the custom applied filter from the UI.
*
* @param {Filter} filterModel - The Filter Model to display
*/
removeCustomAppliedFilter: function(filterModel){
_.each(this.$(".custom.applied-filter"), function(appliedFilterEl){
if( $(appliedFilterEl).data("model") == filterModel ){
$(appliedFilterEl).remove();
this.trigger("customAppliedFilterRemoved", filterModel);
}
}, this);
//Hide the filters title
this.toggleAppliedFiltersHeader();
},
/**
* When a remove button is clicked, get the filter model associated with it
/* and remove the filter from the filter group
*
* @param {Event} - The DOM Event that occured on the filter remove icon
*/
handleRemove: function(e){
// Ensure tooltips are removed
try{
if(e.delegateTarget){
$(e.delegateTarget).find(".tooltip").remove();
}
}
catch(e) {
console.log("Could not remove tooltip from filter label, error message: " + e);
};
//Get the applied filter element and the filter model associated with it
var appliedFilterEl = $(e.target).parents(".applied-filter"),
filterModel = appliedFilterEl.data("model");
if( appliedFilterEl.is(".custom") ){
this.removeCustomAppliedFilter(filterModel);
}
else{
//Remove the filter from the filter group model
this.removeFilter(filterModel, appliedFilterEl);
}
},
/**
* Remove the filter from the UI and the Search collection
* @param {Filter} filterModel The Filter to remove from the Filters collection
* @param {Element} appliedFilterEl The DOM Element for the applied filter on the page
* @param {object} options Additional options for this function
* @param {boolean} options.removeSilently If true, the Filter model will be removed siltently from the Filters collection.
* This is useful when removing multiple Filters at once, and triggering a remove/change/reset event after all have
* been removed.
*/
removeFilter: function(filterModel, appliedFilterEl, options){
var removeSilently = false;
//Parse all the additional options for this function
if( typeof options == "object" ){
removeSilently = typeof options.removeSilently != "undefined"? options.removeSilently : false;
}
if( filterModel ){
//NumericFilters and DateFilters get the min and max values reset
if( filterModel.type == "NumericFilter" || filterModel.type == "DateFilter" ){
//Set the min and max values
filterModel.set({
min: filterModel.get("rangeMin"),
max: filterModel.get("rangeMax"),
values: filterModel.defaults().values
});
if( !removeSilently ){
//Trigger the reset event
filterModel.trigger("rangeReset");
}
}
//For all other filter types
else{
//Get the current value
var modelValues = filterModel.get("values"),
thisValue = $(appliedFilterEl).data("value");
//Numbers that are set on the element `data` are stored as type `number`, but when `number`s are
// set on Backbone models, they are converted to `string`s. So we need to check for this use case.
if( typeof thisValue == "number" ){
//Convert the number to a string
thisValue = thisValue.toString();
}
//Remove the value that was in this applied filter
var newValues = _.without(modelValues, thisValue),
setOptions = {};
if( removeSilently ){
setOptions.silent = true;
}
//Updates the values on the model
filterModel.set("values", newValues, setOptions);
}
}
},
/**
* Gets all the applied filters in this view and their associated filter models
* and removes them.
*/
removeAllFilters: function(){
//Iterate over each applied filter in the view
_.each( this.$(".applied-filter"), function(appliedFilterEl){
var $appliedFilterEl = $(appliedFilterEl);
if( $appliedFilterEl.is(".custom") ){
this.removeCustomAppliedFilter( $appliedFilterEl.data("model") );
}
else{
//Remove the filter from the fitler group
this.removeFilter( $appliedFilterEl.data("model"), appliedFilterEl, { removeSilently: true } );
}
//Remove the applied filter element from the page
$appliedFilterEl.remove();
}, this);
//Trigger the reset event on the Filters collection
this.filters.trigger("reset");
//Toggle the applied filters header
this.toggleAppliedFiltersHeader();
},
/**
* Remove the applied filter element for the given model
* This only removed the element from the page, it doesn't update the model at all or
* trigger any events.
* @param {Filter} - The Filter model whose elements will be deleted
*/
removeAppliedFilterElByModel: function(filterModel){
//Iterate over each applied filter element and find the matching filters
this.$(".applied-filter").each(function(i, el){
if( $(el).data("model") == filterModel ){
//Remove the element from the page
$(el).remove();
}
});
//Toggle the applied filters header
this.toggleAppliedFiltersHeader();
}
});
return FilterGroupsView;
});