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