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

[Discover] Provide cleaner text selection for the grid #192395

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kbn-discover-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export {
getFieldValue,
getVisibleColumns,
canPrependTimeFieldColumn,
overrideGridCopyEvent,
} from './src';

export type { LogsContextService } from './src';
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-discover-utils/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ export * from './nested_fields';
export * from './get_field_value';
export * from './calc_field_counts';
export * from './get_visible_columns';
export { overrideGridCopyEvent } from './override_grid_copy_event';
export { isLegacyTableEnabled } from './is_legacy_table_enabled';
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { ClipboardEvent } from 'react';
import { screen, render } from '@testing-library/react';
import { overrideGridCopyEvent } from './override_grid_copy_event';

describe('overrideGridCopyEvent', () => {
it('should override the default copy action for a selected text in grid', async () => {
render(
<div data-test-subj="grid">
<div role="row" className="euiDataGridHeader">
<div role="columnheader">
<div className="euiDataGridHeaderCell__content">
@timestamp
<div className="euiIcon">Icon</div>
</div>
<div>other elements</div>
</div>
<div role="columnheader">
<div>other elements</div>
<div className="euiDataGridHeaderCell__content">extension</div>
</div>
</div>
<div role="row">
<div role="gridcell">
<div className="euiDataGridRowCell__content">
Sep 12, 2024 @ 10:08:49.615
<div className="euiToken">Token</div>
</div>
<div>other elements</div>
</div>
<div role="gridcell">
<div className="euiDataGridRowCell__content">zip</div>
<div>other elements</div>
</div>
</div>
<div role="row">
<div role="gridcell">
<div className="euiDataGridRowCell__content">Sep 12, 2024 @ 14:08:49.615</div>
<div>other elements</div>
</div>
<div role="gridcell">
<div className="euiDataGridRowCell__content">
<img src="https://test.com/zip" alt="" />
</div>
<div>other elements</div>
</div>
</div>
<div role="row">
<div role="gridcell" className="euiDataGridRowCell--controlColumn">
<div className="euiDataGridRowCell__content">test</div>
</div>
<div role="gridcell">
<div className="euiDataGridRowCell__content">Sep 12, 2024 @ 15:08:49.615</div>
</div>
<div role="gridcell">
<div className="euiDataGridRowCell__content">
<dl
className="euiDescriptionList unifiedDataTable__descriptionList unifiedDataTable__cellValue css-id58dh-euiDescriptionList-inline-left"
data-test-subj="discoverCellDescriptionList"
>
<dt className="euiDescriptionList__title unifiedDataTable__descriptionListTitle css-1fizlic-euiDescriptionList__title-inline-compressed">
@timestamp
</dt>
<dd className="euiDescriptionList__description unifiedDataTable__descriptionListDescription css-11rdew2-euiDescriptionList__description-inline-compressed">
Sep 12, 2024 @ 10:08:49.615
</dd>
<dt className="euiDescriptionList__title unifiedDataTable__descriptionListTitle css-1fizlic-euiDescriptionList__title-inline-compressed">
bytes
</dt>
<dd className="euiDescriptionList__description unifiedDataTable__descriptionListDescription css-11rdew2-euiDescriptionList__description-inline-compressed">
7,490
</dd>
</dl>
</div>
</div>
</div>
</div>
);

const dataGridWrapper = await screen.getByTestId('grid');

const selection = global.window.getSelection();
const range = document.createRange();
range.selectNode(dataGridWrapper);
selection!.removeAllRanges();
selection!.addRange(range);

const copyEvent = {
preventDefault: jest.fn(),
clipboardData: {
setData: jest.fn(),
},
};

overrideGridCopyEvent({
event: copyEvent as unknown as ClipboardEvent<HTMLDivElement>,
dataGridWrapper,
});

expect(copyEvent.preventDefault).toHaveBeenCalled();
expect(copyEvent.clipboardData.setData).toHaveBeenCalledWith(
'text/plain',
'@timestamp\textension\n' +
'Sep 12, 2024 @ 10:08:49.615\tzip\n' +
'Sep 12, 2024 @ 14:08:49.615\thttps://test.com/zip\n' +
'Sep 12, 2024 @ 15:08:49.615\t@timestamp: Sep 12, 2024 @ 10:08:49.615, bytes: 7,490\n'
);
});
});
147 changes: 147 additions & 0 deletions packages/kbn-discover-utils/src/utils/override_grid_copy_event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { ClipboardEvent } from 'react';

