Skip to content

Instantly share code, notes, and snippets.

@kjantzer
Last active October 22, 2021 20:41
Show Gist options
  • Save kjantzer/3996511 to your computer and use it in GitHub Desktop.
Save kjantzer/3996511 to your computer and use it in GitHub Desktop.
Backbone.js: FilterView – extendable View for adding filtering/searching to a view

Backbone.js: Filter View

Easily add filtering through defined filters and user typed terms.


Use

Extend FilterView for the view responsible for rendering the list.

ListOfItems = FilterView.extend({

  events: {
		  'keyup input.filter'  : 'filterCollectionFromInput'  // expects an <input> with class "filter"
	  },
  
  filters: {
	  'type': 'text'
	  },
  
  filterContexts: {
	
		'default': function(term, model){
			return [ LiquidMetal.score(model.get('attr'), term), LiquidMetal.score(model.get('quote'), term) ];
		}
	
  },
  
  render: function(){
    this.getCollection().each(this.addOne, this);
  }

});
/*
FilterView
@author Kevin Jantzer, Blackstone Audio Inc.
@since 2012-11-06
Filter by Presets and user typed Terms. If filtered by a preset,
search term will filter within the preset filtered collection
USE:
When you want to create a list view that can be filtered
extend FilterView instead of Backbone.View
expects an input with class "filter" for attaching the event handler
predefined "filters" can also be applied to the collection.
define the available filters and the method (or defaultMethod) to use for the filter
ex:
filters: {
'type': 'number', // looks for "type" on model (parses to number)
'status': 'text', // looks for "status" on model (plain text)
'color': function(model, filterVal, filterKey){ // custom method
return intergerToColor( model.get('color_id') ) === filterVal;
}
}
Tip:
Use LiquidMetal to score the search term for better results (https://github.com/rmm5t/liquidmetal)
*/
var FilterView = Backbone.View.extend({
events: {
'keyup input.filter' : 'filterCollectionFromInput' // expects an <input> with class "filter"
},
filteredCollection: this.collection,
filterTerm: '', // current filter term
filterResult: null, // current filter result collection
filterMinScore: .7, // minimum score for a model to pass filter
/*
Filter Result Collection - defaults to regular backbone collection
override this method to return filtered result as a different collection
*/
filterResultCollection: function(filteredModels){
return new Backbone.Collection(filteredModels);
},
// commented out so that each instance of FilterView has their own; don't want this in the prototype!
//activeFilters: {}, // current active filters - do not set this manually, use setActiveFilters instead
//filters: {}, // available filters
foreachFilterNotInUse: function(callback){
var self = this;
if( callback )
_.each(this.filters, function(obj, key){
if( self.activeFilters[key] !== undefined ) return;
callback.call(self, obj, key);
})
},
foreachFilterInUse: function(callback){
var self = this;
if( callback )
_.each(this.filters, function(obj, key){
if( self.activeFilters[key] === undefined ) return;
callback.call(self, obj, key);
})
},
filterComparator: null, // should the filtered collection be sorted?
defaultFilterMethods: {
'text': function(model, filterVal, filterKey){
return model.get(filterKey) == filterVal;
},
'number': function(model, filterVal, filterKey){
return Number(model.get(filterKey)) == filterVal;
},
'int': function(model, filterVal, filterKey){
return parseInt(model.get(filterKey)) == filterVal;
},
'array': function(model, filterVal, filterKey){
return _.indexOf(filterVal, model.get(filterKey)) > -1
},
'model_id': function(model, filterVal, filterKey){
return parseInt(model.get(filterKey).id) == filterVal;
},
'starts_with': function(model, filterVal, filterKey){
return (model.get(filterKey)||'').match(RegExp('^'+filterVal));
},
'ends_with': function(model, filterVal, filterKey){
return (model.get(filterKey)||'').match(RegExp(filterVal+'$'));
},
'contains': function(model, filterVal, filterKey){
return (model.get(filterKey)||'').match(RegExp(filterVal));
}
},
/*
Set Active Filters - this can be called on init to pre-set the filters that should be active. Don't directly set this.activeFilters
*/
setActiveFilters: function(filters){
_.each(filters, function(val, key){
this.applyFilter(key, val, false);
}, this)
},
/*
Apply Filter - call this to set/clear a filter;
filter keys must be defined in "this.filters"
if "filterVal" is null (or empty), the filter will be cleared from "activeFilters"
"triggerReset" defaults to true
*/
applyFilter: function(filterKey, filterVal, triggerReset){
// if filter val is null (or empty), remove this filter key from active filters
if(filterVal === undefined || filterVal === null || filterVal === '' || filterVal === 'All' || filterVal === 'all')
this._removeActiveFilter(filterKey)
// else, update/set the filter key with the given value
else
this._addActiveFilter(filterKey, filterVal)
this.trigger('filter:change');
this.refilter(triggerReset);
},
/*
Refilter - filters the collection based on the active filters then triggers collection reset...unless "false" is specified
*/
refilter: function(triggerReset){
// if the collection is empty, dont try to filter it
if( this.collection.length == 0 ){
this.filteredCollection = this.collection;
return;
}
// start the filtering process
this._filterCollectionWithActiveFilters();
// then filter the collection with the user typed term if there is one
if( triggerReset !== false)
this.filterCollection(); // this method will also trigger the collection reset
},
_addActiveFilter: function(filterKey, filterVal){
this.activeFilters = this.activeFilters || {};
this.activeFilters[filterKey] = filterVal;
this.$el.addClass('is-filtered').attr('data-filtered-'+filterKey, filterVal);
},
_removeActiveFilter: function(filterKey){
this.activeFilters = this.activeFilters || {};
delete this.activeFilters[filterKey];
this.$el.attr('data-filtered-'+filterKey, null);
if( _.size(this.activeFilters) === 0)
this.$el.removeClass('is-filtered');
},
// "private" function; get the whole filtered collection
_filterCollectionWithActiveFilters: function(){
// reset the filtered collection to normal
this.filteredCollection = this.collection;
// filter the collection with each of the active filters
_.each(this.activeFilters, this._filterCollectionWith, this)
// sort filtered collection
if( this.filterComparator ){
this.filteredCollection = this.filteredCollection.sortBy(this.filterComparator, this);
this.filteredCollection = this.filterResultCollection( this.filteredCollection );
}
},
// "private" function; filters collection with value for filter key
_filterCollectionWith: function(filterVal, filterKey){
var that = this;
var filterFn = this._filterFn(filterKey);
if( !filterFn ) return; // skip filtering for this key if no filter by function is found
// filter the collection
this.filteredCollection = this.filteredCollection.filter(function(model){
return filterFn.call(that, model, filterVal, filterKey, that);
});
// convert filtered collection array into a real collection
this.filteredCollection = this.filterResultCollection( this.filteredCollection );
},
// "private" function: returns the "filterBy" function from the given filterKey
_filterFn: function(filterKey){
// see if a filter exists for the given filter key
var filterFn = this.filters[filterKey] || null;
if( !filterFn ) return filterFn;
// backwards compatibility - this will probably be removed eventually
if( !_.isFunction(filterFn) && !_.isString(filterFn) ){
filterFn = filterFn.filterBy || null
}
// if the filter function is a string, see if there is a default filter method
if( _.isString(filterFn) )
if(this.defaultFilterMethods[filterFn] ){
filterFn = this.defaultFilterMethods[filterFn]
}else{
console.warn('FilterView: “%s” is not valid default filter method. Available defaults:', filterFn, _.keys(this.defaultFilterMethods))
}
return filterFn;
},
/*
Filter Contexts - filter collection on different things based on what the user types
a 'default' context is required;
override this method to add extra filter contexts; if you do not override this method
filtering will always return all results
if a context key ends with ":" the text following will be returned as a "term"
ex: 'author:' : function(term, model){} // term will = "john" when searching "author:john"
*/
filterContexts: {
// a default context is required
'default': function(term, model){
return [ 1 ]; // return all results
}
},
/*
Filter Collection From Input - trigger "filterCollection" on keyup from an input
*/
filterCollectionFromInput: function(e){
// get term from <input> "keyup"
var term = e.target.value;
// if filter term hasn't changed, dont filter again
if(this.filterTerm === term) return;
e.target.setAttribute('value', term) // set the value attribute for CSS styling
this.filterTerm = term;
this.filterCollection();
},
/*
Filter Collection - filters the collection with the term the user has typed
*/
filterCollection: function(){
var term = this.filterTerm;
// empty search term (or no default context) so clear search results
if( term == '' || !this.filterContexts['default'] ){
this.clearFilter();
if(!this.filterContexts['default'])
console.warn('FilterView: no “default” filter context. Please add one.');
// perform filtering
}else{
this.$el.addClass('filtered');
this.trigger('filter:start', this.filterTerm);
// default to no context
var context = 'default';
// test term for a context - "date:2012" and parse if there is one
_.each(this.filterContexts, function(_filter, _context){
var patt = new RegExp('^'+_context+'(.*)');
if( patt.test(term) ){
context = _context;
term = (term.match(patt))[1].trim();
}
});
// get the filtered collection
var filtered = this.filteredCollection.filter(function(model){
var filterFn = this.filterContexts[context];
var scores = filterFn(term, model);
// grab the highest score
var score = _.max(scores);
// set the score on this model so we can sort by it down below
model.set('score', score, {silent: true});
// only return this model if its score is better than 70%
return score > this.filterMinScore;
}, this);
// sort the filtered collection by the scores and put in descending order (highest score first)
filtered = _.sortBy(filtered, function(model){ return model.get('score') });
filtered = filtered.reverse(); // higher number is better, so reverse results
// convert filtered result to a real collection
this.filterResult = this.filterResultCollection(filtered);
}
// trigger a change on the collection - render method should notice this.searchResult is set and render that collection instead
this.collection.trigger('reset');
},
/*
Clear Filter
*/
clearFilter: function(){
this.filterTerm = '';
this.filterResult = null;
this.$el.removeClass('filtered');
this.trigger('filter:done');
},
/*
Get Collection - convenience method for rendering
in your render method do something like this:
this.getCollection().each(this.addOne, this);
*/
getCollection: function(){
// if there is no filteredCollection, but we have active filters, lets get the filteredCollection now
if( !this.filteredCollection && _.size(this.activeFilters) > 0 )
this._filterCollectionWithActiveFilters();
else if(!this.filteredCollection)
this.filteredCollection = this.collection;
return this.filterResult || this.filteredCollection;
}
});
input.search, input.search:focus,
input.filter, input.filter:focus {
background: #fff url('/common/img/icons/wireframe/black/16/zoom_icon&16.png') 7px center no-repeat !important;
.rounded(14px);
font-size: 12px;
padding-left: 26px;
&.auto-hide {
width: 150px;
.transition(200ms);
}
// if auto-hide is NOT focused and the VALUE is empty, "hide" the input
&.auto-hide:not(:focus):not([value]),
&.auto-hide:not(:focus)[value=""] {
width: 33px;
background-color: transparent !important;
border-color: transparent;
.box-shadow(none);
opacity: .4;
cursor: pointer;
}
// if auto-hide is NOT focused and the VALUE is empty, increase opacity when hovered
&.auto-hide:not(:focus):not([value]):hover,
&.auto-hide:not(:focus)[value=""]:hover {
opacity: .75;
}
}
.filter-presets {
.filter-preset {
float: left;
margin-right: 15px;
color: #bbb;
cursor: pointer;
&.active {
font-weight: bold;
color: #555;
cursor: default;
}
}
}
/*
Sortable Filter View
@author Kevin Jantzer
@since 2013-04-16
Assumes this.collection = SortableCollection
This class will take care of rendering a top bar with controls
for filtering, sorting and searching/filtering by typed term
in addition to rendering the content data into an infinite list
To add sorting and filtering options add the proper attributes:
this.sorts: [
{label: 'ID', val: 'id'},
{label: 'Title', val: 'title'}
]
this.filters: {
'partner': {
values: [ // "values" are the options for this filter
{label: 'Any Partner', val: ''},
'divider', // "values" is given to Dropdown.js so the same opitons apply
{label: 'Blackstone Audio', val: '1'},
{label: 'Blackstone Audio', val: '2'}
],
w: 120, // optionally choose to set the width of the dropdown
filterBy: function(model, filterVal, filterKey){
// the logic for how to filter the collection by "partner"
}
}
}
NOTE: if this view has sorts or filters, the SortableCollection may also need these
sorts and/or filters specified. Collection sorts are optional, but filters are required to save in local storage;
See SortableCollection for details.
For special functionality with user typed search/filter terms, add "filterContexts"; (see FilterView for more details)
this.filterContexts = {
"flagged": function(){}
}
*/
SortableFilterView = FilterView.extend({
//listView: 'Path.To.BackboneView',
//listViewData: function(){ return {} }, // if this is specified, the data will be initialzed with the list view
listStyle: 'simple', // plain, simple, card
filterInputStyle: 'right auto-hide',
// override the default backbone constructor
constructor: function(data, opts){
// make sure setup has happened, but wait until after initialize()
this._checkSetup = setTimeout(this.checkSetup.bind(this), 0)
// call normal backbone constructor
Backbone.View.prototype.constructor.call(this, data);
},
// if we get to this method, it means setup was never called, so call it now
checkSetup: function(){
// make sure setup() hasn't been called
if( this._checkSetup )
this.setup()
},
fetchOnRender: false,
filter: true,
filterPlaceholder: 'Filter...',
// list available sorts
sorts: [
//{label: 'Title', val: 'title'}
],
sortEvents: {
'click a.change-desc': 'changeDesc'
},
// called automatially if not called in initialize method
setup: function(){
clearTimeout(this._checkSetup);
this.events = _.extend({}, this.events||{}, this.sortEvents);
this.$top = $('<div class="filter-bar top clearfix"></div>').appendTo(this.$el);
if( this.displayList !== false ){
this.list = new InfiniteListView(this.$el, {
context: this.options.scrollContext ? this.options.scrollContext : '#main .inner',
className: this.listStyle+' list clearfix'
});
this.list.on('addOne', this.addOne, this); // when the list wants to add one, call "renderOne"
this.list.on('endReached', this.addMore, this); // when end of list is reached, call "renderMore"
}
this.collection.on('reset', this.addAll, this);
this.on('sort:change', this.addAll, this);
this.on('sort:change', this.renderSortSelect, this);
this.on('filter:change', this.renderFilterSelects, this);
//this.collection.on('sort', this.addAll, this);
//this.collection.on('sort:change', this.renderSortTitles, this); // when sort changes, re-render the sort titles/labels
this.collection.on('add', this.addAll, this);
this.collection.on('remove', this.addAll, this);
},
render: function(){
this._renderTop();
this.clearFilter(); // clear the filter input if need be
this.trigger('spin');
if( this.fetchOnRender )
this.collection.fetch();
else if( this.collection.length == 0 )
this.collection.trigger('reset');
else
_.defer(_.bind(function(){ // allow for the list to be inserted into the DOM by the parent before reseting (so infinite list works)
this.collection.trigger('reset')
},this))
this.trigger('render')
this.delegateEvents();
return this;
},
addOne: function(model){
model.trigger('list-rerender', model)
this.trigger('addOne', model);
if( _.isString(this.listView) )
this.listView = _.getObjectByName(this.listView);
if( !this.listView ){
console.error('! SortableFilterView: a “listView” must be defined');
return;
}
var data = {model: model}
if( this.listViewData ) data = _.extend(this.listViewData(), data);
var view = new this.listView(data);
this.list.$el.append( view.render().el );
if( this.close )
view.on('panel:close', this.close, this);
},
/*
Add More - this is called each time we reach the bottom of the list
*/
addMore: function(){
this.list.addMore(this.getCollection());
},
addAll: function(){
this.trigger('spin', false);
this.list.clear(); // clear and reset the list
//this.getCollection().sort();
this.refilter(false);
this.trigger('addAll')
// add the rows - instead of actually adding "all" the rows, we "add more" because infinite scrolling will load the rest
this.addMore();
this.updateCount();
},
_renderTop: function(){
this.$top.html('');
this.$count = $('<span class="count"></span>')
.appendTo(this.$top)
if( this.renderTop )
this.renderTop();
if( this.sorts && this.sorts.length > 0 ){
//this.$top.append('<a class="button white icon-only icon-arrow-combo change-desc"></a>');
this.$top.append( this.renderSortSelect() )
}
if( this.filters ){
this.$top.append( this.renderFilterSelects() )
}
if( this.filter )
this.$filter = $('<input type="text" placeholder="'+this.filterPlaceholder+'" class="filter '+this.filterInputStyle+'">')
.val(this.filterTerm)
.appendTo(this.$top)
},
renderSortSelect: function(){
if( !this.sorts || this.sorts.length == 0 ) return;
if( !this.$sortSelect )
this.$sortSelect = $('<div class="sorts"></div>')
else
this.$sortSelect.html('');
var sort = _.findWhere(this.sorts, {val: this.collection.sortKey()});
var label = sort && sort.label ? sort.label : 'No sort';
var icon = this.collection.sortDesc() ? 'icon-sort-name-down' : 'icon-sort-name-up';
$btn = $('<span class="button-group" title="Change sort">'
+'<a class="button flat subtle '+icon+' change-desc">'+label+'</a>'
+'<a class="button flat subtle icon-only icon-down-open change-sort"></a>'
+'</span>').appendTo(this.$sortSelect);
$btn.find('.change-sort').dropdown(this.sorts, {
align: 'bottom',
w: 120,
onClick: this.onChangeSort.bind(this)
});
return this.$sortSelect;
},
changeDesc: function(e){
this.changeSort(this.collection.sortKey());
},
changeSort: function(val){
//console.log('change sort?', val);
this.collection.changeSort( val );
this.trigger('sort:change');
},
onChangeSort: function(obj){
this.changeSort( obj.val );
},
/*
Render Filter Selects
renders the UI for adding, changing, and removing active filters
*/
renderFilterSelects: function(){
// nothing to render if no filters are given or of the filters given, none of them have "values" to choose from
if( !this.filters || !_.find(this.filters, function(o){ return o.values !== undefined}) ) return;
// create or clear the filter div
if( !this.$filterSelects )
this.$filterSelects = $('<div class="filters"></div>')
else
this.$filterSelects.html('');
// render all the currently active filters
_.each(this.activeFilters, this.renderActiveFilter, this)
// render the "add fitlter" button
this.renderAddFilterBtn();
return this.$filterSelects;
},
/*
Render Add Filter Button
*/
renderAddFilterBtn: function(){
var self = this;
// always add a "reset filter" option
var menu = [{label: 'Reset filters', onClick: this.resetFilters.bind(this)}, 'divider']
// render a dropdown menu for adding new filters, but only show filters that are NOT currently in use
this.foreachFilterNotInUse(function(obj, key){
var label = obj.label || _.humanize(key);
menu.push({
label: label,
dropdown: {
view: obj.values,
align: 'rightBottom',
w: obj.w || 120,
onClick: self.onFilterChange.bind(self, key)
}
})
})
// render the button
$('<a class="button flat subtle icon-only icon-filter" title="Apply new filter"></a>')
.appendTo(this.$filterSelects)
.dropdown(menu, {align: 'bottom', w: 110})
},
/*
Render Active Filters
this shows the user what filters are active and also gives them the option to change each one (via dropdown)
*/
renderActiveFilter: function(val, key){
var filter = this.filters[key]; // find the filter data: {values:[], filterBy:function(){}, etc}
if( !filter ) return;
var values = filter.values; // this filter must have values to choose from
if( _.isFunction(values) ) values = values(); // the values attribute could be an function, if so, run it.
// look for the value that is currently selected
var filterVal = _.find(values, function(item){
return item.val == val
});
if( !filterVal ) return; // if no filter value was found, dont render this filter item
// make a filter type label
var filterTypeLabel = filter.label || _.humanize(key);
// create the active filter as a button with a dropdown for selecting a different value
$('<a class="button flat subtle" title="Change '+filterTypeLabel+'">'+filterVal.label+'</a>')
.appendTo(this.$filterSelects)
.dropdown(filter.values, {
align: 'bottom',
w: filter.w || 120,
onClick: this.onFilterChange.bind(this, key)
})
},
onFilterChange: function(key, obj){
var val = obj.val;
this.collection.applyFilter(key, val);
this.applyFilter(key, val);
},
/*
Reset Filters
resets all the filters to their first value. Often times this will be "all/any";
*/
resetFilters: function(){
var resetVals = {};
this.foreachFilterInUse(function(obj, key){
if( obj.values[0] )
resetVals[key] = obj.values[0].val
})
this.collection.setFilters(resetVals); // these two methods are silent
this.setActiveFilters(resetVals);
this.trigger('sort:change'); // notify there is a sort change for re-render
},
updateCount: function(){
if( this.$count )
this.$count.html( this.getCollection().length );
},
focusFilter: function(){
if( this.$filter )
this.$filter.focus();
},
});
@kjantzer
Copy link
Author

kjantzer commented Nov 9, 2012

Simply extend FilterView to get quick and easy filtering!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment