diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e04de9c30e413..cffe9930c5787 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -356,6 +356,7 @@ packages/kbn-cypress-config @elastic/kibana-operations x-pack/platform/plugins/shared/dashboard_enhanced @elastic/kibana-presentation src/platform/plugins/shared/dashboard @elastic/kibana-presentation x-pack/platform/packages/shared/kbn-data-forge @elastic/obs-ux-management-team +src/platform/packages/shared/kbn-data-grid-in-table-search @elastic/kibana-data-discovery src/platform/plugins/shared/data @elastic/kibana-visualizations @elastic/kibana-data-discovery x-pack/platform/plugins/shared/data_quality @elastic/obs-ux-logs-team test/plugin_functional/plugins/data_search @elastic/kibana-data-discovery diff --git a/.i18nrc.json b/.i18nrc.json index b227f10d6e39c..9c2a500f0ac87 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -165,6 +165,7 @@ "unifiedFieldList": "src/platform/packages/shared/kbn-unified-field-list", "unifiedHistogram": "src/platform/plugins/shared/unified_histogram", "unifiedDataTable": "src/platform/packages/shared/kbn-unified-data-table", + "dataGridInTableSearch": "src/platform/packages/shared/kbn-data-grid-in-table-search", "unsavedChangesBadge": "src/platform/packages/private/kbn-unsaved-changes-badge", "unsavedChangesPrompt": "src/platform/packages/shared/kbn-unsaved-changes-prompt", "managedContentBadge": "src/platform/packages/private/kbn-managed-content-badge", diff --git a/package.json b/package.json index f09e8e77e090a..34604b3436320 100644 --- a/package.json +++ b/package.json @@ -422,6 +422,7 @@ "@kbn/dashboard-enhanced-plugin": "link:x-pack/platform/plugins/shared/dashboard_enhanced", "@kbn/dashboard-plugin": "link:src/platform/plugins/shared/dashboard", "@kbn/data-forge": "link:x-pack/platform/packages/shared/kbn-data-forge", + "@kbn/data-grid-in-table-search": "link:src/platform/packages/shared/kbn-data-grid-in-table-search", "@kbn/data-plugin": "link:src/platform/plugins/shared/data", "@kbn/data-quality-plugin": "link:x-pack/platform/plugins/shared/data_quality", "@kbn/data-search-plugin": "link:test/plugin_functional/plugins/data_search", diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/README.md b/src/platform/packages/shared/kbn-data-grid-in-table-search/README.md new file mode 100644 index 0000000000000..815b17440afc0 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/README.md @@ -0,0 +1,71 @@ +# @kbn/data-grid-in-table-search + +This package allows to extend `EuiDataGrid` with in-table search. + +If you are already using `UnifiedDataTable` component, you can enable in-table search simply by passing `enableInTableSearch` prop to it. + +```tsx + +``` + +If you are using `EuiDataGrid` directly, you can enable in-table search by importing this package and following these steps: + +1. include `useDataGridInTableSearch` hook in your component +2. pass `inTableSearchControl` to `EuiDataGrid` inside `additionalControls.right` prop or `renderCustomToolbar` +3. pass `inTableSearchCss` to the grid container element as `css` prop +4. update your `cellContext` prop with `cellContextWithInTableSearchSupport` +5. update your `renderCellValue` prop with `renderCellValueWithInTableSearchSupport`. + +```tsx + import { useDataGridInTableSearch } from '@kbn/data-grid-in-table-search'; + + // ... + + const dataGridRef = useRef(null); + const [dataGridWrapper, setDataGridWrapper] = useState(null); + + // ... + + const { inTableSearchTermCss, inTableSearchControl, cellContextWithInTableSearchSupport, renderCellValueWithInTableSearchSupport } = + useDataGridInTableSearch({ + dataGridWrapper, + dataGridRef, + visibleColumns, + rows, + cellContext, + renderCellValue, + pagination, + }); + + const toolbarVisibility: EuiDataGridProps['toolbarVisibility'] = useMemo( + () => ({ + additionalControls: inTableSearchControl ? { right: inTableSearchControl } : false, + // ... + }), + [inTableSearchControl] + ); + + // ... +
setDataGridWrapper(node)} css={inTableSearchCss}> + +
+``` + +An example of how to use this package can be found in `kbn-data-grid-in-table-search/__mocks__/data_grid_example.tsx` +or in `kbn-unified-data-table` package. + + + + + + diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/index.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/index.ts new file mode 100644 index 0000000000000..e37b682f21bad --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/index.ts @@ -0,0 +1,23 @@ +/* + * 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". + */ + +export { + useDataGridInTableSearch, + type UseDataGridInTableSearchProps, + type UseDataGridInTableSearchReturn, +} from './src'; + +export { + BUTTON_TEST_SUBJ, + COUNTER_TEST_SUBJ, + BUTTON_PREV_TEST_SUBJ, + BUTTON_NEXT_TEST_SUBJ, + INPUT_TEST_SUBJ, + HIGHLIGHT_CLASS_NAME, +} from './src/constants'; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/jest.config.js b/src/platform/packages/shared/kbn-data-grid-in-table-search/jest.config.js new file mode 100644 index 0000000000000..09cb9ce9b67b6 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/jest.config.js @@ -0,0 +1,14 @@ +/* + * 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". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/platform/packages/shared/kbn-data-grid-in-table-search'], +}; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/kibana.jsonc b/src/platform/packages/shared/kbn-data-grid-in-table-search/kibana.jsonc new file mode 100644 index 0000000000000..e0ab95d8cf70e --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/data-grid-in-table-search", + "owner": "@elastic/kibana-data-discovery", + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/package.json b/src/platform/packages/shared/kbn-data-grid-in-table-search/package.json new file mode 100644 index 0000000000000..a4d8ee36e86e3 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/data-grid-in-table-search", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", + "sideEffects": false +} \ No newline at end of file diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data.ts new file mode 100644 index 0000000000000..a578cd05b163a --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data.ts @@ -0,0 +1,22 @@ +/* + * 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". + */ + +export const generateMockData = (rowsCount: number, columnsCount: number) => { + const testData: string[][] = []; + + Array.from({ length: rowsCount }).forEach((_, i) => { + const row: string[] = []; + Array.from({ length: columnsCount }).forEach((__, j) => { + row.push(`cell-in-row-${i}-col-${j}`); + }); + testData.push(row); + }); + + return testData; +}; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data_grid_example.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data_grid_example.tsx new file mode 100644 index 0000000000000..a495918688cbc --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data_grid_example.tsx @@ -0,0 +1,95 @@ +/* + * 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, { useMemo, useRef, useState } from 'react'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridRefProps } from '@elastic/eui'; +import { generateMockData } from './data'; +import { getRenderCellValueMock } from './render_cell_value_mock'; +import { useDataGridInTableSearch } from '../use_data_grid_in_table_search'; + +export interface DataGridWithInTableSearchExampleProps { + rowsCount: number; + columnsCount: number; + pageSize: number | null; +} + +export const DataGridWithInTableSearchExample: React.FC = ({ + rowsCount, + columnsCount, + pageSize, +}) => { + const dataGridRef = useRef(null); + const [dataGridWrapper, setDataGridWrapper] = useState(null); + + const sampleData = useMemo( + () => generateMockData(rowsCount, columnsCount), + [rowsCount, columnsCount] + ); + const columns = useMemo( + () => Array.from({ length: columnsCount }, (_, i) => ({ id: `column-${i}` })), + [columnsCount] + ); + + const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id)); + + const renderCellValue = useMemo(() => getRenderCellValueMock(sampleData), [sampleData]); + + const isPaginationEnabled = typeof pageSize === 'number'; + const [pageIndex, setPageIndex] = useState(0); + const pagination = useMemo(() => { + return isPaginationEnabled + ? { + onChangePage: setPageIndex, + onChangeItemsPerPage: () => {}, + pageIndex, + pageSize, + } + : undefined; + }, [isPaginationEnabled, setPageIndex, pageSize, pageIndex]); + + const { + inTableSearchTermCss, + inTableSearchControl, + cellContextWithInTableSearchSupport, + renderCellValueWithInTableSearchSupport, + } = useDataGridInTableSearch({ + dataGridWrapper, + dataGridRef, + visibleColumns, + rows: sampleData, + cellContext: undefined, + renderCellValue, + pagination, + }); + + const toolbarVisibility: EuiDataGridProps['toolbarVisibility'] = useMemo( + () => ({ + additionalControls: inTableSearchControl ? { right: inTableSearchControl } : false, + }), + [inTableSearchControl] + ); + + return ( +
setDataGridWrapper(node)} css={inTableSearchTermCss}> + +
+ ); +}; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/index.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/index.ts new file mode 100644 index 0000000000000..6ede0a0b63ea6 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/index.ts @@ -0,0 +1,12 @@ +/* + * 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". + */ + +export { generateMockData } from './data'; +export { getRenderCellValueMock } from './render_cell_value_mock'; +export { DataGridWithInTableSearchExample } from './data_grid_example'; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/render_cell_value_mock.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/render_cell_value_mock.tsx new file mode 100644 index 0000000000000..0016fce431b7d --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/render_cell_value_mock.tsx @@ -0,0 +1,26 @@ +/* + * 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 from 'react'; +import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; + +export function getRenderCellValueMock(testData: string[][]) { + return function OriginalRenderCellValue({ + colIndex, + rowIndex, + }: EuiDataGridCellValueElementProps) { + const cellValue = testData[rowIndex][colIndex]; + + if (!cellValue) { + throw new Error('Testing unexpected errors'); + } + + return
{cellValue}
; + }; +} diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__snapshots__/in_table_search_input.test.tsx.snap b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__snapshots__/in_table_search_input.test.tsx.snap new file mode 100644 index 0000000000000..3ef95996170af --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__snapshots__/in_table_search_input.test.tsx.snap @@ -0,0 +1,186 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InTableSearchInput renders input 1`] = ` +
+
+
+
+ + +
+ +
+
+
+
+
+ 5/10 +   +
+
+
+ +
+
+ +
+
+
+
+
+`; + +exports[`InTableSearchInput renders input when loading 1`] = ` +
+
+
+
+ + +
+ +
+ +
+
+
+
+
+
+ 0/0 +   +
+
+
+ +
+
+ +
+
+
+
+
+`; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/constants.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/constants.ts new file mode 100644 index 0000000000000..1bb3b38da518c --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/constants.ts @@ -0,0 +1,19 @@ +/* + * 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". + */ + +export const CELL_MATCH_INDEX_ATTRIBUTE = 'data-match-index'; +export const HIGHLIGHT_CLASS_NAME = 'dataGridInTableSearch__match'; +export const HIGHLIGHT_COLOR = '#e5ffc0'; // TODO: Use a named color token +export const ACTIVE_HIGHLIGHT_COLOR = '#ffc30e'; // TODO: Use a named color token + +export const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; +export const INPUT_TEST_SUBJ = 'inTableSearchInput'; +export const COUNTER_TEST_SUBJ = 'inTableSearchMatchesCounter'; +export const BUTTON_PREV_TEST_SUBJ = 'inTableSearchButtonPrev'; +export const BUTTON_NEXT_TEST_SUBJ = 'inTableSearchButtonNext'; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx new file mode 100644 index 0000000000000..cab302a30c7a9 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx @@ -0,0 +1,217 @@ +/* + * 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, { useCallback, useState, useEffect, useRef } from 'react'; +import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css, type SerializedStyles } from '@emotion/react'; +import { useFindMatches } from './matches/use_find_matches'; +import { InTableSearchInput } from './in_table_search_input'; +import { UseFindMatchesProps } from './types'; +import { + ACTIVE_HIGHLIGHT_COLOR, + CELL_MATCH_INDEX_ATTRIBUTE, + HIGHLIGHT_CLASS_NAME, + BUTTON_TEST_SUBJ, + INPUT_TEST_SUBJ, +} from './constants'; + +const innerCss = css` + .dataGridInTableSearch__matchesCounter { + font-variant-numeric: tabular-nums; + } + + .dataGridInTableSearch__input { + /* to prevent the width from changing when entering the search term */ + min-width: 210px; + } + + .euiFormControlLayout__append { + padding-inline-end: 0 !important; + background: none; + } + + /* override borders style only if it's under the custom grid toolbar */ + .unifiedDataTableToolbarControlIconButton & .euiFormControlLayout, + .unifiedDataTableToolbarControlIconButton & .dataGridInTableSearch__input { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; + } +`; + +export interface InTableSearchControlProps + extends Omit { + pageSize: number | null; // null when the pagination is disabled + getColumnIndexFromId: (columnId: string) => number; + scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'center' }) => void; + shouldOverrideCmdF: (element: HTMLElement) => boolean; + onChange: (searchTerm: string | undefined) => void; + onChangeCss: (styles: SerializedStyles) => void; + onChangeToExpectedPage: (pageIndex: number) => void; +} + +export const InTableSearchControl: React.FC = ({ + pageSize, + getColumnIndexFromId, + scrollToCell, + shouldOverrideCmdF, + onChange, + onChangeCss, + onChangeToExpectedPage, + ...props +}) => { + const { euiTheme } = useEuiTheme(); + const containerRef = useRef(null); + const shouldReturnFocusToButtonRef = useRef(false); + const [isInputVisible, setIsInputVisible] = useState(false); + + const onScrollToActiveMatch: UseFindMatchesProps['onScrollToActiveMatch'] = useCallback( + ({ rowIndex, columnId, matchIndexWithinCell }) => { + if (typeof pageSize === 'number') { + const expectedPageIndex = Math.floor(rowIndex / pageSize); + onChangeToExpectedPage(expectedPageIndex); + } + + // The cell border is useful when the active match is not visible due to the limited cell height. + onChangeCss(css` + .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${columnId}'] { + &:after { + content: ''; + z-index: 2; + pointer-events: none; + position: absolute; + inset: 0; + border: 2px solid ${ACTIVE_HIGHLIGHT_COLOR} !important; + border-radius: 3px; + } + .${HIGHLIGHT_CLASS_NAME}[${CELL_MATCH_INDEX_ATTRIBUTE}='${matchIndexWithinCell}'] { + background-color: ${ACTIVE_HIGHLIGHT_COLOR} !important; + } + } + `); + + // getting rowIndex for the visible page + const visibleRowIndex = typeof pageSize === 'number' ? rowIndex % pageSize : rowIndex; + + scrollToCell({ + rowIndex: visibleRowIndex, + columnIndex: getColumnIndexFromId(columnId), + align: 'center', + }); + }, + [getColumnIndexFromId, scrollToCell, onChangeCss, onChangeToExpectedPage, pageSize] + ); + + const { + matchesCount, + activeMatchPosition, + isProcessing, + goToPrevMatch, + goToNextMatch, + renderCellsShadowPortal, + resetState, + } = useFindMatches({ ...props, onScrollToActiveMatch }); + + const showInput = useCallback(() => { + setIsInputVisible(true); + }, [setIsInputVisible]); + + const hideInput = useCallback( + (shouldReturnFocusToButton: boolean = false) => { + setIsInputVisible(false); + resetState(); + shouldReturnFocusToButtonRef.current = shouldReturnFocusToButton; + }, + [setIsInputVisible, resetState] + ); + + // listens for the cmd+f or ctrl+f keydown event to open the input + useEffect(() => { + const handleGlobalKeyDown = (event: KeyboardEvent) => { + if ( + (event.metaKey || event.ctrlKey) && + event.key === 'f' && + shouldOverrideCmdF(event.target as HTMLElement) + ) { + event.preventDefault(); // prevent default browser find-in-page behavior + showInput(); + + // if the input was already open before, make sure to shift the focus back to it + ( + containerRef.current?.querySelector( + `[data-test-subj="${INPUT_TEST_SUBJ}"]` + ) as HTMLInputElement + )?.focus(); + } + }; + + document.addEventListener('keydown', handleGlobalKeyDown); + + return () => { + document.removeEventListener('keydown', handleGlobalKeyDown); + }; + }, [showInput, shouldOverrideCmdF]); + + // returns focus to the button when the input was cancelled by pressing the escape key + useEffect(() => { + if (shouldReturnFocusToButtonRef.current && !isInputVisible) { + shouldReturnFocusToButtonRef.current = false; + ( + containerRef.current?.querySelector( + `[data-test-subj="${BUTTON_TEST_SUBJ}"]` + ) as HTMLButtonElement + )?.focus(); + } + }, [isInputVisible]); + + return ( +
(containerRef.current = node)} css={innerCss}> + {isInputVisible ? ( + <> + + {/* We include it here so the same parent contexts (like KibanaRenderContextProvider, UnifiedDataTableContext etc) will be applied to the portal components too */} + {/* as they do for the current component */} + {renderCellsShadowPortal ? renderCellsShadowPortal() : null} + + ) : ( + + + + )} +
+ ); +}; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.test.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.test.tsx new file mode 100644 index 0000000000000..1782e21d8ab17 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.test.tsx @@ -0,0 +1,186 @@ +/* + * 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 from 'react'; +import { InTableSearchHighlightsWrapper } from './in_table_search_highlights_wrapper'; +import { render, waitFor, screen } from '@testing-library/react'; + +describe('InTableSearchHighlightsWrapper', () => { + describe('modifies the DOM and adds search highlights', () => { + it('with matches', async () => { + const { container } = render( + +
+ Some text here with test and test and even more Test to be sure +
test
+
this
+ not for test +
+
+ ); + + await waitFor(() => { + expect(screen.getAllByText('test')).toHaveLength(3); + expect(screen.getAllByText('Test')).toHaveLength(1); + }); + + expect(container.innerHTML).toMatchInlineSnapshot( + `"
Some text here with test and test and even more Test to be sure
test
this
\\"not
"` + ); + }); + + it('with single match', async () => { + const { container } = render( + +
test2
+
+ ); + + await waitFor(() => { + expect(screen.getAllByText('test2')).toHaveLength(1); + }); + + expect(container.innerHTML).toMatchInlineSnapshot( + `"
test2
"` + ); + }); + + it('with no matches', async () => { + const { container } = render( + +
test2
+
+ ); + + expect(container.innerHTML).toMatchInlineSnapshot(`"
test2
"`); + }); + + it('escape the input with tags', async () => { + const { container } = render( + +
+
+
test
+
{'this
'}
+
+
+ ); + + await waitFor(() => { + expect(screen.getAllByText('
')).toHaveLength(1); + }); + + expect(container.innerHTML).toMatchInlineSnapshot( + `"

test
this <hr />
"` + ); + }); + + it('escape the input with regex', async () => { + const { container } = render( + +
test this now.
+
+ ); + + await waitFor(() => { + expect(screen.getAllByText('.')).toHaveLength(1); + }); + + expect(container.innerHTML).toMatchInlineSnapshot( + `"
test this now.
"` + ); + }); + + it('with no search term', async () => { + const { container } = render( + +
test
+
+ ); + + expect(container.innerHTML).toMatchInlineSnapshot(`"
test
"`); + }); + }); + + describe('does not modify the DOM and only counts search matches (dry run)', () => { + it('with matches', async () => { + const onHighlightsCountFound = jest.fn(); + const { container } = render( + +
+ Some text here with test and test and even more Test to be sure +
test
+
this
+ not for test +
+
+ ); + + await waitFor(() => { + expect(onHighlightsCountFound).toHaveBeenCalledWith(4); + }); + + expect(container.innerHTML).toMatchInlineSnapshot( + `"
Some text here with test and test and even more Test to be sure
test
this
\\"not
"` + ); + }); + + it('with single match', async () => { + const onHighlightsCountFound = jest.fn(); + + const { container } = render( + +
test2
+
+ ); + + await waitFor(() => { + expect(onHighlightsCountFound).toHaveBeenCalledWith(1); + }); + + expect(container.innerHTML).toMatchInlineSnapshot(`"
test2
"`); + }); + + it('with no matches', async () => { + const onHighlightsCountFound = jest.fn(); + const { container } = render( + +
test2
+
+ ); + + await waitFor(() => { + expect(onHighlightsCountFound).toHaveBeenCalledWith(0); + }); + + expect(container.innerHTML).toMatchInlineSnapshot(`"
test2
"`); + }); + + it('with no search term', async () => { + const onHighlightsCountFound = jest.fn(); + const { container } = render( + +
test
+
+ ); + + expect(container.innerHTML).toMatchInlineSnapshot(`"
test
"`); + expect(onHighlightsCountFound).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.tsx new file mode 100644 index 0000000000000..a4f62634ee11c --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.tsx @@ -0,0 +1,106 @@ +/* + * 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, { useEffect, useRef } from 'react'; +import { escapeRegExp, memoize } from 'lodash'; +import { HIGHLIGHT_COLOR, HIGHLIGHT_CLASS_NAME, CELL_MATCH_INDEX_ATTRIBUTE } from './constants'; +import { InTableSearchHighlightsWrapperProps } from './types'; + +/** + * Counts and highlights search term matches in the children of the component + */ +export const InTableSearchHighlightsWrapper: React.FC = ({ + inTableSearchTerm, + onHighlightsCountFound, + children, +}) => { + const cellValueRef = useRef(null); + const timerRef = useRef(null); + + const dryRun = Boolean(onHighlightsCountFound); // only to count highlights, not to modify the DOM + const shouldCallCallbackRef = useRef(dryRun); + + useEffect(() => { + if (inTableSearchTerm && cellValueRef.current) { + const cellNode = cellValueRef.current; + + const searchForMatches = () => { + const count = modifyDOMAndAddSearchHighlights(cellNode, inTableSearchTerm, dryRun); + if (shouldCallCallbackRef.current) { + shouldCallCallbackRef.current = false; + onHighlightsCountFound?.(count); + } + }; + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(searchForMatches, 0); + } + }, [dryRun, inTableSearchTerm, children, onHighlightsCountFound]); + + return
{children}
; +}; + +const getSearchTermRegExp = memoize((searchTerm: string): RegExp => { + return new RegExp(`(${escapeRegExp(searchTerm.trim())})`, 'gi'); +}); + +function modifyDOMAndAddSearchHighlights( + originalNode: Node, + inTableSearchTerm: string, + dryRun: boolean +): number { + let matchIndex = 0; + const searchTermRegExp = getSearchTermRegExp(inTableSearchTerm); + + function insertSearchHighlights(node: Node) { + if (node.nodeType === Node.ELEMENT_NODE) { + Array.from(node.childNodes).forEach(insertSearchHighlights); + return; + } + + if (node.nodeType === Node.TEXT_NODE) { + const nodeWithText = node as Text; + const textContent = nodeWithText.textContent || ''; + + if (dryRun) { + const nodeMatchesCount = (textContent.match(searchTermRegExp) || []).length; + matchIndex += nodeMatchesCount; + return; + } + + const parts = textContent.split(searchTermRegExp); + + if (parts.length > 1) { + const nodeWithHighlights = document.createDocumentFragment(); + + parts.forEach(function insertHighlights(part) { + if (searchTermRegExp.test(part)) { + const mark = document.createElement('mark'); + mark.textContent = part; + mark.style.backgroundColor = HIGHLIGHT_COLOR; + mark.setAttribute('class', HIGHLIGHT_CLASS_NAME); + mark.setAttribute(CELL_MATCH_INDEX_ATTRIBUTE, `${matchIndex++}`); + nodeWithHighlights.appendChild(mark); + } else { + nodeWithHighlights.appendChild(document.createTextNode(part)); + } + }); + + nodeWithText.replaceWith(nodeWithHighlights); + } + } + } + + Array.from(originalNode.childNodes).forEach(insertSearchHighlights); + + return matchIndex; +} diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.test.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.test.tsx new file mode 100644 index 0000000000000..05b7e97f40174 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.test.tsx @@ -0,0 +1,149 @@ +/* + * 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 from 'react'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import { InTableSearchInput } from './in_table_search_input'; +import { INPUT_TEST_SUBJ, BUTTON_PREV_TEST_SUBJ, BUTTON_NEXT_TEST_SUBJ } from './constants'; + +describe('InTableSearchInput', () => { + it('renders input', async () => { + const goToPrevMatch = jest.fn(); + const goToNextMatch = jest.fn(); + const onChangeSearchTerm = jest.fn(); + const onHideInput = jest.fn(); + + const { container } = render( + + ); + + const prevButton = screen.getByTestId(BUTTON_PREV_TEST_SUBJ); + expect(prevButton).toBeEnabled(); + prevButton.click(); + expect(goToPrevMatch).toHaveBeenCalled(); + + const nextButton = screen.getByTestId(BUTTON_NEXT_TEST_SUBJ); + expect(nextButton).toBeEnabled(); + nextButton.click(); + expect(goToNextMatch).toHaveBeenCalled(); + + expect(container).toMatchSnapshot(); + }); + + it('renders input when loading', async () => { + const goToPrevMatch = jest.fn(); + const goToNextMatch = jest.fn(); + const onChangeSearchTerm = jest.fn(); + const onHideInput = jest.fn(); + + const { container } = render( + + ); + + expect(screen.getByTestId(BUTTON_PREV_TEST_SUBJ)).toBeDisabled(); + expect(screen.getByTestId(BUTTON_NEXT_TEST_SUBJ)).toBeDisabled(); + + expect(container).toMatchSnapshot(); + }); + + it('handles changes', async () => { + const goToPrevMatch = jest.fn(); + const goToNextMatch = jest.fn(); + const onChangeSearchTerm = jest.fn(); + const onHideInput = jest.fn(); + + render( + + ); + + const input = screen.getByTestId(INPUT_TEST_SUBJ); + fireEvent.change(input, { target: { value: 'test' } }); + expect(input).toHaveValue('test'); + + await waitFor(() => { + expect(onChangeSearchTerm).toHaveBeenCalledWith('test'); + }); + }); + + it('hides on Escape', async () => { + const goToPrevMatch = jest.fn(); + const goToNextMatch = jest.fn(); + const onChangeSearchTerm = jest.fn(); + const onHideInput = jest.fn(); + + render( + + ); + + const input = screen.getByTestId(INPUT_TEST_SUBJ); + fireEvent.keyUp(input, { key: 'Escape' }); + + expect(onHideInput).toHaveBeenCalledWith(true); + }); + + it('handles prev/next with keyboard shortcuts', async () => { + const goToPrevMatch = jest.fn(); + const goToNextMatch = jest.fn(); + const onChangeSearchTerm = jest.fn(); + const onHideInput = jest.fn(); + + render( + + ); + + const input = screen.getByTestId(INPUT_TEST_SUBJ); + fireEvent.keyUp(input, { key: 'Enter' }); + + expect(goToNextMatch).toHaveBeenCalled(); + + fireEvent.keyUp(input, { key: 'Enter', shiftKey: true }); + + expect(goToPrevMatch).toHaveBeenCalled(); + }); +}); diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.tsx new file mode 100644 index 0000000000000..f189b82526a73 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.tsx @@ -0,0 +1,150 @@ +/* + * 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, { ChangeEvent, FocusEvent, useCallback } from 'react'; +import { + EuiButtonIcon, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiText, + keys, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useDebouncedValue } from '@kbn/visualization-utils'; +import { + COUNTER_TEST_SUBJ, + INPUT_TEST_SUBJ, + BUTTON_PREV_TEST_SUBJ, + BUTTON_NEXT_TEST_SUBJ, +} from './constants'; + +export interface InTableSearchInputProps { + matchesCount: number | null; + activeMatchPosition: number | null; + isProcessing: boolean; + goToPrevMatch: () => void; + goToNextMatch: () => void; + onChangeSearchTerm: (searchTerm: string) => void; + onHideInput: (shouldReturnFocusToButton?: boolean) => void; +} + +export const InTableSearchInput: React.FC = React.memo( + ({ + matchesCount, + activeMatchPosition, + isProcessing, + goToPrevMatch, + goToNextMatch, + onChangeSearchTerm, + onHideInput, + }) => { + const { inputValue, handleInputChange } = useDebouncedValue({ + onChange: onChangeSearchTerm, + value: '', + }); + + const onInputChange = useCallback( + (event: ChangeEvent) => { + const nextValue = event.target.value; + handleInputChange(nextValue); + }, + [handleInputChange] + ); + + const onKeyUp = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === keys.ESCAPE) { + onHideInput(true); + return; + } + + if (event.key === keys.ENTER && event.shiftKey) { + goToPrevMatch(); + } else if (event.key === keys.ENTER) { + goToNextMatch(); + } + }, + [goToPrevMatch, goToNextMatch, onHideInput] + ); + + const onBlur = useCallback( + (event: FocusEvent) => { + if ( + (!event.relatedTarget || + event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && + !inputValue + ) { + onHideInput(); + } + }, + [onHideInput, inputValue] + ); + + const areArrowsDisabled = !matchesCount || isProcessing; + + return ( + + + + {matchesCount && activeMatchPosition + ? `${activeMatchPosition}/${matchesCount}` + : '0/0'} +   + + + + + + + + + + } + placeholder={i18n.translate('dataGridInTableSearch.inputPlaceholder', { + defaultMessage: 'Find in table', + })} + value={inputValue} + onChange={onInputChange} + onKeyUp={onKeyUp} + onBlur={onBlur} + /> + ); + } +); diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/index.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/index.ts new file mode 100644 index 0000000000000..02a61e25ce5af --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/index.ts @@ -0,0 +1,14 @@ +/* + * 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". + */ + +export { + useDataGridInTableSearch, + type UseDataGridInTableSearchProps, + type UseDataGridInTableSearchReturn, +} from './use_data_grid_in_table_search'; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_matches_counter.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_matches_counter.tsx new file mode 100644 index 0000000000000..dbd7c5fa77375 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_matches_counter.tsx @@ -0,0 +1,34 @@ +/* + * 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, { useEffect, useRef } from 'react'; +import { createPortal, unmountComponentAtNode } from 'react-dom'; +import { AllCellsRenderer } from './all_cells_renderer'; +import { AllCellsProps } from '../types'; + +export function AllCellsMatchesCounter(props: AllCellsProps) { + const containerRef = useRef(document.createDocumentFragment()); + + useEffect(() => { + return () => { + if (containerRef.current) { + unmountComponentAtNode(containerRef.current); + containerRef.current = null; + } + }; + }, []); + + if (!containerRef.current) { + return null; + } + + // We use createPortal to render the AllCellsRenderer in a separate invisible container. + // All parent contexts will be applied too (like KibanaRenderContextProvider, UnifiedDataTableContext, etc). + return createPortal(, containerRef.current); +} diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.test.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.test.tsx new file mode 100644 index 0000000000000..5d8bc7d95e30d --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.test.tsx @@ -0,0 +1,154 @@ +/* + * 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 from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { AllCellsRenderer } from './all_cells_renderer'; +import { getRenderCellValueMock, generateMockData } from '../__mocks__'; +import { wrapRenderCellValueWithInTableSearchSupport } from '../wrap_render_cell_value'; + +describe('AllCellsRenderer', () => { + const testData = generateMockData(100, 2); + + const originalRenderCellValue = jest.fn(getRenderCellValueMock(testData)); + + beforeEach(() => { + originalRenderCellValue.mockClear(); + }); + + it('processes all cells in all rows', async () => { + const onFinish = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const inTableSearchTerm = 'cell'; + + render( + + ); + + await waitFor(() => { + expect(onFinish).toHaveBeenCalledWith({ + matchesList: testData.map((rowData, rowIndex) => ({ + rowIndex, + rowMatchesCount: 2, + matchesCountPerColumnId: { columnA: 1, columnB: 1 }, + })), + totalMatchesCount: testData.length * 2, // 1 match in each cell + }); + }); + }); + + it('counts multiple matches correctly', async () => { + const onFinish = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const inTableSearchTerm = '-'; + + render( + + ); + + await waitFor(() => { + expect(onFinish).toHaveBeenCalledWith({ + matchesList: testData.map((rowData, rowIndex) => ({ + rowIndex, + rowMatchesCount: 10, + matchesCountPerColumnId: { columnA: 5, columnB: 5 }, + })), + totalMatchesCount: testData.length * 5 * 2, // 5 matches per cell, 2 cells in a row + }); + }); + }); + + it('counts a single match correctly', async () => { + const onFinish = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const inTableSearchTerm = 'cell-in-row-10-col-0'; + + render( + + ); + + await waitFor(() => { + expect(onFinish).toHaveBeenCalledWith({ + matchesList: testData + .map((rowData, rowIndex) => { + if (!rowData[0].startsWith(inTableSearchTerm)) { + return; + } + + return { + rowIndex, + rowMatchesCount: 1, + matchesCountPerColumnId: { columnA: 1 }, + }; + }) + .filter(Boolean), + totalMatchesCount: 1, + }); + }); + }); + + it('skips cells which create exceptions', async () => { + const onFinish = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const inTableSearchTerm = '50'; + + render( + + ); + + await waitFor(() => { + expect(onFinish).toHaveBeenCalledWith({ + matchesList: [ + { + rowIndex: 50, + rowMatchesCount: 2, + matchesCountPerColumnId: { columnA: 1, columnB: 1 }, + }, + ], + totalMatchesCount: 2, + }); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.tsx new file mode 100644 index 0000000000000..1a6298b4e7fcb --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.tsx @@ -0,0 +1,98 @@ +/* + * 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, { useRef, useState, useCallback } from 'react'; +import { RowCellsRenderer } from './row_cells_renderer'; +import { AllCellsProps, RowMatches } from '../types'; + +// Processes rows in chunks: +// - to don't block the main thread for too long +// - and to let users continue interacting with the input which would cancel the processing and start a new one. +const INITIAL_ROWS_CHUNK_SIZE = 10; +// Increases the chunk size by 10 each time. This will increase the speed of processing with each iteration as we get more certain that user is waiting for its completion. +const ROWS_CHUNK_SIZE_INCREMENT = 10; +const ROWS_CHUNK_SIZE_MAX = 100; + +export function AllCellsRenderer(props: AllCellsProps) { + const { inTableSearchTerm, visibleColumns, renderCellValue, rowsCount, onFinish } = props; + const matchesListRef = useRef([]); + const totalMatchesCountRef = useRef(0); + const initialChunkSize = Math.min(INITIAL_ROWS_CHUNK_SIZE, rowsCount); + const [{ chunkStartRowIndex, chunkSize }, setChunk] = useState<{ + chunkStartRowIndex: number; + chunkSize: number; + }>({ chunkStartRowIndex: 0, chunkSize: initialChunkSize }); + const chunkRowResultsMapRef = useRef>({}); + const chunkRemainingRowsCountRef = useRef(initialChunkSize); + + // All cells in the row were processed, and we now know how many matches are in the row. + const onRowProcessed = useCallback( + (rowMatches: RowMatches) => { + if (rowMatches.rowMatchesCount > 0) { + totalMatchesCountRef.current += rowMatches.rowMatchesCount; + chunkRowResultsMapRef.current[rowMatches.rowIndex] = rowMatches; + } + + chunkRemainingRowsCountRef.current -= 1; + + if (chunkRemainingRowsCountRef.current > 0) { + // still waiting for more rows within the chunk to finish + return; + } + + // all rows within the chunk have been processed + // saving the results in the right order + Object.keys(chunkRowResultsMapRef.current) + .sort((a, b) => Number(a) - Number(b)) + .forEach((finishedRowIndex) => { + matchesListRef.current.push(chunkRowResultsMapRef.current[Number(finishedRowIndex)]); + }); + + // moving to the next chunk if there are more rows to process + const nextRowIndex = chunkStartRowIndex + chunkSize; + + if (nextRowIndex < rowsCount) { + const increasedChunkSize = Math.min( + ROWS_CHUNK_SIZE_MAX, + chunkSize + ROWS_CHUNK_SIZE_INCREMENT + ); + const nextChunkSize = Math.min(increasedChunkSize, rowsCount - nextRowIndex); + chunkRowResultsMapRef.current = {}; + chunkRemainingRowsCountRef.current = nextChunkSize; + setChunk({ chunkStartRowIndex: nextRowIndex, chunkSize: nextChunkSize }); + } else { + onFinish({ + matchesList: matchesListRef.current, + totalMatchesCount: totalMatchesCountRef.current, + }); + } + }, + [setChunk, chunkStartRowIndex, chunkSize, rowsCount, onFinish] + ); + + // Iterating through rows one chunk at the time to avoid blocking the main thread. + // If user changes inTableSearchTerm, this component would unmount and the processing would be interrupted right away. Next time it would start from rowIndex 0. + return ( + <> + {Array.from({ length: chunkSize }).map((_, index) => { + const rowIndex = chunkStartRowIndex + index; + return ( + + ); + })} + + ); +} diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.test.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.test.tsx new file mode 100644 index 0000000000000..e759a0d668f28 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.test.tsx @@ -0,0 +1,126 @@ +/* + * 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 from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { RowCellsRenderer } from './row_cells_renderer'; +import { wrapRenderCellValueWithInTableSearchSupport } from '../wrap_render_cell_value'; +import { getRenderCellValueMock } from '../__mocks__'; + +describe('RowCellsRenderer', () => { + const testData = [ + ['aaa', '100'], + ['bbb', 'abb'], + ]; + + const originalRenderCellValue = jest.fn(getRenderCellValueMock(testData)); + + beforeEach(() => { + originalRenderCellValue.mockClear(); + }); + + it('renders cells in row 0', async () => { + const onRowProcessed = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const rowIndex = 0; + const inTableSearchTerm = 'a'; + + render( + + ); + + await waitFor(() => { + expect(onRowProcessed).toHaveBeenCalledWith({ + rowIndex: 0, + rowMatchesCount: 3, + matchesCountPerColumnId: { + columnA: 3, + }, + }); + }); + + expect(renderCellValue).toHaveBeenCalledTimes(2); + expect(originalRenderCellValue).toHaveBeenCalledTimes(2); + expect(onRowProcessed).toHaveBeenCalledTimes(1); + }); + + it('renders cells in row 1', async () => { + const onRowProcessed = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const rowIndex = 1; + const inTableSearchTerm = 'bb'; + + render( + + ); + + await waitFor(() => { + expect(onRowProcessed).toHaveBeenCalledWith({ + rowIndex: 1, + rowMatchesCount: 2, + matchesCountPerColumnId: { + columnA: 1, + columnB: 1, + }, + }); + }); + + expect(renderCellValue).toHaveBeenCalledTimes(2); + expect(originalRenderCellValue).toHaveBeenCalledTimes(2); + expect(onRowProcessed).toHaveBeenCalledTimes(1); + }); + + it('should call onRowProcessed even in case of errors', async () => { + const onRowProcessed = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const rowIndex = 3; + const inTableSearchTerm = 'test'; + + render( + + ); + + await waitFor(() => { + expect(onRowProcessed).toHaveBeenCalledWith({ + rowIndex: 3, + rowMatchesCount: 0, + matchesCountPerColumnId: {}, + }); + }); + + expect(onRowProcessed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.tsx new file mode 100644 index 0000000000000..9d0ed26db67c9 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.tsx @@ -0,0 +1,146 @@ +/* + * 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, { useCallback, useEffect, useRef } from 'react'; +import { AllCellsProps, RowMatches } from '../types'; + +const TIMEOUT_PER_ROW = 2000; // 2 sec per row max + +// Renders all cells in the row and counts matches in each cell and the row in total. +export function RowCellsRenderer({ + rowIndex, + inTableSearchTerm, + visibleColumns, + renderCellValue, + onRowProcessed, +}: Omit & { + rowIndex: number; + onRowProcessed: (rowMatch: RowMatches) => void; +}) { + const RenderCellValue = renderCellValue; + const timerRef = useRef(); + const matchesCountPerColumnIdRef = useRef>({}); + const rowMatchesCountRef = useRef(0); + const remainingNumberOfResultsRef = useRef(visibleColumns.length); + const isCompletedRef = useRef(false); + + // all cells in the row were processed + const onComplete = useCallback(() => { + if (isCompletedRef.current) { + return; + } + isCompletedRef.current = true; // report only once + onRowProcessed({ + rowIndex, + rowMatchesCount: rowMatchesCountRef.current, + matchesCountPerColumnId: matchesCountPerColumnIdRef.current, + }); + }, [rowIndex, onRowProcessed]); + const onCompleteRef = useRef<() => void>(); + onCompleteRef.current = onComplete; + + // cell was rendered and matches count was calculated + const onCellProcessed = useCallback( + (columnId: string, count: number) => { + remainingNumberOfResultsRef.current = remainingNumberOfResultsRef.current - 1; + + if (count > 0) { + matchesCountPerColumnIdRef.current[columnId] = count; + rowMatchesCountRef.current += count; + } + + if (remainingNumberOfResultsRef.current === 0) { + onComplete(); + } + }, + [onComplete] + ); + + // don't let it run longer than TIMEOUT_PER_ROW + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + onCompleteRef.current?.(); + }, TIMEOUT_PER_ROW); + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [rowIndex]); + + return ( + <> + {visibleColumns.map((columnId, colIndex) => { + return ( + { + onCellProcessed(columnId, 0); + }} + > + { + // you can comment out the next line to observe that the row timeout is working as expected. + onCellProcessed(columnId, count); + }} + /> + + ); + })} + + ); +} + +/** + * Renders nothing instead of a component which triggered an exception. + */ +class ErrorBoundary extends React.Component< + React.PropsWithChildren<{ + onError?: () => void; + }>, + { hasError: boolean } +> { + constructor(props: {}) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch() { + this.props.onError?.(); + } + + render() { + if (this.state.hasError) { + return null; + } + + return this.props.children; + } +} + +function setCellProps() { + // nothing to do here +} diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/use_find_matches.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/use_find_matches.tsx new file mode 100644 index 0000000000000..c6a022053ab7c --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/use_find_matches.tsx @@ -0,0 +1,244 @@ +/* + * 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, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; +import { AllCellsMatchesCounter } from './all_cells_matches_counter'; +import { + ActiveMatch, + RowMatches, + UseFindMatchesState, + UseFindMatchesProps, + UseFindMatchesReturn, + AllCellsProps, +} from '../types'; + +const INITIAL_STATE: UseFindMatchesState = { + matchesList: [], + matchesCount: null, + activeMatchPosition: null, + columns: [], + isProcessing: false, + renderCellsShadowPortal: null, +}; + +export const useFindMatches = (props: UseFindMatchesProps): UseFindMatchesReturn => { + const { inTableSearchTerm, visibleColumns, rows, renderCellValue, onScrollToActiveMatch } = props; + const [state, setState] = useState(INITIAL_STATE); + const { matchesCount, activeMatchPosition, isProcessing, renderCellsShadowPortal } = state; + const numberOfRunsRef = useRef(0); + + useEffect(() => { + numberOfRunsRef.current += 1; + + if (!rows?.length || !inTableSearchTerm?.length) { + setState(INITIAL_STATE); + return; + } + + // const startTime = window.performance.now(); + + const numberOfRuns = numberOfRunsRef.current; + + const onFinish: AllCellsProps['onFinish'] = ({ + matchesList: nextMatchesList, + totalMatchesCount, + }) => { + if (numberOfRuns < numberOfRunsRef.current) { + return; + } + + const nextActiveMatchPosition = totalMatchesCount > 0 ? 1 : null; + setState({ + matchesList: nextMatchesList, + matchesCount: totalMatchesCount, + activeMatchPosition: nextActiveMatchPosition, + columns: visibleColumns, + isProcessing: false, + renderCellsShadowPortal: null, + }); + + if (totalMatchesCount > 0) { + updateActiveMatchPosition({ + matchPosition: nextActiveMatchPosition, + matchesList: nextMatchesList, + columns: visibleColumns, + onScrollToActiveMatch, + }); + } + + // const duration = window.performance.now() - startTime; + // console.log(duration); + }; + + const RenderCellsShadowPortal: UseFindMatchesState['renderCellsShadowPortal'] = () => ( + + ); + + setState((prevState) => ({ + ...prevState, + isProcessing: true, + renderCellsShadowPortal: RenderCellsShadowPortal, + })); + }, [setState, renderCellValue, visibleColumns, rows, inTableSearchTerm, onScrollToActiveMatch]); + + const goToPrevMatch = useCallback(() => { + setState((prevState) => changeActiveMatchInState(prevState, 'prev', onScrollToActiveMatch)); + }, [setState, onScrollToActiveMatch]); + + const goToNextMatch = useCallback(() => { + setState((prevState) => changeActiveMatchInState(prevState, 'next', onScrollToActiveMatch)); + }, [setState, onScrollToActiveMatch]); + + const resetState = useCallback(() => { + setState(INITIAL_STATE); + }, [setState]); + + return useMemo( + () => ({ + matchesCount, + activeMatchPosition, + goToPrevMatch, + goToNextMatch, + resetState, + isProcessing, + renderCellsShadowPortal, + }), + [ + matchesCount, + activeMatchPosition, + goToPrevMatch, + goToNextMatch, + resetState, + isProcessing, + renderCellsShadowPortal, + ] + ); +}; + +function getActiveMatchForPosition({ + matchPosition, + matchesList, + columns, +}: { + matchPosition: number; + matchesList: RowMatches[]; + columns: string[]; +}): ActiveMatch | null { + let traversedMatchesCount = 0; + + for (const rowMatch of matchesList) { + const rowIndex = rowMatch.rowIndex; + + if (traversedMatchesCount + rowMatch.rowMatchesCount < matchPosition) { + // going faster to next row + traversedMatchesCount += rowMatch.rowMatchesCount; + continue; + } + + const matchesCountPerColumnId = rowMatch.matchesCountPerColumnId; + + for (const columnId of columns) { + // going slow to next cell within the row + const matchesCountInCell = matchesCountPerColumnId[columnId] ?? 0; + + if ( + traversedMatchesCount < matchPosition && + traversedMatchesCount + matchesCountInCell >= matchPosition + ) { + // can even go slower to next match within the cell + return { + rowIndex: Number(rowIndex), + columnId, + matchIndexWithinCell: matchPosition - traversedMatchesCount - 1, + }; + } + + traversedMatchesCount += matchesCountInCell; + } + } + + // no match found for the requested position + return null; +} + +let prevJumpTimer: NodeJS.Timeout | null = null; + +function updateActiveMatchPosition({ + matchPosition, + matchesList, + columns, + onScrollToActiveMatch, +}: { + matchPosition: number | null; + matchesList: RowMatches[]; + columns: string[]; + onScrollToActiveMatch: (activeMatch: ActiveMatch) => void; +}) { + if (typeof matchPosition !== 'number') { + return; + } + + if (prevJumpTimer) { + clearTimeout(prevJumpTimer); + } + + prevJumpTimer = setTimeout(() => { + const activeMatch = getActiveMatchForPosition({ + matchPosition, + matchesList, + columns, + }); + + if (activeMatch) { + onScrollToActiveMatch(activeMatch); + } + }, 0); +} + +function changeActiveMatchInState( + prevState: UseFindMatchesState, + direction: 'prev' | 'next', + onScrollToActiveMatch: (activeMatch: ActiveMatch) => void +): UseFindMatchesState { + if ( + typeof prevState.matchesCount !== 'number' || + !prevState.activeMatchPosition || + prevState.isProcessing + ) { + return prevState; + } + + let nextMatchPosition = + direction === 'prev' ? prevState.activeMatchPosition - 1 : prevState.activeMatchPosition + 1; + + if (nextMatchPosition < 1) { + nextMatchPosition = prevState.matchesCount; // allow to endlessly circle though matches + } else if (nextMatchPosition > prevState.matchesCount) { + nextMatchPosition = 1; // allow to endlessly circle though matches + } + + updateActiveMatchPosition({ + matchPosition: nextMatchPosition, + matchesList: prevState.matchesList, + columns: prevState.columns, + onScrollToActiveMatch, + }); + + return { + ...prevState, + activeMatchPosition: nextMatchPosition, + }; +} diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/types.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/types.ts new file mode 100644 index 0000000000000..2e70b7367e8e7 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/types.ts @@ -0,0 +1,65 @@ +/* + * 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 { ReactNode } from 'react'; +import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; + +export interface RowMatches { + rowIndex: number; + rowMatchesCount: number; + matchesCountPerColumnId: Record; +} + +export interface ActiveMatch { + rowIndex: number; + columnId: string; + matchIndexWithinCell: number; +} + +export interface InTableSearchHighlightsWrapperProps { + inTableSearchTerm?: string; + onHighlightsCountFound?: (count: number) => void; + children: ReactNode; +} + +export type RenderCellValuePropsWithInTableSearch = EuiDataGridCellValueElementProps & + Pick; + +export type RenderCellValueWrapper = (props: RenderCellValuePropsWithInTableSearch) => ReactNode; + +export interface UseFindMatchesProps { + inTableSearchTerm: string; + visibleColumns: string[]; + rows: unknown[]; + renderCellValue: RenderCellValueWrapper; + onScrollToActiveMatch: (activeMatch: ActiveMatch) => void; +} + +export interface UseFindMatchesState { + matchesList: RowMatches[]; + matchesCount: number | null; + activeMatchPosition: number | null; + columns: string[]; + isProcessing: boolean; + renderCellsShadowPortal: (() => ReactNode) | null; +} + +export interface UseFindMatchesReturn extends Omit { + goToPrevMatch: () => void; + goToNextMatch: () => void; + resetState: () => void; +} + +export type AllCellsProps = Pick< + UseFindMatchesProps, + 'renderCellValue' | 'visibleColumns' | 'inTableSearchTerm' +> & { + rowsCount: number; + onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; +}; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.test.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.test.tsx new file mode 100644 index 0000000000000..2ee72a004ee25 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.test.tsx @@ -0,0 +1,138 @@ +/* + * 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, { createRef } from 'react'; +import { fireEvent, render, screen, waitFor, renderHook } from '@testing-library/react'; +import { + DataGridWithInTableSearchExample, + generateMockData, + getRenderCellValueMock, +} from './__mocks__'; +import { useDataGridInTableSearch } from './use_data_grid_in_table_search'; +import { + BUTTON_TEST_SUBJ, + INPUT_TEST_SUBJ, + COUNTER_TEST_SUBJ, + HIGHLIGHT_CLASS_NAME, + BUTTON_PREV_TEST_SUBJ, +} from './constants'; +import { RenderCellValuePropsWithInTableSearch } from './types'; + +describe('useDataGridInTableSearch', () => { + const testData = generateMockData(100, 2); + + it('should initialize correctly', async () => { + const originalRenderCellValue = getRenderCellValueMock(testData); + const originalCellContext = { testContext: true }; + const initialProps = { + dataGridWrapper: null, + dataGridRef: createRef(), + visibleColumns: ['columnA', 'columnB'], + rows: testData, + cellContext: originalCellContext, + renderCellValue: originalRenderCellValue, + pagination: undefined, + }; + const { result } = renderHook((props) => useDataGridInTableSearch(props), { + initialProps, + }); + + const { + inTableSearchTermCss, + inTableSearchControl, + cellContextWithInTableSearchSupport, + renderCellValueWithInTableSearchSupport, + } = result.current; + + expect(inTableSearchControl).toBeDefined(); + expect(inTableSearchTermCss).toBeUndefined(); + expect(cellContextWithInTableSearchSupport).toEqual({ + ...originalCellContext, + inTableSearchTerm: '', + }); + expect( + renderCellValueWithInTableSearchSupport({ + rowIndex: 0, + colIndex: 0, + inTableSearchTerm: 'test', + } as RenderCellValuePropsWithInTableSearch) + ).toMatchInlineSnapshot(` + + + + `); + + render(inTableSearchControl); + + await waitFor(() => { + expect(screen.getByTestId(BUTTON_TEST_SUBJ)).toBeInTheDocument(); + }); + }); + + it('should render an EuiDataGrid with in table search support', async () => { + render(); + + screen.getByTestId(BUTTON_TEST_SUBJ).click(); + + await waitFor(() => { + expect(screen.getByTestId(INPUT_TEST_SUBJ)).toBeInTheDocument(); + }); + + const searchTerm = 'col-0'; + let input = screen.getByTestId(INPUT_TEST_SUBJ); + fireEvent.change(input, { target: { value: searchTerm } }); + expect(input).toHaveValue(searchTerm); + + await waitFor(() => { + expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('1/100'); + }); + + await waitFor(() => { + const highlights = screen.getAllByText(searchTerm); + expect(highlights.length).toBeGreaterThan(0); + expect( + highlights.every( + (highlight) => + highlight.tagName === 'MARK' && highlight.classList.contains(HIGHLIGHT_CLASS_NAME) + ) + ).toBe(true); + }); + + screen.getByTestId(BUTTON_PREV_TEST_SUBJ).click(); + + await waitFor(() => { + expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('100/100'); + }); + + const searchTerm2 = 'row-1'; + input = screen.getByTestId(INPUT_TEST_SUBJ); + fireEvent.change(input, { target: { value: searchTerm2 } }); + expect(input).toHaveValue(searchTerm2); + + await waitFor(() => { + expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('1/55'); + }); + + await waitFor(() => { + const highlights = screen.getAllByText(searchTerm2); + expect(highlights.length).toBeGreaterThan(0); + expect( + highlights.every( + (highlight) => + highlight.tagName === 'MARK' && highlight.classList.contains(HIGHLIGHT_CLASS_NAME) + ) + ).toBe(true); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.tsx new file mode 100644 index 0000000000000..13f7a138e8124 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.tsx @@ -0,0 +1,140 @@ +/* + * 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, { useMemo, useRef, useState } from 'react'; +import type { SerializedStyles } from '@emotion/react'; +import type { EuiDataGridProps, EuiDataGridRefProps } from '@elastic/eui'; +import { InTableSearchControl, InTableSearchControlProps } from './in_table_search_control'; +import { RenderCellValueWrapper } from './types'; +import { wrapRenderCellValueWithInTableSearchSupport } from './wrap_render_cell_value'; + +export interface UseDataGridInTableSearchProps + extends Pick { + enableInTableSearch?: boolean; + dataGridWrapper: HTMLElement | null; + dataGridRef: React.RefObject; + cellContext: EuiDataGridProps['cellContext'] | undefined; + pagination: EuiDataGridProps['pagination'] | undefined; + renderCellValue: EuiDataGridProps['renderCellValue']; +} + +export interface UseDataGridInTableSearchState { + inTableSearchTerm: string; + inTableSearchTermCss?: SerializedStyles; +} + +export interface UseDataGridInTableSearchReturn { + inTableSearchTermCss?: UseDataGridInTableSearchState['inTableSearchTermCss']; + inTableSearchControl: React.JSX.Element | undefined; + cellContextWithInTableSearchSupport: EuiDataGridProps['cellContext']; + renderCellValueWithInTableSearchSupport: RenderCellValueWrapper; +} + +export const useDataGridInTableSearch = ( + props: UseDataGridInTableSearchProps +): UseDataGridInTableSearchReturn => { + const { + enableInTableSearch = true, + dataGridWrapper, + dataGridRef, + visibleColumns, + rows, + renderCellValue, + pagination, + cellContext, + } = props; + const isPaginationEnabled = Boolean(pagination); + const pageSize = (isPaginationEnabled && pagination?.pageSize) || null; + const onChangePage = pagination?.onChangePage; + const pageIndexRef = useRef(); + pageIndexRef.current = pagination?.pageIndex ?? 0; + + const renderCellValueWithInTableSearchSupport = useMemo( + () => wrapRenderCellValueWithInTableSearchSupport(renderCellValue), + [renderCellValue] + ); + + const [{ inTableSearchTerm, inTableSearchTermCss }, setInTableSearchState] = + useState(() => ({ inTableSearchTerm: '' })); + + const inTableSearchControl = useMemo(() => { + if (!enableInTableSearch) { + return undefined; + } + const controlsCount = dataGridWrapper + ? dataGridWrapper.querySelectorAll('.euiDataGridHeaderCell--controlColumn').length + : 0; + return ( + visibleColumns.indexOf(columnId) + controlsCount} + scrollToCell={(params) => { + dataGridRef.current?.scrollToItem?.(params); + }} + shouldOverrideCmdF={(element) => { + if (!dataGridWrapper) { + return false; + } + return dataGridWrapper.contains?.(element) ?? false; + }} + onChange={(searchTerm) => setInTableSearchState({ inTableSearchTerm: searchTerm || '' })} + onChangeCss={(styles) => + setInTableSearchState((prevState) => ({ ...prevState, inTableSearchTermCss: styles })) + } + onChangeToExpectedPage={(expectedPageIndex: number) => { + if (isPaginationEnabled && pageIndexRef.current !== expectedPageIndex) { + onChangePage?.(expectedPageIndex); + } + }} + /> + ); + }, [ + enableInTableSearch, + setInTableSearchState, + visibleColumns, + rows, + renderCellValueWithInTableSearchSupport, + dataGridRef, + dataGridWrapper, + inTableSearchTerm, + isPaginationEnabled, + pageSize, + onChangePage, + ]); + + const cellContextWithInTableSearchSupport: EuiDataGridProps['cellContext'] = useMemo(() => { + if (!inTableSearchTerm && !cellContext) { + return undefined; + } + + return { + ...cellContext, + inTableSearchTerm, + }; + }, [cellContext, inTableSearchTerm]); + + return useMemo( + () => ({ + inTableSearchTermCss, + inTableSearchControl, + cellContextWithInTableSearchSupport, + renderCellValueWithInTableSearchSupport, + }), + [ + inTableSearchTermCss, + inTableSearchControl, + cellContextWithInTableSearchSupport, + renderCellValueWithInTableSearchSupport, + ] + ); +}; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/wrap_render_cell_value.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/wrap_render_cell_value.tsx new file mode 100644 index 0000000000000..e62efb109234d --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/wrap_render_cell_value.tsx @@ -0,0 +1,36 @@ +/* + * 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 from 'react'; +import type { EuiDataGridProps } from '@elastic/eui'; +import { InTableSearchHighlightsWrapper } from './in_table_search_highlights_wrapper'; +import type { RenderCellValuePropsWithInTableSearch, RenderCellValueWrapper } from './types'; + +export const wrapRenderCellValueWithInTableSearchSupport = ( + renderCellValue: EuiDataGridProps['renderCellValue'] +): RenderCellValueWrapper => { + const RenderCellValue = renderCellValue; + + return ({ + inTableSearchTerm, + onHighlightsCountFound, + ...props + }: RenderCellValuePropsWithInTableSearch) => { + return ( + + + + ); + }; +}; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/tsconfig.json b/src/platform/packages/shared/kbn-data-grid-in-table-search/tsconfig.json new file mode 100644 index 0000000000000..9af05dde99eef --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"], + "compilerOptions": { + "outDir": "target/types" + }, + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/i18n", + "@kbn/visualization-utils", + ] +} diff --git a/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/cell_actions_popover.tsx b/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/cell_actions_popover.tsx index 96651cf26189b..b212c38669654 100644 --- a/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/cell_actions_popover.tsx +++ b/src/platform/packages/shared/kbn-discover-contextual-components/src/data_types/logs/components/cell_actions_popover.tsx @@ -17,7 +17,6 @@ import { EuiPopoverFooter, EuiText, EuiButtonIcon, - EuiTextTruncate, EuiButtonEmpty, EuiCopy, } from '@elastic/eui'; @@ -185,9 +184,19 @@ export function FieldBadgeWithActions({ renderValue={renderValue} renderPopoverTrigger={({ popoverTriggerProps }) => ( - + {truncateMiddle(value)} )} /> ); } + +const MAX_LENGTH = 20; + +function truncateMiddle(value: string): string { + if (value.length < MAX_LENGTH) { + return value; + } + const halfLength = MAX_LENGTH / 2; + return `${value.slice(0, halfLength)}...${value.slice(-halfLength)}`; +} diff --git a/src/platform/packages/shared/kbn-unified-data-table/README.md b/src/platform/packages/shared/kbn-unified-data-table/README.md index 0dd94c7c0977d..921ecc34c15e4 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/README.md +++ b/src/platform/packages/shared/kbn-unified-data-table/README.md @@ -10,6 +10,7 @@ Props description: | **className** | (optional) string | Optional class name to apply. | | **columns** | string[] | Determines ids of the columns which are displayed. | | **expandedDoc** | (optional) DataTableRecord | If set, the given document is displayed in a flyout. | +| **enableInTableSearch** | (optional) boolean | Set to true to allow users to search inside the table. | | **dataView** | DataView | The used data view. | | **loadingState** | DataLoadingState | Determines if data is currently loaded. | | **onFilter** | DocViewFilterFn | Function to add a filter in the grid cell or document flyout. | @@ -76,6 +77,7 @@ Usage example: className={'unifiedDataTableTimeline'} columns={['event.category', 'event.action', 'host.name', 'user.name']} expandedDoc={expandedDoc as DataTableRecord} + enableInTableSearch dataView={dataView} loadingState={isQueryLoading ? DataLoadingState.loading : DataLoadingState.loaded} onFilter={() => { diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.scss index 0277e03c80e17..0eb0748bbb55e 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.scss @@ -45,7 +45,7 @@ } } - .unifiedDataTableToolbarControlIconButton .euiButtonIcon { + .unifiedDataTableToolbarControlIconButton .euiToolTipAnchor .euiButtonIcon { inline-size: $euiSizeXL; block-size: $euiSizeXL; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.tsx index d13b8bbc7b03a..78686e1ca8eda 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/custom_toolbar/render_custom_toolbar.tsx @@ -15,6 +15,7 @@ export interface UnifiedDataTableRenderCustomToolbarProps { toolbarProps: EuiDataGridCustomToolbarProps; gridProps: { additionalControls?: React.ReactNode; + inTableSearchControl?: React.ReactNode; }; } @@ -41,7 +42,7 @@ export const internalRenderCustomToolbar = ( keyboardShortcutsControl, displayControl, }, - gridProps: { additionalControls }, + gridProps: { additionalControls, inTableSearchControl }, } = props; const buttons = hasRoomForGridControls ? ( @@ -90,18 +91,28 @@ export const internalRenderCustomToolbar = ( {Boolean(leftSide) && buttons} - {(keyboardShortcutsControl || displayControl || fullScreenControl) && ( + {Boolean( + keyboardShortcutsControl || + displayControl || + fullScreenControl || + inTableSearchControl + ) && (
- {keyboardShortcutsControl && ( + {Boolean(inTableSearchControl) && ( +
+ {inTableSearchControl} +
+ )} + {Boolean(keyboardShortcutsControl) && (
{keyboardShortcutsControl}
)} - {displayControl && ( + {Boolean(displayControl) && (
{displayControl}
)} - {fullScreenControl && ( + {Boolean(fullScreenControl) && (
{fullScreenControl}
diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.test.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.test.tsx index 3ee4e5a9e7a13..04b3d77ff2c43 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.test.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.test.tsx @@ -9,6 +9,13 @@ import React, { useCallback, useState } from 'react'; import { ReactWrapper } from 'enzyme'; +import { + BUTTON_NEXT_TEST_SUBJ, + BUTTON_TEST_SUBJ, + COUNTER_TEST_SUBJ, + HIGHLIGHT_CLASS_NAME, + INPUT_TEST_SUBJ, +} from '@kbn/data-grid-in-table-search'; import { EuiButton, EuiDataGrid, @@ -38,7 +45,7 @@ import { testTrailingControlColumns, } from '../../__mocks__/external_control_columns'; import { DatatableColumnType } from '@kbn/expressions-plugin/common'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CELL_CLASS } from '../utils/get_render_cell_value'; import { defaultTimeColumnWidth } from '../constants'; @@ -1459,4 +1466,101 @@ describe('UnifiedDataTable', () => { expect(onChangePageMock).toHaveBeenNthCalledWith(1, 0); }); }); + + describe('enableInTableSearch', () => { + it( + 'should render find-button if enableInTableSearch is true and no custom toolbar specified', + async () => { + await renderDataTable({ enableInTableSearch: true, columns: ['bytes'] }); + + expect(screen.getByTestId(BUTTON_TEST_SUBJ)).toBeInTheDocument(); + }, + EXTENDED_JEST_TIMEOUT + ); + + it( + 'should render find-button if enableInTableSearch is true and renderCustomToolbar is provided', + async () => { + const renderCustomToolbarMock = jest.fn((props) => { + return ( +
+ Custom layout {props.gridProps.inTableSearchControl} +
+ ); + }); + + await renderDataTable({ + enableInTableSearch: true, + columns: ['bytes'], + renderCustomToolbar: renderCustomToolbarMock, + }); + + expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument(); + expect(screen.getByTestId(BUTTON_TEST_SUBJ)).toBeInTheDocument(); + }, + EXTENDED_JEST_TIMEOUT + ); + + it( + 'should not render find-button if enableInTableSearch is false', + async () => { + await renderDataTable({ enableInTableSearch: false, columns: ['bytes'] }); + + expect(screen.queryByTestId(BUTTON_TEST_SUBJ)).not.toBeInTheDocument(); + }, + EXTENDED_JEST_TIMEOUT + ); + + it( + 'should find the search term in the table', + async () => { + await renderDataTable({ enableInTableSearch: true, columns: ['bytes'] }); + + expect(screen.getByTestId(BUTTON_TEST_SUBJ)).toBeInTheDocument(); + + screen.getByTestId(BUTTON_TEST_SUBJ).click(); + + expect(screen.getByTestId(INPUT_TEST_SUBJ)).toBeInTheDocument(); + + const searchTerm = '50'; + const input = screen.getByTestId(INPUT_TEST_SUBJ); + fireEvent.change(input, { target: { value: searchTerm } }); + expect(input).toHaveValue(searchTerm); + + await waitFor(() => { + // 3 results for `bytes` column with value `50` + expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('1/3'); + }); + + await waitFor(() => { + const highlights = screen.getAllByText(searchTerm); + expect(highlights.length).toBeGreaterThan(0); + expect( + highlights.every( + (highlight) => + highlight.tagName === 'MARK' && highlight.classList.contains(HIGHLIGHT_CLASS_NAME) + ) + ).toBe(true); + }); + + screen.getByTestId(BUTTON_NEXT_TEST_SUBJ).click(); + + await waitFor(() => { + expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('2/3'); + }); + + const anotherSearchTerm = 'random'; + fireEvent.change(screen.getByTestId(INPUT_TEST_SUBJ), { + target: { value: anotherSearchTerm }, + }); + expect(screen.getByTestId(INPUT_TEST_SUBJ)).toHaveValue(anotherSearchTerm); + + await waitFor(() => { + // no results + expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('0/0'); + }); + }, + EXTENDED_JEST_TIMEOUT + ); + }); }); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.tsx index 3a0378037e576..22229262441b7 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.tsx @@ -51,6 +51,7 @@ import type { ThemeServiceStart } from '@kbn/react-kibana-context-common'; import { type DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; import { AdditionalFieldGroups } from '@kbn/unified-field-list'; +import { useDataGridInTableSearch } from '@kbn/data-grid-in-table-search'; import { DATA_GRID_DENSITY_STYLE_MAP, useDataGridDensity } from '../hooks/use_data_grid_density'; import { UnifiedDataTableSettings, @@ -411,6 +412,10 @@ export interface UnifiedDataTableProps { * Set to true to allow users to compare selected documents */ enableComparisonMode?: boolean; + /** + * Set to true to allow users to search in cell values + */ + enableInTableSearch?: boolean; /** * Optional extra props passed to the renderCellValue function/component. */ @@ -494,6 +499,7 @@ export const UnifiedDataTable = ({ rowLineHeightOverride, customGridColumnsConfiguration, enableComparisonMode, + enableInTableSearch = false, cellContext, renderCellPopover, getRowIndicator, @@ -528,6 +534,14 @@ export const UnifiedDataTable = ({ const [currentPageIndex, setCurrentPageIndex] = useState(0); + const changeCurrentPageIndex = useCallback( + (value: number) => { + setCurrentPageIndex(value); + onUpdatePageIndex?.(value); + }, + [setCurrentPageIndex, onUpdatePageIndex] + ); + useEffect(() => { if (!hasSelectedDocs && isFilterActive) { setIsFilterActive(false); @@ -632,15 +646,10 @@ export const UnifiedDataTable = ({ onUpdateRowsPerPage?.(pageSize); }; - const onChangePage = (newPageIndex: number) => { - setCurrentPageIndex(newPageIndex); - onUpdatePageIndex?.(newPageIndex); - }; - return isPaginationEnabled ? { onChangeItemsPerPage, - onChangePage, + onChangePage: changeCurrentPageIndex, pageIndex: currentPageIndex, pageSize: currentPageSize, pageSizeOptions: rowsPerPageOptions ?? getRowsPerPageOptions(currentPageSize), @@ -652,7 +661,7 @@ export const UnifiedDataTable = ({ onUpdateRowsPerPage, currentPageSize, currentPageIndex, - onUpdatePageIndex, + changeCurrentPageIndex, ]); const unifiedDataTableContextValue = useMemo( @@ -738,6 +747,24 @@ export const UnifiedDataTable = ({ ] ); + const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); + + const { + inTableSearchTermCss, + inTableSearchControl, + cellContextWithInTableSearchSupport, + renderCellValueWithInTableSearchSupport, + } = useDataGridInTableSearch({ + enableInTableSearch, + dataGridWrapper, + dataGridRef, + visibleColumns, + rows: displayedRows, + renderCellValue, + cellContext, + pagination: paginationObj, + }); + const renderCustomPopover = useMemo( () => renderCellPopover ?? getCustomCellPopoverRenderer(), [renderCellPopover] @@ -947,11 +974,11 @@ export const UnifiedDataTable = ({ ]); const additionalControls = useMemo(() => { - if (!externalAdditionalControls && !selectedDocsCount) { + if (!externalAdditionalControls && !selectedDocsCount && !inTableSearchControl) { return null; } - return ( + const leftControls = ( <> {Boolean(selectedDocsCount) && ( ); + + if (!renderCustomToolbar && inTableSearchControl) { + return { + left: leftControls, + right: inTableSearchControl, + }; + } + + return leftControls; }, [ selectedDocsCount, selectedDocsState, @@ -986,6 +1022,8 @@ export const UnifiedDataTable = ({ unifiedDataTableContextValue.pageSize, toastNotifications, visibleColumns, + renderCustomToolbar, + inTableSearchControl, ]); const renderCustomToolbarFn: EuiDataGridProps['renderCustomToolbar'] | undefined = useMemo( @@ -995,11 +1033,15 @@ export const UnifiedDataTable = ({ renderCustomToolbar({ toolbarProps, gridProps: { - additionalControls, + additionalControls: + additionalControls && 'left' in additionalControls + ? additionalControls.left + : additionalControls, + inTableSearchControl, }, }) : undefined, - [renderCustomToolbar, additionalControls] + [renderCustomToolbar, additionalControls, inTableSearchControl] ); const showDisplaySelector = useMemo((): @@ -1080,8 +1122,6 @@ export const UnifiedDataTable = ({ rowLineHeight: rowLineHeightOverride, }); - const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); - const isRenderComplete = loadingState !== DataLoadingState.loading; if (!rowCount && loadingState === DataLoadingState.loading) { @@ -1132,6 +1172,7 @@ export const UnifiedDataTable = ({ data-description={searchDescription} data-document-number={displayedRows.length} className={classnames(className, 'unifiedDataTable__table')} + css={inTableSearchTermCss} > {isCompareActive ? ( = ({ showColumnTokens canDragAndDropColumns enableComparisonMode + enableInTableSearch renderCustomToolbar={renderCustomToolbar} getRowIndicator={getRowIndicator} rowAdditionalLeadingControls={rowAdditionalLeadingControls} diff --git a/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx b/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx index 58145627f139f..39bd976ebb209 100644 --- a/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx +++ b/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx @@ -155,6 +155,7 @@ const DataGrid: React.FC = (props) => { hasRoomForGridControls: true, }, gridProps: { + inTableSearchControl: customToolbarProps.gridProps.inTableSearchControl, additionalControls: ( = (props) => { rows={rows} columnsMeta={columnsMeta} services={services} + enableInTableSearch isPlainRecord isSortEnabled={false} loadingState={DataLoadingState.loaded} diff --git a/test/functional/apps/discover/group2_data_grid2/_data_grid_in_table_search.ts b/test/functional/apps/discover/group2_data_grid2/_data_grid_in_table_search.ts new file mode 100644 index 0000000000000..5ea01974b47ec --- /dev/null +++ b/test/functional/apps/discover/group2_data_grid2/_data_grid_in_table_search.ts @@ -0,0 +1,156 @@ +/* + * 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 expect from '@kbn/expect'; +import { Key } from 'selenium-webdriver'; +import { INPUT_TEST_SUBJ } from '@kbn/data-grid-in-table-search'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const dataGrid = getService('dataGrid'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const queryBar = getService('queryBar'); + const monacoEditor = getService('monacoEditor'); + const security = getService('security'); + const { common, discover, timePicker, unifiedFieldList, header } = getPageObjects([ + 'common', + 'discover', + 'timePicker', + 'unifiedFieldList', + 'header', + ]); + const defaultSettings = { defaultIndex: 'logstash-*' }; + + describe('discover data grid in-table search', function describeIndexTests() { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await browser.setWindowSize(1200, 2000); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + beforeEach(async function () { + await timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await common.navigateToApp('discover'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + }); + + it('should show highlights for in-table search', async () => { + expect(await dataGrid.getCurrentPageNumber()).to.be('1'); + + await dataGrid.runInTableSearch('Sep 22, 2015 @ 18:16:13.025'); + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('1/3'); + expect(await dataGrid.getInTableSearchCellMatchesCount(1, '@timestamp')).to.be(1); + expect(await dataGrid.getInTableSearchCellMatchesCount(1, '_source')).to.be(2); + expect(await dataGrid.getInTableSearchCellMatchesCount(2, '@timestamp')).to.be(0); + expect(await dataGrid.getInTableSearchCellMatchesCount(2, '_source')).to.be(0); + expect(await dataGrid.getCurrentPageNumber()).to.be('3'); + + await dataGrid.runInTableSearch('http'); + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('1/6386'); + expect(await dataGrid.getInTableSearchCellMatchesCount(0, '@timestamp')).to.be(0); + expect(await dataGrid.getInTableSearchCellMatchesCount(0, '_source')).to.be(13); + expect(await dataGrid.getCurrentPageNumber()).to.be('1'); + + await dataGrid.exitInTableSearch(); + + await retry.waitFor('no highlights', async () => { + return (await dataGrid.getInTableSearchCellMatchesCount(0, '@timestamp')) === 0; + }); + }); + + it('uses different colors for highlights in the table', async () => { + await discover.selectTextBaseLang(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + const testQuery = `from logstash-* | sort @timestamp | limit 10`; + await monacoEditor.setCodeEditorValue(testQuery); + await testSubjects.click('querySubmitButton'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + + await dataGrid.runInTableSearch('2015 @'); + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('1/30'); + expect(await dataGrid.getInTableSearchCellMatchesCount(0, '@timestamp')).to.be(1); + expect(await dataGrid.getInTableSearchCellMatchesCount(0, '_source')).to.be(2); + + const firstRowFirstCellMatches = await dataGrid.getInTableSearchCellMatchElements( + 0, + '@timestamp' + ); + const secondRowRowFirstCellMatches = await dataGrid.getInTableSearchCellMatchElements( + 1, + '@timestamp' + ); + const activeMatchBackgroundColor = await firstRowFirstCellMatches[0].getComputedStyle( + 'background-color' + ); + const anotherMatchBackgroundColor = await secondRowRowFirstCellMatches[0].getComputedStyle( + 'background-color' + ); + expect(activeMatchBackgroundColor).to.contain('rgba'); + expect(anotherMatchBackgroundColor).to.contain('rgba'); + expect(activeMatchBackgroundColor).not.to.be(anotherMatchBackgroundColor); + }); + + it('can navigate between matches', async () => { + await dataGrid.changeRowsPerPageTo(10); + await unifiedFieldList.clickFieldListItemAdd('extension'); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + await queryBar.setQuery('response : 404 and @tags.raw : "info" and bytes < 1000'); + await queryBar.submitQuery(); + await header.waitUntilLoadingHasFinished(); + await discover.waitUntilSearchingHasFinished(); + + await dataGrid.runInTableSearch('php'); + + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('1/4'); + expect(await dataGrid.getCurrentPageNumber()).to.be('1'); + + await dataGrid.goToNextInTableSearchMatch(); + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('2/4'); + expect(await dataGrid.getCurrentPageNumber()).to.be('1'); + + await dataGrid.goToNextInTableSearchMatch(); + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('3/4'); + expect(await dataGrid.getCurrentPageNumber()).to.be('2'); + + await dataGrid.goToNextInTableSearchMatch(); + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('4/4'); + expect(await dataGrid.getCurrentPageNumber()).to.be('3'); + + await dataGrid.goToNextInTableSearchMatch(); + expect(await dataGrid.getInTableSearchMatchesCounter()).to.be('1/4'); + expect(await dataGrid.getCurrentPageNumber()).to.be('1'); + }); + + it('overrides cmd+f if grid element was in focus', async () => { + const cell = await dataGrid.getCellElementByColumnName(0, '@timestamp'); + await cell.click(); + + await browser.getActions().keyDown(Key.COMMAND).sendKeys('f').perform(); + await retry.waitFor('in-table search input is visible', async () => { + return await testSubjects.exists(INPUT_TEST_SUBJ); + }); + }); + }); +} diff --git a/test/functional/apps/discover/group2_data_grid2/index.ts b/test/functional/apps/discover/group2_data_grid2/index.ts index 2a4f116ebb8e7..8e0b648051cf0 100644 --- a/test/functional/apps/discover/group2_data_grid2/index.ts +++ b/test/functional/apps/discover/group2_data_grid2/index.ts @@ -26,5 +26,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_data_grid_footer')); loadTestFile(require.resolve('./_data_grid_field_data')); loadTestFile(require.resolve('./_data_grid_field_tokens')); + loadTestFile(require.resolve('./_data_grid_in_table_search')); }); } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 3b17a31d624b6..331038302a283 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -9,6 +9,14 @@ import { chunk } from 'lodash'; import { Key } from 'selenium-webdriver'; +import { + BUTTON_TEST_SUBJ, + INPUT_TEST_SUBJ, + BUTTON_PREV_TEST_SUBJ, + BUTTON_NEXT_TEST_SUBJ, + COUNTER_TEST_SUBJ, + HIGHLIGHT_CLASS_NAME, +} from '@kbn/data-grid-in-table-search'; import { WebElementWrapper, CustomCheerioStatic } from '@kbn/ftr-common-functional-ui-services'; import { FtrService } from '../ftr_provider_context'; @@ -931,4 +939,66 @@ export class DataGridService extends FtrService { public async exitComparisonMode() { await this.testSubjects.click('unifiedDataTableExitDocumentComparison'); } + + public async getCurrentPageNumber() { + const currentPage = await this.find.byCssSelector('.euiPaginationButton[aria-current="true"]'); + return await currentPage.getVisibleText(); + } + + public async runInTableSearch(searchTerm: string) { + if (!(await this.testSubjects.exists(INPUT_TEST_SUBJ))) { + await this.testSubjects.click(BUTTON_TEST_SUBJ); + await this.retry.waitFor('input to appear', async () => { + return await this.testSubjects.exists(INPUT_TEST_SUBJ); + }); + } + const prevCounter = await this.testSubjects.getVisibleText(COUNTER_TEST_SUBJ); + await this.testSubjects.setValue(INPUT_TEST_SUBJ, searchTerm); + await this.retry.waitFor('counter to change', async () => { + return (await this.testSubjects.getVisibleText(COUNTER_TEST_SUBJ)) !== prevCounter; + }); + } + + public async exitInTableSearch() { + if (!(await this.testSubjects.exists(INPUT_TEST_SUBJ))) { + return; + } + const input = await this.testSubjects.find(INPUT_TEST_SUBJ); + await input.pressKeys(this.browser.keys.ESCAPE); + await this.retry.waitFor('input to hide', async () => { + return ( + !(await this.testSubjects.exists(INPUT_TEST_SUBJ)) && + (await this.testSubjects.exists(BUTTON_TEST_SUBJ)) + ); + }); + } + + private async jumpToInTableSearchMatch(buttonTestSubj: string) { + const prevCounter = await this.testSubjects.getVisibleText(COUNTER_TEST_SUBJ); + await this.testSubjects.click(buttonTestSubj); + await this.retry.waitFor('counter to change', async () => { + return (await this.testSubjects.getVisibleText(COUNTER_TEST_SUBJ)) !== prevCounter; + }); + } + + public async goToPrevInTableSearchMatch() { + await this.jumpToInTableSearchMatch(BUTTON_PREV_TEST_SUBJ); + } + + public async goToNextInTableSearchMatch() { + await this.jumpToInTableSearchMatch(BUTTON_NEXT_TEST_SUBJ); + } + + public async getInTableSearchMatchesCounter() { + return (await this.testSubjects.getVisibleText(COUNTER_TEST_SUBJ)).trim(); + } + + public async getInTableSearchCellMatchElements(rowIndex: number, columnName: string) { + const cell = await this.getCellElementByColumnName(rowIndex, columnName); + return await cell.findAllByCssSelector(`.${HIGHLIGHT_CLASS_NAME}`); + } + + public async getInTableSearchCellMatchesCount(rowIndex: number, columnName: string) { + return (await this.getInTableSearchCellMatchElements(rowIndex, columnName)).length; + } } diff --git a/test/tsconfig.json b/test/tsconfig.json index d7e0de39d5e5e..7d4a23e791330 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -78,5 +78,6 @@ "@kbn/core-saved-objects-import-export-server-internal", "@kbn/management-settings-ids", "@kbn/core-deprecations-common", + "@kbn/data-grid-in-table-search", ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 97896db9fc0f1..793665e7bc2d9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -706,6 +706,8 @@ "@kbn/dashboard-plugin/*": ["src/platform/plugins/shared/dashboard/*"], "@kbn/data-forge": ["x-pack/platform/packages/shared/kbn-data-forge"], "@kbn/data-forge/*": ["x-pack/platform/packages/shared/kbn-data-forge/*"], + "@kbn/data-grid-in-table-search": ["src/platform/packages/shared/kbn-data-grid-in-table-search"], + "@kbn/data-grid-in-table-search/*": ["src/platform/packages/shared/kbn-data-grid-in-table-search/*"], "@kbn/data-plugin": ["src/platform/plugins/shared/data"], "@kbn/data-plugin/*": ["src/platform/plugins/shared/data/*"], "@kbn/data-quality-plugin": ["x-pack/platform/plugins/shared/data_quality"], diff --git a/yarn.lock b/yarn.lock index 2728f6f9f191d..699c0a03e3203 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5257,6 +5257,10 @@ version "0.0.0" uid "" +"@kbn/data-grid-in-table-search@link:src/platform/packages/shared/kbn-data-grid-in-table-search": + version "0.0.0" + uid "" + "@kbn/data-plugin@link:src/platform/plugins/shared/data": version "0.0.0" uid ""