Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/code-studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 102 additions & 3 deletions packages/grid/src/Grid.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
];
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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?
</canvas>
Expand Down Expand Up @@ -1524,6 +1621,7 @@ Grid.propTypes = {
to: PropTypes.number.isRequired,
})
),
onError: PropTypes.func,
onSelectionChanged: PropTypes.func,
onMovedColumnsChanged: PropTypes.func,
onMoveColumnComplete: PropTypes.func,
Expand All @@ -1545,6 +1643,7 @@ Grid.defaultProps = {
mouseHandlers: [],
movedColumns: [],
movedRows: [],
onError: () => {},
onSelectionChanged: () => {},
onMovedColumnsChanged: () => {},
onMoveColumnComplete: () => {},
Expand Down
56 changes: 56 additions & 0 deletions packages/grid/src/Grid.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
});
Expand Down Expand Up @@ -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);
});
});
});
12 changes: 12 additions & 0 deletions packages/grid/src/GridModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/grid/src/GridRange.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions packages/grid/src/KeyHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/grid/src/MockGridModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/grid/src/errors/PasteError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class PasteError extends Error {
isPasteError = true;
}

export default PasteError;
2 changes: 2 additions & 0 deletions packages/grid/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as PasteError } from './PasteError';
3 changes: 3 additions & 0 deletions packages/grid/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading