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 = (
+
+
+
+ | A |
+ B |
+ C |
+
+
+
+
+ | 1 |
+ 2 |
+ 3 |
+
+
+
+ );
+
+ 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;