Commit de5e15b

bryfry <bryon.fryer@gmail.com>
2013-12-18 00:22:00
start dynatables
1 parent 8a64f00
audit/1387332439.147935.86003
@@ -0,0 +1,1 @@
+{"transferid": "1387332439.147935.86003", "username": "user", "warnings": ["xxxx", "xxxx", "xxxx"], "error": false, "filenames": ["3.txt", "1.txt", "2.txt"]}
\ No newline at end of file
audit/1387343428.412962.14809
@@ -0,0 +1,1 @@
+{"transferid": "1387343428.412962.14809", "username": "userb", "warnings": ["xxxx", "xxxx"], "error": false, "filenames": ["out.gif", "KH8fCJb.jpg"]}
\ No newline at end of file
static/jquery.dynatable.css
@@ -0,0 +1,48 @@
+th a {
+  color: #fff;
+}
+th a:hover {
+  color: #fff;
+  text-decoration: underline;
+}
+
+.dynatable-search {
+  float: right;
+  margin-bottom: 10px;
+}
+
+.dynatable-pagination-links {
+  float: right;
+}
+
+.dynatable-record-count {
+  display: block;
+  padding: 5px 0;
+}
+
+.dynatable-pagination-links span,
+.dynatable-pagination-links li {
+  display: inline-block;
+}
+
+.dynatable-page-link,
+.dynatable-page-break {
+  display: block;
+  padding: 5px 7px;
+}
+
+.dynatable-page-link {
+  cursor: pointer;
+}
+
+.dynatable-active-page {
+  background: #71AF5A;
+  border-radius: 5px;
+  color: #fff;
+}
+.dynatable-page-prev.dynatable-active-page,
+.dynatable-page-next.dynatable-active-page {
+  background: none;
+  color: #999;
+  cursor: text;
+}
static/jquery.dynatable.js
@@ -0,0 +1,1658 @@
+/*
+ * jQuery Dynatable plugin 0.2.2
+ *
+ * Copyright (c) 2013 Steve Schwartz (JangoSteve)
+ *
+ * Dual licensed under the MIT and GPL licenses:
+ *   http://www.opensource.org/licenses/mit-license.php
+ *   http://www.gnu.org/licenses/gpl.html
+ *
+ * Date: Tue Aug 13 12:50:00 2013 -0500
+ */
+//
+
+(function($) {
+  var defaults,
+      mergeSettings,
+      dt,
+      Model,
+      modelPrototypes = {
+        dom: Dom,
+        domColumns: DomColumns,
+        records: Records,
+        recordsCount: RecordsCount,
+        processingIndicator: ProcessingIndicator,
+        state: State,
+        sorts: Sorts,
+        sortsHeaders: SortsHeaders,
+        queries: Queries,
+        inputsSearch: InputsSearch,
+        paginationPage: PaginationPage,
+        paginationPerPage: PaginationPerPage,
+        paginationLinks: PaginationLinks
+      },
+      utility,
+      build,
+      processAll,
+      initModel,
+      defaultRowWriter,
+      defaultCellWriter,
+      defaultAttributeWriter,
+      defaultAttributeReader;
+
+  //-----------------------------------------------------------------
+  // Cached plugin global defaults
+  //-----------------------------------------------------------------
+
+  defaults = {
+    features: {
+      paginate: true,
+      sort: true,
+      pushState: true,
+      search: true,
+      recordCount: true,
+      perPageSelect: true
+    },
+    table: {
+      defaultColumnIdStyle: 'camelCase',
+      columns: null,
+      headRowSelector: 'thead tr', // or e.g. tr:first-child
+      bodyRowSelector: 'tbody tr',
+      headRowClass: null
+    },
+    inputs: {
+      queries: null,
+      sorts: null,
+      multisort: ['ctrlKey', 'shiftKey', 'metaKey'],
+      page: null,
+      queryEvent: 'blur change',
+      recordCountTarget: null,
+      recordCountPlacement: 'after',
+      paginationLinkTarget: null,
+      paginationLinkPlacement: 'after',
+      paginationPrev: 'Previous',
+      paginationNext: 'Next',
+      paginationGap: [1,2,2,1],
+      searchTarget: null,
+      searchPlacement: 'before',
+      perPageTarget: null,
+      perPagePlacement: 'before',
+      perPageText: 'Show: ',
+      recordCountText: 'Showing ',
+      processingText: 'Processing...'
+    },
+    dataset: {
+      ajax: false,
+      ajaxUrl: null,
+      ajaxCache: null,
+      ajaxOnLoad: false,
+      ajaxMethod: 'GET',
+      ajaxDataType: 'json',
+      totalRecordCount: null,
+      queries: {},
+      queryRecordCount: null,
+      page: null,
+      perPageDefault: 10,
+      perPageOptions: [10,20,50,100],
+      sorts: {},
+      sortsKeys: null,
+      sortTypes: {},
+      records: null
+    },
+    writers: {
+      _rowWriter: defaultRowWriter,
+      _cellWriter: defaultCellWriter,
+      _attributeWriter: defaultAttributeWriter
+    },
+    readers: {
+      _rowReader: null,
+      _attributeReader: defaultAttributeReader
+    },
+    params: {
+      dynatable: 'dynatable',
+      queries: 'queries',
+      sorts: 'sorts',
+      page: 'page',
+      perPage: 'perPage',
+      offset: 'offset',
+      records: 'records',
+      record: null,
+      queryRecordCount: 'queryRecordCount',
+      totalRecordCount: 'totalRecordCount'
+    }
+  };
+
+  //-----------------------------------------------------------------
+  // Each dynatable instance inherits from this,
+  // set properties specific to instance
+  //-----------------------------------------------------------------
+
+  dt = {
+    init: function(element, options) {
+      this.settings = mergeSettings(options);
+      this.element = element;
+      this.$element = $(element);
+
+      // All the setup that doesn't require element or options
+      build.call(this);
+
+      return this;
+    },
+
+    process: function(skipPushState) {
+      processAll.call(this, skipPushState);
+    }
+  };
+
+  //-----------------------------------------------------------------
+  // Cached plugin global functions
+  //-----------------------------------------------------------------
+
+  mergeSettings = function(options) {
+    var newOptions = $.extend(true, {}, defaults, options);
+
+    // TODO: figure out a better way to do this.
+    // Doing `extend(true)` causes any elements that are arrays
+    // to merge the default and options arrays instead of overriding the defaults.
+    if (options) {
+      if (options.inputs) {
+        if (options.inputs.multisort) {
+          newOptions.inputs.multisort = options.inputs.multisort;
+        }
+        if (options.inputs.paginationGap) {
+          newOptions.inputs.paginationGap = options.inputs.paginationGap;
+        }
+      }
+      if (options.dataset && options.dataset.perPageOptions) {
+        newOptions.dataset.perPageOptions = options.dataset.perPageOptions;
+      }
+    }
+
+    return newOptions;
+  };
+
+  build = function() {
+    for (model in modelPrototypes) {
+      if (modelPrototypes.hasOwnProperty(model)) {
+        var modelInstance = this[model] = new modelPrototypes[model](this, this.settings);
+        if (modelInstance.initOnLoad()) {
+          modelInstance.init();
+        }
+      }
+    }
+
+    this.$element.trigger('dynatable:init', this);
+
+    if (!this.settings.dataset.ajax || (this.settings.dataset.ajax && this.settings.dataset.ajaxOnLoad) || this.settings.features.paginate) {
+      this.process();
+    }
+  };
+
+  processAll = function(skipPushState) {
+    var data = {};
+
+    this.$element.trigger('dynatable:beforeProcess', data);
+
+    if (!$.isEmptyObject(this.settings.dataset.queries)) { data[this.settings.params.queries] = this.settings.dataset.queries; }
+    // TODO: Wrap this in a try/rescue block to hide the processing indicator and indicate something went wrong if error
+    this.processingIndicator.show();
+
+    if (this.settings.features.sort && !$.isEmptyObject(this.settings.dataset.sorts)) { data[this.settings.params.sorts] = this.settings.dataset.sorts; }
+    if (this.settings.features.paginate && this.settings.dataset.page) {
+      var page = this.settings.dataset.page,
+          perPage = this.settings.dataset.perPage;
+      data[this.settings.params.page] = page;
+      data[this.settings.params.perPage] = perPage;
+      data[this.settings.params.offset] = (page - 1) * perPage;
+    }
+    if (this.settings.dataset.ajaxData) { $.extend(data, this.settings.dataset.ajaxData); }
+
+    // If ajax, sends query to ajaxUrl with queries and sorts serialized and appended in ajax data
+    // otherwise, executes queries and sorts on in-page data
+    if (this.settings.dataset.ajax) {
+      var _this = this;
+      var options = {
+        type: _this.settings.dataset.ajaxMethod,
+        dataType: _this.settings.dataset.ajaxDataType,
+        data: data,
+        error: function(xhr, error) {
+        },
+        success: function(response) {
+          _this.$element.trigger('dynatable:ajax:success', response);
+          // Merge ajax results and meta-data into dynatables cached data
+          _this.records.updateFromJson(response);
+          // update table with new records
+          _this.dom.update();
+
+          if (!skipPushState && _this.state.initOnLoad()) {
+            _this.state.push(data);
+          }
+        },
+        complete: function() {
+          _this.processingIndicator.hide();
+        }
+      };
+      // Do not pass url to `ajax` options if blank
+      if (this.settings.dataset.ajaxUrl) {
+        options.url = this.settings.dataset.ajaxUrl;
+
+      // If ajaxUrl is blank, then we're using the current page URL,
+      // we need to strip out any query, sort, or page data controlled by dynatable
+      // that may have been in URL when page loaded, so that it doesn't conflict with
+      // what's passed in with the data ajax parameter
+      } else {
+        options.url = utility.refreshQueryString(window.location.href, {}, this.settings);
+      }
+      if (this.settings.dataset.ajaxCache !== null) { options.cache = this.settings.dataset.ajaxCache; }
+
+      $.ajax(options);
+    } else {
+      this.records.resetOriginal();
+      this.queries.run();
+      if (this.settings.features.sort) {
+        this.records.sort();
+      }
+      if (this.settings.features.paginate) {
+        this.records.paginate();
+      }
+      this.dom.update();
+      this.processingIndicator.hide();
+
+      if (!skipPushState && this.state.initOnLoad()) {
+        this.state.push(data);
+      }
+    }
+    this.$element.trigger('dynatable:afterProcess', data);
+  };
+
+  function defaultRowWriter(rowIndex, record, columns, cellWriter) {
+    var tr = '';
+
+    // grab the record's attribute for each column
+    for (var i = 0, len = columns.length; i < len; i++) {
+      tr += cellWriter(columns[i], record);
+    }
+
+    return '<tr>' + tr + '</tr>';
+  };
+
+  function defaultCellWriter(column, record) {
+    var html = column.attributeWriter(record),
+        td = '<td';
+
+    // keep cells for hidden column headers hidden
+    if (column.hidden) {
+      td += ' display="none"';
+    }
+
+    // keep cells aligned as their column headers are aligned
+    if (column.textAlign) {
+      td += ' style="text-align: ' + column.textAlign + ';"';
+    }
+
+    return td + '>' + html + '</td>';
+  };
+
+  function defaultAttributeWriter(record) {
+    // `this` is the column object in settings.columns
+    // TODO: automatically convert common types, such as arrays and objects, to string
+    return record[this.id];
+  };
+
+  function defaultAttributeReader(cell, record) {
+    return $(cell).html();
+  };
+
+  //-----------------------------------------------------------------
+  // Dynatable object model prototype
+  // (all object models get these default functions)
+  //-----------------------------------------------------------------
+
+  Model = {
+    initOnLoad: function() {
+      return true;
+    },
+
+    init: function() {}
+  };
+
+  for (model in modelPrototypes) {
+    if (modelPrototypes.hasOwnProperty(model)) {
+      var modelPrototype = modelPrototypes[model];
+      modelPrototype.prototype = Model;
+    }
+  }
+
+  //-----------------------------------------------------------------
+  // Dynatable object models
+  //-----------------------------------------------------------------
+
+  function Dom(obj, settings) {
+    var _this = this;
+
+    // update table contents with new records array
+    // from query (whether ajax or not)
+    this.update = function() {
+      var rows = '',
+          columns = settings.table.columns,
+          rowWriter = settings.writers._rowWriter,
+          cellWriter = settings.writers._cellWriter;
+
+      obj.$element.trigger('dynatable:beforeUpdate', rows);
+
+      // loop through records
+      for (var i = 0, len = settings.dataset.records.length; i < len; i++) {
+        var record = settings.dataset.records[i],
+            tr = rowWriter(i, record, columns, cellWriter);
+        rows += tr;
+      }
+
+      // Appended dynatable interactive elements
+      if (settings.features.recordCount) {
+        $('#dynatable-record-count-' + obj.element.id).replaceWith(obj.recordsCount.create());
+      }
+      if (settings.features.paginate) {
+        $('#dynatable-pagination-links-' + obj.element.id).replaceWith(obj.paginationLinks.create());
+        if (settings.features.perPageSelect) {
+          $('#dynatable-per-page-' + obj.element.id).val(parseInt(settings.dataset.perPage));
+        }
+      }
+
+      // Sort headers functionality
+      if (settings.features.sort && columns) {
+        obj.sortsHeaders.removeAllArrows();
+        for (var i = 0, len = columns.length; i < len; i++) {
+          var column = columns[i],
+              sortedByColumn = utility.allMatch(settings.dataset.sorts, column.sorts, function(sorts, sort) { return sort in sorts; }),
+              value = settings.dataset.sorts[column.sorts[0]];
+
+          if (sortedByColumn) {
+            obj.$element.find('[data-dynatable-column="' + column.id + '"]').find('.dynatable-sort-header').each(function(){
+              if (value == 1) {
+                obj.sortsHeaders.appendArrowUp($(this));
+              } else {
+                obj.sortsHeaders.appendArrowDown($(this));
+              }
+            });
+          }
+        }
+      }
+
+      // Query search functionality
+      if (settings.inputs.queries) {
+        settings.inputs.queries.each(function() {
+          var $this = $(this),
+              q = settings.dataset.queries[$this.data('dynatable-query')];
+          $(this).val(q || '');
+        });
+      }
+      obj.$element.find(settings.table.bodyRowSelector).remove();
+      obj.$element.append(rows);
+
+      obj.$element.trigger('dynatable:afterUpdate', rows);
+    };
+  };
+
+  function DomColumns(obj, settings) {
+    var _this = this;
+
+    this.initOnLoad = function() {
+      return obj.$element.is('table');
+    };
+
+    this.init = function() {
+      settings.table.columns = [];
+      this.getFromTable();
+    };
+
+    // initialize table[columns] array
+    this.getFromTable = function() {
+      var $columns = obj.$element.find(settings.table.headRowSelector).children('th,td');
+      if ($columns.length) {
+        $columns.each(function(index){
+          _this.add($(this), index, true);
+        });
+      } else {
+        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.");
+      }
+    };
+
+    this.add = function($column, position, skipAppend, skipUpdate) {
+      var columns = settings.table.columns,
+          label = $column.text(),
+          id = $column.data('dynatable-column') || utility.normalizeText(label, settings.table.defaultColumnIdStyle),
+          dataSorts = $column.data('dynatable-sorts'),
+          sorts = dataSorts ? $.map(dataSorts.split(','), function(text) { return $.trim(text); }) : [id];
+
+      // If the column id is blank, generate an id for it
+      if ( !id ) {
+        this.generate($column);
+        id = $column.data('dynatable-column');
+      }
+      // Add column data to plugin instance
+      columns.splice(position, 0, {
+        index: position,
+        label: label,
+        id: id,
+        attributeWriter: settings.writers[id] || settings.writers._attributeWriter,
+        attributeReader: settings.readers[id] || settings.readers._attributeReader,
+        sorts: sorts,
+        hidden: $column.css('display') === 'none',
+        textAlign: $column.css('text-align')
+      });
+
+      // Modify header cell
+      $column
+        .attr('data-dynatable-column', id)
+        .addClass('dynatable-head');
+      if (settings.table.headRowClass) { $column.addClass(settings.table.headRowClass); }
+
+      // Append column header to table
+      if (!skipAppend) {
+        var domPosition = position + 1,
+            $sibling = obj.$element.find(settings.table.headRowSelector)
+              .children('th:nth-child(' + domPosition + '),td:nth-child(' + domPosition + ')').first(),
+            columnsAfter = columns.slice(position + 1, columns.length);
+
+        if ($sibling.length) {
+          $sibling.before($column);
+        // sibling column doesn't yet exist (maybe this is the last column in the header row)
+        } else {
+          obj.$element.find(settings.table.headRowSelector).append($column);
+        }
+
+        obj.sortsHeaders.attachOne($column.get());
+
+        // increment the index of all columns after this one that was just inserted
+        if (columnsAfter.length) {
+          for (var i = 0, len = columnsAfter.length; i < len; i++) {
+            columnsAfter[i].index += 1;
+          }
+        }
+
+        if (!skipUpdate) {
+          obj.dom.update();
+        }
+      }
+
+      return dt;
+    };
+
+    this.remove = function(columnIndexOrId) {
+      var columns = settings.table.columns,
+          length = columns.length;
+
+      if (typeof(columnIndexOrId) === "number") {
+        var column = columns[columnIndexOrId];
+        this.removeFromTable(column.id);
+        this.removeFromArray(columnIndexOrId);
+      } else {
+        // Traverse columns array in reverse order so that subsequent indices
+        // don't get messed up when we delete an item from the array in an iteration
+        for (var i = columns.length - 1; i >= 0; i--) {
+          var column = columns[i];
+
+          if (column.id === columnIndexOrId) {
+            this.removeFromTable(columnIndexOrId);
+            this.removeFromArray(i);
+          }
+        }
+      }
+
+      obj.dom.update();
+    };
+
+    this.removeFromTable = function(columnId) {
+      obj.$element.find(settings.table.headRowSelector).children('[data-dynatable-column="' + columnId + '"]').first()
+        .remove();
+    };
+
+    this.removeFromArray = function(index) {
+      var columns = settings.table.columns,
+          adjustColumns;
+      columns.splice(index, 1);
+      adjustColumns = columns.slice(index, columns.length);
+      for (var i = 0, len = adjustColumns.length; i < len; i++) {
+        adjustColumns[i].index -= 1;
+      }
+    };
+
+    this.generate = function($cell) {
+      var cell = $cell === undefined ? $('<th></th>') : $cell;
+      return this.attachGeneratedAttributes(cell);
+    };
+
+    this.attachGeneratedAttributes = function($cell) {
+      // Use increment to create unique column name that is the same each time the page is reloaded,
+      // in order to avoid errors with mismatched attribute names when loading cached `dataset.records` array
+      var increment = obj.$element.find(settings.table.headRowSelector).children('th[data-dynatable-generated]').length;
+      return $cell
+        .attr('data-dynatable-column', 'dynatable-generated-' + increment) //+ utility.randomHash(),
+        .attr('data-dynatable-no-sort', 'true')
+        .attr('data-dynatable-generated', increment);
+    };
+  };
+
+  function Records(obj, settings) {
+    var _this = this;
+
+    this.initOnLoad = function() {
+      return !settings.dataset.ajax;
+    };
+
+    this.init = function() {
+      if (settings.dataset.records === null) {
+        settings.dataset.records = this.getFromTable();
+
+        if (!settings.dataset.queryRecordCount) {
+          settings.dataset.queryRecordCount = this.count();
+        }
+
+        if (!settings.dataset.totalRecordCount){
+          settings.dataset.totalRecordCount = settings.dataset.queryRecordCount;
+        }
+      }
+
+      // Create cache of original full recordset (unpaginated and unqueried)
+      settings.dataset.originalRecords = $.extend(true, [], settings.dataset.records);
+    };
+
+    // merge ajax response json with cached data including
+    // meta-data and records
+    this.updateFromJson = function(data) {
+      var records;
+      if (settings.params.records === "_root") {
+        records = data;
+      } else if (settings.params.records in data) {
+        records = data[settings.params.records];
+      }
+      if (settings.params.record) {
+        var len = records.length - 1;
+        for (var i = 0; i < len; i++) {
+          records[i] = records[i][settings.params.record];
+        }
+      }
+      if (settings.params.queryRecordCount in data) {
+        settings.dataset.queryRecordCount = data[settings.params.queryRecordCount];
+      }
+      if (settings.params.totalRecordCount in data) {
+        settings.dataset.totalRecordCount = data[settings.params.totalRecordCount];
+      }
+      settings.dataset.records = records;
+    };
+
+    // For really advanced sorting,
+    // see http://james.padolsey.com/javascript/sorting-elements-with-jquery/
+    this.sort = function() {
+      var sort = [].sort,
+          sorts = settings.dataset.sorts,
+          sortsKeys = settings.dataset.sortsKeys,
+          sortTypes = settings.dataset.sortTypes;
+
+      var sortFunction = function(a, b) {
+        var comparison;
+        if ($.isEmptyObject(sorts)) {
+          comparison = obj.sorts.functions['originalPlacement'](a, b);
+        } else {
+          for (var i = 0, len = sortsKeys.length; i < len; i++) {
+            var attr = sortsKeys[i],
+                direction = sorts[attr],
+                sortType = sortTypes[attr] || obj.sorts.guessType(a, b, attr);
+            comparison = obj.sorts.functions[sortType](a, b, attr, direction);
+            // Don't need to sort any further unless this sort is a tie between a and b,
+            // so break the for loop unless tied
+            if (comparison !== 0) { break; }
+          }
+        }
+        return comparison;
+      }
+
+      return sort.call(settings.dataset.records, sortFunction);
+    };
+
+    this.paginate = function() {
+      var bounds = this.pageBounds(),
+          first = bounds[0], last = bounds[1];
+      settings.dataset.records = settings.dataset.records.slice(first, last);
+    };
+
+    this.resetOriginal = function() {
+      settings.dataset.records = settings.dataset.originalRecords || [];
+    };
+
+    this.pageBounds = function() {
+      var page = settings.dataset.page || 1,
+          first = (page - 1) * settings.dataset.perPage,
+          last = Math.min(first + settings.dataset.perPage, settings.dataset.queryRecordCount);
+      return [first,last];
+    };
+
+    // get initial recordset to populate table
+    // if ajax, call ajaxUrl
+    // otherwise, initialize from in-table records
+    this.getFromTable = function() {
+      var records = [],
+          columns = settings.table.columns,
+          tableRecords = obj.$element.find(settings.table.bodyRowSelector);
+
+      tableRecords.each(function(index){
+        var record = {};
+        record['dynatable-original-index'] = index;
+        $(this).find('th,td').each(function(index) {
+          if (columns[index] === undefined) {
+            // Header cell didn't exist for this column, so let's generate and append
+            // a new header cell with a randomly generated name (so we can store and
+            // retrieve the contents of this column for each record)
+            obj.domColumns.add(obj.domColumns.generate(), columns.length, false, true); // don't skipAppend, do skipUpdate
+          }
+          var value = columns[index].attributeReader(this, record),
+              attr = columns[index].id;
+
+          // If value from table is HTML, let's get and cache the text equivalent for
+          // the default string sorting, since it rarely makes sense for sort headers
+          // to sort based on HTML tags.
+          if (typeof(value) === "string" && value.match(/\s*\<.+\>/)) {
+            if (! record['dynatable-sortable-text']) {
+              record['dynatable-sortable-text'] = {};
+            }
+            record['dynatable-sortable-text'][attr] = $.trim($('<div></div>').html(value).text());
+          }
+
+          record[attr] = value;
+        });
+        // Allow configuration function which alters record based on attributes of
+        // table row (e.g. from html5 data- attributes)
+        if (typeof(settings.readers._rowReader) === "function") {
+          settings.readers._rowReader(index, this, record);
+        }
+        records.push(record);
+      });
+      return records; // 1st row is header
+    };
+
+    // count records from table
+    this.count = function() {
+      return settings.dataset.records.length;
+    };
+  };
+
+  function RecordsCount(obj, settings) {
+    this.initOnLoad = function() {
+      return settings.features.recordCount;
+    };
+
+    this.init = function() {
+      this.attach();
+    };
+
+    this.create = function() {
+      var recordsShown = obj.records.count(),
+          recordsQueryCount = settings.dataset.queryRecordCount,
+          recordsTotal = settings.dataset.totalRecordCount,
+          text = settings.inputs.recordCountText,
+          collection_name = settings.params.records;
+
+      if (recordsShown < recordsQueryCount && settings.features.paginate) {
+        var bounds = obj.records.pageBounds();
+        text += "<span class='dynatable-record-bounds'>" + (bounds[0] + 1) + " to " + bounds[1] + "</span> of ";
+      } else if (recordsShown === recordsQueryCount && settings.features.paginate) {
+        text += recordsShown + " of ";
+      }
+      text += recordsQueryCount + " " + collection_name;
+      if (recordsQueryCount < recordsTotal) {
+        text += " (filtered from " + recordsTotal + " total records)";
+      }
+
+      return $('<span></span>', {
+                id: 'dynatable-record-count-' + obj.element.id,
+                'class': 'dynatable-record-count',
+                html: text
+              });
+    };
+
+    this.attach = function() {
+      var $target = settings.inputs.recordCountTarget ? $(settings.inputs.recordCountTarget) : obj.$element;
+      $target[settings.inputs.recordCountPlacement](this.create());
+    };
+  };
+
+  function ProcessingIndicator(obj, settings) {
+    this.init = function() {
+      this.attach();
+    };
+
+    this.create = function() {
+      var $processing = $('<div></div>', {
+            html: '<span>' + settings.inputs.processingText + '</span>',
+            id: 'dynatable-processing-' + obj.element.id,
+            'class': 'dynatable-processing',
+            style: 'position: absolute; display: none;'
+          });
+
+      return $processing;
+    };
+
+    this.position = function() {
+      var $processing = $('#dynatable-processing-' + obj.element.id),
+          $span = $processing.children('span'),
+          spanHeight = $span.outerHeight(),
+          spanWidth = $span.outerWidth(),
+          $covered = obj.$element,
+          offset = $covered.offset(),
+          height = $covered.outerHeight(), width = $covered.outerWidth();
+
+      $processing
+        .offset({left: offset.left, top: offset.top})
+        .width(width)
+        .height(height)
+      $span
+        .offset({left: offset.left + ( (width - spanWidth) / 2 ), top: offset.top + ( (height - spanHeight) / 2 )});
+
+      return $processing;
+    };
+
+    this.attach = function() {
+      obj.$element.before(this.create());
+    };
+
+    this.show = function() {
+      $('#dynatable-processing-' + obj.element.id).show();
+      this.position();
+    };
+
+    this.hide = function() {
+      $('#dynatable-processing-' + obj.element.id).hide();
+    };
+  };
+
+  function State(obj, settings) {
+    this.initOnLoad = function() {
+      // Check if pushState option is true, and if browser supports it
+      return settings.features.pushState && history.pushState;
+    };
+
+    this.init = function() {
+      window.onpopstate = function(event) {
+        if (event.state && event.state.dynatable) {
+          obj.state.pop(event);
+        }
+      }
+    };
+
+    this.push = function(data) {
+      var urlString = window.location.search,
+          urlOptions,
+          path,
+          params,
+          hash,
+          newParams,
+          cacheStr,
+          cache,
+          // replaceState on initial load, then pushState after that
+          firstPush = !(window.history.state && window.history.state.dynatable),
+          pushFunction = firstPush ? 'replaceState' : 'pushState';
+
+      if (urlString && /^\?/.test(urlString)) { urlString = urlString.substring(1); }
+      $.extend(urlOptions, data);
+
+      params = utility.refreshQueryString(urlString, data, settings);
+      if (params) { params = '?' + params; }
+      hash = window.location.hash;
+      path = window.location.pathname;
+
+      obj.$element.trigger('dynatable:push', data);
+
+      cache = { dynatable: { dataset: settings.dataset } };
+      if (!firstPush) { cache.dynatable.scrollTop = $(window).scrollTop(); }
+      cacheStr = JSON.stringify(cache);
+
+      // Mozilla has a 640k char limit on what can be stored in pushState.
+      // See "limit" in https://developer.mozilla.org/en/DOM/Manipulating_the_browser_history#The_pushState().C2.A0method
+      // and "dataStr.length" in http://wine.git.sourceforge.net/git/gitweb.cgi?p=wine/wine-gecko;a=patch;h=43a11bdddc5fc1ff102278a120be66a7b90afe28
+      //
+      // Likewise, other browsers may have varying (undocumented) limits.
+      // Also, Firefox's limit can be changed in about:config as browser.history.maxStateObjectSize
+      // Since we don't know what the actual limit will be in any given situation, we'll just try caching and rescue
+      // any exceptions by retrying pushState without caching the records.
+      //
+      // I have aboslutely no idea why perPageOptions suddenly becomes an array-like object instead of an array,
+      // but just recently, this started throwing an error if I don't convert it:
+      // 'Uncaught Error: DATA_CLONE_ERR: DOM Exception 25'
+      cache.dynatable.dataset.perPageOptions = $.makeArray(cache.dynatable.dataset.perPageOptions);
+
+      try {
+        window.history[pushFunction](cache, "Dynatable state", path + params + hash);
+      } catch(error) {
+        // Make cached records = null, so that `pop` will rerun process to retrieve records
+        cache.dynatable.dataset.records = null;
+        window.history[pushFunction](cache, "Dynatable state", path + params + hash);
+      }
+    };
+
+    this.pop = function(event) {
+      var data = event.state.dynatable;
+      settings.dataset = data.dataset;
+
+      if (data.scrollTop) { $(window).scrollTop(data.scrollTop); }
+
+      // If dataset.records is cached from pushState
+      if ( data.dataset.records ) {
+        obj.dom.update();
+      } else {
+        obj.process(true);
+      }
+    };
+  };
+
+  function Sorts(obj, settings) {
+    this.initOnLoad = function() {
+      return settings.features.sort;
+    };
+
+    this.init = function() {
+      var sortsUrl = window.location.search.match(new RegExp(settings.params.sorts + '[^&=]*=[^&]*', 'g'));
+      settings.dataset.sorts = sortsUrl ? utility.deserialize(sortsUrl)[settings.params.sorts] : {};
+      settings.dataset.sortsKeys = sortsUrl ? utility.keysFromObject(settings.dataset.sorts) : [];
+    };
+
+    this.add = function(attr, direction) {
+      var sortsKeys = settings.dataset.sortsKeys,
+          index = $.inArray(attr, sortsKeys);
+      settings.dataset.sorts[attr] = direction;
+      if (index === -1) { sortsKeys.push(attr); }
+      return dt;
+    };
+
+    this.remove = function(attr) {
+      var sortsKeys = settings.dataset.sortsKeys,
+          index = $.inArray(attr, sortsKeys);
+      delete settings.dataset.sorts[attr];
+      if (index !== -1) { sortsKeys.splice(index, 1); }
+      return dt;
+    };
+
+    this.clear = function() {
+      settings.dataset.sorts = {};
+      settings.dataset.sortsKeys.length = 0;
+    };
+
+    // Try to intelligently guess which sort function to use
+    // based on the type of attribute values.
+    // Consider using something more robust than `typeof` (http://javascriptweblog.wordpress.com/2011/08/08/fixing-the-javascript-typeof-operator/)
+    this.guessType = function(a, b, attr) {
+      var types = {
+            string: 'string',
+            number: 'number',
+            'boolean': 'number',
+            object: 'number' // dates and null values are also objects, this works...
+          },
+          attrType = a[attr] ? typeof(a[attr]) : typeof(b[attr]),
+          type = types[attrType] || 'number';
+      return type;
+    };
+
+    // Built-in sort functions
+    // (the most common use-cases I could think of)
+    this.functions = {
+      number: function(a, b, attr, direction) {
+        return a[attr] === b[attr] ? 0 : (direction > 0 ? a[attr] - b[attr] : b[attr] - a[attr]);
+      },
+      string: function(a, b, attr, direction) {
+        var aAttr = (a['dynatable-sortable-text'] && a['dynatable-sortable-text'][attr]) ? a['dynatable-sortable-text'][attr] : a[attr],
+            bAttr = (b['dynatable-sortable-text'] && b['dynatable-sortable-text'][attr]) ? b['dynatable-sortable-text'][attr] : b[attr],
+            comparison;
+        aAttr = aAttr.toLowerCase();
+        bAttr = bAttr.toLowerCase();
+        comparison = aAttr === bAttr ? 0 : (direction > 0 ? aAttr > bAttr : bAttr > aAttr);
+        // force false boolean value to -1, true to 1, and tie to 0
+        return comparison === false ? -1 : (comparison - 0);
+      },
+      originalPlacement: function(a, b) {
+        return a['dynatable-original-index'] - b['dynatable-original-index'];
+      }
+    };
+  };
+
+  // turn table headers into links which add sort to sorts array
+  function SortsHeaders(obj, settings) {
+    var _this = this;
+
+    this.initOnLoad = function() {
+      return settings.features.sort;
+    };
+
+    this.init = function() {
+      this.attach();
+    };
+
+    this.create = function(cell) {
+      var $cell = $(cell),
+          $link = $('<a></a>', {
+            'class': 'dynatable-sort-header',
+            href: '#',
+            html: $cell.html()
+          }),
+          id = $cell.data('dynatable-column'),
+          column = utility.findObjectInArray(settings.table.columns, {id: id});
+
+      $link.bind('click', function(e) {
+        _this.toggleSort(e, $link, column);
+        obj.process();
+
+        e.preventDefault();
+      });
+
+      if (this.sortedByColumn($link, column)) {
+        if (this.sortedByColumnValue(column) == 1) {
+          this.appendArrowUp($link);
+        } else {
+          this.appendArrowDown($link);
+        }
+      }
+
+      return $link;
+    };
+
+    this.removeAll = function() {
+      obj.$element.find(settings.table.headRowSelector).children('th,td').each(function(){
+        _this.removeAllArrows();
+        _this.removeOne(this);
+      });
+    };
+
+    this.removeOne = function(cell) {
+      var $cell = $(cell),
+          $link = $cell.find('.dynatable-sort-header');
+      if ($link.length) {
+        var html = $link.html();
+        $link.remove();
+        $cell.html($cell.html() + html);
+      }
+    };
+
+    this.attach = function() {
+      obj.$element.find(settings.table.headRowSelector).children('th,td').each(function(){
+        _this.attachOne(this);
+      });
+    };
+
+    this.attachOne = function(cell) {
+      var $cell = $(cell);
+      if (!$cell.data('dynatable-no-sort')) {
+        $cell.html(this.create(cell));
+      }
+    };
+
+    this.appendArrowUp = function($link) {
+      this.removeArrow($link);
+      $link.append("<span class='dynatable-arrow'> &#9650;</span>");
+    };
+
+    this.appendArrowDown = function($link) {
+      this.removeArrow($link);
+      $link.append("<span class='dynatable-arrow'> &#9660;</span>");
+    };
+
+    this.removeArrow = function($link) {
+      // Not sure why `parent()` is needed, the arrow should be inside the link from `append()` above
+      $link.find('.dynatable-arrow').remove();
+    };
+
+    this.removeAllArrows = function() {
+      obj.$element.find('.dynatable-arrow').remove();
+    };
+
+    this.toggleSort = function(e, $link, column) {
+      var sortedByColumn = this.sortedByColumn($link, column),
+          value = this.sortedByColumnValue(column);
+      // Clear existing sorts unless this is a multisort event
+      if (!settings.inputs.multisort || !utility.anyMatch(e, settings.inputs.multisort, function(evt, key) { return e[key]; })) {
+        this.removeAllArrows();
+        obj.sorts.clear();
+      }
+
+      // If sorts for this column are already set
+      if (sortedByColumn) {
+        // If ascending, then make descending
+        if (value == 1) {
+          for (var i = 0, len = column.sorts.length; i < len; i++) {
+            obj.sorts.add(column.sorts[i], -1);
+          }
+          this.appendArrowDown($link);
+        // If descending, remove sort
+        } else {
+          for (var i = 0, len = column.sorts.length; i < len; i++) {
+            obj.sorts.remove(column.sorts[i]);
+          }
+          this.removeArrow($link);
+        }
+      // Otherwise, if not already set, set to ascending
+      } else {
+        for (var i = 0, len = column.sorts.length; i < len; i++) {
+          obj.sorts.add(column.sorts[i], 1);
+        }
+        this.appendArrowUp($link);
+      }
+    };
+
+    this.sortedByColumn = function($link, column) {
+      return utility.allMatch(settings.dataset.sorts, column.sorts, function(sorts, sort) { return sort in sorts; });
+    };
+
+    this.sortedByColumnValue = function(column) {
+      return settings.dataset.sorts[column.sorts[0]];
+    };
+  };
+
+  function Queries(obj, settings) {
+    var _this = this;
+
+    this.initOnLoad = function() {
+      return settings.inputs.queries || settings.features.search;
+    };
+
+    this.init = function() {
+      var queriesUrl = window.location.search.match(new RegExp(settings.params.queries + '[^&=]*=[^&]*', 'g'));
+
+      settings.dataset.queries = queriesUrl ? utility.deserialize(queriesUrl)[settings.params.queries] : {};
+      if (settings.dataset.queries === "") { settings.dataset.queries = {}; }
+
+      if (settings.inputs.queries) {
+        this.setupInputs();
+      }
+    };
+
+    this.add = function(name, value) {
+      // reset to first page since query will change records
+      if (settings.features.paginate) {
+        settings.dataset.page = 1;
+      }
+      settings.dataset.queries[name] = value;
+      return dt;
+    };
+
+    this.remove = function(name) {
+      delete settings.dataset.queries[name];
+      return dt;
+    };
+
+    this.run = function() {
+      for (query in settings.dataset.queries) {
+        if (settings.dataset.queries.hasOwnProperty(query)) {
+          var value = settings.dataset.queries[query];
+          if (_this.functions[query] === undefined) {
+            // Try to lazily evaluate query from column names if not explictly defined
+            var queryColumn = utility.findObjectInArray(settings.table.columns, {id: query});
+            if (queryColumn) {
+              _this.functions[query] = function(record, queryValue) {
+                return record[query] == queryValue;
+              };
+            } else {
+              $.error("Query named '" + query + "' called, but not defined in queries.functions");
+              continue; // to skip to next query
+            }
+          }
+          // collect all records that return true for query
+          settings.dataset.records = $.map(settings.dataset.records, function(record) {
+            return _this.functions[query](record, value) ? record : null;
+          });
+        }
+      }
+      settings.dataset.queryRecordCount = obj.records.count();
+    };
+
+    // Shortcut for performing simple query from built-in search
+    this.runSearch = function(q) {
+      var origQueries = $.extend({}, settings.dataset.queries);
+      if (q) {
+        this.add('search', q);
+      } else {
+        this.remove('search');
+      }
+      if (!utility.objectsEqual(settings.dataset.queries, origQueries)) {
+        obj.process();
+      }
+    };
+
+    this.setupInputs = function() {
+      settings.inputs.queries.each(function() {
+        var $this = $(this),
+            event = $this.data('dynatable-query-event') || settings.inputs.queryEvent,
+            query = $this.data('dynatable-query') || $this.attr('name') || this.id,
+            queryFunction = function(e) {
+              var q = $(this).val();
+              if (q === "") { q = undefined; }
+              if (q === settings.dataset.queries[query]) { return false; }
+              if (q) {
+                _this.add(query, q);
+              } else {
+                _this.remove(query);
+              }
+              obj.process();
+              e.preventDefault();
+            };
+
+        $this
+          .attr('data-dynatable-query', query)
+          .bind(event, queryFunction)
+          .bind('keypress', function(e) {
+            if (e.which == 13) {
+              queryFunction.call(this, e);
+            }
+          });
+
+        if (settings.dataset.queries[query]) { $this.val(decodeURIComponent(settings.dataset.queries[query])); }
+      });
+    };
+
+    // Query functions for in-page querying
+    // each function should take a record and a value as input
+    // and output true of false as to whether the record is a match or not
+    this.functions = {
+      search: function(record, queryValue) {
+        var contains = false;
+        // Loop through each attribute of record
+        for (attr in record) {
+          if (record.hasOwnProperty(attr)) {
+            var attrValue = record[attr];
+            if (typeof(attrValue) === "string" && attrValue.toLowerCase().indexOf(queryValue.toLowerCase()) !== -1) {
+              contains = true;
+              // Don't need to keep searching attributes once found
+              break;
+            } else {
+              continue;
+            }
+          }
+        }
+        return contains;
+      }
+    };
+  };
+
+  function InputsSearch(obj, settings) {
+    var _this = this;
+
+    this.initOnLoad = function() {
+      return settings.features.search;
+    };
+
+    this.init = function() {
+      this.attach();
+    };
+
+    this.create = function() {
+      var $search = $('<input />', {
+            type: 'search',
+            id: 'dynatable-query-search-' + obj.element.id,
+            value: settings.dataset.queries.search
+          }),
+          $searchSpan = $('<span></span>', {
+            id: 'dynatable-search-' + obj.element.id,
+            'class': 'dynatable-search',
+            text: 'Search: '
+          }).append($search);
+
+      $search
+        .bind(settings.inputs.queryEvent, function() {
+          obj.queries.runSearch($(this).val());
+        })
+        .bind('keypress', function(e) {
+          if (e.which == 13) {
+            obj.queries.runSearch($(this).val());
+            e.preventDefault();
+          }
+        });
+      return $searchSpan;
+    };
+
+    this.attach = function() {
+      var $target = settings.inputs.searchTarget ? $(settings.inputs.searchTarget) : obj.$element;
+      $target[settings.inputs.searchPlacement](this.create());
+    };
+  };
+
+  // provide a public function for selecting page
+  function PaginationPage(obj, settings) {
+    this.initOnLoad = function() {
+      return settings.features.paginate;
+    };
+
+    this.init = function() {
+      var pageUrl = window.location.search.match(new RegExp(settings.params.page + '=([^&]*)'));
+      // If page is present in URL parameters and pushState is enabled
+      // (meaning that it'd be possible for dynatable to have put the
+      // page parameter in the URL)
+      if (pageUrl && settings.features.pushState) {
+        this.set(pageUrl[1]);
+      } else {
+        this.set(1);
+      }
+    };
+
+    this.set = function(page) {
+      settings.dataset.page = parseInt(page, 10);
+    }
+  };
+
+  function PaginationPerPage(obj, settings) {
+    var _this = this;
+
+    this.initOnLoad = function() {
+      return settings.features.paginate;
+    };
+
+    this.init = function() {
+      var perPageUrl = window.location.search.match(new RegExp(settings.params.perPage + '=([^&]*)'));
+
+      // If perPage is present in URL parameters and pushState is enabled
+      // (meaning that it'd be possible for dynatable to have put the
+      // perPage parameter in the URL)
+      if (perPageUrl && settings.features.pushState) {
+        // Don't reset page to 1 on init, since it might override page
+        // set on init from URL
+        this.set(perPageUrl[1], true);
+      } else {
+        this.set(settings.dataset.perPageDefault, true);
+      }
+
+      if (settings.features.perPageSelect) {
+        this.attach();
+      }
+    };
+
+    this.create = function() {
+      var $select = $('<select>', {
+            id: 'dynatable-per-page-' + obj.element.id,
+            'class': 'dynatable-per-page-select'
+          });
+
+      for (var i = 0, len = settings.dataset.perPageOptions.length; i < len; i++) {
+        var number = settings.dataset.perPageOptions[i],
+            selected = settings.dataset.perPage == number ? 'selected="selected"' : '';
+        $select.append('<option value="' + number + '" ' + selected + '>' + number + '</option>');
+      }
+
+      $select.bind('change', function(e) {
+        _this.set($(this).val());
+        obj.process();
+      });
+
+      return $('<span />', {
+        'class': 'dynatable-per-page'
+      }).append("<span class='dynatable-per-page-label'>" + settings.inputs.perPageText + "</span>").append($select);
+    };
+
+    this.attach = function() {
+      var $target = settings.inputs.perPageTarget ? $(settings.inputs.perPageTarget) : obj.$element;
+      $target[settings.inputs.perPagePlacement](this.create());
+    };
+
+    this.set = function(number, skipResetPage) {
+      if (!skipResetPage) { obj.paginationPage.set(1); }
+      settings.dataset.perPage = parseInt(number);
+    };
+  };
+
+  // pagination links which update dataset.page attribute
+  function PaginationLinks(obj, settings) {
+    var _this = this;
+
+    this.initOnLoad = function() {
+      return settings.features.paginate;
+    };
+
+    this.init = function() {
+      this.attach();
+    };
+
+    this.create = function() {
+      var $pageLinks = $('<ul></ul>', {
+            id: 'dynatable-pagination-links-' + obj.element.id,
+            'class': 'dynatable-pagination-links',
+            html: '<span>Pages: </span>'
+          }),
+          pageLinkClass = 'dynatable-page-link',
+          activePageClass = 'dynatable-active-page',
+          pages = Math.ceil(settings.dataset.queryRecordCount / settings.dataset.perPage),
+          page = settings.dataset.page,
+          breaks = [
+            settings.inputs.paginationGap[0],
+            settings.dataset.page - settings.inputs.paginationGap[1],
+            settings.dataset.page + settings.inputs.paginationGap[2],
+            (pages + 1) - settings.inputs.paginationGap[3]
+          ],
+          $link;
+
+      for (var i = 1; i <= pages; i++) {
+        if ( (i > breaks[0] && i < breaks[1]) || (i > breaks[2] && i < breaks[3])) {
+          // skip to next iteration in loop
+          continue;
+        } else {
+          $link = $('<a></a>',{
+            html: i,
+            'class': pageLinkClass,
+            'data-dynatable-page': i
+          }).appendTo($pageLinks);
+
+          if (page == i) { $link.addClass(activePageClass); }
+
+          // If i is not between one of the following
+          // (1 + (settings.paginationGap[0]))
+          // (page - settings.paginationGap[1])
+          // (page + settings.paginationGap[2])
+          // (pages - settings.paginationGap[3])
+          var breakIndex = $.inArray(i, breaks),
+              nextBreak = breaks[breakIndex + 1];
+          if (breakIndex > 0 && i !== 1 && nextBreak && nextBreak > (i + 1)) {
+            var $ellip = $('<span class="dynatable-page-break">&hellip;</span>');
+            $link = breakIndex < 2 ? $link.before($ellip) : $link.after($ellip);
+          }
+
+        }
+
+        if (settings.inputs.paginationPrev && i === 1) {
+          var $prevLink = $('<a></a>',{
+            html: settings.inputs.paginationPrev,
+            'class': pageLinkClass + ' dynatable-page-prev',
+            'data-dynatable-page': page - 1
+          });
+          if (page === 1) { $prevLink.addClass(activePageClass); }
+          $link = $link.before($prevLink);
+        }
+        if (settings.inputs.paginationNext && i === pages) {
+          var $nextLink = $('<a></a>',{
+            html: settings.inputs.paginationNext,
+            'class': pageLinkClass + ' dynatable-page-next',
+            'data-dynatable-page': page + 1
+          });
+          if (page === pages) { $nextLink.addClass(activePageClass); }
+          $link = $link.after($nextLink);
+        }
+      }
+
+      $pageLinks.children().wrap('<li></li>');
+
+      // only bind page handler to non-active pages
+      var selector = '#dynatable-pagination-links-' + obj.element.id + ' .' + pageLinkClass + ':not(.' + activePageClass + ')';
+      // kill any existing delegated-bindings so they don't stack up
+      $(document).undelegate(selector, 'click.dynatable');
+      $(document).delegate(selector, 'click.dynatable', function(e) {
+        $this = $(this);
+        $this.closest('.dynatable-pagination-links').find('.' + activePageClass).removeClass(activePageClass);
+        $this.addClass(activePageClass);
+
+        obj.paginationPage.set($this.data('dynatable-page'));
+        obj.process();
+        e.preventDefault();
+      });
+
+      return $pageLinks;
+    };
+
+    this.attach = function() {
+      // append page liks *after* delegate-event-binding so it doesn't need to
+      // find and select all page links to bind event
+      var $target = settings.inputs.paginationLinkTarget ? $(settings.inputs.paginationLinkTarget) : obj.$element;
+      $target[settings.inputs.paginationLinkPlacement](obj.paginationLinks.create());
+    };
+  };
+
+  utility = {
+    normalizeText: function(text, style) {
+      text = this.textTransform[style](text);
+      return text;
+    },
+    textTransform: {
+      trimDash: function(text) {
+        return text.replace(/^\s+|\s+$/g, "").replace(/\s+/g, "-");
+      },
+      camelCase: function(text) {
+        text = this.trimDash(text);
+        return text
+          .replace(/(\-[a-zA-Z])/g, function($1){return $1.toUpperCase().replace('-','');})
+          .replace(/([A-Z])([A-Z]+)/g, function($1,$2,$3){return $2 + $3.toLowerCase();})
+          .replace(/^[A-Z]/, function($1){return $1.toLowerCase();});
+      },
+      dashed: function(text) {
+        text = this.trimDash(text);
+        return this.lowercase(text);
+      },
+      underscore: function(text) {
+        text = this.trimDash(text);
+        return this.lowercase(text.replace(/(-)/g, '_'));
+      },
+      lowercase: function(text) {
+        return text.replace(/([A-Z])/g, function($1){return $1.toLowerCase();});
+      }
+    },
+    // Deserialize params in URL to object
+    // see http://stackoverflow.com/questions/1131630/javascript-jquery-param-inverse-function/3401265#3401265
+    deserialize: function(query) {
+      if (!query) return {};
+      // modified to accept an array of partial URL strings
+      if (typeof(query) === "object") { query = query.join('&'); }
+
+      var hash = {},
+          vars = query.split("&");
+
+      for (var i = 0; i < vars.length; i++) {
+        var pair = vars[i].split("="),
+            k = decodeURIComponent(pair[0]),
+            v, m;
+
+        if (!pair[1]) { continue };
+        v = decodeURIComponent(pair[1].replace(/\+/g, ' '));
+
+        // modified to parse multi-level parameters (e.g. "hi[there][dude]=whatsup" => hi: {there: {dude: "whatsup"}})
+        while (m = k.match(/([^&=]+)\[([^&=]+)\]$/)) {
+          var origV = v;
+          k = m[1];
+          v = {};
+
+          // If nested param ends in '][', then the regex above erroneously included half of a trailing '[]',
+          // which indicates the end-value is part of an array
+          if (m[2].substr(m[2].length-2) == '][') { // must use substr for IE to understand it
+            v[m[2].substr(0,m[2].length-2)] = [origV];
+          } else {
+            v[m[2]] = origV;
+          }
+        }
+
+        // If it is the first entry with this name
+        if (typeof hash[k] === "undefined") {
+          if (k.substr(k.length-2) != '[]') { // not end with []. cannot use negative index as IE doesn't understand it
+            hash[k] = v;
+          } else {
+            hash[k] = [v];
+          }
+        // If subsequent entry with this name and not array
+        } else if (typeof hash[k] === "string") {
+          hash[k] = v;  // replace it
+        // modified to add support for objects
+        } else if (typeof hash[k] === "object") {
+          hash[k] = $.extend({}, hash[k], v);
+        // If subsequent entry with this name and is array
+        } else {
+          hash[k].push(v);
+        }
+      }
+      return hash;
+    },
+    refreshQueryString: function(urlString, data, settings) {
+      var _this = this,
+          queryString = urlString.split('?'),
+          path = queryString.shift(),
+          urlOptions;
+
+      urlOptions = this.deserialize(urlString);
+
+      // Loop through each dynatable param and update the URL with it
+      for (attr in settings.params) {
+        if (settings.params.hasOwnProperty(attr)) {
+          var label = settings.params[attr];
+          // Skip over parameters matching attributes for disabled features (i.e. leave them untouched),
+          // because if the feature is turned off, then parameter name is a coincidence and it's unrelated to dynatable.
+          if (
+            (!settings.features.sort && attr == "sorts") ||
+              (!settings.features.paginate && _this.anyMatch(attr, ["page", "perPage", "offset"], function(attr, param) { return attr == param; }))
+          ) {
+            continue;
+          }
+
+          // Delete page and offset from url params if on page 1 (default)
+          if ((attr === "page" || attr === "offset") && data["page"] === 1) {
+            if (urlOptions[label]) {
+              delete urlOptions[label];
+            }
+            continue;
+          }
+
+          // Delete perPage from url params if default perPage value
+          if (attr === "perPage" && data[label] == settings.dataset.perPageDefault) {
+            if (urlOptions[label]) {
+              delete urlOptions[label];
+            }
+            continue;
+          }
+
+          // For queries, we're going to handle each possible query parameter individually here instead of
+          // handling the entire queries object below, since we need to make sure that this is a query controlled by dynatable.
+          if (attr == "queries" && data[label]) {
+            var queries = settings.inputs.queries || [],
+                inputQueries = $.makeArray(queries.map(function() { return $(this).attr('name') }));
+            for (var i = 0, len = inputQueries.length; i < len; i++) {
+              var attr = inputQueries[i];
+              if (data[label][attr]) {
+                if (typeof urlOptions[label] === 'undefined') { urlOptions[label] = {}; }
+                urlOptions[label][attr] = data[label][attr];
+              } else {
+                delete urlOptions[label][attr];
+              }
+            }
+            continue;
+          }
+
+          // If we havne't returned true by now, then we actually want to update the parameter in the URL
+          if (data[label]) {
+            urlOptions[label] = data[label];
+          } else {
+            delete urlOptions[label];
+          }
+        }
+      }
+      return decodeURI($.param(urlOptions));
+    },
+    // Get array of keys from object
+    // see http://stackoverflow.com/questions/208016/how-to-list-the-properties-of-a-javascript-object/208020#208020
+    keysFromObject: function(obj){
+      var keys = [];
+      for (var key in obj){
+        keys.push(key);
+      }
+      return keys;
+    },
+    // Find an object in an array of objects by attributes.
+    // E.g. find object with {id: 'hi', name: 'there'} in an array of objects
+    findObjectInArray: function(array, objectAttr) {
+      var _this = this,
+          foundObject;
+      for (var i = 0, len = array.length; i < len; i++) {
+        var item = array[i];
+        // For each object in array, test to make sure all attributes in objectAttr match
+        if (_this.allMatch(item, objectAttr, function(item, key, value) { return item[key] == value; })) {
+          foundObject = item;
+          break;
+        }
+      }
+      return foundObject;
+    },
+    // Return true if supplied test function passes for ALL items in an array
+    allMatch: function(item, arrayOrObject, test) {
+      // start off with true result by default
+      var match = true,
+          isArray = $.isArray(arrayOrObject);
+      // Loop through all items in array
+      $.each(arrayOrObject, function(key, value) {
+        var result = isArray ? test(item, value) : test(item, key, value);
+        // If a single item tests false, go ahead and break the array by returning false
+        // and return false as result,
+        // otherwise, continue with next iteration in loop
+        // (if we make it through all iterations without overriding match with false,
+        // then we can return the true result we started with by default)
+        if (!result) { return match = false; }
+      });
+      return match;
+    },
+    // Return true if supplied test function passes for ANY items in an array
+    anyMatch: function(item, arrayOrObject, test) {
+      var match = false,
+          isArray = $.isArray(arrayOrObject);
+
+      $.each(arrayOrObject, function(key, value) {
+        var result = isArray ? test(item, value) : test(item, key, value);
+        if (result) {
+          // As soon as a match is found, set match to true, and return false to stop the `$.each` loop
+          match = true;
+          return false;
+        }
+      });
+      return match;
+    },
+    // Return true if two objects are equal
+    // (i.e. have the same attributes and attribute values)
+    objectsEqual: function(a, b) {
+      for (attr in a) {
+        if (a.hasOwnProperty(attr)) {
+          if (!b.hasOwnProperty(attr) || a[attr] !== b[attr]) {
+            return false;
+          }
+        }
+      }
+      for (attr in b) {
+        if (b.hasOwnProperty(attr) && !a.hasOwnProperty(attr)) {
+          return false;
+        }
+      }
+      return true;
+    },
+    // Taken from http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/105074#105074
+    randomHash: function() {
+      return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
+    }
+  };
+
+  //-----------------------------------------------------------------
+  // Build the dynatable plugin
+  //-----------------------------------------------------------------
+
+  // Object.create support test, and fallback for browsers without it
+  if ( typeof Object.create !== "function" ) {
+    Object.create = function (o) {
+      function F() {}
+      F.prototype = o;
+      return new F();
+    };
+  }
+
+  //-----------------------------------------------------------------
+  // Global dynatable plugin setting defaults
+  //-----------------------------------------------------------------
+
+  $.dynatableSetup = function(options) {
+    defaults = mergeSettings(options);
+  };
+
+  // Create dynatable plugin based on a defined object
+  $.dynatable = function( object ) {
+    $.fn['dynatable'] = function( options ) {
+      return this.each(function() {
+        if ( ! $.data( this, 'dynatable' ) ) {
+          $.data( this, 'dynatable', Object.create(object).init(this, options) );
+        }
+      });
+    };
+  };
+
+  $.dynatable(dt);
+
+})(jQuery);
templates/admin.html
@@ -2,6 +2,7 @@
 
 {% block css %}
 <link rel="stylesheet" href="{{ static_url("ffu.css") }}">
