From 13e46583168c680df5e7689d89771ed6a20129e6 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Fri, 18 Jun 2021 17:04:19 -0300 Subject: [PATCH 01/11] copy selected rows to clipboard --- docs/pages/api-docs/data-grid/grid-api.md | 1 + .../grid/hooks/features/clipboard/index.ts | 1 + .../features/clipboard/useGridClipboard.ts | 80 ++++++++++++++ .../export/serializers/csvSerializer.ts | 2 +- .../features/keyboard/useGridKeyboard.ts | 24 +---- .../_modules_/grid/hooks/root/useEvents.ts | 8 ++ .../grid/_modules_/grid/models/api/gridApi.ts | 4 +- .../grid/models/api/gridClipboardApi.ts | 11 ++ .../grid/_modules_/grid/models/api/index.ts | 1 + .../grid/_modules_/grid/models/gridExport.ts | 2 +- .../grid/_modules_/grid/useGridComponent.tsx | 2 + .../x-grid/src/tests/clipboard.XGrid.test.tsx | 102 ++++++++++++++++++ 12 files changed, 212 insertions(+), 26 deletions(-) create mode 100644 packages/grid/_modules_/grid/hooks/features/clipboard/index.ts create mode 100644 packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts create mode 100644 packages/grid/_modules_/grid/models/api/gridClipboardApi.ts create mode 100644 packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx diff --git a/docs/pages/api-docs/data-grid/grid-api.md b/docs/pages/api-docs/data-grid/grid-api.md index ea39eac9082c..2ab49a3b8804 100644 --- a/docs/pages/api-docs/data-grid/grid-api.md +++ b/docs/pages/api-docs/data-grid/grid-api.md @@ -19,6 +19,7 @@ import { GridApi } from '@material-ui/x-grid'; | commitCellChange | (params: GridEditCellPropsParams) => void | Commits a cell change. Used to update the value when editing a cell. | | components | GridApiRefComponentsProperty | The set of overridable components used in the grid. | | componentsProps? | GridSlotsComponentsProps | Overrideable components props dynamically passed to the component at rendering. | +| copySelectedRowsToClipboard | (includeHeaders?: boolean) => void | Copies the selected rows to the clipboard.
The fields will separated by the TAB character. | | deleteFilter | (item: GridFilterItem) => void | Deletes a GridFilterItem. | | exportDataAsCsv | (options?: GridExportCsvOptions) => void | Downloads and exports a CSV of the grid's data. | | forceUpdate | Dispatch<any> | Forces the grid to rerender. It's often used after a state update. | diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/index.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/index.ts new file mode 100644 index 000000000000..173fe0945ad6 --- /dev/null +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/index.ts @@ -0,0 +1 @@ +export * from './useGridClipboard'; diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts new file mode 100644 index 000000000000..669aa8625299 --- /dev/null +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -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(); + 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( + (acc, id) => + `${acc}${serialiseRow(id, filteredColumns, apiRef.current.getCellParams, '\t').join( + '\t', + )}\r\n`, + includeHeaders ? headers : '', + ); + + if (navigator.clipboard) { + navigator.clipboard.writeText(data).catch(() => { + writeToClipboardPolyfill(data); + }); + } else { + writeToClipboardPolyfill(data); + } + }, + [apiRef, visibleColumns, writeToClipboardPolyfill], + ); + + const handleKeydown = React.useCallback( + (event: KeyboardEvent) => { + const isCtrlPressed = event.ctrlKey || event.metaKey; + if (event.key.toLowerCase() !== 'c' || !isCtrlPressed) { + return; + } + apiRef.current.copySelectedRowsToClipboard(event.shiftKey); + }, + [apiRef], + ); + + useGridApiEventHandler(apiRef, GRID_KEYDOWN, handleKeydown); + + const clipboardApi: GridClipboardApi = { + copySelectedRowsToClipboard, + }; + + useGridApiMethod(apiRef, clipboardApi, 'GridClipboardApi'); +}; diff --git a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts index 9738ff87b849..ddd84530d33f 100644 --- a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts +++ b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts @@ -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; diff --git a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts index 6e6cd254bb3f..cbd7cb7f1073 100644 --- a/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/keyboard/useGridKeyboard.ts @@ -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) => { @@ -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 @@ -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; } @@ -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( diff --git a/packages/grid/_modules_/grid/hooks/root/useEvents.ts b/packages/grid/_modules_/grid/hooks/root/useEvents.ts index 4bd4c7216e04..3b746d605ec4 100644 --- a/packages/grid/_modules_/grid/hooks/root/useEvents.ts +++ b/packages/grid/_modules_/grid/hooks/root/useEvents.ts @@ -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'); @@ -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) { logger.debug('Binding events listeners'); diff --git a/packages/grid/_modules_/grid/models/api/gridApi.ts b/packages/grid/_modules_/grid/models/api/gridApi.ts index 647bd2db792d..48fc530901d0 100644 --- a/packages/grid/_modules_/grid/models/api/gridApi.ts +++ b/packages/grid/_modules_/grid/models/api/gridApi.ts @@ -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. @@ -40,4 +41,5 @@ export interface GridApi GridFilterApi, GridColumnMenuApi, GridPreferencesPanelApi, - GridLocaleTextApi {} + GridLocaleTextApi, + GridClipboardApi {} diff --git a/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts b/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts new file mode 100644 index 000000000000..e3a7d857671c --- /dev/null +++ b/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts @@ -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; +} diff --git a/packages/grid/_modules_/grid/models/api/index.ts b/packages/grid/_modules_/grid/models/api/index.ts index ecf8a468cea4..7599251c833d 100644 --- a/packages/grid/_modules_/grid/models/api/index.ts +++ b/packages/grid/_modules_/grid/models/api/index.ts @@ -19,3 +19,4 @@ export * from './gridFocusApi'; export * from './gridFilterApi'; export * from './gridColumnMenuApi'; export * from './gridPreferencesPanelApi'; +export * from './gridClipboardApi'; diff --git a/packages/grid/_modules_/grid/models/gridExport.ts b/packages/grid/_modules_/grid/models/gridExport.ts index 6c826695b959..b67df5160856 100644 --- a/packages/grid/_modules_/grid/models/gridExport.ts +++ b/packages/grid/_modules_/grid/models/gridExport.ts @@ -1,7 +1,7 @@ /** * Available CSV delimiter characters used to separate fields. */ -export type GridExportCsvDelimiter = ',' | ';'; +export type GridExportCsvDelimiter = ',' | ';' | '\t'; /** * The options to apply on the CSV export. diff --git a/packages/grid/_modules_/grid/useGridComponent.tsx b/packages/grid/_modules_/grid/useGridComponent.tsx index 2de2f23bba40..bd60d0fa9d84 100644 --- a/packages/grid/_modules_/grid/useGridComponent.tsx +++ b/packages/grid/_modules_/grid/useGridComponent.tsx @@ -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'; @@ -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); diff --git a/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx new file mode 100644 index 000000000000..59b4f52e7bf0 --- /dev/null +++ b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx @@ -0,0 +1,102 @@ +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(' - 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 ( +
+ +
+ ); + } + + describe('copySelectedRowsToClipboard', () => { + let writeText; + + before(function beforeHook() { + if (!isJSDOM) { + // Needs permission to read the clipboard + this.skip(); + return; + } + + writeText = stub().resolves(); + + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + }); + }); + + after(function afterHook() { + Object.defineProperty(navigator, 'clipboard', { value: undefined }); + }); + + it('should copy the selected rows to the clipboard', () => { + render(); + 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(); + 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(); + 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')); + }); + }); + }); +}); From a7eb2d4245497162c9c6fd3e5e008e12fc06a313 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Fri, 18 Jun 2021 19:34:56 -0300 Subject: [PATCH 02/11] setup mock in beforeEach --- packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx index 59b4f52e7bf0..cd8c544a07d1 100644 --- a/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx +++ b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx @@ -57,9 +57,10 @@ describe(' - Clipboard', () => { if (!isJSDOM) { // Needs permission to read the clipboard this.skip(); - return; } + }); + beforeEach(function beforeEachHook() { writeText = stub().resolves(); Object.defineProperty(navigator, 'clipboard', { @@ -68,7 +69,7 @@ describe(' - Clipboard', () => { }); }); - after(function afterHook() { + afterEach(function afterEachHook() { Object.defineProperty(navigator, 'clipboard', { value: undefined }); }); From 53f9958368be0f361774a1ed9ecf74e144d0f870 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Fri, 18 Jun 2021 19:45:31 -0300 Subject: [PATCH 03/11] update shortcuts page --- .../data-grid/accessibility/accessibility.md | 21 ++++++++++--------- .../features/clipboard/useGridClipboard.ts | 6 +++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/src/pages/components/data-grid/accessibility/accessibility.md b/docs/src/pages/components/data-grid/accessibility/accessibility.md index b01dc79b5857..1e1903b51342 100644 --- a/docs/src/pages/components/data-grid/accessibility/accessibility.md +++ b/docs/src/pages/components/data-grid/accessibility/accessibility.md @@ -65,16 +65,17 @@ Use the arrow keys to move the focus. ### Selection -| Keys | Description | -| -------------------------------------------------------------------------------------------------: | :------------------------------------------------ | -| Shift + Space | Select the current row | -| Shift + Space + Arrow Up/Down | Select the current row and the row above or below | -| CTRL + A | Select all rows | -| CTRL + C | Copy the currently selected row(s) | -| CTRL + Click on cell | Enable multi-selection | -| CTRL + Click on a selected row | Deselect the row | -| Enter | Sort column when column header is focused | -| CTRL + Enter | Open column menu when column header is focused | +| Keys | Description | +| -------------------------------------------------------------------------------------------------: | :--------------------------------------------------- | +| Shift + Space | Select the current row | +| Shift + Space + Arrow Up/Down | Select the current row and the row above or below | +| CTRL + A | Select all rows | +| CTRL + C | Copy the currently selected row(s) | +| CTRL + ALT + C | Copy the currently selected row(s) including headers | +| CTRL + Click on cell | Enable multi-selection | +| CTRL + Click on a selected row | Deselect the row | +| Enter | Sort column when column header is focused | +| CTRL + Enter | Open column menu when column header is focused | ### Sorting diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts index 669aa8625299..2a8f5d09cffc 100644 --- a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -17,10 +17,10 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { const textarea = document.createElement('textarea'); textarea.style.opacity = '0px'; textarea.value = data; - apiRef.current.rootElementRef?.current?.appendChild(textarea); + apiRef.current.rootElementRef!.current!.appendChild(textarea); textarea.select(); document.execCommand('copy'); - apiRef.current.rootElementRef?.current?.removeChild(textarea); + apiRef.current.rootElementRef!.current!.removeChild(textarea); }, [apiRef], ); @@ -65,7 +65,7 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { if (event.key.toLowerCase() !== 'c' || !isCtrlPressed) { return; } - apiRef.current.copySelectedRowsToClipboard(event.shiftKey); + apiRef.current.copySelectedRowsToClipboard(event.altKey); }, [apiRef], ); From c903dda0bc3d611e8b6ffa7f6b4553ed6eec3054 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Fri, 18 Jun 2021 20:14:43 -0300 Subject: [PATCH 04/11] do not enter edit mode if modifier key is pressed --- .../grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts b/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts index 07283a3e077b..0a546279b180 100644 --- a/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts +++ b/packages/grid/_modules_/grid/hooks/features/rows/useGridEditRows.ts @@ -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)) { From bb133c675bf333684c9f781e59058b2a556a7c6d Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Fri, 18 Jun 2021 20:14:53 -0300 Subject: [PATCH 05/11] use alt + c to copy with headers --- .../pages/components/data-grid/accessibility/accessibility.md | 2 +- .../grid/hooks/features/clipboard/useGridClipboard.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/pages/components/data-grid/accessibility/accessibility.md b/docs/src/pages/components/data-grid/accessibility/accessibility.md index 1e1903b51342..b85c2011a049 100644 --- a/docs/src/pages/components/data-grid/accessibility/accessibility.md +++ b/docs/src/pages/components/data-grid/accessibility/accessibility.md @@ -71,7 +71,7 @@ Use the arrow keys to move the focus. | Shift + Space + Arrow Up/Down | Select the current row and the row above or below | | CTRL + A | Select all rows | | CTRL + C | Copy the currently selected row(s) | -| CTRL + ALT + C | Copy the currently selected row(s) including headers | +| ALT + C | Copy the currently selected row(s) including headers | | CTRL + Click on cell | Enable multi-selection | | CTRL + Click on a selected row | Deselect the row | | Enter | Sort column when column header is focused | diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts index 2a8f5d09cffc..ec207ff09aaa 100644 --- a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -61,8 +61,8 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { const handleKeydown = React.useCallback( (event: KeyboardEvent) => { - const isCtrlPressed = event.ctrlKey || event.metaKey; - if (event.key.toLowerCase() !== 'c' || !isCtrlPressed) { + const isModifierKeyPressed = event.ctrlKey || event.metaKey || event.altKey; + if (event.key.toLowerCase() !== 'c' || !isModifierKeyPressed) { return; } apiRef.current.copySelectedRowsToClipboard(event.altKey); From b6f24ec19f7d6fcacb69961693e8c84306ef1571 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Tue, 22 Jun 2021 16:19:17 -0300 Subject: [PATCH 06/11] improvements --- .../features/clipboard/useGridClipboard.ts | 56 ++++++++++++------- .../export/serializers/csvSerializer.ts | 38 +++++++++---- .../hooks/features/rows/useGridParamsApi.ts | 14 ++++- .../grid/_modules_/grid/models/gridExport.ts | 2 +- .../x-grid/src/tests/clipboard.XGrid.test.tsx | 6 +- 5 files changed, 78 insertions(+), 38 deletions(-) diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts index ec207ff09aaa..6af072442ddc 100644 --- a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -1,8 +1,9 @@ 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 { serialiseRow, serialiseCellValue } from '../export/serializers/csvSerializer'; +import { buildCSV } from '../export/serializers/csvSerializer'; import { useGridSelector } from '../core/useGridSelector'; import { visibleGridColumnsSelector } from '../columns/gridColumnsSelector'; import { gridCheckboxSelectionColDef } from '../../../models/colDef'; @@ -14,13 +15,26 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { 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); + 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); + + 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], ); @@ -36,17 +50,13 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { return; } - const headers = `${filteredColumns - .map((column) => serialiseCellValue(column.headerName || column.field, '\t')) - .join('\t')}\r\n`; - - const data = [...selectedRows.keys()].reduce( - (acc, id) => - `${acc}${serialiseRow(id, filteredColumns, apiRef.current.getCellParams, '\t').join( - '\t', - )}\r\n`, - includeHeaders ? headers : '', - ); + const data = buildCSV({ + columns: visibleColumns, + rows: selectedRows, + includeHeaders, + getCellParams: apiRef.current.getCellParams, + delimiterCharacter: '\t', + }); if (navigator.clipboard) { navigator.clipboard.writeText(data).catch(() => { @@ -65,6 +75,14 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { if (event.key.toLowerCase() !== 'c' || !isModifierKeyPressed) { return; } + + const selection = window.getSelection(); + const anchorNode = selection?.anchorNode; + if (anchorNode && apiRef.current.windowRef?.current?.contains(anchorNode)) { + // Do nothing if there's a native selection + return; + } + apiRef.current.copySelectedRowsToClipboard(event.altKey); }, [apiRef], diff --git a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts index ddd84530d33f..7cc108784475 100644 --- a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts +++ b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts @@ -7,7 +7,7 @@ import { } from '../../../../models'; import { GridExportCsvDelimiter } from '../../../../models/gridExport'; -export const serialiseCellValue = (value: any, delimiterCharacter: GridExportCsvDelimiter) => { +const serialiseCellValue = (value: any, delimiterCharacter: GridExportCsvDelimiter) => { if (typeof value === 'string') { const formattedValue = value.replace(/"/g, '""'); return formattedValue.includes(delimiterCharacter) ? `"${formattedValue}"` : formattedValue; @@ -36,24 +36,30 @@ export function serialiseRow( interface BuildCSVOptions { columns: GridColumns; rows: Map; - selectedRows: Record; + selectedRows?: Record; 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( (acc, id) => @@ -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(); } diff --git a/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts b/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts index a9bfd5889c79..5b2727da16b3 100644 --- a/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts +++ b/packages/grid/_modules_/grid/hooks/features/rows/useGridParamsApi.ts @@ -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, + 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, }; @@ -91,7 +99,7 @@ export function useGridParamsApi(apiRef: GridApiRef) { return params; }, - [apiRef, getBaseCellParams], + [apiRef, cellFocus, cellTabIndex], ); const getCellValue = React.useCallback( diff --git a/packages/grid/_modules_/grid/models/gridExport.ts b/packages/grid/_modules_/grid/models/gridExport.ts index b67df5160856..fbd0fee21f3f 100644 --- a/packages/grid/_modules_/grid/models/gridExport.ts +++ b/packages/grid/_modules_/grid/models/gridExport.ts @@ -1,7 +1,7 @@ /** * Available CSV delimiter characters used to separate fields. */ -export type GridExportCsvDelimiter = ',' | ';' | '\t'; +export type GridExportCsvDelimiter = string; /** * The options to apply on the CSV export. diff --git a/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx index cd8c544a07d1..d1953c9ea9c7 100644 --- a/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx +++ b/packages/grid/x-grid/src/tests/clipboard.XGrid.test.tsx @@ -77,7 +77,7 @@ describe(' - Clipboard', () => { render(); apiRef.current.selectRows([0, 1]); apiRef.current.copySelectedRowsToClipboard(); - expect(writeText.firstCall.args[0]).to.equal(['0\tNike', '1\tAdidas', ''].join('\r\n')); + expect(writeText.firstCall.args[0]).to.equal(['0\tNike', '1\tAdidas'].join('\r\n')); }); it('should include the headers when includeHeaders=true', () => { @@ -85,7 +85,7 @@ describe(' - Clipboard', () => { 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'), + ['id\tBrand', '0\tNike', '1\tAdidas'].join('\r\n'), ); }); @@ -96,7 +96,7 @@ describe(' - Clipboard', () => { 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')); + expect(writeText.firstCall.args[0]).to.equal(['0\tNike', '1\tAdidas'].join('\r\n')); }); }); }); From f0de0132ad03cc5f7c5f50d84b7be205267d3f50 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Tue, 22 Jun 2021 19:29:32 -0300 Subject: [PATCH 07/11] check if selected string is not empty --- .../grid/hooks/features/clipboard/useGridClipboard.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts index 6af072442ddc..1d5042d610eb 100644 --- a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -77,8 +77,11 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { } const selection = window.getSelection(); - const anchorNode = selection?.anchorNode; - if (anchorNode && apiRef.current.windowRef?.current?.contains(anchorNode)) { + if ( + selection?.anchorNode && + apiRef.current.windowRef?.current?.contains(selection.anchorNode) && + selection.toString().length + ) { // Do nothing if there's a native selection return; } From 48f575e1acb470881aafa954b7e6537dd6280401 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Tue, 22 Jun 2021 22:31:35 -0300 Subject: [PATCH 08/11] Update packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts Co-authored-by: Olivier Tassinari --- .../_modules_/grid/hooks/features/clipboard/useGridClipboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts index 1d5042d610eb..061819365c84 100644 --- a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -80,7 +80,7 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { if ( selection?.anchorNode && apiRef.current.windowRef?.current?.contains(selection.anchorNode) && - selection.toString().length + selection.toString() !== '' ) { // Do nothing if there's a native selection return; From bdfefdb19865abb7efa89dd6ad8767fadddc7fae Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Wed, 23 Jun 2021 09:35:10 -0300 Subject: [PATCH 09/11] simplify conditional --- .../grid/hooks/features/clipboard/useGridClipboard.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts index 061819365c84..eae3a5f30fa3 100644 --- a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -76,13 +76,8 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { return; } - const selection = window.getSelection(); - if ( - selection?.anchorNode && - apiRef.current.windowRef?.current?.contains(selection.anchorNode) && - selection.toString() !== '' - ) { - // Do nothing if there's a native selection + // Do nothing if there's a native selection + if (window.getSelection()?.toString() !== '') { return; } From 24634e457b8c335e534e89814d600c872fd19a07 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Thu, 24 Jun 2021 09:20:37 -0300 Subject: [PATCH 10/11] make copySelectedRowsToClipboard private --- docs/pages/api-docs/data-grid/grid-api.md | 1 - packages/grid/_modules_/grid/models/api/gridClipboardApi.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/api-docs/data-grid/grid-api.md b/docs/pages/api-docs/data-grid/grid-api.md index 2ab49a3b8804..ea39eac9082c 100644 --- a/docs/pages/api-docs/data-grid/grid-api.md +++ b/docs/pages/api-docs/data-grid/grid-api.md @@ -19,7 +19,6 @@ import { GridApi } from '@material-ui/x-grid'; | commitCellChange | (params: GridEditCellPropsParams) => void | Commits a cell change. Used to update the value when editing a cell. | | components | GridApiRefComponentsProperty | The set of overridable components used in the grid. | | componentsProps? | GridSlotsComponentsProps | Overrideable components props dynamically passed to the component at rendering. | -| copySelectedRowsToClipboard | (includeHeaders?: boolean) => void | Copies the selected rows to the clipboard.
The fields will separated by the TAB character. | | deleteFilter | (item: GridFilterItem) => void | Deletes a GridFilterItem. | | exportDataAsCsv | (options?: GridExportCsvOptions) => void | Downloads and exports a CSV of the grid's data. | | forceUpdate | Dispatch<any> | Forces the grid to rerender. It's often used after a state update. | diff --git a/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts b/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts index e3a7d857671c..da2af48940df 100644 --- a/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts +++ b/packages/grid/_modules_/grid/models/api/gridClipboardApi.ts @@ -6,6 +6,7 @@ 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`. + * @ignore - do not document. */ copySelectedRowsToClipboard: (includeHeaders?: boolean) => void; } From 634be879ce429e406ad1efb06573e87a15f31126 Mon Sep 17 00:00:00 2001 From: Matheus Wichman Date: Mon, 28 Jun 2021 11:00:37 -0300 Subject: [PATCH 11/11] move polyfill to outside --- .../features/clipboard/useGridClipboard.ts | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts index eae3a5f30fa3..dc49737ab5df 100644 --- a/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts +++ b/packages/grid/_modules_/grid/hooks/features/clipboard/useGridClipboard.ts @@ -1,5 +1,4 @@ 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'; @@ -10,34 +9,30 @@ 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); +function writeToClipboardPolyfill(data: string) { + const span = document.createElement('span'); + span.style.whiteSpace = 'pre'; + span.style.userSelect = 'all'; + span.style.opacity = '0px'; + span.textContent = data; - 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; + document.body.appendChild(span); - apiRef.current.rootElementRef!.current!.appendChild(span); + const range = document.createRange(); + range.selectNode(span); + const selection = window.getSelection(); + selection!.removeAllRanges(); + selection!.addRange(range); - const range = doc.createRange(); - range.selectNode(span); - const selection = window.getSelection(); - selection!.removeAllRanges(); - selection!.addRange(range); + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(span); + } +} - try { - doc.execCommand('copy'); - } finally { - apiRef.current.rootElementRef!.current!.removeChild(span); - } - }, - [apiRef], - ); +export const useGridClipboard = (apiRef: GridApiRef): void => { + const visibleColumns = useGridSelector(apiRef, visibleGridColumnsSelector); const copySelectedRowsToClipboard = React.useCallback( (includeHeaders = false) => { @@ -66,7 +61,7 @@ export const useGridClipboard = (apiRef: GridApiRef): void => { writeToClipboardPolyfill(data); } }, - [apiRef, visibleColumns, writeToClipboardPolyfill], + [apiRef, visibleColumns], ); const handleKeydown = React.useCallback(