$.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($('')); } } // 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 = $(''); } // 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 = $(``); 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 = $(`${colToAdd.value}`); else newCell = $(``); // 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 = $(``); activeOptions.columns.forEach(x => { newRow.append(``); }); } // Add actions if any if (options.actions !== undefined && options.actions.length > 0) { let actionsCell = $(``); 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; };