+<link rel="stylesheet" href="{{ static_url("jquery.dynatable.css") }}">
 {% end %}
 
 {% block content %}
@@ -9,11 +10,40 @@
   <div class="row">
     <div class="col-xs-12">
       <ul id="filelist" class="list-group">
-      {% for item in items %}
-        <li><a href="#">{{item}}</a></li>
+      {% if 'items' in globals() %} 
+        {% for item in items %}
+          <li><a href="#">{{item}}</a></li>
+        {% end %}
       {% end %}
       </ul>
+      <table id="audit-table">
+        <thead>
+          <th data-dynatable-column="transferid">Transfer ID</th>
+          <th>Username</th>
+          <th>Error</th>
+          <th>Filenames</th>
+          <th>Warnings</th>
+          <th>Actions</th>
+        </thead>
+        <tbody>
+        </tbody>
+      </table>
     </div>
   </div>
 </div><!-- /.container -->
 {% end %}
+
+{% block js %}
+<script src="{{ static_url("jquery.dynatable.js") }}"></script>
+<script>
+  $("#audit-table").dynatable({
+    dataset : {
+      ajax : true,
+      ajaxUrl: '/admin',
+      ajaxOnLoad: true,
+      defaultColumnIdStyle: 'lowercase',
+      records: []
+    }
+  });
+</script>
+{% end %}
templates/login.html
@@ -12,7 +12,7 @@
     <!--<label class="checkbox">
       <input type="checkbox" value="remember-me"> Remember me
     </label>-->
