Skip to content

Commit

Permalink
copy selected rows to clipboard
Browse files Browse the repository at this point in the history
  • Loading branch information
m4theushw committed Jun 18, 2021
1 parent a5974e7 commit aa11f65
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 26 deletions.
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
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();
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 : '',
);

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');
};
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
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) {
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';

/**
* 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
102 changes: 102 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,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('<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();
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(<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'));
});
});
});
});

0 comments on commit aa11f65

Please sign in to comment.