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
5 changes: 5 additions & 0 deletions packages/grid/_modules_/grid/constants/eventsConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export const GRID_DEBOUNCED_RESIZE = 'debouncedResize';
*/
export const GRID_SCROLL = 'scroll';

/**
* @ignore - do not document.
*/
export const GRID_KEYDOWN = 'keydown';

// GRID events

/**
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,101 @@
import * as React from 'react';
import { ownerDocument } from '@material-ui/core/utils';
import { GridApiRef } from '../../../models/api/gridApiRef';
import { useGridApiEventHandler } from '../../root/useGridApiEventHandler';
import { GRID_KEYDOWN } from '../../../constants/eventsConstants';
import { buildCSV } 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 doc = ownerDocument(apiRef.current.rootElementRef!.current!);
const span = doc.createElement('span');
span.style.whiteSpace = 'pre';
span.style.userSelect = 'all';
span.style.opacity = '0px';
span.textContent = data;

apiRef.current.rootElementRef!.current!.appendChild(span);
oliviertassinari marked this conversation as resolved.
Show resolved Hide resolved

const range = doc.createRange();
range.selectNode(span);
const selection = window.getSelection();
selection!.removeAllRanges();
selection!.addRange(range);

try {
doc.execCommand('copy');
} finally {
apiRef.current.rootElementRef!.current!.removeChild(span);
}
},
[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 data = buildCSV({
columns: visibleColumns,
rows: selectedRows,
includeHeaders,
getCellParams: apiRef.current.getCellParams,
delimiterCharacter: '\t',
});

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;
}

const selection = window.getSelection();
if (
selection?.anchorNode &&
apiRef.current.windowRef?.current?.contains(selection.anchorNode) &&
m4theushw marked this conversation as resolved.
Show resolved Hide resolved
selection.toString().length
m4theushw marked this conversation as resolved.
Show resolved Hide resolved
) {
// Do nothing if there's a native selection
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 @@ -36,24 +36,30 @@ export function serialiseRow(
interface BuildCSVOptions {
columns: GridColumns;
rows: Map<GridRowId, GridRowModel>;
selectedRows: Record<string, GridRowId>;
selectedRows?: Record<string, GridRowId>;
getCellParams: (id: GridRowId, field: string) => GridCellParams;
delimiterCharacter: GridExportCsvDelimiter;
includeHeaders?: boolean;
}

export function buildCSV(options: BuildCSVOptions): string {
const { columns, rows, selectedRows, getCellParams, delimiterCharacter } = options;
const {
columns,
rows,
selectedRows,
getCellParams,
delimiterCharacter,
includeHeaders = true,
} = options;
let rowIds = [...rows.keys()];
const selectedRowIds = Object.keys(selectedRows);

if (selectedRowIds.length) {
rowIds = rowIds.filter((id) => selectedRowIds.includes(`${id}`));
if (selectedRows) {
const selectedRowIds = Object.keys(selectedRows);
if (selectedRowIds.length) {
rowIds = rowIds.filter((id) => selectedRowIds.includes(`${id}`));
}
}

const CSVHead = `${columns
.filter((column) => column.field !== gridCheckboxSelectionColDef.field)
.map((column) => serialiseCellValue(column.headerName || column.field, delimiterCharacter))
.join(delimiterCharacter)}\r\n`;
const CSVBody = rowIds
.reduce<string>(
(acc, id) =>
Expand All @@ -63,7 +69,15 @@ export function buildCSV(options: BuildCSVOptions): string {
'',
)
.trim();
const csv = `${CSVHead}${CSVBody}`.trim();

return csv;
if (!includeHeaders) {
return CSVBody;
}

const CSVHead = `${columns
.filter((column) => column.field !== gridCheckboxSelectionColDef.field)
.map((column) => serialiseCellValue(column.headerName || column.field, delimiterCharacter))
.join(delimiterCharacter)}\r\n`;

return `${CSVHead}${CSVBody}`.trim();
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,17 @@ export function useGridParamsApi(apiRef: GridApiRef) {
(id: GridRowId, field: string) => {
const colDef = apiRef.current.getColumn(field);
const value = apiRef.current.getCellValue(id, field);
const baseParams = getBaseCellParams(id, field);
const row = apiRef.current.getRow(id);
const params: GridCellParams = {
...baseParams,
Copy link
Member Author

@m4theushw m4theushw Jun 22, 2021

Choose a reason for hiding this comment

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

I got an improvement from 20ms to 8ms in https://deploy-preview-1929--material-ui-x.netlify.app/components/data-grid/#commercial-version by removing this spread operator.

Copy link
Member

@oliviertassinari oliviertassinari Jun 24, 2021

Choose a reason for hiding this comment

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

Wow, it's pretty wild 👏!

On the full 100,000 rows data set, filtering goes from

Capture d’écran 2021-06-24 à 18 57 06

https://material-ui.com/components/data-grid/#commercial-version

to

Capture d’écran 2021-06-24 à 18 57 12

https://deploy-preview-1929--material-ui-x.netlify.app/components/data-grid/#commercial-version

Sorting is unchanged.

id,
field,
row,
colDef,
cellMode: apiRef.current.getCellMode(id, field),
getValue: apiRef.current.getCellValue,
api: apiRef.current,
hasFocus: cellFocus !== null && cellFocus.field === field && cellFocus.id === id,
tabIndex: cellTabIndex && cellTabIndex.field === field && cellTabIndex.id === id ? 0 : -1,
value,
formattedValue: value,
};
Expand All @@ -91,7 +99,7 @@ export function useGridParamsApi(apiRef: GridApiRef) {

return params;
},
[apiRef, getBaseCellParams],
[apiRef, cellFocus, cellTabIndex],
);

const getCellValue = React.useCallback(
Expand Down
17 changes: 17 additions & 0 deletions packages/grid/_modules_/grid/hooks/root/useEvents.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react';
import { GridApiRef } from '../../models/api/gridApiRef';
import { useGridSelector } from '../features/core/useGridSelector';
import { optionsSelector } from '../utils/optionsSelector';
Expand Down Expand Up @@ -26,8 +27,10 @@ import {
GRID_CELL_KEY_DOWN,
GRID_CELL_FOCUS_OUT,
GRID_CELL_BLUR,
GRID_KEYDOWN,
} from '../../constants/eventsConstants';
import { useGridApiOptionHandler } from './useGridApiEventHandler';
import { useNativeEventListener } from './useNativeEventListener';

export function useEvents(apiRef: GridApiRef): void {
const options = useGridSelector(apiRef, optionsSelector);
Expand Down Expand Up @@ -63,4 +66,18 @@ export function useEvents(apiRef: GridApiRef): void {

useGridApiOptionHandler(apiRef, GRID_COMPONENT_ERROR, options.onError);
useGridApiOptionHandler(apiRef, GRID_STATE_CHANGE, options.onStateChange);

const getHandler = React.useCallback(
(name: string) =>
(...args: any[]) =>
apiRef.current.publishEvent(name, ...args),
[apiRef],
);

useNativeEventListener(
apiRef,
apiRef.current.rootElementRef!,
GRID_KEYDOWN,
getHandler(GRID_KEYDOWN),
);
}
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 = string;

/**
* The options to apply on the CSV export.
Expand Down
Loading