/ajax/libs/backbone-pageable/1.4.5/backbone-pageable.js
JavaScript | 1318 lines | 626 code | 147 blank | 545 comment | 225 complexity | 5f07b92fb684e892591d741b0dfb8eb2 MD5 | raw file
1/*
2 backbone-pageable 1.4.5
3 http://github.com/backbone-paginator/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 throw new RangeError("`currentPage` must be firstPage <= currentPage " +
587 (firstPage ? ">" : ">=") +
588 " totalPages if " + firstPage + "-based. Got " +
589 currentPage + '.');
590 }
591 }
592
593 return state;
594 },
595
596 /**
597 Change the page size of this collection.
598
599 Under most if not all circumstances, you should call this method to
600 change the page size of a pageable collection because it will keep the
601 pagination state sane. By default, the method will recalculate the
602 current page number to one that will retain the current page's models
603 when increasing the page size. When decreasing the page size, this method
604 will retain the last models to the current page that will fit into the
605 smaller page size.
606
607 If `options.first` is true, changing the page size will also reset the
608 current page back to the first page instead of trying to be smart.
609
610 For server mode operations, changing the page size will trigger a #fetch
611 and subsequently a `reset` event.
612
613 For client mode operations, changing the page size will `reset` the
614 current page by recalculating the current page boundary on the client
615 side.
616
617 If `options.fetch` is true, a fetch can be forced if the collection is in
618 client mode.
619
620 @param {number} pageSize The new page size to set to #state.
621 @param {Object} [options] {@link #fetch} options.
622 @param {boolean} [options.first=false] Reset the current page number to
623 the first page if `true`.
624 @param {boolean} [options.fetch] If `true`, force a fetch in client mode.
625
626 @throws {TypeError} If `pageSize` is not a finite integer.
627 @throws {RangeError} If `pageSize` is less than 1.
628
629 @chainable
630 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
631 from fetch or this.
632 */
633 setPageSize: function (pageSize, options) {
634 pageSize = finiteInt(pageSize, "pageSize");
635
636 options = options || {first: false};
637
638 var state = this.state;
639 var totalPages = ceil(state.totalRecords / pageSize);
640 var currentPage = totalPages ?
641 max(state.firstPage,
642 floor(totalPages *
643 (state.firstPage ?
644 state.currentPage :
645 state.currentPage + 1) /
646 state.totalPages)) :
647 state.firstPage;
648
649 state = this.state = this._checkState(_extend({}, state, {
650 pageSize: pageSize,
651 currentPage: options.first ? state.firstPage : currentPage,
652 totalPages: totalPages
653 }));
654
655 return this.getPage(state.currentPage, _omit(options, ["first"]));
656 },
657
658 /**
659 Switching between client, server and infinite mode.
660
661 If switching from client to server mode, the #fullCollection is emptied
662 first and then deleted and a fetch is immediately issued for the current
663 page from the server. Pass `false` to `options.fetch` to skip fetching.
664
665 If switching to infinite mode, and if `options.models` is given for an
666 array of models, #links will be populated with a URL per page, using the
667 default URL for this collection.
668
669 If switching from server to client mode, all of the pages are immediately
670 refetched. If you have too many pages, you can pass `false` to
671 `options.fetch` to skip fetching.
672
673 If switching to any mode from infinite mode, the #links will be deleted.
674
675 @param {"server"|"client"|"infinite"} [mode] The mode to switch to.
676
677 @param {Object} [options]
678
679 @param {boolean} [options.fetch=true] If `false`, no fetching is done.
680
681 @param {boolean} [options.resetState=true] If 'false', the state is not
682 reset, but checked for sanity instead.
683
684 @chainable
685 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
686 from fetch or this if `options.fetch` is `false`.
687 */
688 switchMode: function (mode, options) {
689
690 if (!_contains(["server", "client", "infinite"], mode)) {
691 throw new TypeError('`mode` must be one of "server", "client" or "infinite"');
692 }
693
694 options = options || {fetch: true, resetState: true};
695
696 var state = this.state = options.resetState ?
697 _clone(this._initState) :
698 this._checkState(_extend({}, this.state));
699
700 this.mode = mode;
701
702 var self = this;
703 var fullCollection = this.fullCollection;
704 var handlers = this._handlers = this._handlers || {}, handler;
705 if (mode != "server" && !fullCollection) {
706 fullCollection = this._makeFullCollection(options.models || [], options);
707 fullCollection.pageableCollection = this;
708 this.fullCollection = fullCollection;
709 var allHandler = this._makeCollectionEventHandler(this, fullCollection);
710 _each(["add", "remove", "reset", "sort"], function (event) {
711 handlers[event] = handler = _.bind(allHandler, {}, event);
712 self.on(event, handler);
713 fullCollection.on(event, handler);
714 });
715 fullCollection.comparator = this._fullComparator;
716 }
717 else if (mode == "server" && fullCollection) {
718 _each(_keys(handlers), function (event) {
719 handler = handlers[event];
720 self.off(event, handler);
721 fullCollection.off(event, handler);
722 });
723 delete this._handlers;
724 this._fullComparator = fullCollection.comparator;
725 delete this.fullCollection;
726 }
727
728 if (mode == "infinite") {
729 var links = this.links = {};
730 var firstPage = state.firstPage;
731 var totalPages = ceil(state.totalRecords / state.pageSize);
732 var lastPage = firstPage === 0 ? max(0, totalPages - 1) : totalPages || firstPage;
733 for (var i = state.firstPage; i <= lastPage; i++) {
734 links[i] = this.url;
735 }
736 }
737 else if (this.links) delete this.links;
738
739 return options.fetch ?
740 this.fetch(_omit(options, "fetch", "resetState")) :
741 this;
742 },
743
744 /**
745 @return {boolean} `true` if this collection can page backward, `false`
746 otherwise.
747 */
748 hasPrevious: function () {
749 var state = this.state;
750 var currentPage = state.currentPage;
751 if (this.mode != "infinite") return currentPage > state.firstPage;
752 return !!this.links[currentPage - 1];
753 },
754
755 /**
756 @return {boolean} `true` if this collection can page forward, `false`
757 otherwise.
758 */
759 hasNext: function () {
760 var state = this.state;
761 var currentPage = this.state.currentPage;
762 if (this.mode != "infinite") return currentPage < state.lastPage;
763 return !!this.links[currentPage + 1];
764 },
765
766 /**
767 Fetch the first page in server mode, or reset the current page of this
768 collection to the first page in client or infinite mode.
769
770 @param {Object} options {@link #getPage} options.
771
772 @chainable
773 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
774 from fetch or this.
775 */
776 getFirstPage: function (options) {
777 return this.getPage("first", options);
778 },
779
780 /**
781 Fetch the previous page in server mode, or reset the current page of this
782 collection to the previous page in client or infinite mode.
783
784 @param {Object} options {@link #getPage} options.
785
786 @chainable
787 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
788 from fetch or this.
789 */
790 getPreviousPage: function (options) {
791 return this.getPage("prev", options);
792 },
793
794 /**
795 Fetch the next page in server mode, or reset the current page of this
796 collection to the next page in client mode.
797
798 @param {Object} options {@link #getPage} options.
799
800 @chainable
801 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
802 from fetch or this.
803 */
804 getNextPage: function (options) {
805 return this.getPage("next", options);
806 },
807
808 /**
809 Fetch the last page in server mode, or reset the current page of this
810 collection to the last page in client mode.
811
812 @param {Object} options {@link #getPage} options.
813
814 @chainable
815 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
816 from fetch or this.
817 */
818 getLastPage: function (options) {
819 return this.getPage("last", options);
820 },
821
822 /**
823 Given a page index, set #state.currentPage to that index. If this
824 collection is in server mode, fetch the page using the updated state,
825 otherwise, reset the current page of this collection to the page
826 specified by `index` in client mode. If `options.fetch` is true, a fetch
827 can be forced in client mode before resetting the current page. Under
828 infinite mode, if the index is less than the current page, a reset is
829 done as in client mode. If the index is greater than the current page
830 number, a fetch is made with the results **appended** to #fullCollection.
831 The current page will then be reset after fetching.
832
833 @param {number|string} index The page index to go to, or the page name to
834 look up from #links in infinite mode.
835 @param {Object} [options] {@link #fetch} options or
836 [reset](http://backbonejs.org/#Collection-reset) options for client mode
837 when `options.fetch` is `false`.
838 @param {boolean} [options.fetch=false] If true, force a {@link #fetch} in
839 client mode.
840
841 @throws {TypeError} If `index` is not a finite integer under server or
842 client mode, or does not yield a URL from #links under infinite mode.
843
844 @throws {RangeError} If `index` is out of bounds.
845
846 @chainable
847 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
848 from fetch or this.
849 */
850 getPage: function (index, options) {
851
852 var mode = this.mode, fullCollection = this.fullCollection;
853
854 options = options || {fetch: false};
855
856 var state = this.state,
857 firstPage = state.firstPage,
858 currentPage = state.currentPage,
859 lastPage = state.lastPage,
860 pageSize = state.pageSize;
861
862 var pageNum = index;
863 switch (index) {
864 case "first": pageNum = firstPage; break;
865 case "prev": pageNum = currentPage - 1; break;
866 case "next": pageNum = currentPage + 1; break;
867 case "last": pageNum = lastPage; break;
868 default: pageNum = finiteInt(index, "index");
869 }
870
871 this.state = this._checkState(_extend({}, state, {currentPage: pageNum}));
872
873 options.from = currentPage, options.to = pageNum;
874
875 var pageStart = (firstPage === 0 ? pageNum : pageNum - 1) * pageSize;
876 var pageModels = fullCollection && fullCollection.length ?
877 fullCollection.models.slice(pageStart, pageStart + pageSize) :
878 [];
879 if ((mode == "client" || (mode == "infinite" && !_isEmpty(pageModels))) &&
880 !options.fetch) {
881 this.reset(pageModels, _omit(options, "fetch"));
882 return this;
883 }
884
885 if (mode == "infinite") options.url = this.links[pageNum];
886
887 return this.fetch(_omit(options, "fetch"));
888 },
889
890 /**
891 Fetch the page for the provided item offset in server mode, or reset the current page of this
892 collection to the page for the provided item offset in client mode.
893
894 @param {Object} options {@link #getPage} options.
895
896 @chainable
897 @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest
898 from fetch or this.
899 */
900 getPageByOffset: function (offset, options) {
901 if (offset < 0) {
902 throw new RangeError("`offset must be > 0`");
903 }
904 offset = finiteInt(offset);
905
906 var page = floor(offset / this.state.pageSize);
907 if (this.state.firstPage !== 0) page++;
908 if (page > this.state.lastPage) page = this.state.lastPage;
909 return this.getPage(page, options);
910 },
911
912 /**
913 Overidden to make `getPage` compatible with Zepto.
914
915 @param {string} method
916 @param {Backbone.Model|Backbone.Collection} model
917 @param {Object} [options]
918
919 @return {XMLHttpRequest}
920 */
921 sync: function (method, model, options) {
922 var self = this;
923 if (self.mode == "infinite") {
924 var success = options.success;
925 var currentPage = self.state.currentPage;
926 options.success = function (resp, status, xhr) {
927 var links = self.links;
928 var newLinks = self.parseLinks(resp, _extend({xhr: xhr}, options));
929 if (newLinks.first) links[self.state.firstPage] = newLinks.first;
930 if (newLinks.prev) links[currentPage - 1] = newLinks.prev;
931 if (newLinks.next) links[currentPage + 1] = newLinks.next;
932 if (success) success(resp, status, xhr);
933 };
934 }
935
936 return (BBColProto.sync || Backbone.sync).call(self, method, model, options);
937 },
938
939 /**
940 Parse pagination links from the server response. Only valid under
941 infinite mode.
942
943 Given a response body and a XMLHttpRequest object, extract pagination
944 links from them for infinite paging.
945
946 This default implementation parses the RFC 5988 `Link` header and extract
947 3 links from it - `first`, `prev`, `next`. Any subclasses overriding this
948 method __must__ return an object hash having only the keys
949 above. However, simply returning a `next` link or an empty hash if there
950 are no more links should be enough for most implementations.
951
952 @param {*} resp The deserialized response body.
953 @param {Object} [options]
954 @param {XMLHttpRequest} [options.xhr] The XMLHttpRequest object for this
955 response.
956 @return {Object}
957 */
958 parseLinks: function (resp, options) {
959 var links = {};
960 var linkHeader = options.xhr.getResponseHeader("Link");
961 if (linkHeader) {
962 var relations = ["first", "prev", "next"];
963 _each(linkHeader.split(","), function (linkValue) {
964 var linkParts = linkValue.split(";");
965 var url = linkParts[0].replace(URL_TRIM_RE, '');
966 var params = linkParts.slice(1);
967 _each(params, function (param) {
968 var paramParts = param.split("=");
969 var key = paramParts[0].replace(PARAM_TRIM_RE, '');
970 var value = paramParts[1].replace(PARAM_TRIM_RE, '');
971 if (key == "rel" && _contains(relations, value)) links[value] = url;
972 });
973 });
974 }
975
976 return links;
977 },
978
979 /**
980 Parse server response data.
981
982 This default implementation assumes the response data is in one of two
983 structures:
984
985 [
986 {}, // Your new pagination state
987 [{}, ...] // An array of JSON objects
988 ]
989
990 Or,
991
992 [{}] // An array of JSON objects
993
994 The first structure is the preferred form because the pagination states
995 may have been updated on the server side, sending them down again allows
996 this collection to update its states. If the response has a pagination
997 state object, it is checked for errors.
998
999 The second structure is the
1000 [Backbone.Collection#parse](http://backbonejs.org/#Collection-parse)
1001 default.
1002
1003 **Note:** this method has been further simplified since 1.1.7. While
1004 existing #parse implementations will continue to work, new code is
1005 encouraged to override #parseState and #parseRecords instead.
1006
1007 @param {Object} resp The deserialized response data from the server.
1008 @param {Object} the options for the ajax request
1009
1010 @return {Array.<Object>} An array of model objects
1011 */
1012 parse: function (resp, options) {
1013 var newState = this.parseState(resp, _clone(this.queryParams), _clone(this.state), options);
1014 if (newState) this.state = this._checkState(_extend({}, this.state, newState));
1015 return this.parseRecords(resp, options);
1016 },
1017
1018 /**
1019 Parse server response for server pagination state updates. Not applicable
1020 under infinite mode.
1021
1022 This default implementation first checks whether the response has any
1023 state object as documented in #parse. If it exists, a state object is
1024 returned by mapping the server state keys to this pageable collection
1025 instance's query parameter keys using `queryParams`.
1026
1027 It is __NOT__ neccessary to return a full state object complete with all
1028 the mappings defined in #queryParams. Any state object resulted is merged
1029 with a copy of the current pageable collection state and checked for
1030 sanity before actually updating. Most of the time, simply providing a new
1031 `totalRecords` value is enough to trigger a full pagination state
1032 recalculation.
1033
1034 parseState: function (resp, queryParams, state, options) {
1035 return {totalRecords: resp.total_entries};
1036 }
1037
1038 If you want to use header fields use:
1039
1040 parseState: function (resp, queryParams, state, options) {
1041 return {totalRecords: options.xhr.getResponseHeader("X-total")};
1042 }
1043
1044 This method __MUST__ return a new state object instead of directly
1045 modifying the #state object. The behavior of directly modifying #state is
1046 undefined.
1047
1048 @param {Object} resp The deserialized response data from the server.
1049 @param {Object} queryParams A copy of #queryParams.
1050 @param {Object} state A copy of #state.
1051 @param {Object} [options] The options passed through from
1052 `parse`. (backbone >= 0.9.10 only)
1053
1054 @return {Object} A new (partial) state object.
1055 */
1056 parseState: function (resp, queryParams, state, options) {
1057 if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) {
1058
1059 var newState = _clone(state);
1060 var serverState = resp[0];
1061
1062 _each(_pairs(_omit(queryParams, "directions")), function (kvp) {
1063 var k = kvp[0], v = kvp[1];
1064 var serverVal = serverState[v];
1065 if (!_isUndefined(serverVal) && !_.isNull(serverVal)) newState[k] = serverState[v];
1066 });
1067
1068 if (serverState.order) {
1069 newState.order = _invert(queryParams.directions)[serverState.order] * 1;
1070 }
1071
1072 return newState;
1073 }
1074 },
1075
1076 /**
1077 Parse server response for an array of model objects.
1078
1079 This default implementation first checks whether the response has any
1080 state object as documented in #parse. If it exists, the array of model
1081 objects is assumed to be the second element, otherwise the entire
1082 response is returned directly.
1083
1084 @param {Object} resp The deserialized response data from the server.
1085 @param {Object} [options] The options passed through from the
1086 `parse`. (backbone >= 0.9.10 only)
1087
1088 @return {Array.<Object>} An array of model objects
1089 */
1090 parseRecords: function (resp, options) {
1091 if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) {
1092 return resp[1];
1093 }
1094
1095 return resp;
1096 },
1097
1098 /**
1099 Fetch a page from the server in server mode, or all the pages in client
1100 mode. Under infinite mode, the current page is refetched by default and
1101 then reset.
1102
1103 The query string is constructed by translating the current pagination
1104 state to your server API query parameter using #queryParams. The current
1105 page will reset after fetch.
1106
1107 @param {Object} [options] Accepts all
1108 [Backbone.Collection#fetch](http://backbonejs.org/#Collection-fetch)
1109 options.
1110
1111 @return {XMLHttpRequest}
1112 */
1113 fetch: function (options) {
1114
1115 options = options || {};
1116
1117 var state = this._checkState(this.state);
1118
1119 var mode = this.mode;
1120
1121 if (mode == "infinite" && !options.url) {
1122 options.url = this.links[state.currentPage];
1123 }
1124
1125 var data = options.data || {};
1126
1127 // dedup query params
1128 var url = _result(options, "url") || _result(this, "url") || '';
1129 var qsi = url.indexOf('?');
1130 if (qsi != -1) {
1131 _extend(data, queryStringToParams(url.slice(qsi + 1)));
1132 url = url.slice(0, qsi);
1133 }
1134
1135 options.url = url;
1136 options.data = data;
1137
1138 // map params except directions
1139 var queryParams = this.mode == "client" ?
1140 _pick(this.queryParams, "sortKey", "order") :
1141 _omit(_pick(this.queryParams, _keys(PageableProto.queryParams)),
1142 "directions");
1143
1144 var i, kvp, k, v, kvps = _pairs(queryParams), thisCopy = _clone(this);
1145 for (i = 0; i < kvps.length; i++) {
1146 kvp = kvps[i], k = kvp[0], v = kvp[1];
1147 v = _isFunction(v) ? v.call(thisCopy) : v;
1148 if (state[k] != null && v != null) {
1149 data[v] = state[k];
1150 }
1151 }
1152
1153 // fix up sorting parameters
1154 if (state.sortKey && state.order) {
1155 data[queryParams.order] = this.queryParams.directions[state.order + ""];
1156 }
1157 else if (!state.sortKey) delete data[queryParams.order];
1158
1159 // map extra query parameters
1160 var extraKvps = _pairs(_omit(this.queryParams,
1161 _keys(PageableProto.queryParams)));
1162 for (i = 0; i < extraKvps.length; i++) {
1163 kvp = extraKvps[i];
1164 v = kvp[1];
1165 v = _isFunction(v) ? v.call(thisCopy) : v;
1166 if (v != null) data[kvp[0]] = v;
1167 }
1168
1169 if (mode != "server") {
1170 var self = this, fullCol = this.fullCollection;
1171 var success = options.success;
1172 options.success = function (col, resp, opts) {
1173
1174 // make sure the caller's intent is obeyed
1175 opts = opts || {};
1176 if (_isUndefined(options.silent)) delete opts.silent;
1177 else opts.silent = options.silent;
1178
1179 var models = col.models;
1180 if (mode == "client") fullCol.reset(models, opts);
1181 else {
1182 fullCol.add(models, _extend({at: fullCol.length},
1183 _extend(opts, {parse: false})));
1184 self.trigger("reset", self, opts);
1185 }
1186
1187 if (success) success(col, resp, opts);
1188 };
1189
1190 // silent the first reset from backbone
1191 return BBColProto.fetch.call(this, _extend({}, options, {silent: true}));
1192 }
1193
1194 return BBColProto.fetch.call(this, options);
1195 },
1196
1197 /**
1198 Convenient method for making a `comparator` sorted by a model attribute
1199 identified by `sortKey` and ordered by `order`.
1200
1201 Like a Backbone.Collection, a Backbone.PageableCollection will maintain
1202 the __current page__ in sorted order on the client side if a `comparator`
1203 is attached to it. If the collection is in client mode, you can attach a
1204 comparator to #fullCollection to have all the pages reflect the global
1205 sorting order by specifying an option `full` to `true`. You __must__ call
1206 `sort` manually or #fullCollection.sort after calling this method to
1207 force a resort.
1208
1209 While you can use this method to sort the current page in server mode,
1210 the sorting order may not reflect the global sorting order due to the
1211 additions or removals of the records on the server since the last
1212 fetch. If you want the most updated page in a global sorting order, it is
1213 recommended that you set #state.sortKey and optionally #state.order, and
1214 then call #fetch.
1215
1216 @protected
1217
1218 @param {string} [sortKey=this.state.sortKey] See `state.sortKey`.
1219 @param {number} [order=this.state.order] See `state.order`.
1220 @param {(function(Backbone.Model, string): Object) | string} [sortValue] See #setSorting.
1221
1222 See [Backbone.Collection.comparator](http://backbonejs.org/#Collection-comparator).
1223 */
1224 _makeComparator: function (sortKey, order, sortValue) {
1225 var state = this.state;
1226
1227 sortKey = sortKey || state.sortKey;
1228 order = order || state.order;
1229
1230 if (!sortKey || !order) return;
1231
1232 if (!sortValue) sortValue = function (model, attr) {
1233 return model.get(attr);
1234 };
1235
1236 return function (left, right) {
1237 var l = sortValue(left, sortKey), r = sortValue(right, sortKey), t;
1238 if (order === 1) t = l, l = r, r = t;
1239 if (l === r) return 0;
1240 else if (l < r) return -1;
1241 return 1;
1242 };
1243 },
1244
1245 /**
1246 Adjusts the sorting for this pageable collection.
1247
1248 Given a `sortKey` and an `order`, sets `state.sortKey` and
1249 `state.order`. A comparator can be applied on the client side to sort in
1250 the order defined if `options.side` is `"client"`. By default the
1251 comparator is applied to the #fullCollection. Set `options.full` to
1252 `false` to apply a comparator to the current page under any mode. Setting
1253 `sortKey` to `null` removes the comparator from both the current page and
1254 the full collection.
1255
1256 If a `sortValue` function is given, it will be passed the `(model,
1257 sortKey)` arguments and is used to extract a value from the model during
1258 comparison sorts. If `sortValue` is not given, `model.get(sortKey)` is
1259 used for sorting.
1260
1261 @chainable
1262
1263 @param {string} sortKey See `state.sortKey`.
1264 @param {number} [order=this.state.order] See `state.order`.
1265 @param {Object} [options]
1266 @param {"server"|"client"} [options.side] By default, `"client"` if
1267 `mode` is `"client"`, `"server"` otherwise.
1268 @param {boolean} [options.full=true]
1269 @param {(function(Backbone.Model, string): Object) | string} [options.sortValue]
1270 */
1271 setSorting: function (sortKey, order, options) {
1272
1273 var state = this.state;
1274
1275 state.sortKey = sortKey;
1276 state.order = order = order || state.order;
1277
1278 var fullCollection = this.fullCollection;
1279
1280 var delComp = false, delFullComp = false;
1281
1282 if (!sortKey) delComp = delFullComp = true;
1283
1284 var mode = this.mode;
1285 options = _extend({side: mode == "client" ? mode : "server", full: true},
1286 options);
1287
1288 var comparator = this._makeComparator(sortKey, order, options.sortValue);
1289
1290 var full = options.full, side = options.side;
1291
1292 if (side == "client") {
1293 if (full) {
1294 if (fullCollection) fullCollection.comparator = comparator;
1295 delComp = true;
1296 }
1297 else {
1298 this.comparator = comparator;
1299 delFullComp = true;
1300 }
1301 }
1302 else if (side == "server" && !full) {
1303 this.comparator = comparator;
1304 }
1305
1306 if (delComp) this.comparator = null;
1307 if (delFullComp && fullCollection) fullCollection.comparator = null;
1308
1309 return this;
1310 }
1311
1312 });
1313
1314 var PageableProto = PageableCollection.prototype;
1315
1316 return PageableCollection;
1317
1318}));