/ajax/libs/backbone-pageable/1.3.2/backbone-pageable.js
JavaScript | 1325 lines | 625 code | 152 blank | 548 comment | 230 complexity | f860317610755ed637a5fb30583d4536 MD5 | raw file
1/*
2 backbone-pageable 1.3.2
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 = 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.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 Under most if not all circumstances, you should call this method to
578 change the page size of a pageable collection because it will keep the
579 pagination state sane. By default, the method will recalculate the
580 current page number to one that will retain the current page's models
581 when increasing the page size. When decreasing the page size, this method
582 will retain the last models to the current page that will fit into the
583 smaller page size.
584
585 If `options.first` is true, changing the page size will also reset the
586 current page back to the first page instead of trying to be smart.
587
588 For server mode operations, changing the page size will trigger a #fetch
589 and subsequently a `reset` event.
590
591 For client mode operations, changing the page size will `reset` the
592 current page by recalculating the current page boundary on the client
593 side.
594
595 If `options.fetch` is true, a fetch can be forced if the collection is in
596 client mode.
597
598 @param {number} pageSize The new page size to set to #state.
599 @param {Object} [options] {@link #fetch} options.
600 @param {boolean} [options.first=false] Reset the current page number to
601 the first page if `true`.
602 @param {boolean} [options.fetch] If `true`, force a fetch in client mode.
603
604 @throws {TypeError} If `pageSize` is not a finite integer.
605 @throws {RangeError} If `pageSize` is less than 1.
606
607 @chainable
608 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
609 from fetch or this.
610 */
611 setPageSize: function (pageSize, options) {
612 pageSize = finiteInt(pageSize, "pageSize");
613
614 options = options || {first: false};
615
616 var state = this.state;
617 var totalPages = ceil(state.totalRecords / pageSize);
618 var currentPage = totalPages ?
619 max(state.firstPage,
620 floor(totalPages *
621 (state.firstPage ?
622 state.currentPage :
623 state.currentPage + 1) /
624 state.totalPages)) :
625 state.firstPage;
626
627 state = this.state = this._checkState(_extend({}, state, {
628 pageSize: pageSize,
629 currentPage: options.first ? state.firstPage : currentPage,
630 totalPages: totalPages
631 }));
632
633 return this.getPage(state.currentPage, _omit(options, ["first"]));
634 },
635
636 /**
637 Switching between client, server and infinite mode.
638
639 If switching from client to server mode, the #fullCollection is emptied
640 first and then deleted and a fetch is immediately issued for the current
641 page from the server. Pass `false` to `options.fetch` to skip fetching.
642
643 If switching to infinite mode, and if `options.models` is given for an
644 array of models, #links will be populated with a URL per page, using the
645 default URL for this collection.
646
647 If switching from server to client mode, all of the pages are immediately
648 refetched. If you have too many pages, you can pass `false` to
649 `options.fetch` to skip fetching.
650
651 If switching to any mode from infinite mode, the #links will be deleted.
652
653 @param {"server"|"client"|"infinite"} [mode] The mode to switch to.
654
655 @param {Object} [options]
656
657 @param {boolean} [options.fetch=true] If `false`, no fetching is done.
658
659 @param {boolean} [options.resetState=true] If 'false', the state is not
660 reset, but checked for sanity instead.
661
662 @chainable
663 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
664 from fetch or this if `options.fetch` is `false`.
665 */
666 switchMode: function (mode, options) {
667
668 if (!_contains(["server", "client", "infinite"], mode)) {
669 throw new TypeError('`mode` must be one of "server", "client" or "infinite"');
670 }
671
672 options = options || {fetch: true, resetState: true};
673
674 var state = this.state = options.resetState ?
675 _clone(this._initState) :
676 this._checkState(_extend({}, this.state));
677
678 this.mode = mode;
679
680 var self = this;
681 var fullCollection = this.fullCollection;
682 var handlers = this._handlers = this._handlers || {}, handler;
683 if (mode != "server" && !fullCollection) {
684 fullCollection = this._makeFullCollection(options.models || []);
685 fullCollection.pageableCollection = this;
686 this.fullCollection = fullCollection;
687 var allHandler = this._makeCollectionEventHandler(this, fullCollection);
688 _each(["add", "remove", "reset", "sort"], function (event) {
689 handlers[event] = handler = _.bind(allHandler, {}, event);
690 self.on(event, handler);
691 fullCollection.on(event, handler);
692 });
693 fullCollection.comparator = this._fullComparator;
694 }
695 else if (mode == "server" && fullCollection) {
696 _each(_keys(handlers), function (event) {
697 handler = handlers[event];
698 self.off(event, handler);
699 fullCollection.off(event, handler);
700 });
701 delete this._handlers;
702 this._fullComparator = fullCollection.comparator;
703 delete this.fullCollection;
704 }
705
706 if (mode == "infinite") {
707 var links = this.links = {};
708 var firstPage = state.firstPage;
709 var totalPages = ceil(state.totalRecords / state.pageSize);
710 var lastPage = firstPage === 0 ? max(0, totalPages - 1) : totalPages || firstPage;
711 for (var i = state.firstPage; i <= lastPage; i++) {
712 links[i] = this.url;
713 }
714 }
715 else if (this.links) delete this.links;
716
717 return options.fetch ?
718 this.fetch(_omit(options, "fetch", "resetState")) :
719 this;
720 },
721
722 /**
723 @return {boolean} `true` if this collection can page backward, `false`
724 otherwise.
725 */
726 hasPrevious: function () {
727 var state = this.state;
728 var currentPage = state.currentPage;
729 if (this.mode != "infinite") return currentPage > state.firstPage;
730 return !!this.links[currentPage - 1];
731 },
732
733 /**
734 @return {boolean} `true` if this collection can page forward, `false`
735 otherwise.
736 */
737 hasNext: function () {
738 var state = this.state;
739 var currentPage = this.state.currentPage;
740 if (this.mode != "infinite") return currentPage < state.lastPage;
741 return !!this.links[currentPage + 1];
742 },
743
744 /**
745 Fetch the first page in server mode, or reset the current page of this
746 collection to the first page in client or infinite mode.
747
748 @param {Object} options {@link #getPage} options.
749
750 @chainable
751 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
752 from fetch or this.
753 */
754 getFirstPage: function (options) {
755 return this.getPage("first", options);
756 },
757
758 /**
759 Fetch the previous page in server mode, or reset the current page of this
760 collection to the previous page in client or infinite mode.
761
762 @param {Object} options {@link #getPage} options.
763
764 @chainable
765 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
766 from fetch or this.
767 */
768 getPreviousPage: function (options) {
769 return this.getPage("prev", options);
770 },
771
772 /**
773 Fetch the next page in server mode, or reset the current page of this
774 collection to the next page in client mode.
775
776 @param {Object} options {@link #getPage} options.
777
778 @chainable
779 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
780 from fetch or this.
781 */
782 getNextPage: function (options) {
783 return this.getPage("next", options);
784 },
785
786 /**
787 Fetch the last page in server mode, or reset the current page of this
788 collection to the last page in client mode.
789
790 @param {Object} options {@link #getPage} options.
791
792 @chainable
793 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
794 from fetch or this.
795 */
796 getLastPage: function (options) {
797 return this.getPage("last", options);
798 },
799
800 /**
801 Given a page index, set #state.currentPage to that index. If this
802 collection is in server mode, fetch the page using the updated state,
803 otherwise, reset the current page of this collection to the page
804 specified by `index` in client mode. If `options.fetch` is true, a fetch
805 can be forced in client mode before resetting the current page. Under
806 infinite mode, if the index is less than the current page, a reset is
807 done as in client mode. If the index is greater than the current page
808 number, a fetch is made with the results **appended** to #fullCollection.
809 The current page will then be reset after fetching.
810
811 @param {number|string} index The page index to go to, or the page name to
812 look up from #links in infinite mode.
813 @param {Object} [options] {@link #fetch} options or
814 [reset](http://backbonejs.org/#Collection-reset) options for client mode
815 when `options.fetch` is `false`.
816 @param {boolean} [options.fetch=false] If true, force a {@link #fetch} in
817 client mode.
818
819 @throws {TypeError} If `index` is not a finite integer under server or
820 client mode, or does not yield a URL from #links under infinite mode.
821
822 @throws {RangeError} If `index` is out of bounds.
823
824 @chainable
825 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
826 from fetch or this.
827 */
828 getPage: function (index, options) {
829
830 var mode = this.mode, fullCollection = this.fullCollection;
831
832 options = options || {fetch: false};
833
834 var state = this.state,
835 firstPage = state.firstPage,
836 currentPage = state.currentPage,
837 lastPage = state.lastPage,
838 pageSize = state.pageSize;
839
840 var pageNum = index;
841 switch (index) {
842 case "first": pageNum = firstPage; break;
843 case "prev": pageNum = currentPage - 1; break;
844 case "next": pageNum = currentPage + 1; break;
845 case "last": pageNum = lastPage; break;
846 default: pageNum = finiteInt(index, "index");
847 }
848
849 this.state = this._checkState(_extend({}, state, {currentPage: pageNum}));
850
851 options.from = currentPage, options.to = pageNum;
852
853 var pageStart = (firstPage === 0 ? pageNum : pageNum - 1) * pageSize;
854 var pageModels = fullCollection && fullCollection.length ?
855 fullCollection.models.slice(pageStart, pageStart + pageSize) :
856 [];
857 if ((mode == "client" || (mode == "infinite" && !_isEmpty(pageModels))) &&
858 !options.fetch) {
859 return this.reset(pageModels, _omit(options, "fetch"));
860 }
861
862 if (mode == "infinite") options.url = this.links[pageNum];
863
864 return this.fetch(_omit(options, "fetch"));
865 },
866
867 /**
868 Fetch the page for the provided item offset in server mode, or reset the current page of this
869 collection to the page for the provided item offset in client mode.
870
871 @param {Object} options {@link #getPage} options.
872
873 @chainable
874 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
875 from fetch or this.
876 */
877 getPageByOffset: function (offset, options) {
878 if (offset < 0) {
879 throw new RangeError("`offset must be > 0`");
880 }
881 offset = finiteInt(offset);
882
883 var page = floor(offset / this.state.pageSize);
884 if (this.state.firstPage !== 0) page++;
885 if (page > this.state.lastPage) page = this.state.lastPage;
886 return this.getPage(page, options);
887 },
888
889 /**
890 Overidden to make `getPage` compatible with Zepto.
891
892 @param {string} method
893 @param {Backbone.Model|Backbone.Collection} model
894 @param {Object} [options]
895
896 @return {XMLHttpRequest}
897 */
898 sync: function (method, model, options) {
899 var self = this;
900 if (self.mode == "infinite") {
901 var success = options.success;
902 var currentPage = self.state.currentPage;
903 options.success = function (resp, status, xhr) {
904 var links = self.links;
905 var newLinks = self.parseLinks(resp, _extend({xhr: xhr}, options));
906 if (newLinks.first) links[self.state.firstPage] = newLinks.first;
907 if (newLinks.prev) links[currentPage - 1] = newLinks.prev;
908 if (newLinks.next) links[currentPage + 1] = newLinks.next;
909 if (success) success(resp, status, xhr);
910 };
911 }
912
913 return (BBColProto.sync || Backbone.sync).call(self, method, model, options);
914 },
915
916 /**
917 Parse pagination links from the server response. Only valid under
918 infinite mode.
919
920 Given a response body and a XMLHttpRequest object, extract pagination
921 links from them for infinite paging.
922
923 This default implementation parses the RFC 5988 `Link` header and extract
924 3 links from it - `first`, `prev`, `next`. If a `previous` link is found,
925 it will be found in the `prev` key in the returned object hash. Any
926 subclasses overriding this method __must__ return an object hash having
927 only the keys above. If `first` is missing, the collection's default URL
928 is assumed to be the `first` URL. If `prev` or `next` is missing, it is
929 assumed to be `null`. An empty object hash must be returned if there are
930 no links found. If either the response or the header contains information
931 pertaining to the total number of records on the server, #state.totalRecords
932 must be set to that number. The default implementation uses the `last`
933 link from the header to calculate it.
934
935 @param {*} resp The deserialized response body.
936 @param {Object} [options]
937 @param {XMLHttpRequest} [options.xhr] The XMLHttpRequest object for this
938 response.
939 @return {Object}
940 */
941 parseLinks: function (resp, options) {
942 var links = {};
943 var linkHeader = options.xhr.getResponseHeader("Link");
944 if (linkHeader) {
945 var relations = ["first", "prev", "previous", "next", "last"];
946 _each(linkHeader.split(","), function (linkValue) {
947 var linkParts = linkValue.split(";");
948 var url = linkParts[0].replace(URL_TRIM_RE, '');
949 var params = linkParts.slice(1);
950 _each(params, function (param) {
951 var paramParts = param.split("=");
952 var key = paramParts[0].replace(PARAM_TRIM_RE, '');
953 var value = paramParts[1].replace(PARAM_TRIM_RE, '');
954 if (key == "rel" && _contains(relations, value)) {
955 if (value == "previous") links.prev = url;
956 else links[value] = url;
957 }
958 });
959 });
960
961 var last = links.last || '', qsi, qs;
962 if (qs = (qsi = last.indexOf('?')) ? last.slice(qsi + 1) : '') {
963 var params = queryStringToParams(qs);
964
965 var state = _clone(this.state);
966 var queryParams = this.queryParams;
967 var pageSize = state.pageSize;
968
969 var totalRecords = params[queryParams.totalRecords] * 1;
970 var pageNum = params[queryParams.currentPage] * 1;
971 var totalPages = params[queryParams.totalPages];
972
973 if (!totalRecords) {
974 if (pageNum) totalRecords = (state.firstPage === 0 ?
975 pageNum + 1 :
976 pageNum) * pageSize;
977 else if (totalPages) totalRecords = totalPages * pageSize;
978 }
979
980 if (totalRecords) state.totalRecords = totalRecords;
981
982 this.state = this._checkState(state);
983 }
984 }
985
986 delete links.last;
987
988 return links;
989 },
990
991 /**
992 Parse server response data.
993
994 This default implementation assumes the response data is in one of two
995 structures:
996
997 [
998 {}, // Your new pagination state
999 [{}, ...] // An array of JSON objects
1000 ]
1001
1002 Or,
1003
1004 [{}] // An array of JSON objects
1005
1006 The first structure is the preferred form because the pagination states
1007 may have been updated on the server side, sending them down again allows
1008 this collection to update its states. If the response has a pagination
1009 state object, it is checked for errors.
1010
1011 The second structure is the
1012 [Backbone.Collection#parse](http://backbonejs.org/#Collection-parse)
1013 default.
1014
1015 **Note:** this method has been further simplified since 1.1.7. While
1016 existing #parse implementations will continue to work, new code is
1017 encouraged to override #parseState and #parseRecords instead.
1018
1019 @param {Object} resp The deserialized response data from the server.
1020 @param {Object} the options for the ajax request
1021
1022 @return {Array.<Object>} An array of model objects
1023 */
1024 parse: function (resp, options) {
1025 var newState = this.parseState(resp, _clone(this.queryParams), _clone(this.state), options);
1026 if (newState) this.state = this._checkState(_extend({}, this.state, newState));
1027 return this.parseRecords(resp, options);
1028 },
1029
1030 /**
1031 Parse server response for server pagination state updates.
1032
1033 This default implementation first checks whether the response has any
1034 state object as documented in #parse. If it exists, a state object is
1035 returned by mapping the server state keys to this pageable collection
1036 instance's query parameter keys using `queryParams`.
1037
1038 It is __NOT__ neccessary to return a full state object complete with all
1039 the mappings defined in #queryParams. Any state object resulted is merged
1040 with a copy of the current pageable collection state and checked for
1041 sanity before actually updating. Most of the time, simply providing a new
1042 `totalRecords` value is enough to trigger a full pagination state
1043 recalculation.
1044
1045 parseState: function (resp, queryParams, state, options) {
1046 return {totalRecords: resp.total_entries};
1047 }
1048
1049 If you want to use header fields use:
1050
1051 parseState: function (resp, queryParams, state, options) {
1052 return {totalRecords: options.xhr.getResponseHeader("X-total")};
1053 }
1054
1055 This method __MUST__ return a new state object instead of directly
1056 modifying the #state object. The behavior of directly modifying #state is
1057 undefined.
1058
1059 @param {Object} resp The deserialized response data from the server.
1060 @param {Object} queryParams A copy of #queryParams.
1061 @param {Object} state A copy of #state.
1062 @param {Object} [options] The options passed through from
1063 `parse`. (backbone >= 0.9.10 only)
1064
1065 @return {Object} A new (partial) state object.
1066 */
1067 parseState: function (resp, queryParams, state, options) {
1068 if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) {
1069
1070 var newState = _clone(state);
1071 var serverState = resp[0];
1072
1073 _each(_pairs(_omit(queryParams, "directions")), function (kvp) {
1074 var k = kvp[0], v = kvp[1];
1075 var serverVal = serverState[v];
1076 if (!_isUndefined(serverVal) && !_.isNull(serverVal)) newState[k] = serverState[v];
1077 });
1078
1079 if (serverState.order) {
1080 newState.order = _invert(queryParams.directions)[serverState.order] * 1;
1081 }
1082
1083 return newState;
1084 }
1085 },
1086
1087 /**
1088 Parse server response for an array of model objects.
1089
1090 This default implementation first checks whether the response has any
1091 state object as documented in #parse. If it exists, the array of model
1092 objects is assumed to be the second element, otherwise the entire
1093 response is returned directly.
1094
1095 @param {Object} resp The deserialized response data from the server.
1096 @param {Object} [options] The options passed through from the
1097 `parse`. (backbone >= 0.9.10 only)
1098
1099 @return {Array.<Object>} An array of model objects
1100 */
1101 parseRecords: function (resp, options) {
1102 if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) {
1103 return resp[1];
1104 }
1105
1106 return resp;
1107 },
1108
1109 /**
1110 Fetch a page from the server in server mode, or all the pages in client
1111 mode. Under infinite mode, the current page is refetched by default and
1112 then reset.
1113
1114 The query string is constructed by translating the current pagination
1115 state to your server API query parameter using #queryParams. The current
1116 page will reset after fetch.
1117
1118 @param {Object} [options] Accepts all
1119 [Backbone.Collection#fetch](http://backbonejs.org/#Collection-fetch)
1120 options.
1121
1122 @return {XMLHttpRequest}
1123 */
1124 fetch: function (options) {
1125
1126 options = options || {};
1127
1128 var state = this._checkState(this.state);
1129
1130 var mode = this.mode;
1131
1132 if (mode == "infinite" && !options.url) {
1133 options.url = this.links[state.currentPage];
1134 }
1135
1136 var data = options.data || {};
1137
1138 // dedup query params
1139 var url = _result(options, "url") || _result(this, "url") || '';
1140 var qsi = url.indexOf('?');
1141 if (qsi != -1) {
1142 _extend(data, queryStringToParams(url.slice(qsi + 1)));
1143 url = url.slice(0, qsi);
1144 }
1145
1146 options.url = url;
1147 options.data = data;
1148
1149 // map params except directions
1150 var queryParams = this.mode == "client" ?
1151 _pick(this.queryParams, "sortKey", "order") :
1152 _omit(_pick(this.queryParams, _keys(PageableProto.queryParams)),
1153 "directions");
1154
1155 var i, kvp, k, v, kvps = _pairs(queryParams), thisCopy = _clone(this);
1156 for (i = 0; i < kvps.length; i++) {
1157 kvp = kvps[i], k = kvp[0], v = kvp[1];
1158 v = _isFunction(v) ? v.call(thisCopy) : v;
1159 if (state[k] != null && v != null) {
1160 data[v] = state[k];
1161 }
1162 }
1163
1164 // fix up sorting parameters
1165 if (state.sortKey && state.order) {
1166 data[queryParams.order] = this.queryParams.directions[state.order + ""];
1167 }
1168 else if (!state.sortKey) delete data[queryParams.order];
1169
1170 // map extra query parameters
1171 var extraKvps = _pairs(_omit(this.queryParams,
1172 _keys(PageableProto.queryParams)));
1173 for (i = 0; i < extraKvps.length; i++) {
1174 kvp = extraKvps[i];
1175 v = kvp[1];
1176 v = _isFunction(v) ? v.call(thisCopy) : v;
1177 if (v != null) data[kvp[0]] = v;
1178 }
1179
1180 if (mode != "server") {
1181 var self = this, fullCol = this.fullCollection;
1182 var success = options.success;
1183 options.success = function (col, resp, opts) {
1184
1185 // make sure the caller's intent is obeyed
1186 opts = opts || {};
1187 if (_isUndefined(options.silent)) delete opts.silent;
1188 else opts.silent = options.silent;
1189
1190 var models = col.models;
1191 if (mode == "client") fullCol.reset(models, opts);
1192 else fullCol.add(models, _extend({at: fullCol.length}, opts));
1193
1194 if (success) success(col, resp, opts);
1195 };
1196
1197 // silent the first reset from backbone
1198 return BBColProto.fetch.call(self, _extend({}, options, {silent: true}));
1199 }
1200
1201 return BBColProto.fetch.call(this, options);
1202 },
1203
1204 /**
1205 Convenient method for making a `comparator` sorted by a model attribute
1206 identified by `sortKey` and ordered by `order`.
1207
1208 Like a Backbone.Collection, a Backbone.PageableCollection will maintain
1209 the __current page__ in sorted order on the client side if a `comparator`
1210 is attached to it. If the collection is in client mode, you can attach a
1211 comparator to #fullCollection to have all the pages reflect the global
1212 sorting order by specifying an option `full` to `true`. You __must__ call
1213 `sort` manually or #fullCollection.sort after calling this method to
1214 force a resort.
1215
1216 While you can use this method to sort the current page in server mode,
1217 the sorting order may not reflect the global sorting order due to the
1218 additions or removals of the records on the server since the last
1219 fetch. If you want the most updated page in a global sorting order, it is
1220 recommended that you set #state.sortKey and optionally #state.order, and
1221 then call #fetch.
1222
1223 @protected
1224
1225 @param {string} [sortKey=this.state.sortKey] See `state.sortKey`.
1226 @param {number} [order=this.state.order] See `state.order`.
1227 @param {(function(Backbone.Model, string): Object) | string} [sortValue] See #setSorting.
1228
1229 See [Backbone.Collection.comparator](http://backbonejs.org/#Collection-comparator).
1230 */
1231 _makeComparator: function (sortKey, order, sortValue) {
1232 var state = this.state;
1233
1234 sortKey = sortKey || state.sortKey;
1235 order = order || state.order;
1236
1237 if (!sortKey || !order) return;
1238
1239 if (!sortValue) sortValue = function (model, attr) {
1240 return model.get(attr);
1241 };
1242
1243 return function (left, right) {
1244 var l = sortValue(left, sortKey), r = sortValue(right, sortKey), t;
1245 if (order === 1) t = l, l = r, r = t;
1246 if (l === r) return 0;
1247 else if (l < r) return -1;
1248 return 1;
1249 };
1250 },
1251
1252 /**
1253 Adjusts the sorting for this pageable collection.
1254
1255 Given a `sortKey` and an `order`, sets `state.sortKey` and
1256 `state.order`. A comparator can be applied on the client side to sort in
1257 the order defined if `options.side` is `"client"`. By default the
1258 comparator is applied to the #fullCollection. Set `options.full` to
1259 `false` to apply a comparator to the current page under any mode. Setting
1260 `sortKey` to `null` removes the comparator from both the current page and
1261 the full collection.
1262
1263 If a `sortValue` function is given, it will be passed the `(model,
1264 sortKey)` arguments and is used to extract a value from the model during
1265 comparison sorts. If `sortValue` is not given, `model.get(sortKey)` is
1266 used for sorting.
1267
1268 @chainable
1269
1270 @param {string} sortKey See `state.sortKey`.
1271 @param {number} [order=this.state.order] See `state.order`.
1272 @param {Object} [options]
1273 @param {"server"|"client"} [options.side] By default, `"client"` if
1274 `mode` is `"client"`, `"server"` otherwise.
1275 @param {boolean} [options.full=true]
1276 @param {(function(Backbone.Model, string): Object) | string} [options.sortValue]
1277 */
1278 setSorting: function (sortKey, order, options) {
1279
1280 var state = this.state;
1281
1282 state.sortKey = sortKey;
1283 state.order = order = order || state.order;
1284
1285 var fullCollection = this.fullCollection;
1286
1287 var delComp = false, delFullComp = false;
1288
1289 if (!sortKey) delComp = delFullComp = true;
1290
1291 var mode = this.mode;
1292 options = _extend({side: mode == "client" ? mode : "server", full: true},
1293 options);
1294
1295 var comparator = this._makeComparator(sortKey, order, options.sortValue);
1296
1297 var full = options.full, side = options.side;
1298
1299 if (side == "client") {
1300 if (full) {
1301 if (fullCollection) fullCollection.comparator = comparator;
1302 delComp = true;
1303 }
1304 else {
1305 this.comparator = comparator;
1306 delFullComp = true;
1307 }
1308 }
1309 else if (side == "server" && !full) {
1310 this.comparator = comparator;
1311 }
1312
1313 if (delComp) delete this.comparator;
1314 if (delFullComp && fullCollection) delete fullCollection.comparator;
1315
1316 return this;
1317 }
1318
1319 });
1320
1321 var PageableProto = PageableCollection.prototype;
1322
1323 return PageableCollection;
1324
1325}));