interface OverrideGridCopyEventParams {
event: ClipboardEvent<HTMLDivElement>;
dataGridWrapper: HTMLElement | null | Element;
}

export function overrideGridCopyEvent({ event, dataGridWrapper }: OverrideGridCopyEventParams) {
try {
const selection = window.getSelection();
if (!selection) {
return;
}
const ranges = Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i));

if (!ranges.length || !event.clipboardData?.setData || !dataGridWrapper) {
return;
}

let tsvData = '';
let totalCellsCount = 0;
let totalRowsCount = 0;

const rows = dataGridWrapper.querySelectorAll('[role="row"]');
rows.forEach((row) => {
const isHeaderRow = row.classList?.contains('euiDataGridHeader');

const cells = row.querySelectorAll(
isHeaderRow ? '[role="columnheader"]' : '[role="gridcell"]'
);

const cellsTextContent: string[] = [];
let hasSelectedCellsInRow = false;

cells.forEach((cell) => {
if (
cell.classList?.contains?.(
isHeaderRow
? 'euiDataGridHeaderCell--controlColumn'
: 'euiDataGridRowCell--controlColumn'
) &&
cell.getAttribute('data-gridcell-column-id') !== 'timeline-event-detail-row' // in Security Solution "Event renderes" are appended as control column
) {
// skip controls
return;
}

const cellContentElement = cell.querySelector(
isHeaderRow ? '.euiDataGridHeaderCell__content' : '.euiDataGridRowCell__content'
);
if (!cellContentElement) {
return;
}

// get text content of selected cells
if (ranges.some((range) => range?.intersectsNode(cell))) {
cellsTextContent.push(getCellTextContent(cellContentElement));
hasSelectedCellsInRow = true;
totalCellsCount++;
} else {
cellsTextContent.push(''); // placeholder for empty cells
}
});

if (cellsTextContent.length > 0 && hasSelectedCellsInRow) {
tsvData += cellsTextContent.join('\t') + '\n';
totalRowsCount++;
}
});

if (totalRowsCount === 1) {
tsvData = tsvData.trim();
}

if (totalCellsCount > 1 && tsvData) {
event.preventDefault();
event.clipboardData.setData('text/plain', tsvData);
}
} catch {
// use default copy behavior
}
}

function getCellTextContent(cell: Element) {
const cellCloned = cell.cloneNode(true) as HTMLElement;

// remove from the grid
dropBySelector(cellCloned, '.euiIcon');
dropBySelector(cellCloned, '[data-euiicon-type]');
dropBySelector(cellCloned, '.euiToken');
dropBySelector(cellCloned, 'svg');

// Logs Explorer
appendTextToSelector(cellCloned, '[data-test-subj*="dataTablePopoverChip_"]', ', ', true);
appendTextToSelector(cellCloned, '[data-test-subj*="logLevelBadge-"]', ': ');

// for Document column
appendTextToSelector(cellCloned, '.unifiedDataTable__descriptionListTitle', ': ');
appendTextToSelector(cellCloned, '.unifiedDataTable__descriptionListDescription', ', ', true);

// a field value can be formatted as an image or audio => replace it with the src
replaceWithSrcTextNode(cellCloned, 'img');
replaceWithSrcTextNode(cellCloned, 'audio');

const textContent = (cellCloned.textContent || '').trim();

return textContent.replaceAll('\n', '');
}

