PageRenderTime 167ms CodeModel.GetById 1ms app.highlight 138ms RepoModel.GetById 2ms app.codeStats 0ms

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

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