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