function replaceWithSrcTextNode(element: HTMLElement, tagName: 'img' | 'audio') {
const tags = element.querySelectorAll('img');

tags.forEach((tag) => {
const textNode = document.createTextNode(tag.src);
tag.parentNode?.replaceChild(textNode, tag);
});
}

function dropBySelector(element: HTMLElement, selector: string) {
const elements = element.querySelectorAll(selector);
elements.forEach((el) => el.remove());
}

function appendTextToSelector(
element: HTMLElement,
selector: string,
text: string,
skipLast: boolean | undefined = false
) {
const elements = element.querySelectorAll(selector);
elements.forEach((el, index) => {
if (skipLast && index === elements.length - 1) {
return;
}
const textNode = document.createTextNode(text);
el.appendChild(textNode);
});
}
40 changes: 38 additions & 2 deletions packages/kbn-unified-data-table/src/components/data_table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { useCallback, useState } from 'react';
import React, { useCallback, useState, ClipboardEvent } from 'react';
import { ReactWrapper } from 'enzyme';
import {
EuiButton,
Expand All @@ -29,7 +29,7 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DataLoadingState, UnifiedDataTable, UnifiedDataTableProps } from './data_table';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { servicesMock } from '../../__mocks__/services';
import { buildDataTableRecord, getDocId } from '@kbn/discover-utils';
import { buildDataTableRecord, getDocId, overrideGridCopyEvent } from '@kbn/discover-utils';
import type { DataTableRecord, EsHitRecord } from '@kbn/discover-utils/types';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import {
Expand Down Expand Up @@ -1355,4 +1355,40 @@ describe('UnifiedDataTable', () => {
EXTENDED_JEST_TIMEOUT
);
});

describe('copy action', () => {
it(
'should override the default copy action for a selected text in grid',
async () => {
await renderDataTable({ columns: ['name'] });

const dataGridWrapper = await screen.getByTestId('euiDataGridBody');

const selection = global.window.getSelection();
const range = document.createRange();
range.selectNode(dataGridWrapper);
selection!.removeAllRanges();
selection!.addRange(range);

const copyEvent = {
preventDefault: jest.fn(),
clipboardData: {
setData: jest.fn(),
},
};

overrideGridCopyEvent({
event: copyEvent as unknown as ClipboardEvent<HTMLDivElement>,
dataGridWrapper,
});

expect(copyEvent.preventDefault).toHaveBeenCalled();
expect(copyEvent.clipboardData.setData).toHaveBeenCalledWith(
'text/plain',
'@timestamp\tname'
);
},
EXTENDED_JEST_TIMEOUT
);
});
});
14 changes: 13 additions & 1 deletion packages/kbn-unified-data-table/src/components/data_table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { ClipboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import { FormattedMessage } from '@kbn/i18n-react';
import { of } from 'rxjs';
Expand Down Expand Up @@ -44,6 +44,7 @@ import {
getShouldShowFieldHandler,
canPrependTimeFieldColumn,
getVisibleColumns,
overrideGridCopyEvent,
} from '@kbn/discover-utils';
import type { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
Expand Down Expand Up @@ -1048,6 +1049,16 @@ export const UnifiedDataTable = ({

const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher();

const onCopyGridCellsContent = useCallback(
(event: ClipboardEvent<HTMLDivElement>) => {
overrideGridCopyEvent({
event,
dataGridWrapper,
});
},
[dataGridWrapper]
);

const isRenderComplete = loadingState !== DataLoadingState.loading;

if (!rowCount && loadingState === DataLoadingState.loading) {
Expand Down Expand Up @@ -1098,6 +1109,7 @@ export const UnifiedDataTable = ({
data-description={searchDescription}
data-document-number={displayedRows.length}
className={classnames(className, 'unifiedDataTable__table')}
onCopy={onCopyGridCellsContent}
>
{isCompareActive ? (
<CompareDocuments
Expand Down
Loading