This weekly widget was suggested by @Cherif_b. The article covers how to create a basic paginated grid. But even more important, it gives a glipse at how we organize large applications.
The app
The app allows you to paginate through a list of links:
What it does
You can go through each page by clicking the "Next" and "Prev" links. Those buttons become gray when clicking on one would move to a page without data.
The app also supports bookmarking of the current page and forward/back button support.
How it works
Bitovi's goal is a rapid development pace provided by maintainable, easily adaptable code. As I've said before, the secret to building large applications is to build small, isolated modules and glue them together. But the key to making that possible is organizing state into the observer pattern (pub-sub is an anti-pattern).
I've written this example to illustrate how Bitovi typically builds apps with CanJS. Our pattern is typically:
- Organize the client's stateful behavior into a
can.Observe
client state. - Build low-level views that are ignorant of that state.
- Write models to retrieve data and fixtures to fake services.
- Create an application controller that:
- hooks up the client state to
can.route
. - hooks up the client state to low-level widgets.
- hooks up the client state to
Client State
Organizing state is the most important thing you can do to make a maintainable app. For this app, I choose to represent the state with 3 values:
- limit - the number of items I want to display in the list
- offset - the index of the first item currently being displayed
- count - the total number of items that could be displayed
I created a Paginate
Observe "class" that encapsulates these values (and adds some nice helper functions):
var Paginate = can.Observe( .... )
This allows me to create a paginate
instance, set its limit, offset, and count, and also listen to when those values change:
var paginate = new Paginate({limit: 5, count: 20})
paginate.bind('offset', function(ev, newVal){
console.log("offset is now", newVal)
});
paginate.attr('offset',10)
I added setters to do bounds checking:
setOffset: function(newOffset){
return newOffset < 0 ?
0 :
Math.min(newOffset, ! isNaN(this.count - 1) ?
this.count - 1 :
Infinity )
},
This prevents offset
from being less than 0 or greater or equal to count
.
paginate.attr('offset',-20);
paginate.attr('offset') // ->0
I also created a bunch of helpers that represent the state as pages:
paginate.attr('offset',0);
paginate.page() //-> 0
paginate.canPrev() //-> false
paginate.page(2)
paginate.attr('offset') //-> 10
This application is small, so a single state object works. In bigger apps, state may be broken up into multiple state objects.
Application Controller
An application controller coordinates between the client state, view-widgets, and can.route
. In larger apps, the application controller might coordinate between other, lower-level controllers. For this app, only a single controller is necessary.
When an AppControl
is initialized, it starts by creating the client state:
var paginate = this.options.paginate = new Paginate({
limit: 5
});
And then hooking up the pagination widgets with that state:
new NextPrev("#nextprev",{paginate: paginate});
new PageCount("#pagecount",{
page: can.compute(paginate.page,paginate),
pageCount: can.compute(paginate.pageCount,paginate)
});
To hook up the grid, AppControl
uses can.compute to translate between the limit/offset and a deferred that resolves to corresponding Website
items. Through the magic of computed properties, when any widget in the app changes the limit or offset properties, this function will run, causing a new findAll request for websites:
var items = can.compute(function(){
var params = {
limit: paginate.attr('limit'),
offset: paginate.attr('offset')
};
var websitesDeferred = Website.findAll(params);
websitesDeferred(function(websites){
paginate.attr('count', websites.count)
});
return websitesDeferred;
});
items
is passed to the Grid
:
new Grid("#websites",{
items: items,
template: "websiteStache"
})
Grid will render items
in a live-bound template. Thus, any change to the client state will automatically cause the app to request and render the correct data. For example, if any widget changes offset
, items' can.compute function will fire, causing a new Website.findAll request. New data comes back, pushes to the items list, and causes the live-bound grid template to re-render itself. can.compute + live binding = awesome.
Notice that items
also sets the count
property of paginate
. count
is the total number of websites that would be returned if there was no pagination. I'll show you how to set up a fixture to do this in the next section.
Finally, AppControl
cross-binds the client state to can.route for bookmarking and back-button support:
"{paginate} offset": function(paginate){
can.route.attr('page', paginate.page());
},
"{can.route} page": function(route){
this.options.paginate.page(route.attr('page'));
}
Model and Fixtures
I created a Website
model that retrieves json data from a "/websites" url like:
var Website = can.Model({
findAll: "/websites"
},{});
The "/websites" service supports offset and limit parameters and returns a list of websites like:
{
data: [
{
"id": 1,
"name": "CanJS",
"url": "http://canjs.us"
},
...
],
count: 20
}
Where data
is an array of website data objects and count
is the total number of websites on the server. When this type of data is loaded by can.Model.findAll, the data
property's items are converted into a can.Model.List and all other properties, like count
are added as expando properties to the list.
View Widgets
NextPrev Widget
The view widgets are pretty straightforward. NextPrev
makes use of paginate
's helper methods directly:
var NextPrev = can.Control({
init: function(){
this.element.html(
can.view('nextPrevStache',this.options) );
},
".next click" : function(){
var paginate = this.options.paginate;
paginate.next();
},
".prev click" : function(){
var paginate = this.options.paginate;
paginate.prev();
}
});
PageCount Widget
While PageCount
simply renders the following template with its page
and pageCount
computes passed by AppControl
:
Page {{page}} of {{pageCount}}.
Bonus points to anyone who updates the fiddle to change the page number to a 2-way bound input element!
Grid Widget
Finally, the Grid
is written to accept an items can.compute
that contains either a list of items or a deferred that will resolve to the list of items. When the grid is initialized or items changes, update
is called, which checks if items is a deferred or not. If it is a deferred, it changes the opacity of the <tbody>
to indicate it is loading and then calls draw
once the deferred has resolved. If items is not a deferred, it assumes that it is a list of items and calls draw
.
update: function(){
var items = this.options.items();
if(can.isDeferred( items )){
this.element.find('tbody').css('opacity',0.5)
items.then(this.proxy('draw'));
} else {
this.draw(items);
}
}
draw
restores the opacity of the <tbody>
and renders the template passed in with items
:
draw: function(items){
this.element.find('tbody').css('opacity',1);
var data = $.extend({}, this.options,{items: items})
this.element.html( can.view(this.options.template, data) );
}
Initializing the App
At the end of the fiddle, an instance of AppControl
is created and the default route is set:
new AppControl(document.body);
can.route(":page",{page: "1"});
Conclusion
Understand and organize your app's state and you'll save yourself a lot of pain.
Hopefully this widget demonstrates the power of combining can.compute
, can.Observe
, and live binding. Build applications from the top down by:
- Creating client state (like
paginate
) to represent the state of your app. - Creating can.compute functions that listen for changes in client state and request new data (like
items
makes a new request forWebsite.findAll
). - Passing this data into your widgets, which render templates that are live-bound to changes in the data.
- Creating controls that listen to UI events and update client state.
Changes in your application just take care of themself.
User changes UI -> Event handler changes client state -> can.compute function fires -> new data is requested -> live bound templates re-render the relevant data
Lets hear some suggestions for next week!
Previous Post
Next Post