-    {% if errormsg is not None %}
+    {% if 'errormsg' in globals() %}
     <div class="alert-warning text-center bot-padding">{{ errormsg }}</div>
     {% end %}
     <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
ffu.py
@@ -4,14 +4,14 @@ import tornado.web
 #from login import login
 #from soccermove import process_file_import
 # Tornado
+from functools import wraps
+import time, random, json, os
 WORKING_DIR = os.path.dirname(os.path.abspath(__file__))
 COOKIE_SECRET = "F+ek4AWESWewGryPRbzq5aSHR1uRD0/ItZenLimCkGI="
 STATIC_DIR = os.path.join(WORKING_DIR,"static")
 TEMPLATE_DIR = os.path.join(WORKING_DIR,"templates")
 AUDIT_DIR = os.path.join(WORKING_DIR,"audit")
 
-import time, random, json, os
-
 class BaseHandler(tornado.web.RequestHandler):
   def get_login_url(self):
     return u"/login"
@@ -31,14 +31,6 @@ class BaseHandler(tornado.web.RequestHandler):
       return None
 
   def render(self, template, **kwargs):
-    if hasattr(self, 'errormsg'):
-      kwargs['errormsg'] = self.errormsg
-    else:
-      kwargs['errormsg'] = None
-    if hasattr(self, 'items'):
-      kwargs['items'] = self.items
-    else:
-      kwargs['items'] = []
     kwargs['admin'] = self.get_admin_status()
     super(BaseHandler, self).render(template, **kwargs)
 
