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