master
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'> ▲</span>");
989 };
990
991 this.appendArrowDown = function($link) {
992 this.removeArrow($link);
993 $link.append("<span class='dynatable-arrow'> ▼</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">…</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);