@@ -50,6 +42,7 @@ class LoginHandler(BaseHandler):
     username = self.get_argument("username", "")
     password = self.get_argument("password", "")
     #status, error =  login(username, password)
+    error = "error"
     status = ((username=="user" or username=="userb") and password=="pass")
     if status:
       #adminstatus = error #Yes, ghetto
@@ -58,9 +51,7 @@ class LoginHandler(BaseHandler):
       self.set_admin_status(adminstatus)
       self.redirect(self.get_argument("next",u"/"))
     else:
-      #self.errormsg = error
-      self.errormsg = "error"
-      self.render("login.html", errormsg=self.errormsg)
+      self.render("login.html", errormsg=error)
 
   def set_current_user(self, user):
     if user:
@@ -111,18 +102,30 @@ class UploadHandler(BaseHandler):
     f.write(json.dumps(manifest))
     f.close()
 
+def is_admin(method):
+  @wraps(method)
+  def wrapper(self, *args, **kwargs):
+    if self.get_admin_status():
+      return method(self, *args, **kwargs)
+    else:
+      self.redirect(u"/") 
+  return wrapper
+
 class AdminHandler(BaseHandler):
   @tornado.web.authenticated
+  @is_admin
   def get(self):
-    if self.get_admin_status():
-      self.items = []
+    if self.request.headers.get('X-Requested-With') == "XMLHttpRequest":
+      items = []
       for filename in os.listdir(AUDIT_DIR):
         fout = open(os.path.join(AUDIT_DIR,filename), 'rb')
-        self.items.append(json.loads(fout.read()))
+        items.append(json.loads(fout.read()))
         fout.close()
-      self.render("admin.html", items=self.items)
+      self.write(json.dumps(items))
+      self.set_header("Content-Type", "application/json; charset=UTF-8")
+      self.finish()
     else:
-      self.redirect(u"/")
+      self.render("admin.html")
 
 handlers = [
   (r"/", UploadHandler),