diff --git a/docs/pages/api-docs/data-grid/grid-api.md b/docs/pages/api-docs/data-grid/grid-api.md index ea39eac9082cd..2ab49a3b88047 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 0000000000000..173fe0945ad60 --- /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 0000000000000..669aa86252994 --- /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 9738ff87b849e..ddd84530d33fc 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 6e6cd254bb3f3..cbd7cb7f1073b 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 4bd4c7216e044..3b746d605ec41 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 647bd2db792df..48fc530901d0c 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 0000000000000..e3a7d857671c1 --- /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 ecf8a468cea46..7599251c833d5 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 6c826695b9597..b67df51608560 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 2de2f23bba407..bd60d0fa9d84c 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 0000000000000..59b4f52e7bf07 --- /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')); + }); + }); + }); +});