diff --git a/packages/code-studio/package.json b/packages/code-studio/package.json index ad76069b8a..fbedf0df45 100644 --- a/packages/code-studio/package.json +++ b/packages/code-studio/package.json @@ -60,7 +60,6 @@ "react-virtualized-auto-sizer": "^1.0.2", "react-window": "^1.8.5", "reactstrap": "^8.4.1", - "reduce-reducers": "^1.0.1", "redux": "^4.0.5", "redux-thunk": "^2.3.0", "shortid": "^2.2.15", diff --git a/packages/grid/src/Grid.jsx b/packages/grid/src/Grid.jsx index c58a18d274..f8dfc5169f 100644 --- a/packages/grid/src/Grid.jsx +++ b/packages/grid/src/Grid.jsx @@ -27,10 +27,12 @@ import './Grid.scss'; import KeyHandler from './KeyHandler'; import { EditKeyHandler, + PasteKeyHandler, SelectionKeyHandler, TreeKeyHandler, } from './key-handlers'; import CellInputField from './CellInputField'; +import PasteError from './errors/PasteError'; /** * High performance, extendible, themeable grid component. @@ -115,6 +117,7 @@ class Grid extends PureComponent { // specify handler ordering, such that any extensions can insert handlers in between this.keyHandlers = [ new EditKeyHandler(400), + new PasteKeyHandler(450), new SelectionKeyHandler(500), new TreeKeyHandler(900), ]; @@ -934,6 +937,100 @@ class Grid extends PureComponent { return model.isValidForCell(modelColumn, modelRow, value); } + /** + * Paste a value with the current selection + * It first needs to validate that the pasted table is valid for the given selection. + * Also may update selection if single cells are selected and a table is pasted. + * @param {string[][] | string} value Table or a string that is being pasted + */ + async pasteValue(value) { + const { model } = this.props; + const { movedColumns, movedRows, selectedRanges } = this.state; + + try { + if ( + !model.isEditable || + !selectedRanges.every(range => model.isEditableRange(range)) + ) { + throw new PasteError("Can't paste in to read-only area."); + } + + if (selectedRanges.length <= 0) { + throw new PasteError('Select an area to paste to.'); + } + + if (typeof value === 'string') { + // Just paste the value into all the selected cells + const edits = []; + + const modelRanges = GridUtils.getModelRanges( + selectedRanges, + movedColumns, + movedRows + ); + GridRange.forEachCell(modelRanges, (x, y) => { + edits.push({ x, y, text: value }); + }); + await model.setValues(edits); + return; + } + + // Otherwise it's a table of data + const tableHeight = value.length; + const tableWidth = value[0].length; + const { columnCount, rowCount } = model; + let ranges = selectedRanges; + // If each cell is a single selection, we need to update the selection to map to the newly pasted data + if ( + ranges.every( + range => + GridRange.cellCount([range]) === 1 && + range.startColumn + tableWidth <= columnCount && + range.startRow + tableHeight <= rowCount + ) + ) { + // Remap the selected ranges + ranges = ranges.map( + range => + new GridRange( + range.startColumn, + range.startRow, + range.startColumn + tableWidth - 1, + range.startRow + tableHeight - 1 + ) + ); + this.setSelectedRanges(ranges); + } + + if ( + !ranges.every( + range => + GridRange.rowCount([range]) === tableHeight && + GridRange.columnCount([range]) === tableWidth + ) + ) { + throw new PasteError('Copy and paste area are not same size.'); + } + + const edits = []; + ranges.forEach(range => { + for (let x = 0; x < tableWidth; x += 1) { + for (let y = 0; y < tableHeight; y += 1) { + edits.push({ + x: range.startColumn + x, + y: range.startRow + y, + text: value[y][x], + }); + } + } + }); + await model.setValues(edits); + } catch (e) { + const { onError } = this.props; + onError(e); + } + } + setValueForCell(column, row, value) { const { model } = this.props; @@ -1117,8 +1214,8 @@ class Grid extends PureComponent { const keyHandler = keyHandlers[i]; const result = keyHandler.onDown(e, this); if (result) { - e.stopPropagation(); - e.preventDefault(); + if (result?.stopPropagation ?? true) e.stopPropagation(); + if (result?.preventDefault ?? true) e.preventDefault(); break; } } @@ -1494,7 +1591,7 @@ class Grid extends PureComponent { onMouseDown={this.handleMouseDown} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave} - tabIndex="0" + tabIndex={0} > Your browser does not support HTML canvas. Update your browser? @@ -1524,6 +1621,7 @@ Grid.propTypes = { to: PropTypes.number.isRequired, }) ), + onError: PropTypes.func, onSelectionChanged: PropTypes.func, onMovedColumnsChanged: PropTypes.func, onMoveColumnComplete: PropTypes.func, @@ -1545,6 +1643,7 @@ Grid.defaultProps = { mouseHandlers: [], movedColumns: [], movedRows: [], + onError: () => {}, onSelectionChanged: () => {}, onMovedColumnsChanged: () => {}, onMoveColumnComplete: () => {}, diff --git a/packages/grid/src/Grid.test.jsx b/packages/grid/src/Grid.test.jsx index 89e8a39d0d..68dc602d9e 100644 --- a/packages/grid/src/Grid.test.jsx +++ b/packages/grid/src/Grid.test.jsx @@ -42,6 +42,8 @@ const defaultTheme = { ...GridTheme, autoSizeColumns: false }; const VIEW_SIZE = 5000; +const DEFAULT_PASTE_DATA = 'TEST_PASTE_DATA'; + function makeMockCanvas() { return { clientWidth: VIEW_SIZE, @@ -220,6 +222,10 @@ function pageDown(component, extraArgs) { keyDown('PageDown', component, extraArgs); } +function paste(component, data = DEFAULT_PASTE_DATA) { + component.pasteValue(data); +} + it('renders default model without crashing', () => { makeGridComponent(new GridModel()); }); @@ -755,3 +761,53 @@ describe('truncate to width', () => { expectTruncate(MockGridData.JSON, '{"command…'); }); }); + +describe('paste tests', () => { + describe('non-editable', () => { + it('does nothing if table is not editable', () => { + const model = new MockGridModel(); + model.setValues = jest.fn(); + + const component = makeGridComponent(model); + paste(component); + expect(model.setValues).not.toHaveBeenCalled(); + }); + }); + + describe('editable', () => { + let model = null; + let component = null; + + beforeEach(() => { + model = new MockGridModel({ isEditable: true }); + model.setValues = jest.fn(); + + component = makeGridComponent(model); + }); + + it('does nothing if no selection', () => { + paste(component); + expect(model.setValues).not.toHaveBeenCalled(); + }); + + it('modifies a single cell if only one selection', () => { + mouseClick(5, 7, component); + paste(component); + expect(model.setValues).toHaveBeenCalledTimes(1); + expect(model.setValues).toHaveBeenCalledWith([ + expect.objectContaining({ + x: 5, + y: 7, + text: DEFAULT_PASTE_DATA, + }), + ]); + }); + + it('does the whole selected range', () => { + mouseClick(5, 7, component); + mouseClick(3, 2, component, { shiftKey: true }); + paste(component); + expect(model.setValues).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/grid/src/GridModel.js b/packages/grid/src/GridModel.js index 50fbc6ccec..e7ab8979c8 100644 --- a/packages/grid/src/GridModel.js +++ b/packages/grid/src/GridModel.js @@ -136,6 +136,18 @@ class GridModel extends EventTarget { throw new Error('setValueForRanges not implemented'); } + /** + * Apply edits to the model + * @param {{ + * x: number, + * y: number, + * text: string, + * }[]} edits The edits to apply to the model + */ + async setValues(edits) { + throw new Error('setValues not implemented'); + } + /** * Check if a text value is a valid edit for a cell * @param {number} x The column to check diff --git a/packages/grid/src/GridRange.js b/packages/grid/src/GridRange.js index fdb6b1b068..7d11e5ac55 100644 --- a/packages/grid/src/GridRange.js +++ b/packages/grid/src/GridRange.js @@ -496,6 +496,19 @@ class GridRange { ); } + /** + * Count the number of columns in the provided grid ranges + * @param {GridRange[]} ranges The ranges to count the columns of + * @returns {number|NaN} The number of columns in the ranges, or `NaN` if any of the ranges were unbounded + */ + static columnCount(ranges) { + return ranges.reduce( + (columnCount, range) => + columnCount + (range.endColumn ?? NaN) - (range.startColumn ?? NaN) + 1, + 0 + ); + } + /** * Check if the provided ranges contain the provided cell * @param {GridRange[]} ranges The ranges to check diff --git a/packages/grid/src/KeyHandler.ts b/packages/grid/src/KeyHandler.ts index 5544c0df72..3a6a165637 100644 --- a/packages/grid/src/KeyHandler.ts +++ b/packages/grid/src/KeyHandler.ts @@ -8,6 +8,12 @@ // eslint-disable-next-line import/no-cycle import Grid from './Grid'; +// True if consumed and to stop event propagation/prevent default, false if not consumed. +// OR an object if consumed with boolean properties to control whether to stopPropagation/preventDefault +export type KeyHandlerResponse = + | boolean + | { stopPropagation?: boolean; preventDefault?: boolean }; + class KeyHandler { private order; @@ -21,9 +27,9 @@ class KeyHandler { * Handle a keydown event on the grid. * @param event The keyboard event * @param grid The grid component the key press is on - * @returns True if consumed and to stop event propagation/prevent default, false if not consumed. + * @returns Response indicating if the key was consumed */ - onDown(event: KeyboardEvent, grid: Grid): boolean { + onDown(event: KeyboardEvent, grid: Grid): KeyHandlerResponse { return false; } } diff --git a/packages/grid/src/MockGridModel.js b/packages/grid/src/MockGridModel.js index 0046f55e40..1e47f4f88f 100644 --- a/packages/grid/src/MockGridModel.js +++ b/packages/grid/src/MockGridModel.js @@ -86,6 +86,13 @@ class MockGridModel extends GridModel { }); } + async setValues(edits) { + for (let i = 0; i < edits.length; i += 1) { + const edit = edits[i]; + this.setValueForCell(edit.x, edit.y, edit.text); + } + } + editValueForCell(x, y) { return this.textForCell(x, y); } diff --git a/packages/grid/src/errors/PasteError.ts b/packages/grid/src/errors/PasteError.ts new file mode 100644 index 0000000000..409645f42e --- /dev/null +++ b/packages/grid/src/errors/PasteError.ts @@ -0,0 +1,5 @@ +class PasteError extends Error { + isPasteError = true; +} + +export default PasteError; diff --git a/packages/grid/src/errors/index.ts b/packages/grid/src/errors/index.ts new file mode 100644 index 0000000000..b1164799b7 --- /dev/null +++ b/packages/grid/src/errors/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as PasteError } from './PasteError'; diff --git a/packages/grid/src/index.ts b/packages/grid/src/index.ts index 4b6301ac13..ba2e1a101e 100644 --- a/packages/grid/src/index.ts +++ b/packages/grid/src/index.ts @@ -10,3 +10,6 @@ export { default as GridUtils } from './GridUtils'; export { default as KeyHandler } from './KeyHandler'; export { default as MockGridModel } from './MockGridModel'; export { default as MockTreeGridModel } from './MockTreeGridModel'; +export * from './key-handlers'; +export * from './mouse-handlers'; +export * from './errors'; diff --git a/packages/grid/src/key-handlers/PasteKeyHandler.test.tsx b/packages/grid/src/key-handlers/PasteKeyHandler.test.tsx new file mode 100644 index 0000000000..22019e6162 --- /dev/null +++ b/packages/grid/src/key-handlers/PasteKeyHandler.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { parseValueFromElement } from './PasteKeyHandler'; + +function makeElementFromJsx(jsx: JSX.Element): HTMLElement { + const div = document.createElement('div'); + ReactDOM.render(jsx, div); + return div; +} + +describe('table parsing', () => { + const EMPTY_TABLE = ; + const SMALL_TABLE = ( +
+ + + + + + + + + + + + + + +
ABC
123
+ ); + + const EMPTY_DATA = [] as string[][]; + const SMALL_DATA = [ + ['A', 'B', 'C'], + ['1', '2', '3'], + ]; + + const SINGLE_ROW_DATA = [SMALL_DATA[0]]; + + /** + * Below are a couple of different representations for how the data comes in when pasting a text string with tab characters + * For example, if you paste 'A\tB\tC\n1\t2\t3' (two rows separated with new line, three columns separated by tab), in + * Chrome it pastes as one div per row and the tab characters are preserved. + * In Firefox, it converts the tabs to a combination of non-breaking spaces and regular spaces, and text node separated by + * break nodes. + */ + const TEXT_TABLE_CHROME = ( + <> +
+ A{'\t'}B{'\t'}C +
+
+ 1{'\t'}2{'\t'}3 +
+
+
+
+ + ); + + const SINGLE_ROW_CHROME = ( + <> + A{'\t'}B{'\t'}C + + ); + + const TEXT_TABLE_FIREFOX = ( + <> + A    B    C +
+ 1    2    3 + + ); + + const SINGLE_ROW_FIREFOX = <>A    B    C; + + function testTable(jsx: JSX.Element, expectedValue: string[][]) { + const element = makeElementFromJsx(jsx); + const result = parseValueFromElement(element); + expect(result).not.toBe(null); + if (result != null) { + expect(result.length).toBe(expectedValue.length); + for (let i = 0; i < expectedValue.length; i += 1) { + expect(result[i].length).toBe(expectedValue[i].length); + for (let j = 0; j < expectedValue[i].length; j += 1) { + expect(result[i][j]).toBe(expectedValue[i][j]); + } + } + } + } + + it('parses an empty table', () => { + testTable(EMPTY_TABLE, EMPTY_DATA); + }); + + it('parses a small table', () => { + testTable(SMALL_TABLE, SMALL_DATA); + }); + + it('parses a nested small table', () => { + testTable(
{SMALL_TABLE}
, SMALL_DATA); + }); + + it('parses out a basic div table', () => { + testTable(<>{TEXT_TABLE_CHROME}, SMALL_DATA); + }); + + it('parses out a basic text table', () => { + testTable(<>{TEXT_TABLE_FIREFOX}, SMALL_DATA); + }); + + it('parses out a single row in Chrome', () => { + testTable(<>{SINGLE_ROW_CHROME}, SINGLE_ROW_DATA); + }); + + it('parses out a single row in Firefox', () => { + testTable(<>{SINGLE_ROW_FIREFOX}, SINGLE_ROW_DATA); + }); +}); + +describe('text parsing', () => { + function testHtml(jsx: JSX.Element, expectedValue: string | null) { + const element = makeElementFromJsx(jsx); + const result = parseValueFromElement(element); + expect(result).toBe(expectedValue); + } + + it('parses empty html', () => { + testHtml(
, null); + }); + + it('parses simple text element', () => { + testHtml(
foo
, 'foo'); + }); +}); diff --git a/packages/grid/src/key-handlers/PasteKeyHandler.ts b/packages/grid/src/key-handlers/PasteKeyHandler.ts new file mode 100644 index 0000000000..7e52eb6359 --- /dev/null +++ b/packages/grid/src/key-handlers/PasteKeyHandler.ts @@ -0,0 +1,136 @@ +/* eslint class-methods-use-this: "off" */ +import Grid from '../Grid'; +import GridUtils from '../GridUtils'; +import KeyHandler from '../KeyHandler'; + +/** + * Parse out data from an HTML table. Currently does not support colspan/rowspan + * @param table HTML Table + * @returns A two dimensional array with the data found in the table + */ +export function parseValueFromTable(table: HTMLTableElement): string[][] { + const data = []; + const rows = table.querySelectorAll('tr'); + for (let r = 0; r < rows.length; r += 1) { + const row = rows[r]; + const cells = row.querySelectorAll('td'); + const rowData = []; + for (let c = 0; c < cells.length; c += 1) { + const cell = cells[c]; + rowData.push(cell.textContent?.trim() ?? ''); + } + data.push(rowData); + } + + return data; +} + +/** + * Parses out a table of data from HTML elements. Treats each element as one rows. + * Filters out blank rows. + * @param rows The elements to parse out + * @returns A string table of data + */ +export function parseValueFromNodes(nodes: NodeListOf): string[][] { + const result = [] as string[][]; + nodes.forEach(node => { + const text = node.textContent ?? ''; + if (text.length > 0) { + // When Chrome pastes a table from text, it preserves the tab characters + // In Firefox, it breaks it into a combination of non-breaking spaces and spaces + result.push(text.split(/\t|\u00a0\u00a0 \u00a0/)); + } + }); + + return result; +} + +export function parseValueFromElement( + element: HTMLElement +): string | string[][] | null { + // Check first if there's an HTML table element that we can use + const table = element.querySelector('table'); + if (table != null) { + return parseValueFromTable(table); + } + + // Otherwise check if there's any text content at all + const text = element.textContent?.trim() ?? ''; + if (text.length > 0) { + // If there's text content, try and parse out a table from the child nodes. Each node is a row. + // If there's only one row and it doesn't contain a tab, then just treat it as a regular value + const { childNodes } = element; + const hasTabChar = text.includes('\t'); + const hasFirefoxTab = text.includes('\u00a0\u00a0 \u00a0'); + if ( + hasTabChar && + childNodes.length !== 0 && + (childNodes.length === 1 || + (childNodes.length > 1 && !childNodes[0].textContent?.includes('\t'))) + ) { + // When Chrome pastes a single row, it gets split into multiple child nodes + // If we check the first child node and it doesn't have a tab character, but the full element text content does, then + // just parse the text out separated by the tab chars + return text.split('\n').map(row => row.split('\t')); + } + if (childNodes.length > 1 || hasFirefoxTab) { + return parseValueFromNodes(element.childNodes); + } + // If there's no tabs or no multiple rows, than just treat it as one value + return text; + } + return null; +} + +/** + * Handles the paste key combination + */ +class PasteKeyHandler extends KeyHandler { + onDown( + e: KeyboardEvent, + grid: Grid + ): boolean | { stopPropagation?: boolean; preventDefault?: boolean } { + switch (e.key) { + case 'v': + if (GridUtils.isModifierKeyDown(e)) { + // Chrome doesn't allow the paste event on canvas elements + // Instead, we capture the ctrl+v keydown, then do this to capture the input + const dummyInput = document.createElement('div'); + document.body.appendChild(dummyInput); + dummyInput.setAttribute('contenteditable', 'true'); + + // Give it invisible styling + dummyInput.setAttribute( + 'style', + 'clip-path: "inset(50%)"; height: 1px; width: 1px; margin: -1px; overflow: hidden; padding 0; position: absolute;' + ); + + const listener = () => { + dummyInput.removeEventListener('input', listener); + dummyInput.remove(); + + grid.focus(); + const value = parseValueFromElement(dummyInput); + if (value != null) { + grid.pasteValue(value); + } + }; + + // Listen for the `input` event, when there's a change to the HTML + // We could also listen to the `paste` event to get the clipboard data, but that's just text data + // By listening to `input`, we can get a table that's already parsed in HTML, which is easier to consume + dummyInput.addEventListener('input', listener); + + // Focus the element so it receives the paste event + dummyInput.focus(); + + // Don't block the paste event from updating our dummy input + return { preventDefault: false, stopPropagation: true }; + } + break; + } + return false; + } +} + +export default PasteKeyHandler; diff --git a/packages/grid/src/key-handlers/index.ts b/packages/grid/src/key-handlers/index.ts index 1091f43d10..f7bda23e92 100644 --- a/packages/grid/src/key-handlers/index.ts +++ b/packages/grid/src/key-handlers/index.ts @@ -1,3 +1,4 @@ export { default as SelectionKeyHandler } from './SelectionKeyHandler'; export { default as TreeKeyHandler } from './TreeKeyHandler'; export { default as EditKeyHandler } from './EditKeyHandler'; +export { default as PasteKeyHandler } from './PasteKeyHandler'; diff --git a/packages/iris-grid/src/IrisGrid.jsx b/packages/iris-grid/src/IrisGrid.jsx index 60f8d1156d..05c314b45b 100644 --- a/packages/iris-grid/src/IrisGrid.jsx +++ b/packages/iris-grid/src/IrisGrid.jsx @@ -55,6 +55,7 @@ import { IrisGridSortMouseHandler, PendingMouseHandler, } from './mousehandlers'; +import ToastBottomBar from './ToastBottomBar'; import IrisGridMetricCalculator from './IrisGridMetricCalculator'; import IrisGridModelUpdater from './IrisGridModelUpdater'; import IrisGridRenderer from './IrisGridRenderer'; @@ -136,6 +137,7 @@ export class IrisGrid extends Component { this.handleAnimationEnd = this.handleAnimationEnd.bind(this); this.handleChartChange = this.handleChartChange.bind(this); this.handleChartCreate = this.handleChartCreate.bind(this); + this.handleGridError = this.handleGridError.bind(this); this.handleFilterBarChange = this.handleFilterBarChange.bind(this); this.handleFilterBarDone = this.handleFilterBarDone.bind(this); this.handleFilterBarTab = this.handleFilterBarTab.bind(this); @@ -359,6 +361,8 @@ export class IrisGrid extends Component { pendingDataErrors: new Map(), pendingSavePromise: null, pendingSaveError: null, + + toastMessage: null, }; } @@ -1653,6 +1657,13 @@ export class IrisGrid extends Component { onCreateChart(settings, model.table); } + handleGridError(error) { + log.warn('Grid Error', error); + this.setState({ + toastMessage:
{`${error}`}
, + }); + } + handleFilterBarChange(value) { this.startLoading('Filtering...', true); @@ -2199,6 +2210,7 @@ export class IrisGrid extends Component { pendingRowCount, pendingDataErrors, pendingDataMap, + toastMessage, } = this.state; if (!isReady) { return null; @@ -2714,6 +2726,7 @@ export class IrisGrid extends Component { mouseHandlers={mouseHandlers} movedColumns={movedColumns} movedRows={movedRows} + onError={this.handleGridError} onViewChanged={this.handleViewChanged} onSelectionChanged={this.handleSelectionChanged} onMovedColumnsChanged={this.handleMovedColumnsChanged} @@ -2789,6 +2802,7 @@ export class IrisGrid extends Component { onSave={this.handlePendingCommitClicked} onDiscard={this.handlePendingDiscardClicked} /> + {toastMessage} } A promise that resolves successfully when the operation is complete, or rejects if there's an error */ async setValueForRanges(ranges, text) { @@ -1184,9 +1184,7 @@ class IrisGridTableModel extends IrisGridModel { const newRowData = new Map(rowData); const value = TableUtils.makeValue(column.type, text); if (value != null) { - newRowData.set(columnIndex, { - value: TableUtils.makeValue(column.type, text), - }); + newRowData.set(columnIndex, { value }); } else { newRowData.delete(columnIndex); } @@ -1253,6 +1251,141 @@ class IrisGridTableModel extends IrisGridModel { } } + async setValues(edits = []) { + log.debug('setValues(', edits, ')'); + if ( + !edits.every(edit => + this.isEditableRange(GridRange.makeCell(edit.x, edit.y)) + ) + ) { + throw new Error('Uneditable ranges', edits); + } + + try { + const newDataMap = new Map(this.pendingNewDataMap); + + // Cache the display values + edits.forEach(edit => { + const { x, y, text } = edit; + const column = this.columns[x]; + const value = TableUtils.makeValue(column.type, text); + const formattedText = + value != null + ? this.displayString(value, column.type, column.name) + : null; + this.cachePendingValue(x, y, formattedText); + + // Take care of updates to the pending new area as well, as that can be updated synchronously + const pendingRow = this.pendingRow(y); + if (pendingRow != null) { + if (!newDataMap.has(pendingRow)) { + newDataMap.set(pendingRow, { data: new Map() }); + } + + const row = newDataMap.get(pendingRow); + const { data: rowData } = row; + const newRowData = new Map(rowData); + if (value != null) { + newRowData.set(x, { value }); + } else { + newRowData.delete(x); + } + if (newRowData.size > 0) { + newDataMap.set(pendingRow, { ...row, data: newRowData }); + } else { + newDataMap.delete(pendingRow); + } + } + }); + + this.pendingDataMap = newDataMap; + + // Send an update right after setting the pending map so the values are displayed immediately + this.dispatchEvent(new CustomEvent(IrisGridModel.EVENT.UPDATED)); + + // Need to group by row... + const rowEditMap = edits.reduce((rowMap, edit) => { + if (!rowMap.has(edit.y)) { + rowMap.set(edit.y, []); + } + rowMap.get(edit.y).push(edit); + return rowMap; + }, new Map()); + + const ranges = GridRange.consolidate( + edits.map(edit => GridRange.makeCell(edit.x, edit.y)) + ); + const tableAreaRange = this.getTableAreaRange(); + const tableRanges = ranges + .map(range => GridRange.intersection(tableAreaRange, range)) + .filter(range => range != null); + + if (tableRanges.length > 0) { + // Get a snapshot of the full rows, as we need to write a full row when editing + const data = await this.snapshot( + tableRanges.map( + range => new GridRange(null, range.startRow, null, range.endRow) + ) + ); + const newRows = data.map((row, dataIndex) => { + let rowIndex = null; + let r = dataIndex; + for (let i = 0; i < tableRanges.length; i += 1) { + const range = tableRanges[i]; + const rangeRowCount = GridRange.rowCount([range]); + if (r < rangeRowCount) { + rowIndex = range.startRow + r; + break; + } + r -= rangeRowCount; + } + + const newRow = {}; + for (let c = 0; c < this.columns.length; c += 1) { + newRow[this.columns[c].name] = row[c]; + } + + const rowEdits = rowEditMap.get(rowIndex); + if (rowEdits != null) { + rowEdits.forEach(edit => { + const column = this.columns[edit.x]; + newRow[column.name] = TableUtils.makeValue( + column.type, + edit.text + ); + }); + } + return newRow; + }); + + log.info('setValues setting tableRanges', tableRanges); + + const result = await this.inputTable.addRows(newRows); + + log.info('setValues set tableRanges', tableRanges, 'SUCCESS', result); + } + + // We've sent the changes to the server, but have not yet got an update with those changes committed + // Add the changes to the formatted cache so it's still displayed until the update event is received + // The update event could be received on the next tick, after the input rows have been committed, + // so make sure we don't display stale data + edits.forEach(edit => { + const { x, y, text } = edit; + const column = this.columns[x]; + const value = TableUtils.makeValue(column.type, text); + const formattedText = + value != null + ? this.displayString(value, column.type, column.name) + : null; + this.cacheFormattedValue(x, y, formattedText); + }); + } finally { + edits.forEach(edit => { + this.clearPendingValue(edit.x, edit.y); + }); + } + } + async commitPending() { if (this.pendingNewDataMap.size <= 0) { throw new Error('No pending changes to commit'); diff --git a/packages/iris-grid/src/PendingDataBottomBar.tsx b/packages/iris-grid/src/PendingDataBottomBar.tsx index 0f9c31a05c..1733fc1d58 100644 --- a/packages/iris-grid/src/PendingDataBottomBar.tsx +++ b/packages/iris-grid/src/PendingDataBottomBar.tsx @@ -137,4 +137,4 @@ export const PendingDataBottomBar = ({ ); }; -export default IrisGridBottomBar; +export default PendingDataBottomBar; diff --git a/packages/iris-grid/src/TableUtils.js b/packages/iris-grid/src/TableUtils.js index c97b155760..4bd7405901 100644 --- a/packages/iris-grid/src/TableUtils.js +++ b/packages/iris-grid/src/TableUtils.js @@ -375,6 +375,15 @@ class TableUtils { } } + /** + * Get base column type + * @param {string} columnType Column type + * @returns {string} Element type for array columns, original type for non-array columns + */ + static getBaseType(columnType) { + return columnType.split('[]')[0]; + } + /** * Check if the column types are compatible * @param {string} type1 Column type to check @@ -989,10 +998,11 @@ class TableUtils { * @returns {dh.FilterValue} The FilterValue item for this column/value combination */ static makeFilterValue(columnType, value) { - if (TableUtils.isTextType(columnType)) { + const type = TableUtils.getBaseType(columnType); + if (TableUtils.isTextType(type)) { return dh.FilterValue.ofString(value); } - if (TableUtils.isLongType(columnType)) { + if (TableUtils.isLongType(type)) { return dh.FilterValue.ofNumber( dh.LongWrapper.ofString(TableUtils.removeCommas(value)) ); diff --git a/packages/iris-grid/src/ToastBottomBar.tsx b/packages/iris-grid/src/ToastBottomBar.tsx new file mode 100644 index 0000000000..710f8432a1 --- /dev/null +++ b/packages/iris-grid/src/ToastBottomBar.tsx @@ -0,0 +1,62 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { usePrevious } from '@deephaven/react-hooks'; +import IrisGridBottomBar from './IrisGridBottomBar'; +import './PendingDataBottomBar.scss'; + +const HIDE_TIMEOUT = 3000; + +export type ToastBottomBarProps = { + children?: React.ReactNode; + onEntering?: () => void; + onEntered?: () => void; + onExiting?: () => void; + onExited?: () => void; +}; + +export const ToastBottomBar = ({ + children = null, + onEntering, + onEntered, + onExiting, + onExited, +}: ToastBottomBarProps): JSX.Element => { + const [isShown, setIsShown] = useState(false); + const timeout = useRef>(); + const prevChildren = usePrevious(children); + + const startTimer = useCallback(() => { + setIsShown(true); + if (timeout.current) { + clearTimeout(timeout.current); + } + timeout.current = setTimeout(() => { + setIsShown(false); + }, HIDE_TIMEOUT); + }, [setIsShown, timeout]); + + useEffect(() => { + if (prevChildren !== children && children != null) { + startTimer(); + } + }, [children, prevChildren, setIsShown, startTimer]); + + useEffect( + () => () => (timeout.current ? clearTimeout(timeout.current) : undefined), + [] + ); + + return ( + + {children} + + ); +}; + +export default ToastBottomBar; diff --git a/packages/iris-grid/src/filters/FilterType.js b/packages/iris-grid/src/filters/FilterType.js index a672bf947c..7e9916c20c 100644 --- a/packages/iris-grid/src/filters/FilterType.js +++ b/packages/iris-grid/src/filters/FilterType.js @@ -39,6 +39,8 @@ class FilterType { static startsWith = 'startsWith'; static endsWith = 'endsWith'; + + static containsAny = 'containsAny'; } export default FilterType;