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