/ajax/libs/backbone-pageable/1.1.8/backbone-pageable.js
JavaScript | 1293 lines | 622 code | 154 blank | 517 comment | 231 complexity | 4a067a702318014874a3f4de64137805 MD5 | raw file
1/*
2 backbone-pageable
3 http://github.com/wyuenho/backbone-pageable
4
5 Copyright (c) 2013 Jimmy Yuen Ho Wong
6 Licensed under the MIT @license.
7*/
8
9(function (factory) {
10
11 // CommonJS
12 if (typeof exports == "object") {
13 module.exports = factory(require("underscore"), require("backbone"));
14 }
15 // AMD
16 else if (typeof define == "function" && define.amd) {
17 define(["underscore", "backbone"], factory);
18 }
19 // Browser
20 else if (typeof _ !== "undefined" && typeof Backbone !== "undefined") {
21 var oldPageableCollection = Backbone.PageableCollection;
22 var PageableCollection = Backbone.PageableCollection = factory(_, Backbone);
23
24 /**
25 __BROWSER ONLY__
26
27 If you already have an object named `PageableCollection` attached to the
28 `Backbone` module, you can use this to return a local reference to this
29 Backbone.PageableCollection class and reset the name
30 Backbone.PageableCollection to its previous definition.
31
32 // The left hand side gives you a reference to this
33 // Backbone.PageableCollection implementation, the right hand side
34 // resets Backbone.PageableCollection to your other
35 // Backbone.PageableCollection.
36 var PageableCollection = Backbone.PageableCollection.noConflict();
37
38 @static
39 @member Backbone.PageableCollection
40 @return {Backbone.PageableCollection}
41 */
42 Backbone.PageableCollection.noConflict = function () {
43 Backbone.PageableCollection = oldPageableCollection;
44 return PageableCollection;
45 };
46 }
47
48}(function (_, Backbone) {
49
50 "use strict";
51
52 var _extend = _.extend;
53 var _omit = _.omit;
54 var _clone = _.clone;
55 var _each = _.each;
56 var _pick = _.pick;
57 var _contains = _.contains;
58 var _isEmpty = _.isEmpty;
59 var _pairs = _.pairs;
60 var _invert = _.invert;
61 var _isArray = _.isArray;
62 var _isFunction = _.isFunction;
63 var _keys = _.keys;
64 var _isUndefined = _.isUndefined;
65 var _result = _.result;
66 var _bind = _.bind;
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] = prop;
369 }
370 }
371
372 return fullCollection;
373 },
374
375 /**
376 Factory method that returns a Backbone event handler that responses to
377 the `all` event. The returned event handler will synchronize the current
378 page collection and the full 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
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 linkHeader = options.xhr.getResponseHeader("Link");
906 var relations = ["first", "prev", "previous", "next", "last"];
907 var links = {};
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 delete links.last;
948
949 return links;
950 },
951
952 /**
953 Parse server response data.
954
955 This default implementation assumes the response data is in one of two
956 structures:
957
958 [
959 {}, // Your new pagination state
960 [{}, ...] // An array of JSON objects
961 ]
962
963 Or,
964
965 [{}] // An array of JSON objects
966
967 The first structure is the preferred form because the pagination states
968 may have been updated on the server side, sending them down again allows
969 this collection to update its states. If the response has a pagination
970 state object, it is checked for errors.
971
972 The second structure is the
973 [Backbone.Collection#parse](http://backbonejs.org/#Collection-parse)
974 default.
975
976 **Note:** this method has been further simplified since 1.1.7. While
977 existing #parse implementations will continue to work, new code is
978 encouraged to override #parseState and #parseRecords instead.
979
980 @param {Object} resp The deserialized response data from the server.
981
982 @return {Array.<Object>} An array of model objects
983 */
984 parse: function (resp) {
985 var newState = this.parseState(resp, _clone(this.queryParams), _clone(this.state));
986 if (newState) this.state = this._checkState(_extend({}, this.state, newState));
987 return this.parseRecords(resp);
988 },
989
990 /**
991 Parse server response for server pagination state updates.
992
993 This default implementation first checks whether the response has any
994 state object as documented in #parse. If it exists, a state object is
995 returned by mapping the server state keys to this pageable collection
996 instance's query parameter keys using `queryParams`.
997
998 It is __NOT__ neccessary to return a full state object complete with all
999 the mappings defined in #queryParams. Any state object resulted is merged
1000 with a copy of the current pageable collection state and checked for
1001 sanity before actually updating. Most of the time, simply providing a new
1002 `totalRecords` value is enough to trigger a full pagination state
1003 recalculation.
1004
1005 parseState: function (resp, queryParams, state) {
1006 return {totalRecords: resp.total_entries};
1007 }
1008
1009 __Note__: `totalRecords` cannot be set to 0 for compatibility reasons,
1010 use `null` instead of 0 for all cases where you would like to set it to
1011 0. You can do this either on the server-side or in your overridden #parseState
1012 method.
1013
1014 This method __MUST__ return a new state object instead of directly
1015 modifying the #state object. The behavior of directly modifying #state is
1016 undefined.
1017
1018 @param {Object} resp The deserialized response data from the server.
1019 @param {Object} queryParams A copy of #queryParams.
1020 @param {Object} state A copy of #state.
1021
1022 @return {Object} A new (partial) state object.
1023 */
1024 parseState: function (resp, queryParams, state) {
1025 if (resp && resp.length === 2 && _.isObject(resp[0]) && _isArray(resp[1])) {
1026
1027 var newState = _clone(state);
1028 var serverState = resp[0];
1029
1030 _each(_pairs(_omit(queryParams, "directions")), function (kvp) {
1031 var k = kvp[0], v = kvp[1];
1032 var serverVal = serverState[v];
1033 if (!_isUndefined(serverVal) && !_.isNull(serverVal)) newState[k] = serverState[v];
1034 });
1035
1036 if (serverState.order) {
1037 newState.order = _invert(queryParams.directions)[serverState.order] * 1;
1038 }
1039
1040 return newState;
1041 }
1042 },
1043
1044 /**
1045 Parse server response for an array of model objects.
1046
1047 This default implementation first checks whether the response has any
1048 state object as documented in #parse. If it exists, the array of model
1049 objects is assumed to be the second element, otherwise the entire
1050 response is returned directly.
1051
1052 @param {Object} resp The deserialized response data from the server.
1053
1054 @return {Array.<Object>} An array of model objects
1055 */
1056 parseRecords: function (resp) {
1057 if (resp && resp.length === 2 && _.isObject(resp[0]) && _isArray(resp[1])) {
1058 return resp[1];
1059 }
1060
1061 return resp;
1062 },
1063
1064 /**
1065 Fetch a page from the server in server mode, or all the pages in client
1066 mode. Under infinite mode, the current page is refetched by default and
1067 then reset.
1068
1069 The query string is constructed by translating the current pagination
1070 state to your server API query parameter using #queryParams. The current
1071 page will reset after fetch.
1072
1073 @param {Object} [options] Accepts all
1074 [Backbone.Collection#fetch](http://backbonejs.org/#Collection-fetch)
1075 options.
1076
1077 @return {XMLHttpRequest}
1078 */
1079 fetch: function (options) {
1080
1081 options = options || {};
1082
1083 var state = this._checkState(this.state);
1084
1085 var mode = this.mode;
1086
1087 if (mode == "infinite" && !options.url) {
1088 options.url = this.links[state.currentPage];
1089 }
1090
1091 var data = options.data || {};
1092
1093 // dedup query params
1094 var url = _result(options, "url") || _result(this, "url") || '';
1095 var qsi = url.indexOf('?');
1096 if (qsi != -1) {
1097 _extend(data, queryStringToParams(url.slice(qsi + 1)));
1098 url = url.slice(0, qsi);
1099 }
1100
1101 options.url = url;
1102 options.data = data;
1103
1104 // map params except directions
1105 var queryParams = this.mode == "client" ?
1106 _pick(this.queryParams, "sortKey", "order") :
1107 _omit(_pick(this.queryParams, _keys(PageableProto.queryParams)),
1108 "directions");
1109
1110 var i, kvp, k, v, kvps = _pairs(queryParams), thisCopy = _clone(this);
1111 for (i = 0; i < kvps.length; i++) {
1112 kvp = kvps[i], k = kvp[0], v = kvp[1];
1113 v = _isFunction(v) ? v.call(thisCopy) : v;
1114 if (state[k] != null && v != null) {
1115 data[v] = state[k];
1116 }
1117 }
1118
1119 // fix up sorting parameters
1120 if (state.sortKey && state.order) {
1121 data[queryParams.order] = this.queryParams.directions[state.order + ""];
1122 }
1123 else if (!state.sortKey) delete data[queryParams.order];
1124
1125 // map extra query parameters
1126 var extraKvps = _pairs(_omit(this.queryParams,
1127 _keys(PageableProto.queryParams)));
1128 for (i = 0; i < extraKvps.length; i++) {
1129 kvp = extraKvps[i];
1130 v = kvp[1];
1131 v = _isFunction(v) ? v.call(thisCopy) : v;
1132 data[kvp[0]] = v;
1133 }
1134
1135 var fullCollection = this.fullCollection, links = this.links;
1136
1137 if (mode != "server") {
1138
1139 var self = this;
1140 var success = options.success;
1141 options.success = function (col, resp, opts) {
1142
1143 // make sure the caller's intent is obeyed
1144 opts = opts || {};
1145 if (_isUndefined(options.silent)) delete opts.silent;
1146 else opts.silent = options.silent;
1147
1148 var models = col.models;
1149 var currentPage = state.currentPage;
1150
1151 if (mode == "client") resetQuickly(fullCollection, models, opts);
1152 else if (links[currentPage]) { // refetching a page
1153 var pageSize = state.pageSize;
1154 var pageStart = (state.firstPage === 0 ?
1155 currentPage :
1156 currentPage - 1) * pageSize;
1157 var fullModels = fullCollection.models;
1158 var head = fullModels.slice(0, pageStart);
1159 var tail = fullModels.slice(pageStart + pageSize);
1160 fullModels = head.concat(models).concat(tail);
1161 fullCollection.update(fullModels,
1162 _extend({silent: true, sort: false}, opts));
1163 if (fullCollection.comparator) fullCollection.sort();
1164 fullCollection.trigger("reset", fullCollection, opts);
1165 }
1166 else { // fetching new page
1167 fullCollection.add(models, _extend({at: fullCollection.length,
1168 silent: true}, opts));
1169 fullCollection.trigger("reset", fullCollection, opts);
1170 }
1171
1172 if (success) success(col, resp, opts);
1173 };
1174
1175 // silent the first reset from backbone
1176 return BBColProto.fetch.call(self, _extend({}, options, {silent: true}));
1177 }
1178
1179 return BBColProto.fetch.call(this, options);
1180 },
1181
1182 /**
1183 Convenient method for making a `comparator` sorted by a model attribute
1184 identified by `sortKey` and ordered by `order`.
1185
1186 Like a Backbone.Collection, a Backbone.PageableCollection will maintain
1187 the __current page__ in sorted order on the client side if a `comparator`
1188 is attached to it. If the collection is in client mode, you can attach a
1189 comparator to #fullCollection to have all the pages reflect the global
1190 sorting order by specifying an option `full` to `true`. You __must__ call
1191 `sort` manually or #fullCollection.sort after calling this method to
1192 force a resort.
1193
1194 While you can use this method to sort the current page in server mode,
1195 the sorting order may not reflect the global sorting order due to the
1196 additions or removals of the records on the server since the last
1197 fetch. If you want the most updated page in a global sorting order, it is
1198 recommended that you set #state.sortKey and optionally #state.order, and
1199 then call #fetch.
1200
1201 @protected
1202
1203 @param {string} [sortKey=this.state.sortKey] See `state.sortKey`.
1204 @param {number} [order=this.state.order] See `state.order`.
1205
1206 See [Backbone.Collection.comparator](http://backbonejs.org/#Collection-comparator).
1207 */
1208 _makeComparator: function (sortKey, order) {
1209
1210 var state = this.state;
1211
1212 sortKey = sortKey || state.sortKey;
1213 order = order || state.order;
1214
1215 if (!sortKey || !order) return;
1216
1217 return function (left, right) {
1218 var l = left.get(sortKey), r = right.get(sortKey), t;
1219 if (order === 1) t = l, l = r, r = t;
1220 if (l === r) return 0;
1221 else if (l < r) return -1;
1222 return 1;
1223 };
1224 },
1225
1226 /**
1227 Adjusts the sorting for this pageable collection.
1228
1229 Given a `sortKey` and an `order`, sets `state.sortKey` and
1230 `state.order`. A comparator can be applied on the client side to sort in
1231 the order defined if `options.side` is `"client"`. By default the
1232 comparator is applied to the #fullCollection. Set `options.full` to
1233 `false` to apply a comparator to the current page under any mode. Setting
1234 `sortKey` to `null` removes the comparator from both the current page and
1235 the full collection.
1236
1237 @chainable
1238
1239 @param {string} sortKey See `state.sortKey`.
1240 @param {number} [order=this.state.order] See `state.order`.
1241 @param {Object} [options]
1242 @param {"server"|"client"} [options.side] By default, `"client"` if
1243 `mode` is `"client"`, `"server"` otherwise.
1244 @param {boolean} [options.full=true]
1245 */
1246 setSorting: function (sortKey, order, options) {
1247
1248 var state = this.state;
1249
1250 state.sortKey = sortKey;
1251 state.order = order = order || state.order;
1252
1253 var fullCollection = this.fullCollection;
1254
1255 var delComp = false, delFullComp = false;
1256
1257 if (!sortKey) delComp = delFullComp = true;
1258
1259 var mode = this.mode;
1260 options = _extend({side: mode == "client" ? mode : "server", full: true},
1261 options);
1262
1263 var comparator = this._makeComparator(sortKey, order);
1264
1265 var full = options.full, side = options.side;
1266
1267 if (side == "client") {
1268 if (full) {
1269 if (fullCollection) fullCollection.comparator = comparator;
1270 delComp = true;
1271 }
1272 else {
1273 this.comparator = comparator;
1274 delFullComp = true;
1275 }
1276 }
1277 else if (side == "server" && !full) {
1278 this.comparator = comparator;
1279 }
1280
1281 if (delComp) delete this.comparator;
1282 if (delFullComp && fullCollection) delete fullCollection.comparator;
1283
1284 return this;
1285 }
1286
1287 });
1288
1289 var PageableProto = PageableCollection.prototype;
1290
1291 return PageableCollection;
1292
1293}));