diff --git a/lib/defaults.js b/lib/defaults.js index 8bc00c4c..2db4a61b 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -18,6 +18,7 @@ export default function (self) { ['allowSorting', true], ['allowGroupingRows', true], ['allowGroupingColumns', true], + ['allowGridExpandOnPaste', false], ['animationDurationShowContextMenu', 50], ['animationDurationHideContextMenu', 50], ['autoGenerateSchema', false], diff --git a/lib/docs.js b/lib/docs.js index e2df9895..32a69743 100644 --- a/lib/docs.js +++ b/lib/docs.js @@ -39,6 +39,7 @@ * @param {string} [args.blanksText=(Blanks)] - The text that appears on the context menu for filtering blank values (i.e. `undefined`, `null`, `''`). * @param {string} [args.ellipsisText=...] - The text used as ellipsis text on truncated values. * @param {boolean} [args.allowSorting=true] - Allow user to sort rows by clicking on column headers. + * @param {boolean} [args.allowGridExpandOnPaste=false] - Allow adding new rows and columns if pasted data dimensions are bigger than existing grid dimensions. * @param {boolean} [args.allowGroupingColumns=true] - Allow user to group columns by clicking on column headers. * @param {boolean} [args.allowGroupingRows=true] - Allow user to group rows by clicking on rows headers. * @param {boolean} [args.showFilter=true] - When true, filter will be an option in the context menu. diff --git a/lib/events/index.js b/lib/events/index.js index 66da1ef0..915d75eb 100644 --- a/lib/events/index.js +++ b/lib/events/index.js @@ -3,7 +3,7 @@ 'use strict'; import isPrintableKeyEvent from 'is-printable-key-event'; -import { isSupportedHtml, parseData } from './util'; +import { isSupportedHtml, parseData, deduceColTypeFromData } from './util'; export default function (self) { var wheeling; @@ -2007,7 +2007,6 @@ export default function (self) { alwaysFilling = false, direction = 'both', }) { - var schema = self.getSchema(); const rowsLength = Math.max(rows.length, minRowsLength); const fillCellCallback = self.fillCellCallback; const filledCells = []; @@ -2029,12 +2028,22 @@ export default function (self) { : rowPosReal; // Rows may have been moved by user, so get the actual row index // (instead of the row index at which the row is rendered): - var realRowIndex = self.orders.rows[startRowIndex + rowPosition]; - var cells = rows[rowDataPos]; + if (self.originalData[startRowIndex + rowPosition] === undefined) { + if (self.attributes.allowGridExpandOnPaste) { + // This needs to be optimized because .addRow() calls .refresh() + self.addRow({}); + } else { + console.warn('Paste data exceeded grid bounds. Skipping.'); + break; + } + } + const realRowIndex = self.orders.rows[startRowIndex + rowPosition]; + + const cells = rows[rowDataPos]; const cellsLength = Math.max(cells.length, minColumnsLength); - var existingRowData = self.viewData[realRowIndex]; - var newRowData = Object.assign({}, existingRowData); + const existingRowData = self.viewData[realRowIndex]; + const newRowData = Object.assign({}, existingRowData); const fillArgs = fillCellCallback ? { rows: rows, @@ -2060,7 +2069,7 @@ export default function (self) { : undefined; for ( - var colPosReal = 0, cellDataPos = 0; + let colPosReal = 0, cellDataPos = 0; colPosReal < cellsLength; colPosReal++, cellDataPos++ ) { @@ -2073,15 +2082,26 @@ export default function (self) { ? cellsLength - colPosReal - 1 : colPosReal; const columnIndex = startColumnIndex + colPosition; - var column = schema[self.orders.columns[columnIndex]]; - - if (!column) { - console.warn('Paste data exceeded grid bounds. Skipping.'); - continue; + if (!self.getSchema()[columnIndex]) { + if (self.attributes.allowGridExpandOnPaste) { + const lastColSchema = self.getSchema()[ + self.orders.columns[self.getSchema().length - 1] + ]; + const newColSchema = { + name: `col${self.getSchema().length + 1}:${Date.now()}`, + type: deduceColTypeFromData(rows, colPosReal), + title: ' ', + width: lastColSchema.width, + }; + self.addColumn(newColSchema); + } else { + console.warn('Paste data exceeded grid bounds. Skipping.'); + continue; + } } - - var columnName = column.name; - var cellData = cells[cellDataPos]; + const column = self.getSchema()[self.orders.columns[columnIndex]]; + const columnName = column.name; + let cellData = cells[cellDataPos]; if (cellData && cellData.value) { cellData = cellData.value.map((item) => item.value).join(''); } diff --git a/lib/events/util.js b/lib/events/util.js index 186666ff..a6468814 100644 --- a/lib/events/util.js +++ b/lib/events/util.js @@ -122,6 +122,35 @@ const createHTMLString = function (selectedData, isNeat) { return htmlString; }; +/** + * deduce the type of values in the column 'colNum' of data represented by 'rows' + * if the column is not empty and all values in the column are numbers, then the type is 'number', + * otherwise the type is 'string'. + * Purpose: check the type of pasted data when a new column is created dynamically + * @param {Array} rows - array of rows, e.g. [ [{"value": [{"value": "a"}]},{"value": [{"value": "b"}]}], [{"value": [{"value": "1"}]},{"value": [{"value": "2"}]}] ] + * @param {number} colNum - Column index to check + * @returns {("string" | "number")} deduced type + */ +const deduceColTypeFromData = function (rows, colNum) { + const len = rows.length; + // the first row can represent column headings. + // if one row is pasted, check it, otherwise start from the second row. + const startRow = len === 1 ? 0 : 1; + let isColEmpty = true; + for (let i = startRow; i < len; i += 1) { + const cellData = rows[i][colNum]; + if (!cellData || cellData.value.length === 0) { + continue; + } + isColEmpty = false; + const val = cellData.value[0].value; + if (!Number.isFinite(Number(val))) { + return 'string'; + } + } + return isColEmpty ? 'string' : 'number'; +}; + export { createTextString, createHTMLString, @@ -132,4 +161,5 @@ export { parseHtmlText, parseText, sanitizeElementData, + deduceColTypeFromData, }; diff --git a/test/editing.js b/test/editing.js index 89acb387..1e778fed 100644 --- a/test/editing.js +++ b/test/editing.js @@ -113,8 +113,8 @@ export default function () { done( assertIf( grid.input.childNodes[0].innerHTML === 'A' && - grid.input.childNodes.length === 3 && - grid.input.tagName !== 'SELECT', + grid.input.childNodes.length === 3 && + grid.input.tagName !== 'SELECT', 'Expected an input to have appeared', ), ); @@ -438,6 +438,200 @@ export default function () { ); }, 10); }); + + it('paste a 2x3 numeric table into 2x2 grid should add a new column of type "number", if allowGridExpandOnPaste === true', function (done) { + var grid = g({ + test: this.test, + data: [ + { a: 'a', b: 'b' }, + { a: 'c', b: 'd' }, + ], + autoGenerateSchema: true, + allowGridExpandOnPaste: true, + }); + + grid.focus(); + grid.setActiveCell(0, 0); + grid.selectArea({ top: 0, left: 0, bottom: 0, right: 0 }); + + grid.paste({ + clipboardData: { + items: [ + { + type: 'text/plain', + getAsString: function (callback) { + callback('1\t2\t3\n4\t5\t6'); + }, + }, + ], + }, + }); + + setTimeout(function () { + const colName = Object.keys(grid.viewData[0])[2]; + const cellData1 = grid.viewData[0][colName]; + const cellData2 = grid.viewData[1][colName]; + + try { + doAssert( + grid.viewData.length === 2 && + Object.keys(grid.viewData[0]).length === 3, + 'New column was not added to the grid', + ); + doAssert( + cellData1 === '3' && cellData2 === '6', + 'Correct data was not added to the new column', + ); + doAssert( + grid.schema.find((col) => col.name === colName).type === 'number', + 'New column has incorrect type', + ); + done(); + } catch (error) { + done(error); + } + }, 10); + }); + + it('paste a 3x2 table into 2x2 grid should add a new row, if allowGridExpandOnPaste === true', function (done) { + var grid = g({ + test: this.test, + data: [ + { a: 'a', b: 'b' }, + { a: 'c', b: 'd' }, + ], + autoGenerateSchema: true, + allowGridExpandOnPaste: true, + }); + + grid.focus(); + grid.setActiveCell(0, 0); + grid.selectArea({ top: 0, left: 0, bottom: 0, right: 0 }); + + grid.paste({ + clipboardData: { + items: [ + { + type: 'text/plain', + getAsString: function (callback) { + callback('1\t2\n3\t4\n5\t6'); + }, + }, + ], + }, + }); + + setTimeout(function () { + const lastRow = grid.viewData[2]; + const cellData1 = lastRow['a']; + const cellData2 = lastRow['b']; + + try { + doAssert( + grid.viewData.length === 3 && + Object.keys(grid.viewData[0]).length === 2, + 'New row was not added to the grid', + ); + doAssert( + cellData1 === '5' && cellData2 === '6', + 'Correct data was not added to the new row', + ); + done(); + } catch (error) { + done(error); + } + }, 10); + }); + + it('paste a 2x2 table of text cells into 1x1 grid should add a new row and a new column of type "string", if allowGridExpandOnPaste === true', function (done) { + var grid = g({ + test: this.test, + data: [{ a: 'a' }], + autoGenerateSchema: true, + allowGridExpandOnPaste: true, + }); + + grid.focus(); + grid.setActiveCell(0, 0); + grid.selectArea({ top: 0, left: 0, bottom: 0, right: 0 }); + + grid.paste({ + clipboardData: { + items: [ + { + type: 'text/plain', + getAsString: function (callback) { + callback('a\tb\nc\td'); + }, + }, + ], + }, + }); + + setTimeout(function () { + const [firstRow, lastRow] = grid.viewData; + const colName = Object.keys(firstRow)[1]; + const cellData1 = firstRow[colName]; + const cellData2 = lastRow['a']; + const cellData3 = lastRow[colName]; + + try { + doAssert( + grid.viewData.length === 2 && + Object.keys(grid.viewData[0]).length === 2, + 'New row or new column was not added to the grid', + ); + doAssert( + cellData1 === 'b' && cellData2 === 'c' && cellData3 === 'd', + 'Correct data was not added to the new row', + ); + doAssert( + grid.schema.find((col) => col.name === colName).type === 'string', + 'New column has incorrect type', + ); + done(); + } catch (error) { + done(error); + } + }, 10); + }); + + it('paste a 1x1 table into 1x1 grid should not add any new rows or columns, if allowGridExpandOnPaste === true', function (done) { + var grid = g({ + test: this.test, + data: [{ a: 'a' }], + autoGenerateSchema: true, + allowGridExpandOnPaste: true, + }); + + grid.focus(); + grid.setActiveCell(0, 0); + grid.selectArea({ top: 0, left: 0, bottom: 0, right: 0 }); + + grid.paste({ + clipboardData: { + items: [ + { + type: 'text/plain', + getAsString: function (callback) { + callback('1'); + }, + }, + ], + }, + }); + + setTimeout(function () { + done( + doAssert( + grid.viewData.length === 1 && + Object.keys(grid.viewData[0]).length === 1, + 'New row or new column was not added to the grid', + ), + ); + }, 10); + }); + it('paste a Excel table single row / single cell value from the clipboard into a cell', function (done) { var grid = g({ test: this.test, @@ -558,14 +752,20 @@ export default function () { setTimeout(function () { try { doAssert(grid.viewData.length == 3, 'Should have 3 rows exactly'); - doAssert(Object.keys(grid.viewData[0]).length == 3, 'Should have 3 columns exactly'); + doAssert( + Object.keys(grid.viewData[0]).length == 3, + 'Should have 3 columns exactly', + ); for (let i = 0; i < grid.viewData.length; i++) { for (const columnKey in grid.viewData[i]) { const currentValue = grid.viewData[i][columnKey]; doAssert( currentValue === 'New value', - 'Value for "' + columnKey + '" should be "New value", but got ' + currentValue + 'Value for "' + + columnKey + + '" should be "New value", but got ' + + currentValue, ); } } @@ -617,7 +817,10 @@ export default function () { setTimeout(function () { try { doAssert(grid.viewData.length == 2, 'Should have 2 rows exactly'); - doAssert(Object.keys(grid.viewData[0]).length == 3, 'Should have 3 columns exactly'); + doAssert( + Object.keys(grid.viewData[0]).length == 3, + 'Should have 3 columns exactly', + ); const expectedResult = [ { @@ -638,7 +841,7 @@ export default function () { const currentValue = grid.viewData[i][columnKey]; doAssert( currentValue === expectedValue, - `Value for "${columnKey}" should be "${expectedValue}", but got "${currentValue}"` + `Value for "${columnKey}" should be "${expectedValue}", but got "${currentValue}"`, ); } } @@ -808,14 +1011,11 @@ export default function () { it('Moving handle on desktop fills the overlay region with selection data', function (done) { const grid = g({ test: this.test, - data: [ - { field1: 'value1' }, - { field1: 'value2' }, - { field1: 'value3' }, - ], + data: [{ field1: 'value1' }, { field1: 'value2' }, { field1: 'value3' }], fillCellCallback: function (args) { - return args.newCellData + ' ' + - (args.fillingRowPosition + 1).toString(); + return ( + args.newCellData + ' ' + (args.fillingRowPosition + 1).toString() + ); }, selectionHandleBehavior: 'single', }); @@ -839,7 +1039,7 @@ export default function () { const currentValue = grid.viewData[i][columnKey]; doAssert( currentValue === expectedValue, - `Value for "${columnKey}" should be "${expectedValue}", but got "${currentValue}"` + `Value for "${columnKey}" should be "${expectedValue}", but got "${currentValue}"`, ); } }