UNPKG

backbone-list-controller

Version:

A powerful Backbone.js class that renders a collection of models as a list with infinite scrolling, sorting, filtering, and search field.

1,188 lines (832 loc) 34 kB
/* List Controller --- Also see "DBListController" @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': { label: 'Partner', // optional label for this filter; if not set, the key, in this case "partner" will be used. values: [ // "values" are the options for this filter {label: 'Any Partner', val: ''}, 'divider', // "values" is given to Dropdown.js so the same options 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: not needed if the collection filter is set to db:true (filtering will happen server-side) }, multi: false, // if set to true, multiple values can be selected by holding "cmd/ctrl" // these two params are useful when you want to apply programmatically, but don't immeditely know what filter options are available // see "recent proofing changes" feature for first use manual: true, // if set, this filter will not show as an "filter" to select. this is useful for setting filters programmatically manualVal: 'Partner' // when manual, the val displayed in active filter "button" is value saved in collection...unless this is set } } 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"; this.filterContexts = { "flagged": function(){} } METHODS Many of the methods are prefixed with an underscore (_) to symbolize being "private" to the class - they shouldn't really be called from outside this class Some common methods that may be used changeFilter(key, val, optional) toggleFilter(key, val, optional) */ ListController = Backbone.View.extend({ _className: 'list-controller', //listView: 'Path.To.BackboneView', //listViewDivider: function(model){ return model.label(); }, // or string of method name `label` //listViewData: function(){ return {} }, // if this is specified, the data will be initialzed with the list view maxRows: 30, listStyle: 'simple', // plain, simple, card reuseListViews: false, // eventually will default to true, but event binding can need extra work displayList: true, // this means this view will load the collection into an infinite list scrollContext: '#main .inner', // the wrapping element that triggers infinite scrolling noResultsMsg: 'No Results', // string or function filter: true, // provide a "filter" input to make searches filterMinScore: .7, // minimum score for a model to pass filter filterDelay: 200, // (ms) filterPlaceholder: 'Filter...', filterInputStyle: 'right auto-hide', filterDropdownW: 120, // width of "apply filter" dropdown menu // hmmm... I think this shouldn't be used now... // filterResultCollection should be overridden to a custom Collection // which would contain the comparator....(need to confirm this though) filterComparator: null, // set to sort the filtered collection fetchOnRender: false, // list collection will be fetched upon rendering the list refreshBtn: false, // will show button for manual refresh allowFilterQueueing: true, viewBtn: false, defaultView: 'list', // list, grid, compare compareViewLimit: null, // note: the model for this collection switcher should contain a "switcherCollection" method //collectionSwitcher: null, // allows to switch the collection being rendered by using the filters //collectionSwitcherKey: null, //renderTop: function(){}, // override this to hook into the top bar render /* Sorts - see SortableCollection for how sorting is applied */ sorts: [ //{label: 'ID', val: 'id'}, //{label: 'Title', val: 'title'} ], /* 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: { //'default': function(term, model){} // if no default is given, this._defaultFilterContext will be used }, /* 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); }, /* ====================================================================================================================================== Shouldn't need to override the methods below */ // override the default backbone constructor constructor: function(data, opts){ // make sure setup has happened, but wait until after initialize() // note: this might be better: https://gist.github.com/fiznool/5720342 this.checkSetupTimeout = 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.checkSetupTimeout ) this.__setup() }, __events: { 'keyup input.filter' : 'filterCollectionFromInput', 'click a.change-desc': 'changeDesc', 'click .manual-refresh': 'manualRefresh', 'click .apply-queued-filters': 'applyQueuedFilters', }, __listenerEvents: [ ['sort:change', '_addAll'], ['sort:change', '_renderSortSelect'] ], // called automatially if not called in initialize method __setup: function(){ clearTimeout(this.checkSetupTimeout); this.events = _.extend({}, this.events||{}, this.__events); if( this.setupFilters ) this.setupFilters(); this.$el.addClass(this._className); // make sure ListController always has its class name this.$top = $('<div class="filter-bar top clearfix"></div>').appendTo(this.$el); if( this.displayList !== false ){ var scrollContext = this.options.scrollContext || this.scrollContext; this.list = new InfiniteListView(this.$el, { maxRows: this.maxRows, context: scrollContext, className: this.listStyle+' list clearfix' }); this.setView(); this.listenTo(this.list, 'addOne', this._addOne); // when the list wants to add one, call "renderOne" this.listenTo(this.list, 'endReached', this._addMore); // when end of list is reached, call "renderMore" } //if( !(this.collection instanceof SortableCollection) ) // console.warn('!! ListController: “this.collection” is not an instance of SortableCollection', this); // if no default filter context is given, add one if( !_.isFunction(this.filterContexts['default']) ) this.filterContexts['default'] = this._defaultFilterContext; _.each(this.__listenerEvents, function(l){ var key = l[0], fn = l[1]; if( this[fn] ) this.listenTo(this, key, this[fn]); }.bind(this)) if( this.collection || (!this.collection && !this.collectionSwitcher)) this.changeCollection( this.collection ); this.trigger('setup:done'); }, changeCollection: function(coll){ if( coll && !(coll instanceof SortableCollection) ) console.warn('!! ListController: “collection” is not an instance of SortableCollection', this); else if( !coll ) coll = new SortableCollection(); // fake collection for now so "no" results can be displayed if( this.collection ){ this.collection.getFiltered = null; this.stopListening(this.collection); } this.collection = coll; // reference this.collection.getFiltered = this.getCollection.bind(this); this.listenTo(this.collection, 'spin', this.spin); this.listenTo(this.collection, 'reset', this._addAllFromReset); // try this out... this.listenTo(this.collection, 'add', this._addAll); this.listenTo(this.collection, 'remove', this._removeItem); this.listenTo(this.collection, 'fetch:failed', this._fetchFailed) }, spin: function(stopSpin){ this.trigger('spin', stopSpin!==false); if( stopSpin ) this.updateCount('Loading...'); if( stopSpin!==false ){ // maybe not the best location, but all I could think of for now... this.$el.removeClass('invalid-data'); } }, render: function(){ this._renderTop(); this.clearFilter(); // clear the filter input if need be if( !this.collection && this.collectionSwitcher ) this.promptCollectionSwitcherPick(); else if( this.fetchOnRender ) this.autoFetchOnRender(); 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; }, // TODO: finish this; the idea is if collection isn't set, but we have a collection switcher, // prompt the user to pick a collection to begin viewing. Was thinking about this for merchandising // dashboard, but solved a different way promptCollectionSwitcherPick: function(){ console.log('promptCollectionSwitcherPick: finish me'); }, autoFetchOnRender: function(update){ update = update || false; this.collection.refresh(update) }, manualRefresh: function(){ this.autoFetchOnRender(true); }, _fetchFailed: function(coll, status, retry){ this.$el.addClass('invalid-data'); if( status == 'Invalid User' ) return;// (the global ajax error will catch this) new Modal({ title: status, msg: 'What would you like to do?', theme: 'ios7', btns: [{ label: 'Retry', className: 'green btn-primary md-close', onClick: function(){ retry() }, eventKey: 'enter' },{ label: 'Reset Filters', className: 'btn-primary md-close', onClick: this.resetFilters.bind(this) }, 'cancel'] }) }, _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('! ListController: a “listView” must be defined'); return; } var data = {model: model}; if( this.listViewData ) data = _.extend(this.listViewData(this.lastModel, model), data); // add label divider if needed this._addOneDivider(model); // reuse an existing view or create a new one if( this.reuseListViews == true ) var view = this.list.subview('list-view-'+model.id) || this.list.subview('list-view-'+model.id, new this.listView(data)); else var view = this.list.subview('list-view-'+model.id, new this.listView(data)); view.parentView = this; // overriding as we dont need InfiniteList to be accessible view.controller = this; this.lastModel = model; this.list.addOne( view ); model._selected ? view.$el.addClass('selected') : view.$el.removeClass('selected'); if( this.close ) this.listenTo(view, 'panel:close', this.close); }, _addOneDivider: function(model){ if( this.listViewDivider && !this.filterTerm ){ var dividerLabel = _.isFunction(this.listViewDivider) ? this.listViewDivider() : (model[this.listViewDivider] && _.isFunction(model[this.listViewDivider]) ? model[this.listViewDivider].call(model) : model.get(this.listViewDivider)); if( dividerLabel != this.__lastLabelDivider ){ this.list.$el.append('<li class="list-divider">'+dividerLabel+'</li>'); this.__lastLabelDivider = dividerLabel; } } }, /* Add More - this is called each time we reach the bottom of the list */ _addMore: function(){ this.list.addMore(this.getCollection()); this.trigger('addMore'); }, refreshList: function(){ this._addAll(); }, _addAllFromReset: function(coll, data){ // reset has new data if( coll && data ) this.list.cleanupSubviews(true); this._addAll(); }, _addAll: function(){ if( !this.displayList ) return; this.list.clear(); // clear and reset the list this.lastModel = null; this.__lastLabelDivider = null; // change the "no results" message if there is a search term this.list.noResultsMsg = this.filterTerm ? 'No Results for <i>“'+this.filterTerm+'”</i>' : ( _.isFunction(this.noResultsMsg) ? this.noResultsMsg() : this.noResultsMsg ); //this.getCollection().sort(); this.refilter(false); this.trigger('addAll'); // if the compare view has a limit, check it now if(this.compareViewLimit && this.inCompareMode() && this.getCollection().length > this.compareViewLimit) this.setView('list'); // add the rows - instead of actually adding "all" the rows, we "add more" because infinite scrolling will load the rest this._addMore(); this.updateCount(); }, _removeItem: function(model, coll, data){ //var indx = data.index; var indx = this.getCollection().indexOf(model); this.list.removeItem(indx, false); // remove the model from the DOM this.filteredCollection.remove(model); // remove from the filtered collection _.defer(function(){ this.updateCount(); }.bind(this)) }, _renderTop: function(){ this.$top.html(''); var showBulkSelect = this.bulkActions || (this.allowBulkSelect && this.allowDownload ); if( this.refreshBtn) this.$top.append('<a class="btn right icon-only icon-arrows-ccw manual-refresh" title="Refresh results"></a>'); if( this.viewBtn && this.displayList ) this._appendViewBtn(); if( this.allowDownload && this._appendDownloadBtn ) this._appendDownloadBtn(); if( this.filter ) this.$filter = $('<input type="text" placeholder="'+this.filterPlaceholder+'" class="filter '+this.filterInputStyle+'">') .val(this.filterTerm) .appendTo(this.$top) this.$count = $('<span class="count no-selection '+(showBulkSelect?'btn':'')+'" title="'+(showBulkSelect?'Toggle bulk select':'')+'"></span>') .appendTo(this.$top); if( this.renderTop ) this.renderTop(); if( showBulkSelect && this._appendBulkSelectActions ) this._appendBulkSelectActions(); if( this.sorts && this.sorts.length > 0 ) this.$top.append( this._renderSortSelect() ); if( this.filters && this._renderFilterSelects ) this.$top.append( this._renderFilterSelects() ) if( this.header ) this._renderHeader() }, _renderHeader: function(){ var titles = ''; _.each(this.header, function(obj){ obj = _.extend({ label: '', className: '' }, (_.isString(obj) ? {label: obj} : obj)); var className = _.underscored(_.stripTags(obj.label)); titles += '<div class="col '+className+' header-'+className+' '+obj.className+'">'+obj.label+'</div>'; }); this.$top.addClass('has-header'); this.$header = $('<div class="header-bar clearfix">'+titles+'</div>').appendTo(this.$top); }, _appendViewBtn: function(){ var menu = [ {label: 'List', val: 'list'}, {label: 'Grid',val: 'grid'}, {label: 'Compare',val: 'compare'} ]; var opts = { align: 'bottomLeft', w: 100, onClick: this.onSetView.bind(this) }; var $btn = $('<a class="btn right icon-only icon-columns switch-view" title="Switch view: List / Compare / Grid"></a>') .dropdown(menu, opts) .appendTo( this.$top ) }, inListMode: function(){ return this.viewKey() == 'list' }, inCompareMode: function(){ return this.viewKey() == 'compare' }, inGridMode: function(){ return this.viewKey() == 'grid' }, viewKey: function(){ return _.store('ListController:view:'+this.className) || this.defaultView; }, onSetView: function(obj){ var setVal = this.setView(obj.val); var title = _.capitalize(obj.val)+' View'; var msg = 'There are too many results for the <b>'+obj.val.toUpperCase()+'</b> view.<br><br>It is limited to '+this.compareViewLimit+' results'; if( setVal != obj.val ){ typeof Modal == 'undefined' ? alert(title+"\n\n"+_.stripTags(msg)) : Modal.alert(title, msg); } }, setView: function(viewKey){ var oldKey = this.viewKey(); var newKey = viewKey ? viewKey : oldKey; // if view does not allow view button, make sure to set to List if( !this.viewBtn ) newKey = this.defaultView; // 'list' // compare view doesn't work well with too many results if( this.compareViewLimit && newKey == 'compare' && this.getCollection().length > this.compareViewLimit ) newKey = oldKey == 'compare' ? 'list' : oldKey; this.list.$el.removeClass('mode-'+oldKey); //if( newKey != 'list' ) this.list.$el.addClass('mode-'+newKey); _.store('ListController:view:'+this.className, newKey ); if( newKey != oldKey ) this.trigger('view:change', newKey, this); return newKey; }, _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="btn-group" title="Change sort">' +'<a class="btn '+icon+' change-desc">'+label+'</a>' +'<a class="btn 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; }, // ! deprecated - see ListController.resultExports downloadCSV: function(){ this.getCollection().saveToCSV(this.downloadName); }, changeDesc: function(e){ this.changeSort(this.collection.sortKey()); }, changeSort: function(val){ this.collection.changeSort( val ); this.trigger('sort:change'); }, _onChangeSort: function(obj){ this.changeSort( obj.val ); }, activeFilter: function(key){ return this.activeFilters && this.activeFilters[key]; }, activeFilterVal: function(key, defaultVal){ return this.activeFilter(key) ? this.activeFilter(key).val : defaultVal; }, /* Active Filter String - returns HTML string of the active (selected) filters */ activeFilterString: function(separator, html){ var strs = []; _.each(this.activeFilters, function(data, key){ var title = this.filters[key].label || _.titleize(_.humanize(key)); strs.push('<em>'+title+':</em> <b>'+this._renderActiveFilter(data, key, true)+'</b>'); }, this); strs = strs.join(separator||', '); if( html === false ) strs = _.stripTags(strs); return strs; }, isQueueing: function(){ return this.__queueing === true; }, toggleQueueUpFilters: function(){ if( this.isQueueing() ) this.applyQueuedFilters(); else this.beginQueueingFilters(); }, beginQueueingFilters: function(){ this.__queueing = true; this.$el.addClass('queueing-filters'); this.trigger('filter:queue:begin') }, applyQueuedFilters: function(){ this.__queueing = false; this.$el.removeClass('queueing-filters'); this.trigger('filter:queue:apply'); this.manualRefresh(); }, /* On Filter Change - when a filter option is selected */ _onFilterChange: function(key, optional, item){ var val = item.val; var filter = this.filters[key]; var activeFilter = this.activeFilters[key]; // instead of setting a value for the selected filter, set several filters based on the given data if( !val && item.filters ){ this.resetFiltersTo(item.filters); return; } // if it is a multi select filter, then figure out what the real value should be if( filter.multi ){ if(val === undefined || val === null || val === '' /*|| val === 'All' || val === 'all'*/){ // do nothing; // was "ctrl/cmd" pressed on the click? }else if( _.metaKey() ){ // get current values for filter key var vals = activeFilter ? [].concat(activeFilter.val) : []; var indx = _.indexOf(vals, val); // if the selcted val already exists, remove it if( indx > -1 ) vals.splice(indx, 1); // else add to existing values => [val1, val2, newVal] else vals.push(val); val = vals; // no, just replace the old value with the new one }else{ val = [val]; } } // begin the filter change this.changeFilter(key, val, optional) }, changeFilter: function(key, val, optional){ var self = this; // if the filter changed was set to be the Collection Switcher, switch the collection now if( this.collectionSwitcherKey == key ){ // find the new model to switch to, then get the collection from it var model = this.collectionSwitcher.get(val); var coll = model ? model.switcherCollection() : null; if( coll && !(coll instanceof Backbone.Collection) ){ if( coll ) console.warn('!! Cannot switch to non Backbone.Collection:', val, model, coll) }else{ this.spin(); this.changeCollection(coll); // switch to new collection if( coll ) this.autoFetchOnRender(); // fetch data for the collection else this.manualRefresh() } } // notify filter is changing if other views want to know this.trigger('filter:changing', key, val); // let the JS process queue clear before saving _.defer(function(){ self.collection.applyFilter(key, val, {fetch: !self.isQueueing()}); // save to collection (local storage rememberance and such) self._applyFilter(key, {val: val, optional:optional}, !self.isQueueing()); // actually apply the filter (will trigger refilter) }) }, toggleFilter: function(key, val, optional){ if( this.activeFilters && this.activeFilters[key] ) val = null; // this will cause the filter to be removed this.changeFilter(key, val, optional); }, _onFilterOptionalToggle: function(key, item){ this.collection.toggleFilterOptional(key, {fetch: !this.isQueueing()}); this._toggleFilterOptional(key, !this.isQueueing()); }, /* Reset Filters resets all the filters to their first value. Often times this will be "all/any/clear"; */ resetFilters: function(){ this.resetFiltersTo({}); }, resetFiltersTo: function(vals){ var resetVals = {}; this.foreachFilterInUse(function(obj, key){ if( obj.values ){ var values = obj.values; if( _.isFunction(values) ) values = values.call(obj); if( values[obj.defaultValIndex||0] ) resetVals[key] = values[obj.defaultValIndex||0].val } }); resetVals = _.extend(resetVals, vals); this.collection.setFilters(resetVals, !this.isQueueing()); // these two methods are silent this.setActiveFilters(resetVals); this.trigger('sort:change'); // notify there is a sort change for re-render }, updateCount: function(label){ if( !this.$count ) return; var str = label || this.getCollection().length; if( !label && this.isBulkSelectOn ){ str = this.getSelected().length+' of '+str; this.$count.attr('data-selected', this.getSelected().length) } this.$count.html( str ); }, focusFilter: function(){ if( this.$filter ) this.$filter.focus(); }, // todo: add padding support scrollTo: function(el, padding){ var el = el instanceof Backbone.View ? el.el : el; padding = padding || 0; this.list.el.scrollTop = el.offsetTop + padding; // NOTE: this probably isn't cross browser...need to enhance before public release }, /* FILTER VIEW code */ filteredCollection: this.collection, filterTerm: '', // current filter term filterResult: null, // current filter result collection // 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 /* For Each Filter NOT in Use - convenience method */ foreachFilterNotInUse: function(callback){ var self = this; if( callback ) _.each(this.filters, function(obj, key){ if( self.activeFilters && self.activeFilters[key] !== undefined ) return; callback.call(self, obj, key); }) }, /* For Each Filter IN USE - convenience method */ foreachFilterInUse: function(callback){ var self = this; if( callback ) _.each(this.filters, function(obj, key){ if( !self.activeFilters || self.activeFilters[key] === undefined ) return; callback.call(self, obj, key); }) }, /* 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){ this.activeFilters = {}; _.each(filters, function(data, key){ if( _.isObject(data) && !_.isArray(data) ){ this._applyFilter(data.key, {val: data.val, optional:data.optional}, false); }else{ this._applyFilter(key, {val:data}, false); } }, this) }, /* PRIVATE: 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, filterData, triggerReset){ filterData = _.isObject(filterData) ? filterData : {val:filterData}; // backwards compatibility var val = filterData.val; // if filter val is null (or empty), remove this filter key from active filters if(val === undefined || val === null || val === '' /*|| val === 'All' || val === 'all'*/) this._removeActiveFilter(filterKey); // else, update/set the filter key with the given value else this._addActiveFilter(filterKey, filterData); this.trigger('filter:change'); this.refilter(triggerReset); }, /* Toggle Filter Optional - toggle the optional value on a filter */ _toggleFilterOptional: function(filterKey, triggerReset){ var activeFilter = this.activeFilters[filterKey]; if( !activeFilter ) return; this.activeFilters[filterKey].optional = !activeFilter.optional; 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, filterData){ this.activeFilters = this.activeFilters || {}; this.activeFilters[filterKey] = filterData; this.$el.addClass('is-filtered').attr('data-filtered-'+filterKey, filterData.val); }, _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; this.filteredCollection = this.filteredCollection.filter(function(model){ var required = [], optional = []; _.each(this.activeFilters, function(filterData, filterKey){ var filterVal = filterData.val, filterOptional = filterData.optional === true, filterFn = this._filterFn(filterKey); if( !filterFn ) return; // skip filtering for this key if no filter by function is found var matched = filterFn.call(this, model, filterVal, filterKey, this); filterOptional ? optional.push(matched) : required.push(matched); }, this); return ( required.length === 0 || _.indexOf(required, false) == -1 ) && (optional.length === 0 || _.indexOf(optional, true) > -1 ); }, this); this.filteredCollection = this.filterResultCollection( this.filteredCollection ); // sort filtered collection if( this.filterComparator ){ this.filteredCollection = this.filteredCollection.sortBy(this.filterComparator, this); 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 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; clearTimeout(this._filterCollectionFromInputTimeout); // slight delay to see if key is pressed again this._filterCollectionFromInputTimeout = setTimeout(function(){ this.filterCollection(); this.trigger('filter:search', term) }.bind(this), this.filterDelay) }, /* 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 (the current filtered collection) */ 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; }, /* Get Selected (when bulk select is on, gets the books that are selected) TODO: these results should probably be cached, but it needs to be reset when getCollection changes */ getSelected: function(){ return this.filterResultCollection(this.getCollection().filter(function(model){ return model._selected; })); }, /* Get Current Collection takes in account "bulk select" */ getCurrentCollection: function(){ return this.isBulkSelectOn ? this.getSelected() : this.getCollection(); } });