<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Bitovi Blog - UX and UI design, JavaScript and Frontend development
Loading

Weekly Widget 3 - Paginated Grid

Weekly Widget 3 - Paginated Grid

Justin Meyer

Justin Meyer

Twitter Reddit

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:

  1. Organize the client's stateful behavior into a can.Observe client state.
  2. Build low-level views that are ignorant of that state.
  3. Write models to retrieve data and fixtures to fake services.
  4. Create an application controller that:
    • hooks up the client state to can.route.
    • hooks up the client state to low-level widgets.

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:

  1. Creating client state (like paginate) to represent the state of your app.
  2. Creating can.compute functions that listen for changes in client state and request new data (like items makes a new request for Website.findAll).
  3. Passing this data into your widgets, which render templates that are live-bound to changes in the data.
  4. 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!