diff --git a/Gruntfile.js b/Gruntfile.js index a319fb897..2314d0e74 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -22,6 +22,7 @@ module.exports = function (grunt) { 'UI/JsLibraries/backbone.pageable.js' : 'http://raw.github.com/wyuenho/backbone-pageable/master/lib/backbone-pageable.js', 'UI/JsLibraries/backbone.backgrid.js' : 'http://raw.github.com/wyuenho/backgrid/master/lib/backgrid.js', 'UI/JsLibraries/backbone.backgrid.paginator.js' : 'http://raw.github.com/wyuenho/backgrid/master/lib/extensions/paginator/backgrid-paginator.js', + 'UI/JsLibraries/backbone.backgrid.filter.js' : 'http://raw.github.com/wyuenho/backgrid/master/lib/extensions/filter/backgrid-filter.js', 'UI/JsLibraries/messenger.js' : 'http://raw.github.com/HubSpot/messenger/master/build/js/messenger.js', 'UI/Content/messenger.css' : 'http://raw.github.com/HubSpot/messenger/master/build/css/messenger.css', 'UI/Content/bootstrap.toggle-switch.css' : 'http://raw.github.com/ghinda/css-toggle-switch/gh-pages/toggle-switch.css', diff --git a/UI/Content/backbone.backgrid.filter.css b/UI/Content/backbone.backgrid.filter.css new file mode 100644 index 000000000..4f9388529 --- /dev/null +++ b/UI/Content/backbone.backgrid.filter.css @@ -0,0 +1,19 @@ +/* +backgrid-filter +http://github.com/wyuenho/backgrid + +Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors +Licensed under the MIT @license. +*/ + +.backgrid-filter .close { + display: inline-block; + float: none; + width: 20px; + height: 20px; + margin-top: -4px; + font-size: 20px; + line-height: 20px; + text-align: center; + vertical-align: text-top; +} \ No newline at end of file diff --git a/UI/Index.html b/UI/Index.html index b834817c7..7d9cefd56 100644 --- a/UI/Index.html +++ b/UI/Index.html @@ -17,6 +17,7 @@ + @@ -104,6 +105,7 @@ + diff --git a/UI/JsLibraries/backbone.backgrid.filter.js b/UI/JsLibraries/backbone.backgrid.filter.js new file mode 100644 index 000000000..c98c6658b --- /dev/null +++ b/UI/JsLibraries/backbone.backgrid.filter.js @@ -0,0 +1,357 @@ +/* + backgrid-filter + http://github.com/wyuenho/backgrid + + Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors + Licensed under the MIT @license. +*/ + +(function ($, _, Backbone, Backgrid, lunr) { + + "use strict"; + + /** + ServerSideFilter is a search form widget that submits a query to the server + for filtering the current collection. + + @class Backgrid.Extension.ServerSideFilter + */ + var ServerSideFilter = Backgrid.Extension.ServerSideFilter = Backbone.View.extend({ + + /** @property */ + tagName: "form", + + /** @property */ + className: "backgrid-filter form-search", + + /** @property {function(Object, ?Object=): string} template */ + template: _.template('
placeholder="<%- placeholder %>" <% } %> name="<%- name %>" />×
'), + + /** @property */ + events: { + "click .close": "clear", + "submit": "search" + }, + + /** @property {string} [name='q'] Query key */ + name: "q", + + /** @property The HTML5 placeholder to appear beneath the search box. */ + placeholder: null, + + /** + @param {Object} options + @param {Backbone.Collection} options.collection + @param {String} [options.name] + @param {String} [options.placeholder] + */ + initialize: function (options) { + Backgrid.requireOptions(options, ["collection"]); + Backbone.View.prototype.initialize.apply(this, arguments); + this.name = options.name || this.name; + this.placeholder = options.placeholder || this.placeholder; + }, + + /** + Upon search form submission, this event handler constructs a query + parameter object and pass it to Collection#fetch for server-side + filtering. + */ + search: function (e) { + if (e) e.preventDefault(); + var $text = $(e.target).find("input[type=text]"); + var data = {}; + data[$text.attr("name")] = $text.val(); + this.collection.fetch({data: data}); + }, + + /** + Event handler for the close button. Clears the search box and refetch the + collection. + */ + clear: function (e) { + if (e) e.preventDefault(); + this.$("input[type=text]").val(null); + this.collection.fetch(); + }, + + /** + Renders a search form with a text box, optionally with a placeholder and + a preset value if supplied during initialization. + */ + render: function () { + this.$el.empty().append(this.template({ + name: this.name, + placeholder: this.placeholder, + value: this.value + })); + this.delegateEvents(); + return this; + } + + }); + + /** + ClientSideFilter is a search form widget that searches a collection for + model matches against a query on the client side. The exact matching + algorithm can be overriden by subclasses. + + @class Backgrid.Extension.ClientSideFilter + @extends Backgrid.Extension.ServerSideFilter + */ + var ClientSideFilter = Backgrid.Extension.ClientSideFilter = ServerSideFilter.extend({ + + /** @property */ + events: { + "click .close": function (e) { + e.preventDefault(); + this.clear(); + }, + "change input[type=text]": "search", + "keyup input[type=text]": "search", + "submit": function (e) { + e.preventDefault(); + this.search(); + } + }, + + /** + @property {?Array.} A list of model field names to search + for matches. If null, all of the fields will be searched. + */ + fields: null, + + /** + @property wait The time in milliseconds to wait since for since the last + change to the search box's value before searching. This value can be + adjusted depending on how often the search box is used and how large the + search index is. + */ + wait: 149, + + /** + Debounces the #search and #clear methods and makes a copy of the given + collection for searching. + + @param {Object} options + @param {Backbone.Collection} options.collection + @param {String} [options.placeholder] + @param {String} [options.fields] + @param {String} [options.wait=149] + */ + initialize: function (options) { + ServerSideFilter.prototype.initialize.apply(this, arguments); + + this.fields = options.fields || this.fields; + this.wait = options.wait || this.wait; + + this._debounceMethods(["search", "clear"]); + + var collection = this.collection; + var shadowCollection = this.shadowCollection = collection.clone(); + shadowCollection.url = collection.url; + shadowCollection.sync = collection.sync; + shadowCollection.parse = collection.parse; + + this.listenTo(collection, "add", function (model, collection, options) { + shadowCollection.add(model, options); + }); + this.listenTo(collection, "remove", function (model, collection, options) { + shadowCollection.remove(model, options); + }); + this.listenTo(collection, "sort reset", function (collection, options) { + options = _.extend({reindex: true}, options || {}); + if (options.reindex) shadowCollection.reset(collection.models); + }); + }, + + _debounceMethods: function (methodNames) { + if (_.isString(methodNames)) methodNames = [methodNames]; + + this.undelegateEvents(); + + for (var i = 0, l = methodNames.length; i < l; i++) { + var methodName = methodNames[i]; + var method = this[methodName]; + this[methodName] = _.debounce(method, this.wait); + } + + this.delegateEvents(); + }, + + /** + This default implementation takes a query string and returns a matcher + function that looks for matches in the model's #fields or all of its + fields if #fields is null, for any of the words in the query + case-insensitively. + + Subclasses overriding this method must take care to conform to the + signature of the matcher function. In addition, when the matcher function + is called, its context will be bound to this ClientSideFilter object so + it has access to the filter's attributes and methods. + + @param {string} query The search query in the search box. + @return {function(Backbone.Model):boolean} A matching function. + */ + makeMatcher: function (query) { + var regexp = new RegExp(query.trim().split(/\W/).join("|"), "i"); + return function (model) { + var keys = this.fields || model.keys(); + for (var i = 0, l = keys.length; i < l; i++) { + if (regexp.test(model.get(keys[i]) + "")) return true; + } + return false; + }; + }, + + /** + Takes the query from the search box, constructs a matcher with it and + loops through collection looking for matches. Reset the given collection + when all the matches have been found. + */ + search: function () { + var matcher = _.bind(this.makeMatcher(this.$("input[type=text]").val()), this); + this.collection.reset(this.shadowCollection.filter(matcher), {reindex: false}); + }, + + /** + Clears the search box and reset the collection to its original. + */ + clear: function () { + this.$("input[type=text]").val(null); + this.collection.reset(this.shadowCollection.models, {reindex: false}); + } + + }); + + /** + LunrFilter is a ClientSideFilter that uses [lunrjs](http://lunrjs.com/) to + index the text fields of each model for a collection, and performs + full-text searching. + + @class Backgrid.Extension.LunrFilter + @extends Backgrid.Extension.ClientSideFilter + */ + Backgrid.Extension.LunrFilter = ClientSideFilter.extend({ + + /** + @property {string} [ref="id"]`lunrjs` document reference attribute name. + */ + ref: "id", + + /** + @property {Object} fields A hash of `lunrjs` index field names and boost + value. Unlike ClientSideFilter#fields, LunrFilter#fields is _required_ to + initialize the index. + */ + fields: null, + + /** + Indexes the underlying collection on construction. The index will refresh + when the underlying collection is reset. If any model is added, removed + or if any indexed fields of any models has changed, the index will be + updated. + + @param {Object} options + @param {Backbone.Collection} options.collection + @param {String} [options.placeholder] + @param {string} [options.ref] `lunrjs` document reference attribute name. + @param {Object} [options.fields] A hash of `lunrjs` index field names and + boost value. + @param {number} [options.wait] + */ + initialize: function (options) { + ClientSideFilter.prototype.initialize.apply(this, arguments); + + this.ref = options.ref || this.ref; + + var collection = this.collection; + this.listenTo(collection, "add", this.addToIndex); + this.listenTo(collection, "remove", this.removeFromIndex); + this.listenTo(collection, "reset", this.resetIndex); + this.listenTo(collection, "change", this.updateIndex); + + this.resetIndex(collection); + }, + + /** + Reindex the collection. If `options.reindex` is `false`, this method is a + no-op. + + @param {Backbone.Collection} collection + @param {Object} [options] + @param {boolean} [options.reindex=true] + */ + resetIndex: function (collection, options) { + options = _.extend({reindex: true}, options || {}); + + if (options.reindex) { + var self = this; + this.index = lunr(function () { + _.each(self.fields, function (boost, fieldName) { + this.field(fieldName, boost); + this.ref(self.ref); + }, this); + }); + + collection.each(function (model) { + this.addToIndex(model); + }, this); + } + }, + + /** + Adds the given model to the index. + + @param {Backbone.Model} model + */ + addToIndex: function (model) { + var index = this.index; + var doc = model.toJSON(); + if (index.documentStore.has(doc[this.ref])) index.update(doc); + else index.add(doc); + }, + + /** + Removes the given model from the index. + + @param {Backbone.Model} model + */ + removeFromIndex: function (model) { + var index = this.index; + var doc = model.toJSON(); + if (index.documentStore.has(doc[this.ref])) index.remove(doc); + }, + + /** + Updates the index for the given model. + + @param {Backbone.Model} model + */ + updateIndex: function (model) { + var changed = model.changedAttributes(); + if (changed && !_.isEmpty(_.intersection(_.keys(this.fields), + _.keys(changed)))) { + this.index.update(model.toJSON()); + } + }, + + /** + Takes the query from the search box and performs a full-text search on + the client-side. The search result is returned by resetting the + underlying collection to the models after interrogating the index for the + query answer. + */ + search: function () { + var searchResults = this.index.search(this.$("input[type=text]").val()); + var models = []; + for (var i = 0; i < searchResults.length; i++) { + var result = searchResults[i]; + models.push(this.shadowCollection.get(result.ref)); + } + this.collection.reset(models, {reindex: false}); + } + + }); + +}(jQuery, _, Backbone, Backgrid, lunr));