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