PageRenderTime 174ms CodeModel.GetById 21ms app.highlight 104ms RepoModel.GetById 17ms app.codeStats 4ms

/ajax/libs/backbone-pageable/1.4.1/backbone-pageable.js

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