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