Skip to content

Instantly share code, notes, and snippets.

@optilude
Created October 5, 2014 22:24
Show Gist options
  • Save optilude/90d6a574a3ba4186f816 to your computer and use it in GitHub Desktop.
Save optilude/90d6a574a3ba4186f816 to your computer and use it in GitHub Desktop.
define([
'jquery',
'underscore',
'app/app',
'moment',
'numeral',
'app/utils/dateutils',
'jquery.handsontable',
'bootstrap.datepicker'
], function($, _, app, moment, numeral, DateUtils, Handsontable) {
"use strict";
// XXX: Why on earth is this necessary?
if(Handsontable === undefined) {
Handsontable = window.Handsontable;
}
// General handler tracking table changes
Handsontable.PluginHooks.add('afterChange', function(changes, source) {
if(this.getSettings().allowUnload !== true && _.contains(["alter", "empty", "edit", "autofill", "paste"], source)) {
app.dirty = true;
}
});
// Helper to avoid validating blank spare rows
function entryValidator(validator) {
return function(value, callback) {
var table = this.instance,
maxRow = table.countRows() - table.getSettings().minSpareRows - 1;
if(this.row > maxRow) {
callback(true);
return;
}
validator.call(this, value, callback);
};
}
// Date editor using bootstrap.datepicker
var DateEditor = (function (Handsontable) {
var BootstrapDateEditor = Handsontable.editors.TextEditor.prototype.extend();
BootstrapDateEditor.prototype.init = function () {
var self = this;
Handsontable.editors.TextEditor.prototype.init.apply(self, arguments);
self.isCellEdited = false;
self.instance.addHook('afterDestroy', function () {
self.destroyElements();
});
};
BootstrapDateEditor.prototype.createElements = function () {
var self = this;
Handsontable.editors.TextEditor.prototype.createElements.apply(self, arguments);
self.datePickerHolder = document.createElement('DIV');
this.datePickerHolderStyle = this.datePickerHolder.style;
this.instance.view.wt.wtDom.addClass(this.datePickerHolder, 'htDatepickerHolder');
document.body.appendChild(self.datePickerHolder);
self.$datePickerHolder = $(self.datePickerHolder);
self.$datePickerHolder.datepicker({
format: app.datePickerHolderDateFormat,
weekStart: 1
}).on('changeDate', function(event) {
self.setValue(moment.utc(event.date).format(app.momentDateFormat));
self.hideDatepicker();
self.finishEditing(false);
});
// prevent recognizing clicking on date picker as clicking outside of table
self.$datePickerHolder.on('mousedown', function (event) {
event.stopPropagation();
});
self.hideDatepicker();
};
BootstrapDateEditor.prototype.destroyElements = function () {
this.$datePickerHolder.datepicker('remove');
this.$datePickerHolder.remove();
};
BootstrapDateEditor.prototype.beginEditing = function (initialValue) {
if(!initialValue && this.originalValue) {
initialValue = moment.utc(this.originalValue).format(app.momentDateFormat);
}
Handsontable.editors.TextEditor.prototype.beginEditing.call(this, initialValue);
this.showDatepicker();
};
BootstrapDateEditor.prototype.finishEditing = function (isCancelled, ctrlDown) {
this.hideDatepicker();
Handsontable.editors.TextEditor.prototype.finishEditing.apply(this, arguments);
};
BootstrapDateEditor.prototype.saveValue = function(val, ctrlDown){
for(var i = 0; i < val.length; ++i) {
for(var j = 0; j < val[i].length; ++j) {
if(_.isString(val[i][j])) {
var d = moment.utc(val[i][j], app.momentDateFormat);
if(d.isValid()) {
val[i][j] = d.toDate();
}
}
}
}
Handsontable.editors.TextEditor.prototype.saveValue.call(this, val, ctrlDown);
};
BootstrapDateEditor.prototype.showDatepicker = function () {
var $td = $(this.TD);
var offset = $td.offset();
this.datePickerHolderStyle.top = (offset.top + $td.height()) + 'px';
this.datePickerHolderStyle.left = offset.left + 'px';
var dateOptions = {
defaultDate: this.originalValue || void 0
};
$.extend(dateOptions, this.cellProperties);
this.$datePickerHolder.datepicker("option", dateOptions);
if (this.originalValue) {
this.$datePickerHolder.datepicker('setDate', moment.utc(this.originalValue).toDate());
}
this.$datePickerHolder.show();
this.$datePickerHolder.datepicker('show');
};
BootstrapDateEditor.prototype.hideDatepicker = function () {
this.$datePickerHolder.datepicker('hide');
this.$datePickerHolder.hide();
};
return BootstrapDateEditor;
})(Handsontable);
/**
* Autocomplete editor that can store a value separately to its displayed
* value. Uses a "handsontable-in-handsontable" editor.
*
* Requires the following configuration:
*
* - `source`, an array of arrays or a function that takes `query` and
* `callback` parameters like the one used by `AutocompleteEditor`.
* The data passed should be an array of objects or array of arrays,
* akin to the `data` of a Handsontable instance.
* - `handsontable`, an object with settings for the inner table. Typical
* settings include `colWidths`, `colHeaders`, `dataSchema` and
* `columns`.
* - `extractValue`, a function that will be passed a selected row from
* the `source` data and which should return the *value* to store in
* the underlying handsontable.
* - `extractTitle`, a function that will be passed a selected row from
* the `source` data and which should return the *title* to show when
* opening the editor
*
* For example:
*
* {
* data: "user",
* editor: KeyValueAutocompleteEditor
* handsontable: {
* colWidths: [140, 196],
* colHeaders: [Name", "Email"],
* dataSchema: {name: null, email: null},
* columns: [{
* data: 'name'
* }, {
* data: 'email'
* }]
* },
* extractValue: function(row) { return row.userId; },
* extractTitle: function(row) { return row.name; },
* source: function(query, callback) {
*
* if(query.length < 2) {
* return callback([]);
* }
*
* // This would more likely use an AJAX call or some other
* // mechanism to build an array of arrays of possible values
* callback([{userId: 1, name: 'User 1', email: '[email protected]'},
* {userId: 2, name: 'User 2', email: '[email protected]'}]);
*
* },
* }
*
* When a row is chosen in the editor the value returned by `extractValue()`
* is saved in the underlying handsontable, but a "hint" is also stored in
* the relevant row of the underlying handsontable data array. The hint has
* a key of `_<prop>` where <prop> is the name of the column, so in the
* example above, that would be `_user`. The hint contains the full row of
* the inner handsontable. In the example above, that would be a single
* object with keys `userId`, `name` and `email`.
*
* You can load the hint object into the data array before the editor is
* invoked for the first time to let the editor start with the correct
* value. You can also use this in a cell renderer to render the title
* rather than the data:
*
* renderer: function(instance, td, row, col, prop, value, cellProperties) {
* if(value !== undefined && value !== null) {
* var hint = instance.getDataAtRow(row)['_' + prop];
*
* if(hint !== undefined) {
* value = cellProperties.extractTitle(hint);
* }
* }
*
* Handsontable.AutocompleteRenderer(instance, td, row, col, prop, value, cellProperties);
* }
*
*/
var KeyValueAutocompleteEditor = (function() {
var KeyValueAutocompleteEditor = Handsontable.editors.HandsontableEditor.prototype.extend();
KeyValueAutocompleteEditor.prototype.init = function () {
Handsontable.editors.HandsontableEditor.prototype.init.apply(this, arguments);
this.query = null;
this.choices = [];
};
KeyValueAutocompleteEditor.prototype.prepare = function () {
Handsontable.editors.HandsontableEditor.prototype.prepare.apply(this, arguments);
this.hintProp = '_' + this.prop;
// Make the prototype behave as we want
this.cellProperties.filter = false;
this.cellProperties.strict = true;
this.keyTimeout = this.cellProperties.keyTimeout || 0;
};
KeyValueAutocompleteEditor.prototype.beginEditing = function (initialValue) {
// Set initial editor value based on the title that was last used
var hint = this.instance.getDataAtRow(this.row)[this.hintProp];
if(hint !== undefined && this.cellProperties.extractTitle !== undefined) {
initialValue = this.cellProperties.extractTitle(hint);
}
Handsontable.editors.HandsontableEditor.prototype.beginEditing.apply(this, [initialValue]);
};
KeyValueAutocompleteEditor.prototype.createElements = function(){
Handsontable.editors.HandsontableEditor.prototype.createElements.apply(this, arguments);
this.$htContainer.addClass('keyValueAutocompleteEditor');
};
KeyValueAutocompleteEditor.prototype.bindEvents = function(){
var that = this,
timer = null;
this.$textarea.on('keydown.autocompleteEditor', function(event){
if(event.altKey || event.ctrlKey || event.metaKey ||
(Handsontable.helper.isMetaKey(event.keyCode) && [Handsontable.helper.keyCode.BACKSPACE, Handsontable.helper.keyCode.DELETE].indexOf(event.keyCode) === -1)) {
return;
}
// Don't run query until user stops typing
if(timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(function () {
that.queryChoices(that.$textarea.val());
}, that.keyTimeout);
});
this.$htContainer.on('mouseleave', function () {
that.highlightBestMatchingChoice();
});
this.$htContainer.on('mouseenter', function () {
that.$htContainer.handsontable('deselectCell');
});
Handsontable.editors.HandsontableEditor.prototype.bindEvents.apply(this, arguments);
};
var onBeforeKeyDownInner;
KeyValueAutocompleteEditor.prototype.open = function () {
Handsontable.editors.HandsontableEditor.prototype.open.apply(this, arguments);
this.$textarea[0].style.visibility = 'visible';
this.focus();
var choicesListHot = this.$htContainer.handsontable('getInstance');
var that = this;
choicesListHot.updateSettings({
afterRenderer: function (TD, row, col, prop, value) {
if(!_.isString(value)){
return;
}
var caseSensitive = this.getCellMeta(row, col).filteringCaseSensitive === true;
var indexOfMatch = caseSensitive ? value.indexOf(this.query) : value.toLowerCase().indexOf(that.query.toLowerCase());
if(indexOfMatch != -1){
var match = value.substr(indexOfMatch, that.query.length);
TD.innerHTML = value.replace(match, '<strong>' + match + '</strong>');
}
}
});
onBeforeKeyDownInner = function (event) {
var instance = this;
if (event.keyCode == Handsontable.helper.keyCode.ARROW_UP){
if (instance.getSelected() && instance.getSelected()[0] === 0){
that.instance.listen();
that.focus();
event.preventDefault();
event.stopImmediatePropagation();
}
}
};
choicesListHot.addHook('beforeKeyDown', onBeforeKeyDownInner);
this.queryChoices(this.TEXTAREA.value);
};
KeyValueAutocompleteEditor.prototype.close = function () {
this.$htContainer.handsontable('getInstance').removeHook('beforeKeyDown', onBeforeKeyDownInner);
// Clear hint if we're not going to have one
if(!this.$htContainer.handsontable('getInstance').getSelected()) {
this.saveValue([[null]]);
this.instance.getDataAtRow(this.row)[this.hintProp] = {};
}
Handsontable.editors.HandsontableEditor.prototype.close.apply(this, arguments);
};
KeyValueAutocompleteEditor.prototype.queryChoices = function(query){
this.query = query;
if (typeof this.cellProperties.source == 'function'){
var that = this;
this.cellProperties.source(query, function(choices){
that.updateChoicesList(choices);
});
} else if (Handsontable.helper.isArray(this.cellProperties.source)) {
this.updateChoicesList(this.cellProperties.source);
} else {
this.updateChoicesList([]);
}
};
KeyValueAutocompleteEditor.prototype.updateChoicesList = function (choices) {
this.choices = choices;
this.$htContainer.handsontable('loadData', choices);
this.highlightBestMatchingChoice();
this.focus();
};
KeyValueAutocompleteEditor.prototype.highlightBestMatchingChoice = function () {
var bestMatchingChoice = this.findBestMatchingChoice();
if ( typeof bestMatchingChoice == 'undefined' && this.cellProperties.allowInvalid === false){
bestMatchingChoice = 0;
}
if(typeof bestMatchingChoice == 'undefined'){
this.$htContainer.handsontable('deselectCell');
} else {
var endCol = this.$htContainer.handsontable('countCols') - 1;
this.$htContainer.handsontable('selectCell', bestMatchingChoice, 0, bestMatchingChoice, endCol, true);
}
};
KeyValueAutocompleteEditor.prototype.findBestMatchingChoice = function(){
var bestMatch = {},
hot = this.$htContainer.handsontable('getInstance'),
valueLength = this.getValue().length,
currentItem,
indexOfValue,
charsLeft;
for(var i = 0, len = this.choices.length; i < len; i++){
currentItem = this.cellProperties.extractTitle(this.choices[i]);
if(valueLength > 0){
indexOfValue = currentItem.indexOf(this.getValue());
} else {
indexOfValue = currentItem === this.getValue() ? 0 : -1;
}
if(indexOfValue == -1) continue;
charsLeft = currentItem.length - indexOfValue - valueLength;
if( typeof bestMatch.indexOfValue == 'undefined' || bestMatch.indexOfValue > indexOfValue ||
( bestMatch.indexOfValue == indexOfValue && bestMatch.charsLeft > charsLeft ) ){
bestMatch.indexOfValue = indexOfValue;
bestMatch.charsLeft = charsLeft;
bestMatch.index = i;
}
}
return bestMatch.index;
};
KeyValueAutocompleteEditor.prototype.finishEditing = function (isCancelled, ctrlDown) {
var hot = this.$htContainer.handsontable('getInstance'),
selection = hot.getSelected();
if (hot.isListening()) { //if focus is still in the HOT editor
this.instance.listen(); //return the focus to the parent HOT instance
}
if (selection) {
var selectedRow = selection[0],
value = this.cellProperties.extractValue(hot.getDataAtRow(selectedRow)),
title = this.cellProperties.extractTitle(hot.getDataAtRow(selectedRow));
// Value stored in the table
if (value !== void 0) {
this.saveValue([[value]]);
// Store rest of data in a hint for future rendering
this.instance.getDataAtRow(this.row)[this.hintProp] = hot.getDataAtRow(selectedRow);
}
// Value of the editor text box
if (title !== void 0) {
this.setValue(title);
}
}
return Handsontable.editors.TextEditor.prototype.finishEditing.apply(this, arguments);
};
return KeyValueAutocompleteEditor;
})(Handsontable);
// Editor (sorta) allowing an overlay with further details to be popped up. See
// DetailsListCell below.
var DetailsListEditor = (function (Handsontable) {
var DetailsListEditor = Handsontable.editors.TextEditor.prototype.extend();
DetailsListEditor.prototype.beginEditing = function () {
var value = this.originalValue;
if(_.isNull(value)) { // turn null into a list
var rowData = this.instance.getData()[this.row];
value = rowData[this.prop] = [];
}
this.cellProperties.showEditor(this.instance, this.TD, this.row, this.col, this.prop, value, this.cellProperties);
};
DetailsListEditor.prototype.finishEditing = function () {
};
DetailsListEditor.prototype.init = function () {
};
DetailsListEditor.prototype.open = function () {};
DetailsListEditor.prototype.close = function () {};
DetailsListEditor.prototype.getValue = function () {};
DetailsListEditor.prototype.setValue = function () {};
DetailsListEditor.prototype.focus = function () {};
return DetailsListEditor;
})(Handsontable);
return {
/**
* Standard table config
*/
handsontableConfig: {
contextMenu: ['row_above', 'row_below', 'remove_row', 'hsep1', 'undo', 'redo'],
minSpareRows: 1,
rowHeaders: false,
autoWrapRow: true
},
/**
* Turn a collection into a data structure suitable for Handsontable.
* If `modifier` is passed, it should be a function passed an attributes
* hash and an item in the collection, which may modifiy the former.
*/
prepareTableData: function(collection, modifier) {
var data = [];
if (collection) {
collection.each(function(item, index, collection) {
var attrs = _.clone(item.attributes);
attrs.id = item.id;
attrs.cid = item.cid;
if(modifier)
modifier(attrs, item);
data.push(attrs);
});
}
return data;
},
/**
* Return a copy of the given handsontable's data with any spare rows
* sliced off.
*/
getTableData: function(table) {
var minSpareRows = table.getSettings().minSpareRows;
if(minSpareRows > 0) {
return table.getData().slice(0, -minSpareRows);
} else {
return table.getData().slice();
}
},
/**
* Turn a list of handsontable data into a set of models for the given
* collection. Returns a promise. Success callbacks are called with
* a list of models. Failure callbacks are called with a list of
* parsed models, and a list of failed row indexes for the remaining
* rows.
*
* Note: If tableData contains a blank row due to minSpareRows, you
* should splice it before calling this function.
*
* Pass a function parseRow() to turn a row object into a set of
* properties appropriate for the collection's model. May optionally
* take the current/new, but unchanged, model as a second argument.
* Return false to indicate an error.
*/
processTableData: function(tableData, collection, parseRow) {
var dfd = $.Deferred(),
models = [],
failed = [],
// Loop variables
row = null,
parsed = null,
cid = null,
model = null,
valid = true,
rowIndex = null;
function triggerInvalid() {
valid = false;
failed.push(rowIndex);
}
for(rowIndex = 0; rowIndex < tableData.length; ++rowIndex) {
row = tableData[rowIndex];
cid = row.cid;
model = null;
valid = true; // reset each time
if(cid) model = collection.get(cid);
if(!model) model = new collection.model();
parsed = parseRow? parseRow(row, model) : row;
if(parsed === false) {
triggerInvalid();
} else {
model.once('invalid', triggerInvalid);
model.set(parsed, {
silent: true,
validate: true
});
}
if(!valid)
continue;
models.push(model);
}
if(failed.length > 0) {
dfd.reject(models, failed);
} else {
dfd.resolve(models);
}
return dfd.promise();
},
/**
* Update a collection with new models. Any item that is removed
* will be destroyed.
*/
updateCollection: function(collection, newModels) {
function destroyOnRemove(model) {
model.destroy();
}
collection.on('remove', destroyOnRemove);
collection.set(newModels);
collection.off('remove', destroyOnRemove);
},
/**
* Sync a single collection in its entirety as a single `update`
* request
*/
syncCollection: function(collection, options) {
options = options ? _.clone(options) : {};
if (options.parse === void 0)
options.parse = true;
var success = options.success;
options.success = function(resp) {
collection.reset(resp, options);
if (success)
success(collection, resp, options);
collection.trigger('sync', collection, resp, options);
};
return collection.sync('update', collection, options);
},
/**
* Return a promise that will be resolved if the table is valid, or
* rejected if invalid. When invalid, an array of invalid row/col
* coordinate pairs is provided.
*/
validateTable: function(table) {
var deferred = new $.Deferred(),
waitingFor = [],
invalid = [];
function validatorChecker(validatorDeferred, invalid, i, j) {
return function(isValid) {
if(!isValid) {
invalid.push([i, j]);
}
validatorDeferred.resolve();
};
}
for(var i = 0; i < table.countRows() - table.getSettings().minSpareRows; ++i) {
for(var j = 0; j < table.countCols() - table.getSettings().minSpareCols; ++j) {
var validatorDeferred = new $.Deferred();
waitingFor.push(validatorDeferred);
table.validateCell(
table.getDataAtCell(i, j),
table.getCellMeta(i, j),
validatorChecker(validatorDeferred, invalid, i, j),
'validateCells'
);
}
}
$.when.apply($, waitingFor)
.done(function() {
if(invalid.length > 0) {
invalid.forEach(function(coords) {
var node = table.getCell(coords[0], coords[1]);
if(node !== null) {
$(node).addClass(table.getSettings().invalidCellClassName);
}
});
deferred.reject(invalid);
} else {
deferred.resolve();
}
})
.fail(function() {
deferred.reject(invalid);
});
return deferred.promise();
},
/**
* Splice the data from newList into list, replacing any elements there already,
* in such a way that list remains the relevant reference
*/
spliceTableData: function(table, list, newList) {
var minSpareRows = table.getSettings().minSpareRows;
newList.splice(-minSpareRows, minSpareRows);
Array.prototype.splice.apply(list, [0, list.length].concat(newList));
},
// Validators
validators: {
/**
* Validate that a value is provided
*/
required: entryValidator(function(value, callback) {
if(value === null || value === undefined || value === "") {
callback(false);
} else if(_.isDate(value)) {
callback(true);
} else if(_.isArray(value) && _.isEmpty(value)) {
callback(false);
} else if(_.isObject(value) && _.isEmpty(value)) {
callback(false);
} else {
callback(true);
}
}),
/**
* Validate that a value is a number
*/
number: entryValidator(function(value, callback) {
if(value === null || value === undefined || value === "") {
callback(true);
} else if(!_.isFinite(value)) {
callback(false);
} else {
callback(true);
}
}),
/**
* Validate that a value is a number and is provided
*/
requiredNumber: entryValidator(function(value, callback) {
if(!_.isFinite(value)) {
callback(false);
} else {
callback(true);
}
}),
/**
* Validate that a value is a percentage
*/
percentage: entryValidator(function(value, callback) {
if(value === null || value === undefined || value === "") {
callback(true);
} else if(!_.isFinite(value) || value < 0 || value > 100) {
callback(false);
} else {
callback(true);
}
}),
/**
* Validate that a value is a percentage and is provided
*/
requiredPercentage: entryValidator(function(value, callback) {
if(!_.isFinite(value) || value < 0 || value > 100) {
callback(false);
} else {
callback(true);
}
}),
/**
* Validate that a value is a date
*/
date: entryValidator(function(value, callback) {
if(value === null || value === undefined) {
callback(true);
} else if(!_.isDate(value)) {
callback(false);
} else {
callback(true);
}
}),
/**
* Validate that a value is a date and is provided
*/
requiredDate: entryValidator(function(value, callback) {
if(!_.isDate(value)) {
callback(false);
} else {
callback(true);
}
}),
/**
* Validate that a value is unique in its column
*/
unique: entryValidator(function(value, callback) {
var table = this.instance,
numRows = table.countRows() - table.getSettings().minSpareRows,
row = this.row,
col = this.col;
if(value === null || value === undefined || value === "") {
callback(false);
return;
} else if(_.isArray(value) && _.isEmpty(value)) {
callback(false);
return;
} else if(_.isObject(value) && _.isEmpty(value)) {
callback(false);
return;
}
for(var i = 0; i < numRows; ++i) {
if(i === row) {
continue;
}
var otherValue = table.getDataAtCell(i, col);
if(_.isEqual(value, otherValue)) {
callback(false);
return;
}
}
callback(true);
})
},
// Custom cell types
cellTypes: {
/**
* Date using Bootstrap datepicker
*/
DateCell: {
renderer: function(instance, td, row, col, prop, value, cellProperties) {
if(_.isDate(value)) {
value = moment.utc(value).format(app.momentDateFormat);
}
Handsontable.AutocompleteRenderer(instance, td, row, col, prop, value, cellProperties);
},
editor: DateEditor,
dataType: 'date'
},
/**
* Percentage
*/
PercentageCell: {
renderer: function(instance, td, row, col, prop, value, cellProperties) {
// Minor hack - we store percentages as whole numbers, not fractions,
// but the numeral library works in fractions
if(value) {
value /= 100;
}
Handsontable.NumericRenderer(instance, td, row, col, prop, value, cellProperties);
},
editor: Handsontable.NumericCell.editor,
dataType: 'number'
},
/**
* A list of values. Renders the number of items in the list. On edit,
* calls a callback function with the list, which can be edited inplace.
* For example:
*
* columns: [
* {
* data: "allocations",
* type: TablePlugins.ChildListCell,
* showEditor: function(instance, td, row, col, prop, cellProperties, value) {
* ...
* }
* },
* ...
* ]
*
* If the current value is null, a new list will be created
*/
DetailsListCell: {
renderer: function(instance, td, row, col, prop, value, cellProperties) {
value = _.isNull(value)? "" : value.length + "...";
Handsontable.AutocompleteRenderer(instance, td, row, col, prop, value, cellProperties);
},
editor: DetailsListEditor
},
/**
* See `KeyValueAutocompleteEditor` docs
*/
KeyValueCell: {
renderer: function(instance, td, row, col, prop, value, cellProperties) {
if(value !== undefined && value !== null) {
var hint = instance.getDataAtRow(row)['_' + prop];
if(hint !== undefined) {
value = cellProperties.extractTitle(hint);
}
}
Handsontable.AutocompleteRenderer(instance, td, row, col, prop, value, cellProperties);
},
editor: KeyValueAutocompleteEditor
}
}
};
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment