/ajax/libs/backbone-pageable/1.3.0/backbone-pageable.js
JavaScript | 1303 lines | 626 code | 154 blank | 523 comment | 230 complexity | 9ce1347671a74a3a6ff4a9a6c9d642e7 MD5 | raw file
1/*
2 backbone-pageable 1.3.0
3 http://github.com/wyuenho/backbone-pageable
4
5 Copyright (c) 2013 Jimmy Yuen Ho Wong
6 Licensed under the MIT @license.
7*/
8
9(function (factory) {
10
11 // CommonJS
12 if (typeof exports == "object") {
13 module.exports = factory(require("underscore"), require("backbone"));
14 }
15 // AMD
16 else if (typeof define == "function" && define.amd) {
17 define(["underscore", "backbone"], factory);
18 }
19 // Browser
20 else if (typeof _ !== "undefined" && typeof Backbone !== "undefined") {
21 var oldPageableCollection = Backbone.PageableCollection;
22 var PageableCollection = Backbone.PageableCollection = factory(_, Backbone);
23
24 /**
25 __BROWSER ONLY__
26
27 If you already have an object named `PageableCollection` attached to the
28 `Backbone` module, you can use this to return a local reference to this
29 Backbone.PageableCollection class and reset the name
30 Backbone.PageableCollection to its previous definition.
31
32 // The left hand side gives you a reference to this
33 // Backbone.PageableCollection implementation, the right hand side
34 // resets Backbone.PageableCollection to your other
35 // Backbone.PageableCollection.
36 var PageableCollection = Backbone.PageableCollection.noConflict();
37
38 @static
39 @member Backbone.PageableCollection
40 @return {Backbone.PageableCollection}
41 */
42 Backbone.PageableCollection.noConflict = function () {
43 Backbone.PageableCollection = oldPageableCollection;
44 return PageableCollection;
45 };
46 }
47
48}(function (_, Backbone) {
49
50 "use strict";
51
52 var _extend = _.extend;
53 var _omit = _.omit;
54 var _clone = _.clone;
55 var _each = _.each;
56 var _pick = _.pick;
57 var _contains = _.contains;
58 var _isEmpty = _.isEmpty;
59 var _pairs = _.pairs;
60 var _invert = _.invert;
61 var _isArray = _.isArray;
62 var _isFunction = _.isFunction;
63 var _isObject = _.isObject;
64 var _keys = _.keys;
65 var _isUndefined = _.isUndefined;
66 var _result = _.result;
67 var ceil = Math.ceil;
68 var floor = Math.floor;
69 var max = Math.max;
70
71 var BBColProto = Backbone.Collection.prototype;
72
73 function finiteInt (val, name) {
74 if (!_.isNumber(val) || _.isNaN(val) || !_.isFinite(val) || ~~val !== val) {
75 throw new TypeError("`" + name + "` must be a finite integer");
76 }
77 return val;
78 }
79
80 function queryStringToParams (qs) {
81 var kvp, k, v, ls, params = {}, decode = decodeURIComponent;
82 var kvps = qs.split('&');
83 for (var i = 0, l = kvps.length; i < l; i++) {
84 var param = kvps[i];
85 kvp = param.split('='), k = kvp[0], v = kvp[1] || true;
86 k = decode(k), ls = params[k];
87 if (_isArray(ls)) ls.push(v);
88 else if (ls) params[k] = [ls, v];
89 else params[k] = v;
90 }
91 return params;
92 }
93
94 var PARAM_TRIM_RE = /[\s'"]/g;
95 var URL_TRIM_RE = /[<>\s'"]/g;
96
97 /**
98 Drop-in replacement for Backbone.Collection. Supports server-side and
99 client-side pagination and sorting. Client-side mode also support fully
100 multi-directional synchronization of changes between pages.
101
102 @class Backbone.PageableCollection
103 @extends Backbone.Collection
104 */
105 var PageableCollection = Backbone.Collection.extend({
106
107 /**
108 The container object to store all pagination states.
109
110 You can override the default state by extending this class or specifying
111 them in an `options` hash to the constructor.
112
113 @property {Object} state
114
115 @property {0|1} [state.firstPage=1] The first page index. Set to 0 if
116 your server API uses 0-based indices. You should only override this value
117 during extension, initialization or reset by the server after
118 fetching. This value should be read only at other times.
119
120 @property {number} [state.lastPage=null] The last page index. This value
121 is __read only__ and it's calculated based on whether `firstPage` is 0 or
122 1, during bootstrapping, fetching and resetting. Please don't change this
123 value under any circumstances.
124
125 @property {number} [state.currentPage=null] The current page index. You
126 should only override this value during extension, initialization or reset
127 by the server after fetching. This value should be read only at other
128 times. Can be a 0-based or 1-based index, depending on whether
129 `firstPage` is 0 or 1. If left as default, it will be set to `firstPage`
130 on initialization.
131
132 @property {number} [state.pageSize=25] How many records to show per
133 page. This value is __read only__ after initialization, if you want to
134 change the page size after initialization, you must call #setPageSize.
135
136 @property {number} [state.totalPages=null] How many pages there are. This
137 value is __read only__ and it is calculated from `totalRecords`.
138
139 @property {number} [state.totalRecords=null] How many records there
140 are. This value is __required__ under server mode. This value is optional
141 for client mode as the number will be the same as the number of models
142 during bootstrapping and during fetching, either supplied by the server
143 in the metadata, or calculated from the size of the response.
144
145 @property {string} [state.sortKey=null] The model attribute to use for
146 sorting.
147
148 @property {-1|0|1} [state.order=-1] The order to use for sorting. Specify
149 -1 for ascending order or 1 for descending order. If 0, no client side
150 sorting will be done and the order query parameter will not be sent to
151 the server during a fetch.
152 */
153 state: {
154 firstPage: 1,
155 lastPage: null,
156 currentPage: null,
157 pageSize: 25,
158 totalPages: null,
159 totalRecords: null,
160 sortKey: null,
161 order: -1
162 },
163
164 /**
165 @property {"server"|"client"|"infinite"} [mode="server"] The mode of
166 operations for this collection. `"server"` paginates on the server-side,
167 `"client"` paginates on the client-side and `"infinite"` paginates on the
168 server-side for APIs that do not support `totalRecords`.
169 */
170 mode: "server",
171
172 /**
173 A translation map to convert Backbone.PageableCollection state attributes
174 to the query parameters accepted by your server API.
175
176 You can override the default state by extending this class or specifying
177 them in `options.queryParams` object hash to the constructor.
178
179 @property {Object} queryParams
180 @property {string} [queryParams.currentPage="page"]
181 @property {string} [queryParams.pageSize="per_page"]
182 @property {string} [queryParams.totalPages="total_pages"]
183 @property {string} [queryParams.totalRecords="total_entries"]
184 @property {string} [queryParams.sortKey="sort_by"]
185 @property {string} [queryParams.order="order"]
186 @property {string} [queryParams.directions={"-1": "asc", "1": "desc"}] A
187 map for translating a Backbone.PageableCollection#state.order constant to
188 the ones your server API accepts.
189 */
190 queryParams: {
191 currentPage: "page",
192 pageSize: "per_page",
193 totalPages: "total_pages",
194 totalRecords: "total_entries",
195 sortKey: "sort_by",
196 order: "order",
197 directions: {
198 "-1": "asc",
199 "1": "desc"
200 }
201 },
202
203 /**
204 __CLIENT MODE ONLY__
205
206 This collection is the internal storage for the bootstrapped or fetched
207 models. You can use this if you want to operate on all the pages.
208
209 @property {Backbone.Collection} fullCollection
210 */
211
212 /**
213 Given a list of models or model attributues, bootstraps the full
214 collection in client mode or infinite mode, or just the page you want in
215 server mode.
216
217 If you want to initialize a collection to a different state than the
218 default, you can specify them in `options.state`. Any state parameters
219 supplied will be merged with the default. If you want to change the
220 default mapping from #state keys to your server API's query parameter
221 names, you can specifiy an object hash in `option.queryParams`. Likewise,
222 any mapping provided will be merged with the default. Lastly, all
223 Backbone.Collection constructor options are also accepted.
224
225 See:
226
227 - Backbone.PageableCollection#state
228 - Backbone.PageableCollection#queryParams
229 - [Backbone.Collection#initialize](http://backbonejs.org/#Collection-constructor)
230
231 @param {Array.<Object>} [models]
232
233 @param {Object} [options]
234
235 @param {function(*, *): number} [options.comparator] If specified, this
236 comparator is set to the current page under server mode, or the #fullCollection
237 otherwise.
238
239 @param {boolean} [options.full] If `false` and either a
240 `options.comparator` or `sortKey` is defined, the comparator is attached
241 to the current page. Default is `true` under client or infinite mode and
242 the comparator will be attached to the #fullCollection.
243
244 @param {Object} [options.state] The state attributes overriding the defaults.
245
246 @param {string} [options.state.sortKey] The model attribute to use for
247 sorting. If specified instead of `options.comparator`, a comparator will
248 be automatically created using this value, and optionally a sorting order
249 specified in `options.state.order`. The comparator is then attached to
250 the new collection instance.
251
252 @param {-1|1} [options.state.order] The order to use for sorting. Specify
253 -1 for ascending order and 1 for descending order.
254
255 @param {Object} [options.queryParam]
256 */
257 constructor: function (models, options) {
258
259 Backbone.Collection.apply(this, arguments);
260
261 options = options || {};
262
263 var mode = this.mode = options.mode || this.mode || PageableProto.mode;
264
265 var queryParams = _extend({}, PageableProto.queryParams, this.queryParams,
266 options.queryParams || {});
267
268 queryParams.directions = _extend({},
269 PageableProto.queryParams.directions,
270 this.queryParams.directions,
271 queryParams.directions || {});
272
273 this.queryParams = queryParams;
274
275 var state = this.state = _extend({}, PageableProto.state, this.state,
276 options.state || {});
277
278 state.currentPage = state.currentPage == null ?
279 state.firstPage :
280 state.currentPage;
281
282 if (!_isArray(models)) models = models ? [models] : [];
283
284 if (mode != "server" && state.totalRecords == null && !_isEmpty(models)) {
285 state.totalRecords = models.length;
286 }
287
288 this.switchMode(mode, _extend({fetch: false,
289 resetState: false,
290 models: models}, options));
291
292 var comparator = options.comparator;
293
294 if (state.sortKey && !comparator) {
295 this.setSorting(state.sortKey, state.order, options);
296 }
297
298 if (mode != "server") {
299 var fullCollection = this.fullCollection;
300
301 if (comparator && options.full) {
302 delete this.comparator;
303 fullCollection.comparator = comparator;
304 }
305
306 if (options.full) fullCollection.sort();
307
308 // make sure the models in the current page and full collection have the
309 // same references
310 if (models && !_isEmpty(models)) {
311 this.getPage(state.currentPage);
312 models.splice.apply(models, [0, models.length].concat(this.models));
313 }
314 }
315
316 this._initState = _clone(this.state);
317 },
318
319 /**
320 Makes a Backbone.Collection that contains all the pages.
321
322 @private
323 @param {Array.<Object|Backbone.Model>} models
324 @param {Object} options Options for Backbone.Collection constructor.
325 @return {Backbone.Collection}
326 */
327 _makeFullCollection: function (models, options) {
328
329 var properties = ["url", "model", "sync", "comparator"];
330 var thisProto = this.constructor.prototype;
331 var i, length, prop;
332
333 var proto = {};
334 for (i = 0, length = properties.length; i < length; i++) {
335 prop = properties[i];
336 if (!_isUndefined(thisProto[prop])) {
337 proto[prop] = thisProto[prop];
338 }
339 }
340
341 var fullCollection = new (Backbone.Collection.extend(proto))(models, options);
342
343 for (i = 0, length = properties.length; i < length; i++) {
344 prop = properties[i];
345 if (this[prop] !== thisProto[prop]) {
346 fullCollection[prop] = this[prop];
347 }
348 }
349
350 return fullCollection;
351 },
352
353 /**
354 Factory method that returns a Backbone event handler that responses to
355 the `add`, `remove`, `reset`, and the `sort` events. The returned event
356 handler will synchronize the current page collection and the full
357 collection's models.
358
359 @private
360
361 @param {Backbone.PageableCollection} pageCol
362 @param {Backbone.Collection} fullCol
363
364 @return {function(string, Backbone.Model, Backbone.Collection, Object)}
365 Collection event handler
366 */
367 _makeCollectionEventHandler: function (pageCol, fullCol) {
368
369 return function collectionEventHandler (event, model, collection, options) {
370
371 var handlers = pageCol._handlers;
372 _each(_keys(handlers), function (event) {
373 var handler = handlers[event];
374 pageCol.off(event, handler);
375 fullCol.off(event, handler);
376 });
377
378 var state = _clone(pageCol.state);
379 var firstPage = state.firstPage;
380 var currentPage = firstPage === 0 ?
381 state.currentPage :
382 state.currentPage - 1;
383 var pageSize = state.pageSize;
384 var pageStart = currentPage * pageSize, pageEnd = pageStart + pageSize;
385
386 if (event == "add") {
387 var pageIndex, fullIndex, addAt, colToAdd, options = options || {};
388 if (collection == fullCol) {
389 fullIndex = fullCol.indexOf(model);
390 if (fullIndex >= pageStart && fullIndex < pageEnd) {
391 colToAdd = pageCol;
392 pageIndex = addAt = fullIndex - pageStart;
393 }
394 }
395 else {
396 pageIndex = pageCol.indexOf(model);
397 fullIndex = pageStart + pageIndex;
398 colToAdd = fullCol;
399 var addAt = !_isUndefined(options.at) ?
400 options.at + pageStart :
401 fullIndex;
402 }
403
404 ++state.totalRecords;
405 pageCol.state = pageCol._checkState(state);
406
407 if (colToAdd) {
408 colToAdd.add(model, _extend({}, options || {}, {at: addAt}));
409 var modelToRemove = pageIndex >= pageSize ?
410 model :
411 !_isUndefined(options.at) && addAt < pageEnd && pageCol.length > pageSize ?
412 pageCol.at(pageSize) :
413 null;
414 if (modelToRemove) {
415 var addHandlers = collection._events.add || [],
416 popOptions = {onAdd: true};
417 if (addHandlers.length) {
418 var lastAddHandler = addHandlers[addHandlers.length - 1];
419 var oldCallback = lastAddHandler.callback;
420 lastAddHandler.callback = function () {
421 try {
422 oldCallback.apply(this, arguments);
423 pageCol.remove(modelToRemove, popOptions);
424 }
425 finally {
426 lastAddHandler.callback = oldCallback;
427 }
428 };
429 }
430 else pageCol.remove(modelToRemove, popOptions);
431 }
432 }
433 }
434
435 // remove the model from the other collection as well
436 if (event == "remove") {
437 if (!options.onAdd) {
438 // decrement totalRecords and update totalPages and lastPage
439 if (!--state.totalRecords) {
440 state.totalRecords = null;
441 state.totalPages = null;
442 }
443 else {
444 var totalPages = state.totalPages = ceil(state.totalRecords / pageSize);
445 state.lastPage = firstPage === 0 ? totalPages - 1 : totalPages;
446 if (state.currentPage > totalPages) state.currentPage = state.lastPage;
447 }
448 pageCol.state = pageCol._checkState(state);
449
450 var nextModel, removedIndex = options.index;
451 if (collection == pageCol) {
452 if (nextModel = fullCol.at(pageEnd)) pageCol.push(nextModel);
453 fullCol.remove(model);
454 }
455 else if (removedIndex >= pageStart && removedIndex < pageEnd) {
456 pageCol.remove(model);
457 nextModel = fullCol.at(currentPage * (pageSize + removedIndex));
458 if (nextModel) pageCol.push(nextModel);
459 }
460 }
461 else delete options.onAdd;
462 }
463
464 if (event == "reset") {
465 options = collection;
466 collection = model;
467
468 // Reset that's not a result of getPage
469 if (collection === pageCol && options.from == null &&
470 options.to == null) {
471 var head = fullCol.models.slice(0, pageStart);
472 var tail = fullCol.models.slice(pageStart + pageCol.models.length);
473 fullCol.reset(head.concat(pageCol.models).concat(tail), options);
474 }
475 else if (collection === fullCol) {
476 if (!(state.totalRecords = fullCol.models.length)) {
477 state.totalRecords = null;
478 state.totalPages = null;
479 }
480 if (pageCol.mode == "client") {
481 state.lastPage = state.currentPage = state.firstPage;
482 }
483 pageCol.state = pageCol._checkState(state);
484 pageCol.reset(fullCol.models.slice(pageStart, pageEnd),
485 _extend({}, options, {parse: false}));
486 }
487 }
488
489 if (event == "sort") {
490 options = collection;
491 collection = model;
492 if (collection === fullCol) {
493 pageCol.reset(fullCol.models.slice(pageStart, pageEnd),
494 _extend({}, options, {parse: false}));
495 }
496 }
497
498 _each(_keys(handlers), function (event) {
499 var handler = handlers[event];
500 _each([pageCol, fullCol], function (col) {
501 col.on(event, handler);
502 var callbacks = col._events[event] || [];
503 callbacks.unshift(callbacks.pop());
504 });
505 });
506 };
507 },
508
509 /**
510 Sanity check this collection's pagination states. Only perform checks
511 when all the required pagination state values are defined and not null.
512 If `totalPages` is undefined or null, it is set to `totalRecords` /
513 `pageSize`. `lastPage` is set according to whether `firstPage` is 0 or 1
514 when no error occurs.
515
516 @private
517
518 @throws {TypeError} If `totalRecords`, `pageSize`, `currentPage` or
519 `firstPage` is not a finite integer.
520
521 @throws {RangeError} If `pageSize`, `currentPage` or `firstPage` is out
522 of bounds.
523
524 @return {Object} Returns the `state` object if no error was found.
525 */
526 _checkState: function (state) {
527
528 var mode = this.mode;
529 var links = this.links;
530 var totalRecords = state.totalRecords;
531 var pageSize = state.pageSize;
532 var currentPage = state.currentPage;
533 var firstPage = state.firstPage;
534 var totalPages = state.totalPages;
535
536 if (totalRecords != null && pageSize != null && currentPage != null &&
537 firstPage != null && (mode == "infinite" ? links : true)) {
538
539 totalRecords = finiteInt(totalRecords, "totalRecords");
540 pageSize = finiteInt(pageSize, "pageSize");
541 currentPage = finiteInt(currentPage, "currentPage");
542 firstPage = finiteInt(firstPage, "firstPage");
543
544 if (pageSize < 1) {
545 throw new RangeError("`pageSize` must be >= 1");
546 }
547
548 totalPages = state.totalPages = ceil(totalRecords / pageSize);
549
550 if (firstPage < 0 || firstPage > 1) {
551 throw new RangeError("`firstPage must be 0 or 1`");
552 }
553
554 state.lastPage = firstPage === 0 ? max(0, totalPages - 1) : totalPages;
555
556 if (mode == "infinite") {
557 if (!links[currentPage + '']) {
558 throw new RangeError("No link found for page " + currentPage);
559 }
560 }
561 else if (currentPage < firstPage ||
562 (totalPages > 0 &&
563 (firstPage ? currentPage > totalPages : currentPage >= totalPages))) {
564 throw new RangeError("`currentPage` must be firstPage <= currentPage " +
565 (firstPage ? ">" : ">=") +
566 " totalPages if " + firstPage + "-based. Got " +
567 currentPage + '.');
568 }
569 }
570
571 return state;
572 },
573
574 /**
575 Change the page size of this collection.
576
577 For server mode operations, changing the page size will trigger a #fetch
578 and subsequently a `reset` event.
579
580 For client mode operations, changing the page size will `reset` the
581 current page by recalculating the current page boundary on the client
582 side.
583
584 If `options.fetch` is true, a fetch can be forced if the collection is in
585 client mode.
586
587 @param {number} pageSize The new page size to set to #state.
588 @param {Object} [options] {@link #fetch} options.
589 @param {boolean} [options.fetch] If `true`, force a fetch in client mode.
590
591 @throws {TypeError} If `pageSize` is not a finite integer.
592 @throws {RangeError} If `pageSize` is less than 1.
593
594 @chainable
595 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
596 from fetch or this.
597 */
598 setPageSize: function (pageSize, options) {
599 pageSize = finiteInt(pageSize, "pageSize");
600
601 options = options || {};
602
603 this.state = this._checkState(_extend({}, this.state, {
604 pageSize: pageSize,
605 totalPages: ceil(this.state.totalRecords / pageSize)
606 }));
607
608 return this.getPage(this.state.currentPage, options);
609 },
610
611 /**
612 Switching between client, server and infinite mode.
613
614 If switching from client to server mode, the #fullCollection is emptied
615 first and then deleted and a fetch is immediately issued for the current
616 page from the server. Pass `false` to `options.fetch` to skip fetching.
617
618 If switching to infinite mode, and if `options.models` is given for an
619 array of models, #links will be populated with a URL per page, using the
620 default URL for this collection.
621
622 If switching from server to client mode, all of the pages are immediately
623 refetched. If you have too many pages, you can pass `false` to
624 `options.fetch` to skip fetching.
625
626 If switching to any mode from infinite mode, the #links will be deleted.
627
628 @param {"server"|"client"|"infinite"} [mode] The mode to switch to.
629
630 @param {Object} [options]
631
632 @param {boolean} [options.fetch=true] If `false`, no fetching is done.
633
634 @param {boolean} [options.resetState=true] If 'false', the state is not
635 reset, but checked for sanity instead.
636
637 @chainable
638 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
639 from fetch or this if `options.fetch` is `false`.
640 */
641 switchMode: function (mode, options) {
642
643 if (!_contains(["server", "client", "infinite"], mode)) {
644 throw new TypeError('`mode` must be one of "server", "client" or "infinite"');
645 }
646
647 options = options || {fetch: true, resetState: true};
648
649 var state = this.state = options.resetState ?
650 _clone(this._initState) :
651 this._checkState(_extend({}, this.state));
652
653 this.mode = mode;
654
655 var self = this;
656 var fullCollection = this.fullCollection;
657 var handlers = this._handlers = this._handlers || {}, handler;
658 if (mode != "server" && !fullCollection) {
659 fullCollection = this._makeFullCollection(options.models || []);
660 fullCollection.pageableCollection = this;
661 this.fullCollection = fullCollection;
662 var allHandler = this._makeCollectionEventHandler(this, fullCollection);
663 _each(["add", "remove", "reset", "sort"], function (event) {
664 handlers[event] = handler = _.bind(allHandler, {}, event);
665 self.on(event, handler);
666 fullCollection.on(event, handler);
667 });
668 fullCollection.comparator = this._fullComparator;
669 }
670 else if (mode == "server" && fullCollection) {
671 _each(_keys(handlers), function (event) {
672 handler = handlers[event];
673 self.off(event, handler);
674 fullCollection.off(event, handler);
675 });
676 delete this._handlers;
677 this._fullComparator = fullCollection.comparator;
678 delete this.fullCollection;
679 }
680
681 if (mode == "infinite") {
682 var links = this.links = {};
683 var firstPage = state.firstPage;
684 var totalPages = ceil(state.totalRecords / state.pageSize);
685 var lastPage = firstPage === 0 ? max(0, totalPages - 1) : totalPages || firstPage;
686 for (var i = state.firstPage; i <= lastPage; i++) {
687 links[i] = this.url;
688 }
689 }
690 else if (this.links) delete this.links;
691
692 return options.fetch ?
693 this.fetch(_omit(options, "fetch", "resetState")) :
694 this;
695 },
696
697 /**
698 @return {boolean} `true` if this collection can page backward, `false`
699 otherwise.
700 */
701 hasPrevious: function () {
702 var state = this.state;
703 var currentPage = state.currentPage;
704 if (this.mode != "infinite") return currentPage > state.firstPage;
705 return !!this.links[currentPage - 1];
706 },
707
708 /**
709 @return {boolean} `true` if this collection can page forward, `false`
710 otherwise.
711 */
712 hasNext: function () {
713 var state = this.state;
714 var currentPage = this.state.currentPage;
715 if (this.mode != "infinite") return currentPage < state.lastPage;
716 return !!this.links[currentPage + 1];
717 },
718
719 /**
720 Fetch the first page in server mode, or reset the current page of this
721 collection to the first page in client or infinite mode.
722
723 @param {Object} options {@link #getPage} options.
724
725 @chainable
726 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
727 from fetch or this.
728 */
729 getFirstPage: function (options) {
730 return this.getPage("first", options);
731 },
732
733 /**
734 Fetch the previous page in server mode, or reset the current page of this
735 collection to the previous page in client or infinite mode.
736
737 @param {Object} options {@link #getPage} options.
738
739 @chainable
740 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
741 from fetch or this.
742 */
743 getPreviousPage: function (options) {
744 return this.getPage("prev", options);
745 },
746
747 /**
748 Fetch the next page in server mode, or reset the current page of this
749 collection to the next page in client mode.
750
751 @param {Object} options {@link #getPage} options.
752
753 @chainable
754 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
755 from fetch or this.
756 */
757 getNextPage: function (options) {
758 return this.getPage("next", options);
759 },
760
761 /**
762 Fetch the last page in server mode, or reset the current page of this
763 collection to the last page in client mode.
764
765 @param {Object} options {@link #getPage} options.
766
767 @chainable
768 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
769 from fetch or this.
770 */
771 getLastPage: function (options) {
772 return this.getPage("last", options);
773 },
774
775 /**
776 Given a page index, set #state.currentPage to that index. If this
777 collection is in server mode, fetch the page using the updated state,
778 otherwise, reset the current page of this collection to the page
779 specified by `index` in client mode. If `options.fetch` is true, a fetch
780 can be forced in client mode before resetting the current page. Under
781 infinite mode, if the index is less than the current page, a reset is
782 done as in client mode. If the index is greater than the current page
783 number, a fetch is made with the results **appended** to
784 #fullCollection. The current page will then be reset after fetching.
785
786 @param {number|string} index The page index to go to, or the page name to
787 look up from #links in infinite mode.
788 @param {Object} [options] {@link #fetch} options or
789 [reset](http://backbonejs.org/#Collection-reset) options for client mode
790 when `options.fetch` is `false`.
791 @param {boolean} [options.fetch=false] If true, force a {@link #fetch} in
792 client mode.
793
794 @throws {TypeError} If `index` is not a finite integer under server or
795 client mode, or does not yield a URL from #links under infinite mode.
796
797 @throws {RangeError} If `index` is out of bounds.
798
799 @chainable
800 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
801 from fetch or this.
802 */
803 getPage: function (index, options) {
804
805 var mode = this.mode, fullCollection = this.fullCollection;
806
807 options = options || {fetch: false};
808
809 var state = this.state,
810 firstPage = state.firstPage,
811 currentPage = state.currentPage,
812 lastPage = state.lastPage,
813 pageSize = state.pageSize;
814
815 var pageNum = index;
816 switch (index) {
817 case "first": pageNum = firstPage; break;
818 case "prev": pageNum = currentPage - 1; break;
819 case "next": pageNum = currentPage + 1; break;
820 case "last": pageNum = lastPage; break;
821 default: pageNum = finiteInt(index, "index");
822 }
823
824 this.state = this._checkState(_extend({}, state, {currentPage: pageNum}));
825
826 options.from = currentPage, options.to = pageNum;
827
828 var pageStart = (firstPage === 0 ? pageNum : pageNum - 1) * pageSize;
829 var pageModels = fullCollection && fullCollection.length ?
830 fullCollection.models.slice(pageStart, pageStart + pageSize) :
831 [];
832 if ((mode == "client" || (mode == "infinite" && !_isEmpty(pageModels))) &&
833 !options.fetch) {
834 return this.reset(pageModels, _omit(options, "fetch"));
835 }
836
837 if (mode == "infinite") options.url = this.links[pageNum];
838
839 return this.fetch(_omit(options, "fetch"));
840 },
841
842 /**
843 Fetch the page for the provided item offset in server mode, or reset the current page of this
844 collection to the page for the provided item offset in client mode.
845
846 @param {Object} options {@link #getPage} options.
847
848 @chainable
849 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
850 from fetch or this.
851 */
852 getPageByOffset: function (offset, options) {
853 if (offset < 0) {
854 throw new RangeError("`offset must be > 0`");
855 }
856 offset = finiteInt(offset);
857
858 var page = floor(offset / this.state.pageSize);
859 if (this.state.firstPage !== 0) page++;
860 if (page > this.state.lastPage) page = this.state.lastPage;
861 return this.getPage(page, options);
862 },
863
864 /**
865 Overidden to make `getPage` compatible with Zepto.
866
867 @param {string} method
868 @param {Backbone.Model|Backbone.Collection} model
869 @param {Object} [options]
870
871 @return {XMLHttpRequest}
872 */
873 sync: function (method, model, options) {
874 var self = this;
875 if (self.mode == "infinite") {
876 var success = options.success;
877 var currentPage = self.state.currentPage;
878 options.success = function (resp, status, xhr) {
879 var links = self.links;
880 var newLinks = self.parseLinks(resp, _extend({xhr: xhr}, options));
881 if (newLinks.first) links[self.state.firstPage] = newLinks.first;
882 if (newLinks.prev) links[currentPage - 1] = newLinks.prev;
883 if (newLinks.next) links[currentPage + 1] = newLinks.next;
884 if (success) success(resp, status, xhr);
885 };
886 }
887
888 return (BBColProto.sync || Backbone.sync).call(self, method, model, options);
889 },
890
891 /**
892 Parse pagination links from the server response. Only valid under
893 infinite mode.
894
895 Given a response body and a XMLHttpRequest object, extract pagination
896 links from them for infinite paging.
897
898 This default implementation parses the RFC 5988 `Link` header and extract
899 3 links from it - `first`, `prev`, `next`. If a `previous` link is found,
900 it will be found in the `prev` key in the returned object hash. Any
901 subclasses overriding this method __must__ return an object hash having
902 only the keys above. If `first` is missing, the collection's default URL
903 is assumed to be the `first` URL. If `prev` or `next` is missing, it is
904 assumed to be `null`. An empty object hash must be returned if there are
905 no links found. If either the response or the header contains information
906 pertaining to the total number of records on the server,
907 #state.totalRecords must be set to that number. The default
908 implementation uses the `last` link from the header to calculate it.
909
910 @param {*} resp The deserialized response body.
911 @param {Object} [options]
912 @param {XMLHttpRequest} [options.xhr] The XMLHttpRequest object for this
913 response.
914 @return {Object}
915 */
916 parseLinks: function (resp, options) {
917 var links = {};
918 var linkHeader = options.xhr.getResponseHeader("Link");
919 if (linkHeader) {
920 var relations = ["first", "prev", "previous", "next", "last"];
921 _each(linkHeader.split(","), function (linkValue) {
922 var linkParts = linkValue.split(";");
923 var url = linkParts[0].replace(URL_TRIM_RE, '');
924 var params = linkParts.slice(1);
925 _each(params, function (param) {
926 var paramParts = param.split("=");
927 var key = paramParts[0].replace(PARAM_TRIM_RE, '');
928 var value = paramParts[1].replace(PARAM_TRIM_RE, '');
929 if (key == "rel" && _contains(relations, value)) {
930 if (value == "previous") links.prev = url;
931 else links[value] = url;
932 }
933 });
934 });
935
936 var last = links.last || '', qsi, qs;
937 if (qs = (qsi = last.indexOf('?')) ? last.slice(qsi + 1) : '') {
938 var params = queryStringToParams(qs);
939
940 var state = _clone(this.state);
941 var queryParams = this.queryParams;
942 var pageSize = state.pageSize;
943
944 var totalRecords = params[queryParams.totalRecords] * 1;
945 var pageNum = params[queryParams.currentPage] * 1;
946 var totalPages = params[queryParams.totalPages];
947
948 if (!totalRecords) {
949 if (pageNum) totalRecords = (state.firstPage === 0 ?
950 pageNum + 1 :
951 pageNum) * pageSize;
952 else if (totalPages) totalRecords = totalPages * pageSize;
953 }
954
955 if (totalRecords) state.totalRecords = totalRecords;
956
957 this.state = this._checkState(state);
958 }
959 }
960
961 delete links.last;
962
963 return links;
964 },
965
966 /**
967 Parse server response data.
968
969 This default implementation assumes the response data is in one of two
970 structures:
971
972 [
973 {}, // Your new pagination state
974 [{}, ...] // An array of JSON objects
975 ]
976
977 Or,
978
979 [{}] // An array of JSON objects
980
981 The first structure is the preferred form because the pagination states
982 may have been updated on the server side, sending them down again allows
983 this collection to update its states. If the response has a pagination
984 state object, it is checked for errors.
985
986 The second structure is the
987 [Backbone.Collection#parse](http://backbonejs.org/#Collection-parse)
988 default.
989
990 **Note:** this method has been further simplified since 1.1.7. While
991 existing #parse implementations will continue to work, new code is
992 encouraged to override #parseState and #parseRecords instead.
993
994 @param {Object} resp The deserialized response data from the server.
995
996 @return {Array.<Object>} An array of model objects
997 */
998 parse: function (resp) {
999 var newState = this.parseState(resp, _clone(this.queryParams), _clone(this.state));
1000 if (newState) this.state = this._checkState(_extend({}, this.state, newState));
1001 return this.parseRecords(resp);
1002 },
1003
1004 /**
1005 Parse server response for server pagination state updates.
1006
1007 This default implementation first checks whether the response has any
1008 state object as documented in #parse. If it exists, a state object is
1009 returned by mapping the server state keys to this pageable collection
1010 instance's query parameter keys using `queryParams`.
1011
1012 It is __NOT__ neccessary to return a full state object complete with all
1013 the mappings defined in #queryParams. Any state object resulted is merged
1014 with a copy of the current pageable collection state and checked for
1015 sanity before actually updating. Most of the time, simply providing a new
1016 `totalRecords` value is enough to trigger a full pagination state
1017 recalculation.
1018
1019 parseState: function (resp, queryParams, state) {
1020 return {totalRecords: resp.total_entries};
1021 }
1022
1023 This method __MUST__ return a new state object instead of directly
1024 modifying the #state object. The behavior of directly modifying #state is
1025 undefined.
1026
1027 @param {Object} resp The deserialized response data from the server.
1028 @param {Object} queryParams A copy of #queryParams.
1029 @param {Object} state A copy of #state.
1030
1031 @return {Object} A new (partial) state object.
1032 */
1033 parseState: function (resp, queryParams, state) {
1034 if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) {
1035
1036 var newState = _clone(state);
1037 var serverState = resp[0];
1038
1039 _each(_pairs(_omit(queryParams, "directions")), function (kvp) {
1040 var k = kvp[0], v = kvp[1];
1041 var serverVal = serverState[v];
1042 if (!_isUndefined(serverVal) && !_.isNull(serverVal)) newState[k] = serverState[v];
1043 });
1044
1045 if (serverState.order) {
1046 newState.order = _invert(queryParams.directions)[serverState.order] * 1;
1047 }
1048
1049 return newState;
1050 }
1051 },
1052
1053 /**
1054 Parse server response for an array of model objects.
1055
1056 This default implementation first checks whether the response has any
1057 state object as documented in #parse. If it exists, the array of model
1058 objects is assumed to be the second element, otherwise the entire
1059 response is returned directly.
1060
1061 @param {Object} resp The deserialized response data from the server.
1062
1063 @return {Array.<Object>} An array of model objects
1064 */
1065 parseRecords: function (resp) {
1066 if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) {
1067 return resp[1];
1068 }
1069
1070 return resp;
1071 },
1072
1073 /**
1074 Fetch a page from the server in server mode, or all the pages in client
1075 mode. Under infinite mode, the current page is refetched by default and
1076 then reset.
1077
1078 The query string is constructed by translating the current pagination
1079 state to your server API query parameter using #queryParams. The current
1080 page will reset after fetch.
1081
1082 @param {Object} [options] Accepts all
1083 [Backbone.Collection#fetch](http://backbonejs.org/#Collection-fetch)
1084 options.
1085
1086 @return {XMLHttpRequest}
1087 */
1088 fetch: function (options) {
1089
1090 options = options || {};
1091
1092 var state = this._checkState(this.state);
1093
1094 var mode = this.mode;
1095
1096 if (mode == "infinite" && !options.url) {
1097 options.url = this.links[state.currentPage];
1098 }
1099
1100 var data = options.data || {};
1101
1102 // dedup query params
1103 var url = _result(options, "url") || _result(this, "url") || '';
1104 var qsi = url.indexOf('?');
1105 if (qsi != -1) {
1106 _extend(data, queryStringToParams(url.slice(qsi + 1)));
1107 url = url.slice(0, qsi);
1108 }
1109
1110 options.url = url;
1111 options.data = data;
1112
1113 // map params except directions
1114 var queryParams = this.mode == "client" ?
1115 _pick(this.queryParams, "sortKey", "order") :
1116 _omit(_pick(this.queryParams, _keys(PageableProto.queryParams)),
1117 "directions");
1118
1119 var i, kvp, k, v, kvps = _pairs(queryParams), thisCopy = _clone(this);
1120 for (i = 0; i < kvps.length; i++) {
1121 kvp = kvps[i], k = kvp[0], v = kvp[1];
1122 v = _isFunction(v) ? v.call(thisCopy) : v;
1123 if (state[k] != null && v != null) {
1124 data[v] = state[k];
1125 }
1126 }
1127
1128 // fix up sorting parameters
1129 if (state.sortKey && state.order) {
1130 data[queryParams.order] = this.queryParams.directions[state.order + ""];
1131 }
1132 else if (!state.sortKey) delete data[queryParams.order];
1133
1134 // map extra query parameters
1135 var extraKvps = _pairs(_omit(this.queryParams,
1136 _keys(PageableProto.queryParams)));
1137 for (i = 0; i < extraKvps.length; i++) {
1138 kvp = extraKvps[i];
1139 v = kvp[1];
1140 v = _isFunction(v) ? v.call(thisCopy) : v;
1141 data[kvp[0]] = v;
1142 }
1143
1144 var fullCol = this.fullCollection, links = this.links;
1145
1146 if (mode != "server") {
1147
1148 var self = this;
1149 var success = options.success;
1150 options.success = function (col, resp, opts) {
1151
1152 // make sure the caller's intent is obeyed
1153 opts = opts || {};
1154 if (_isUndefined(options.silent)) delete opts.silent;
1155 else opts.silent = options.silent;
1156
1157 var models = col.models;
1158 var currentPage = state.currentPage;
1159
1160 if (mode == "client") fullCol.reset(models, opts);
1161 else if (links[currentPage]) { // refetching a page
1162 var pageSize = state.pageSize;
1163 var pageStart = (state.firstPage === 0 ?
1164 currentPage :
1165 currentPage - 1) * pageSize;
1166 var fullModels = fullCol.models;
1167 var head = fullModels.slice(0, pageStart);
1168 var tail = fullModels.slice(pageStart + pageSize);
1169 fullModels = head.concat(models).concat(tail);
1170 var updateFunc = fullCol.set || fullCol.update;
1171 // Must silent update and trigger reset later because the event
1172 // sychronization handler is temporarily taken out during either add
1173 // or remove, which Collection#set does, so the pageable collection
1174 // will be out of sync if not silenced because adding will trigger
1175 // the sychonization event handler
1176 updateFunc.call(fullCol, fullModels, _extend({silent: true}, opts));
1177 fullCol.trigger("reset", fullCol, opts);
1178 }
1179 // fetching new page
1180 else fullCol.add(models, _extend({at: fullCol.length}, opts));
1181
1182 if (success) success(col, resp, opts);
1183 };
1184
1185 // silent the first reset from backbone
1186 return BBColProto.fetch.call(self, _extend({}, options, {silent: true}));
1187 }
1188
1189 return BBColProto.fetch.call(this, options);
1190 },
1191
1192 /**
1193 Convenient method for making a `comparator` sorted by a model attribute
1194 identified by `sortKey` and ordered by `order`.
1195
1196 Like a Backbone.Collection, a Backbone.PageableCollection will maintain
1197 the __current page__ in sorted order on the client side if a `comparator`
1198 is attached to it. If the collection is in client mode, you can attach a
1199 comparator to #fullCollection to have all the pages reflect the global
1200 sorting order by specifying an option `full` to `true`. You __must__ call
1201 `sort` manually or #fullCollection.sort after calling this method to
1202 force a resort.
1203
1204 While you can use this method to sort the current page in server mode,
1205 the sorting order may not reflect the global sorting order due to the
1206 additions or removals of the records on the server since the last
1207 fetch. If you want the most updated page in a global sorting order, it is
1208 recommended that you set #state.sortKey and optionally #state.order, and
1209 then call #fetch.
1210
1211 @protected
1212
1213 @param {string} [sortKey=this.state.sortKey] See `state.sortKey`.
1214 @param {number} [order=this.state.order] See `state.order`.
1215
1216 See [Backbone.Collection.comparator](http://backbonejs.org/#Collection-comparator).
1217 */
1218 _makeComparator: function (sortKey, order) {
1219
1220 var state = this.state;
1221
1222 sortKey = sortKey || state.sortKey;
1223 order = order || state.order;
1224
1225 if (!sortKey || !order) return;
1226
1227 return function (left, right) {
1228 var l = left.get(sortKey), r = right.get(sortKey), t;
1229 if (order === 1) t = l, l = r, r = t;
1230 if (l === r) return 0;
1231 else if (l < r) return -1;
1232 return 1;
1233 };
1234 },
1235
1236 /**
1237 Adjusts the sorting for this pageable collection.
1238
1239 Given a `sortKey` and an `order`, sets `state.sortKey` and
1240 `state.order`. A comparator can be applied on the client side to sort in
1241 the order defined if `options.side` is `"client"`. By default the
1242 comparator is applied to the #fullCollection. Set `options.full` to
1243 `false` to apply a comparator to the current page under any mode. Setting
1244 `sortKey` to `null` removes the comparator from both the current page and
1245 the full collection.
1246
1247 @chainable
1248
1249 @param {string} sortKey See `state.sortKey`.
1250 @param {number} [order=this.state.order] See `state.order`.
1251 @param {Object} [options]
1252 @param {"server"|"client"} [options.side] By default, `"client"` if
1253 `mode` is `"client"`, `"server"` otherwise.
1254 @param {boolean} [options.full=true]
1255 */
1256 setSorting: function (sortKey, order, options) {
1257
1258 var state = this.state;
1259
1260 state.sortKey = sortKey;
1261 state.order = order = order || state.order;
1262
1263 var fullCollection = this.fullCollection;
1264
1265 var delComp = false, delFullComp = false;
1266
1267 if (!sortKey) delComp = delFullComp = true;
1268
1269 var mode = this.mode;
1270 options = _extend({side: mode == "client" ? mode : "server", full: true},
1271 options);
1272
1273 var comparator = this._makeComparator(sortKey, order);
1274
1275 var full = options.full, side = options.side;
1276
1277 if (side == "client") {
1278 if (full) {
1279 if (fullCollection) fullCollection.comparator = comparator;
1280 delComp = true;
1281 }
1282 else {
1283 this.comparator = comparator;
1284 delFullComp = true;
1285 }
1286 }
1287 else if (side == "server" && !full) {
1288 this.comparator = comparator;
1289 }
1290
1291 if (delComp) delete this.comparator;
1292 if (delFullComp && fullCollection) delete fullCollection.comparator;
1293
1294 return this;
1295 }
1296
1297 });
1298
1299 var PageableProto = PageableCollection.prototype;
1300
1301 return PageableCollection;
1302
1303}));