Backbone.Obscura
Backbone.Obscura is a read-only proxy of a Backbone.Collection that can easily be filtered, sorted, and paginated, while still implementing all of the read-only Backbone.Collection methods. As the underlying collection is changed the proxy is efficiently kept in sync, taking into account all of the transformations. All transformations can be modified or removed at any time.
You can pass the proxy into a Backbone.View and let Backbone.Obscura take care of the filtering or paginating logic, leaving your view to only re-render itself as the collection changes. This keeps your views simple and DRY. This works particularly well with Marionette's CollectionView and CompositeView.
Demo
var proxy = new Backbone.Obscura(originalCollection);
// Set the transformations on the original collection
proxy
.setPerPage(25)
.setSort('age', 'desc')
.filterBy(function(model) {
return model.get('age') > 17 && model.get('age') < 70;
});
// Read-only Backbone.Collection functions work on the transformed proxy
proxy.toJSON();
proxy.pluck('age');
proxy.at(3);
proxy.first();
// 'add', 'remove', 'change', 'reset' events are all forwarded for models in the proxy
proxy.on('add', function() { /* ... */ });
// Pass the proxy to a view that knows how to react to a changing collection
var view = new CollectionView({ collection: proxy });
// In another view or a controller, you can modify the state of the filters
if (proxy.hasNextPage()) {
proxy.nextPage();
}
$('button').on('click', function() {
proxy.reverseSort();
});
What's with the name?
The camera obscura is an optical device that projects an image of its surroundings on a screen. In a similar way, we are using a crude projection of the original collection to "draw" our views.
Use Cases
- You want to move pagination, filtering code out of your views
- You have several ways of filtering down an in-memory collection (find-as-you-type, value ranges) that all need to work together
- You have a collection that might be updated via push and need a filtered view to react to this change
- You have a centralized place where you manage your app's data, and need to visualize part of this data
- You want to have an additional view that presents a different view on the same data (Top 5, Latest)
How does it work?
This library is effectively a convenience wrapper around backbone-filtered-collection,
backbone-sorted-collection,
and backbone-paginated-collection.
The original libraries are exposed on the Obscura
object.
// backbone-filtered-collection
Backbone.Obscura.FilteredCollection
// backbone-sorted-collection
Backbone.Obscura.SortedCollection
// backbone-paginated-collection
Backbone.Obscura.PaginatedCollection
You can think of Backbone.Obscura
as a composition of these three transforms applied
one after the other.
var collection = new Backbone.Collection(/* data */);
var filtered = new FilteredCollection(collection);
var sorted = new SortedCollection(filtered);
var obscura = new PaginatedCollection(sorted);
API
Filter Methods
proxy.filterBy()
proxy.removeFilter()
proxy.resetFilters()
proxy.refilter()
proxy.getFilters()
proxy.hasFilter()
proxy.getFilteredLength
Sorting Methods
Pagination Methods
proxy.setPerPage()
proxy.setPage()
proxy.getPerPage()
proxy.getNumPages()
proxy.getPage()
proxy.hasNextPage()
proxy.hasPrevPage()
proxy.movePage()
proxy.nextPage()
proxy.prevPage()
proxy.firstPage()
proxy.lastPage()
proxy.removePagination()
Events
Methods
new Backbone.Obscura(collection [, options])
Initialize a new Obscura collection by passing in the original collection.
var proxy = new Backbone.Obscura(originalCollection);
You may also optionally pass an options hash. Currently the only supported option is setting the perPage
setting of the paginated transform.
var proxy = new Backbone.Obscura(originalCollection, { perPage: 25 });
proxy.superset()
Return a reference to the original collection.
proxy.superset();
proxy.removeTransforms()
Remove all filters, pagination settings, and sorting transforms. Afterwards the collection should be identical to the original collection.
proxy.removeTransforms();
proxy.destroy()
Remove all ties to the superset and stop updating. Will now be garbage collected when it falls out of scope.
proxy.destroy();
Filter methods
proxy.filterBy([filterName], filter)
Apply a new filter to the set. Takes an optional filter name.
Can be a simple object that defines required key / value pairs.
filtered.filterBy('foo and bar filter', { foo: 2, bar: 3 });
Or the you can pass a filter function instead of a value.
filtered.filterBy('a > 2', { a: function(val) {
return val > 2;
}});
Or say you wanted to narrow a value down to one of a couple of options:
filtered.filterBy('quality', { a: function(val) {
return _.contains([ 'better', 'best' ], val);
}});
Or you can use an arbitrary filter function on the model itself.
filtered.filterBy('age', function(model) {
return model.get('age') > 10 && model.get('age') < 40;
});
proxy.removeFilter(filterName)
Remove a previously applied filter. Accepts a filter name.
proxy.removeFilter('age');
proxy.resetFilters()
Removes all applied filters.
proxy.resetFilters();
proxy.refilter()
If the collections get out of sync (ex: change events have been suppressed) force the collection to refilter all of the models.
proxy.refilter();
Can also be forced to run on one model in particular.
proxy.refilter(model);
filtered.getFilters()
Returns a list of the names of applied filters.
Note: If added a filter with no name, it will show up here as __default
.
filtered.getFilters();
filtered.hasFilter()
Given a string, return whether or not that filter is currently applied.
filtered.hasFilter('name');
filtered.getFilteredLength()
Return the length of the filtered set. This is useful when you have also paginated the collection and want to know how many items are in the unpaginated set.
filtered.getFilteredLength();
Sorting methods
proxy.setSort(comparator, direction)
comparator
accepts:
- nothing or
null
, resets the sorting to the same order as the superset - a string, sorts by a model key
- a function that accepts a model and returns a value
direction
must be one of: "asc"
or "desc"
. If it's not provided it
will default to "asc"
.
// sort by the 'age' property descending
proxy.setSort('age', 'desc');
// equivalent to this
proxy.setSort(function(model) {
return model.get('age');
}, 'desc');
// but we can also do arbitrary computation in the closure
proxy.setSort(function(mode) {
return someComplicatedCalculation(model);
});
// Characters with accents get sorted to the end of the alphabet,
// so let's sort based on the unaccented version.
proxy.setSort(function(model) {
return removeAccents(model.get('name'));
});
// Pass nothing as an option to remove all sorting
proxy.setSort();
proxy.removeSort
Remove all sorting. Equivalent to calling sorted.setSort()
proxy.removeSort();
proxy.reverseSort
Reverse the sort. The API is chainable, so this can be called directly
after setSort
if you want the sort to be descending.
If there is no current sort function then this does nothing.
// Sort by age descending
proxy.setSort('age').reverseSort();
Pagination methods
proxy.setPerPage(perPage)
Change the number of models displayed per page. This will reset the current page to 0.
proxy.setPerPage(50);
proxy.setPage(page)
Change the page. If the page is less than 0, it will be set to 0. If it is longer than the number of pages, the last page will be selected.
proxy.setPage(5);
proxy.getPerPage()
Return the current setting for number of models per page.
proxy.getPerPage();
proxy.getNumPages()
Return the current number of pages.
proxy.getNumPages();
proxy.getPage()
Return the current page. E.G. if this returns 0, you're on the first page.
proxy.getPage();
proxy.hasNextPage()
Returns true if this is not the last page.
proxy.hasNextPage();
proxy.hasPrevPage()
Returns true if this is not the first page.
proxy.hasPrevPage();
proxy.movePage(delta)
Move delta
pages forwards or backwards (if delta
is negative).
// will move two pages back
proxy.movePage(-2);
proxy.nextPage()
Move to the next page. Equivalent to paginated.movePage(1)
.
proxy.nextPage();
proxy.prevPage()
Move to the previous page. Equivalent to paginated.movePage(-1)
.
proxy.prevPage();
paginated.firstPage()
Move to the first page of the collection. Equivalent to paginated.setPage(0)
.
proxy.firstPage();
paginated.lastPage()
Move to the last page of the collection. Equivalent to paginated.setPage(paginated.getNumPages() - 1)
.
proxy.lastPage();
proxy.removePagination()
Get rid of any paginated settings.
proxy.removePagination();
Events
add
, remove
, change
, reset
should fire as you expect.
filtered:add
- Fired when a new filter is added. Passes the filter name.
filtered:remove
- Fired with a filter is removed. Passes the filter name.
filtered:reset
- Fired when all of the filters are removed.
sorted:add
- Trigger when a sort function is set
sorted:remove
- Trigger when a sort function is removed
paginated:change:perPage
- Fired whenever the number of models per page is changed. If you
remove the pagination settings, perPage
will be passed as null
.
paginated:change:page
- Fired whenever the page is changed.
paginated:change:numPages
- Fired whenever the number of pages changes.
obscura:destroy
- Fired when the proxy is destroyed
Installation
Usage with Browserify or similar
Install with npm, use with Browserify
> npm install backbone.obscura
and in your code
var Obscura = require('backbone.obscura');
Usage with Bower
Install with Bower:
bower install backbone.obscura
The component can be used as a Common JS module, an AMD module, or a global.
Usage as browser global
You can include backbone.obscura.js
directly in a script tag. Make
sure that it is loaded after underscore and backbone. It's exported as
Backbone.Obscura
.
<script src="underscore.js"></script>
<script src="backbone.js"></script>
<script src="backbone.obscura.js"></script>
Testing
Install Node (comes with npm) and Bower.
From the repo root, install the project's development dependencies:
npm install
npm start
To run the tests in Firefox, just once, as CI would:
npm test
Alternative Libraries
There are several libraries that offer similar functionality, but none that offered the combination of features that I wanted.
- JavaScript, not CoffeeScript
- No need to define filters or sorting on initialization
- Ability to use arbitrary functions for filters or sorting
- Transparency, if no transforms are defined, the proxy should be the same as the original collection
- Ability to add and remove multiple filters
- Easy to use with Browserify, but also easy to throw into an AMD project
If this library doesn't meet your needs, maybe one of the following will:
- Backbone.CollectionFilter
- Backbone.Projections
- backbone.collectionsubset
- Backbone.VirtualCollection
- Backbone.Subset
License
MIT
Acknowledgements
Photo taken from Flickr, licensed under Creative Commons
Photo taken from Wikimedia, and is in the public domain