master
Raw Download raw file
   1/*
   2 * jQuery Dynatable plugin 0.2.2
   3 *
   4 * Copyright (c) 2013 Steve Schwartz (JangoSteve)
   5 *
   6 * Dual licensed under the MIT and GPL licenses:
   7 *   http://www.opensource.org/licenses/mit-license.php
   8 *   http://www.gnu.org/licenses/gpl.html
   9 *
  10 * Date: Tue Aug 13 12:50:00 2013 -0500
  11 */
  12//
  13
  14(function($) {
  15  var defaults,
  16      mergeSettings,
  17      dt,
  18      Model,
  19      modelPrototypes = {
  20        dom: Dom,
  21        domColumns: DomColumns,
  22        records: Records,
  23        recordsCount: RecordsCount,
  24        processingIndicator: ProcessingIndicator,
  25        state: State,
  26        sorts: Sorts,
  27        sortsHeaders: SortsHeaders,
  28        queries: Queries,
  29        inputsSearch: InputsSearch,
  30        paginationPage: PaginationPage,
  31        paginationPerPage: PaginationPerPage,
  32        paginationLinks: PaginationLinks
  33      },
  34      utility,
  35      build,
  36      processAll,
  37      initModel,
  38      defaultRowWriter,
  39      defaultCellWriter,
  40      defaultAttributeWriter,
  41      defaultAttributeReader;
  42
  43  //-----------------------------------------------------------------
  44  // Cached plugin global defaults
  45  //-----------------------------------------------------------------
  46
  47  defaults = {
  48    features: {
  49      paginate: true,
  50      sort: true,
  51      pushState: true,
  52      search: true,
  53      recordCount: true,
  54      perPageSelect: true
  55    },
  56    table: {
  57      defaultColumnIdStyle: 'camelCase',
  58      columns: null,
  59      headRowSelector: 'thead tr', // or e.g. tr:first-child
  60      bodyRowSelector: 'tbody tr',
  61      headRowClass: null
  62    },
  63    inputs: {
  64      queries: null,
  65      sorts: null,
  66      multisort: ['ctrlKey', 'shiftKey', 'metaKey'],
  67      page: null,
  68      queryEvent: 'blur change',
  69      recordCountTarget: null,
  70      recordCountPlacement: 'after',
  71      paginationLinkTarget: null,
  72      paginationLinkPlacement: 'after',
  73      paginationPrev: 'Previous',
  74      paginationNext: 'Next',
  75      paginationGap: [1,2,2,1],
  76      searchTarget: null,
  77      searchPlacement: 'before',
  78      perPageTarget: null,
  79      perPagePlacement: 'before',
  80      perPageText: 'Show: ',
  81      recordCountText: 'Showing ',
  82      processingText: 'Processing...'
  83    },
  84    dataset: {
  85      ajax: false,
  86      ajaxUrl: null,
  87      ajaxCache: null,
  88      ajaxOnLoad: false,
  89      ajaxMethod: 'GET',
  90      ajaxDataType: 'json',
  91      totalRecordCount: null,
  92      queries: {},
  93      queryRecordCount: null,
  94      page: null,
  95      perPageDefault: 10,
  96      perPageOptions: [10,20,50,100],
  97      sorts: {},
  98      sortsKeys: null,
  99      sortTypes: {},
 100      records: null
 101    },
 102    writers: {
 103      _rowWriter: defaultRowWriter,
 104      _cellWriter: defaultCellWriter,
 105      _attributeWriter: defaultAttributeWriter
 106    },
 107    readers: {
 108      _rowReader: null,
 109      _attributeReader: defaultAttributeReader
 110    },
 111    params: {
 112      dynatable: 'dynatable',
 113      queries: 'queries',
 114      sorts: 'sorts',
 115      page: 'page',
 116      perPage: 'perPage',
 117      offset: 'offset',
 118      records: 'records',
 119      record: null,
 120      queryRecordCount: 'queryRecordCount',
 121      totalRecordCount: 'totalRecordCount'
 122    }
 123  };
 124
 125  //-----------------------------------------------------------------
 126  // Each dynatable instance inherits from this,
 127  // set properties specific to instance
 128  //-----------------------------------------------------------------
 129
 130  dt = {
 131    init: function(element, options) {
 132      this.settings = mergeSettings(options);
 133      this.element = element;
 134      this.$element = $(element);
 135
 136      // All the setup that doesn't require element or options
 137      build.call(this);
 138
 139      return this;
 140    },
 141
 142    process: function(skipPushState) {
 143      processAll.call(this, skipPushState);
 144    }
 145  };
 146
 147  //-----------------------------------------------------------------
 148  // Cached plugin global functions
 149  //-----------------------------------------------------------------
 150
 151  mergeSettings = function(options) {
 152    var newOptions = $.extend(true, {}, defaults, options);
 153
 154    // TODO: figure out a better way to do this.
 155    // Doing `extend(true)` causes any elements that are arrays
 156    // to merge the default and options arrays instead of overriding the defaults.
 157    if (options) {
 158      if (options.inputs) {
 159        if (options.inputs.multisort) {
 160          newOptions.inputs.multisort = options.inputs.multisort;
 161        }
 162        if (options.inputs.paginationGap) {
 163          newOptions.inputs.paginationGap = options.inputs.paginationGap;
 164        }
 165      }
 166      if (options.dataset && options.dataset.perPageOptions) {
 167        newOptions.dataset.perPageOptions = options.dataset.perPageOptions;
 168      }
 169    }
 170
 171    return newOptions;
 172  };
 173
 174  build = function() {
 175    for (model in modelPrototypes) {
 176      if (modelPrototypes.hasOwnProperty(model)) {
 177        var modelInstance = this[model] = new modelPrototypes[model](this, this.settings);
 178        if (modelInstance.initOnLoad()) {
 179          modelInstance.init();
 180        }
 181      }
 182    }
 183
 184    this.$element.trigger('dynatable:init', this);
 185
 186    if (!this.settings.dataset.ajax || (this.settings.dataset.ajax && this.settings.dataset.ajaxOnLoad) || this.settings.features.paginate) {
 187      this.process();
 188    }
 189  };
 190
 191  processAll = function(skipPushState) {
 192    var data = {};
 193
 194    this.$element.trigger('dynatable:beforeProcess', data);
 195
 196    if (!$.isEmptyObject(this.settings.dataset.queries)) { data[this.settings.params.queries] = this.settings.dataset.queries; }
 197    // TODO: Wrap this in a try/rescue block to hide the processing indicator and indicate something went wrong if error
 198    this.processingIndicator.show();
 199
 200    if (this.settings.features.sort && !$.isEmptyObject(this.settings.dataset.sorts)) { data[this.settings.params.sorts] = this.settings.dataset.sorts; }
 201    if (this.settings.features.paginate && this.settings.dataset.page) {
 202      var page = this.settings.dataset.page,
 203          perPage = this.settings.dataset.perPage;
 204      data[this.settings.params.page] = page;
 205      data[this.settings.params.perPage] = perPage;
 206      data[this.settings.params.offset] = (page - 1) * perPage;
 207    }
 208    if (this.settings.dataset.ajaxData) { $.extend(data, this.settings.dataset.ajaxData); }
 209
 210    // If ajax, sends query to ajaxUrl with queries and sorts serialized and appended in ajax data
 211    // otherwise, executes queries and sorts on in-page data
 212    if (this.settings.dataset.ajax) {
 213      var _this = this;
 214      var options = {
 215        type: _this.settings.dataset.ajaxMethod,
 216        dataType: _this.settings.dataset.ajaxDataType,
 217        data: data,
 218        error: function(xhr, error) {
 219        },
 220        success: function(response) {
 221          _this.$element.trigger('dynatable:ajax:success', response);
 222          // Merge ajax results and meta-data into dynatables cached data
 223          _this.records.updateFromJson(response);
 224          // update table with new records
 225          _this.dom.update();
 226
 227          if (!skipPushState && _this.state.initOnLoad()) {
 228            _this.state.push(data);
 229          }
 230        },
 231        complete: function() {
 232          _this.processingIndicator.hide();
 233        }
 234      };
 235      // Do not pass url to `ajax` options if blank
 236      if (this.settings.dataset.ajaxUrl) {
 237        options.url = this.settings.dataset.ajaxUrl;
 238
 239      // If ajaxUrl is blank, then we're using the current page URL,
 240      // we need to strip out any query, sort, or page data controlled by dynatable
 241      // that may have been in URL when page loaded, so that it doesn't conflict with
 242      // what's passed in with the data ajax parameter
 243      } else {
 244        options.url = utility.refreshQueryString(window.location.href, {}, this.settings);
 245      }
 246      if (this.settings.dataset.ajaxCache !== null) { options.cache = this.settings.dataset.ajaxCache; }
 247
 248      $.ajax(options);
 249    } else {
 250      this.records.resetOriginal();
 251      this.queries.run();
 252      if (this.settings.features.sort) {
 253        this.records.sort();
 254      }
 255      if (this.settings.features.paginate) {
 256        this.records.paginate();
 257      }
 258      this.dom.update();
 259      this.processingIndicator.hide();
 260
 261      if (!skipPushState && this.state.initOnLoad()) {
 262        this.state.push(data);
 263      }
 264    }
 265    this.$element.trigger('dynatable:afterProcess', data);
 266  };
 267
 268  function defaultRowWriter(rowIndex, record, columns, cellWriter) {
 269    var tr = '';
 270
 271    // grab the record's attribute for each column
 272    for (var i = 0, len = columns.length; i < len; i++) {
 273      tr += cellWriter(columns[i], record);
 274    }
 275
 276    return '<tr>' + tr + '</tr>';
 277  };
 278
 279  function defaultCellWriter(column, record) {
 280    var html = column.attributeWriter(record),
 281        td = '<td';
 282
 283    // keep cells for hidden column headers hidden
 284    if (column.hidden) {
 285      td += ' display="none"';
 286    }
 287
 288    // keep cells aligned as their column headers are aligned
 289    if (column.textAlign) {
 290      td += ' style="text-align: ' + column.textAlign + ';"';
 291    }
 292
 293    return td + '>' + html + '</td>';
 294  };
 295
 296  function defaultAttributeWriter(record) {
 297    // `this` is the column object in settings.columns
 298    // TODO: automatically convert common types, such as arrays and objects, to string
 299    return record[this.id];
 300  };
 301
 302  function defaultAttributeReader(cell, record) {
 303    return $(cell).html();
 304  };
 305
 306  //-----------------------------------------------------------------
 307  // Dynatable object model prototype
 308  // (all object models get these default functions)
 309  //-----------------------------------------------------------------
 310
 311  Model = {
 312    initOnLoad: function() {
 313      return true;
 314    },
 315
 316    init: function() {}
 317  };
 318
 319  for (model in modelPrototypes) {
 320    if (modelPrototypes.hasOwnProperty(model)) {
 321      var modelPrototype = modelPrototypes[model];
 322      modelPrototype.prototype = Model;
 323    }
 324  }
 325
 326  //-----------------------------------------------------------------
 327  // Dynatable object models
 328  //-----------------------------------------------------------------
 329
 330  function Dom(obj, settings) {
 331    var _this = this;
 332
 333    // update table contents with new records array
 334    // from query (whether ajax or not)
 335    this.update = function() {
 336      var rows = '',
 337          columns = settings.table.columns,
 338          rowWriter = settings.writers._rowWriter,
 339          cellWriter = settings.writers._cellWriter;
 340
 341      obj.$element.trigger('dynatable:beforeUpdate', rows);
 342
 343      // loop through records
 344      for (var i = 0, len = settings.dataset.records.length; i < len; i++) {
 345        var record = settings.dataset.records[i],
 346            tr = rowWriter(i, record, columns, cellWriter);
 347        rows += tr;
 348      }
 349
 350      // Appended dynatable interactive elements
 351      if (settings.features.recordCount) {
 352        $('#dynatable-record-count-' + obj.element.id).replaceWith(obj.recordsCount.create());
 353      }
 354      if (settings.features.paginate) {
 355        $('#dynatable-pagination-links-' + obj.element.id).replaceWith(obj.paginationLinks.create());
 356        if (settings.features.perPageSelect) {
 357          $('#dynatable-per-page-' + obj.element.id).val(parseInt(settings.dataset.perPage));
 358        }
 359      }
 360
 361      // Sort headers functionality
 362      if (settings.features.sort && columns) {
 363        obj.sortsHeaders.removeAllArrows();
 364        for (var i = 0, len = columns.length; i < len; i++) {
 365          var column = columns[i],
 366              sortedByColumn = utility.allMatch(settings.dataset.sorts, column.sorts, function(sorts, sort) { return sort in sorts; }),
 367              value = settings.dataset.sorts[column.sorts[0]];
 368
 369          if (sortedByColumn) {
 370            obj.$element.find('[data-dynatable-column="' + column.id + '"]').find('.dynatable-sort-header').each(function(){
 371              if (value == 1) {
 372                obj.sortsHeaders.appendArrowUp($(this));
 373              } else {
 374                obj.sortsHeaders.appendArrowDown($(this));
 375              }
 376            });
 377          }
 378        }
 379      }
 380
 381      // Query search functionality
 382      if (settings.inputs.queries) {
 383        settings.inputs.queries.each(function() {
 384          var $this = $(this),
 385              q = settings.dataset.queries[$this.data('dynatable-query')];
 386          $(this).val(q || '');
 387        });
 388      }
 389      obj.$element.find(settings.table.bodyRowSelector).remove();
 390      obj.$element.append(rows);
 391
 392      obj.$element.trigger('dynatable:afterUpdate', rows);
 393    };
 394  };
 395
 396  function DomColumns(obj, settings) {
 397    var _this = this;
 398
 399    this.initOnLoad = function() {
 400      return obj.$element.is('table');
 401    };
 402
 403    this.init = function() {
 404      settings.table.columns = [];
 405      this.getFromTable();
 406    };
 407
 408    // initialize table[columns] array
 409    this.getFromTable = function() {
 410      var $columns = obj.$element.find(settings.table.headRowSelector).children('th,td');
 411      if ($columns.length) {
 412        $columns.each(function(index){
 413          _this.add($(this), index, true);
 414        });
 415      } else {
 416        return $.error("Couldn't find any columns headers in '" + settings.table.headRowSelector + " th,td'. If your header row is different, specify the selector in the table: headRowSelector option.");
 417      }
 418    };
 419
 420    this.add = function($column, position, skipAppend, skipUpdate) {
 421      var columns = settings.table.columns,
 422          label = $column.text(),
 423          id = $column.data('dynatable-column') || utility.normalizeText(label, settings.table.defaultColumnIdStyle),
 424          dataSorts = $column.data('dynatable-sorts'),
 425          sorts = dataSorts ? $.map(dataSorts.split(','), function(text) { return $.trim(text); }) : [id];
 426
 427      // If the column id is blank, generate an id for it
 428      if ( !id ) {
 429        this.generate($column);
 430        id = $column.data('dynatable-column');
 431      }
 432      // Add column data to plugin instance
 433      columns.splice(position, 0, {
 434        index: position,
 435        label: label,
 436        id: id,
 437        attributeWriter: settings.writers[id] || settings.writers._attributeWriter,
 438        attributeReader: settings.readers[id] || settings.readers._attributeReader,
 439        sorts: sorts,
 440        hidden: $column.css('display') === 'none',
 441        textAlign: $column.css('text-align')
 442      });
 443
 444      // Modify header cell
 445      $column
 446        .attr('data-dynatable-column', id)
 447        .addClass('dynatable-head');
 448      if (settings.table.headRowClass) { $column.addClass(settings.table.headRowClass); }
 449
 450      // Append column header to table
 451      if (!skipAppend) {
 452        var domPosition = position + 1,
 453            $sibling = obj.$element.find(settings.table.headRowSelector)
 454              .children('th:nth-child(' + domPosition + '),td:nth-child(' + domPosition + ')').first(),
 455            columnsAfter = columns.slice(position + 1, columns.length);
 456
 457        if ($sibling.length) {
 458          $sibling.before($column);
 459        // sibling column doesn't yet exist (maybe this is the last column in the header row)
 460        } else {
 461          obj.$element.find(settings.table.headRowSelector).append($column);
 462        }
 463
 464        obj.sortsHeaders.attachOne($column.get());
 465
 466        // increment the index of all columns after this one that was just inserted
 467        if (columnsAfter.length) {
 468          for (var i = 0, len = columnsAfter.length; i < len; i++) {
 469            columnsAfter[i].index += 1;
 470          }
 471        }
 472
 473        if (!skipUpdate) {
 474          obj.dom.update();
 475        }
 476      }
 477
 478      return dt;
 479    };
 480
 481    this.remove = function(columnIndexOrId) {
 482      var columns = settings.table.columns,
 483          length = columns.length;
 484
 485      if (typeof(columnIndexOrId) === "number") {
 486        var column = columns[columnIndexOrId];
 487        this.removeFromTable(column.id);
 488        this.removeFromArray(columnIndexOrId);
 489      } else {
 490        // Traverse columns array in reverse order so that subsequent indices
 491        // don't get messed up when we delete an item from the array in an iteration
 492        for (var i = columns.length - 1; i >= 0; i--) {
 493          var column = columns[i];
 494
 495          if (column.id === columnIndexOrId) {
 496            this.removeFromTable(columnIndexOrId);
 497            this.removeFromArray(i);
 498          }
 499        }
 500      }
 501
 502      obj.dom.update();
 503    };
 504
 505    this.removeFromTable = function(columnId) {
 506      obj.$element.find(settings.table.headRowSelector).children('[data-dynatable-column="' + columnId + '"]').first()
 507        .remove();
 508    };
 509
 510    this.removeFromArray = function(index) {
 511      var columns = settings.table.columns,
 512          adjustColumns;
 513      columns.splice(index, 1);
 514      adjustColumns = columns.slice(index, columns.length);
 515      for (var i = 0, len = adjustColumns.length; i < len; i++) {
 516        adjustColumns[i].index -= 1;
 517      }
 518    };
 519
 520    this.generate = function($cell) {
 521      var cell = $cell === undefined ? $('<th></th>') : $cell;
 522      return this.attachGeneratedAttributes(cell);
 523    };
 524
 525    this.attachGeneratedAttributes = function($cell) {
 526      // Use increment to create unique column name that is the same each time the page is reloaded,
 527      // in order to avoid errors with mismatched attribute names when loading cached `dataset.records` array
 528      var increment = obj.$element.find(settings.table.headRowSelector).children('th[data-dynatable-generated]').length;
 529      return $cell
 530        .attr('data-dynatable-column', 'dynatable-generated-' + increment) //+ utility.randomHash(),
 531        .attr('data-dynatable-no-sort', 'true')
 532        .attr('data-dynatable-generated', increment);
 533    };
 534  };
 535
 536  function Records(obj, settings) {
 537    var _this = this;
 538
 539    this.initOnLoad = function() {
 540      return !settings.dataset.ajax;
 541    };
 542
 543    this.init = function() {
 544      if (settings.dataset.records === null) {
 545        settings.dataset.records = this.getFromTable();
 546
 547        if (!settings.dataset.queryRecordCount) {
 548          settings.dataset.queryRecordCount = this.count();
 549        }
 550
 551        if (!settings.dataset.totalRecordCount){
 552          settings.dataset.totalRecordCount = settings.dataset.queryRecordCount;
 553        }
 554      }
 555
 556      // Create cache of original full recordset (unpaginated and unqueried)
 557      settings.dataset.originalRecords = $.extend(true, [], settings.dataset.records);
 558    };
 559
 560    // merge ajax response json with cached data including
 561    // meta-data and records
 562    this.updateFromJson = function(data) {
 563      var records;
 564      if (settings.params.records === "_root") {
 565        records = data;
 566      } else if (settings.params.records in data) {
 567        records = data[settings.params.records];
 568      }
 569      if (settings.params.record) {
 570        var len = records.length - 1;
 571        for (var i = 0; i < len; i++) {
 572          records[i] = records[i][settings.params.record];
 573        }
 574      }
 575      if (settings.params.queryRecordCount in data) {
 576        settings.dataset.queryRecordCount = data[settings.params.queryRecordCount];
 577      }
 578      if (settings.params.totalRecordCount in data) {
 579        settings.dataset.totalRecordCount = data[settings.params.totalRecordCount];
 580      }
 581      settings.dataset.records = records;
 582    };
 583
 584    // For really advanced sorting,
 585    // see http://james.padolsey.com/javascript/sorting-elements-with-jquery/
 586    this.sort = function() {
 587      var sort = [].sort,
 588          sorts = settings.dataset.sorts,
 589          sortsKeys = settings.dataset.sortsKeys,
 590          sortTypes = settings.dataset.sortTypes;
 591
 592      var sortFunction = function(a, b) {
 593        var comparison;
 594        if ($.isEmptyObject(sorts)) {
 595          comparison = obj.sorts.functions['originalPlacement'](a, b);
 596        } else {
 597          for (var i = 0, len = sortsKeys.length; i < len; i++) {
 598            var attr = sortsKeys[i],
 599                direction = sorts[attr],
 600                sortType = sortTypes[attr] || obj.sorts.guessType(a, b, attr);
 601            comparison = obj.sorts.functions[sortType](a, b, attr, direction);
 602            // Don't need to sort any further unless this sort is a tie between a and b,
 603            // so break the for loop unless tied
 604            if (comparison !== 0) { break; }
 605          }
 606        }
 607        return comparison;
 608      }
 609
 610      return sort.call(settings.dataset.records, sortFunction);
 611    };
 612
 613    this.paginate = function() {
 614      var bounds = this.pageBounds(),
 615          first = bounds[0], last = bounds[1];
 616      settings.dataset.records = settings.dataset.records.slice(first, last);
 617    };
 618
 619    this.resetOriginal = function() {
 620      settings.dataset.records = settings.dataset.originalRecords || [];
 621    };
 622
 623    this.pageBounds = function() {
 624      var page = settings.dataset.page || 1,
 625          first = (page - 1) * settings.dataset.perPage,
 626          last = Math.min(first + settings.dataset.perPage, settings.dataset.queryRecordCount);
 627      return [first,last];
 628    };
 629
 630    // get initial recordset to populate table
 631    // if ajax, call ajaxUrl
 632    // otherwise, initialize from in-table records
 633    this.getFromTable = function() {
 634      var records = [],
 635          columns = settings.table.columns,
 636          tableRecords = obj.$element.find(settings.table.bodyRowSelector);
 637
 638      tableRecords.each(function(index){
 639        var record = {};
 640        record['dynatable-original-index'] = index;
 641        $(this).find('th,td').each(function(index) {
 642          if (columns[index] === undefined) {
 643            // Header cell didn't exist for this column, so let's generate and append
 644            // a new header cell with a randomly generated name (so we can store and
 645            // retrieve the contents of this column for each record)
 646            obj.domColumns.add(obj.domColumns.generate(), columns.length, false, true); // don't skipAppend, do skipUpdate
 647          }
 648          var value = columns[index].attributeReader(this, record),
 649              attr = columns[index].id;
 650
 651          // If value from table is HTML, let's get and cache the text equivalent for
 652          // the default string sorting, since it rarely makes sense for sort headers
 653          // to sort based on HTML tags.
 654          if (typeof(value) === "string" && value.match(/\s*\<.+\>/)) {
 655            if (! record['dynatable-sortable-text']) {
 656              record['dynatable-sortable-text'] = {};
 657            }
 658            record['dynatable-sortable-text'][attr] = $.trim($('<div></div>').html(value).text());
 659          }
 660
 661          record[attr] = value;
 662        });
 663        // Allow configuration function which alters record based on attributes of
 664        // table row (e.g. from html5 data- attributes)
 665        if (typeof(settings.readers._rowReader) === "function") {
 666          settings.readers._rowReader(index, this, record);
 667        }
 668        records.push(record);
 669      });
 670      return records; // 1st row is header
 671    };
 672
 673    // count records from table
 674    this.count = function() {
 675      return settings.dataset.records.length;
 676    };
 677  };
 678
 679  function RecordsCount(obj, settings) {
 680    this.initOnLoad = function() {
 681      return settings.features.recordCount;
 682    };
 683
 684    this.init = function() {
 685      this.attach();
 686    };
 687
 688    this.create = function() {
 689      var recordsShown = obj.records.count(),
 690          recordsQueryCount = settings.dataset.queryRecordCount,
 691          recordsTotal = settings.dataset.totalRecordCount,
 692          text = settings.inputs.recordCountText,
 693          collection_name = settings.params.records;
 694
 695      if (recordsShown < recordsQueryCount && settings.features.paginate) {
 696        var bounds = obj.records.pageBounds();
 697        text += "<span class='dynatable-record-bounds'>" + (bounds[0] + 1) + " to " + bounds[1] + "</span> of ";
 698      } else if (recordsShown === recordsQueryCount && settings.features.paginate) {
 699        text += recordsShown + " of ";
 700      }
 701      text += recordsQueryCount + " " + collection_name;
 702      if (recordsQueryCount < recordsTotal) {
 703        text += " (filtered from " + recordsTotal + " total records)";
 704      }
 705
 706      return $('<span></span>', {
 707                id: 'dynatable-record-count-' + obj.element.id,
 708                'class': 'dynatable-record-count',
 709                html: text
 710              });
 711    };
 712
 713    this.attach = function() {
 714      var $target = settings.inputs.recordCountTarget ? $(settings.inputs.recordCountTarget) : obj.$element;
 715      $target[settings.inputs.recordCountPlacement](this.create());
 716    };
 717  };
 718
 719  function ProcessingIndicator(obj, settings) {
 720    this.init = function() {
 721      this.attach();
 722    };
 723
 724    this.create = function() {
 725      var $processing = $('<div></div>', {
 726            html: '<span>' + settings.inputs.processingText + '</span>',
 727            id: 'dynatable-processing-' + obj.element.id,
 728            'class': 'dynatable-processing',
 729            style: 'position: absolute; display: none;'
 730          });
 731
 732      return $processing;
 733    };
 734
 735    this.position = function() {
 736      var $processing = $('#dynatable-processing-' + obj.element.id),
 737          $span = $processing.children('span'),
 738          spanHeight = $span.outerHeight(),
 739          spanWidth = $span.outerWidth(),
 740          $covered = obj.$element,
 741          offset = $covered.offset(),
 742          height = $covered.outerHeight(), width = $covered.outerWidth();
 743
 744      $processing
 745        .offset({left: offset.left, top: offset.top})
 746        .width(width)
 747        .height(height)
 748      $span
 749        .offset({left: offset.left + ( (width - spanWidth) / 2 ), top: offset.top + ( (height - spanHeight) / 2 )});
 750
 751      return $processing;
 752    };
 753
 754    this.attach = function() {
 755      obj.$element.before(this.create());
 756    };
 757
 758    this.show = function() {
 759      $('#dynatable-processing-' + obj.element.id).show();
 760      this.position();
 761    };
 762
 763    this.hide = function() {
 764      $('#dynatable-processing-' + obj.element.id).hide();
 765    };
 766  };
 767
 768  function State(obj, settings) {
 769    this.initOnLoad = function() {
 770      // Check if pushState option is true, and if browser supports it
 771      return settings.features.pushState && history.pushState;
 772    };
 773
 774    this.init = function() {
 775      window.onpopstate = function(event) {
 776        if (event.state && event.state.dynatable) {
 777          obj.state.pop(event);
 778        }
 779      }
 780    };
 781
 782    this.push = function(data) {
 783      var urlString = window.location.search,
 784          urlOptions,
 785          path,
 786          params,
 787          hash,
 788          newParams,
 789          cacheStr,
 790          cache,
 791          // replaceState on initial load, then pushState after that
 792          firstPush = !(window.history.state && window.history.state.dynatable),
 793          pushFunction = firstPush ? 'replaceState' : 'pushState';
 794
 795      if (urlString && /^\?/.test(urlString)) { urlString = urlString.substring(1); }
 796      $.extend(urlOptions, data);
 797
 798      params = utility.refreshQueryString(urlString, data, settings);
 799      if (params) { params = '?' + params; }
 800      hash = window.location.hash;
 801      path = window.location.pathname;
 802
 803      obj.$element.trigger('dynatable:push', data);
 804
 805      cache = { dynatable: { dataset: settings.dataset } };
 806      if (!firstPush) { cache.dynatable.scrollTop = $(window).scrollTop(); }
 807      cacheStr = JSON.stringify(cache);
 808
 809      // Mozilla has a 640k char limit on what can be stored in pushState.
 810      // See "limit" in https://developer.mozilla.org/en/DOM/Manipulating_the_browser_history#The_pushState().C2.A0method
 811      // and "dataStr.length" in http://wine.git.sourceforge.net/git/gitweb.cgi?p=wine/wine-gecko;a=patch;h=43a11bdddc5fc1ff102278a120be66a7b90afe28
 812      //
 813      // Likewise, other browsers may have varying (undocumented) limits.
 814      // Also, Firefox's limit can be changed in about:config as browser.history.maxStateObjectSize
 815      // Since we don't know what the actual limit will be in any given situation, we'll just try caching and rescue
 816      // any exceptions by retrying pushState without caching the records.
 817      //
 818      // I have aboslutely no idea why perPageOptions suddenly becomes an array-like object instead of an array,
 819      // but just recently, this started throwing an error if I don't convert it:
 820      // 'Uncaught Error: DATA_CLONE_ERR: DOM Exception 25'
 821      cache.dynatable.dataset.perPageOptions = $.makeArray(cache.dynatable.dataset.perPageOptions);
 822
 823      try {
 824        window.history[pushFunction](cache, "Dynatable state", path + params + hash);
 825      } catch(error) {
 826        // Make cached records = null, so that `pop` will rerun process to retrieve records
 827        cache.dynatable.dataset.records = null;
 828        window.history[pushFunction](cache, "Dynatable state", path + params + hash);
 829      }
 830    };
 831
 832    this.pop = function(event) {
 833      var data = event.state.dynatable;
 834      settings.dataset = data.dataset;
 835
 836      if (data.scrollTop) { $(window).scrollTop(data.scrollTop); }
 837
 838      // If dataset.records is cached from pushState
 839      if ( data.dataset.records ) {
 840        obj.dom.update();
 841      } else {
 842        obj.process(true);
 843      }
 844    };
 845  };
 846
 847  function Sorts(obj, settings) {
 848    this.initOnLoad = function() {
 849      return settings.features.sort;
 850    };
 851
 852    this.init = function() {
 853      var sortsUrl = window.location.search.match(new RegExp(settings.params.sorts + '[^&=]*=[^&]*', 'g'));
 854      settings.dataset.sorts = sortsUrl ? utility.deserialize(sortsUrl)[settings.params.sorts] : {};
 855      settings.dataset.sortsKeys = sortsUrl ? utility.keysFromObject(settings.dataset.sorts) : [];
 856    };
 857
 858    this.add = function(attr, direction) {
 859      var sortsKeys = settings.dataset.sortsKeys,
 860          index = $.inArray(attr, sortsKeys);
 861      settings.dataset.sorts[attr] = direction;
 862      if (index === -1) { sortsKeys.push(attr); }
 863      return dt;
 864    };
 865
 866    this.remove = function(attr) {
 867      var sortsKeys = settings.dataset.sortsKeys,
 868          index = $.inArray(attr, sortsKeys);
 869      delete settings.dataset.sorts[attr];
 870      if (index !== -1) { sortsKeys.splice(index, 1); }
 871      return dt;
 872    };
 873
 874    this.clear = function() {
 875      settings.dataset.sorts = {};
 876      settings.dataset.sortsKeys.length = 0;
 877    };
 878
 879    // Try to intelligently guess which sort function to use
 880    // based on the type of attribute values.
 881    // Consider using something more robust than `typeof` (http://javascriptweblog.wordpress.com/2011/08/08/fixing-the-javascript-typeof-operator/)
 882    this.guessType = function(a, b, attr) {
 883      var types = {
 884            string: 'string',
 885            number: 'number',
 886            'boolean': 'number',
 887            object: 'number' // dates and null values are also objects, this works...
 888          },
 889          attrType = a[attr] ? typeof(a[attr]) : typeof(b[attr]),
 890          type = types[attrType] || 'number';
 891      return type;
 892    };
 893
 894    // Built-in sort functions
 895    // (the most common use-cases I could think of)
 896    this.functions = {
 897      number: function(a, b, attr, direction) {
 898        return a[attr] === b[attr] ? 0 : (direction > 0 ? a[attr] - b[attr] : b[attr] - a[attr]);
 899      },
 900      string: function(a, b, attr, direction) {
 901        var aAttr = (a['dynatable-sortable-text'] && a['dynatable-sortable-text'][attr]) ? a['dynatable-sortable-text'][attr] : a[attr],
 902            bAttr = (b['dynatable-sortable-text'] && b['dynatable-sortable-text'][attr]) ? b['dynatable-sortable-text'][attr] : b[attr],
 903            comparison;
 904        aAttr = aAttr.toLowerCase();
 905        bAttr = bAttr.toLowerCase();
 906        comparison = aAttr === bAttr ? 0 : (direction > 0 ? aAttr > bAttr : bAttr > aAttr);
 907        // force false boolean value to -1, true to 1, and tie to 0
 908        return comparison === false ? -1 : (comparison - 0);
 909      },
 910      originalPlacement: function(a, b) {
 911        return a['dynatable-original-index'] - b['dynatable-original-index'];
 912      }
 913    };
 914  };
 915
 916  // turn table headers into links which add sort to sorts array
 917  function SortsHeaders(obj, settings) {
 918    var _this = this;
 919
 920    this.initOnLoad = function() {
 921      return settings.features.sort;
 922    };
 923
 924    this.init = function() {
 925      this.attach();
 926    };
 927
 928    this.create = function(cell) {
 929      var $cell = $(cell),
 930          $link = $('<a></a>', {
 931            'class': 'dynatable-sort-header',
 932            href: '#',
 933            html: $cell.html()
 934          }),
 935          id = $cell.data('dynatable-column'),
 936          column = utility.findObjectInArray(settings.table.columns, {id: id});
 937
 938      $link.bind('click', function(e) {
 939        _this.toggleSort(e, $link, column);
 940        obj.process();
 941
 942        e.preventDefault();
 943      });
 944
 945      if (this.sortedByColumn($link, column)) {
 946        if (this.sortedByColumnValue(column) == 1) {
 947          this.appendArrowUp($link);
 948        } else {
 949          this.appendArrowDown($link);
 950        }
 951      }
 952
 953      return $link;
 954    };
 955
 956    this.removeAll = function() {
 957      obj.$element.find(settings.table.headRowSelector).children('th,td').each(function(){
 958        _this.removeAllArrows();
 959        _this.removeOne(this);
 960      });
 961    };
 962
 963    this.removeOne = function(cell) {
 964      var $cell = $(cell),
 965          $link = $cell.find('.dynatable-sort-header');
 966      if ($link.length) {
 967        var html = $link.html();
 968        $link.remove();
 969        $cell.html($cell.html() + html);
 970      }
 971    };
 972
 973    this.attach = function() {
 974      obj.$element.find(settings.table.headRowSelector).children('th,td').each(function(){
 975        _this.attachOne(this);
 976      });
 977    };
 978
 979    this.attachOne = function(cell) {
 980      var $cell = $(cell);
 981      if (!$cell.data('dynatable-no-sort')) {
 982        $cell.html(this.create(cell));
 983      }
 984    };
 985
 986    this.appendArrowUp = function($link) {
 987      this.removeArrow($link);
 988      $link.append("<span class='dynatable-arrow'> &#9650;</span>");
 989    };
 990
 991    this.appendArrowDown = function($link) {
 992      this.removeArrow($link);
 993      $link.append("<span class='dynatable-arrow'> &#9660;</span>");
 994    };
 995
 996    this.removeArrow = function($link) {
 997      // Not sure why `parent()` is needed, the arrow should be inside the link from `append()` above
 998      $link.find('.dynatable-arrow').remove();
 999    };
1000
1001    this.removeAllArrows = function() {
1002      obj.$element.find('.dynatable-arrow').remove();
1003    };
1004
1005    this.toggleSort = function(e, $link, column) {
1006      var sortedByColumn = this.sortedByColumn($link, column),
1007          value = this.sortedByColumnValue(column);
1008      // Clear existing sorts unless this is a multisort event
1009      if (!settings.inputs.multisort || !utility.anyMatch(e, settings.inputs.multisort, function(evt, key) { return e[key]; })) {
1010        this.removeAllArrows();
1011        obj.sorts.clear();
1012      }
1013
1014      // If sorts for this column are already set
1015      if (sortedByColumn) {
1016        // If ascending, then make descending
1017        if (value == 1) {
1018          for (var i = 0, len = column.sorts.length; i < len; i++) {
1019            obj.sorts.add(column.sorts[i], -1);
1020          }
1021          this.appendArrowDown($link);
1022        // If descending, remove sort
1023        } else {
1024          for (var i = 0, len = column.sorts.length; i < len; i++) {
1025            obj.sorts.remove(column.sorts[i]);
1026          }
1027          this.removeArrow($link);
1028        }
1029      // Otherwise, if not already set, set to ascending
1030      } else {
1031        for (var i = 0, len = column.sorts.length; i < len; i++) {
1032          obj.sorts.add(column.sorts[i], 1);
1033        }
1034        this.appendArrowUp($link);
1035      }
1036    };
1037
1038    this.sortedByColumn = function($link, column) {
1039      return utility.allMatch(settings.dataset.sorts, column.sorts, function(sorts, sort) { return sort in sorts; });
1040    };
1041
1042    this.sortedByColumnValue = function(column) {
1043      return settings.dataset.sorts[column.sorts[0]];
1044    };
1045  };
1046
1047  function Queries(obj, settings) {
1048    var _this = this;
1049
1050    this.initOnLoad = function() {
1051      return settings.inputs.queries || settings.features.search;
1052    };
1053
1054    this.init = function() {
1055      var queriesUrl = window.location.search.match(new RegExp(settings.params.queries + '[^&=]*=[^&]*', 'g'));
1056
1057      settings.dataset.queries = queriesUrl ? utility.deserialize(queriesUrl)[settings.params.queries] : {};
1058      if (settings.dataset.queries === "") { settings.dataset.queries = {}; }
1059
1060      if (settings.inputs.queries) {
1061        this.setupInputs();
1062      }
1063    };
1064
1065    this.add = function(name, value) {
1066      // reset to first page since query will change records
1067      if (settings.features.paginate) {
1068        settings.dataset.page = 1;
1069      }
1070      settings.dataset.queries[name] = value;
1071      return dt;
1072    };
1073
1074    this.remove = function(name) {
1075      delete settings.dataset.queries[name];
1076      return dt;
1077    };
1078
1079    this.run = function() {
1080      for (query in settings.dataset.queries) {
1081        if (settings.dataset.queries.hasOwnProperty(query)) {
1082          var value = settings.dataset.queries[query];
1083          if (_this.functions[query] === undefined) {
1084            // Try to lazily evaluate query from column names if not explictly defined
1085            var queryColumn = utility.findObjectInArray(settings.table.columns, {id: query});
1086            if (queryColumn) {
1087              _this.functions[query] = function(record, queryValue) {
1088                return record[query] == queryValue;
1089              };
1090            } else {
1091              $.error("Query named '" + query + "' called, but not defined in queries.functions");
1092              continue; // to skip to next query
1093            }
1094          }
1095          // collect all records that return true for query
1096          settings.dataset.records = $.map(settings.dataset.records, function(record) {
1097            return _this.functions[query](record, value) ? record : null;
1098          });
1099        }
1100      }
1101      settings.dataset.queryRecordCount = obj.records.count();
1102    };
1103
1104    // Shortcut for performing simple query from built-in search
1105    this.runSearch = function(q) {
1106      var origQueries = $.extend({}, settings.dataset.queries);
1107      if (q) {
1108        this.add('search', q);
1109      } else {
1110        this.remove('search');
1111      }
1112      if (!utility.objectsEqual(settings.dataset.queries, origQueries)) {
1113        obj.process();
1114      }
1115    };
1116
1117    this.setupInputs = function() {
1118      settings.inputs.queries.each(function() {
1119        var $this = $(this),
1120            event = $this.data('dynatable-query-event') || settings.inputs.queryEvent,
1121            query = $this.data('dynatable-query') || $this.attr('name') || this.id,
1122            queryFunction = function(e) {
1123              var q = $(this).val();
1124              if (q === "") { q = undefined; }
1125              if (q === settings.dataset.queries[query]) { return false; }
1126              if (q) {
1127                _this.add(query, q);
1128              } else {
1129                _this.remove(query);
1130              }
1131              obj.process();
1132              e.preventDefault();
1133            };
1134
1135        $this
1136          .attr('data-dynatable-query', query)
1137          .bind(event, queryFunction)
1138          .bind('keypress', function(e) {
1139            if (e.which == 13) {
1140              queryFunction.call(this, e);
1141            }
1142          });
1143
1144        if (settings.dataset.queries[query]) { $this.val(decodeURIComponent(settings.dataset.queries[query])); }
1145      });
1146    };
1147
1148    // Query functions for in-page querying
1149    // each function should take a record and a value as input
1150    // and output true of false as to whether the record is a match or not
1151    this.functions = {
1152      search: function(record, queryValue) {
1153        var contains = false;
1154        // Loop through each attribute of record
1155        for (attr in record) {
1156          if (record.hasOwnProperty(attr)) {
1157            var attrValue = record[attr];
1158            if (typeof(attrValue) === "string" && attrValue.toLowerCase().indexOf(queryValue.toLowerCase()) !== -1) {
1159              contains = true;
1160              // Don't need to keep searching attributes once found
1161              break;
1162            } else {
1163              continue;
1164            }
1165          }
1166        }
1167        return contains;
1168      }
1169    };
1170  };
1171
1172  function InputsSearch(obj, settings) {
1173    var _this = this;
1174
1175    this.initOnLoad = function() {
1176      return settings.features.search;
1177    };
1178
1179    this.init = function() {
1180      this.attach();
1181    };
1182
1183    this.create = function() {
1184      var $search = $('<input />', {
1185            type: 'search',
1186            id: 'dynatable-query-search-' + obj.element.id,
1187            value: settings.dataset.queries.search
1188          }),
1189          $searchSpan = $('<span></span>', {
1190            id: 'dynatable-search-' + obj.element.id,
1191            'class': 'dynatable-search',
1192            text: 'Search: '
1193          }).append($search);
1194
1195      $search
1196        .bind(settings.inputs.queryEvent, function() {
1197          obj.queries.runSearch($(this).val());
1198        })
1199        .bind('keypress', function(e) {
1200          if (e.which == 13) {
1201            obj.queries.runSearch($(this).val());
1202            e.preventDefault();
1203          }
1204        });
1205      return $searchSpan;
1206    };
1207
1208    this.attach = function() {
1209      var $target = settings.inputs.searchTarget ? $(settings.inputs.searchTarget) : obj.$element;
1210      $target[settings.inputs.searchPlacement](this.create());
1211    };
1212  };
1213
1214  // provide a public function for selecting page
1215  function PaginationPage(obj, settings) {
1216    this.initOnLoad = function() {
1217      return settings.features.paginate;
1218    };
1219
1220    this.init = function() {
1221      var pageUrl = window.location.search.match(new RegExp(settings.params.page + '=([^&]*)'));
1222      // If page is present in URL parameters and pushState is enabled
1223      // (meaning that it'd be possible for dynatable to have put the
1224      // page parameter in the URL)
1225      if (pageUrl && settings.features.pushState) {
1226        this.set(pageUrl[1]);
1227      } else {
1228        this.set(1);
1229      }
1230    };
1231
1232    this.set = function(page) {
1233      settings.dataset.page = parseInt(page, 10);
1234    }
1235  };
1236
1237  function PaginationPerPage(obj, settings) {
1238    var _this = this;
1239
1240    this.initOnLoad = function() {
1241      return settings.features.paginate;
1242    };
1243
1244    this.init = function() {
1245      var perPageUrl = window.location.search.match(new RegExp(settings.params.perPage + '=([^&]*)'));
1246
1247      // If perPage is present in URL parameters and pushState is enabled
1248      // (meaning that it'd be possible for dynatable to have put the
1249      // perPage parameter in the URL)
1250      if (perPageUrl && settings.features.pushState) {
1251        // Don't reset page to 1 on init, since it might override page
1252        // set on init from URL
1253        this.set(perPageUrl[1], true);
1254      } else {
1255        this.set(settings.dataset.perPageDefault, true);
1256      }
1257
1258      if (settings.features.perPageSelect) {
1259        this.attach();
1260      }
1261    };
1262
1263    this.create = function() {
1264      var $select = $('<select>', {
1265            id: 'dynatable-per-page-' + obj.element.id,
1266            'class': 'dynatable-per-page-select'
1267          });
1268
1269      for (var i = 0, len = settings.dataset.perPageOptions.length; i < len; i++) {
1270        var number = settings.dataset.perPageOptions[i],
1271            selected = settings.dataset.perPage == number ? 'selected="selected"' : '';
1272        $select.append('<option value="' + number + '" ' + selected + '>' + number + '</option>');
1273      }
1274
1275      $select.bind('change', function(e) {
1276        _this.set($(this).val());
1277        obj.process();
1278      });
1279
1280      return $('<span />', {
1281        'class': 'dynatable-per-page'
1282      }).append("<span class='dynatable-per-page-label'>" + settings.inputs.perPageText + "</span>").append($select);
1283    };
1284
1285    this.attach = function() {
1286      var $target = settings.inputs.perPageTarget ? $(settings.inputs.perPageTarget) : obj.$element;
1287      $target[settings.inputs.perPagePlacement](this.create());
1288    };
1289
1290    this.set = function(number, skipResetPage) {
1291      if (!skipResetPage) { obj.paginationPage.set(1); }
1292      settings.dataset.perPage = parseInt(number);
1293    };
1294  };
1295
1296  // pagination links which update dataset.page attribute
1297  function PaginationLinks(obj, settings) {
1298    var _this = this;
1299
1300    this.initOnLoad = function() {
1301      return settings.features.paginate;
1302    };
1303
1304    this.init = function() {
1305      this.attach();
1306    };
1307
1308    this.create = function() {
1309      var $pageLinks = $('<ul></ul>', {
1310            id: 'dynatable-pagination-links-' + obj.element.id,
1311            'class': 'dynatable-pagination-links',
1312            html: '<span>Pages: </span>'
1313          }),
1314          pageLinkClass = 'dynatable-page-link',
1315          activePageClass = 'dynatable-active-page',
1316          pages = Math.ceil(settings.dataset.queryRecordCount / settings.dataset.perPage),
1317          page = settings.dataset.page,
1318          breaks = [
1319            settings.inputs.paginationGap[0],
1320            settings.dataset.page - settings.inputs.paginationGap[1],
1321            settings.dataset.page + settings.inputs.paginationGap[2],
1322            (pages + 1) - settings.inputs.paginationGap[3]
1323          ],
1324          $link;
1325
1326      for (var i = 1; i <= pages; i++) {
1327        if ( (i > breaks[0] && i < breaks[1]) || (i > breaks[2] && i < breaks[3])) {
1328          // skip to next iteration in loop
1329          continue;
1330        } else {
1331          $link = $('<a></a>',{
1332            html: i,
1333            'class': pageLinkClass,
1334            'data-dynatable-page': i
1335          }).appendTo($pageLinks);
1336
1337          if (page == i) { $link.addClass(activePageClass); }
1338
1339          // If i is not between one of the following
1340          // (1 + (settings.paginationGap[0]))
1341          // (page - settings.paginationGap[1])
1342          // (page + settings.paginationGap[2])
1343          // (pages - settings.paginationGap[3])
1344          var breakIndex = $.inArray(i, breaks),
1345              nextBreak = breaks[breakIndex + 1];
1346          if (breakIndex > 0 && i !== 1 && nextBreak && nextBreak > (i + 1)) {
1347            var $ellip = $('<span class="dynatable-page-break">&hellip;</span>');
1348            $link = breakIndex < 2 ? $link.before($ellip) : $link.after($ellip);
1349          }
1350
1351        }
1352
1353        if (settings.inputs.paginationPrev && i === 1) {
1354          var $prevLink = $('<a></a>',{
1355            html: settings.inputs.paginationPrev,
1356            'class': pageLinkClass + ' dynatable-page-prev',
1357            'data-dynatable-page': page - 1
1358          });
1359          if (page === 1) { $prevLink.addClass(activePageClass); }
1360          $link = $link.before($prevLink);
1361        }
1362        if (settings.inputs.paginationNext && i === pages) {
1363          var $nextLink = $('<a></a>',{
1364            html: settings.inputs.paginationNext,
1365            'class': pageLinkClass + ' dynatable-page-next',
1366            'data-dynatable-page': page + 1
1367          });
1368          if (page === pages) { $nextLink.addClass(activePageClass); }
1369          $link = $link.after($nextLink);
1370        }
1371      }
1372
1373      $pageLinks.children().wrap('<li></li>');
1374
1375      // only bind page handler to non-active pages
1376      var selector = '#dynatable-pagination-links-' + obj.element.id + ' .' + pageLinkClass + ':not(.' + activePageClass + ')';
1377      // kill any existing delegated-bindings so they don't stack up
1378      $(document).undelegate(selector, 'click.dynatable');
1379      $(document).delegate(selector, 'click.dynatable', function(e) {
1380        $this = $(this);
1381        $this.closest('.dynatable-pagination-links').find('.' + activePageClass).removeClass(activePageClass);
1382        $this.addClass(activePageClass);
1383
1384        obj.paginationPage.set($this.data('dynatable-page'));
1385        obj.process();
1386        e.preventDefault();
1387      });
1388
1389      return $pageLinks;
1390    };
1391
1392    this.attach = function() {
1393      // append page liks *after* delegate-event-binding so it doesn't need to
1394      // find and select all page links to bind event
1395      var $target = settings.inputs.paginationLinkTarget ? $(settings.inputs.paginationLinkTarget) : obj.$element;
1396      $target[settings.inputs.paginationLinkPlacement](obj.paginationLinks.create());
1397    };
1398  };
1399
1400  utility = {
1401    normalizeText: function(text, style) {
1402      text = this.textTransform[style](text);
1403      return text;
1404    },
1405    textTransform: {
1406      trimDash: function(text) {
1407        return text.replace(/^\s+|\s+$/g, "").replace(/\s+/g, "-");
1408      },
1409      camelCase: function(text) {
1410        text = this.trimDash(text);
1411        return text
1412          .replace(/(\-[a-zA-Z])/g, function($1){return $1.toUpperCase().replace('-','');})
1413          .replace(/([A-Z])([A-Z]+)/g, function($1,$2,$3){return $2 + $3.toLowerCase();})
1414          .replace(/^[A-Z]/, function($1){return $1.toLowerCase();});
1415      },
1416      dashed: function(text) {
1417        text = this.trimDash(text);
1418        return this.lowercase(text);
1419      },
1420      underscore: function(text) {
1421        text = this.trimDash(text);
1422        return this.lowercase(text.replace(/(-)/g, '_'));
1423      },
1424      lowercase: function(text) {
1425        return text.replace(/([A-Z])/g, function($1){return $1.toLowerCase();});
1426      }
1427    },
1428    // Deserialize params in URL to object
1429    // see http://stackoverflow.com/questions/1131630/javascript-jquery-param-inverse-function/3401265#3401265
1430    deserialize: function(query) {
1431      if (!query) return {};
1432      // modified to accept an array of partial URL strings
1433      if (typeof(query) === "object") { query = query.join('&'); }
1434
1435      var hash = {},
1436          vars = query.split("&");
1437
1438      for (var i = 0; i < vars.length; i++) {
1439        var pair = vars[i].split("="),
1440            k = decodeURIComponent(pair[0]),
1441            v, m;
1442
1443        if (!pair[1]) { continue };
1444        v = decodeURIComponent(pair[1].replace(/\+/g, ' '));
1445
1446        // modified to parse multi-level parameters (e.g. "hi[there][dude]=whatsup" => hi: {there: {dude: "whatsup"}})
1447        while (m = k.match(/([^&=]+)\[([^&=]+)\]$/)) {
1448          var origV = v;
1449          k = m[1];
1450          v = {};
1451
1452          // If nested param ends in '][', then the regex above erroneously included half of a trailing '[]',
1453          // which indicates the end-value is part of an array
1454          if (m[2].substr(m[2].length-2) == '][') { // must use substr for IE to understand it
1455            v[m[2].substr(0,m[2].length-2)] = [origV];
1456          } else {
1457            v[m[2]] = origV;
1458          }
1459        }
1460
1461        // If it is the first entry with this name
1462        if (typeof hash[k] === "undefined") {
1463          if (k.substr(k.length-2) != '[]') { // not end with []. cannot use negative index as IE doesn't understand it
1464            hash[k] = v;
1465          } else {
1466            hash[k] = [v];
1467          }
1468        // If subsequent entry with this name and not array
1469        } else if (typeof hash[k] === "string") {
1470          hash[k] = v;  // replace it
1471        // modified to add support for objects
1472        } else if (typeof hash[k] === "object") {
1473          hash[k] = $.extend({}, hash[k], v);
1474        // If subsequent entry with this name and is array
1475        } else {
1476          hash[k].push(v);
1477        }
1478      }
1479      return hash;
1480    },
1481    refreshQueryString: function(urlString, data, settings) {
1482      var _this = this,
1483          queryString = urlString.split('?'),
1484          path = queryString.shift(),
1485          urlOptions;
1486
1487      urlOptions = this.deserialize(urlString);
1488
1489      // Loop through each dynatable param and update the URL with it
1490      for (attr in settings.params) {
1491        if (settings.params.hasOwnProperty(attr)) {
1492          var label = settings.params[attr];
1493          // Skip over parameters matching attributes for disabled features (i.e. leave them untouched),
1494          // because if the feature is turned off, then parameter name is a coincidence and it's unrelated to dynatable.
1495          if (
1496            (!settings.features.sort && attr == "sorts") ||
1497              (!settings.features.paginate && _this.anyMatch(attr, ["page", "perPage", "offset"], function(attr, param) { return attr == param; }))
1498          ) {
1499            continue;
1500          }
1501
1502          // Delete page and offset from url params if on page 1 (default)
1503          if ((attr === "page" || attr === "offset") && data["page"] === 1) {
1504            if (urlOptions[label]) {
1505              delete urlOptions[label];
1506            }
1507            continue;
1508          }
1509
1510          // Delete perPage from url params if default perPage value
1511          if (attr === "perPage" && data[label] == settings.dataset.perPageDefault) {
1512            if (urlOptions[label]) {
1513              delete urlOptions[label];
1514            }
1515            continue;
1516          }
1517
1518          // For queries, we're going to handle each possible query parameter individually here instead of
1519          // handling the entire queries object below, since we need to make sure that this is a query controlled by dynatable.
1520          if (attr == "queries" && data[label]) {
1521            var queries = settings.inputs.queries || [],
1522                inputQueries = $.makeArray(queries.map(function() { return $(this).attr('name') }));
1523            for (var i = 0, len = inputQueries.length; i < len; i++) {
1524              var attr = inputQueries[i];
1525              if (data[label][attr]) {
1526                if (typeof urlOptions[label] === 'undefined') { urlOptions[label] = {}; }
1527                urlOptions[label][attr] = data[label][attr];
1528              } else {
1529                delete urlOptions[label][attr];
1530              }
1531            }
1532            continue;
1533          }
1534
1535          // If we havne't returned true by now, then we actually want to update the parameter in the URL
1536          if (data[label]) {
1537            urlOptions[label] = data[label];
1538          } else {
1539            delete urlOptions[label];
1540          }
1541        }
1542      }
1543      return decodeURI($.param(urlOptions));
1544    },
1545    // Get array of keys from object
1546    // see http://stackoverflow.com/questions/208016/how-to-list-the-properties-of-a-javascript-object/208020#208020
1547    keysFromObject: function(obj){
1548      var keys = [];
1549      for (var key in obj){
1550        keys.push(key);
1551      }
1552      return keys;
1553    },
1554    // Find an object in an array of objects by attributes.
1555    // E.g. find object with {id: 'hi', name: 'there'} in an array of objects
1556    findObjectInArray: function(array, objectAttr) {
1557      var _this = this,
1558          foundObject;
1559      for (var i = 0, len = array.length; i < len; i++) {
1560        var item = array[i];
1561        // For each object in array, test to make sure all attributes in objectAttr match
1562        if (_this.allMatch(item, objectAttr, function(item, key, value) { return item[key] == value; })) {
1563          foundObject = item;
1564          break;
1565        }
1566      }
1567      return foundObject;
1568    },
1569    // Return true if supplied test function passes for ALL items in an array
1570    allMatch: function(item, arrayOrObject, test) {
1571      // start off with true result by default
1572      var match = true,
1573          isArray = $.isArray(arrayOrObject);
1574      // Loop through all items in array
1575      $.each(arrayOrObject, function(key, value) {
1576        var result = isArray ? test(item, value) : test(item, key, value);
1577        // If a single item tests false, go ahead and break the array by returning false
1578        // and return false as result,
1579        // otherwise, continue with next iteration in loop
1580        // (if we make it through all iterations without overriding match with false,
1581        // then we can return the true result we started with by default)
1582        if (!result) { return match = false; }
1583      });
1584      return match;
1585    },
1586    // Return true if supplied test function passes for ANY items in an array
1587    anyMatch: function(item, arrayOrObject, test) {
1588      var match = false,
1589          isArray = $.isArray(arrayOrObject);
1590
1591      $.each(arrayOrObject, function(key, value) {
1592        var result = isArray ? test(item, value) : test(item, key, value);
1593        if (result) {
1594          // As soon as a match is found, set match to true, and return false to stop the `$.each` loop
1595          match = true;
1596          return false;
1597        }
1598      });
1599      return match;
1600    },
1601    // Return true if two objects are equal
1602    // (i.e. have the same attributes and attribute values)
1603    objectsEqual: function(a, b) {
1604      for (attr in a) {
1605        if (a.hasOwnProperty(attr)) {
1606          if (!b.hasOwnProperty(attr) || a[attr] !== b[attr]) {
1607            return false;
1608          }
1609        }
1610      }
1611      for (attr in b) {
1612        if (b.hasOwnProperty(attr) && !a.hasOwnProperty(attr)) {
1613          return false;
1614        }
1615      }
1616      return true;
1617    },
1618    // Taken from http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/105074#105074
1619    randomHash: function() {
1620      return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
1621    }
1622  };
1623
1624  //-----------------------------------------------------------------
1625  // Build the dynatable plugin
1626  //-----------------------------------------------------------------
1627
1628  // Object.create support test, and fallback for browsers without it
1629  if ( typeof Object.create !== "function" ) {
1630    Object.create = function (o) {
1631      function F() {}
1632      F.prototype = o;
1633      return new F();
1634    };
1635  }
1636
1637  //-----------------------------------------------------------------
1638  // Global dynatable plugin setting defaults
1639  //-----------------------------------------------------------------
1640
1641  $.dynatableSetup = function(options) {
1642    defaults = mergeSettings(options);
1643  };
1644
1645  // Create dynatable plugin based on a defined object
1646  $.dynatable = function( object ) {
1647    $.fn['dynatable'] = function( options ) {
1648      return this.each(function() {
1649        if ( ! $.data( this, 'dynatable' ) ) {
1650          $.data( this, 'dynatable', Object.create(object).init(this, options) );
1651        }
1652      });
1653    };
1654  };
1655
1656  $.dynatable(dt);
1657
1658})(jQuery);