PageRenderTime 61ms CodeModel.GetById 2ms app.highlight 53ms RepoModel.GetById 1ms app.codeStats 0ms

/chapters/10-pagination.md

https://github.com/xiaoyukid/backbone-fundamentals
Markdown | 549 lines | 384 code | 165 blank | 0 comment | 0 complexity | ff8735afb5ab30386e098bd6bf20f476 MD5 | raw file
  1
  2# Paginating Backbone.js Requests & Collections
  3
  4## Introduction
  5
  6Pagination is a ubiquitous problem we often find ourselves needing to solve on the web - perhaps most predominantly when working with service APIs and JavaScript-heavy clients which consume them. It's also a problem that is often under-refined as most of us consider pagination relatively easy to get right. This isn't however always the case as pagination tends to get more tricky than it initially seems. 
  7
  8Before we dive into solutions for paginating data for your Backbone applications, let's define exactly what we consider pagination to be:
  9
 10Pagination is a control system allowing users to browse through pages of search results (or any type of content) which is continued. Search results are the canonical example, but pagination today is found on news sites, blogs, and discussion boards, often in the form of Previous and Next links. More complete pagination systems offer granular control of the specific pages you can navigate to, giving the user more power to find what they are looking for. 
 11
 12It isn't a problem limited to pages requiring some visual controls for pagination either - sites like Facebook, Pinterest, and Twitter have demonstrated that there are many contexts where infinite paging is also useful. Infinite paging is, of course, when we pre-fetch (or appear to pre-fetch) content from a subsequent page and add it directly to the user’s current page, making the experience feel "infinite".
 13
 14Pagination is very context-specific and depends on the content being displayed. In the Google search results, pagination is important as they want to offer you the most relevant set of results in the first 1-2 pages. After that, you might be a little more selective (or random) with the page you choose to navigate to. This differs from cases where you'll want to cycle through consecutive pages for (e.g., for a news article or blog post). 
 15
 16Pagination is almost certainly content and context-specific, but as Faruk Ates has [previously](https://gist.github.com/mislav/622561) pointed out the principles of good pagination apply no matter what the content or context is. As with everything extensible when it comes to Backbone, you can write your own pagination to address many of these content-specific types of pagination problems. That said, you'll probably spend quite a bit of time on this and sometimes you just want to use a tried and tested solution that just works.
 17
 18On this topic, we're going to go through a set of pagination components I (and a group of [contributors](https://github.com/addyosmani/backbone.paginator/contributors)) wrote for Backbone.js, which should hopefully come in useful if you're working on applications which need to page Backbone Collections. They're part of an extension called [Backbone.Paginator](http://github.com/addyosmani/backbone.paginator).
 19
 20### Backbone.Paginator
 21
 22When working with data on the client-side, the three types of pagination we are most likely to run into are:
 23
 24**Requests to a service layer (API)** - For example, query for results containing the term 'Paul' - if 5,000 results are available only display 20 results per page (leaving us with 250 possible result pages that can be navigated to).
 25
 26This problem actually has quite a great deal more to it, such as maintaining persistence of other URL parameters (e.g sort, query, order) which can change based on a user's search configuration in a UI. One also has to think of a clean way of hooking views up to this pagination so you can easily navigate between pages (e.g., First, Last, Next, Previous, 1,2,3), manage the number of results displayed per page and so on.
 27
 28**Further client-side pagination of data returned -** e.g we've been returned a JSON response containing 100 results. Rather than displaying all 100 to the user, we only display 20 of these results within a navigable UI in the browser.
 29
 30Similar to the request problem, client-pagination has its own challenges like navigation once again (Next, Previous, 1,2,3), sorting, order, switching the number of results to display per page and so on.
 31
 32**Infinite results** - with services such as Facebook, the concept of numeric pagination is instead replaced with a 'Load More' or 'View More' button. Triggering this normally fetches the next 'page' of N results but rather than replacing the previous set of results loaded entirely, we simply append to them instead.
 33
 34A request pager which simply appends results in a view rather than replacing on each new fetch is effectively an 'infinite' pager.
 35
 36**Let's now take a look at exactly what we're getting out of the box:**
 37
 38Backbone.Paginator is a set of opinionated components for paginating collections of data using Backbone.js. It aims to provide both solutions for assisting with pagination of requests to a server (e.g an API) as well as pagination of single-loads of data, where we may wish to further paginate a collection of N results into M pages within a view.
 39
 40![](img/paginator-ui.png)
 41
 42Backbone.Paginator supports two main pagination components:
 43
 44* **Backbone.Paginator.requestPager**: For pagination of requests between a client and a server-side API
 45* **Backbone.Paginator.clientPager**: For pagination of data returned from a server which you would like to further paginate within the UI (e.g 60 results are returned, paginate into 3 pages of 20)
 46
 47### Live Examples
 48
 49If you would like to look at examples built using the components included in the project, links to official demos are included below and use the Netflix API so that you can see them working with an actual data source.
 50
 51* [Backbone.Paginator.requestPager()](http://addyosmani.github.com/backbone.paginator/examples/netflix-request-paging/index.html)
 52* [Backbone.Paginator.clientPager()](http://addyosmani.github.com/backbone.paginator/examples/netflix-client-paging/index.html)
 53* [Infinite Pagination (Backbone.Paginator.requestPager())](http://addyosmani.github.com/backbone.paginator/examples/netflix-infinite-paging/index.html)
 54* [Diacritic Plugin](http://addyosmani.github.com/backbone.paginator/examples/google-diacritic/index.html)
 55
 56##Paginator.requestPager
 57
 58In this section we're going to walk through using the requestPager. You would use this component when working with a service API which itself supports pagination. This component allows users to control the pagination settings for requests to this API (i.e navigate to the next, previous, N pages) via the client-side. 
 59
 60The idea is that pagination, searching, and filtering of data can all be done from your Backbone application without the need for a page reload. 
 61
 62![](img/paginator-request.png)
 63
 64####1. Create a new Paginated collection
 65
 66First, we define a new Paginated collection using `Backbone.Paginator.requestPager()` as follows:
 67
 68```javascript
 69
 70var PaginatedCollection = Backbone.Paginator.requestPager.extend({
 71
 72```
 73
 74####2. Set the model for the collection as normal
 75
 76Within our collection, we then (as normal) specify the model to be used with this collection followed by the URL (or base URL) for the service providing our data (e.g the Netflix API).
 77
 78```javascript
 79
 80        model: model,
 81```
 82
 83####3. Configure the base URL and the type of the request
 84
 85We need to set a base URL. The `type` of the request is `GET` by default, and the `dataType` is `jsonp` in order to enable cross-domain requests.
 86
 87```javascript
 88    paginator_core: {
 89      // the type of the request (GET by default)
 90      type: 'GET',
 91
 92      // the type of reply (jsonp by default)
 93      dataType: 'jsonp',
 94
 95      // the URL (or base URL) for the service
 96      // if you want to have a more dynamic URL, you can make this a function
 97      // that returns a string
 98      url: 'http://odata.netflix.com/Catalog/People(49446)/TitlesActedIn?'
 99    },
100```
101
102## Gotchas!
103
104If you use `dataType` **NOT** jsonp, please remove the callback custom parameter inside the `server_api` configuration.
105
106####4. Configure how the library will show the results
107
108We need to tell the library how many items per page we would like to see, etc...
109
110```javascript
111    paginator_ui: {
112      // the lowest page index your API allows to be accessed
113      firstPage: 0,
114
115      // which page should the paginator start from
116      // (also, the actual page the paginator is on)
117      currentPage: 0,
118
119      // how many items per page should be shown
120      perPage: 3,
121
122      // a default number of total pages to query in case the API or
123      // service you are using does not support providing the total
124      // number of pages for us.
125      // 10 as a default in case your service doesn't return the total
126      totalPages: 10
127    },
128```
129
130####5. Configure the parameters we want to send to the server
131
132Only the base URL won't be enough for most cases, so you can pass more parameters to the server.
133Note how you can use functions instead of hardcoded values, and you can also refer to the values you specified in `paginator_ui`.
134
135```javascript
136    server_api: {
137      // the query field in the request
138      '$filter': '',
139
140      // number of items to return per request/page
141      '$top': function() { return this.perPage },
142
143      // how many results the request should skip ahead to
144      // customize as needed. For the Netflix API, skipping ahead based on
145      // page * number of results per page was necessary.
146      '$skip': function() { return this.currentPage * this.perPage },
147
148      // field to sort by
149      '$orderby': 'ReleaseYear',
150
151      // what format would you like to request results in?
152      '$format': 'json',
153
154      // custom parameters
155      '$inlinecount': 'allpages',
156      '$callback': 'callback'
157    },
158```
159
160## Gotchas!
161
162If you use `$callback`, please ensure that you did use the jsonp as a `dataType` inside your `paginator_core` configuration.
163
164####6. Finally, configure Collection.parse() and we're done
165
166The last thing we need to do is configure our collection's `parse()` method. We want to ensure we're returning the correct part of our JSON response containing the data our collection will be populated with, which below is `response.d.results` (for the Netflix API).
167
168You might also notice that we're setting `this.totalPages` to the total page count returned by the API. This allows us to define the maximum number of (result) pages available for the current/last request so that we can clearly display this in the UI. It also allows us to influence whether clicking say, a 'next' button should proceed with a request or not.
169
170```javascript
171        parse: function (response) {
172            // Be sure to change this based on how your results
173            // are structured (e.g d.results is Netflix specific)
174            var tags = response.d.results;
175            //Normally this.totalPages would equal response.d.__count
176            //but as this particular NetFlix request only returns a
177            //total count of items for the search, we divide.
178            this.totalPages = Math.ceil(response.d.__count / this.perPage);
179            return tags;
180        }
181    });
182
183});
184```
185
186####Convenience methods:
187
188For your convenience, the following methods are made available for use in your views to interact with the `requestPager`:
189
190* **Collection.goTo( n, options )** - go to a specific page
191* **Collection.nextPage( options )** - go to the next page
192* **Collection.prevPage( options )** - go to the previous page
193* **Collection.howManyPer( n )** - set the number of items to display per page
194
195**requestPager** collection's methods `.goTo()`, `.nextPage()` and `.prevPage()` are all extensions of the original [Backbone Collection.fetch() methods](http://documentcloud.github.com/backbone/#Collection-fetch). As so, they all can take the same option object as a parameter.
196
197This option object can use `success` and `error` parameters to pass a function to be executed after server answer.
198
199```javascript
200Collection.goTo(n, {
201  success: function( collection, response ) {
202    // called is server request success
203  },
204  error: function( collection, response ) {
205    // called if server request fail
206  }
207});
208```
209
210To manage callback, you could also use the [jqXHR](http://api.jquery.com/jQuery.ajax/#jqXHR) returned by these methods to manage callback.
211
212```javascript
213Collection
214  .requestNextPage()
215  .done(function( data, textStatus, jqXHR ) {
216    // called is server request success
217  })
218  .fail(function( data, textStatus, jqXHR ) {
219    // called if server request fail
220  })
221  .always(function( data, textStatus, jqXHR ) {
222    // do something after server request is complete
223  });
224});
225```
226
227If you'd like to add the incoming models to the current collection, instead of replacing the collection's contents, pass `{update: true, remove: false}` as options to these methods.
228
229```javascript
230Collection.prevPage({ update: true, remove: false });
231```
232
233##Paginator.clientPager
234
235
236The clientPager is used to further paginate data that has already been returned by the service API. Say you've requested 100 results from the service and wish to split this into 5 pages of paginated results, each containing 20 results at a client level - the clientPager makes it trivial to do this.
237
238![](img/paginator-client.png)
239
240Use the clientPager when you prefer to get results in a single "load" and thus avoid making additional network requests each time your users want to fetch the next "page" of items. As the results have all already been requested, it's just a case of switching between the ranges of data actually presented to the user.
241
242####1. Create a new paginated collection with a model and URL
243
244As with `requestPager`, let's first create a new Paginated `Backbone.Paginator.clientPager` collection, with a model:
245
246```javascript
247    var PaginatedCollection = Backbone.Paginator.clientPager.extend({
248
249        model: model,
250```
251
252####2. Configure the base URL and the type of the request
253
254We need to set a base URL. The `type` of the request is `GET` by default, and the `dataType` is `jsonp` in order to enable cross-domain requests.
255
256```javascript
257    paginator_core: {
258      // the type of the request (GET by default)
259      type: 'GET',
260
261      // the type of reply (jsonp by default)
262      dataType: 'jsonp',
263
264      // the URL (or base URL) for the service
265      url: 'http://odata.netflix.com/v2/Catalog/Titles?&'
266    },
267```
268
269####3. Configure how the library will show the results
270
271We need to tell the library how many items per page we would like to see, etc...
272
273```javascript
274    paginator_ui: {
275      // the lowest page index your API allows to be accessed
276      firstPage: 1,
277
278      // which page should the paginator start from
279      // (also, the actual page the paginator is on)
280      currentPage: 1,
281
282      // how many items per page should be shown
283      perPage: 3,
284
285      // a default number of total pages to query in case the API or
286      // service you are using does not support providing the total
287      // number of pages for us.
288      // 10 as a default in case your service doesn't return the total
289      totalPages: 10,
290
291      // The total number of pages to be shown as a pagination
292      // list is calculated by (pagesInRange * 2) + 1.
293      pagesInRange: 4
294    },
295```
296
297####4. Configure the parameters we want to send to the server
298
299Only the base URL won't be enough for most cases, so you can pass more parameters to the server.
300Note how you can use functions instead of hardcoded values, and you can also refer to the values you specified in `paginator_ui`.
301
302```javascript
303    server_api: {
304      // the query field in the request
305      '$filter': 'substringof(\'america\',Name)',
306
307      // number of items to return per request/page
308      '$top': function() { return this.perPage },
309
310      // how many results the request should skip ahead to
311      // customize as needed. For the Netflix API, skipping ahead based on
312      // page * number of results per page was necessary.
313      '$skip': function() { return this.currentPage * this.perPage },
314
315      // field to sort by
316      '$orderby': 'ReleaseYear',
317
318      // what format would you like to request results in?
319      '$format': 'json',
320
321      // custom parameters
322      '$inlinecount': 'allpages',
323      '$callback': 'callback'
324    },
325```
326
327####5. Finally, configure Collection.parse() and we're done
328
329And finally we have our `parse()` method, which in this case isn't concerned with the total number of result pages available on the server as we have our own total count of pages for the paginated data in the UI.
330
331```javascript
332    parse: function (response) {
333            var tags = response.d.results;
334            return tags;
335        }
336
337    });
338```
339
340####Convenience methods:
341
342As mentioned, your views can hook into a number of convenience methods to navigate around UI-paginated data. For `clientPager` these include:
343
344* **Collection.goTo(n, options)** - go to a specific page
345* **Collection.prevPage(options)** - go to the previous page
346* **Collection.nextPage(options)** - go to the next page
347* **Collection.howManyPer(n)** - set how many items to display per page
348* **Collection.setSort(sortBy, sortDirection)** - update sort on the current view. Sorting will automatically detect if you're trying to sort numbers (even if they're strored as strings) and will do the right thing.
349* **Collection.setFilter(filterFields, filterWords)** - filter the current view. Filtering supports multiple words without any specific order, so you'll basically get a full-text search ability. Also, you can pass it only one field from the model, or you can pass an array with fields and all of them will get filtered. Last option is to pass it an object containing a comparison method and rules. Currently, only ```levenshtein``` method is available.
350
351The `goTo()`, `prevPage()`, and `nextPage()` functions do not require the `options` param since they will be executed synchronously. However, when specified, the success callback will be invoked before the function returns. For example:
352
353```javascript
354nextPage(); // this works just fine!
355nextPage({success: function() { }}); // this will call the success function
356```
357
358The options param exists to preserve (some) interface unification between the requestPaginator and clientPaginator so that they may be used interchangeably in your Backbone.Views.
359
360```javascript
361  this.collection.setFilter(
362    {'Name': {cmp_method: 'levenshtein', max_distance: 7}}
363    , "Amreican P" // Note the switched 'r' and 'e', and the 'P' from 'Pie'
364  );
365```
366
367Also note that the Levenshtein plugin should be loaded and enabled using the ```useLevenshteinPlugin``` variable.
368Last but not less important: performing Levenshtein comparison returns the ```distance``` between two strings. It won't let you *search* lengthy text.
369The distance between two strings means the number of characters that should be added, removed or moved to the left or to the right so the strings get equal.
370That means that comparing "Something" in "This is a test that could show something" will return 32, which is bigger than comparing "Something" and "ABCDEFG" (9).
371Use Levenshtein only for short texts (titles, names, etc).
372
373* **Collection.doFakeFilter(filterFields, filterWords)** - returns the models count after fake-applying a call to ```Collection.setFilter```.
374
375* **Collection.setFieldFilter(rules)** - filter each value of each model according to `rules` that you pass as argument. Example: You have a collection of books with 'release year' and 'author'. You can filter only the books that were released between 1999 and 2003. And then you can add another `rule` that will filter those books only to authors who's name start with 'A'. Possible rules: function, required, min, max, range, minLength, maxLength, rangeLength, oneOf, equalTo, containsAllOf, pattern.  Passing this an empty rules set will remove any FieldFilter rules applied.
376
377
378```javascript
379
380  my_collection.setFieldFilter([
381    {field: 'release_year', type: 'range', value: {min: '1999', max: '2003'}},
382    {field: 'author', type: 'pattern', value: new RegExp('A*', 'igm')}
383  ]);
384
385  //Rules:
386  //
387  //var my_var = 'green';
388  //
389  //{field: 'color', type: 'equalTo', value: my_var}
390  //{field: 'color', type: 'function', value: function(field_value){ return field_value == my_var; } }
391  //{field: 'color', type: 'required'}
392  //{field: 'number_of_colors', type: 'min', value: '2'}
393  //{field: 'number_of_colors', type: 'max', value: '4'}
394  //{field: 'number_of_colors', type: 'range', value: {min: '2', max: '4'} }
395  //{field: 'color_name', type: 'minLength', value: '4'}
396  //{field: 'color_name', type: 'maxLength', value: '6'}
397  //{field: 'color_name', type: 'rangeLength', value: {min: '4', max: '6'}}
398  //{field: 'color_name', type: 'oneOf', value: ['green', 'yellow']}
399  //{field: 'color_name', type: 'pattern', value: new RegExp('gre*', 'ig')}
400  //{field: 'color_name', type: 'containsAllOf', value: ['green', 'yellow', 'blue']}
401```
402
403* **Collection.doFakeFieldFilter(rules)** - returns the models count after fake-applying a call to ```Collection.setFieldFilter```.
404
405####Implementation notes:
406
407You can use some variables in your ```View``` to represent the actual state of the paginator.
408
409* ```totalUnfilteredRecords``` - Contains the number of records, including all records filtered in any way. (Only available in ```clientPager```)
410* ```totalRecords``` - Contains the number of records
411* ```currentPage``` - The actual page were the paginator is at.
412* ```perPage``` - The number of records the paginator will show per page.
413* ```totalPages``` - The number of total pages.
414* ```startRecord``` - The position of the first record shown in the current page (eg 41 to 50 from 2000 records) (Only available in ```clientPager```)
415* ```endRecord``` - The position of the last record shown in the current page (eg 41 to 50 from 2000 records) (Only available in ```clientPager```)
416* ```pagesInRange``` - The number of pages to be drawn on each side of the current page. So if ```pagesInRange``` is 3 and ```currentPage``` is 13 you will get the numbers 10, 11, 12, 13(selected), 14, 15, 16.
417
418```html
419<!-- sample template for pagination UI -->
420<script type="text/html" id="tmpServerPagination">
421
422  <div class="row-fluid">
423
424    <div class="pagination span8">
425      <ul>
426        <% _.each (pageSet, function (p) { %>
427        <% if (currentPage == p) { %>
428          <li class="active"><span><%= p %></span></li>
429        <% } else { %>
430          <li><a href="#" class="page"><%= p %></a></li>
431        <% } %>
432        <% }); %>
433      </ul>
434    </div>
435
436    <div class="pagination span4">
437      <ul>
438        <% if (currentPage > firstPage) { %>
439          <li><a href="#" class="serverprevious">Previous</a></li>
440        <% }else{ %>
441          <li><span>Previous</span></li>
442        <% }%>
443        <% if (currentPage < totalPages) { %>
444          <li><a href="#" class="servernext">Next</a></li>
445        <% } else { %>
446          <li><span>Next</span></li>
447        <% } %>
448        <% if (firstPage != currentPage) { %>
449          <li><a href="#" class="serverfirst">First</a></li>
450        <% } else { %>
451          <li><span>First</span></li>
452        <% } %>
453        <% if (totalPages != currentPage) { %>
454          <li><a href="#" class="serverlast">Last</a></li>
455        <% } else { %>
456          <li><span>Last</span></li>
457        <% } %>
458      </ul>
459    </div>
460
461  </div>
462
463  <span class="cell serverhowmany"> Show <a href="#"
464    class="selected">18</a> | <a href="#" class="">9</a> | <a href="#" class="">12</a> per page
465  </span>
466
467  <span class="divider">/</span>
468
469  <span class="cell first records">
470    Page: <span class="label"><%= currentPage %></span> of <span class="label"><%= totalPages %></span> shown
471  </span>
472
473</script>
474```
475
476### Plugins
477
478**Diacritic.js**
479
480A plugin for Backbone.Paginator that replaces diacritic characters (`´`, `˝`, `̏`, `˚`,`~` etc.) with characters that match them most closely. This is particularly useful for filtering.
481
482![](img/paginator-dia.png)
483
484To enable the plugin, set `this.useDiacriticsPlugin` to true, as can be seen in the example below:
485
486```javascript
487Paginator.clientPager = Backbone.Collection.extend({
488
489    // Default values used when sorting and/or filtering.
490    initialize: function(){
491      this.useDiacriticsPlugin = true; // use diacritics plugin if available
492    ...
493```
494
495### Bootstrapping
496
497By default, both the clientPager and requestPager will make an initial request to the server in order to populate their internal paging data. In order to avoid this additional request, it may be beneficial to bootstrap your Backbone.Paginator instance from data that already exists in the dom.
498
499**Backbone.Paginator.clientPager:**
500
501```javascript
502
503// Extend the Backbone.Paginator.clientPager with your own configuration options
504var MyClientPager =  Backbone.Paginator.clientPager.extend({paginator_ui: {}});
505// Create an instance of your class and populate with the models of your entire collection
506var aClientPager = new MyClientPager([{id: 1, title: 'foo'}, {id: 2, title: 'bar'}]);
507// Invoke the bootstrap function
508aClientPager.bootstrap();
509```
510
511Note: If you intend to bootstrap a clientPager, there is no need to specify a 'paginator_core' object in your configuration (since you should have already populated the clientPager with the entirety of it's necessary data)
512
513**Backbone.Paginator.requestPager:**
514
515```javascript
516
517// Extend the Backbone.Paginator.requestPager with your own configuration options
518var MyRequestPager =  Backbone.Paginator.requestPager.extend({paginator_ui: {}});
519// Create an instance of your class with the first page of data
520var aRequestPager = new MyRequestPager([{id: 1, title: 'foo'}, {id: 2, title: 'bar'}]);
521// Invoke the bootstrap function and configure requestPager with 'totalRecords'
522aRequestPager.bootstrap({totalRecords: 50});
523```
524
525Note: Both the clientPager and requestPager ```bootstrap``` function will accept an options param that will be extended by your Backbone.Paginator instance. However the 'totalRecords' property will be set implicitly by the clientPager.
526
527[More on Backbone bootstrapping](http://ricostacruz.com/backbone-patterns/#bootstrapping_data)
528
529### Styling
530
531You're of course free to customize the overall look and feel of the paginators as much as you wish. By default, all sample applications make use of the [Twitter Bootstrap](http://twitter.github.com/bootstrap) for styling links, buttons and drop-downs. 
532
533CSS classes are available to style record counts, filters, sorting and more:
534
535![](img/paginator-styling2.png)
536
537Classes are also available for styling more granular elements like page counts within `breadcrumb > pages`  e.g `.page`, `.page selected`:
538
539![](img/paginator-classes.png)
540
541There's a tremendous amount of flexibility available for styling and as you're in control of templating too, your paginators can be made to look as visually simple or complex as needed.
542
543### Conclusions
544
545Although it's certainly possible to write your own custom pagination classes to work with Backbone Collections, Backbone.Paginator tries to take care of much of this for you. 
546
547It's highly configurable, avoiding the need to write your own paging when working with Collections of data sourced from your database or API. Use the plugin to help tame large lists of data into more manageable, easily navigatable, paginated lists. 
548
549Additionally, if you have any questions about Backbone.Paginator (or would like to help improve it), feel free to post to the project [issues](https://github.com/addyosmani/backbone.paginator) list.