Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DataGrid] Allow to copy the selected rows to the clipboard #1929

Merged
merged 12 commits into from
Jun 29, 2021
1 change: 1 addition & 0 deletions docs/pages/api-docs/data-grid/grid-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { GridApi } from '@material-ui/x-grid';
| <span class="prop-name">commitCellChange</span> | <span class="prop-type">(params: GridEditCellPropsParams) =&gt; void</span> | Commits a cell change. Used to update the value when editing a cell. |
| <span class="prop-name">components</span> | <span class="prop-type">GridApiRefComponentsProperty</span> | The set of overridable components used in the grid. |
| <span class="prop-name optional">componentsProps<sup><abbr title="optional">?</abbr></sup></span> | <span class="prop-type">GridSlotsComponentsProps</span> | Overrideable components props dynamically passed to the component at rendering. |
| <span class="prop-name">copySelectedRowsToClipboard</span> | <span class="prop-type">(includeHeaders?: boolean) =&gt; void</span> | Copies the selected rows to the clipboard.<br />The fields will separated by the TAB character. |
| <span class="prop-name">deleteFilter</span> | <span class="prop-type">(item: GridFilterItem) =&gt; void</span> | Deletes a GridFilterItem. |
| <span class="prop-name">exportDataAsCsv</span> | <span class="prop-type">(options?: GridExportCsvOptions) =&gt; void</span> | Downloads and exports a CSV of the grid's data. |
| <span class="prop-name">forceUpdate</span> | <span class="prop-type">Dispatch&lt;any&gt;</span> | Forces the grid to rerender. It's often used after a state update. |
Expand Down
21 changes: 11 additions & 10 deletions docs/src/pages/components/data-grid/accessibility/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,17 @@ Use the arrow keys to move the focus.

### Selection

| Keys | Description |
| -------------------------------------------------------------------------------------------------: | :------------------------------------------------ |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> | Select the current row |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> + <kbd class="key">Arrow Up/Down</kbd> | Select the current row and the row above or below |
| <kbd class="key">CTRL</kbd> + <kbd class="key">A</kbd> | Select all rows |
| <kbd class="key">CTRL</kbd> + <kbd class="key">C</kbd> | Copy the currently selected row(s) |
| <kbd class="key">CTRL</kbd> + Click on cell | Enable multi-selection |
| <kbd class="key">CTRL</kbd> + Click on a selected row | Deselect the row |
| <kbd class="key">Enter</kbd> | Sort column when column header is focused |
| <kbd class="key">CTRL</kbd> + <kbd class="key">Enter</kbd> | Open column menu when column header is focused |
| Keys | Description |
| -------------------------------------------------------------------------------------------------: | :--------------------------------------------------- |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> | Select the current row |
| <kbd class="key">Shift</kbd> + <kbd class="key">Space</kbd> + <kbd class="key">Arrow Up/Down</kbd> | Select the current row and the row above or below |
| <kbd class="key">CTRL</kbd> + <kbd class="key">A</kbd> | Select all rows |
| <kbd class="key">CTRL</kbd> + <kbd class="key">C</kbd> | Copy the currently selected row(s) |
| <kbd class="key">ALT</kbd> + <kbd class="key">C</kbd> | Copy the currently selected row(s) including headers |
| <kbd class="key">CTRL</kbd> + Click on cell | Enable multi-selection |
| <kbd class="key">CTRL</kbd> + Click on a selected row | Deselect the row |
| <kbd class="key">Enter</kbd> | Sort column when column header is focused |
| <kbd class="key">CTRL</kbd> + <kbd class="key">Enter</kbd> | Open column menu when column header is focused |

### Sorting

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useGridClipboard';
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as React from 'react';
import { GridApiRef } from '../../../models/api/gridApiRef';
import { useGridApiEventHandler } from '../../root/useGridApiEventHandler';
import { GRID_KEYDOWN } from '../../../constants/eventsConstants';
import { serialiseRow, serialiseCellValue } from '../export/serializers/csvSerializer';
import { useGridSelector } from '../core/useGridSelector';
import { visibleGridColumnsSelector } from '../columns/gridColumnsSelector';
import { gridCheckboxSelectionColDef } from '../../../models/colDef';
import { GridClipboardApi } from '../../../models/api';
import { useGridApiMethod } from '../../root/useGridApiMethod';

