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