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