export const useGridClipboard = (apiRef: GridApiRef): void => {
const visibleColumns = useGridSelector(apiRef, visibleGridColumnsSelector);

const writeToClipboardPolyfill = React.useCallback(
(data: string) => {
const textarea = document.createElement('textarea');
textarea.style.opacity = '0px';
textarea.value = data;
apiRef.current.rootElementRef!.current!.appendChild(textarea);
textarea.select();
document.execCommand('copy');
apiRef.current.rootElementRef!.current!.removeChild(textarea);
},
[apiRef],
);

const copySelectedRowsToClipboard = React.useCallback(
(includeHeaders = false) => {
const selectedRows = apiRef.current.getSelectedRows();
oliviertassinari marked this conversation as resolved.
Show resolved Hide resolved
const filteredColumns = visibleColumns.filter(
(column) => column.field !== gridCheckboxSelectionColDef.field,
);

if (selectedRows.size === 0 || filteredColumns.length === 0) {
return;
}

const headers = `${filteredColumns
.map((column) => serialiseCellValue(column.headerName || column.field, '\t'))
.join('\t')}\r\n`;

const data = [...selectedRows.keys()].reduce<string>(
(acc, id) =>
`${acc}${serialiseRow(id, filteredColumns, apiRef.current.getCellParams, '\t').join(
'\t',
)}\r\n`,
includeHeaders ? headers : '',
);
m4theushw marked this conversation as resolved.
Show resolved Hide resolved

if (navigator.clipboard) {
navigator.clipboard.writeText(data).catch(() => {
writeToClipboardPolyfill(data);
});
} else {
writeToClipboardPolyfill(data);
}
Comment on lines +56 to +62
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the docs, we use this package: https://github.com/feross/clipboard-copy/blob/master/index.js, in case there is interesting stuff to copy from them. Maybe we could actually make this function in its own file?

},
[apiRef, visibleColumns, writeToClipboardPolyfill],
);

const handleKeydown = React.useCallback(
(event: KeyboardEvent) => {
const isModifierKeyPressed = event.ctrlKey || event.metaKey || event.altKey;
if (event.key.toLowerCase() !== 'c' || !isModifierKeyPressed) {
return;
}
apiRef.current.copySelectedRowsToClipboard(event.altKey);
},
[apiRef],
);

useGridApiEventHandler(apiRef, GRID_KEYDOWN, handleKeydown);

const clipboardApi: GridClipboardApi = {
copySelectedRowsToClipboard,
};

useGridApiMethod(apiRef, clipboardApi, 'GridClipboardApi');
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '../../../../models';
import { GridExportCsvDelimiter } from '../../../../models/gridExport';

const serialiseCellValue = (value: any, delimiterCharacter: GridExportCsvDelimiter) => {
export const serialiseCellValue = (value: any, delimiterCharacter: GridExportCsvDelimiter) => {
if (typeof value === 'string') {
const formattedValue = value.replace(/"/g, '""');
return formattedValue.includes(delimiterCharacter) ? `"${formattedValue}"` : formattedValue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,15 @@ import { GridApiRef } from '../../../models/api/gridApiRef';
import { GridCellParams } from '../../../models/params/gridCellParams';
import {
findParentElementFromClassName,
getIdFromRowElem,
getRowEl,
isGridCellRoot,
isGridHeaderCellRoot,
} from '../../../utils/domUtils';
import { isEnterKey, isNavigationKey, isSpaceKey } from '../../../utils/keyboardUtils';
import { useGridSelector } from '../core/useGridSelector';
import { useLogger } from '../../utils/useLogger';
import { useGridApiEventHandler } from '../../root/useGridApiEventHandler';
import { gridSelectionStateSelector } from '../selection/gridSelectionSelector';

export const useGridKeyboard = (apiRef: GridApiRef): void => {
const logger = useLogger('useGridKeyboard');
const selectionState = useGridSelector(apiRef, gridSelectionStateSelector);

const expandSelection = React.useCallback(
(params: GridCellParams, event: React.KeyboardEvent) => {
Expand Down Expand Up @@ -63,22 +58,6 @@ export const useGridKeyboard = (apiRef: GridApiRef): void => {
[logger, apiRef],
);

const handleCopy = React.useCallback(
(target: HTMLElement) => {
const rowEl = getRowEl(target)!;
const rowId = getIdFromRowElem(rowEl);
const isRowSelected = selectionState[rowId];

if (isRowSelected) {
window?.getSelection()?.selectAllChildren(rowEl);
} else {
window?.getSelection()?.selectAllChildren(target);
}
document.execCommand('copy');
},
[selectionState],
);

const handleCellKeyDown = React.useCallback(
(params: GridCellParams, event: React.KeyboardEvent) => {
// The target is not an element when triggered by a Select inside the cell
Expand Down Expand Up @@ -115,7 +94,6 @@ export const useGridKeyboard = (apiRef: GridApiRef): void => {
}

if (event.key.toLowerCase() === 'c' && (event.ctrlKey || event.metaKey)) {
handleCopy(event.target as HTMLElement);
return;
}

Expand All @@ -124,7 +102,7 @@ export const useGridKeyboard = (apiRef: GridApiRef): void => {
apiRef.current.selectRows(apiRef.current.getAllRowIds(), true);
}
},
[apiRef, expandSelection, handleCopy],
[apiRef, expandSelection],
);

const handleColumnHeaderKeyDown = React.useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ export function useGridEditRows(apiRef: GridApiRef) {

const isEditMode = params.cellMode === 'edit';

if (!isEditMode && isCellEnterEditModeKeys(event.key)) {
const isModifierKeyPressed = event.ctrlKey || event.metaKey || event.altKey;
if (!isEditMode && isCellEnterEditModeKeys(event.key) && !isModifierKeyPressed) {
apiRef.current.publishEvent(GRID_CELL_EDIT_ENTER, params, event);
}
if (!isEditMode && isDeleteKeys(event.key)) {
Expand Down
8 changes: 8 additions & 0 deletions packages/grid/_modules_/grid/hooks/root/useEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
GRID_CELL_BLUR,
} from '../../constants/eventsConstants';
import { useGridApiOptionHandler } from './useGridApiEventHandler';
import { useNativeEventListener } from './useNativeEventListener';

export function useEvents(apiRef: GridApiRef): void {
const logger = useLogger('useEvents');
Expand Down Expand Up @@ -88,6 +89,13 @@ export function useEvents(apiRef: GridApiRef): void {
useGridApiOptionHandler(apiRef, GRID_COMPONENT_ERROR, options.onError);
useGridApiOptionHandler(apiRef, GRID_STATE_CHANGE, options.onStateChange);

useNativeEventListener(
apiRef,
apiRef.current.rootElementRef!,
GRID_KEYDOWN,
getHandler(GRID_KEYDOWN),
);

React.useEffect(() => {
if (apiRef.current.rootElementRef?.current) {
Copy link
Member Author

@m4theushw m4theushw Jun 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect doesn't work. When it runs, apiRef.current.rootElementRef?.current is null. It's already to be removed by #1862, so a native event listener is used.

logger.debug('Binding events listeners');
Expand Down
4 changes: 3 additions & 1 deletion packages/grid/_modules_/grid/models/api/gridApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { GridEventsApi } from './gridEventsApi';
import { GridDensityApi } from './gridDensityApi';
import { GridLocaleTextApi } from './gridLocaleTextApi';
import { GridCsvExportApi } from './gridCsvExportApi';
import { GridClipboardApi } from './gridClipboardApi';

/**
* The full grid API.
Expand All @@ -40,4 +41,5 @@ export interface GridApi
GridFilterApi,
GridColumnMenuApi,
GridPreferencesPanelApi,
GridLocaleTextApi {}
GridLocaleTextApi,
GridClipboardApi {}
11 changes: 11 additions & 0 deletions packages/grid/_modules_/grid/models/api/gridClipboardApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* The Clipboard API interface that is available in the grid [[apiRef]].
*/
export interface GridClipboardApi {
/**
* Copies the selected rows to the clipboard.
* The fields will separated by the TAB character.
* @param {boolean} includeHeaders Whether to include the headers or not. Default is `false`.
*/
copySelectedRowsToClipboard: (includeHeaders?: boolean) => void;
}
1 change: 1 addition & 0 deletions packages/grid/_modules_/grid/models/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export * from './gridFocusApi';
export * from './gridFilterApi';
export * from './gridColumnMenuApi';
export * from './gridPreferencesPanelApi';
export * from './gridClipboardApi';
2 changes: 1 addition & 1 deletion packages/grid/_modules_/grid/models/gridExport.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Available CSV delimiter characters used to separate fields.
*/
export type GridExportCsvDelimiter = ',' | ';';
export type GridExportCsvDelimiter = ',' | ';' | '\t';
m4theushw marked this conversation as resolved.
Show resolved Hide resolved

/**
* The options to apply on the CSV export.
Expand Down
2 changes: 2 additions & 0 deletions packages/grid/_modules_/grid/useGridComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useGridSelection } from './hooks/features/selection/useGridSelection';
import { useGridSorting } from './hooks/features/sorting/useGridSorting';
import { useGridComponents } from './hooks/features/useGridComponents';
import { useGridVirtualRows } from './hooks/features/virtualization/useGridVirtualRows';
import { useGridClipboard } from './hooks/features/clipboard/useGridClipboard';
import { useApi } from './hooks/root/useApi';
import { useEvents } from './hooks/root/useEvents';
import { useGridContainerProps } from './hooks/root/useGridContainerProps';
Expand Down Expand Up @@ -63,6 +64,7 @@ export const useGridComponent = (apiRef: GridApiRef, props: GridComponentProps)
useGridPagination(apiRef);
useGridCsvExport(apiRef);
useGridInfiniteLoader(apiRef);
useGridClipboard(apiRef);
useGridComponents(apiRef, props);
useStateProp(apiRef, props);
useRenderInfoLog(apiRef);
Expand Down
103 changes: 103 additions & 0 deletions packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as React from 'react';
import { GridApiRef, useGridApiRef, XGrid } from '@material-ui/x-grid';
import { expect } from 'chai';
import { stub } from 'sinon';
import {
createClientRenderStrictMode,
// @ts-expect-error need to migrate helpers to TypeScript
fireEvent,
} from 'test/utils';
import { getCell } from 'test/utils/helperFn';

const isJSDOM = /jsdom/.test(window.navigator.userAgent);

describe('<XGrid /> - Clipboard', () => {
// TODO v5: replace with createClientRender
const render = createClientRenderStrictMode();

const baselineProps = {
autoHeight: isJSDOM,
};

const columns = [{ field: 'id' }, { field: 'brand', headerName: 'Brand' }];

let apiRef: GridApiRef;

function Test() {
apiRef = useGridApiRef();
return (
<div style={{ width: 300, height: 300 }}>
<XGrid
{...baselineProps}
apiRef={apiRef}
columns={columns}
rows={[
{
id: 0,
brand: 'Nike',
},
{
id: 1,
brand: 'Adidas',
},
{
id: 2,
brand: 'Puma',
},
]}
/>
</div>
);
}

describe('copySelectedRowsToClipboard', () => {
let writeText;

before(function beforeHook() {
if (!isJSDOM) {
// Needs permission to read the clipboard
this.skip();
}
});

beforeEach(function beforeEachHook() {
writeText = stub().resolves();

Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
writable: true,
});
});

afterEach(function afterEachHook() {
Object.defineProperty(navigator, 'clipboard', { value: undefined });
});

it('should copy the selected rows to the clipboard', () => {
render(<Test />);
apiRef.current.selectRows([0, 1]);
apiRef.current.copySelectedRowsToClipboard();
expect(writeText.firstCall.args[0]).to.equal(['0\tNike', '1\tAdidas', ''].join('\r\n'));
});

it('should include the headers when includeHeaders=true', () => {
render(<Test />);
apiRef.current.selectRows([0, 1]);
apiRef.current.copySelectedRowsToClipboard(true);
expect(writeText.firstCall.args[0]).to.equal(
['id\tBrand', '0\tNike', '1\tAdidas', ''].join('\r\n'),
);
});

['ctrlKey', 'metaKey'].forEach((key) => {
it(`should copy the selected rows to the clipboard when ${key} + C is pressed`, () => {
render(<Test />);
apiRef.current.selectRows([0, 1]);
const cell = getCell(0, 0);
cell.focus();
fireEvent.keyDown(cell, { key: 'c', [key]: true });
expect(writeText.firstCall.args[0]).to.equal(['0\tNike', '1\tAdidas', ''].join('\r\n'));
});
});
});
});