2025-04-10 23:19:13 +08:00

374 lines
13 KiB
JavaScript

$.fn.editableTable = function(options) {
// Default options
let defaultOptions = {
cloneProperties: ['padding', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
'text-align', 'font', 'font-size', 'font-family', 'font-weight',
'border', 'border-top', 'border-bottom', 'border-left', 'border-right', 'color', 'background-color', 'border-radius'
],
columns: []
}
// Overlay default options on user options
options = $.extend({}, defaultOptions, options);
// The instance reference we'll pass to the user
let _instance;
// Class to put on the editor to denote an error
let errorClass = 'error';
// Something to tag the hidden input with
let identifierAttribute = 'table-editor-input';
// Keycodes
let ARROW_LEFT = 37,
ARROW_UP = 38,
ARROW_RIGHT = 39,
ARROW_DOWN = 40,
ENTER = 13,
ESC = 27,
TAB = 9,
CONTROL = 17,
LEFT_WINDOWS = 91,
SELECT_KEY = 93;
// The table element
let element = $(this);
// Show all columns and then hide any hidden ones
element.find('th').show();
options.columns.forEach((col, i) => {
if (col.isHidden !== undefined && col.isHidden) element.find('th').eq(i).hide();
});
// If there are actions, add a final blank table header
if (options.actions !== undefined && options.actions.length > 0) {
// Add header if we don't already have one
if (!element.find('thead tr th[actions]').length) {
element.find('thead tr').append($('<th actions=""></th>'));
}
}
// The textbox allowing user input. Only add if there's not already an editor input control around
let editor;
let existingEditor = element.parent().find(`input[${identifierAttribute}]`);
if (existingEditor.length) {
editor = existingEditor.first();
} else {
editor = $('<input>');
}
// Add it to the DOM
editor.attr(identifierAttribute, '')
.css('position', 'absolute')
.hide()
.appendTo(element.parent());
// The `td` being "edited"
let activeCell;
// Function to show the editor
function showEditor(select) {
// Set the active cell
activeCell = element.find('td:focus:not([action])');
if (activeCell.length) {
// Get column def
let currentColIndex = $(activeCell).parent().children('td').index($(activeCell));
let columnDef = options.columns[currentColIndex];
// Prepare input control
editor.val(activeCell.text()) // Throw the value in
.removeClass(errorClass) // remove any error classes
.show() // show it
.offset(activeCell.offset()) // position it
.css(activeCell.css(options.cloneProperties)) // make it look similar by cloning properties
.width(activeCell.width()) // size it
.height(activeCell.height()) // size it
.focus(); // focus user input into it
// Set max length, if it's specified
if (columnDef.maxLength !== undefined) editor.attr('maxlength', columnDef.maxLength);
else editor.removeAttr('maxlength');
if (select) {
editor.select();
}
}
};
function setActiveText() {
let text = editor.val(),
evt = $.Event('change'),
originalContent;
if (activeCell.text() === text || editor.hasClass(errorClass)) {
return true;
}
originalContent = activeCell.html();
activeCell.text(text).trigger(evt, text);
if (evt.result === false) {
activeCell.html(originalContent);
}
};
// For traversing the table up/down/left/right by returning the adjacent cell
function handleMovement(element, keycode) {
if (keycode === ARROW_RIGHT) {
return element.next('td');
} else if (keycode === ARROW_LEFT) {
return element.prev('td');
} else if (keycode === ARROW_UP) {
return element.parent().prev().children().eq(element.index());
} else if (keycode === ARROW_DOWN) {
return element.parent().next().children().eq(element.index());
}
return [];
};
// On the editor losing focus, hide the input
editor.blur(function() {
setActiveText();
editor.hide();
});
// Handle typing into the input
editor.keydown(function(e) {
if (e.which === ENTER) {
setActiveText();
editor.hide();
activeCell.focus();
e.preventDefault();
e.stopPropagation();
} else if (e.which === ESC) {
editor.val(activeCell.text());
e.preventDefault();
e.stopPropagation();
editor.hide();
activeCell.focus();
} else if (e.which === TAB) {
activeCell.focus();
} else if (this.selectionEnd - this.selectionStart === this.value.length) {
let possibleMove = handleMovement(activeCell, e.which);
if (possibleMove.length > 0) {
possibleMove.focus();
e.preventDefault();
e.stopPropagation();
}
}
});
// Validate cell input on typing or pasting
editor.on('input paste', function() {
let evt = $.Event('validate');
activeCell.trigger(evt, editor.val());
if (evt.result !== undefined) {
if (evt.result === false) {
editor.addClass(errorClass);
} else {
editor.removeClass(errorClass);
}
}
});
// On table clicking, move around cells
element.on('click keypress dblclick', showEditor);
element.keydown(function(e) {
let prevent = true,
possibleMove = handleMovement($(e.target), e.which);
if (possibleMove.length > 0) {
possibleMove.focus();
} else if (e.which === ENTER) {
showEditor(false);
} else if (e.which === CONTROL || e.which === LEFT_WINDOWS || e.which === SELECT_KEY) {
showEditor(true);
prevent = false;
} else {
prevent = false;
}
if (prevent) {
e.stopPropagation();
e.preventDefault();
}
});
element.find('td').prop('tabindex', 1);
$(window).on('resize', function() {
if (editor.is(':visible')) {
editor.offset(activeCell.offset())
.width(activeCell.width())
.height(activeCell.height());
}
});
function refresh() {
$(element).editableTable(options);
}
// Validate based on options
$('table td').on('validate', function(evt, newValue) {
let currentColIndex = $(evt.currentTarget).parent().children('td').index($(evt.currentTarget));
let columnDef = options.columns[currentColIndex];
let currentData = _instance.getData({
convert: false
}); // current data to allow user to validate based on existing data
let isValid = columnDef.isValid && columnDef.isValid(newValue, currentData);
return isValid;
});
$('table td').on('change', function(evt, newValue) {
let td = $(this);
let currentColIndex = $(evt.currentTarget).parent().children('td').index($(evt.currentTarget));
let columnDef = options.columns[currentColIndex];
if (columnDef.removeRowIfCleared && newValue == '') {
td.parent('tr').remove();
}
// Bind user-specified events if they exist
if (typeof columnDef.afterChange == 'function') {
columnDef.afterChange(newValue, td);
}
return true;
});
// Set up the instance reference
_instance = {
// Get table back out as JSON
getData: function(opts) {
opts = $.extend({}, {
convert: true
}, opts);
let rowData = [];
element.find('tbody tr').toArray().forEach(row => {
let newRow = {};
$(row).find('td:not([action])').toArray().forEach(col => {
let columnsDef = options.columns[
$(col).parent().children('td').index($(col)) // only index at cells, or "td"s
];
let value = $(col).text();
// Check if the cell was marked as having a null value, and if so, extract as null and not
// as a blank string
let isNull = $(col).attr('data-is-null');
if (typeof isNull !== 'undefined' && isNull !== false) {
value = null;
}
// Convert if requried
if (opts.convert && typeof columnsDef.convertOut == 'function') {
value = columnsDef.convertOut(value);
}
newRow[columnsDef.name] = value;
});
rowData.push(newRow);
});
return rowData;
},
// Add a new row with JSON
addRow: function(row) {
let newRow = $(`<tr></tr>`);
if (row !== undefined && row !== null) {
let props = Object.keys(row);
let columnsToAdd = [];
props.forEach(prop => {
let columnDef = options.columns.filter(col => col.name === prop);
if (columnDef.length) {
columnDef = columnDef[0];
columnsToAdd.push({
order: columnDef.index,
value: row[prop],
prop: prop,
def: columnDef
});
}
})
columnsToAdd.sort((a, b) => a.order - b.order).forEach((colToAdd, index) => {
let newCell;
if (colToAdd.value !== null)
newCell = $(`<td>${colToAdd.value}</td>`);
else
newCell = $(`<td data-is-null></td>`);
// Apply any classes
if (colToAdd.def.classes !== undefined && colToAdd.def.classes.length) {
colToAdd.def.classes.forEach(classToAdd => newCell.addClass(classToAdd));
}
// Apply any style
if (colToAdd.def.style !== undefined && colToAdd.def.style.length) {
newCell.attr("style", newCell.attr("style") + "; " + colToAdd.def.style);
}
// Hide if hidden
if (colToAdd.def.isHidden !== undefined && colToAdd.def.isHidden) {
newCell.hide();
}
// Add to the column
newRow.append(newCell);
// Trigger any events
let columnDef = options.columns.filter(col => col.name === colToAdd.prop)[0];
if (typeof columnDef.afterAdd == 'function') {
columnDef.afterAdd(colToAdd.value, newCell);
}
});
} else {
newRow = $(`<tr></tr>`);
activeOptions.columns.forEach(x => {
newRow.append(`<td></td>`);
});
}
// Add actions if any
if (options.actions !== undefined && options.actions.length > 0) {
let actionsCell = $(`<td action></td>`);
options.actions.forEach(a => {
let actionElement = $(a.label);
actionElement.css('cursor', 'pointer');
actionElement.click(e => a.action(e, newRow))
actionsCell.append(actionElement);
});
newRow.append(actionsCell);
}
// Add the new row
let lastRow = element.find('tbody tr:last');
if (lastRow.length > 0)
lastRow.after(newRow);
else
element.find('tbody').append(newRow);
refresh();
},
// Clear the table
clear: function() {
element.find('tbody tr').remove();
},
// Set the table's data with JSON
setData: function(data) {
if (data) {
this.clear();
data.forEach(datum => {
this.addRow(datum);
});
}
}
};
return _instance;
};