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