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