From 9a50e4c41141bdf360e168265c854d8f9a757fe5 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 13 Jan 2025 15:56:44 +0100 Subject: [PATCH 01/90] [Discover] Initial implementation --- .../custom_toolbar/render_custom_toolbar.tsx | 4 ++- .../src/components/data_table.scss | 4 +++ .../src/components/data_table.tsx | 12 ++++++- .../src/components/search_control.tsx | 36 +++++++++++++++++++ .../src/table_context.tsx | 1 + .../src/utils/get_render_cell_value.tsx | 20 ++++++++++- 6 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx 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..0d375104d2607 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; + uiSearchControl?: React.ReactNode; }; } @@ -41,7 +42,7 @@ export const internalRenderCustomToolbar = ( keyboardShortcutsControl, displayControl, }, - gridProps: { additionalControls }, + gridProps: { additionalControls, uiSearchControl }, } = props; const buttons = hasRoomForGridControls ? ( @@ -90,6 +91,7 @@ export const internalRenderCustomToolbar = ( {Boolean(leftSide) && buttons} + {Boolean(uiSearchControl) && {uiSearchControl}} {(keyboardShortcutsControl || displayControl || fullScreenControl) && (
diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss index a9d8f26a3c68a..0bc0b926121a6 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss @@ -9,6 +9,10 @@ font-family: $euiCodeFontFamily; } +.unifiedDataTable__findMatch { + background-color: #E5FFC0; +} + .unifiedDataTable__cell--expanded { background-color: $euiColorHighlight; } 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 e7680ccc9175f..f263dded74249 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 @@ -94,6 +94,7 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; +import { SearchControl } from './search_control'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; @@ -650,6 +651,12 @@ export const UnifiedDataTable = ({ onUpdatePageIndex, ]); + const [uiSearchTerm, setUISearchTerm] = useState(''); + + const uiSearchControl = useMemo(() => { + return ; + }, [uiSearchTerm, setUISearchTerm]); + const unifiedDataTableContextValue = useMemo( () => ({ expanded: expandedDoc, @@ -664,6 +671,7 @@ export const UnifiedDataTable = ({ isPlainRecord, pageIndex: isPaginationEnabled ? paginationObj?.pageIndex : 0, pageSize: isPaginationEnabled ? paginationObj?.pageSize : displayedRows.length, + uiSearchTerm, }), [ componentsTourSteps, @@ -678,6 +686,7 @@ export const UnifiedDataTable = ({ selectedDocsState, paginationObj, valueToStringConverter, + uiSearchTerm, ] ); @@ -989,10 +998,11 @@ export const UnifiedDataTable = ({ toolbarProps, gridProps: { additionalControls, + uiSearchControl, }, }) : undefined, - [renderCustomToolbar, additionalControls] + [renderCustomToolbar, additionalControls, uiSearchControl] ); const showDisplaySelector = useMemo((): diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx new file mode 100644 index 0000000000000..ba2ac92247236 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.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, { useCallback } from 'react'; +import { EuiFieldText } from '@elastic/eui'; + +export interface SearchControlProps { + uiSearchTerm: string | undefined; + onChange: (searchTerm: string | undefined) => void; +} + +export const SearchControl: React.FC = ({ uiSearchTerm, onChange }) => { + // TODO: needs debouncing + const onChangeUiSearchTerm = useCallback( + (event) => { + const nextUiSearchTerm = event.target.value.toLowerCase(); + onChange(nextUiSearchTerm); + }, + [onChange] + ); + + return ( + + ); +}; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx index 204453416b6f8..f01fb38aa04ab 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx @@ -27,6 +27,7 @@ export interface DataTableContext { isPlainRecord?: boolean; pageIndex: number | undefined; // undefined when the pagination is disabled pageSize: number | undefined; + uiSearchTerm: string | undefined; } const defaultContext = {} as unknown as DataTableContext; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index f05499f7618b9..2ea1220f5453b 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -140,13 +140,30 @@ export const getRenderCellValueFn = ({ ); } + const { uiSearchTerm } = ctx; + const formattedFieldValueAsHtml = formatFieldValue( + row.flattened[columnId], + row.raw, + fieldFormats, + dataView, + field + ); + let matchIndex = 0; + const fieldValue = uiSearchTerm?.length + ? formattedFieldValueAsHtml.replace( + new RegExp(uiSearchTerm, 'gi'), // TODO: escape the input as it would be passed to html + (match) => + `${match}` + ) + : formattedFieldValueAsHtml; + return ( ); @@ -218,6 +235,7 @@ function renderPopoverContent({ // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ __html: formatFieldValue( + // TODO: update too row.flattened[columnId], row.raw, fieldFormats, From 1b172807cded62de7d99ab508490fe33b54cc3d9 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 13 Jan 2025 20:21:21 +0100 Subject: [PATCH 02/90] [Discover] Some updates --- .../src/data_types/data_table_record.ts | 122 ++++++++++++++++++ .../shared/kbn-discover-utils/src/types.ts | 33 +---- .../src/utils/build_data_record.ts | 6 +- .../src/utils/format_hit.ts | 11 +- .../shared/kbn-discover-utils/types.ts | 2 +- .../src/components/source_document.tsx | 23 +++- .../src/utils/get_render_cell_value.tsx | 45 +++---- .../main/data_fetching/fetch_esql.ts | 6 +- .../shared/esql_datagrid/public/data_grid.tsx | 6 +- 9 files changed, 176 insertions(+), 78 deletions(-) create mode 100644 src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts diff --git a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts new file mode 100644 index 0000000000000..1b3f3d9799e78 --- /dev/null +++ b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts @@ -0,0 +1,122 @@ +/* + * 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 type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { formatFieldValue } from '../utils/format_value'; + +type DiscoverSearchHit = SearchHit>; + +export interface EsHitRecord extends Omit { + _index?: DiscoverSearchHit['_index']; + _id?: DiscoverSearchHit['_id']; + _source?: DiscoverSearchHit['_source']; +} + +/** + * This is the record/row of data provided to our Data Table + */ +export class DataTableRecord { + /** + * A unique id generated by index, id and routing of a record + */ + readonly id: string; + /** + * The document returned by Elasticsearch for search queries + */ + readonly raw: EsHitRecord; + /** + * A flattened version of the ES doc or data provided by SQL, aggregations ... + */ + readonly flattened: Record; + /** + * Determines that the given doc is the anchor doc when rendering view surrounding docs + */ + readonly isAnchor?: boolean; + + /** + * Cache for formatted field values per data view id and field name + * @private + */ + private formattedFieldValuesCache: Record>; + + constructor({ + id, + raw, + flattened, + isAnchor, + }: { + id: string; + raw: EsHitRecord; + flattened: Record; + isAnchor?: boolean; + }) { + this.id = id; + this.raw = raw; + this.isAnchor = isAnchor; + this.flattened = flattened; + + this.formattedFieldValuesCache = {}; + } + + formatAndCacheFieldValue({ + dataView, + fieldName, + fieldFormats, + }: { + dataView: DataView; + fieldName: string; + fieldFormats: FieldFormatsStart; + }): string { + if (!dataView?.id) { + return ''; + } + + const cachedFieldValue = this.formattedFieldValuesCache[dataView.id]?.[fieldName]; + if (typeof cachedFieldValue !== 'undefined') { + return cachedFieldValue; + } + + const newlyFormattedFieldValue = formatFieldValue( + this.flattened[fieldName], + this.raw, + fieldFormats, + dataView, + dataView.fields.getByName(fieldName) + ); + + this.formattedFieldValuesCache[dataView.id] = { + ...this.formattedFieldValuesCache[dataView.id], + [fieldName]: newlyFormattedFieldValue, + }; + + return newlyFormattedFieldValue; + } + + // TODO: cache too? + highlightSearchTermsInFormattedValue({ + formattedFieldValue, + uiSearchTerm, + }: { + formattedFieldValue: string; + uiSearchTerm: string | undefined; + }): string { + let matchIndex = 0; + + return uiSearchTerm?.length + ? formattedFieldValue.replace( + // TODO: implement better replacement to account for html tags + new RegExp(uiSearchTerm, 'gi'), // TODO: escape the input as it would be passed to html + (match) => + `${match}` + ) + : formattedFieldValue; + } +} diff --git a/src/platform/packages/shared/kbn-discover-utils/src/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/types.ts index 2c298da999490..534632f2ab47d 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/types.ts @@ -7,9 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { DatatableColumnMeta } from '@kbn/expressions-plugin/common'; - +export { type EsHitRecord, DataTableRecord } from './data_types/data_table_record'; export type { IgnoredReason, ShouldShowFieldInTableHandler } from './utils'; export type { RowControlColumn, @@ -20,36 +19,6 @@ export type { export type * from './components/app_menu/types'; export { AppMenuActionId, AppMenuActionType } from './components/app_menu/types'; -type DiscoverSearchHit = SearchHit>; - -export interface EsHitRecord extends Omit { - _index?: DiscoverSearchHit['_index']; - _id?: DiscoverSearchHit['_id']; - _source?: DiscoverSearchHit['_source']; -} - -/** - * This is the record/row of data provided to our Data Table - */ -export interface DataTableRecord { - /** - * A unique id generated by index, id and routing of a record - */ - id: string; - /** - * The document returned by Elasticsearch for search queries - */ - raw: EsHitRecord; - /** - * A flattened version of the ES doc or data provided by SQL, aggregations ... - */ - flattened: Record; - /** - * Determines that the given doc is the anchor doc when rendering view surrounding docs - */ - isAnchor?: boolean; -} - /** * Custom column types per column name */ diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts index 83e637f438119..44673f13672e7 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts @@ -13,7 +13,7 @@ import { flattenHit, getFlattenedFieldsComparator, } from '@kbn/data-service'; -import type { DataTableRecord, EsHitRecord } from '../types'; +import { DataTableRecord, EsHitRecord } from '../data_types/data_table_record'; import { getDocId } from './get_doc_id'; /** @@ -28,7 +28,7 @@ export function buildDataTableRecord( isAnchor?: boolean, options?: { flattenedFieldsComparator?: FlattenedFieldsComparator } ): DataTableRecord { - return { + return new DataTableRecord({ id: getDocId(doc), raw: doc, flattened: flattenHit(doc, dataView, { @@ -36,7 +36,7 @@ export function buildDataTableRecord( flattenedFieldsComparator: options?.flattenedFieldsComparator, }), isAnchor, - }; + }); } /** diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts index b29353253df51..6852fdae6c71b 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts @@ -16,7 +16,6 @@ import type { FormattedHit, EsHitRecord, } from '../types'; -import { formatFieldValue } from './format_value'; // We use a special type here allowing formattedValue to be undefined because // we want to avoid formatting values which will not be shown to users since @@ -102,13 +101,11 @@ export function formatHit( const key = pair[2]!; // Format the raw value using the regular field formatters for that field - pair[1] = formatFieldValue( - flattened[key], - hit.raw, - fieldFormats, + pair[1] = hit.formatAndCacheFieldValue({ dataView, - dataView.getFieldByName(key) - ); + fieldName: key, + fieldFormats, + }); } // If document has more formatted fields than configured via MAX_DOC_FIELDS_DISPLAYED we cut diff --git a/src/platform/packages/shared/kbn-discover-utils/types.ts b/src/platform/packages/shared/kbn-discover-utils/types.ts index dfbb54f1f09ca..f04c979cf39ff 100644 --- a/src/platform/packages/shared/kbn-discover-utils/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/types.ts @@ -8,10 +8,10 @@ */ export type { - DataTableRecord, DataTableColumnsMeta, EsHitRecord, IgnoredReason, ShouldShowFieldInTableHandler, FormattedHit, } from './src/types'; +export { DataTableRecord } from './src/types'; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx index f5f659c2ba313..8821c70730186 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx @@ -39,6 +39,7 @@ export function SourceDocument({ dataTestSubj = 'discoverCellDescriptionList', className, isCompressed = true, + uiSearchTerm, }: { useTopLevelObjectColumns: boolean; row: DataTableRecord; @@ -51,6 +52,7 @@ export function SourceDocument({ dataTestSubj?: string; className?: string; isCompressed?: boolean; + uiSearchTerm?: string; }) { const pairs: FormattedHit = useTopLevelObjectColumns ? getTopLevelObjectPairs(row.raw, columnId, dataView, shouldShowFieldHandler).slice( @@ -59,6 +61,8 @@ export function SourceDocument({ ) : formatHit(row, dataView, shouldShowFieldHandler, maxEntries, fieldFormats); + // TODO: what if the match is cut off by the cell height configuration? + return ( - - {fieldDisplayName} - + ); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 2ea1220f5453b..fd0cf4b2f8731 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -18,7 +18,6 @@ import { } from '@elastic/eui'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { DataTableRecord, ShouldShowFieldInTableHandler } from '@kbn/discover-utils/types'; -import { formatFieldValue } from '@kbn/discover-utils'; import { UnifiedDataTableContext } from '../table_context'; import type { CustomCellRenderer } from '../types'; import { SourceDocument } from '../components/source_document'; @@ -62,6 +61,7 @@ export const getRenderCellValueFn = ({ const row = rows ? rows[rowIndex] : undefined; const field = dataView.fields.getByName(columnId); const ctx = useContext(UnifiedDataTableContext); + const { uiSearchTerm } = ctx; useEffect(() => { if (row?.isAnchor) { @@ -83,6 +83,7 @@ export const getRenderCellValueFn = ({ const CustomCellRenderer = externalCustomRenderers?.[columnId]; + // TODO: what to do with highlights here? if (CustomCellRenderer) { return ( @@ -108,6 +109,7 @@ export const getRenderCellValueFn = ({ * when using the fields api this code is used to show top level objects * this is used for legacy stuff like displaying products of our ecommerce dataset */ + // TODO: does it need an update? const useTopLevelObjectColumns = Boolean( !field && row?.raw.fields && !(row.raw.fields as Record)[columnId] ); @@ -121,6 +123,7 @@ export const getRenderCellValueFn = ({ useTopLevelObjectColumns, fieldFormats, closePopover, + uiSearchTerm, }); } @@ -136,26 +139,16 @@ export const getRenderCellValueFn = ({ maxEntries={maxEntries} isPlainRecord={isPlainRecord} isCompressed={isCompressed} + uiSearchTerm={uiSearchTerm} /> ); } - const { uiSearchTerm } = ctx; - const formattedFieldValueAsHtml = formatFieldValue( - row.flattened[columnId], - row.raw, - fieldFormats, + const formattedFieldValue = row.formatAndCacheFieldValue({ dataView, - field - ); - let matchIndex = 0; - const fieldValue = uiSearchTerm?.length - ? formattedFieldValueAsHtml.replace( - new RegExp(uiSearchTerm, 'gi'), // TODO: escape the input as it would be passed to html - (match) => - `${match}` - ) - : formattedFieldValueAsHtml; + fieldName: columnId, + fieldFormats, + }); return ( ); @@ -189,6 +182,7 @@ function renderPopoverContent({ useTopLevelObjectColumns, fieldFormats, closePopover, + uiSearchTerm, }: { row: DataTableRecord; field: DataViewField | undefined; @@ -197,6 +191,7 @@ function renderPopoverContent({ useTopLevelObjectColumns: boolean; fieldFormats: FieldFormatsStart; closePopover: () => void; + uiSearchTerm: string | undefined; }) { const closeButton = ( diff --git a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts index 9c5540443f86d..740ea2c83584c 100644 --- a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts +++ b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts @@ -17,7 +17,7 @@ import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { Datatable } from '@kbn/expressions-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common'; -import type { DataTableRecord } from '@kbn/discover-utils'; +import { DataTableRecord } from '@kbn/discover-utils'; import type { RecordsFetchResponse } from '../../types'; import type { ProfilesManager } from '../../../context_awareness'; @@ -81,11 +81,11 @@ export function fetchEsql({ esqlQueryColumns = table?.columns ?? undefined; esqlHeaderWarning = table.warning ?? undefined; finalData = rows.map((row, idx) => { - const record: DataTableRecord = { + const record: DataTableRecord = new DataTableRecord({ id: String(idx), raw: row, flattened: row, - }; + }); return profilesManager.resolveDocumentProfile({ record }); }); 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 1b6dbca2b5eb8..72042f888f4a8 100644 --- a/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx +++ b/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx @@ -24,7 +24,7 @@ import type { ESQLRow } from '@kbn/es-types'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { AggregateQuery } from '@kbn/es-query'; -import type { DataTableRecord, DataTableColumnsMeta } from '@kbn/discover-utils/types'; +import { DataTableRecord, type DataTableColumnsMeta } from '@kbn/discover-utils/types'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { CoreStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -109,11 +109,11 @@ const DataGrid: React.FC = (props) => { return props.rows .map((row) => zipObject(columnNames, row)) .map((row, idx: number) => { - return { + return new DataTableRecord({ id: String(idx), raw: row, flattened: row, - } as unknown as DataTableRecord; + }); }); }, [props.columns, props.rows]); From 69073981b57f8f97311f298fc1b323206d88c20b Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 14 Jan 2025 13:06:50 +0100 Subject: [PATCH 03/90] [Discover] Calculate matches count --- .../src/data_types/data_table_record.ts | 13 +++ .../src/components/data_table.tsx | 15 ++- .../src/components/search_control.tsx | 32 +++++- .../src/hooks/use_find_search_matches.ts | 98 +++++++++++++++++++ 4 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts diff --git a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts index 1b3f3d9799e78..554b0f8672c0a 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts @@ -119,4 +119,17 @@ export class DataTableRecord { ) : formattedFieldValue; } + + findSearchMatchesInFormattedValue({ + formattedFieldValue, + uiSearchTerm, + }: { + formattedFieldValue: string; + uiSearchTerm: string | undefined; + }): number { + return uiSearchTerm?.length + ? // TODO: implement better matching to account for html tags + (formattedFieldValue.match(new RegExp(uiSearchTerm, 'gi')) || []).length + : 0; + } } 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 f263dded74249..da5128218bb4f 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 @@ -651,11 +651,20 @@ export const UnifiedDataTable = ({ onUpdatePageIndex, ]); - const [uiSearchTerm, setUISearchTerm] = useState(''); + const [uiSearchTerm, setUISearchTerm] = useState(); const uiSearchControl = useMemo(() => { - return ; - }, [uiSearchTerm, setUISearchTerm]); + return ( + + ); + }, [uiSearchTerm, setUISearchTerm, rows, dataView, fieldFormats, visibleColumns]); const unifiedDataTableContextValue = useMemo( () => ({ diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx index ba2ac92247236..7f36e2bdfa39c 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx @@ -8,14 +8,37 @@ */ import React, { useCallback } from 'react'; -import { EuiFieldText } from '@elastic/eui'; +import { EuiFieldSearch } from '@elastic/eui'; +import type { DataTableRecord } from '@kbn/discover-utils'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import { useFindSearchMatches } from '../hooks/use_find_search_matches'; export interface SearchControlProps { uiSearchTerm: string | undefined; + visibleColumns: string[]; + rows: DataTableRecord[]; + dataView: DataView; + fieldFormats: FieldFormatsStart; onChange: (searchTerm: string | undefined) => void; } -export const SearchControl: React.FC = ({ uiSearchTerm, onChange }) => { +export const SearchControl: React.FC = ({ + uiSearchTerm, + visibleColumns, + rows, + dataView, + fieldFormats, + onChange, +}) => { + const { matchesCount, isProcessing } = useFindSearchMatches({ + visibleColumns, + rows, + uiSearchTerm, + dataView, + fieldFormats, + }); + // TODO: needs debouncing const onChangeUiSearchTerm = useCallback( (event) => { @@ -26,8 +49,11 @@ export const SearchControl: React.FC = ({ uiSearchTerm, onCh ); return ( - >; // per row index, per field name, number of matches +const DEFAULT_MATCHES: MatchesMap = {}; + +export interface UseFindSearchMatchesProps { + visibleColumns: string[]; + rows: DataTableRecord[]; + uiSearchTerm: string | undefined; + dataView: DataView; + fieldFormats: FieldFormatsStart; +} + +export interface UseFindSearchMatchesReturn { + matchesCount: number; + isProcessing: boolean; +} + +export const useFindSearchMatches = ({ + visibleColumns, + rows, + uiSearchTerm, + dataView, + fieldFormats, +}: UseFindSearchMatchesProps): UseFindSearchMatchesReturn => { + const [matchesMap, setMatchesMap] = useState(DEFAULT_MATCHES); + const [matchesCount, setMatchesCount] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + + useEffect(() => { + if (!rows?.length || !uiSearchTerm?.length) { + setMatchesMap(DEFAULT_MATCHES); + setMatchesCount(0); + return; + } + + setIsProcessing(true); + const result: Record> = {}; + let totalMatchesCount = 0; + rows.forEach((row, rowIndex) => { + const matchesPerFieldName: Record = {}; + const columns = visibleColumns.includes('_source') + ? Object.keys(row.flattened) + : visibleColumns; + + columns.forEach((fieldName) => { + const formattedFieldValue = row.formatAndCacheFieldValue({ + fieldName, + dataView, + fieldFormats, + }); + + const matchesCountForFieldName = row.findSearchMatchesInFormattedValue({ + formattedFieldValue, + uiSearchTerm, + }); + + if (matchesCountForFieldName) { + matchesPerFieldName[fieldName] = matchesCountForFieldName; + totalMatchesCount += matchesCountForFieldName; + } + }); + if (Object.keys(matchesPerFieldName).length) { + result[rowIndex] = matchesPerFieldName; + } + }); + + setMatchesMap(totalMatchesCount > 0 ? result : DEFAULT_MATCHES); + setMatchesCount(totalMatchesCount); + setIsProcessing(false); + }, [ + setMatchesMap, + setMatchesCount, + setIsProcessing, + visibleColumns, + rows, + uiSearchTerm, + dataView, + fieldFormats, + ]); + + return { + matchesCount, + isProcessing, + }; +}; From bb3385cd6fc932f94f7bfa319ae5c195ad631645 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 14 Jan 2025 14:09:27 +0100 Subject: [PATCH 04/90] [Discover] Add scrolling to a visible row --- .../src/components/data_table.tsx | 6 +- .../src/components/search_control.tsx | 40 +++++++++--- .../src/hooks/use_find_search_matches.ts | 63 ++++++++++++++++++- 3 files changed, 98 insertions(+), 11 deletions(-) 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 da5128218bb4f..f30bc61146b05 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 @@ -661,10 +661,14 @@ export const UnifiedDataTable = ({ rows={rows} dataView={dataView} fieldFormats={fieldFormats} + scrollToRow={(rowIndex) => + // TODO: scroll to the column too? + dataGridRef.current?.scrollToItem?.({ rowIndex, columnIndex: 0, align: 'start' }) + } onChange={setUISearchTerm} /> ); - }, [uiSearchTerm, setUISearchTerm, rows, dataView, fieldFormats, visibleColumns]); + }, [uiSearchTerm, setUISearchTerm, rows, dataView, fieldFormats, visibleColumns, dataGridRef]); const unifiedDataTableContextValue = useMemo( () => ({ diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx index 7f36e2bdfa39c..63d6ab324554f 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx @@ -8,7 +8,7 @@ */ import React, { useCallback } from 'react'; -import { EuiFieldSearch } from '@elastic/eui'; +import { EuiFieldSearch, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import type { DataTableRecord } from '@kbn/discover-utils'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; @@ -20,6 +20,7 @@ export interface SearchControlProps { rows: DataTableRecord[]; dataView: DataView; fieldFormats: FieldFormatsStart; + scrollToRow: (rowIndex: number) => void; onChange: (searchTerm: string | undefined) => void; } @@ -29,15 +30,18 @@ export const SearchControl: React.FC = ({ rows, dataView, fieldFormats, + scrollToRow, onChange, }) => { - const { matchesCount, isProcessing } = useFindSearchMatches({ - visibleColumns, - rows, - uiSearchTerm, - dataView, - fieldFormats, - }); + const { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, isProcessing } = + useFindSearchMatches({ + visibleColumns, + rows, + uiSearchTerm, + dataView, + fieldFormats, + scrollToRow, + }); // TODO: needs debouncing const onChangeUiSearchTerm = useCallback( @@ -53,7 +57,25 @@ export const SearchControl: React.FC = ({ compressed isClearable isLoading={isProcessing} - append={matchesCount || undefined} + append={ + matchesCount ? ( + + {`${activeMatchPosition} / ${matchesCount}`} + {/* TODO: disabled states */} + + + + + {/* TODO: i18n */} + + + + ) : undefined + } placeholder="Search in the table" // TODO: i18n value={uiSearchTerm} onChange={onChangeUiSearchTerm} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts index 2483414a60ad9..1ef249fa75e44 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; @@ -21,11 +21,15 @@ export interface UseFindSearchMatchesProps { uiSearchTerm: string | undefined; dataView: DataView; fieldFormats: FieldFormatsStart; + scrollToRow: (rowIndex: number) => void; } export interface UseFindSearchMatchesReturn { matchesCount: number; + activeMatchPosition: number; isProcessing: boolean; + goToPrevMatch: () => void; + goToNextMatch: () => void; } export const useFindSearchMatches = ({ @@ -34,15 +38,18 @@ export const useFindSearchMatches = ({ uiSearchTerm, dataView, fieldFormats, + scrollToRow, }: UseFindSearchMatchesProps): UseFindSearchMatchesReturn => { const [matchesMap, setMatchesMap] = useState(DEFAULT_MATCHES); const [matchesCount, setMatchesCount] = useState(0); + const [activeMatchPosition, setActiveMatchPosition] = useState(1); const [isProcessing, setIsProcessing] = useState(false); useEffect(() => { if (!rows?.length || !uiSearchTerm?.length) { setMatchesMap(DEFAULT_MATCHES); setMatchesCount(0); + setActiveMatchPosition(1); return; } @@ -79,10 +86,12 @@ export const useFindSearchMatches = ({ setMatchesMap(totalMatchesCount > 0 ? result : DEFAULT_MATCHES); setMatchesCount(totalMatchesCount); + setActiveMatchPosition(1); setIsProcessing(false); }, [ setMatchesMap, setMatchesCount, + setActiveMatchPosition, setIsProcessing, visibleColumns, rows, @@ -91,8 +100,60 @@ export const useFindSearchMatches = ({ fieldFormats, ]); + const scrollToMatch = useCallback( + (matchPosition: number) => { + const rowIndices = Object.keys(matchesMap); + let traversedMatchesCount = 0; + + for (const rowIndex of rowIndices) { + const matchesPerFieldName = matchesMap[rowIndex]; + const fieldNames = Object.keys(matchesPerFieldName); + + for (const fieldName of fieldNames) { + const matchesCountForFieldName = matchesPerFieldName[fieldName]; + + if ( + traversedMatchesCount < matchPosition && + traversedMatchesCount + matchesCountForFieldName >= matchPosition + ) { + scrollToRow(Number(rowIndex)); + return; + } + + traversedMatchesCount += matchesCountForFieldName; + } + } + }, + [matchesMap, scrollToRow] + ); + + const goToPrevMatch = useCallback(() => { + setActiveMatchPosition((prev) => { + if (prev - 1 < 1) { + return prev; + } + const nextMatchPosition = prev - 1; + scrollToMatch(nextMatchPosition); + return nextMatchPosition; + }); + }, [setActiveMatchPosition, scrollToMatch]); + + const goToNextMatch = useCallback(() => { + setActiveMatchPosition((prev) => { + if (prev + 1 > matchesCount) { + return prev; + } + const nextMatchPosition = prev + 1; + scrollToMatch(nextMatchPosition); + return nextMatchPosition; + }); + }, [setActiveMatchPosition, scrollToMatch, matchesCount]); + return { matchesCount, + activeMatchPosition, + goToPrevMatch, + goToNextMatch, isProcessing, }; }; From 1c1a015240aee066491d08d3189ad1d05603e20e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 11:32:35 +0100 Subject: [PATCH 05/90] [Discover] Change the approach --- .../src/data_types/data_table_record.ts | 67 ++++++++++--------- .../src/components/source_document.tsx | 24 +++---- .../src/hooks/use_find_search_matches.ts | 2 +- .../src/utils/get_render_cell_value.tsx | 23 +++---- 4 files changed, 56 insertions(+), 60 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts index 554b0f8672c0a..c1ccfd870a878 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts @@ -42,10 +42,17 @@ export class DataTableRecord { readonly isAnchor?: boolean; /** - * Cache for formatted field values per data view id and field name + * Cache for formatted field values per field name * @private */ - private formattedFieldValuesCache: Record>; + private formattedFieldValuesCache: Record< + string, + { + formatted: string; + formattedAndHighlighted: string; + uiSearchTerm?: string; + } + >; constructor({ id, @@ -70,17 +77,22 @@ export class DataTableRecord { dataView, fieldName, fieldFormats, + uiSearchTerm, }: { dataView: DataView; fieldName: string; fieldFormats: FieldFormatsStart; + uiSearchTerm: string | undefined; }): string { if (!dataView?.id) { return ''; } - const cachedFieldValue = this.formattedFieldValuesCache[dataView.id]?.[fieldName]; - if (typeof cachedFieldValue !== 'undefined') { + const cachedFieldValue = this.formattedFieldValuesCache[fieldName]?.formattedAndHighlighted; + if ( + typeof cachedFieldValue === 'string' && + uiSearchTerm === this.formattedFieldValuesCache[fieldName].uiSearchTerm + ) { return cachedFieldValue; } @@ -92,44 +104,33 @@ export class DataTableRecord { dataView.fields.getByName(fieldName) ); - this.formattedFieldValuesCache[dataView.id] = { - ...this.formattedFieldValuesCache[dataView.id], - [fieldName]: newlyFormattedFieldValue, + this.formattedFieldValuesCache[fieldName] = { + formatted: newlyFormattedFieldValue, + formattedAndHighlighted: newlyFormattedFieldValue, }; - return newlyFormattedFieldValue; - } - - // TODO: cache too? - highlightSearchTermsInFormattedValue({ - formattedFieldValue, - uiSearchTerm, - }: { - formattedFieldValue: string; - uiSearchTerm: string | undefined; - }): string { - let matchIndex = 0; + if (uiSearchTerm?.length) { + let matchIndex = 0; + const formattedAndHighlighted = newlyFormattedFieldValue.replace( + // TODO: implement better replacement to account for html tags + new RegExp(uiSearchTerm, 'gi'), // TODO: escape the input as it would be passed to html + (match) => + `${match}` + ); + this.formattedFieldValuesCache[fieldName].formattedAndHighlighted = formattedAndHighlighted; + this.formattedFieldValuesCache[fieldName].uiSearchTerm = uiSearchTerm; + } - return uiSearchTerm?.length - ? formattedFieldValue.replace( - // TODO: implement better replacement to account for html tags - new RegExp(uiSearchTerm, 'gi'), // TODO: escape the input as it would be passed to html - (match) => - `${match}` - ) - : formattedFieldValue; + return this.formattedFieldValuesCache[fieldName].formattedAndHighlighted || ''; } findSearchMatchesInFormattedValue({ formattedFieldValue, - uiSearchTerm, }: { formattedFieldValue: string; - uiSearchTerm: string | undefined; }): number { - return uiSearchTerm?.length - ? // TODO: implement better matching to account for html tags - (formattedFieldValue.match(new RegExp(uiSearchTerm, 'gi')) || []).length - : 0; + return ( + formattedFieldValue.match(new RegExp('mark class="unifiedDataTable__findMatch"', 'gi')) || [] + ).length; } } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx index 8821c70730186..be2c1096947a5 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx @@ -76,22 +76,20 @@ export function SourceDocument({ if (isPlainRecord && fieldName && (row.flattened[fieldName] ?? null) === null) return null; return ( - + + {fieldDisplayName} + diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts index 1ef249fa75e44..9b97681f228df 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts @@ -67,11 +67,11 @@ export const useFindSearchMatches = ({ fieldName, dataView, fieldFormats, + uiSearchTerm, }); const matchesCountForFieldName = row.findSearchMatchesInFormattedValue({ formattedFieldValue, - uiSearchTerm, }); if (matchesCountForFieldName) { diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index fd0cf4b2f8731..0f1ba9846e862 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -144,19 +144,18 @@ export const getRenderCellValueFn = ({ ); } - const formattedFieldValue = row.formatAndCacheFieldValue({ - dataView, - fieldName: columnId, - fieldFormats, - }); - return ( ); @@ -229,12 +228,10 @@ function renderPopoverContent({ // formatFieldValue guarantees sanitized values // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ - __html: row.highlightSearchTermsInFormattedValue({ - formattedFieldValue: row.formatAndCacheFieldValue({ - dataView, - fieldName: columnId, - fieldFormats, - }), + __html: row.formatAndCacheFieldValue({ + dataView, + fieldName: columnId, + fieldFormats, uiSearchTerm, }), }} From 949d18cf706e8e53b32fb30d83008120fbe032d3 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 11:54:51 +0100 Subject: [PATCH 06/90] [Discover] Improve highlight inserts --- .../src/data_types/data_table_record.ts | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts index c1ccfd870a878..402ed2946aa46 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts @@ -10,6 +10,7 @@ import type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; +import { escape } from 'lodash'; import { formatFieldValue } from '../utils/format_value'; type DiscoverSearchHit = SearchHit>; @@ -110,13 +111,7 @@ export class DataTableRecord { }; if (uiSearchTerm?.length) { - let matchIndex = 0; - const formattedAndHighlighted = newlyFormattedFieldValue.replace( - // TODO: implement better replacement to account for html tags - new RegExp(uiSearchTerm, 'gi'), // TODO: escape the input as it would be passed to html - (match) => - `${match}` - ); + const formattedAndHighlighted = addSearchHighlights(newlyFormattedFieldValue, uiSearchTerm); this.formattedFieldValuesCache[fieldName].formattedAndHighlighted = formattedAndHighlighted; this.formattedFieldValuesCache[fieldName].uiSearchTerm = uiSearchTerm; } @@ -134,3 +129,51 @@ export class DataTableRecord { ).length; } } + +export function addSearchHighlights( + formattedFieldValueAsHtml: string, + uiSearchTerm: string +): string { + if (!uiSearchTerm) return formattedFieldValueAsHtml; + const searchTerm = escape(uiSearchTerm); + + const parser = new DOMParser(); + const result = parser.parseFromString(formattedFieldValueAsHtml, 'text/html'); + const searchTermRegExp = new RegExp(`(${searchTerm})`, 'gi'); + + let matchIndex = 0; + + 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 parts = (nodeWithText.textContent || '').split(searchTermRegExp); + + if (parts.length > 1) { + const nodeWithHighlights = document.createDocumentFragment(); + + parts.forEach((part) => { + if (searchTermRegExp.test(part)) { + const mark = document.createElement('mark'); + mark.textContent = part; + mark.setAttribute('class', 'unifiedDataTable__findMatch'); + mark.setAttribute('data-match-index', `${matchIndex++}`); + nodeWithHighlights.appendChild(mark); + } else { + nodeWithHighlights.appendChild(document.createTextNode(part)); + } + }); + + nodeWithText.replaceWith(nodeWithHighlights); + } + } + } + + Array.from(result.body.childNodes).forEach(insertSearchHighlights); + + return result.body.innerHTML; +} From a0ddc31201d6bd1defa0975953f793dc02c9ef89 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 11:59:06 +0100 Subject: [PATCH 07/90] [Discover] Rename --- .../src/data_types/data_table_record.ts | 10 ++-------- .../src/hooks/use_find_search_matches.ts | 7 +++---- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts index 402ed2946aa46..0f0afe471e1fc 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts @@ -119,14 +119,8 @@ export class DataTableRecord { return this.formattedFieldValuesCache[fieldName].formattedAndHighlighted || ''; } - findSearchMatchesInFormattedValue({ - formattedFieldValue, - }: { - formattedFieldValue: string; - }): number { - return ( - formattedFieldValue.match(new RegExp('mark class="unifiedDataTable__findMatch"', 'gi')) || [] - ).length; + findSearchMatchesInFormattedAndHighlightedValue(value: string): number { + return (value.match(new RegExp('mark class="unifiedDataTable__findMatch"', 'gi')) || []).length; } } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts index 9b97681f228df..9317817ddc7eb 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts @@ -70,9 +70,8 @@ export const useFindSearchMatches = ({ uiSearchTerm, }); - const matchesCountForFieldName = row.findSearchMatchesInFormattedValue({ - formattedFieldValue, - }); + const matchesCountForFieldName = + row.findSearchMatchesInFormattedAndHighlightedValue(formattedFieldValue); if (matchesCountForFieldName) { matchesPerFieldName[fieldName] = matchesCountForFieldName; @@ -106,7 +105,7 @@ export const useFindSearchMatches = ({ let traversedMatchesCount = 0; for (const rowIndex of rowIndices) { - const matchesPerFieldName = matchesMap[rowIndex]; + const matchesPerFieldName = matchesMap[Number(rowIndex)]; const fieldNames = Object.keys(matchesPerFieldName); for (const fieldName of fieldNames) { From f7286db2b782297e1f4d7cc8d00638d155af3556 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 13:50:06 +0100 Subject: [PATCH 08/90] [Discover] Add highlights for the next active match --- .../src/components/data_table.tsx | 48 +++++- .../src/components/search_control.tsx | 17 +- .../src/hooks/use_find_search_matches.ts | 155 +++++++++++------- 3 files changed, 146 insertions(+), 74 deletions(-) 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 f30bc61146b05..1f1d38207ea14 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 @@ -36,6 +36,7 @@ import { useDataGridColumnsCellActions, type UseDataGridColumnsCellActionsProps, } from '@kbn/cell-actions'; +import { SerializedStyles, css } from '@emotion/react'; import type { ToastsStart, IUiSettingsClient } from '@kbn/core/public'; import type { Serializable } from '@kbn/utility-types'; import type { DataTableRecord } from '@kbn/discover-utils/types'; @@ -652,6 +653,7 @@ export const UnifiedDataTable = ({ ]); const [uiSearchTerm, setUISearchTerm] = useState(); + const [uiSearchTermCss, setUISearchTermCss] = useState(); const uiSearchControl = useMemo(() => { return ( @@ -661,14 +663,47 @@ export const UnifiedDataTable = ({ rows={rows} dataView={dataView} fieldFormats={fieldFormats} - scrollToRow={(rowIndex) => - // TODO: scroll to the column too? - dataGridRef.current?.scrollToItem?.({ rowIndex, columnIndex: 0, align: 'start' }) - } - onChange={setUISearchTerm} + scrollToFoundMatch={({ rowIndex, fieldName, matchIndex, shouldJump }) => { + // TODO: use a named color token + setUISearchTermCss(css` + .euiDataGridRowCell[data-gridcell-visible-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] + .unifiedDataTable__findMatch[data-match-index='${matchIndex}'] { + background-color: #ffc30e; + } + `); + + if (shouldJump) { + const anyCellForFieldName = document.querySelector( + `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` + ); + + // getting column index by column id + const columnIndex = + anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; + + dataGridRef.current?.scrollToItem?.({ + rowIndex, + columnIndex: Number(columnIndex), + align: 'start', + }); + } + }} + onChange={(searchTerm) => { + setUISearchTerm(searchTerm); + setUISearchTermCss(undefined); + }} /> ); - }, [uiSearchTerm, setUISearchTerm, rows, dataView, fieldFormats, visibleColumns, dataGridRef]); + }, [ + uiSearchTerm, + setUISearchTerm, + setUISearchTermCss, + rows, + dataView, + fieldFormats, + visibleColumns, + dataGridRef, + ]); const unifiedDataTableContextValue = useMemo( () => ({ @@ -1148,6 +1183,7 @@ export const UnifiedDataTable = ({ data-description={searchDescription} data-document-number={displayedRows.length} className={classnames(className, 'unifiedDataTable__table')} + css={uiSearchTermCss} > {isCompareActive ? ( void; +export interface SearchControlProps extends UseFindSearchMatchesProps { onChange: (searchTerm: string | undefined) => void; } @@ -30,7 +21,7 @@ export const SearchControl: React.FC = ({ rows, dataView, fieldFormats, - scrollToRow, + scrollToFoundMatch, onChange, }) => { const { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, isProcessing } = @@ -40,7 +31,7 @@ export const SearchControl: React.FC = ({ uiSearchTerm, dataView, fieldFormats, - scrollToRow, + scrollToFoundMatch, }); // TODO: needs debouncing diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts index 9317817ddc7eb..ad00460aaa409 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts @@ -14,6 +14,7 @@ import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; type MatchesMap = Record>; // per row index, per field name, number of matches const DEFAULT_MATCHES: MatchesMap = {}; +const DEFAULT_ACTIVE_MATCH_POSITION = 1; export interface UseFindSearchMatchesProps { visibleColumns: string[]; @@ -21,7 +22,12 @@ export interface UseFindSearchMatchesProps { uiSearchTerm: string | undefined; dataView: DataView; fieldFormats: FieldFormatsStart; - scrollToRow: (rowIndex: number) => void; + scrollToFoundMatch: (params: { + rowIndex: number; + fieldName: string; + matchIndex: number; + shouldJump: boolean; + }) => void; } export interface UseFindSearchMatchesReturn { @@ -38,18 +44,94 @@ export const useFindSearchMatches = ({ uiSearchTerm, dataView, fieldFormats, - scrollToRow, + scrollToFoundMatch, }: UseFindSearchMatchesProps): UseFindSearchMatchesReturn => { const [matchesMap, setMatchesMap] = useState(DEFAULT_MATCHES); const [matchesCount, setMatchesCount] = useState(0); - const [activeMatchPosition, setActiveMatchPosition] = useState(1); + const [activeMatchPosition, setActiveMatchPosition] = useState( + DEFAULT_ACTIVE_MATCH_POSITION + ); const [isProcessing, setIsProcessing] = useState(false); + const scrollToMatch = useCallback( + ({ + matchPosition, + activeMatchesMap, + activeColumns, + shouldJump, + }: { + matchPosition: number; + activeMatchesMap: MatchesMap; + activeColumns: string[]; + shouldJump: boolean; + }) => { + const rowIndices = Object.keys(activeMatchesMap); + let traversedMatchesCount = 0; + + for (const rowIndex of rowIndices) { + const matchesPerFieldName = activeMatchesMap[Number(rowIndex)]; + const fieldNames = Object.keys(matchesPerFieldName); + + for (const fieldName of fieldNames) { + const matchesCountForFieldName = matchesPerFieldName[fieldName]; + + if ( + traversedMatchesCount < matchPosition && + traversedMatchesCount + matchesCountForFieldName >= matchPosition + ) { + scrollToFoundMatch({ + rowIndex: Number(rowIndex), + fieldName, + matchIndex: matchPosition - traversedMatchesCount - 1, + shouldJump, + }); + return; + } + + traversedMatchesCount += matchesCountForFieldName; + } + } + }, + [scrollToFoundMatch] + ); + + const goToPrevMatch = useCallback(() => { + setActiveMatchPosition((prev) => { + if (prev - 1 < 1) { + return prev; + } + const nextMatchPosition = prev - 1; + scrollToMatch({ + matchPosition: nextMatchPosition, + activeMatchesMap: matchesMap, + activeColumns: visibleColumns, + shouldJump: true, + }); + return nextMatchPosition; + }); + }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns]); + + const goToNextMatch = useCallback(() => { + setActiveMatchPosition((prev) => { + if (prev + 1 > matchesCount) { + return prev; + } + const nextMatchPosition = prev + 1; + scrollToMatch({ + matchPosition: nextMatchPosition, + activeMatchesMap: matchesMap, + activeColumns: visibleColumns, + shouldJump: true, + }); + return nextMatchPosition; + }); + }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns, matchesCount]); + useEffect(() => { if (!rows?.length || !uiSearchTerm?.length) { setMatchesMap(DEFAULT_MATCHES); setMatchesCount(0); - setActiveMatchPosition(1); + setActiveMatchPosition(DEFAULT_ACTIVE_MATCH_POSITION); return; } @@ -83,15 +165,27 @@ export const useFindSearchMatches = ({ } }); - setMatchesMap(totalMatchesCount > 0 ? result : DEFAULT_MATCHES); + const nextMatchesMap = totalMatchesCount > 0 ? result : DEFAULT_MATCHES; + const nextActiveMatchPosition = DEFAULT_ACTIVE_MATCH_POSITION; + setMatchesMap(nextMatchesMap); setMatchesCount(totalMatchesCount); - setActiveMatchPosition(1); + setActiveMatchPosition(nextActiveMatchPosition); setIsProcessing(false); + + if (totalMatchesCount > 0) { + scrollToMatch({ + matchPosition: nextActiveMatchPosition, + activeMatchesMap: nextMatchesMap, + activeColumns: visibleColumns, + shouldJump: false, + }); + } }, [ setMatchesMap, setMatchesCount, setActiveMatchPosition, setIsProcessing, + scrollToMatch, visibleColumns, rows, uiSearchTerm, @@ -99,55 +193,6 @@ export const useFindSearchMatches = ({ fieldFormats, ]); - const scrollToMatch = useCallback( - (matchPosition: number) => { - const rowIndices = Object.keys(matchesMap); - let traversedMatchesCount = 0; - - for (const rowIndex of rowIndices) { - const matchesPerFieldName = matchesMap[Number(rowIndex)]; - const fieldNames = Object.keys(matchesPerFieldName); - - for (const fieldName of fieldNames) { - const matchesCountForFieldName = matchesPerFieldName[fieldName]; - - if ( - traversedMatchesCount < matchPosition && - traversedMatchesCount + matchesCountForFieldName >= matchPosition - ) { - scrollToRow(Number(rowIndex)); - return; - } - - traversedMatchesCount += matchesCountForFieldName; - } - } - }, - [matchesMap, scrollToRow] - ); - - const goToPrevMatch = useCallback(() => { - setActiveMatchPosition((prev) => { - if (prev - 1 < 1) { - return prev; - } - const nextMatchPosition = prev - 1; - scrollToMatch(nextMatchPosition); - return nextMatchPosition; - }); - }, [setActiveMatchPosition, scrollToMatch]); - - const goToNextMatch = useCallback(() => { - setActiveMatchPosition((prev) => { - if (prev + 1 > matchesCount) { - return prev; - } - const nextMatchPosition = prev + 1; - scrollToMatch(nextMatchPosition); - return nextMatchPosition; - }); - }, [setActiveMatchPosition, scrollToMatch, matchesCount]); - return { matchesCount, activeMatchPosition, From 27559ec520bb983ba30dc261de8e73d7fbfcd0be Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 14:17:58 +0100 Subject: [PATCH 09/90] [Discover] Exclude Summary column for now completely --- .../kbn-discover-utils/src/utils/format_hit.ts | 11 +++++++---- .../src/components/source_document.tsx | 15 +-------------- .../src/hooks/use_find_search_matches.ts | 10 ++++++---- .../src/utils/get_render_cell_value.tsx | 1 - 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts index 6852fdae6c71b..b29353253df51 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts @@ -16,6 +16,7 @@ import type { FormattedHit, EsHitRecord, } from '../types'; +import { formatFieldValue } from './format_value'; // We use a special type here allowing formattedValue to be undefined because // we want to avoid formatting values which will not be shown to users since @@ -101,11 +102,13 @@ export function formatHit( const key = pair[2]!; // Format the raw value using the regular field formatters for that field - pair[1] = hit.formatAndCacheFieldValue({ - dataView, - fieldName: key, + pair[1] = formatFieldValue( + flattened[key], + hit.raw, fieldFormats, - }); + dataView, + dataView.getFieldByName(key) + ); } // If document has more formatted fields than configured via MAX_DOC_FIELDS_DISPLAYED we cut diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx index be2c1096947a5..f5f659c2ba313 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/source_document.tsx @@ -39,7 +39,6 @@ export function SourceDocument({ dataTestSubj = 'discoverCellDescriptionList', className, isCompressed = true, - uiSearchTerm, }: { useTopLevelObjectColumns: boolean; row: DataTableRecord; @@ -52,7 +51,6 @@ export function SourceDocument({ dataTestSubj?: string; className?: string; isCompressed?: boolean; - uiSearchTerm?: string; }) { const pairs: FormattedHit = useTopLevelObjectColumns ? getTopLevelObjectPairs(row.raw, columnId, dataView, shouldShowFieldHandler).slice( @@ -61,8 +59,6 @@ export function SourceDocument({ ) : formatHit(row, dataView, shouldShowFieldHandler, maxEntries, fieldFormats); - // TODO: what if the match is cut off by the cell height configuration? - return ( ); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts index ad00460aaa409..fd47cb34fe8f1 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts @@ -70,7 +70,10 @@ export const useFindSearchMatches = ({ for (const rowIndex of rowIndices) { const matchesPerFieldName = activeMatchesMap[Number(rowIndex)]; - const fieldNames = Object.keys(matchesPerFieldName); + // TODO: figure out what to do with Summary column + const fieldNames = activeColumns.filter( + (fieldName) => fieldName !== '_source' && fieldName in matchesPerFieldName + ); for (const fieldName of fieldNames) { const matchesCountForFieldName = matchesPerFieldName[fieldName]; @@ -140,9 +143,8 @@ export const useFindSearchMatches = ({ let totalMatchesCount = 0; rows.forEach((row, rowIndex) => { const matchesPerFieldName: Record = {}; - const columns = visibleColumns.includes('_source') - ? Object.keys(row.flattened) - : visibleColumns; + // TODO: figure out what to do with Summary column + const columns = visibleColumns.filter((fieldName) => fieldName !== '_source'); columns.forEach((fieldName) => { const formattedFieldValue = row.formatAndCacheFieldValue({ diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 0f1ba9846e862..e2be1605763f5 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -139,7 +139,6 @@ export const getRenderCellValueFn = ({ maxEntries={maxEntries} isPlainRecord={isPlainRecord} isCompressed={isCompressed} - uiSearchTerm={uiSearchTerm} /> ); } From dadb590031a515a4993ebc3f3fdae2b88ab421c8 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 16:28:11 +0100 Subject: [PATCH 10/90] [Discover] Support Summary column too --- .../src/data_types/data_table_record.ts | 116 ------- .../src/components/data_table.tsx | 98 +++--- .../src/components/search_control.tsx | 15 +- .../src/hooks/use_find_search_matches.ts | 205 ------------ .../src/hooks/use_find_search_matches.tsx | 308 ++++++++++++++++++ .../src/utils/get_render_cell_value.tsx | 126 ++++--- 6 files changed, 441 insertions(+), 427 deletions(-) delete mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx diff --git a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts index 0f0afe471e1fc..fc4e1b84ffec6 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts @@ -8,10 +8,6 @@ */ import type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import type { DataView } from '@kbn/data-views-plugin/common'; -import { escape } from 'lodash'; -import { formatFieldValue } from '../utils/format_value'; type DiscoverSearchHit = SearchHit>; @@ -42,19 +38,6 @@ export class DataTableRecord { */ readonly isAnchor?: boolean; - /** - * Cache for formatted field values per field name - * @private - */ - private formattedFieldValuesCache: Record< - string, - { - formatted: string; - formattedAndHighlighted: string; - uiSearchTerm?: string; - } - >; - constructor({ id, raw, @@ -70,104 +53,5 @@ export class DataTableRecord { this.raw = raw; this.isAnchor = isAnchor; this.flattened = flattened; - - this.formattedFieldValuesCache = {}; - } - - formatAndCacheFieldValue({ - dataView, - fieldName, - fieldFormats, - uiSearchTerm, - }: { - dataView: DataView; - fieldName: string; - fieldFormats: FieldFormatsStart; - uiSearchTerm: string | undefined; - }): string { - if (!dataView?.id) { - return ''; - } - - const cachedFieldValue = this.formattedFieldValuesCache[fieldName]?.formattedAndHighlighted; - if ( - typeof cachedFieldValue === 'string' && - uiSearchTerm === this.formattedFieldValuesCache[fieldName].uiSearchTerm - ) { - return cachedFieldValue; - } - - const newlyFormattedFieldValue = formatFieldValue( - this.flattened[fieldName], - this.raw, - fieldFormats, - dataView, - dataView.fields.getByName(fieldName) - ); - - this.formattedFieldValuesCache[fieldName] = { - formatted: newlyFormattedFieldValue, - formattedAndHighlighted: newlyFormattedFieldValue, - }; - - if (uiSearchTerm?.length) { - const formattedAndHighlighted = addSearchHighlights(newlyFormattedFieldValue, uiSearchTerm); - this.formattedFieldValuesCache[fieldName].formattedAndHighlighted = formattedAndHighlighted; - this.formattedFieldValuesCache[fieldName].uiSearchTerm = uiSearchTerm; - } - - return this.formattedFieldValuesCache[fieldName].formattedAndHighlighted || ''; - } - - findSearchMatchesInFormattedAndHighlightedValue(value: string): number { - return (value.match(new RegExp('mark class="unifiedDataTable__findMatch"', 'gi')) || []).length; } } - -export function addSearchHighlights( - formattedFieldValueAsHtml: string, - uiSearchTerm: string -): string { - if (!uiSearchTerm) return formattedFieldValueAsHtml; - const searchTerm = escape(uiSearchTerm); - - const parser = new DOMParser(); - const result = parser.parseFromString(formattedFieldValueAsHtml, 'text/html'); - const searchTermRegExp = new RegExp(`(${searchTerm})`, 'gi'); - - let matchIndex = 0; - - 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 parts = (nodeWithText.textContent || '').split(searchTermRegExp); - - if (parts.length > 1) { - const nodeWithHighlights = document.createDocumentFragment(); - - parts.forEach((part) => { - if (searchTermRegExp.test(part)) { - const mark = document.createElement('mark'); - mark.textContent = part; - mark.setAttribute('class', 'unifiedDataTable__findMatch'); - mark.setAttribute('data-match-index', `${matchIndex++}`); - nodeWithHighlights.appendChild(mark); - } else { - nodeWithHighlights.appendChild(document.createTextNode(part)); - } - }); - - nodeWithText.replaceWith(nodeWithHighlights); - } - } - } - - Array.from(result.body.childNodes).forEach(insertSearchHighlights); - - return result.body.innerHTML; -} 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 1f1d38207ea14..f877474844492 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 @@ -655,56 +655,6 @@ export const UnifiedDataTable = ({ const [uiSearchTerm, setUISearchTerm] = useState(); const [uiSearchTermCss, setUISearchTermCss] = useState(); - const uiSearchControl = useMemo(() => { - return ( - { - // TODO: use a named color token - setUISearchTermCss(css` - .euiDataGridRowCell[data-gridcell-visible-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] - .unifiedDataTable__findMatch[data-match-index='${matchIndex}'] { - background-color: #ffc30e; - } - `); - - if (shouldJump) { - const anyCellForFieldName = document.querySelector( - `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` - ); - - // getting column index by column id - const columnIndex = - anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; - - dataGridRef.current?.scrollToItem?.({ - rowIndex, - columnIndex: Number(columnIndex), - align: 'start', - }); - } - }} - onChange={(searchTerm) => { - setUISearchTerm(searchTerm); - setUISearchTermCss(undefined); - }} - /> - ); - }, [ - uiSearchTerm, - setUISearchTerm, - setUISearchTermCss, - rows, - dataView, - fieldFormats, - visibleColumns, - dataGridRef, - ]); - const unifiedDataTableContextValue = useMemo( () => ({ expanded: expandedDoc, @@ -788,6 +738,54 @@ export const UnifiedDataTable = ({ ] ); + const uiSearchControl = useMemo(() => { + return ( + { + // TODO: use a named color token + setUISearchTermCss(css` + .euiDataGridRowCell[data-gridcell-visible-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] + .unifiedDataTable__findMatch[data-match-index='${matchIndex}'] { + background-color: #ffc30e; + } + `); + + if (shouldJump) { + const anyCellForFieldName = document.querySelector( + `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` + ); + + // getting column index by column id + const columnIndex = + anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; + + dataGridRef.current?.scrollToItem?.({ + rowIndex, + columnIndex: Number(columnIndex), + align: 'start', + }); + } + }} + onChange={(searchTerm) => { + setUISearchTerm(searchTerm); + setUISearchTermCss(undefined); + }} + /> + ); + }, [ + uiSearchTerm, + setUISearchTerm, + setUISearchTermCss, + rows, + renderCellValue, + visibleColumns, + dataGridRef, + ]); + const renderCustomPopover = useMemo( () => renderCellPopover ?? getCustomCellPopoverRenderer(), [renderCellPopover] diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx index 1cad483bdd5e2..fe530f0fef3de 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx @@ -19,8 +19,7 @@ export const SearchControl: React.FC = ({ uiSearchTerm, visibleColumns, rows, - dataView, - fieldFormats, + renderCellValue, scrollToFoundMatch, onChange, }) => { @@ -29,8 +28,7 @@ export const SearchControl: React.FC = ({ visibleColumns, rows, uiSearchTerm, - dataView, - fieldFormats, + renderCellValue, scrollToFoundMatch, }); @@ -52,17 +50,22 @@ export const SearchControl: React.FC = ({ matchesCount ? ( {`${activeMatchPosition} / ${matchesCount}`} - {/* TODO: disabled states */} {/* TODO: i18n */} - + = matchesCount} + onClick={goToNextMatch} + /> ) : undefined diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts deleted file mode 100644 index fd47cb34fe8f1..0000000000000 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.ts +++ /dev/null @@ -1,205 +0,0 @@ -/* - * 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 { useCallback, useEffect, useState } from 'react'; -import type { DataTableRecord } from '@kbn/discover-utils/types'; -import type { DataView } from '@kbn/data-views-plugin/common'; -import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; - -type MatchesMap = Record>; // per row index, per field name, number of matches -const DEFAULT_MATCHES: MatchesMap = {}; -const DEFAULT_ACTIVE_MATCH_POSITION = 1; - -export interface UseFindSearchMatchesProps { - visibleColumns: string[]; - rows: DataTableRecord[]; - uiSearchTerm: string | undefined; - dataView: DataView; - fieldFormats: FieldFormatsStart; - scrollToFoundMatch: (params: { - rowIndex: number; - fieldName: string; - matchIndex: number; - shouldJump: boolean; - }) => void; -} - -export interface UseFindSearchMatchesReturn { - matchesCount: number; - activeMatchPosition: number; - isProcessing: boolean; - goToPrevMatch: () => void; - goToNextMatch: () => void; -} - -export const useFindSearchMatches = ({ - visibleColumns, - rows, - uiSearchTerm, - dataView, - fieldFormats, - scrollToFoundMatch, -}: UseFindSearchMatchesProps): UseFindSearchMatchesReturn => { - const [matchesMap, setMatchesMap] = useState(DEFAULT_MATCHES); - const [matchesCount, setMatchesCount] = useState(0); - const [activeMatchPosition, setActiveMatchPosition] = useState( - DEFAULT_ACTIVE_MATCH_POSITION - ); - const [isProcessing, setIsProcessing] = useState(false); - - const scrollToMatch = useCallback( - ({ - matchPosition, - activeMatchesMap, - activeColumns, - shouldJump, - }: { - matchPosition: number; - activeMatchesMap: MatchesMap; - activeColumns: string[]; - shouldJump: boolean; - }) => { - const rowIndices = Object.keys(activeMatchesMap); - let traversedMatchesCount = 0; - - for (const rowIndex of rowIndices) { - const matchesPerFieldName = activeMatchesMap[Number(rowIndex)]; - // TODO: figure out what to do with Summary column - const fieldNames = activeColumns.filter( - (fieldName) => fieldName !== '_source' && fieldName in matchesPerFieldName - ); - - for (const fieldName of fieldNames) { - const matchesCountForFieldName = matchesPerFieldName[fieldName]; - - if ( - traversedMatchesCount < matchPosition && - traversedMatchesCount + matchesCountForFieldName >= matchPosition - ) { - scrollToFoundMatch({ - rowIndex: Number(rowIndex), - fieldName, - matchIndex: matchPosition - traversedMatchesCount - 1, - shouldJump, - }); - return; - } - - traversedMatchesCount += matchesCountForFieldName; - } - } - }, - [scrollToFoundMatch] - ); - - const goToPrevMatch = useCallback(() => { - setActiveMatchPosition((prev) => { - if (prev - 1 < 1) { - return prev; - } - const nextMatchPosition = prev - 1; - scrollToMatch({ - matchPosition: nextMatchPosition, - activeMatchesMap: matchesMap, - activeColumns: visibleColumns, - shouldJump: true, - }); - return nextMatchPosition; - }); - }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns]); - - const goToNextMatch = useCallback(() => { - setActiveMatchPosition((prev) => { - if (prev + 1 > matchesCount) { - return prev; - } - const nextMatchPosition = prev + 1; - scrollToMatch({ - matchPosition: nextMatchPosition, - activeMatchesMap: matchesMap, - activeColumns: visibleColumns, - shouldJump: true, - }); - return nextMatchPosition; - }); - }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns, matchesCount]); - - useEffect(() => { - if (!rows?.length || !uiSearchTerm?.length) { - setMatchesMap(DEFAULT_MATCHES); - setMatchesCount(0); - setActiveMatchPosition(DEFAULT_ACTIVE_MATCH_POSITION); - return; - } - - setIsProcessing(true); - const result: Record> = {}; - let totalMatchesCount = 0; - rows.forEach((row, rowIndex) => { - const matchesPerFieldName: Record = {}; - // TODO: figure out what to do with Summary column - const columns = visibleColumns.filter((fieldName) => fieldName !== '_source'); - - columns.forEach((fieldName) => { - const formattedFieldValue = row.formatAndCacheFieldValue({ - fieldName, - dataView, - fieldFormats, - uiSearchTerm, - }); - - const matchesCountForFieldName = - row.findSearchMatchesInFormattedAndHighlightedValue(formattedFieldValue); - - if (matchesCountForFieldName) { - matchesPerFieldName[fieldName] = matchesCountForFieldName; - totalMatchesCount += matchesCountForFieldName; - } - }); - if (Object.keys(matchesPerFieldName).length) { - result[rowIndex] = matchesPerFieldName; - } - }); - - const nextMatchesMap = totalMatchesCount > 0 ? result : DEFAULT_MATCHES; - const nextActiveMatchPosition = DEFAULT_ACTIVE_MATCH_POSITION; - setMatchesMap(nextMatchesMap); - setMatchesCount(totalMatchesCount); - setActiveMatchPosition(nextActiveMatchPosition); - setIsProcessing(false); - - if (totalMatchesCount > 0) { - scrollToMatch({ - matchPosition: nextActiveMatchPosition, - activeMatchesMap: nextMatchesMap, - activeColumns: visibleColumns, - shouldJump: false, - }); - } - }, [ - setMatchesMap, - setMatchesCount, - setActiveMatchPosition, - setIsProcessing, - scrollToMatch, - visibleColumns, - rows, - uiSearchTerm, - dataView, - fieldFormats, - ]); - - return { - matchesCount, - activeMatchPosition, - goToPrevMatch, - goToNextMatch, - isProcessing, - }; -}; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx new file mode 100644 index 0000000000000..3819cb645b535 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx @@ -0,0 +1,308 @@ +/* + * 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, ReactNode, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import type { DataTableRecord } from '@kbn/discover-utils/types'; +import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { escape } from 'lodash'; + +type MatchesMap = Record>; // per row index, per field name, number of matches +const DEFAULT_MATCHES: MatchesMap = {}; +const DEFAULT_ACTIVE_MATCH_POSITION = 1; + +export interface UseFindSearchMatchesProps { + visibleColumns: string[]; + rows: DataTableRecord[]; + uiSearchTerm: string | undefined; + renderCellValue: (props: EuiDataGridCellValueElementProps) => ReactNode; + scrollToFoundMatch: (params: { + rowIndex: number; + fieldName: string; + matchIndex: number; + shouldJump: boolean; + }) => void; +} + +export interface UseFindSearchMatchesReturn { + matchesCount: number; + activeMatchPosition: number; + isProcessing: boolean; + goToPrevMatch: () => void; + goToNextMatch: () => void; +} + +export const useFindSearchMatches = ({ + visibleColumns, + rows, + uiSearchTerm, + renderCellValue, + scrollToFoundMatch, +}: UseFindSearchMatchesProps): UseFindSearchMatchesReturn => { + const [matchesMap, setMatchesMap] = useState(DEFAULT_MATCHES); + const [matchesCount, setMatchesCount] = useState(0); + const [activeMatchPosition, setActiveMatchPosition] = useState( + DEFAULT_ACTIVE_MATCH_POSITION + ); + const [isProcessing, setIsProcessing] = useState(false); + + const scrollToMatch = useCallback( + ({ + matchPosition, + activeMatchesMap, + activeColumns, + shouldJump, + }: { + matchPosition: number; + activeMatchesMap: MatchesMap; + activeColumns: string[]; + shouldJump: boolean; + }) => { + const rowIndices = Object.keys(activeMatchesMap); + let traversedMatchesCount = 0; + + for (const rowIndex of rowIndices) { + const matchesPerFieldName = activeMatchesMap[Number(rowIndex)]; + + for (const fieldName of activeColumns) { + const matchesCountForFieldName = matchesPerFieldName[fieldName] ?? 0; + + if ( + traversedMatchesCount < matchPosition && + traversedMatchesCount + matchesCountForFieldName >= matchPosition + ) { + scrollToFoundMatch({ + rowIndex: Number(rowIndex), + fieldName, + matchIndex: matchPosition - traversedMatchesCount - 1, + shouldJump, + }); + return; + } + + traversedMatchesCount += matchesCountForFieldName; + } + } + }, + [scrollToFoundMatch] + ); + + const goToPrevMatch = useCallback(() => { + setActiveMatchPosition((prev) => { + if (prev - 1 < 1) { + return prev; + } + const nextMatchPosition = prev - 1; + scrollToMatch({ + matchPosition: nextMatchPosition, + activeMatchesMap: matchesMap, + activeColumns: visibleColumns, + shouldJump: true, + }); + return nextMatchPosition; + }); + }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns]); + + const goToNextMatch = useCallback(() => { + setActiveMatchPosition((prev) => { + if (prev + 1 > matchesCount) { + return prev; + } + const nextMatchPosition = prev + 1; + scrollToMatch({ + matchPosition: nextMatchPosition, + activeMatchesMap: matchesMap, + activeColumns: visibleColumns, + shouldJump: true, + }); + return nextMatchPosition; + }); + }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns, matchesCount]); + + useEffect(() => { + if (!rows?.length || !uiSearchTerm?.length) { + setMatchesMap(DEFAULT_MATCHES); + setMatchesCount(0); + setActiveMatchPosition(DEFAULT_ACTIVE_MATCH_POSITION); + return; + } + + setIsProcessing(true); + + const findMatches = async () => { + const UnifiedDataTableRenderCellValue = renderCellValue; + const result: Record> = {}; + let totalMatchesCount = 0; + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const matchesPerFieldName: Record = {}; + + for (const fieldName of visibleColumns) { + const formattedFieldValue = addSearchHighlights( + await getCellHtmlPromise( + {}} + /> + ), + uiSearchTerm + ); + + const matchesCountForFieldName = + findSearchMatchesInFormattedAndHighlightedValue(formattedFieldValue); + + if (matchesCountForFieldName) { + matchesPerFieldName[fieldName] = matchesCountForFieldName; + totalMatchesCount += matchesCountForFieldName; + } + } + + if (Object.keys(matchesPerFieldName).length) { + result[rowIndex] = matchesPerFieldName; + } + } + + const nextMatchesMap = totalMatchesCount > 0 ? result : DEFAULT_MATCHES; + const nextActiveMatchPosition = DEFAULT_ACTIVE_MATCH_POSITION; + setMatchesMap(nextMatchesMap); + setMatchesCount(totalMatchesCount); + setActiveMatchPosition(nextActiveMatchPosition); + setIsProcessing(false); + + if (totalMatchesCount > 0) { + scrollToMatch({ + matchPosition: nextActiveMatchPosition, + activeMatchesMap: nextMatchesMap, + activeColumns: visibleColumns, + shouldJump: false, + }); + } + }; + + void findMatches(); + }, [ + setMatchesMap, + setMatchesCount, + setActiveMatchPosition, + setIsProcessing, + renderCellValue, + scrollToMatch, + visibleColumns, + rows, + uiSearchTerm, + ]); + + return { + matchesCount, + activeMatchPosition, + goToPrevMatch, + goToNextMatch, + isProcessing, + }; +}; + +function getCellHtmlPromise(cell: ReactNode): Promise { + const container = document.createElement('div'); + return new Promise((resolve) => { + ReactDOM.render( + { + resolve(html); + ReactDOM.unmountComponentAtNode(container); + }} + > + {cell} + , + container + ); + }); +} + +function GetHtmlWrapper({ + onReady, + children, +}: { + children: ReactNode; + onReady: (html: string) => void; +}) { + const cellValueRef = useRef(null); + const processedRef = useRef(false); + + useEffect(() => { + if (!processedRef.current) { + processedRef.current = true; + onReady(cellValueRef.current?.innerHTML || ''); + } + }, [onReady]); + + return
{children}
; +} + +function getSearchTermRegExp(searchTerm: string): RegExp { + return new RegExp(`(${escape(searchTerm)})`, 'gi'); +} + +export function modifyDOMAndAddSearchHighlights(originalNode: Node, uiSearchTerm: string) { + let matchIndex = 0; + const searchTermRegExp = getSearchTermRegExp(uiSearchTerm); + + 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 parts = (nodeWithText.textContent || '').split(searchTermRegExp); + + if (parts.length > 1) { + const nodeWithHighlights = document.createDocumentFragment(); + + parts.forEach((part) => { + if (searchTermRegExp.test(part)) { + const mark = document.createElement('mark'); + mark.textContent = part; + mark.setAttribute('class', 'unifiedDataTable__findMatch'); + mark.setAttribute('data-match-index', `${matchIndex++}`); + nodeWithHighlights.appendChild(mark); + } else { + nodeWithHighlights.appendChild(document.createTextNode(part)); + } + }); + + nodeWithText.replaceWith(nodeWithHighlights); + } + } + } + + Array.from(originalNode.childNodes).forEach(insertSearchHighlights); +} + +export function addSearchHighlights( + formattedFieldValueAsHtml: string, + uiSearchTerm: string +): string { + if (!uiSearchTerm) return formattedFieldValueAsHtml; + + const parser = new DOMParser(); + const result = parser.parseFromString(formattedFieldValueAsHtml, 'text/html'); + + modifyDOMAndAddSearchHighlights(result.body, uiSearchTerm); + + return result.body.innerHTML; +} + +function findSearchMatchesInFormattedAndHighlightedValue(value: string): number { + return (value.match(new RegExp('mark class="unifiedDataTable__findMatch"', 'gi')) || []).length; +} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index e2be1605763f5..3b11b8b86261e 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useEffect, useContext, memo } from 'react'; +import React, { useEffect, useContext, memo, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { @@ -18,11 +18,13 @@ import { } from '@elastic/eui'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { DataTableRecord, ShouldShowFieldInTableHandler } from '@kbn/discover-utils/types'; +import { formatFieldValue } from '@kbn/discover-utils'; import { UnifiedDataTableContext } from '../table_context'; import type { CustomCellRenderer } from '../types'; import { SourceDocument } from '../components/source_document'; import SourcePopoverContent from '../components/source_popover_content'; import { DataTablePopoverCellValue } from '../components/data_table_cell_value'; +import { modifyDOMAndAddSearchHighlights } from '../hooks/use_find_search_matches'; export const CELL_CLASS = 'unifiedDataTable__cellValue'; @@ -83,25 +85,26 @@ export const getRenderCellValueFn = ({ const CustomCellRenderer = externalCustomRenderers?.[columnId]; - // TODO: what to do with highlights here? if (CustomCellRenderer) { return ( - - - + + + + + ); } @@ -123,40 +126,44 @@ export const getRenderCellValueFn = ({ useTopLevelObjectColumns, fieldFormats, closePopover, - uiSearchTerm, }); } if (field?.type === '_source' || useTopLevelObjectColumns) { return ( - + + + ); } return ( - + + + ); }; @@ -169,6 +176,26 @@ export const getRenderCellValueFn = ({ : memo(UnifiedDataTableRenderCellValue); }; +function CellValueWrapper({ + uiSearchTerm, + children, +}: { + uiSearchTerm?: string; + children: React.ReactElement | string; +}) { + const cellValueRef = useRef(null); + const renderedForSearchTerm = useRef(); + + useEffect(() => { + if (uiSearchTerm && cellValueRef.current && renderedForSearchTerm.current !== uiSearchTerm) { + renderedForSearchTerm.current = uiSearchTerm; + modifyDOMAndAddSearchHighlights(cellValueRef.current, uiSearchTerm); + } + }, [uiSearchTerm]); + + return
{children}
; +} + /** * Helper function for the cell popover */ @@ -180,7 +207,6 @@ function renderPopoverContent({ useTopLevelObjectColumns, fieldFormats, closePopover, - uiSearchTerm, }: { row: DataTableRecord; field: DataViewField | undefined; @@ -189,7 +215,6 @@ function renderPopoverContent({ useTopLevelObjectColumns: boolean; fieldFormats: FieldFormatsStart; closePopover: () => void; - uiSearchTerm: string | undefined; }) { const closeButton = ( From 2520df701bae01baaa81e23232a4a51798e55ab7 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 17:29:51 +0100 Subject: [PATCH 11/90] [Discover] Add debounce for the input --- .../src/components/data_table.tsx | 2 +- .../src/components/search_control.tsx | 34 +++++++++++++------ .../src/hooks/use_find_search_matches.tsx | 2 +- 3 files changed, 26 insertions(+), 12 deletions(-) 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 f877474844492..7ec3f0a9ada1a 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 @@ -652,7 +652,7 @@ export const UnifiedDataTable = ({ onUpdatePageIndex, ]); - const [uiSearchTerm, setUISearchTerm] = useState(); + const [uiSearchTerm, setUISearchTerm] = useState(''); const [uiSearchTermCss, setUISearchTermCss] = useState(); const unifiedDataTableContextValue = useMemo( diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx index fe530f0fef3de..8007706bb2f69 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx @@ -7,8 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback } from 'react'; -import { EuiFieldSearch, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { ChangeEvent, KeyboardEvent, useCallback } from 'react'; +import { EuiFieldSearch, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, keys } from '@elastic/eui'; +import { useDebouncedValue } from '@kbn/visualization-utils'; import { useFindSearchMatches, UseFindSearchMatchesProps } from '../hooks/use_find_search_matches'; export interface SearchControlProps extends UseFindSearchMatchesProps { @@ -32,13 +33,25 @@ export const SearchControl: React.FC = ({ scrollToFoundMatch, }); - // TODO: needs debouncing - const onChangeUiSearchTerm = useCallback( - (event) => { - const nextUiSearchTerm = event.target.value.toLowerCase(); - onChange(nextUiSearchTerm); + const { inputValue, handleInputChange } = useDebouncedValue({ + onChange, + value: uiSearchTerm, + }); + + const onInputChange = useCallback( + (event: ChangeEvent) => { + handleInputChange(event.target.value); + }, + [handleInputChange] + ); + + const onKeyUp = useCallback( + (event: KeyboardEvent) => { + if (event.key === keys.ENTER) { + goToNextMatch(); + } }, - [onChange] + [goToNextMatch] ); return ( @@ -71,8 +84,9 @@ export const SearchControl: React.FC = ({ ) : undefined } placeholder="Search in the table" // TODO: i18n - value={uiSearchTerm} - onChange={onChangeUiSearchTerm} + value={inputValue} + onChange={onInputChange} + onKeyUp={onKeyUp} /> ); }; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx index 3819cb645b535..d1aff9bed9332 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx @@ -19,7 +19,7 @@ const DEFAULT_ACTIVE_MATCH_POSITION = 1; export interface UseFindSearchMatchesProps { visibleColumns: string[]; - rows: DataTableRecord[]; + rows: DataTableRecord[] | undefined; uiSearchTerm: string | undefined; renderCellValue: (props: EuiDataGridCellValueElementProps) => ReactNode; scrollToFoundMatch: (params: { From 40ca7ab9717226cb7fe8b0fa2f988e2fa6c354aa Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 17:37:08 +0100 Subject: [PATCH 12/90] [Discover] Revert some redundant changes --- .../src/data_types/data_table_record.ts | 57 ------------------- .../shared/kbn-discover-utils/src/types.ts | 33 ++++++++++- .../src/utils/build_data_record.ts | 6 +- .../shared/kbn-discover-utils/types.ts | 2 +- .../src/utils/get_render_cell_value.tsx | 1 - .../main/data_fetching/fetch_esql.ts | 6 +- .../shared/esql_datagrid/public/data_grid.tsx | 6 +- 7 files changed, 42 insertions(+), 69 deletions(-) delete mode 100644 src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts diff --git a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts deleted file mode 100644 index fc4e1b84ffec6..0000000000000 --- a/src/platform/packages/shared/kbn-discover-utils/src/data_types/data_table_record.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -type DiscoverSearchHit = SearchHit>; - -export interface EsHitRecord extends Omit { - _index?: DiscoverSearchHit['_index']; - _id?: DiscoverSearchHit['_id']; - _source?: DiscoverSearchHit['_source']; -} - -/** - * This is the record/row of data provided to our Data Table - */ -export class DataTableRecord { - /** - * A unique id generated by index, id and routing of a record - */ - readonly id: string; - /** - * The document returned by Elasticsearch for search queries - */ - readonly raw: EsHitRecord; - /** - * A flattened version of the ES doc or data provided by SQL, aggregations ... - */ - readonly flattened: Record; - /** - * Determines that the given doc is the anchor doc when rendering view surrounding docs - */ - readonly isAnchor?: boolean; - - constructor({ - id, - raw, - flattened, - isAnchor, - }: { - id: string; - raw: EsHitRecord; - flattened: Record; - isAnchor?: boolean; - }) { - this.id = id; - this.raw = raw; - this.isAnchor = isAnchor; - this.flattened = flattened; - } -} diff --git a/src/platform/packages/shared/kbn-discover-utils/src/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/types.ts index 534632f2ab47d..2c298da999490 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/types.ts @@ -7,8 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { DatatableColumnMeta } from '@kbn/expressions-plugin/common'; -export { type EsHitRecord, DataTableRecord } from './data_types/data_table_record'; + export type { IgnoredReason, ShouldShowFieldInTableHandler } from './utils'; export type { RowControlColumn, @@ -19,6 +20,36 @@ export type { export type * from './components/app_menu/types'; export { AppMenuActionId, AppMenuActionType } from './components/app_menu/types'; +type DiscoverSearchHit = SearchHit>; + +export interface EsHitRecord extends Omit { + _index?: DiscoverSearchHit['_index']; + _id?: DiscoverSearchHit['_id']; + _source?: DiscoverSearchHit['_source']; +} + +/** + * This is the record/row of data provided to our Data Table + */ +export interface DataTableRecord { + /** + * A unique id generated by index, id and routing of a record + */ + id: string; + /** + * The document returned by Elasticsearch for search queries + */ + raw: EsHitRecord; + /** + * A flattened version of the ES doc or data provided by SQL, aggregations ... + */ + flattened: Record; + /** + * Determines that the given doc is the anchor doc when rendering view surrounding docs + */ + isAnchor?: boolean; +} + /** * Custom column types per column name */ diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts index 44673f13672e7..83e637f438119 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/build_data_record.ts @@ -13,7 +13,7 @@ import { flattenHit, getFlattenedFieldsComparator, } from '@kbn/data-service'; -import { DataTableRecord, EsHitRecord } from '../data_types/data_table_record'; +import type { DataTableRecord, EsHitRecord } from '../types'; import { getDocId } from './get_doc_id'; /** @@ -28,7 +28,7 @@ export function buildDataTableRecord( isAnchor?: boolean, options?: { flattenedFieldsComparator?: FlattenedFieldsComparator } ): DataTableRecord { - return new DataTableRecord({ + return { id: getDocId(doc), raw: doc, flattened: flattenHit(doc, dataView, { @@ -36,7 +36,7 @@ export function buildDataTableRecord( flattenedFieldsComparator: options?.flattenedFieldsComparator, }), isAnchor, - }); + }; } /** diff --git a/src/platform/packages/shared/kbn-discover-utils/types.ts b/src/platform/packages/shared/kbn-discover-utils/types.ts index f04c979cf39ff..dfbb54f1f09ca 100644 --- a/src/platform/packages/shared/kbn-discover-utils/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/types.ts @@ -8,10 +8,10 @@ */ export type { + DataTableRecord, DataTableColumnsMeta, EsHitRecord, IgnoredReason, ShouldShowFieldInTableHandler, FormattedHit, } from './src/types'; -export { DataTableRecord } from './src/types'; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 3b11b8b86261e..362def989fa99 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -112,7 +112,6 @@ export const getRenderCellValueFn = ({ * when using the fields api this code is used to show top level objects * this is used for legacy stuff like displaying products of our ecommerce dataset */ - // TODO: does it need an update? const useTopLevelObjectColumns = Boolean( !field && row?.raw.fields && !(row.raw.fields as Record)[columnId] ); diff --git a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts index 740ea2c83584c..9c5540443f86d 100644 --- a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts +++ b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts @@ -17,7 +17,7 @@ import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { Datatable } from '@kbn/expressions-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/common'; import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common'; -import { DataTableRecord } from '@kbn/discover-utils'; +import type { DataTableRecord } from '@kbn/discover-utils'; import type { RecordsFetchResponse } from '../../types'; import type { ProfilesManager } from '../../../context_awareness'; @@ -81,11 +81,11 @@ export function fetchEsql({ esqlQueryColumns = table?.columns ?? undefined; esqlHeaderWarning = table.warning ?? undefined; finalData = rows.map((row, idx) => { - const record: DataTableRecord = new DataTableRecord({ + const record: DataTableRecord = { id: String(idx), raw: row, flattened: row, - }); + }; return profilesManager.resolveDocumentProfile({ record }); }); 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 72042f888f4a8..1b6dbca2b5eb8 100644 --- a/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx +++ b/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx @@ -24,7 +24,7 @@ import type { ESQLRow } from '@kbn/es-types'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { AggregateQuery } from '@kbn/es-query'; -import { DataTableRecord, type DataTableColumnsMeta } from '@kbn/discover-utils/types'; +import type { DataTableRecord, DataTableColumnsMeta } from '@kbn/discover-utils/types'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { CoreStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -109,11 +109,11 @@ const DataGrid: React.FC = (props) => { return props.rows .map((row) => zipObject(columnNames, row)) .map((row, idx: number) => { - return new DataTableRecord({ + return { id: String(idx), raw: row, flattened: row, - }); + } as unknown as DataTableRecord; }); }, [props.columns, props.rows]); From 9b3b39070ab903dbbb3910073356ca8090e56523 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 18:24:09 +0100 Subject: [PATCH 13/90] [Discover] Add navigation between pages --- .../src/components/data_table.tsx | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) 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 7ec3f0a9ada1a..b432d011b0ce3 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 @@ -524,6 +524,16 @@ export const UnifiedDataTable = ({ } = selectedDocsState; const [currentPageIndex, setCurrentPageIndex] = useState(0); + const currentPageIndexRef = useRef(0); + + const changeCurrentPageIndex = useCallback( + (value: number) => { + setCurrentPageIndex(value); + onUpdatePageIndex?.(value); + currentPageIndexRef.current = value; + }, + [setCurrentPageIndex, onUpdatePageIndex] + ); useEffect(() => { if (!hasSelectedDocs && isFilterActive) { @@ -619,6 +629,7 @@ export const UnifiedDataTable = ({ const calculatedPageIndex = previousPageIndex > pageCount - 1 ? 0 : previousPageIndex; if (calculatedPageIndex !== previousPageIndex) { onUpdatePageIndex?.(calculatedPageIndex); + currentPageIndexRef.current = calculatedPageIndex; } return calculatedPageIndex; }); @@ -629,15 +640,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), @@ -649,7 +655,7 @@ export const UnifiedDataTable = ({ onUpdateRowsPerPage, currentPageSize, currentPageIndex, - onUpdatePageIndex, + changeCurrentPageIndex, ]); const [uiSearchTerm, setUISearchTerm] = useState(''); @@ -682,7 +688,8 @@ export const UnifiedDataTable = ({ onFilter, setExpandedDoc, selectedDocsState, - paginationObj, + paginationObj?.pageIndex, + paginationObj?.pageSize, valueToStringConverter, uiSearchTerm, ] @@ -743,12 +750,18 @@ export const UnifiedDataTable = ({ { + const expectedPageIndex = Math.floor(rowIndex / currentPageSize); + + if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { + changeCurrentPageIndex(expectedPageIndex); + } + // TODO: use a named color token setUISearchTermCss(css` - .euiDataGridRowCell[data-gridcell-visible-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] + .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] .unifiedDataTable__findMatch[data-match-index='${matchIndex}'] { background-color: #ffc30e; } @@ -763,15 +776,17 @@ export const UnifiedDataTable = ({ const columnIndex = anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; + const visibleRowIndex = isPaginationEnabled ? rowIndex % currentPageSize : rowIndex; + dataGridRef.current?.scrollToItem?.({ - rowIndex, + rowIndex: visibleRowIndex, columnIndex: Number(columnIndex), align: 'start', }); } }} onChange={(searchTerm) => { - setUISearchTerm(searchTerm); + setUISearchTerm(searchTerm || ''); setUISearchTermCss(undefined); }} /> @@ -780,10 +795,13 @@ export const UnifiedDataTable = ({ uiSearchTerm, setUISearchTerm, setUISearchTermCss, - rows, + displayedRows, renderCellValue, visibleColumns, dataGridRef, + currentPageSize, + changeCurrentPageIndex, + isPaginationEnabled, ]); const renderCustomPopover = useMemo( From ee62e43f3d9900aad2fbfbe8cb766c3e61890988 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 18:30:48 +0100 Subject: [PATCH 14/90] [Discover] Improve search input --- .../src/components/search_control.tsx | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx index 8007706bb2f69..84fd90faa0001 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx @@ -10,6 +10,7 @@ import React, { ChangeEvent, KeyboardEvent, useCallback } from 'react'; import { EuiFieldSearch, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, keys } from '@elastic/eui'; import { useDebouncedValue } from '@kbn/visualization-utils'; +import { i18n } from '@kbn/i18n'; import { useFindSearchMatches, UseFindSearchMatchesProps } from '../hooks/use_find_search_matches'; export interface SearchControlProps extends UseFindSearchMatchesProps { @@ -60,30 +61,44 @@ export const SearchControl: React.FC = ({ isClearable isLoading={isProcessing} append={ - matchesCount ? ( + Boolean(uiSearchTerm?.length) && !isProcessing ? ( - {`${activeMatchPosition} / ${matchesCount}`} - - - - - {/* TODO: i18n */} - = matchesCount} - onClick={goToNextMatch} - /> - + {matchesCount > 0 ? ( + <> + {`${activeMatchPosition} / ${matchesCount}`} + + + + + = matchesCount} + onClick={goToNextMatch} + /> + + + ) : ( + 0 + )} ) : undefined } - placeholder="Search in the table" // TODO: i18n + placeholder={i18n.translate('unifiedDataTable.searchControl.inputPlaceholder', { + defaultMessage: 'Find in the table', + })} value={inputValue} onChange={onInputChange} onKeyUp={onKeyUp} From 52c97163fb36675f0730adc782350aea0faa1783 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 18:52:36 +0100 Subject: [PATCH 15/90] [Discover] Update data structure --- .../src/hooks/use_find_search_matches.tsx | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx index d1aff9bed9332..aefb00073d057 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx @@ -13,8 +13,12 @@ import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { escape } from 'lodash'; -type MatchesMap = Record>; // per row index, per field name, number of matches -const DEFAULT_MATCHES: MatchesMap = {}; +interface RowMatches { + rowIndex: number; + rowMatchesCount: number; + matchesCountPerField: Record; +} +const DEFAULT_MATCHES: RowMatches[] = []; const DEFAULT_ACTIVE_MATCH_POSITION = 1; export interface UseFindSearchMatchesProps { @@ -45,7 +49,7 @@ export const useFindSearchMatches = ({ renderCellValue, scrollToFoundMatch, }: UseFindSearchMatchesProps): UseFindSearchMatchesReturn => { - const [matchesMap, setMatchesMap] = useState(DEFAULT_MATCHES); + const [matchesList, setMatchesList] = useState(DEFAULT_MATCHES); const [matchesCount, setMatchesCount] = useState(0); const [activeMatchPosition, setActiveMatchPosition] = useState( DEFAULT_ACTIVE_MATCH_POSITION @@ -55,28 +59,37 @@ export const useFindSearchMatches = ({ const scrollToMatch = useCallback( ({ matchPosition, - activeMatchesMap, + activeMatchesList, activeColumns, shouldJump, }: { matchPosition: number; - activeMatchesMap: MatchesMap; + activeMatchesList: RowMatches[]; activeColumns: string[]; shouldJump: boolean; }) => { - const rowIndices = Object.keys(activeMatchesMap); let traversedMatchesCount = 0; - for (const rowIndex of rowIndices) { - const matchesPerFieldName = activeMatchesMap[Number(rowIndex)]; + for (const rowMatch of activeMatchesList) { + const rowIndex = rowMatch.rowIndex; + + if (traversedMatchesCount + rowMatch.rowMatchesCount < matchPosition) { + // going faster to next row + traversedMatchesCount += rowMatch.rowMatchesCount; + continue; + } + + const matchesCountPerField = rowMatch.matchesCountPerField; for (const fieldName of activeColumns) { - const matchesCountForFieldName = matchesPerFieldName[fieldName] ?? 0; + // going slow to next field within the row + const matchesCountForFieldName = matchesCountPerField[fieldName] ?? 0; if ( traversedMatchesCount < matchPosition && traversedMatchesCount + matchesCountForFieldName >= matchPosition ) { + // can even go slower to next match within the field within the row scrollToFoundMatch({ rowIndex: Number(rowIndex), fieldName, @@ -101,13 +114,13 @@ export const useFindSearchMatches = ({ const nextMatchPosition = prev - 1; scrollToMatch({ matchPosition: nextMatchPosition, - activeMatchesMap: matchesMap, + activeMatchesList: matchesList, activeColumns: visibleColumns, shouldJump: true, }); return nextMatchPosition; }); - }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns]); + }, [setActiveMatchPosition, scrollToMatch, matchesList, visibleColumns]); const goToNextMatch = useCallback(() => { setActiveMatchPosition((prev) => { @@ -117,17 +130,17 @@ export const useFindSearchMatches = ({ const nextMatchPosition = prev + 1; scrollToMatch({ matchPosition: nextMatchPosition, - activeMatchesMap: matchesMap, + activeMatchesList: matchesList, activeColumns: visibleColumns, shouldJump: true, }); return nextMatchPosition; }); - }, [setActiveMatchPosition, scrollToMatch, matchesMap, visibleColumns, matchesCount]); + }, [setActiveMatchPosition, scrollToMatch, matchesList, visibleColumns, matchesCount]); useEffect(() => { if (!rows?.length || !uiSearchTerm?.length) { - setMatchesMap(DEFAULT_MATCHES); + setMatchesList(DEFAULT_MATCHES); setMatchesCount(0); setActiveMatchPosition(DEFAULT_ACTIVE_MATCH_POSITION); return; @@ -137,10 +150,13 @@ export const useFindSearchMatches = ({ const findMatches = async () => { const UnifiedDataTableRenderCellValue = renderCellValue; - const result: Record> = {}; + + const result: RowMatches[] = []; let totalMatchesCount = 0; + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { - const matchesPerFieldName: Record = {}; + const matchesCountPerField: Record = {}; + let rowMatchesCount = 0; for (const fieldName of visibleColumns) { const formattedFieldValue = addSearchHighlights( @@ -162,19 +178,24 @@ export const useFindSearchMatches = ({ findSearchMatchesInFormattedAndHighlightedValue(formattedFieldValue); if (matchesCountForFieldName) { - matchesPerFieldName[fieldName] = matchesCountForFieldName; + matchesCountPerField[fieldName] = matchesCountForFieldName; totalMatchesCount += matchesCountForFieldName; + rowMatchesCount += matchesCountForFieldName; } } - if (Object.keys(matchesPerFieldName).length) { - result[rowIndex] = matchesPerFieldName; + if (Object.keys(matchesCountPerField).length) { + result.push({ + rowIndex, + rowMatchesCount, + matchesCountPerField, + }); } } - const nextMatchesMap = totalMatchesCount > 0 ? result : DEFAULT_MATCHES; + const nextMatchesList = totalMatchesCount > 0 ? result : DEFAULT_MATCHES; const nextActiveMatchPosition = DEFAULT_ACTIVE_MATCH_POSITION; - setMatchesMap(nextMatchesMap); + setMatchesList(nextMatchesList); setMatchesCount(totalMatchesCount); setActiveMatchPosition(nextActiveMatchPosition); setIsProcessing(false); @@ -182,7 +203,7 @@ export const useFindSearchMatches = ({ if (totalMatchesCount > 0) { scrollToMatch({ matchPosition: nextActiveMatchPosition, - activeMatchesMap: nextMatchesMap, + activeMatchesList: nextMatchesList, activeColumns: visibleColumns, shouldJump: false, }); @@ -191,7 +212,7 @@ export const useFindSearchMatches = ({ void findMatches(); }, [ - setMatchesMap, + setMatchesList, setMatchesCount, setActiveMatchPosition, setIsProcessing, From d9a2a5fd3c5aa4ea025ce405568b11434625a04e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 19:16:47 +0100 Subject: [PATCH 16/90] [Discover] Support shift+enter --- .../src/components/search_control.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx index 84fd90faa0001..7c84837a70cc6 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/search_control.tsx @@ -48,11 +48,16 @@ export const SearchControl: React.FC = ({ const onKeyUp = useCallback( (event: KeyboardEvent) => { - if (event.key === keys.ENTER) { + if (isProcessing) { + return; + } + if (event.key === keys.ENTER && event.shiftKey) { + goToPrevMatch(); + } else if (event.key === keys.ENTER) { goToNextMatch(); } }, - [goToNextMatch] + [goToPrevMatch, goToNextMatch, isProcessing] ); return ( From b4323362bc9edd2a733362835abea687b6a60bc9 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 20:09:07 +0100 Subject: [PATCH 17/90] [Discover] Skip converting html to string --- .../src/components/data_table.scss | 2 +- .../src/hooks/use_find_search_matches.tsx | 99 ++++++++--------- .../src/utils/get_render_cell_value.tsx | 101 ++++++++---------- 3 files changed, 94 insertions(+), 108 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss index 0bc0b926121a6..56f28d948feaa 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss @@ -10,7 +10,7 @@ } .unifiedDataTable__findMatch { - background-color: #E5FFC0; + background-color: #E5FFC0; // TODO: Use a named color token } .unifiedDataTable__cell--expanded { diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx index aefb00073d057..7cb078c3183c9 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx @@ -25,7 +25,9 @@ export interface UseFindSearchMatchesProps { visibleColumns: string[]; rows: DataTableRecord[] | undefined; uiSearchTerm: string | undefined; - renderCellValue: (props: EuiDataGridCellValueElementProps) => ReactNode; + renderCellValue: ( + props: EuiDataGridCellValueElementProps & { onHighlightsCountFound?: (count: number) => void } + ) => ReactNode; scrollToFoundMatch: (params: { rowIndex: number; fieldName: string; @@ -159,24 +161,19 @@ export const useFindSearchMatches = ({ let rowMatchesCount = 0; for (const fieldName of visibleColumns) { - const formattedFieldValue = addSearchHighlights( - await getCellHtmlPromise( - {}} - /> - ), + const matchesCountForFieldName = await getCellMatchesCount( + {}} + />, uiSearchTerm ); - const matchesCountForFieldName = - findSearchMatchesInFormattedAndHighlightedValue(formattedFieldValue); - if (matchesCountForFieldName) { matchesCountPerField[fieldName] = matchesCountForFieldName; totalMatchesCount += matchesCountForFieldName; @@ -232,48 +229,61 @@ export const useFindSearchMatches = ({ }; }; -function getCellHtmlPromise(cell: ReactNode): Promise { +function getCellMatchesCount(cell: ReactNode, uiSearchTerm: string): Promise { const container = document.createElement('div'); + // TODO: add a timeout to prevent infinite waiting return new Promise((resolve) => { ReactDOM.render( - { - resolve(html); + { + resolve(count); ReactDOM.unmountComponentAtNode(container); }} > {cell} - , + , container ); }); } -function GetHtmlWrapper({ - onReady, +export function CellValueWrapper({ + uiSearchTerm, + onHighlightsCountFound, children, }: { + uiSearchTerm?: string; + onHighlightsCountFound?: (count: number) => void; children: ReactNode; - onReady: (html: string) => void; }) { const cellValueRef = useRef(null); - const processedRef = useRef(false); + const renderedForSearchTerm = useRef(); useEffect(() => { - if (!processedRef.current) { - processedRef.current = true; - onReady(cellValueRef.current?.innerHTML || ''); + if (uiSearchTerm && cellValueRef.current && renderedForSearchTerm.current !== uiSearchTerm) { + renderedForSearchTerm.current = uiSearchTerm; + const count = modifyDOMAndAddSearchHighlights( + cellValueRef.current, + uiSearchTerm, + Boolean(onHighlightsCountFound) + ); + onHighlightsCountFound?.(count); } - }, [onReady]); + }, [uiSearchTerm, onHighlightsCountFound]); return
{children}
; } function getSearchTermRegExp(searchTerm: string): RegExp { - return new RegExp(`(${escape(searchTerm)})`, 'gi'); + return new RegExp(`(${escape(searchTerm.trim())})`, 'gi'); } -export function modifyDOMAndAddSearchHighlights(originalNode: Node, uiSearchTerm: string) { +export function modifyDOMAndAddSearchHighlights( + originalNode: Node, + uiSearchTerm: string, + dryRun: boolean +): number { let matchIndex = 0; const searchTermRegExp = getSearchTermRegExp(uiSearchTerm); @@ -291,6 +301,11 @@ export function modifyDOMAndAddSearchHighlights(originalNode: Node, uiSearchTerm const nodeWithHighlights = document.createDocumentFragment(); parts.forEach((part) => { + if (dryRun && searchTermRegExp.test(part)) { + matchIndex++; + return; + } + if (searchTermRegExp.test(part)) { const mark = document.createElement('mark'); mark.textContent = part; @@ -302,28 +317,14 @@ export function modifyDOMAndAddSearchHighlights(originalNode: Node, uiSearchTerm } }); - nodeWithText.replaceWith(nodeWithHighlights); + if (!dryRun) { + nodeWithText.replaceWith(nodeWithHighlights); + } } } } Array.from(originalNode.childNodes).forEach(insertSearchHighlights); -} - -export function addSearchHighlights( - formattedFieldValueAsHtml: string, - uiSearchTerm: string -): string { - if (!uiSearchTerm) return formattedFieldValueAsHtml; - - const parser = new DOMParser(); - const result = parser.parseFromString(formattedFieldValueAsHtml, 'text/html'); - - modifyDOMAndAddSearchHighlights(result.body, uiSearchTerm); - - return result.body.innerHTML; -} -function findSearchMatchesInFormattedAndHighlightedValue(value: string): number { - return (value.match(new RegExp('mark class="unifiedDataTable__findMatch"', 'gi')) || []).length; + return matchIndex; } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 362def989fa99..b3373fcc46041 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useEffect, useContext, memo, useRef } from 'react'; +import React, { useEffect, useContext, memo } from 'react'; import { i18n } from '@kbn/i18n'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { @@ -24,7 +24,7 @@ import type { CustomCellRenderer } from '../types'; import { SourceDocument } from '../components/source_document'; import SourcePopoverContent from '../components/source_popover_content'; import { DataTablePopoverCellValue } from '../components/data_table_cell_value'; -import { modifyDOMAndAddSearchHighlights } from '../hooks/use_find_search_matches'; +import { CellValueWrapper } from '../hooks/use_find_search_matches'; export const CELL_CLASS = 'unifiedDataTable__cellValue'; @@ -79,15 +79,15 @@ export const getRenderCellValueFn = ({ } }, [ctx, row, setCellProps]); - if (typeof row === 'undefined') { - return -; - } + const render = () => { + if (typeof row === 'undefined') { + return -; + } - const CustomCellRenderer = externalCustomRenderers?.[columnId]; + const CustomCellRenderer = externalCustomRenderers?.[columnId]; - if (CustomCellRenderer) { - return ( - + if (CustomCellRenderer) { + return ( - - ); - } + ); + } - /** - * when using the fields api this code is used to show top level objects - * this is used for legacy stuff like displaying products of our ecommerce dataset - */ - const useTopLevelObjectColumns = Boolean( - !field && row?.raw.fields && !(row.raw.fields as Record)[columnId] - ); + /** + * when using the fields api this code is used to show top level objects + * this is used for legacy stuff like displaying products of our ecommerce dataset + */ + const useTopLevelObjectColumns = Boolean( + !field && row?.raw.fields && !(row.raw.fields as Record)[columnId] + ); - if (isDetails) { - return renderPopoverContent({ - row, - field, - columnId, - dataView, - useTopLevelObjectColumns, - fieldFormats, - closePopover, - }); - } + if (isDetails) { + return renderPopoverContent({ + row, + field, + columnId, + dataView, + useTopLevelObjectColumns, + fieldFormats, + closePopover, + }); + } - if (field?.type === '_source' || useTopLevelObjectColumns) { - return ( - + if (field?.type === '_source' || useTopLevelObjectColumns) { + return ( - - ); - } + ); + } - return ( - + return ( + ); + }; + + return ( + + {render()} ); }; @@ -175,26 +180,6 @@ export const getRenderCellValueFn = ({ : memo(UnifiedDataTableRenderCellValue); }; -function CellValueWrapper({ - uiSearchTerm, - children, -}: { - uiSearchTerm?: string; - children: React.ReactElement | string; -}) { - const cellValueRef = useRef(null); - const renderedForSearchTerm = useRef(); - - useEffect(() => { - if (uiSearchTerm && cellValueRef.current && renderedForSearchTerm.current !== uiSearchTerm) { - renderedForSearchTerm.current = uiSearchTerm; - modifyDOMAndAddSearchHighlights(cellValueRef.current, uiSearchTerm); - } - }, [uiSearchTerm]); - - return
{children}
; -} - /** * Helper function for the cell popover */ From 0e97a272b65fe887f8fa5aaae8b66ef220f5466d Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 15 Jan 2025 20:22:49 +0100 Subject: [PATCH 18/90] [Discover] More updates --- .../src/hooks/use_find_search_matches.tsx | 57 +++++++++++-------- .../src/utils/get_render_cell_value.tsx | 4 +- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx index 7cb078c3183c9..49423be29cef3 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx @@ -11,7 +11,8 @@ import React, { useCallback, useEffect, useState, ReactNode, useRef } from 'reac import ReactDOM from 'react-dom'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { escape } from 'lodash'; +import { escape, memoize } from 'lodash'; +import { UnifiedDataTableContext } from '../table_context'; interface RowMatches { rowIndex: number; @@ -151,8 +152,6 @@ export const useFindSearchMatches = ({ setIsProcessing(true); const findMatches = async () => { - const UnifiedDataTableRenderCellValue = renderCellValue; - const result: RowMatches[] = []; let totalMatchesCount = 0; @@ -162,16 +161,10 @@ export const useFindSearchMatches = ({ for (const fieldName of visibleColumns) { const matchesCountForFieldName = await getCellMatchesCount( - {}} - />, - uiSearchTerm + uiSearchTerm, + rowIndex, + fieldName, + renderCellValue ); if (matchesCountForFieldName) { @@ -229,20 +222,38 @@ export const useFindSearchMatches = ({ }; }; -function getCellMatchesCount(cell: ReactNode, uiSearchTerm: string): Promise { +function getCellMatchesCount( + uiSearchTerm: string, + rowIndex: number, + fieldName: string, + renderCellValue: UseFindSearchMatchesProps['renderCellValue'] +): Promise { + const UnifiedDataTableRenderCellValue = renderCellValue; + const container = document.createElement('div'); // TODO: add a timeout to prevent infinite waiting return new Promise((resolve) => { ReactDOM.render( - { - resolve(count); - ReactDOM.unmountComponentAtNode(container); + - {cell} - , + {}} + onHighlightsCountFound={(count) => { + resolve(count); + ReactDOM.unmountComponentAtNode(container); + }} + /> + , container ); }); @@ -275,9 +286,9 @@ export function CellValueWrapper({ return
{children}
; } -function getSearchTermRegExp(searchTerm: string): RegExp { +const getSearchTermRegExp = memoize((searchTerm: string): RegExp => { return new RegExp(`(${escape(searchTerm.trim())})`, 'gi'); -} +}); export function modifyDOMAndAddSearchHighlights( originalNode: Node, diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index b3373fcc46041..1e5ce12ca3c64 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -59,7 +59,8 @@ export const getRenderCellValueFn = ({ colIndex, isExpandable, isExpanded, - }: EuiDataGridCellValueElementProps) => { + onHighlightsCountFound, + }: EuiDataGridCellValueElementProps & { onHighlightsCountFound?: (count: number) => void }) => { const row = rows ? rows[rowIndex] : undefined; const field = dataView.fields.getByName(columnId); const ctx = useContext(UnifiedDataTableContext); @@ -165,6 +166,7 @@ export const getRenderCellValueFn = ({ {render()} From b749d4f96d45954df3a1bfbb6e6e455e4555dc29 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 15 Jan 2025 19:34:38 +0000 Subject: [PATCH 19/90] [CI] Auto-commit changed files from 'node scripts/notice' --- .../packages/shared/kbn-unified-data-table/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json index b643bf4282b04..7f4e1b212b59f 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json +++ b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json @@ -42,6 +42,7 @@ "@kbn/unified-field-list", "@kbn/core-notifications-browser", "@kbn/core-capabilities-browser-mocks", - "@kbn/sort-predicates" + "@kbn/sort-predicates", + "@kbn/visualization-utils" ] } From ec80ce423c6ef20e9644a3a75b555e9bd3e2378f Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 10:38:39 +0100 Subject: [PATCH 20/90] [Discover] Reorganize code --- .../custom_toolbar/render_custom_toolbar.tsx | 8 +- .../src/components/data_table.scss | 2 +- .../src/components/data_table.tsx | 36 +++--- .../in_table_search_control.tsx} | 25 ++-- .../in_table_search_highlights_wrapper.tsx | 98 +++++++++++++++ .../src/components/in_table_search/index.ts | 19 +++ .../use_in_table_search_matches.tsx} | 114 +++--------------- .../src/table_context.tsx | 2 +- .../src/utils/get_render_cell_value.tsx | 18 +-- 9 files changed, 184 insertions(+), 138 deletions(-) rename src/platform/packages/shared/kbn-unified-data-table/src/components/{search_control.tsx => in_table_search/in_table_search_control.tsx} (83%) create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts rename src/platform/packages/shared/kbn-unified-data-table/src/{hooks/use_find_search_matches.tsx => components/in_table_search/use_in_table_search_matches.tsx} (69%) 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 0d375104d2607..ece45ef490f0a 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,7 +15,7 @@ export interface UnifiedDataTableRenderCustomToolbarProps { toolbarProps: EuiDataGridCustomToolbarProps; gridProps: { additionalControls?: React.ReactNode; - uiSearchControl?: React.ReactNode; + inTableSearchControl?: React.ReactNode; }; } @@ -42,7 +42,7 @@ export const internalRenderCustomToolbar = ( keyboardShortcutsControl, displayControl, }, - gridProps: { additionalControls, uiSearchControl }, + gridProps: { additionalControls, inTableSearchControl }, } = props; const buttons = hasRoomForGridControls ? ( @@ -91,7 +91,9 @@ export const internalRenderCustomToolbar = ( {Boolean(leftSide) && buttons} - {Boolean(uiSearchControl) && {uiSearchControl}} + {Boolean(inTableSearchControl) && ( + {inTableSearchControl} + )} {(keyboardShortcutsControl || displayControl || fullScreenControl) && (
diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss index 56f28d948feaa..0f9fee3796131 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss @@ -9,7 +9,7 @@ font-family: $euiCodeFontFamily; } -.unifiedDataTable__findMatch { +.unifiedDataTable__inTableSearchMatch { background-color: #E5FFC0; // TODO: Use a named color token } 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 b432d011b0ce3..b0dddede36670 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 @@ -95,7 +95,7 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; -import { SearchControl } from './search_control'; +import { InTableSearchControl } from './in_table_search'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; @@ -658,8 +658,8 @@ export const UnifiedDataTable = ({ changeCurrentPageIndex, ]); - const [uiSearchTerm, setUISearchTerm] = useState(''); - const [uiSearchTermCss, setUISearchTermCss] = useState(); + const [inTableSearchTerm, setInTableSearchTerm] = useState(''); + const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); const unifiedDataTableContextValue = useMemo( () => ({ @@ -675,7 +675,7 @@ export const UnifiedDataTable = ({ isPlainRecord, pageIndex: isPaginationEnabled ? paginationObj?.pageIndex : 0, pageSize: isPaginationEnabled ? paginationObj?.pageSize : displayedRows.length, - uiSearchTerm, + inTableSearchTerm, }), [ componentsTourSteps, @@ -691,7 +691,7 @@ export const UnifiedDataTable = ({ paginationObj?.pageIndex, paginationObj?.pageSize, valueToStringConverter, - uiSearchTerm, + inTableSearchTerm, ] ); @@ -745,10 +745,10 @@ export const UnifiedDataTable = ({ ] ); - const uiSearchControl = useMemo(() => { + const inTableSearchControl = useMemo(() => { return ( - { - setUISearchTerm(searchTerm || ''); - setUISearchTermCss(undefined); + setInTableSearchTerm(searchTerm || ''); + setInTableSearchTermCss(undefined); }} /> ); }, [ - uiSearchTerm, - setUISearchTerm, - setUISearchTermCss, + inTableSearchTerm, + setInTableSearchTerm, + setInTableSearchTermCss, displayedRows, renderCellValue, visibleColumns, @@ -1062,11 +1062,11 @@ export const UnifiedDataTable = ({ toolbarProps, gridProps: { additionalControls, - uiSearchControl, + inTableSearchControl, }, }) : undefined, - [renderCustomToolbar, additionalControls, uiSearchControl] + [renderCustomToolbar, additionalControls, inTableSearchControl] ); const showDisplaySelector = useMemo((): @@ -1199,7 +1199,7 @@ export const UnifiedDataTable = ({ data-description={searchDescription} data-document-number={displayedRows.length} className={classnames(className, 'unifiedDataTable__table')} - css={uiSearchTermCss} + css={inTableSearchTermCss} > {isCompareActive ? ( void; } -export const SearchControl: React.FC = ({ - uiSearchTerm, +export const InTableSearchControl: React.FC = ({ + inTableSearchTerm, visibleColumns, rows, renderCellValue, @@ -26,17 +29,17 @@ export const SearchControl: React.FC = ({ onChange, }) => { const { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, isProcessing } = - useFindSearchMatches({ + useInTableSearchMatches({ visibleColumns, rows, - uiSearchTerm, + inTableSearchTerm, renderCellValue, scrollToFoundMatch, }); const { inputValue, handleInputChange } = useDebouncedValue({ onChange, - value: uiSearchTerm, + value: inTableSearchTerm, }); const onInputChange = useCallback( @@ -66,7 +69,7 @@ export const SearchControl: React.FC = ({ isClearable isLoading={isProcessing} append={ - Boolean(uiSearchTerm?.length) && !isProcessing ? ( + Boolean(inTableSearchTerm?.length) && !isProcessing ? ( {matchesCount > 0 ? ( <> @@ -75,7 +78,7 @@ export const SearchControl: React.FC = ({ = ({ = matchesCount} @@ -101,7 +104,7 @@ export const SearchControl: React.FC = ({ ) : undefined } - placeholder={i18n.translate('unifiedDataTable.searchControl.inputPlaceholder', { + placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { defaultMessage: 'Find in the table', })} value={inputValue} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx new file mode 100644 index 0000000000000..7b1c758a62526 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.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, { ReactNode, useEffect, useRef } from 'react'; +import { escape, memoize } from 'lodash'; + +export interface InTableSearchHighlightsWrapperProps { + inTableSearchTerm?: string; + onHighlightsCountFound?: (count: number) => void; + children: ReactNode; +} + +export const InTableSearchHighlightsWrapper: React.FC = ({ + inTableSearchTerm, + onHighlightsCountFound, + children, +}) => { + const cellValueRef = useRef(null); + const renderedForSearchTerm = useRef(); + + useEffect(() => { + if ( + inTableSearchTerm && + cellValueRef.current && + renderedForSearchTerm.current !== inTableSearchTerm + ) { + renderedForSearchTerm.current = inTableSearchTerm; + const count = modifyDOMAndAddSearchHighlights( + cellValueRef.current, + inTableSearchTerm, + Boolean(onHighlightsCountFound) + ); + onHighlightsCountFound?.(count); + } + }, [inTableSearchTerm, onHighlightsCountFound]); + + return
{children}
; +}; + +const getSearchTermRegExp = memoize((searchTerm: string): RegExp => { + return new RegExp(`(${escape(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 parts = (nodeWithText.textContent || '').split(searchTermRegExp); + + if (parts.length > 1) { + const nodeWithHighlights = document.createDocumentFragment(); + + parts.forEach((part) => { + if (dryRun && searchTermRegExp.test(part)) { + matchIndex++; + return; + } + + if (searchTermRegExp.test(part)) { + const mark = document.createElement('mark'); + mark.textContent = part; + mark.setAttribute('class', 'unifiedDataTable__inTableSearchMatch'); + mark.setAttribute('data-match-index', `${matchIndex++}`); + nodeWithHighlights.appendChild(mark); + } else { + nodeWithHighlights.appendChild(document.createTextNode(part)); + } + }); + + if (!dryRun) { + nodeWithText.replaceWith(nodeWithHighlights); + } + } + } + } + + Array.from(originalNode.childNodes).forEach(insertSearchHighlights); + + return matchIndex; +} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts new file mode 100644 index 0000000000000..678861b1eda73 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.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 { InTableSearchControl, type InTableSearchControlProps } from './in_table_search_control'; +export { + InTableSearchHighlightsWrapper, + type InTableSearchHighlightsWrapperProps, +} from './in_table_search_highlights_wrapper'; +export { + useInTableSearchMatches, + type UseInTableSearchMatchesProps, + type UseInTableSearchMatchesReturn, +} from './use_in_table_search_matches'; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx similarity index 69% rename from src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx rename to src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 49423be29cef3..0844e17005d99 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/hooks/use_find_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -7,12 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useEffect, useState, ReactNode, useRef } from 'react'; +import React, { useCallback, useEffect, useState, ReactNode } from 'react'; import ReactDOM from 'react-dom'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { escape, memoize } from 'lodash'; -import { UnifiedDataTableContext } from '../table_context'; +import { UnifiedDataTableContext } from '../../table_context'; +import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; interface RowMatches { rowIndex: number; @@ -22,12 +22,13 @@ interface RowMatches { const DEFAULT_MATCHES: RowMatches[] = []; const DEFAULT_ACTIVE_MATCH_POSITION = 1; -export interface UseFindSearchMatchesProps { +export interface UseInTableSearchMatchesProps { visibleColumns: string[]; rows: DataTableRecord[] | undefined; - uiSearchTerm: string | undefined; + inTableSearchTerm: string | undefined; renderCellValue: ( - props: EuiDataGridCellValueElementProps & { onHighlightsCountFound?: (count: number) => void } + props: EuiDataGridCellValueElementProps & + Pick ) => ReactNode; scrollToFoundMatch: (params: { rowIndex: number; @@ -37,7 +38,7 @@ export interface UseFindSearchMatchesProps { }) => void; } -export interface UseFindSearchMatchesReturn { +export interface UseInTableSearchMatchesReturn { matchesCount: number; activeMatchPosition: number; isProcessing: boolean; @@ -45,13 +46,13 @@ export interface UseFindSearchMatchesReturn { goToNextMatch: () => void; } -export const useFindSearchMatches = ({ +export const useInTableSearchMatches = ({ visibleColumns, rows, - uiSearchTerm, + inTableSearchTerm, renderCellValue, scrollToFoundMatch, -}: UseFindSearchMatchesProps): UseFindSearchMatchesReturn => { +}: UseInTableSearchMatchesProps): UseInTableSearchMatchesReturn => { const [matchesList, setMatchesList] = useState(DEFAULT_MATCHES); const [matchesCount, setMatchesCount] = useState(0); const [activeMatchPosition, setActiveMatchPosition] = useState( @@ -142,7 +143,7 @@ export const useFindSearchMatches = ({ }, [setActiveMatchPosition, scrollToMatch, matchesList, visibleColumns, matchesCount]); useEffect(() => { - if (!rows?.length || !uiSearchTerm?.length) { + if (!rows?.length || !inTableSearchTerm?.length) { setMatchesList(DEFAULT_MATCHES); setMatchesCount(0); setActiveMatchPosition(DEFAULT_ACTIVE_MATCH_POSITION); @@ -161,7 +162,7 @@ export const useFindSearchMatches = ({ for (const fieldName of visibleColumns) { const matchesCountForFieldName = await getCellMatchesCount( - uiSearchTerm, + inTableSearchTerm, rowIndex, fieldName, renderCellValue @@ -210,7 +211,7 @@ export const useFindSearchMatches = ({ scrollToMatch, visibleColumns, rows, - uiSearchTerm, + inTableSearchTerm, ]); return { @@ -223,10 +224,10 @@ export const useFindSearchMatches = ({ }; function getCellMatchesCount( - uiSearchTerm: string, + inTableSearchTerm: string, rowIndex: number, fieldName: string, - renderCellValue: UseFindSearchMatchesProps['renderCellValue'] + renderCellValue: UseInTableSearchMatchesProps['renderCellValue'] ): Promise { const UnifiedDataTableRenderCellValue = renderCellValue; @@ -236,7 +237,7 @@ function getCellMatchesCount( ReactDOM.render( @@ -258,84 +259,3 @@ function getCellMatchesCount( ); }); } - -export function CellValueWrapper({ - uiSearchTerm, - onHighlightsCountFound, - children, -}: { - uiSearchTerm?: string; - onHighlightsCountFound?: (count: number) => void; - children: ReactNode; -}) { - const cellValueRef = useRef(null); - const renderedForSearchTerm = useRef(); - - useEffect(() => { - if (uiSearchTerm && cellValueRef.current && renderedForSearchTerm.current !== uiSearchTerm) { - renderedForSearchTerm.current = uiSearchTerm; - const count = modifyDOMAndAddSearchHighlights( - cellValueRef.current, - uiSearchTerm, - Boolean(onHighlightsCountFound) - ); - onHighlightsCountFound?.(count); - } - }, [uiSearchTerm, onHighlightsCountFound]); - - return
{children}
; -} - -const getSearchTermRegExp = memoize((searchTerm: string): RegExp => { - return new RegExp(`(${escape(searchTerm.trim())})`, 'gi'); -}); - -export function modifyDOMAndAddSearchHighlights( - originalNode: Node, - uiSearchTerm: string, - dryRun: boolean -): number { - let matchIndex = 0; - const searchTermRegExp = getSearchTermRegExp(uiSearchTerm); - - 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 parts = (nodeWithText.textContent || '').split(searchTermRegExp); - - if (parts.length > 1) { - const nodeWithHighlights = document.createDocumentFragment(); - - parts.forEach((part) => { - if (dryRun && searchTermRegExp.test(part)) { - matchIndex++; - return; - } - - if (searchTermRegExp.test(part)) { - const mark = document.createElement('mark'); - mark.textContent = part; - mark.setAttribute('class', 'unifiedDataTable__findMatch'); - mark.setAttribute('data-match-index', `${matchIndex++}`); - nodeWithHighlights.appendChild(mark); - } else { - nodeWithHighlights.appendChild(document.createTextNode(part)); - } - }); - - if (!dryRun) { - nodeWithText.replaceWith(nodeWithHighlights); - } - } - } - } - - Array.from(originalNode.childNodes).forEach(insertSearchHighlights); - - return matchIndex; -} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx index f01fb38aa04ab..138e28b7b9647 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/table_context.tsx @@ -27,7 +27,7 @@ export interface DataTableContext { isPlainRecord?: boolean; pageIndex: number | undefined; // undefined when the pagination is disabled pageSize: number | undefined; - uiSearchTerm: string | undefined; + inTableSearchTerm?: string; } const defaultContext = {} as unknown as DataTableContext; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 1e5ce12ca3c64..ea3c10bf9732a 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -24,7 +24,10 @@ import type { CustomCellRenderer } from '../types'; import { SourceDocument } from '../components/source_document'; import SourcePopoverContent from '../components/source_popover_content'; import { DataTablePopoverCellValue } from '../components/data_table_cell_value'; -import { CellValueWrapper } from '../hooks/use_find_search_matches'; +import { + InTableSearchHighlightsWrapper, + InTableSearchHighlightsWrapperProps, +} from '../components/in_table_search'; export const CELL_CLASS = 'unifiedDataTable__cellValue'; @@ -60,11 +63,12 @@ export const getRenderCellValueFn = ({ isExpandable, isExpanded, onHighlightsCountFound, - }: EuiDataGridCellValueElementProps & { onHighlightsCountFound?: (count: number) => void }) => { + }: EuiDataGridCellValueElementProps & + Pick) => { const row = rows ? rows[rowIndex] : undefined; const field = dataView.fields.getByName(columnId); const ctx = useContext(UnifiedDataTableContext); - const { uiSearchTerm } = ctx; + const { inTableSearchTerm } = ctx; useEffect(() => { if (row?.isAnchor) { @@ -163,13 +167,13 @@ export const getRenderCellValueFn = ({ }; return ( - {render()} - + ); }; From 85c2ba862dadabff3e53a478d6e6064e1f1d4a5d Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 11:18:35 +0100 Subject: [PATCH 21/90] [Discover] Fix the context issue --- .../src/components/data_table.tsx | 34 +++++++++---- .../in_table_search_control.tsx | 18 ++----- .../in_table_search_highlights_wrapper.tsx | 18 ++++--- .../use_in_table_search_matches.tsx | 51 ++++++++++++------- 4 files changed, 69 insertions(+), 52 deletions(-) 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 b0dddede36670..c4f299f231c11 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 @@ -95,7 +95,7 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; -import { InTableSearchControl } from './in_table_search'; +import { InTableSearchControl, UseInTableSearchMatchesProps } from './in_table_search'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; @@ -661,36 +661,46 @@ export const UnifiedDataTable = ({ const [inTableSearchTerm, setInTableSearchTerm] = useState(''); const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); - const unifiedDataTableContextValue = useMemo( + const inTableSearchContextValue = useMemo( () => ({ - expanded: expandedDoc, - setExpanded: setExpandedDoc, getRowByIndex: (index: number) => displayedRows[index], - onFilter, dataView, isDarkMode: darkMode, selectedDocsState, valueToStringConverter, componentsTourSteps, isPlainRecord, - pageIndex: isPaginationEnabled ? paginationObj?.pageIndex : 0, - pageSize: isPaginationEnabled ? paginationObj?.pageSize : displayedRows.length, - inTableSearchTerm, }), [ componentsTourSteps, darkMode, dataView, isPlainRecord, + displayedRows, + selectedDocsState, + valueToStringConverter, + ] + ); + + const unifiedDataTableContextValue = useMemo( + () => ({ + ...inTableSearchContextValue, + expanded: expandedDoc, + setExpanded: setExpandedDoc, + onFilter, + pageIndex: isPaginationEnabled ? paginationObj?.pageIndex : 0, + pageSize: isPaginationEnabled ? paginationObj?.pageSize : displayedRows.length, + inTableSearchTerm, + }), + [ + inTableSearchContextValue, isPaginationEnabled, displayedRows, expandedDoc, - onFilter, setExpandedDoc, - selectedDocsState, + onFilter, paginationObj?.pageIndex, paginationObj?.pageSize, - valueToStringConverter, inTableSearchTerm, ] ); @@ -749,6 +759,7 @@ export const UnifiedDataTable = ({ return ( = ({ - inTableSearchTerm, - visibleColumns, - rows, - renderCellValue, - scrollToFoundMatch, onChange, + ...props }) => { const { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, isProcessing } = - useInTableSearchMatches({ - visibleColumns, - rows, - inTableSearchTerm, - renderCellValue, - scrollToFoundMatch, - }); + useInTableSearchMatches(props); const { inputValue, handleInputChange } = useDebouncedValue({ onChange, - value: inTableSearchTerm, + value: props.inTableSearchTerm, }); const onInputChange = useCallback( @@ -69,7 +59,7 @@ export const InTableSearchControl: React.FC = ({ isClearable isLoading={isProcessing} append={ - Boolean(inTableSearchTerm?.length) && !isProcessing ? ( + Boolean(inputValue?.length) && !isProcessing ? ( {matchesCount > 0 ? ( <> diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx index 7b1c758a62526..a7887f7e13718 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx @@ -65,15 +65,19 @@ function modifyDOMAndAddSearchHighlights( const nodeWithText = node as Text; const parts = (nodeWithText.textContent || '').split(searchTermRegExp); - if (parts.length > 1) { - const nodeWithHighlights = document.createDocumentFragment(); - + if (dryRun) { parts.forEach((part) => { - if (dryRun && searchTermRegExp.test(part)) { + if (searchTermRegExp.test(part)) { matchIndex++; - return; } + }); + return; + } + if (parts.length > 1) { + const nodeWithHighlights = document.createDocumentFragment(); + + parts.forEach((part) => { if (searchTermRegExp.test(part)) { const mark = document.createElement('mark'); mark.textContent = part; @@ -85,9 +89,7 @@ function modifyDOMAndAddSearchHighlights( } }); - if (!dryRun) { - nodeWithText.replaceWith(nodeWithHighlights); - } + nodeWithText.replaceWith(nodeWithHighlights); } } } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 0844e17005d99..a6c276147a2cd 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -11,7 +11,7 @@ import React, { useCallback, useEffect, useState, ReactNode } from 'react'; import ReactDOM from 'react-dom'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { UnifiedDataTableContext } from '../../table_context'; +import { UnifiedDataTableContext, DataTableContext } from '../../table_context'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; interface RowMatches { @@ -26,6 +26,7 @@ export interface UseInTableSearchMatchesProps { visibleColumns: string[]; rows: DataTableRecord[] | undefined; inTableSearchTerm: string | undefined; + tableContext: Omit; renderCellValue: ( props: EuiDataGridCellValueElementProps & Pick @@ -46,13 +47,17 @@ export interface UseInTableSearchMatchesReturn { goToNextMatch: () => void; } -export const useInTableSearchMatches = ({ - visibleColumns, - rows, - inTableSearchTerm, - renderCellValue, - scrollToFoundMatch, -}: UseInTableSearchMatchesProps): UseInTableSearchMatchesReturn => { +export const useInTableSearchMatches = ( + props: UseInTableSearchMatchesProps +): UseInTableSearchMatchesReturn => { + const { + visibleColumns, + rows, + inTableSearchTerm, + tableContext, + renderCellValue, + scrollToFoundMatch, + } = props; const [matchesList, setMatchesList] = useState(DEFAULT_MATCHES); const [matchesCount, setMatchesCount] = useState(0); const [activeMatchPosition, setActiveMatchPosition] = useState( @@ -161,12 +166,13 @@ export const useInTableSearchMatches = ({ let rowMatchesCount = 0; for (const fieldName of visibleColumns) { - const matchesCountForFieldName = await getCellMatchesCount( - inTableSearchTerm, + const matchesCountForFieldName = await getCellMatchesCount({ rowIndex, fieldName, - renderCellValue - ); + inTableSearchTerm, + tableContext, + renderCellValue, + }); if (matchesCountForFieldName) { matchesCountPerField[fieldName] = matchesCountForFieldName; @@ -212,6 +218,7 @@ export const useInTableSearchMatches = ({ visibleColumns, rows, inTableSearchTerm, + tableContext, ]); return { @@ -223,12 +230,16 @@ export const useInTableSearchMatches = ({ }; }; -function getCellMatchesCount( - inTableSearchTerm: string, - rowIndex: number, - fieldName: string, - renderCellValue: UseInTableSearchMatchesProps['renderCellValue'] -): Promise { +function getCellMatchesCount({ + rowIndex, + fieldName, + inTableSearchTerm, + renderCellValue, + tableContext, +}: Pick & { + rowIndex: number; + fieldName: string; +}): Promise { const UnifiedDataTableRenderCellValue = renderCellValue; const container = document.createElement('div'); @@ -237,8 +248,10 @@ function getCellMatchesCount( ReactDOM.render( Date: Thu, 16 Jan 2025 11:43:09 +0100 Subject: [PATCH 22/90] [Discover] Reorganize the code --- .../src/components/data_table.tsx | 37 +++----------- .../in_table_search_control.tsx | 50 ++++++++++++++++++- .../use_in_table_search_matches.tsx | 8 +-- 3 files changed, 59 insertions(+), 36 deletions(-) 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 c4f299f231c11..ed6952dfc225d 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 @@ -36,7 +36,7 @@ import { useDataGridColumnsCellActions, type UseDataGridColumnsCellActionsProps, } from '@kbn/cell-actions'; -import { SerializedStyles, css } from '@emotion/react'; +import type { SerializedStyles } from '@emotion/react'; import type { ToastsStart, IUiSettingsClient } from '@kbn/core/public'; import type { Serializable } from '@kbn/utility-types'; import type { DataTableRecord } from '@kbn/discover-utils/types'; @@ -763,43 +763,20 @@ export const UnifiedDataTable = ({ visibleColumns={visibleColumns} rows={displayedRows} renderCellValue={renderCellValue} - scrollToFoundMatch={({ rowIndex, fieldName, matchIndex, shouldJump }) => { - const expectedPageIndex = Math.floor(rowIndex / currentPageSize); - + pageSize={isPaginationEnabled ? currentPageSize : null} + changeToExpectedPage={(expectedPageIndex) => { if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { changeCurrentPageIndex(expectedPageIndex); } - - // TODO: use a named color token - setInTableSearchTermCss(css` - .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] - .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndex}'] { - background-color: #ffc30e; - } - `); - - if (shouldJump) { - const anyCellForFieldName = document.querySelector( - `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` - ); - - // getting column index by column id - const columnIndex = - anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; - - const visibleRowIndex = isPaginationEnabled ? rowIndex % currentPageSize : rowIndex; - - dataGridRef.current?.scrollToItem?.({ - rowIndex: visibleRowIndex, - columnIndex: Number(columnIndex), - align: 'start', - }); - } + }} + scrollToCell={(params) => { + dataGridRef.current?.scrollToItem?.(params); }} onChange={(searchTerm) => { setInTableSearchTerm(searchTerm || ''); setInTableSearchTermCss(undefined); }} + onChangeCss={(styles) => setInTableSearchTermCss(styles)} /> ); }, [ diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 17bba5c3000fc..2b4c19ac0f310 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -11,21 +11,67 @@ import React, { ChangeEvent, KeyboardEvent, useCallback } from 'react'; import { EuiFieldSearch, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, keys } from '@elastic/eui'; import { useDebouncedValue } from '@kbn/visualization-utils'; import { i18n } from '@kbn/i18n'; +import { css, type SerializedStyles } from '@emotion/react'; import { useInTableSearchMatches, UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; -export interface InTableSearchControlProps extends UseInTableSearchMatchesProps { +export interface InTableSearchControlProps + extends Omit { + pageSize: number | null; // null when the pagination is disabled + changeToExpectedPage: (pageIndex: number) => void; + scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'start' }) => void; onChange: (searchTerm: string | undefined) => void; + onChangeCss: (styles: SerializedStyles) => void; } export const InTableSearchControl: React.FC = ({ + pageSize, + changeToExpectedPage, + scrollToCell, onChange, + onChangeCss, ...props }) => { + const scrollToActiveMatch: UseInTableSearchMatchesProps['scrollToActiveMatch'] = useCallback( + ({ rowIndex, fieldName, matchIndex, shouldJump }) => { + if (typeof pageSize === 'number') { + const expectedPageIndex = Math.floor(rowIndex / pageSize); + changeToExpectedPage(expectedPageIndex); + } + + // TODO: use a named color token + onChangeCss(css` + .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] + .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndex}'] { + background-color: #ffc30e; + } + `); + + if (shouldJump) { + const anyCellForFieldName = document.querySelector( + `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` + ); + + // getting column index by column id + const columnIndex = anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; + + // getting rowIndex for the visible page + const visibleRowIndex = typeof pageSize === 'number' ? rowIndex % pageSize : rowIndex; + + scrollToCell({ + rowIndex: visibleRowIndex, + columnIndex: Number(columnIndex), + align: 'start', + }); + } + }, + [pageSize, changeToExpectedPage, scrollToCell, onChangeCss] + ); + const { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, isProcessing } = - useInTableSearchMatches(props); + useInTableSearchMatches({ ...props, scrollToActiveMatch }); const { inputValue, handleInputChange } = useDebouncedValue({ onChange, diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index a6c276147a2cd..64d286929d091 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -31,7 +31,7 @@ export interface UseInTableSearchMatchesProps { props: EuiDataGridCellValueElementProps & Pick ) => ReactNode; - scrollToFoundMatch: (params: { + scrollToActiveMatch: (params: { rowIndex: number; fieldName: string; matchIndex: number; @@ -56,7 +56,7 @@ export const useInTableSearchMatches = ( inTableSearchTerm, tableContext, renderCellValue, - scrollToFoundMatch, + scrollToActiveMatch, } = props; const [matchesList, setMatchesList] = useState(DEFAULT_MATCHES); const [matchesCount, setMatchesCount] = useState(0); @@ -99,7 +99,7 @@ export const useInTableSearchMatches = ( traversedMatchesCount + matchesCountForFieldName >= matchPosition ) { // can even go slower to next match within the field within the row - scrollToFoundMatch({ + scrollToActiveMatch({ rowIndex: Number(rowIndex), fieldName, matchIndex: matchPosition - traversedMatchesCount - 1, @@ -112,7 +112,7 @@ export const useInTableSearchMatches = ( } } }, - [scrollToFoundMatch] + [scrollToActiveMatch] ); const goToPrevMatch = useCallback(() => { From 286fbecd2b5f2c978d9b022cd744bf0420b46246 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 11:51:03 +0100 Subject: [PATCH 23/90] [Discover] Allow to circle though matches endlessly --- .../in_table_search/in_table_search_control.tsx | 2 -- .../use_in_table_search_matches.tsx | 14 +++++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 2b4c19ac0f310..ea91d14e23c07 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -119,7 +119,6 @@ export const InTableSearchControl: React.FC = ({ defaultMessage: 'Previous match', } )} - disabled={activeMatchPosition <= 1} onClick={goToPrevMatch} /> @@ -129,7 +128,6 @@ export const InTableSearchControl: React.FC = ({ aria-label={i18n.translate('unifiedDataTable.inTableSearch.buttonNextMatch', { defaultMessage: 'Next match', })} - disabled={activeMatchPosition >= matchesCount} onClick={goToNextMatch} /> diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 64d286929d091..f71716ce0bd46 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -117,10 +117,12 @@ export const useInTableSearchMatches = ( const goToPrevMatch = useCallback(() => { setActiveMatchPosition((prev) => { + let nextMatchPosition = prev - 1; + if (prev - 1 < 1) { - return prev; + nextMatchPosition = matchesCount; // allow to circle though matches endlessly } - const nextMatchPosition = prev - 1; + scrollToMatch({ matchPosition: nextMatchPosition, activeMatchesList: matchesList, @@ -129,14 +131,16 @@ export const useInTableSearchMatches = ( }); return nextMatchPosition; }); - }, [setActiveMatchPosition, scrollToMatch, matchesList, visibleColumns]); + }, [setActiveMatchPosition, scrollToMatch, matchesList, visibleColumns, matchesCount]); const goToNextMatch = useCallback(() => { setActiveMatchPosition((prev) => { + let nextMatchPosition = prev + 1; + if (prev + 1 > matchesCount) { - return prev; + nextMatchPosition = 1; // allow to circle though matches endlessly } - const nextMatchPosition = prev + 1; + scrollToMatch({ matchPosition: nextMatchPosition, activeMatchesList: matchesList, From e181a0bafd7d528dcfb2879058a74dfaf732bc52 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 12:00:07 +0100 Subject: [PATCH 24/90] [Discover] Update styles --- .../in_table_search_control.tsx | 100 ++++++++++-------- 1 file changed, 55 insertions(+), 45 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index ea91d14e23c07..d6f242d95664d 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -100,50 +100,60 @@ export const InTableSearchControl: React.FC = ({ ); return ( - - {matchesCount > 0 ? ( - <> - {`${activeMatchPosition} / ${matchesCount}`} - - - - - - - - ) : ( - 0 - )} - - ) : undefined - } - placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { - defaultMessage: 'Find in the table', - })} - value={inputValue} - onChange={onInputChange} - onKeyUp={onKeyUp} - /> +
+ + {matchesCount > 0 ? ( + <> + {`${activeMatchPosition} / ${matchesCount}`} + + + + + + + + ) : ( + 0 + )} + + ) : undefined + } + placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { + defaultMessage: 'Find in the table', + })} + value={inputValue} + onChange={onInputChange} + onKeyUp={onKeyUp} + /> +
); }; From 2d9337e69ed18ad06e309cb3e46ad3303e723780 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 14:35:13 +0100 Subject: [PATCH 25/90] [Discover] Update search input interactions --- .../custom_toolbar/render_custom_toolbar.scss | 2 +- .../custom_toolbar/render_custom_toolbar.tsx | 21 ++- .../in_table_search_control.tsx | 137 ++++++++++++------ .../use_in_table_search_matches.tsx | 18 ++- 4 files changed, 122 insertions(+), 56 deletions(-) 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 ece45ef490f0a..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 @@ -91,21 +91,28 @@ export const internalRenderCustomToolbar = ( {Boolean(leftSide) && buttons} - {Boolean(inTableSearchControl) && ( - {inTableSearchControl} - )} - {(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/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index d6f242d95664d..ddcb14da56ec5 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -7,8 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { ChangeEvent, KeyboardEvent, useCallback } from 'react'; -import { EuiFieldSearch, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, keys } from '@elastic/eui'; +import React, { ChangeEvent, KeyboardEvent, FocusEvent, useCallback, useState } from 'react'; +import { + EuiFieldSearch, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + keys, +} from '@elastic/eui'; import { useDebouncedValue } from '@kbn/visualization-utils'; import { i18n } from '@kbn/i18n'; import { css, type SerializedStyles } from '@emotion/react'; @@ -17,6 +24,23 @@ import { UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; +const searchInputCss = css` + .euiFormControlLayout, + input.euiFieldSearch { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; + } + + .euiFormControlLayout__append { + padding-inline-end: 0 !important; + } +`; + +const matchesCss = css` + font-variant-numeric: tabular-nums; +`; + export interface InTableSearchControlProps extends Omit { pageSize: number | null; // null when the pagination is disabled @@ -72,7 +96,9 @@ export const InTableSearchControl: React.FC = ({ const { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, isProcessing } = useInTableSearchMatches({ ...props, scrollToActiveMatch }); + const areArrowsDisabled = !matchesCount || isProcessing; + const [isFocused, setIsFocused] = useState(false); const { inputValue, handleInputChange } = useDebouncedValue({ onChange, value: props.inTableSearchTerm, @@ -87,7 +113,7 @@ export const InTableSearchControl: React.FC = ({ const onKeyUp = useCallback( (event: KeyboardEvent) => { - if (isProcessing) { + if (areArrowsDisabled) { return; } if (event.key === keys.ENTER && event.shiftKey) { @@ -96,54 +122,78 @@ export const InTableSearchControl: React.FC = ({ goToNextMatch(); } }, - [goToPrevMatch, goToNextMatch, isProcessing] + [goToPrevMatch, goToNextMatch, areArrowsDisabled] + ); + + const onBlur = useCallback( + (event: FocusEvent) => { + if ( + (!event.relatedTarget || + event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && + !inputValue + ) { + setIsFocused(false); + } + }, + [setIsFocused, inputValue] ); + if (!isFocused && !inputValue) { + return ( + + setIsFocused(true)} + aria-label={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { + defaultMessage: 'Find in the table', + })} + /> + + ); + } + return ( -
+
- {matchesCount > 0 ? ( - <> - {`${activeMatchPosition} / ${matchesCount}`} - - - - - - - - ) : ( - 0 - )} + + {matchesCount ? `${activeMatchPosition}/${matchesCount}` : '0/0'}  + + + + + + + ) : undefined } @@ -153,6 +203,7 @@ export const InTableSearchControl: React.FC = ({ value={inputValue} onChange={onInputChange} onKeyUp={onKeyUp} + onBlur={onBlur} />
); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index f71716ce0bd46..ec7bd308ff61a 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -40,7 +40,7 @@ export interface UseInTableSearchMatchesProps { } export interface UseInTableSearchMatchesReturn { - matchesCount: number; + matchesCount: number | null; activeMatchPosition: number; isProcessing: boolean; goToPrevMatch: () => void; @@ -59,7 +59,7 @@ export const useInTableSearchMatches = ( scrollToActiveMatch, } = props; const [matchesList, setMatchesList] = useState(DEFAULT_MATCHES); - const [matchesCount, setMatchesCount] = useState(0); + const [matchesCount, setMatchesCount] = useState(null); const [activeMatchPosition, setActiveMatchPosition] = useState( DEFAULT_ACTIVE_MATCH_POSITION ); @@ -117,10 +117,14 @@ export const useInTableSearchMatches = ( const goToPrevMatch = useCallback(() => { setActiveMatchPosition((prev) => { + if (typeof matchesCount !== 'number') { + return prev; + } + let nextMatchPosition = prev - 1; if (prev - 1 < 1) { - nextMatchPosition = matchesCount; // allow to circle though matches endlessly + nextMatchPosition = matchesCount; // allow to endlessly circle though matches } scrollToMatch({ @@ -135,10 +139,14 @@ export const useInTableSearchMatches = ( const goToNextMatch = useCallback(() => { setActiveMatchPosition((prev) => { + if (typeof matchesCount !== 'number') { + return prev; + } + let nextMatchPosition = prev + 1; if (prev + 1 > matchesCount) { - nextMatchPosition = 1; // allow to circle though matches endlessly + nextMatchPosition = 1; // allow to endlessly circle though matches } scrollToMatch({ @@ -154,7 +162,7 @@ export const useInTableSearchMatches = ( useEffect(() => { if (!rows?.length || !inTableSearchTerm?.length) { setMatchesList(DEFAULT_MATCHES); - setMatchesCount(0); + setMatchesCount(null); setActiveMatchPosition(DEFAULT_ACTIVE_MATCH_POSITION); return; } From 5361661d80fa63eab23f1290c4d0a08c7cc5cfa7 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 15:16:51 +0100 Subject: [PATCH 26/90] [Discover] Opt-in in table search. Fix rendering. --- .../shared/kbn-unified-data-table/README.md | 3 + .../src/components/data_table.tsx | 14 ++++- .../use_in_table_search_matches.tsx | 60 ++++++++++++------- .../discover_grid/discover_grid.tsx | 1 + 4 files changed, 55 insertions(+), 23 deletions(-) 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 a6927eaae69b4..a771e6c06d980 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. | @@ -55,6 +56,7 @@ Props description: *Required **services** list: ``` theme: ThemeServiceStart; + i18n: I18nStart; fieldFormats: FieldFormatsStart; uiSettings: IUiSettingsClient; dataViewFieldEditor: DataViewFieldEditorStart; @@ -75,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/data_table.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.tsx index ed6952dfc225d..de9fe8fb3a498 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 @@ -37,7 +37,7 @@ import { type UseDataGridColumnsCellActionsProps, } from '@kbn/cell-actions'; import type { SerializedStyles } from '@emotion/react'; -import type { ToastsStart, IUiSettingsClient } from '@kbn/core/public'; +import type { ToastsStart, IUiSettingsClient, I18nStart } from '@kbn/core/public'; import type { Serializable } from '@kbn/utility-types'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import { @@ -286,6 +286,7 @@ export interface UnifiedDataTableProps { */ services: { theme: ThemeServiceStart; + i18n: I18nStart; fieldFormats: FieldFormatsStart; uiSettings: IUiSettingsClient; dataViewFieldEditor?: DataViewFieldEditorStart; @@ -409,6 +410,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. */ @@ -491,6 +496,7 @@ export const UnifiedDataTable = ({ rowLineHeightOverride, customGridColumnsConfiguration, enableComparisonMode, + enableInTableSearch, cellContext, renderCellPopover, getRowIndicator, @@ -756,6 +762,9 @@ export const UnifiedDataTable = ({ ); const inTableSearchControl = useMemo(() => { + if (!enableInTableSearch) { + return undefined; + } return ( { if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { @@ -780,6 +790,7 @@ export const UnifiedDataTable = ({ /> ); }, [ + enableInTableSearch, inTableSearchTerm, setInTableSearchTerm, setInTableSearchTermCss, @@ -791,6 +802,7 @@ export const UnifiedDataTable = ({ changeCurrentPageIndex, isPaginationEnabled, inTableSearchContextValue, + services, ]); const renderCustomPopover = useMemo( diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index ec7bd308ff61a..4ecaf7e8c12e7 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -9,11 +9,17 @@ import React, { useCallback, useEffect, useState, ReactNode } from 'react'; import ReactDOM from 'react-dom'; +import { + KibanaRenderContextProvider, + KibanaRenderContextProviderProps, +} from '@kbn/react-kibana-context-render'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { UnifiedDataTableContext, DataTableContext } from '../../table_context'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; +type Services = Pick; + interface RowMatches { rowIndex: number; rowMatchesCount: number; @@ -37,6 +43,7 @@ export interface UseInTableSearchMatchesProps { matchIndex: number; shouldJump: boolean; }) => void; + services: Services; } export interface UseInTableSearchMatchesReturn { @@ -57,6 +64,7 @@ export const useInTableSearchMatches = ( tableContext, renderCellValue, scrollToActiveMatch, + services, } = props; const [matchesList, setMatchesList] = useState(DEFAULT_MATCHES); const [matchesCount, setMatchesCount] = useState(null); @@ -184,6 +192,7 @@ export const useInTableSearchMatches = ( inTableSearchTerm, tableContext, renderCellValue, + services, }); if (matchesCountForFieldName) { @@ -231,6 +240,7 @@ export const useInTableSearchMatches = ( rows, inTableSearchTerm, tableContext, + services, ]); return { @@ -248,7 +258,11 @@ function getCellMatchesCount({ inTableSearchTerm, renderCellValue, tableContext, -}: Pick & { + services, +}: Pick< + UseInTableSearchMatchesProps, + 'inTableSearchTerm' | 'tableContext' | 'renderCellValue' | 'services' +> & { rowIndex: number; fieldName: string; }): Promise { @@ -258,28 +272,30 @@ function getCellMatchesCount({ // TODO: add a timeout to prevent infinite waiting return new Promise((resolve) => { ReactDOM.render( - - {}} - onHighlightsCountFound={(count) => { - resolve(count); - ReactDOM.unmountComponentAtNode(container); + + - , + > + {}} + onHighlightsCountFound={(count) => { + resolve(count); + ReactDOM.unmountComponentAtNode(container); + }} + /> + + , container ); }); diff --git a/src/platform/plugins/shared/discover/public/components/discover_grid/discover_grid.tsx b/src/platform/plugins/shared/discover/public/components/discover_grid/discover_grid.tsx index b14da8c2d4836..98f691006cf2e 100644 --- a/src/platform/plugins/shared/discover/public/components/discover_grid/discover_grid.tsx +++ b/src/platform/plugins/shared/discover/public/components/discover_grid/discover_grid.tsx @@ -60,6 +60,7 @@ export const DiscoverGrid: React.FC = ({ showColumnTokens canDragAndDropColumns enableComparisonMode + enableInTableSearch renderCustomToolbar={renderCustomToolbar} getRowIndicator={getRowIndicator} rowAdditionalLeadingControls={rowAdditionalLeadingControls} From 3eed799fea640541f37c6d3781a46aae41ad01c7 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 16 Jan 2025 14:30:26 +0000 Subject: [PATCH 27/90] [CI] Auto-commit changed files from 'node scripts/notice' --- .../packages/shared/kbn-unified-data-table/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json index 7f4e1b212b59f..bee1964b73623 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json +++ b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json @@ -43,6 +43,7 @@ "@kbn/core-notifications-browser", "@kbn/core-capabilities-browser-mocks", "@kbn/sort-predicates", - "@kbn/visualization-utils" + "@kbn/visualization-utils", + "@kbn/react-kibana-context-render" ] } From 5180cd531eea61c10ee18baf7cbdf74907484bef Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 15:39:01 +0100 Subject: [PATCH 28/90] [Discover] Handle errors during rendering --- .../use_in_table_search_matches.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 4ecaf7e8c12e7..c651a256770ce 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -269,8 +269,18 @@ function getCellMatchesCount({ const UnifiedDataTableRenderCellValue = renderCellValue; const container = document.createElement('div'); - // TODO: add a timeout to prevent infinite waiting - return new Promise((resolve) => { + + return new Promise((resolve) => { + const finish = (count: number) => { + resolve(count); + ReactDOM.unmountComponentAtNode(container); + }; + + const timer = setTimeout(() => { + // time out if rendering takes longer + finish(0); + }, 1000); + ReactDOM.render( {}} onHighlightsCountFound={(count) => { - resolve(count); - ReactDOM.unmountComponentAtNode(container); + clearTimeout(timer); + finish(count); }} /> , container ); - }); + }).catch(() => 0); // catching unexpected errors } From 4e5c1d0cd56c52f8fb2161b7ac463c9cfd02492d Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 15:53:08 +0100 Subject: [PATCH 29/90] [Discover] Better search in Summary column --- .../logs/components/cell_actions_popover.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 8fa56c56162ab..6c0b171c8cacf 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, useEuiTheme, @@ -187,9 +186,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)}`; +} From 9e16b4108aced62f126f7a89ea28f44b80c113db Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 15:56:56 +0100 Subject: [PATCH 30/90] [Discover] Update types --- .../packages/shared/kbn-unified-data-table/__mocks__/services.ts | 1 + .../kbn-unified-data-table/src/components/data_table.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts b/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts index 8a3f9568ba5e9..958e2d3f8ee5e 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts @@ -81,6 +81,7 @@ export function createServicesMock() { }, } as unknown as DataViewFieldEditorStart, theme, + i18n: corePluginMock.i18n, storage: { clear: jest.fn(), get: jest.fn(), 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 c52d9377112b7..2467fcf1db23a 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 @@ -90,6 +90,7 @@ function getProps(): UnifiedDataTableProps { storage: services.storage as unknown as Storage, data: services.data, theme: services.theme, + i18n: services.i18n, }, cellActionsMetadata: { someKey: 'someValue', From 5db514e115e4a69eae326c71705bc2148cd01e79 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 19:06:33 +0100 Subject: [PATCH 31/90] [Discover] Enable for ES|QL grid too --- .../plugins/shared/esql_datagrid/public/data_grid.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 1b6dbca2b5eb8..5e48c4da47a43 100644 --- a/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx +++ b/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx @@ -123,6 +123,7 @@ const DataGrid: React.FC = (props) => { return { data: props.data, theme: props.core.theme, + i18n: props.core.i18n, uiSettings: props.core.uiSettings, toastNotifications: props.core.notifications.toasts, fieldFormats: props.fieldFormats, @@ -131,6 +132,7 @@ const DataGrid: React.FC = (props) => { }, [ props.core.notifications.toasts, props.core.theme, + props.core.i18n, props.core.uiSettings, props.data, props.fieldFormats, @@ -155,6 +157,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} From 7737aa66e7da45adb11b673d6e6d59a07ba1bceb Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 16 Jan 2025 19:40:37 +0100 Subject: [PATCH 32/90] [Discover] Override Cmd+f --- .../src/components/data_table.tsx | 7 ++-- .../in_table_search_control.tsx | 32 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) 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 de9fe8fb3a498..3ee798da0717c 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 @@ -664,6 +664,7 @@ export const UnifiedDataTable = ({ changeCurrentPageIndex, ]); + const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); const [inTableSearchTerm, setInTableSearchTerm] = useState(''); const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); @@ -782,6 +783,9 @@ export const UnifiedDataTable = ({ scrollToCell={(params) => { dataGridRef.current?.scrollToItem?.(params); }} + shouldOverrideCmdF={(element) => { + return dataGridWrapper?.contains?.(element) ?? false; + }} onChange={(searchTerm) => { setInTableSearchTerm(searchTerm || ''); setInTableSearchTermCss(undefined); @@ -798,6 +802,7 @@ export const UnifiedDataTable = ({ renderCellValue, visibleColumns, dataGridRef, + dataGridWrapper, currentPageSize, changeCurrentPageIndex, isPaginationEnabled, @@ -1148,8 +1153,6 @@ export const UnifiedDataTable = ({ rowLineHeight: rowLineHeightOverride, }); - const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); - const isRenderComplete = loadingState !== DataLoadingState.loading; if (!rowCount && loadingState === DataLoadingState.loading) { diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index ddcb14da56ec5..9ba00f7f3b08e 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { ChangeEvent, KeyboardEvent, FocusEvent, useCallback, useState } from 'react'; +import React, { ChangeEvent, FocusEvent, useCallback, useState, useEffect } from 'react'; import { EuiFieldSearch, EuiButtonIcon, @@ -46,6 +46,7 @@ export interface InTableSearchControlProps pageSize: number | null; // null when the pagination is disabled changeToExpectedPage: (pageIndex: number) => void; scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'start' }) => void; + shouldOverrideCmdF: (element: HTMLElement) => boolean; onChange: (searchTerm: string | undefined) => void; onChangeCss: (styles: SerializedStyles) => void; } @@ -54,6 +55,7 @@ export const InTableSearchControl: React.FC = ({ pageSize, changeToExpectedPage, scrollToCell, + shouldOverrideCmdF, onChange, onChangeCss, ...props @@ -112,10 +114,16 @@ export const InTableSearchControl: React.FC = ({ ); const onKeyUp = useCallback( - (event: KeyboardEvent) => { + (event: React.KeyboardEvent) => { + if (event.key === keys.ESCAPE) { + setIsFocused(false); + return; + } + if (areArrowsDisabled) { return; } + if (event.key === keys.ENTER && event.shiftKey) { goToPrevMatch(); } else if (event.key === keys.ENTER) { @@ -138,6 +146,26 @@ export const InTableSearchControl: React.FC = ({ [setIsFocused, inputValue] ); + 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 + setIsFocused(true); + } + }; + + document.addEventListener('keydown', handleGlobalKeyDown); + + // Cleanup the event listener + return () => { + document.removeEventListener('keydown', handleGlobalKeyDown); + }; + }, [setIsFocused, shouldOverrideCmdF]); + if (!isFocused && !inputValue) { return ( Date: Fri, 17 Jan 2025 11:48:08 +0100 Subject: [PATCH 33/90] [Discover] Better escaping for regexp --- .../in_table_search/in_table_search_highlights_wrapper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx index a7887f7e13718..a47ebfcd228e7 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx @@ -8,7 +8,7 @@ */ import React, { ReactNode, useEffect, useRef } from 'react'; -import { escape, memoize } from 'lodash'; +import { escapeRegExp, memoize } from 'lodash'; export interface InTableSearchHighlightsWrapperProps { inTableSearchTerm?: string; @@ -44,7 +44,7 @@ export const InTableSearchHighlightsWrapper: React.FC { - return new RegExp(`(${escape(searchTerm.trim())})`, 'gi'); + return new RegExp(`(${escapeRegExp(searchTerm.trim())})`, 'gi'); }); function modifyDOMAndAddSearchHighlights( From 1ede72efcc4c16d46a0a1eb9108a005d1e4fce4e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 17 Jan 2025 14:32:40 +0100 Subject: [PATCH 34/90] [Discover] Makes the matches count aggregation faster --- .../use_in_table_search_matches.tsx | 242 ++++++++++++------ 1 file changed, 164 insertions(+), 78 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index c651a256770ce..c22fb1494c30b 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -178,40 +178,15 @@ export const useInTableSearchMatches = ( setIsProcessing(true); const findMatches = async () => { - const result: RowMatches[] = []; - let totalMatchesCount = 0; - - for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { - const matchesCountPerField: Record = {}; - let rowMatchesCount = 0; - - for (const fieldName of visibleColumns) { - const matchesCountForFieldName = await getCellMatchesCount({ - rowIndex, - fieldName, - inTableSearchTerm, - tableContext, - renderCellValue, - services, - }); - - if (matchesCountForFieldName) { - matchesCountPerField[fieldName] = matchesCountForFieldName; - totalMatchesCount += matchesCountForFieldName; - rowMatchesCount += matchesCountForFieldName; - } - } - - if (Object.keys(matchesCountPerField).length) { - result.push({ - rowIndex, - rowMatchesCount, - matchesCountPerField, - }); - } - } + const { matchesList: nextMatchesList, totalMatchesCount } = await getCellMatchesCounts({ + inTableSearchTerm, + tableContext, + renderCellValue, + rows, + visibleColumns, + services, + }); - const nextMatchesList = totalMatchesCount > 0 ? result : DEFAULT_MATCHES; const nextActiveMatchPosition = DEFAULT_ACTIVE_MATCH_POSITION; setMatchesList(nextMatchesList); setMatchesCount(totalMatchesCount); @@ -252,61 +227,172 @@ export const useInTableSearchMatches = ( }; }; -function getCellMatchesCount({ - rowIndex, - fieldName, - inTableSearchTerm, - renderCellValue, - tableContext, - services, -}: Pick< - UseInTableSearchMatchesProps, - 'inTableSearchTerm' | 'tableContext' | 'renderCellValue' | 'services' -> & { - rowIndex: number; - fieldName: string; -}): Promise { - const UnifiedDataTableRenderCellValue = renderCellValue; +function getCellMatchesCounts( + props: Pick< + UseInTableSearchMatchesProps, + | 'inTableSearchTerm' + | 'tableContext' + | 'renderCellValue' + | 'rows' + | 'visibleColumns' + | 'services' + > +): Promise<{ matchesList: RowMatches[]; totalMatchesCount: number }> { + const { rows, visibleColumns } = props; + + if (!rows?.length || !visibleColumns?.length) { + return Promise.resolve({ matchesList: DEFAULT_MATCHES, totalMatchesCount: 0 }); + } + + const resultsMap: Record> = {}; + let remainingNumberOfResults = rows.length * visibleColumns.length; + + const onHighlightsCountFound = (rowIndex: number, fieldName: string, count: number) => { + remainingNumberOfResults--; + + if (count === 0) { + return; + } + + if (!resultsMap[rowIndex]) { + resultsMap[rowIndex] = {}; + } + resultsMap[rowIndex][fieldName] = count; + }; const container = document.createElement('div'); - return new Promise((resolve) => { - const finish = (count: number) => { - resolve(count); + return new Promise<{ matchesList: RowMatches[]; totalMatchesCount: number }>((resolve) => { + const finish = () => { + let totalMatchesCount = 0; + const newMatchesList: RowMatches[] = []; + + Object.keys(resultsMap) + .map((rowIndex) => Number(rowIndex)) + .sort((a, b) => a - b) + .forEach((rowIndex) => { + const matchesCountPerField = resultsMap[rowIndex]; + const rowMatchesCount = Object.values(matchesCountPerField).reduce( + (acc, count) => acc + count, + 0 + ); + + newMatchesList.push({ + rowIndex, + rowMatchesCount, + matchesCountPerField, + }); + totalMatchesCount += rowMatchesCount; + }); + + resolve({ matchesList: newMatchesList, totalMatchesCount }); ReactDOM.unmountComponentAtNode(container); }; const timer = setTimeout(() => { // time out if rendering takes longer - finish(0); - }, 1000); + finish(); + }, 60000); + // render all cells in parallel and get the count of highlights as the result ReactDOM.render( - - - {}} - onHighlightsCountFound={(count) => { - clearTimeout(timer); - finish(count); - }} - /> - - , + { + onHighlightsCountFound(rowIndex, fieldName, count); + + if (remainingNumberOfResults === 0) { + clearTimeout(timer); + finish(); + } + }} + />, container ); - }).catch(() => 0); // catching unexpected errors + }).catch(() => ({ matchesList: DEFAULT_MATCHES, totalMatchesCount: 0 })); // catching unexpected errors +} + +function AllCells({ + inTableSearchTerm, + rows, + visibleColumns, + renderCellValue, + tableContext, + services, + onHighlightsCountFound, +}: Pick< + UseInTableSearchMatchesProps, + 'inTableSearchTerm' | 'tableContext' | 'renderCellValue' | 'rows' | 'visibleColumns' | 'services' +> & { onHighlightsCountFound: (rowIndex: number, fieldName: string, count: number) => void }) { + const UnifiedDataTableRenderCellValue = renderCellValue; + + return ( + + + {(rows || []).flatMap((_, rowIndex) => { + return visibleColumns.map((fieldName) => { + return ( + { + onHighlightsCountFound(rowIndex, fieldName, 0); + }} + > + {}} + onHighlightsCountFound={(count) => { + onHighlightsCountFound(rowIndex, fieldName, count); + }} + /> + + ); + }); + })} + + + ); +} + +/** + * Renders nothing instead of a component which triggered an exception. + */ +export 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; + } } From b383650132d6dd2276b12121339a903c5b756ec6 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 17 Jan 2025 16:01:51 +0100 Subject: [PATCH 35/90] [Discover] Update input styles --- .../in_table_search_control.tsx | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 9ba00f7f3b08e..b610316a7afea 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiToolTip, + EuiText, keys, } from '@elastic/eui'; import { useDebouncedValue } from '@kbn/visualization-utils'; @@ -34,6 +35,7 @@ const searchInputCss = css` .euiFormControlLayout__append { padding-inline-end: 0 !important; + background: none; } `; @@ -170,7 +172,7 @@ export const InTableSearchControl: React.FC = ({ return ( @@ -180,7 +182,7 @@ export const InTableSearchControl: React.FC = ({ color="text" onClick={() => setIsFocused(true)} aria-label={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { - defaultMessage: 'Find in the table', + defaultMessage: 'Search in the table', })} /> @@ -195,38 +197,38 @@ export const InTableSearchControl: React.FC = ({ isClearable={!isProcessing} isLoading={isProcessing} append={ - inputValue && typeof matchesCount === 'number' ? ( - - + + + {matchesCount ? `${activeMatchPosition}/${matchesCount}` : '0/0'}  - - - - - - - - - ) : undefined + + + + + + + + + } placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { - defaultMessage: 'Find in the table', + defaultMessage: 'Search in the table', })} value={inputValue} onChange={onInputChange} From ac700ff21649e85f088a321360fca36198b07bc0 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 17 Jan 2025 16:18:14 +0100 Subject: [PATCH 36/90] [Discover] Return the focus to the button --- .../in_table_search_control.tsx | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index b610316a7afea..ee33de60f1584 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -25,6 +25,8 @@ import { UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; +const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; + const searchInputCss = css` .euiFormControlLayout, input.euiFieldSearch { @@ -103,6 +105,25 @@ export const InTableSearchControl: React.FC = ({ const areArrowsDisabled = !matchesCount || isProcessing; const [isFocused, setIsFocused] = useState(false); + + const focusInput = useCallback(() => { + setIsFocused(true); + }, [setIsFocused]); + + const hideInput = useCallback( + (shouldFocusTheButton?: boolean) => { + setIsFocused(false); + + if (shouldFocusTheButton) { + setTimeout(() => { + const button = document.querySelector(`[data-test-subj='${BUTTON_TEST_SUBJ}']`); + (button as HTMLButtonElement)?.focus(); + }, 350); + } + }, + [setIsFocused] + ); + const { inputValue, handleInputChange } = useDebouncedValue({ onChange, value: props.inTableSearchTerm, @@ -118,7 +139,7 @@ export const InTableSearchControl: React.FC = ({ const onKeyUp = useCallback( (event: React.KeyboardEvent) => { if (event.key === keys.ESCAPE) { - setIsFocused(false); + hideInput(true); return; } @@ -132,7 +153,7 @@ export const InTableSearchControl: React.FC = ({ goToNextMatch(); } }, - [goToPrevMatch, goToNextMatch, areArrowsDisabled] + [goToPrevMatch, goToNextMatch, hideInput, areArrowsDisabled] ); const onBlur = useCallback( @@ -142,10 +163,10 @@ export const InTableSearchControl: React.FC = ({ event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && !inputValue ) { - setIsFocused(false); + hideInput(); } }, - [setIsFocused, inputValue] + [hideInput, inputValue] ); useEffect(() => { @@ -156,7 +177,7 @@ export const InTableSearchControl: React.FC = ({ shouldOverrideCmdF(event.target as HTMLElement) ) { event.preventDefault(); // prevent default browser find-in-page behavior - setIsFocused(true); + focusInput(); } }; @@ -166,7 +187,7 @@ export const InTableSearchControl: React.FC = ({ return () => { document.removeEventListener('keydown', handleGlobalKeyDown); }; - }, [setIsFocused, shouldOverrideCmdF]); + }, [focusInput, shouldOverrideCmdF]); if (!isFocused && !inputValue) { return ( @@ -177,10 +198,11 @@ export const InTableSearchControl: React.FC = ({ delay="long" > setIsFocused(true)} + onClick={focusInput} aria-label={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { defaultMessage: 'Search in the table', })} From 7f5609f55f01f599c02366e82b8aa1dbe5cc3d71 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 17 Jan 2025 19:35:05 +0100 Subject: [PATCH 37/90] [Discover] Refactor the solution to account for any parent contexts --- .../shared/kbn-unified-data-table/README.md | 1 - .../src/components/data_table.tsx | 10 +- .../in_table_search_control.tsx | 19 +- .../use_in_table_search_matches.tsx | 402 ++++++++++-------- .../shared/esql_datagrid/public/data_grid.tsx | 2 - 5 files changed, 233 insertions(+), 201 deletions(-) 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 a771e6c06d980..54baebb6bb800 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/README.md +++ b/src/platform/packages/shared/kbn-unified-data-table/README.md @@ -56,7 +56,6 @@ Props description: *Required **services** list: ``` theme: ThemeServiceStart; - i18n: I18nStart; fieldFormats: FieldFormatsStart; uiSettings: IUiSettingsClient; dataViewFieldEditor: DataViewFieldEditorStart; 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 3ee798da0717c..690b9c35a0900 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 @@ -37,7 +37,7 @@ import { type UseDataGridColumnsCellActionsProps, } from '@kbn/cell-actions'; import type { SerializedStyles } from '@emotion/react'; -import type { ToastsStart, IUiSettingsClient, I18nStart } from '@kbn/core/public'; +import type { ToastsStart, IUiSettingsClient } from '@kbn/core/public'; import type { Serializable } from '@kbn/utility-types'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import { @@ -286,7 +286,6 @@ export interface UnifiedDataTableProps { */ services: { theme: ThemeServiceStart; - i18n: I18nStart; fieldFormats: FieldFormatsStart; uiSettings: IUiSettingsClient; dataViewFieldEditor?: DataViewFieldEditorStart; @@ -773,7 +772,6 @@ export const UnifiedDataTable = ({ visibleColumns={visibleColumns} rows={displayedRows} renderCellValue={renderCellValue} - services={services} pageSize={isPaginationEnabled ? currentPageSize : null} changeToExpectedPage={(expectedPageIndex) => { if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { @@ -786,10 +784,7 @@ export const UnifiedDataTable = ({ shouldOverrideCmdF={(element) => { return dataGridWrapper?.contains?.(element) ?? false; }} - onChange={(searchTerm) => { - setInTableSearchTerm(searchTerm || ''); - setInTableSearchTermCss(undefined); - }} + onChange={(searchTerm) => setInTableSearchTerm(searchTerm || '')} onChangeCss={(styles) => setInTableSearchTermCss(styles)} /> ); @@ -807,7 +802,6 @@ export const UnifiedDataTable = ({ changeCurrentPageIndex, isPaginationEnabled, inTableSearchContextValue, - services, ]); const renderCustomPopover = useMemo( diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index ee33de60f1584..3a493f4dc2090 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -49,7 +49,7 @@ export interface InTableSearchControlProps extends Omit { pageSize: number | null; // null when the pagination is disabled changeToExpectedPage: (pageIndex: number) => void; - scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'start' }) => void; + scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'smart' }) => void; shouldOverrideCmdF: (element: HTMLElement) => boolean; onChange: (searchTerm: string | undefined) => void; onChangeCss: (styles: SerializedStyles) => void; @@ -93,15 +93,22 @@ export const InTableSearchControl: React.FC = ({ scrollToCell({ rowIndex: visibleRowIndex, columnIndex: Number(columnIndex), - align: 'start', + align: 'smart', }); } }, [pageSize, changeToExpectedPage, scrollToCell, onChangeCss] ); - const { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, isProcessing } = - useInTableSearchMatches({ ...props, scrollToActiveMatch }); + const { + matchesCount, + activeMatchPosition, + isProcessing, + cellsShadowPortal, + goToPrevMatch, + goToNextMatch, + resetState, + } = useInTableSearchMatches({ ...props, scrollToActiveMatch }); const areArrowsDisabled = !matchesCount || isProcessing; const [isFocused, setIsFocused] = useState(false); @@ -113,6 +120,7 @@ export const InTableSearchControl: React.FC = ({ const hideInput = useCallback( (shouldFocusTheButton?: boolean) => { setIsFocused(false); + resetState(); if (shouldFocusTheButton) { setTimeout(() => { @@ -121,7 +129,7 @@ export const InTableSearchControl: React.FC = ({ }, 350); } }, - [setIsFocused] + [setIsFocused, resetState] ); const { inputValue, handleInputChange } = useDebouncedValue({ @@ -257,6 +265,7 @@ export const InTableSearchControl: React.FC = ({ onKeyUp={onKeyUp} onBlur={onBlur} /> + {cellsShadowPortal}
); }; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index c22fb1494c30b..5df2ba0342736 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -7,30 +7,24 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useEffect, useState, ReactNode } from 'react'; -import ReactDOM from 'react-dom'; -import { - KibanaRenderContextProvider, - KibanaRenderContextProviderProps, -} from '@kbn/react-kibana-context-render'; +import React, { useCallback, useEffect, useState, ReactNode, useRef } from 'react'; +import { createPortal } from 'react-dom'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { UnifiedDataTableContext, DataTableContext } from '../../table_context'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; -type Services = Pick; +let latestTimeoutTimer: NodeJS.Timeout | null = null; interface RowMatches { rowIndex: number; rowMatchesCount: number; matchesCountPerField: Record; } -const DEFAULT_MATCHES: RowMatches[] = []; -const DEFAULT_ACTIVE_MATCH_POSITION = 1; export interface UseInTableSearchMatchesProps { visibleColumns: string[]; - rows: DataTableRecord[] | undefined; + rows: DataTableRecord[]; inTableSearchTerm: string | undefined; tableContext: Omit; renderCellValue: ( @@ -43,17 +37,33 @@ export interface UseInTableSearchMatchesProps { matchIndex: number; shouldJump: boolean; }) => void; - services: Services; } -export interface UseInTableSearchMatchesReturn { +interface UseInTableSearchMatchesState { + matchesList: RowMatches[]; matchesCount: number | null; activeMatchPosition: number; isProcessing: boolean; + cellsShadowPortal: ReactNode | null; +} + +export interface UseInTableSearchMatchesReturn + extends Omit { goToPrevMatch: () => void; goToNextMatch: () => void; + resetState: () => void; } +const DEFAULT_MATCHES: RowMatches[] = []; +const DEFAULT_ACTIVE_MATCH_POSITION = 1; +const INITIAL_STATE: UseInTableSearchMatchesState = { + matchesList: DEFAULT_MATCHES, + matchesCount: null, + activeMatchPosition: DEFAULT_ACTIVE_MATCH_POSITION, + isProcessing: false, + cellsShadowPortal: null, +}; + export const useInTableSearchMatches = ( props: UseInTableSearchMatchesProps ): UseInTableSearchMatchesReturn => { @@ -64,14 +74,10 @@ export const useInTableSearchMatches = ( tableContext, renderCellValue, scrollToActiveMatch, - services, } = props; - const [matchesList, setMatchesList] = useState(DEFAULT_MATCHES); - const [matchesCount, setMatchesCount] = useState(null); - const [activeMatchPosition, setActiveMatchPosition] = useState( - DEFAULT_ACTIVE_MATCH_POSITION - ); - const [isProcessing, setIsProcessing] = useState(false); + const [state, setState] = useState(INITIAL_STATE); + const { matchesList, matchesCount, activeMatchPosition, isProcessing, cellsShadowPortal } = state; + const numberOfRunsRef = useRef(0); const scrollToMatch = useCallback( ({ @@ -124,15 +130,15 @@ export const useInTableSearchMatches = ( ); const goToPrevMatch = useCallback(() => { - setActiveMatchPosition((prev) => { - if (typeof matchesCount !== 'number') { - return prev; + setState((prevState) => { + if (typeof prevState.matchesCount !== 'number') { + return prevState; } - let nextMatchPosition = prev - 1; + let nextMatchPosition = prevState.activeMatchPosition - 1; - if (prev - 1 < 1) { - nextMatchPosition = matchesCount; // allow to endlessly circle though matches + if (nextMatchPosition < 1) { + nextMatchPosition = prevState.matchesCount; // allow to endlessly circle though matches } scrollToMatch({ @@ -141,19 +147,23 @@ export const useInTableSearchMatches = ( activeColumns: visibleColumns, shouldJump: true, }); - return nextMatchPosition; + + return { + ...prevState, + activeMatchPosition: nextMatchPosition, + }; }); - }, [setActiveMatchPosition, scrollToMatch, matchesList, visibleColumns, matchesCount]); + }, [setState, scrollToMatch, matchesList, visibleColumns]); const goToNextMatch = useCallback(() => { - setActiveMatchPosition((prev) => { - if (typeof matchesCount !== 'number') { - return prev; + setState((prevState) => { + if (typeof prevState.matchesCount !== 'number') { + return prevState; } - let nextMatchPosition = prev + 1; + let nextMatchPosition = prevState.activeMatchPosition + 1; - if (prev + 1 > matchesCount) { + if (nextMatchPosition > prevState.matchesCount) { nextMatchPosition = 1; // allow to endlessly circle though matches } @@ -163,153 +173,167 @@ export const useInTableSearchMatches = ( activeColumns: visibleColumns, shouldJump: true, }); - return nextMatchPosition; + + return { + ...prevState, + activeMatchPosition: nextMatchPosition, + }; }); - }, [setActiveMatchPosition, scrollToMatch, matchesList, visibleColumns, matchesCount]); + }, [setState, scrollToMatch, matchesList, visibleColumns]); useEffect(() => { + numberOfRunsRef.current += 1; + if (!rows?.length || !inTableSearchTerm?.length) { - setMatchesList(DEFAULT_MATCHES); - setMatchesCount(null); - setActiveMatchPosition(DEFAULT_ACTIVE_MATCH_POSITION); + setState(INITIAL_STATE); return; } - setIsProcessing(true); - - const findMatches = async () => { - const { matchesList: nextMatchesList, totalMatchesCount } = await getCellMatchesCounts({ - inTableSearchTerm, - tableContext, - renderCellValue, - rows, - visibleColumns, - services, - }); - - const nextActiveMatchPosition = DEFAULT_ACTIVE_MATCH_POSITION; - setMatchesList(nextMatchesList); - setMatchesCount(totalMatchesCount); - setActiveMatchPosition(nextActiveMatchPosition); - setIsProcessing(false); - - if (totalMatchesCount > 0) { - scrollToMatch({ - matchPosition: nextActiveMatchPosition, - activeMatchesList: nextMatchesList, - activeColumns: visibleColumns, - shouldJump: false, - }); - } - }; + const numberOfRuns = numberOfRunsRef.current; + + stopTimer(latestTimeoutTimer); + + setState((prevState) => ({ + ...prevState, + isProcessing: true, + cellsShadowPortal: ( + { + setState({ + matchesList: nextMatchesList, + matchesCount: totalMatchesCount, + activeMatchPosition: DEFAULT_ACTIVE_MATCH_POSITION, + isProcessing: false, + cellsShadowPortal: null, + }); - void findMatches(); + if (totalMatchesCount > 0) { + scrollToMatch({ + matchPosition: DEFAULT_ACTIVE_MATCH_POSITION, + activeMatchesList: nextMatchesList, + activeColumns: visibleColumns, + shouldJump: true, + }); + } + }} + /> + ), + })); }, [ - setMatchesList, - setMatchesCount, - setActiveMatchPosition, - setIsProcessing, + setState, renderCellValue, scrollToMatch, visibleColumns, rows, inTableSearchTerm, tableContext, - services, ]); + const resetState = useCallback(() => { + stopTimer(latestTimeoutTimer); + setState(INITIAL_STATE); + }, [setState]); + return { matchesCount, activeMatchPosition, goToPrevMatch, goToNextMatch, + resetState, isProcessing, + cellsShadowPortal, }; }; -function getCellMatchesCounts( - props: Pick< - UseInTableSearchMatchesProps, - | 'inTableSearchTerm' - | 'tableContext' - | 'renderCellValue' - | 'rows' - | 'visibleColumns' - | 'services' - > -): Promise<{ matchesList: RowMatches[]; totalMatchesCount: number }> { - const { rows, visibleColumns } = props; - - if (!rows?.length || !visibleColumns?.length) { - return Promise.resolve({ matchesList: DEFAULT_MATCHES, totalMatchesCount: 0 }); - } - - const resultsMap: Record> = {}; - let remainingNumberOfResults = rows.length * visibleColumns.length; +type AllCellsProps = Pick< + UseInTableSearchMatchesProps, + 'inTableSearchTerm' | 'tableContext' | 'renderCellValue' | 'rows' | 'visibleColumns' +>; - const onHighlightsCountFound = (rowIndex: number, fieldName: string, count: number) => { - remainingNumberOfResults--; +function AllCellsHighlightsCounter( + props: AllCellsProps & { + onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; + } +) { + const [container] = useState(() => document.createDocumentFragment()); + const { rows, visibleColumns, onFinish } = props; + const resultsMapRef = useRef>>({}); + const remainingNumberOfResultsRef = useRef(rows.length * visibleColumns.length); + + const onHighlightsCountFound = useCallback( + (rowIndex: number, fieldName: string, count: number) => { + remainingNumberOfResultsRef.current = remainingNumberOfResultsRef.current - 1; + + if (count === 0) { + return; + } - if (count === 0) { - return; - } + if (!resultsMapRef.current[rowIndex]) { + resultsMapRef.current[rowIndex] = {}; + } + resultsMapRef.current[rowIndex][fieldName] = count; + }, + [] + ); - if (!resultsMap[rowIndex]) { - resultsMap[rowIndex] = {}; - } - resultsMap[rowIndex][fieldName] = count; - }; + const onComplete = useCallback(() => { + let totalMatchesCount = 0; + const newMatchesList: RowMatches[] = []; + + Object.keys(resultsMapRef.current) + .map((rowIndex) => Number(rowIndex)) + .sort((a, b) => a - b) + .forEach((rowIndex) => { + const matchesCountPerField = resultsMapRef.current[rowIndex]; + const rowMatchesCount = Object.values(matchesCountPerField).reduce( + (acc, count) => acc + count, + 0 + ); + + newMatchesList.push({ + rowIndex, + rowMatchesCount, + matchesCountPerField, + }); + totalMatchesCount += rowMatchesCount; + }); - const container = document.createElement('div'); - - return new Promise<{ matchesList: RowMatches[]; totalMatchesCount: number }>((resolve) => { - const finish = () => { - let totalMatchesCount = 0; - const newMatchesList: RowMatches[] = []; - - Object.keys(resultsMap) - .map((rowIndex) => Number(rowIndex)) - .sort((a, b) => a - b) - .forEach((rowIndex) => { - const matchesCountPerField = resultsMap[rowIndex]; - const rowMatchesCount = Object.values(matchesCountPerField).reduce( - (acc, count) => acc + count, - 0 - ); + onFinish({ matchesList: newMatchesList, totalMatchesCount }); + }, [onFinish]); - newMatchesList.push({ - rowIndex, - rowMatchesCount, - matchesCountPerField, - }); - totalMatchesCount += rowMatchesCount; - }); + const timerRef = useRef(null); + const [_] = useState(() => { + const newTimer = setTimeout(onComplete, 30000); + timerRef.current = newTimer; + return registerTimer(newTimer); + }); - resolve({ matchesList: newMatchesList, totalMatchesCount }); - ReactDOM.unmountComponentAtNode(container); + useEffect(() => { + return () => { + stopTimer(timerRef.current); }; + }, []); - const timer = setTimeout(() => { - // time out if rendering takes longer - finish(); - }, 60000); - - // render all cells in parallel and get the count of highlights as the result - ReactDOM.render( - { - onHighlightsCountFound(rowIndex, fieldName, count); - - if (remainingNumberOfResults === 0) { - clearTimeout(timer); - finish(); - } - }} - />, - container - ); - }).catch(() => ({ matchesList: DEFAULT_MATCHES, totalMatchesCount: 0 })); // catching unexpected errors + return createPortal( + { + onHighlightsCountFound(rowIndex, fieldName, count); + + if (remainingNumberOfResultsRef.current === 0) { + stopTimer(timerRef.current); + onComplete(); + } + }} + />, + container + ); } function AllCells({ @@ -318,51 +342,47 @@ function AllCells({ visibleColumns, renderCellValue, tableContext, - services, onHighlightsCountFound, -}: Pick< - UseInTableSearchMatchesProps, - 'inTableSearchTerm' | 'tableContext' | 'renderCellValue' | 'rows' | 'visibleColumns' | 'services' -> & { onHighlightsCountFound: (rowIndex: number, fieldName: string, count: number) => void }) { +}: AllCellsProps & { + onHighlightsCountFound: (rowIndex: number, fieldName: string, count: number) => void; +}) { const UnifiedDataTableRenderCellValue = renderCellValue; return ( - - - {(rows || []).flatMap((_, rowIndex) => { - return visibleColumns.map((fieldName) => { - return ( - { - onHighlightsCountFound(rowIndex, fieldName, 0); + + {(rows || []).flatMap((_, rowIndex) => { + return visibleColumns.map((fieldName) => { + return ( + { + onHighlightsCountFound(rowIndex, fieldName, 0); + }} + > + {}} + onHighlightsCountFound={(count) => { + onHighlightsCountFound(rowIndex, fieldName, count); }} - > - {}} - onHighlightsCountFound={(count) => { - onHighlightsCountFound(rowIndex, fieldName, count); - }} - /> - - ); - }); - })} - - + /> + + ); + }); + })} + ); } @@ -396,3 +416,15 @@ export class ErrorBoundary extends React.Component< return this.props.children; } } + +function registerTimer(timer: NodeJS.Timeout) { + stopTimer(latestTimeoutTimer); + latestTimeoutTimer = timer; + return timer; +} + +function stopTimer(timer: NodeJS.Timeout | null) { + if (timer) { + clearTimeout(timer); + } +} 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 5e48c4da47a43..7b9957c00aaa2 100644 --- a/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx +++ b/src/platform/plugins/shared/esql_datagrid/public/data_grid.tsx @@ -123,7 +123,6 @@ const DataGrid: React.FC = (props) => { return { data: props.data, theme: props.core.theme, - i18n: props.core.i18n, uiSettings: props.core.uiSettings, toastNotifications: props.core.notifications.toasts, fieldFormats: props.fieldFormats, @@ -132,7 +131,6 @@ const DataGrid: React.FC = (props) => { }, [ props.core.notifications.toasts, props.core.theme, - props.core.i18n, props.core.uiSettings, props.data, props.fieldFormats, From 3a05a537f47cb46144e3aa1842db17da754bcafa Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 17 Jan 2025 19:40:13 +0100 Subject: [PATCH 38/90] [Discover] Revert some changes --- .../packages/shared/kbn-unified-data-table/__mocks__/services.ts | 1 - .../kbn-unified-data-table/src/components/data_table.test.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts b/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts index 958e2d3f8ee5e..8a3f9568ba5e9 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/__mocks__/services.ts @@ -81,7 +81,6 @@ export function createServicesMock() { }, } as unknown as DataViewFieldEditorStart, theme, - i18n: corePluginMock.i18n, storage: { clear: jest.fn(), get: jest.fn(), 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 2467fcf1db23a..c52d9377112b7 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 @@ -90,7 +90,6 @@ function getProps(): UnifiedDataTableProps { storage: services.storage as unknown as Storage, data: services.data, theme: services.theme, - i18n: services.i18n, }, cellActionsMetadata: { someKey: 'someValue', From 890d1c81cd5c90a38e3902eeee2cf2fcaea24978 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 17 Jan 2025 18:50:52 +0000 Subject: [PATCH 39/90] [CI] Auto-commit changed files from 'node scripts/notice' --- .../packages/shared/kbn-unified-data-table/tsconfig.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json index bee1964b73623..bb699ca02fb5e 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json +++ b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json @@ -44,6 +44,5 @@ "@kbn/core-capabilities-browser-mocks", "@kbn/sort-predicates", "@kbn/visualization-utils", - "@kbn/react-kibana-context-render" ] } From e5ca868e4a311b855d1ce8e049b7b032c8e24edb Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Sat, 18 Jan 2025 15:44:35 +0100 Subject: [PATCH 40/90] [Discover] Update tests --- .../src/utils/get_render_cell_value.test.tsx | 348 +++++++++--------- 1 file changed, 180 insertions(+), 168 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx index 0dfeb1f691e88..8ac1f86d8ee80 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx @@ -130,7 +130,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"100"` + `"
100
"` ); }); @@ -155,7 +155,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
100
"` + `"
100
"` ); }); @@ -181,7 +181,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
100
"` + `"
100
"` ); findTestSubject(component, 'docTableClosePopover').simulate('click'); expect(closePopoverMockFn).toHaveBeenCalledTimes(1); @@ -246,46 +246,48 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - } - columnId="_source" - row={ - Object { - "flattened": Object { - "_index": "test", - "_score": 1, - "bytes": 100, - "extension": ".gz", - }, - "id": "test::1::", - "isAnchor": undefined, - "raw": Object { - "_id": "1", - "_index": "test", - "_score": 1, - "_source": Object { + + + } + columnId="_source" + row={ + Object { + "flattened": Object { + "_index": "test", + "_score": 1, "bytes": 100, "extension": ".gz", }, - "highlight": Object { - "extension": Array [ - "@kibana-highlighted-field.gz@/kibana-highlighted-field", - ], + "id": "test::1::", + "isAnchor": undefined, + "raw": Object { + "_id": "1", + "_index": "test", + "_score": 1, + "_source": Object { + "bytes": 100, + "extension": ".gz", + }, + "highlight": Object { + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], + }, }, - }, + } } - } - useTopLevelObjectColumns={false} - /> + useTopLevelObjectColumns={false} + /> + `); }); @@ -427,38 +429,24 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - } - columnId="_source" - row={ - Object { - "flattened": Object { - "_index": "test", - "_score": 1, - "bytes": Array [ - 100, - ], - "extension": Array [ - ".gz", - ], - }, - "id": "test::1::", - "isAnchor": undefined, - "raw": Object { - "_id": "1", - "_index": "test", - "_score": 1, - "_source": undefined, - "fields": Object { + + + } + columnId="_source" + row={ + Object { + "flattened": Object { + "_index": "test", + "_score": 1, "bytes": Array [ 100, ], @@ -466,16 +454,32 @@ describe('Unified data table cell rendering', function () { ".gz", ], }, - "highlight": Object { - "extension": Array [ - "@kibana-highlighted-field.gz@/kibana-highlighted-field", - ], + "id": "test::1::", + "isAnchor": undefined, + "raw": Object { + "_id": "1", + "_index": "test", + "_score": 1, + "_source": undefined, + "fields": Object { + "bytes": Array [ + 100, + ], + "extension": Array [ + ".gz", + ], + }, + "highlight": Object { + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], + }, }, - }, + } } - } - useTopLevelObjectColumns={false} - /> + useTopLevelObjectColumns={false} + /> + `); }); @@ -580,38 +584,24 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - } - columnId="object" - row={ - Object { - "flattened": Object { - "_index": "test", - "_score": 1, - "extension": Array [ - ".gz", - ], - "object.value": Array [ - 100, - ], - }, - "id": "test::1::", - "isAnchor": undefined, - "raw": Object { - "_id": "1", - "_index": "test", - "_score": 1, - "_source": undefined, - "fields": Object { + + + } + columnId="object" + row={ + Object { + "flattened": Object { + "_index": "test", + "_score": 1, "extension": Array [ ".gz", ], @@ -619,16 +609,32 @@ describe('Unified data table cell rendering', function () { 100, ], }, - "highlight": Object { - "extension": Array [ - "@kibana-highlighted-field.gz@/kibana-highlighted-field", - ], + "id": "test::1::", + "isAnchor": undefined, + "raw": Object { + "_id": "1", + "_index": "test", + "_score": 1, + "_source": undefined, + "fields": Object { + "extension": Array [ + ".gz", + ], + "object.value": Array [ + 100, + ], + }, + "highlight": Object { + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], + }, }, - }, + } } - } - useTopLevelObjectColumns={true} - /> + useTopLevelObjectColumns={true} + /> + `); }); @@ -682,16 +688,18 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - + + /> + `); }); @@ -716,7 +724,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"-"` + `"
-
"` ); }); @@ -741,7 +749,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"-"` + `"
-
"` ); }); @@ -779,16 +787,18 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - + + /> + `); const componentWithDetails = shallow( @@ -803,38 +813,40 @@ describe('Unified data table cell rendering', function () { /> ); expect(componentWithDetails).toMatchInlineSnapshot(` - - - - + + + + + + + + - - - - - - + + + `); }); }); From aa07743e1fe37ac8120c73114aceb9c4b420f01a Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Sat, 18 Jan 2025 16:08:49 +0100 Subject: [PATCH 41/90] [Discover] Simplify --- .../src/components/data_table.tsx | 37 ++++++------------- .../use_in_table_search_matches.tsx | 31 ++++------------ 2 files changed, 19 insertions(+), 49 deletions(-) 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 0ca7fe2ef5fb5..45003d28fb526 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 @@ -95,7 +95,7 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; -import { InTableSearchControl, UseInTableSearchMatchesProps } from './in_table_search'; +import { InTableSearchControl } from './in_table_search'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; @@ -672,46 +672,35 @@ export const UnifiedDataTable = ({ const [inTableSearchTerm, setInTableSearchTerm] = useState(''); const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); - const inTableSearchContextValue = useMemo( + const unifiedDataTableContextValue = useMemo( () => ({ + expanded: expandedDoc, + setExpanded: setExpandedDoc, getRowByIndex: (index: number) => displayedRows[index], + onFilter, dataView, isDarkMode: darkMode, selectedDocsState, valueToStringConverter, componentsTourSteps, isPlainRecord, + pageIndex: isPaginationEnabled ? paginationObj?.pageIndex : 0, + pageSize: isPaginationEnabled ? paginationObj?.pageSize : displayedRows.length, + inTableSearchTerm, }), [ componentsTourSteps, darkMode, dataView, isPlainRecord, - displayedRows, - selectedDocsState, - valueToStringConverter, - ] - ); - - const unifiedDataTableContextValue = useMemo( - () => ({ - ...inTableSearchContextValue, - expanded: expandedDoc, - setExpanded: setExpandedDoc, - onFilter, - pageIndex: isPaginationEnabled ? paginationObj?.pageIndex : 0, - pageSize: isPaginationEnabled ? paginationObj?.pageSize : displayedRows.length, - inTableSearchTerm, - }), - [ - inTableSearchContextValue, isPaginationEnabled, displayedRows, expandedDoc, - setExpandedDoc, onFilter, - paginationObj?.pageIndex, - paginationObj?.pageSize, + setExpandedDoc, + selectedDocsState, + paginationObj, + valueToStringConverter, inTableSearchTerm, ] ); @@ -773,7 +762,6 @@ export const UnifiedDataTable = ({ return ( ; renderCellValue: ( props: EuiDataGridCellValueElementProps & Pick @@ -67,14 +66,7 @@ const INITIAL_STATE: UseInTableSearchMatchesState = { export const useInTableSearchMatches = ( props: UseInTableSearchMatchesProps ): UseInTableSearchMatchesReturn => { - const { - visibleColumns, - rows, - inTableSearchTerm, - tableContext, - renderCellValue, - scrollToActiveMatch, - } = props; + const { visibleColumns, rows, inTableSearchTerm, renderCellValue, scrollToActiveMatch } = props; const [state, setState] = useState(INITIAL_STATE); const { matchesList, matchesCount, activeMatchPosition, isProcessing, cellsShadowPortal } = state; const numberOfRunsRef = useRef(0); @@ -200,7 +192,6 @@ export const useInTableSearchMatches = ( ), })); - }, [ - setState, - renderCellValue, - scrollToMatch, - visibleColumns, - rows, - inTableSearchTerm, - tableContext, - ]); + }, [setState, renderCellValue, scrollToMatch, visibleColumns, rows, inTableSearchTerm]); const resetState = useCallback(() => { stopTimer(latestTimeoutTimer); @@ -253,7 +236,7 @@ export const useInTableSearchMatches = ( type AllCellsProps = Pick< UseInTableSearchMatchesProps, - 'inTableSearchTerm' | 'tableContext' | 'renderCellValue' | 'rows' | 'visibleColumns' + 'inTableSearchTerm' | 'renderCellValue' | 'rows' | 'visibleColumns' >; function AllCellsHighlightsCounter( @@ -341,17 +324,17 @@ function AllCells({ rows, visibleColumns, renderCellValue, - tableContext, onHighlightsCountFound, }: AllCellsProps & { onHighlightsCountFound: (rowIndex: number, fieldName: string, count: number) => void; }) { const UnifiedDataTableRenderCellValue = renderCellValue; + const ctx = useContext(UnifiedDataTableContext); return ( Date: Mon, 20 Jan 2025 11:07:43 +0100 Subject: [PATCH 42/90] [Discover] Refocus the input if it's already present --- .../src/components/data_table.scss | 4 ---- .../components/in_table_search/in_table_search.scss | 3 +++ .../in_table_search/in_table_search_control.tsx | 10 +++++++--- 3 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss index 52e6173acc199..7b7888817b50f 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.scss @@ -9,10 +9,6 @@ font-family: $euiCodeFontFamily; } -.unifiedDataTable__inTableSearchMatch { - background-color: #E5FFC0; // TODO: Use a named color token -} - .unifiedDataTable__cell--expanded { background-color: $euiColorHighlight; } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss new file mode 100644 index 0000000000000..6727d80b13d94 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss @@ -0,0 +1,3 @@ +.unifiedDataTable__inTableSearchMatch { + background-color: #E5FFC0; // TODO: Use a named color token +} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 3a493f4dc2090..2fe552da413bc 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { ChangeEvent, FocusEvent, useCallback, useState, useEffect } from 'react'; +import React, { ChangeEvent, FocusEvent, useCallback, useState, useEffect, useRef } from 'react'; import { EuiFieldSearch, EuiButtonIcon, @@ -24,6 +24,7 @@ import { useInTableSearchMatches, UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; +import './in_table_search.scss'; const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; @@ -111,6 +112,7 @@ export const InTableSearchControl: React.FC = ({ } = useInTableSearchMatches({ ...props, scrollToActiveMatch }); const areArrowsDisabled = !matchesCount || isProcessing; + const inputRef = useRef(null); const [isFocused, setIsFocused] = useState(false); const focusInput = useCallback(() => { @@ -118,11 +120,11 @@ export const InTableSearchControl: React.FC = ({ }, [setIsFocused]); const hideInput = useCallback( - (shouldFocusTheButton?: boolean) => { + (shouldReturnFocusToButton?: boolean) => { setIsFocused(false); resetState(); - if (shouldFocusTheButton) { + if (shouldReturnFocusToButton) { setTimeout(() => { const button = document.querySelector(`[data-test-subj='${BUTTON_TEST_SUBJ}']`); (button as HTMLButtonElement)?.focus(); @@ -186,6 +188,7 @@ export const InTableSearchControl: React.FC = ({ ) { event.preventDefault(); // prevent default browser find-in-page behavior focusInput(); + inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it } }; @@ -222,6 +225,7 @@ export const InTableSearchControl: React.FC = ({ return (
(inputRef.current = node)} autoFocus compressed isClearable={!isProcessing} From a85e31821160f3b491c8699bbb73d130b17e1f8e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 11:42:03 +0100 Subject: [PATCH 43/90] [Discover] Split contexts and improve styles --- .../src/components/data_table.tsx | 229 +++++++++--------- .../in_table_search/in_table_search.scss | 1 + .../in_table_search_context.tsx | 18 ++ .../in_table_search_control.tsx | 16 +- .../src/components/in_table_search/index.ts | 1 + .../use_in_table_search_matches.tsx | 22 +- .../src/utils/get_render_cell_value.tsx | 3 +- 7 files changed, 163 insertions(+), 127 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx 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 45003d28fb526..8994aabe3f778 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 @@ -95,7 +95,11 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; -import { InTableSearchControl } from './in_table_search'; +import { + InTableSearchControl, + InTableSearchContext, + InTableSearchContextValue, +} from './in_table_search'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; @@ -668,10 +672,6 @@ export const UnifiedDataTable = ({ changeCurrentPageIndex, ]); - const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); - const [inTableSearchTerm, setInTableSearchTerm] = useState(''); - const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); - const unifiedDataTableContextValue = useMemo( () => ({ expanded: expandedDoc, @@ -686,7 +686,6 @@ export const UnifiedDataTable = ({ isPlainRecord, pageIndex: isPaginationEnabled ? paginationObj?.pageIndex : 0, pageSize: isPaginationEnabled ? paginationObj?.pageSize : displayedRows.length, - inTableSearchTerm, }), [ componentsTourSteps, @@ -701,7 +700,6 @@ export const UnifiedDataTable = ({ selectedDocsState, paginationObj, valueToStringConverter, - inTableSearchTerm, ] ); @@ -755,6 +753,17 @@ export const UnifiedDataTable = ({ ] ); + const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); + const [inTableSearchTerm, setInTableSearchTerm] = useState(''); + const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); + + const inTableSearchContextValue = useMemo( + () => ({ + inTableSearchTerm, + }), + [inTableSearchTerm] + ); + const inTableSearchControl = useMemo(() => { if (!enableInTableSearch) { return undefined; @@ -1186,109 +1195,111 @@ export const UnifiedDataTable = ({ return ( - -
- {isCompareActive ? ( - - ) : ( - + + +
+ {isCompareActive ? ( + + ) : ( + + )} +
+ {loadingState !== DataLoadingState.loading && + isPaginationEnabled && // we hide the footer for Surrounding Documents page + !isFilterActive && // hide footer when showing selected documents + !isCompareActive && ( + + )} + {searchTitle && ( + +

+ {searchDescription ? ( + + ) : ( + + )} +

+
)} -
- {loadingState !== DataLoadingState.loading && - isPaginationEnabled && // we hide the footer for Surrounding Documents page - !isFilterActive && // hide footer when showing selected documents - !isCompareActive && ( - - )} - {searchTitle && ( - -

- {searchDescription ? ( - - ) : ( - - )} -

-
- )} - {canSetExpandedDoc && - expandedDoc && - renderDocumentView!(expandedDoc, displayedRows, displayedColumns, columnsMeta)} -
+ {canSetExpandedDoc && + expandedDoc && + renderDocumentView!(expandedDoc, displayedRows, displayedColumns, columnsMeta)} + +
); }; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss index 6727d80b13d94..879b8c4b9504e 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss @@ -1,3 +1,4 @@ .unifiedDataTable__inTableSearchMatch { background-color: #E5FFC0; // TODO: Use a named color token + transition: background-color $euiAnimSpeedFast ease-in-out; } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx new file mode 100644 index 0000000000000..67c39fb77332f --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx @@ -0,0 +1,18 @@ +/* + * 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'; + +export interface InTableSearchContextValue { + inTableSearchTerm: string; +} + +const defaultContext: InTableSearchContextValue = { inTableSearchTerm: '' }; + +export const InTableSearchContext = React.createContext(defaultContext); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 2fe552da413bc..6d2ff928fa894 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -29,17 +29,25 @@ import './in_table_search.scss'; const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; const searchInputCss = css` - .euiFormControlLayout, input.euiFieldSearch { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-right: 0; + /* 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, + input.euiFieldSearch { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; + } + } `; const matchesCss = css` diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts index 678861b1eda73..ba8c73a9882be 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts @@ -17,3 +17,4 @@ export { type UseInTableSearchMatchesProps, type UseInTableSearchMatchesReturn, } from './use_in_table_search_matches'; +export { InTableSearchContext, type InTableSearchContextValue } from './in_table_search_context'; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 8d39e587f9d27..9e07e95bed27f 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -7,11 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useEffect, useState, ReactNode, useRef, useContext } from 'react'; +import React, { useCallback, useEffect, useState, ReactNode, useRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { UnifiedDataTableContext } from '../../table_context'; +import { InTableSearchContext, InTableSearchContextValue } from './in_table_search_context'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; let latestTimeoutTimer: NodeJS.Timeout | null = null; @@ -25,7 +25,7 @@ interface RowMatches { export interface UseInTableSearchMatchesProps { visibleColumns: string[]; rows: DataTableRecord[]; - inTableSearchTerm: string | undefined; + inTableSearchTerm: string; renderCellValue: ( props: EuiDataGridCellValueElementProps & Pick @@ -329,17 +329,13 @@ function AllCells({ onHighlightsCountFound: (rowIndex: number, fieldName: string, count: number) => void; }) { const UnifiedDataTableRenderCellValue = renderCellValue; - const ctx = useContext(UnifiedDataTableContext); + const contextValue = useMemo( + () => ({ inTableSearchTerm }), + [inTableSearchTerm] + ); return ( - + {(rows || []).flatMap((_, rowIndex) => { return visibleColumns.map((fieldName) => { return ( @@ -365,7 +361,7 @@ function AllCells({ ); }); })} - + ); } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index ea3c10bf9732a..687ebf90093d8 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -27,6 +27,7 @@ import { DataTablePopoverCellValue } from '../components/data_table_cell_value'; import { InTableSearchHighlightsWrapper, InTableSearchHighlightsWrapperProps, + InTableSearchContext, } from '../components/in_table_search'; export const CELL_CLASS = 'unifiedDataTable__cellValue'; @@ -68,7 +69,7 @@ export const getRenderCellValueFn = ({ const row = rows ? rows[rowIndex] : undefined; const field = dataView.fields.getByName(columnId); const ctx = useContext(UnifiedDataTableContext); - const { inTableSearchTerm } = ctx; + const { inTableSearchTerm } = useContext(InTableSearchContext); useEffect(() => { if (row?.isAnchor) { From f98aae21cb20e1474324229c700779f5eb5b73db Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 11:46:42 +0100 Subject: [PATCH 44/90] [Discover] Update tests --- .../src/utils/get_render_cell_value.test.tsx | 30 +++++++++++++++---- .../src/utils/get_render_cell_value.tsx | 2 +- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx index 8ac1f86d8ee80..fc4b45e1e3a63 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx @@ -246,7 +246,10 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(componentWithDetails).toMatchInlineSnapshot(` - + From e755f66098f02562177ead74c6ce3a45add9cf98 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 13:25:22 +0100 Subject: [PATCH 45/90] [Discover] Add support for Timeline toolbar --- .../src/components/data_table.tsx | 20 ++++++++++++++++--- .../in_table_search/in_table_search.scss | 5 +++++ .../in_table_search_control.tsx | 7 ++++--- 3 files changed, 26 insertions(+), 6 deletions(-) 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 8994aabe3f778..bfb9770349469 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 @@ -1014,11 +1014,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, @@ -1053,6 +1062,8 @@ export const UnifiedDataTable = ({ unifiedDataTableContextValue.pageSize, toastNotifications, visibleColumns, + renderCustomToolbar, + inTableSearchControl, ]); const renderCustomToolbarFn: EuiDataGridProps['renderCustomToolbar'] | undefined = useMemo( @@ -1062,7 +1073,10 @@ export const UnifiedDataTable = ({ renderCustomToolbar({ toolbarProps, gridProps: { - additionalControls, + additionalControls: + additionalControls && 'left' in additionalControls + ? additionalControls.left + : additionalControls, inTableSearchControl, }, }) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss index 879b8c4b9504e..aae4f2856c763 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss @@ -2,3 +2,8 @@ background-color: #E5FFC0; // TODO: Use a named color token transition: background-color $euiAnimSpeedFast ease-in-out; } + +.unifiedDataTable__inTableSearchButton { + /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ + min-height: 2 * $euiSize; // input height +} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 6d2ff928fa894..c384cc9b9c036 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -221,10 +221,11 @@ export const InTableSearchControl: React.FC = ({ iconType="search" size="xs" color="text" - onClick={focusInput} + className="unifiedDataTable__inTableSearchButton" aria-label={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { defaultMessage: 'Search in the table', })} + onClick={focusInput} /> ); @@ -251,7 +252,7 @@ export const InTableSearchControl: React.FC = ({ color="text" disabled={areArrowsDisabled} aria-label={i18n.translate('unifiedDataTable.inTableSearch.buttonPreviousMatch', { - defaultMessage: 'Previous match', + defaultMessage: 'Previous', })} onClick={goToPrevMatch} /> @@ -262,7 +263,7 @@ export const InTableSearchControl: React.FC = ({ color="text" disabled={areArrowsDisabled} aria-label={i18n.translate('unifiedDataTable.inTableSearch.buttonNextMatch', { - defaultMessage: 'Next match', + defaultMessage: 'Next', })} onClick={goToNextMatch} /> From 2bb5bd0b1e02279f43ffdd0ef2e2dc0191685844 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 13:36:22 +0100 Subject: [PATCH 46/90] [Discover] Fix styles --- .../in_table_search/in_table_search.scss | 24 ++++++++++++++ .../in_table_search_control.tsx | 31 ++----------------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss index aae4f2856c763..030489034a5bc 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss @@ -3,7 +3,31 @@ transition: background-color $euiAnimSpeedFast ease-in-out; } +.unifiedDataTable__inTableSearchMatchesCounter { + font-variant-numeric: tabular-nums; +} + .unifiedDataTable__inTableSearchButton { /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ min-height: 2 * $euiSize; // input height } + +.unifiedDataTable__inTableSearchInputContainer { + .unifiedDataTable__inTableSearchInput { + /* 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 & .unifiedDataTable__inTableSearchInput { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; + } +} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index c384cc9b9c036..2a8c213d9cd88 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -28,32 +28,6 @@ import './in_table_search.scss'; const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; -const searchInputCss = css` - input.euiFieldSearch { - /* 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, - input.euiFieldSearch { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-right: 0; - } - } -`; - -const matchesCss = css` - font-variant-numeric: tabular-nums; -`; - export interface InTableSearchControlProps extends Omit { pageSize: number | null; // null when the pagination is disabled @@ -232,16 +206,17 @@ export const InTableSearchControl: React.FC = ({ } return ( -
+
(inputRef.current = node)} autoFocus compressed + className="unifiedDataTable__inTableSearchInput" isClearable={!isProcessing} isLoading={isProcessing} append={ - + {matchesCount ? `${activeMatchPosition}/${matchesCount}` : '0/0'}  From a8234dfdb77ffcd1c2425a653083d980924e28c3 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 14:12:43 +0100 Subject: [PATCH 47/90] [Discover] Update code style --- .../in_table_search_control.tsx | 415 ++++++++++-------- .../use_in_table_search_matches.tsx | 4 + 2 files changed, 224 insertions(+), 195 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 2a8c213d9cd88..d1a84d478b504 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -7,7 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { ChangeEvent, FocusEvent, useCallback, useState, useEffect, useRef } from 'react'; +import React, { + ChangeEvent, + FocusEvent, + useCallback, + useState, + useEffect, + useRef, + useMemo, +} from 'react'; import { EuiFieldSearch, EuiButtonIcon, @@ -17,8 +25,8 @@ import { EuiText, keys, } from '@elastic/eui'; -import { useDebouncedValue } from '@kbn/visualization-utils'; import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; import { css, type SerializedStyles } from '@emotion/react'; import { useInTableSearchMatches, @@ -38,222 +46,239 @@ export interface InTableSearchControlProps onChangeCss: (styles: SerializedStyles) => void; } -export const InTableSearchControl: React.FC = ({ - pageSize, - changeToExpectedPage, - scrollToCell, - shouldOverrideCmdF, - onChange, - onChangeCss, - ...props -}) => { - const scrollToActiveMatch: UseInTableSearchMatchesProps['scrollToActiveMatch'] = useCallback( - ({ rowIndex, fieldName, matchIndex, shouldJump }) => { - if (typeof pageSize === 'number') { - const expectedPageIndex = Math.floor(rowIndex / pageSize); - changeToExpectedPage(expectedPageIndex); - } - - // TODO: use a named color token - onChangeCss(css` - .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] - .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndex}'] { - background-color: #ffc30e; +export const InTableSearchControl: React.FC = React.memo( + ({ + pageSize, + changeToExpectedPage, + scrollToCell, + shouldOverrideCmdF, + onChange, + onChangeCss, + ...props + }) => { + const scrollToActiveMatch: UseInTableSearchMatchesProps['scrollToActiveMatch'] = useCallback( + ({ rowIndex, fieldName, matchIndex, shouldJump }) => { + if (typeof pageSize === 'number') { + const expectedPageIndex = Math.floor(rowIndex / pageSize); + changeToExpectedPage(expectedPageIndex); } - `); - if (shouldJump) { - const anyCellForFieldName = document.querySelector( - `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` - ); + // TODO: use a named color token + onChangeCss(css` + .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] + .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndex}'] { + background-color: #ffc30e; + } + `); - // getting column index by column id - const columnIndex = anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; + if (shouldJump) { + const anyCellForFieldName = document.querySelector( + `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` + ); - // getting rowIndex for the visible page - const visibleRowIndex = typeof pageSize === 'number' ? rowIndex % pageSize : rowIndex; + // getting column index by column id + const columnIndex = anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; - scrollToCell({ - rowIndex: visibleRowIndex, - columnIndex: Number(columnIndex), - align: 'smart', - }); - } - }, - [pageSize, changeToExpectedPage, scrollToCell, onChangeCss] - ); + // getting rowIndex for the visible page + const visibleRowIndex = typeof pageSize === 'number' ? rowIndex % pageSize : rowIndex; - const { - matchesCount, - activeMatchPosition, - isProcessing, - cellsShadowPortal, - goToPrevMatch, - goToNextMatch, - resetState, - } = useInTableSearchMatches({ ...props, scrollToActiveMatch }); - const areArrowsDisabled = !matchesCount || isProcessing; + scrollToCell({ + rowIndex: visibleRowIndex, + columnIndex: Number(columnIndex), + align: 'smart', + }); + } + }, + [pageSize, changeToExpectedPage, scrollToCell, onChangeCss] + ); - const inputRef = useRef(null); - const [isFocused, setIsFocused] = useState(false); + const { + matchesCount, + activeMatchPosition, + isProcessing, + cellsShadowPortal, + goToPrevMatch, + goToNextMatch, + resetState, + } = useInTableSearchMatches({ ...props, scrollToActiveMatch }); + const areArrowsDisabled = !matchesCount || isProcessing; + const [inputValue, setInputValue] = useState(props.inTableSearchTerm); + const inputRef = useRef(null); + const [buttonNode, setButtonNode] = useState(null); + const shouldReturnFocusToButtonRef = useRef(false); + const [isFocused, setIsFocused] = useState(false); - const focusInput = useCallback(() => { - setIsFocused(true); - }, [setIsFocused]); + const focusInput = useCallback(() => { + setIsFocused(true); + }, [setIsFocused]); - const hideInput = useCallback( - (shouldReturnFocusToButton?: boolean) => { - setIsFocused(false); - resetState(); + const hideInput = useCallback( + (shouldReturnFocusToButton: boolean = false) => { + setIsFocused(false); + resetState(); + shouldReturnFocusToButtonRef.current = shouldReturnFocusToButton; + }, + [setIsFocused, resetState] + ); - if (shouldReturnFocusToButton) { - setTimeout(() => { - const button = document.querySelector(`[data-test-subj='${BUTTON_TEST_SUBJ}']`); - (button as HTMLButtonElement)?.focus(); - }, 350); - } - }, - [setIsFocused, resetState] - ); + const debouncedOnChange = useMemo( + () => + debounce( + (value: string) => { + onChange(value); + }, + 300, + { leading: false, trailing: true } + ), + [onChange] + ); - const { inputValue, handleInputChange } = useDebouncedValue({ - onChange, - value: props.inTableSearchTerm, - }); + const onInputChange = useCallback( + (event: ChangeEvent) => { + const nextValue = event.target.value; + setInputValue(nextValue); + debouncedOnChange(nextValue); + }, + [debouncedOnChange, setInputValue] + ); - const onInputChange = useCallback( - (event: ChangeEvent) => { - handleInputChange(event.target.value); - }, - [handleInputChange] - ); + const onKeyUp = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === keys.ESCAPE) { + hideInput(true); + return; + } - const onKeyUp = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === keys.ESCAPE) { - hideInput(true); - return; - } + if (areArrowsDisabled) { + return; + } - if (areArrowsDisabled) { - return; - } + if (event.key === keys.ENTER && event.shiftKey) { + goToPrevMatch(); + } else if (event.key === keys.ENTER) { + goToNextMatch(); + } + }, + [goToPrevMatch, goToNextMatch, hideInput, areArrowsDisabled] + ); - if (event.key === keys.ENTER && event.shiftKey) { - goToPrevMatch(); - } else if (event.key === keys.ENTER) { - goToNextMatch(); - } - }, - [goToPrevMatch, goToNextMatch, hideInput, areArrowsDisabled] - ); + const onBlur = useCallback( + (event: FocusEvent) => { + if ( + (!event.relatedTarget || + event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && + !inputValue + ) { + hideInput(); + } + }, + [hideInput, inputValue] + ); - const onBlur = useCallback( - (event: FocusEvent) => { - if ( - (!event.relatedTarget || - event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && - !inputValue - ) { - hideInput(); - } - }, - [hideInput, inputValue] - ); + 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 + focusInput(); + inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it + } + }; - 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 - focusInput(); - inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it - } - }; + document.addEventListener('keydown', handleGlobalKeyDown); - document.addEventListener('keydown', handleGlobalKeyDown); + // Cleanup the event listener + return () => { + document.removeEventListener('keydown', handleGlobalKeyDown); + }; + }, [focusInput, shouldOverrideCmdF]); - // Cleanup the event listener - return () => { - document.removeEventListener('keydown', handleGlobalKeyDown); - }; - }, [focusInput, shouldOverrideCmdF]); + const shouldRenderButton = !isFocused && !inputValue; + + useEffect(() => { + if (shouldReturnFocusToButtonRef.current && buttonNode && shouldRenderButton) { + shouldReturnFocusToButtonRef.current = false; + buttonNode.focus(); + } + }, [buttonNode, shouldRenderButton]); + + if (shouldRenderButton) { + return ( + + + + ); + } - if (!isFocused && !inputValue) { return ( - - + (inputRef.current = node)} + autoFocus + compressed + className="unifiedDataTable__inTableSearchInput" + isClearable={!isProcessing} + isLoading={isProcessing} + append={ + + + + {matchesCount ? `${activeMatchPosition}/${matchesCount}` : '0/0'}  + + + + + + + + + + } + placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { defaultMessage: 'Search in the table', })} - onClick={focusInput} + value={inputValue} + onChange={onInputChange} + onKeyUp={onKeyUp} + onBlur={onBlur} /> - + {cellsShadowPortal} +
); } - - return ( -
- (inputRef.current = node)} - autoFocus - compressed - className="unifiedDataTable__inTableSearchInput" - isClearable={!isProcessing} - isLoading={isProcessing} - append={ - - - - {matchesCount ? `${activeMatchPosition}/${matchesCount}` : '0/0'}  - - - - - - - - - - } - placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { - defaultMessage: 'Search in the table', - })} - value={inputValue} - onChange={onInputChange} - onKeyUp={onKeyUp} - onBlur={onBlur} - /> - {cellsShadowPortal} -
- ); -}; +); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 9e07e95bed27f..70d2d734c11dc 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -196,6 +196,10 @@ export const useInTableSearchMatches = ( rows={rows} visibleColumns={visibleColumns} onFinish={({ matchesList: nextMatchesList, totalMatchesCount }) => { + if (numberOfRuns < numberOfRunsRef.current) { + return; + } + setState({ matchesList: nextMatchesList, matchesCount: totalMatchesCount, From 70e45c57cbde95fbccef252dce3b5df5e9675fa0 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 16:32:13 +0100 Subject: [PATCH 48/90] [Discover] Use cellContext instead of table context. Refactor the hook. --- .../src/components/data_table.tsx | 242 +++++++------- .../in_table_search_context.tsx | 18 -- .../in_table_search_control.tsx | 148 ++++----- .../src/components/in_table_search/index.ts | 1 - .../use_in_table_search_matches.tsx | 298 +++++++++--------- .../src/utils/get_render_cell_value.tsx | 7 +- 6 files changed, 337 insertions(+), 377 deletions(-) delete mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx 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 bfb9770349469..dc97b4609bace 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 @@ -95,11 +95,7 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; -import { - InTableSearchControl, - InTableSearchContext, - InTableSearchContextValue, -} from './in_table_search'; +import { InTableSearchControl } from './in_table_search'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; @@ -757,29 +753,16 @@ export const UnifiedDataTable = ({ const [inTableSearchTerm, setInTableSearchTerm] = useState(''); const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); - const inTableSearchContextValue = useMemo( - () => ({ - inTableSearchTerm, - }), - [inTableSearchTerm] - ); - const inTableSearchControl = useMemo(() => { if (!enableInTableSearch) { return undefined; } return ( { - if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { - changeCurrentPageIndex(expectedPageIndex); - } - }} scrollToCell={(params) => { dataGridRef.current?.scrollToItem?.(params); }} @@ -788,11 +771,15 @@ export const UnifiedDataTable = ({ }} onChange={(searchTerm) => setInTableSearchTerm(searchTerm || '')} onChangeCss={(styles) => setInTableSearchTermCss(styles)} + onChangeToExpectedPage={(expectedPageIndex) => { + if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { + changeCurrentPageIndex(expectedPageIndex); + } + }} /> ); }, [ enableInTableSearch, - inTableSearchTerm, setInTableSearchTerm, setInTableSearchTermCss, displayedRows, @@ -805,6 +792,17 @@ export const UnifiedDataTable = ({ isPaginationEnabled, ]); + const extendedCellContext: EuiDataGridProps['cellContext'] = useMemo(() => { + if (!inTableSearchTerm && !cellContext) { + return undefined; + } + + return { + ...cellContext, + inTableSearchTerm, + }; + }, [cellContext, inTableSearchTerm]); + const renderCustomPopover = useMemo( () => renderCellPopover ?? getCustomCellPopoverRenderer(), [renderCellPopover] @@ -1209,111 +1207,109 @@ export const UnifiedDataTable = ({ return ( - - -
- {isCompareActive ? ( - - ) : ( - - )} -
- {loadingState !== DataLoadingState.loading && - isPaginationEnabled && // we hide the footer for Surrounding Documents page - !isFilterActive && // hide footer when showing selected documents - !isCompareActive && ( - - )} - {searchTitle && ( - -

- {searchDescription ? ( - - ) : ( - - )} -

-
+ +
+ {isCompareActive ? ( + + ) : ( + )} - {canSetExpandedDoc && - expandedDoc && - renderDocumentView!(expandedDoc, displayedRows, displayedColumns, columnsMeta)} - - +
+ {loadingState !== DataLoadingState.loading && + isPaginationEnabled && // we hide the footer for Surrounding Documents page + !isFilterActive && // hide footer when showing selected documents + !isCompareActive && ( + + )} + {searchTitle && ( + +

+ {searchDescription ? ( + + ) : ( + + )} +

+
+ )} + {canSetExpandedDoc && + expandedDoc && + renderDocumentView!(expandedDoc, displayedRows, displayedColumns, columnsMeta)} +
); }; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx deleted file mode 100644 index 67c39fb77332f..0000000000000 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_context.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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'; - -export interface InTableSearchContextValue { - inTableSearchTerm: string; -} - -const defaultContext: InTableSearchContextValue = { inTableSearchTerm: '' }; - -export const InTableSearchContext = React.createContext(defaultContext); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index d1a84d478b504..45001108eeef6 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -7,15 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { - ChangeEvent, - FocusEvent, - useCallback, - useState, - useEffect, - useRef, - useMemo, -} from 'react'; +import React, { ChangeEvent, FocusEvent, useCallback, useState, useEffect, useRef } from 'react'; import { EuiFieldSearch, EuiButtonIcon, @@ -25,88 +17,109 @@ import { EuiText, keys, } from '@elastic/eui'; +import { useDebouncedValue } from '@kbn/visualization-utils'; import { i18n } from '@kbn/i18n'; -import { debounce } from 'lodash'; import { css, type SerializedStyles } from '@emotion/react'; import { useInTableSearchMatches, UseInTableSearchMatchesProps, + UseInTableSearchMatchesReturn, } from './use_in_table_search_matches'; import './in_table_search.scss'; const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; export interface InTableSearchControlProps - extends Omit { + extends Omit { pageSize: number | null; // null when the pagination is disabled - changeToExpectedPage: (pageIndex: number) => void; scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'smart' }) => void; shouldOverrideCmdF: (element: HTMLElement) => boolean; onChange: (searchTerm: string | undefined) => void; onChangeCss: (styles: SerializedStyles) => void; + onChangeToExpectedPage: (pageIndex: number) => void; } export const InTableSearchControl: React.FC = React.memo( ({ pageSize, - changeToExpectedPage, scrollToCell, shouldOverrideCmdF, onChange, onChangeCss, + onChangeToExpectedPage, ...props }) => { - const scrollToActiveMatch: UseInTableSearchMatchesProps['scrollToActiveMatch'] = useCallback( - ({ rowIndex, fieldName, matchIndex, shouldJump }) => { - if (typeof pageSize === 'number') { - const expectedPageIndex = Math.floor(rowIndex / pageSize); - changeToExpectedPage(expectedPageIndex); - } - - // TODO: use a named color token - onChangeCss(css` - .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${fieldName}'] - .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndex}'] { - background-color: #ffc30e; - } - `); - - if (shouldJump) { - const anyCellForFieldName = document.querySelector( - `.euiDataGridRowCell[data-gridcell-column-id='${fieldName}']` - ); - - // getting column index by column id - const columnIndex = anyCellForFieldName?.getAttribute('data-gridcell-column-index') ?? 0; - - // getting rowIndex for the visible page - const visibleRowIndex = typeof pageSize === 'number' ? rowIndex % pageSize : rowIndex; + const inputRef = useRef(null); + const [buttonNode, setButtonNode] = useState(null); + const shouldReturnFocusToButtonRef = useRef(false); + const [isFocused, setIsFocused] = useState(false); + const processedActiveMatchRef = useRef(null); - scrollToCell({ - rowIndex: visibleRowIndex, - columnIndex: Number(columnIndex), - align: 'smart', - }); - } + const [inTableSearchTerm, setInTableSearchTerm] = useState(''); + const onChangeSearchTerm = useCallback( + (value: string) => { + // sending the value to the grid and to the hook, so they can process it hopefully in parallel + onChange(value); + setInTableSearchTerm(value); }, - [pageSize, changeToExpectedPage, scrollToCell, onChangeCss] + [onChange, setInTableSearchTerm] ); + const { inputValue, handleInputChange } = useDebouncedValue({ + onChange: onChangeSearchTerm, + value: inTableSearchTerm, + }); const { matchesCount, - activeMatchPosition, + activeMatch, isProcessing, - cellsShadowPortal, + renderCellsShadowPortal, goToPrevMatch, goToNextMatch, resetState, - } = useInTableSearchMatches({ ...props, scrollToActiveMatch }); - const areArrowsDisabled = !matchesCount || isProcessing; - const [inputValue, setInputValue] = useState(props.inTableSearchTerm); - const inputRef = useRef(null); - const [buttonNode, setButtonNode] = useState(null); - const shouldReturnFocusToButtonRef = useRef(false); - const [isFocused, setIsFocused] = useState(false); + } = useInTableSearchMatches({ ...props, inTableSearchTerm }); + + const isSearching = isProcessing; + const areArrowsDisabled = !matchesCount || isSearching; + + useEffect(() => { + if (!activeMatch || processedActiveMatchRef.current === activeMatch) { + return; + } + + processedActiveMatchRef.current = activeMatch; // prevent multiple executions + + const { rowIndex, columnId, matchIndexWithinCell } = activeMatch; + + if (typeof pageSize === 'number') { + const expectedPageIndex = Math.floor(rowIndex / pageSize); + onChangeToExpectedPage(expectedPageIndex); + } + + // TODO: use a named color token + onChangeCss(css` + .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${columnId}'] + .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndexWithinCell}'] { + background-color: #ffc30e; + } + `); + + const anyCellForColumnId = document.querySelector( + `.euiDataGridRowCell[data-gridcell-column-id='${columnId}']` + ); + + // getting column index by column id + const columnIndex = anyCellForColumnId?.getAttribute('data-gridcell-column-index') ?? 0; + + // getting rowIndex for the visible page + const visibleRowIndex = typeof pageSize === 'number' ? rowIndex % pageSize : rowIndex; + + scrollToCell({ + rowIndex: visibleRowIndex, + columnIndex: Number(columnIndex), + align: 'smart', + }); + }, [activeMatch, scrollToCell, onChange, onChangeCss, onChangeToExpectedPage, pageSize]); const focusInput = useCallback(() => { setIsFocused(true); @@ -121,25 +134,11 @@ export const InTableSearchControl: React.FC = React.m [setIsFocused, resetState] ); - const debouncedOnChange = useMemo( - () => - debounce( - (value: string) => { - onChange(value); - }, - 300, - { leading: false, trailing: true } - ), - [onChange] - ); - const onInputChange = useCallback( (event: ChangeEvent) => { - const nextValue = event.target.value; - setInputValue(nextValue); - debouncedOnChange(nextValue); + handleInputChange(event.target.value); }, - [debouncedOnChange, setInputValue] + [handleInputChange] ); const onKeyUp = useCallback( @@ -236,13 +235,14 @@ export const InTableSearchControl: React.FC = React.m autoFocus compressed className="unifiedDataTable__inTableSearchInput" - isClearable={!isProcessing} - isLoading={isProcessing} + isClearable={!isSearching} + isLoading={isSearching} append={ - {matchesCount ? `${activeMatchPosition}/${matchesCount}` : '0/0'}  + {matchesCount && activeMatch ? `${activeMatch.position}/${matchesCount}` : '0/0'} +   @@ -277,7 +277,7 @@ export const InTableSearchControl: React.FC = React.m onKeyUp={onKeyUp} onBlur={onBlur} /> - {cellsShadowPortal} + {renderCellsShadowPortal ? renderCellsShadowPortal() : null}
); } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts index ba8c73a9882be..678861b1eda73 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts @@ -17,4 +17,3 @@ export { type UseInTableSearchMatchesProps, type UseInTableSearchMatchesReturn, } from './use_in_table_search_matches'; -export { InTableSearchContext, type InTableSearchContextValue } from './in_table_search_context'; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 70d2d734c11dc..47cc0175cb63e 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -7,11 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useEffect, useState, ReactNode, useRef, useMemo } from 'react'; +import React, { useCallback, useEffect, useState, ReactNode, useRef } from 'react'; import { createPortal } from 'react-dom'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { InTableSearchContext, InTableSearchContextValue } from './in_table_search_context'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; let latestTimeoutTimer: NodeJS.Timeout | null = null; @@ -19,7 +18,7 @@ let latestTimeoutTimer: NodeJS.Timeout | null = null; interface RowMatches { rowIndex: number; rowMatchesCount: number; - matchesCountPerField: Record; + matchesCountPerColumnId: Record; } export interface UseInTableSearchMatchesProps { @@ -28,151 +27,56 @@ export interface UseInTableSearchMatchesProps { inTableSearchTerm: string; renderCellValue: ( props: EuiDataGridCellValueElementProps & - Pick + Pick ) => ReactNode; - scrollToActiveMatch: (params: { - rowIndex: number; - fieldName: string; - matchIndex: number; - shouldJump: boolean; - }) => void; } interface UseInTableSearchMatchesState { matchesList: RowMatches[]; matchesCount: number | null; - activeMatchPosition: number; + activeMatch: { + position: number; + rowIndex: number; + columnId: string; + matchIndexWithinCell: number; + } | null; + columns: string[]; isProcessing: boolean; - cellsShadowPortal: ReactNode | null; + calculatedForSearchTerm: string | null; + renderCellsShadowPortal: (() => ReactNode) | null; } export interface UseInTableSearchMatchesReturn - extends Omit { + extends Omit { goToPrevMatch: () => void; goToNextMatch: () => void; resetState: () => void; } -const DEFAULT_MATCHES: RowMatches[] = []; -const DEFAULT_ACTIVE_MATCH_POSITION = 1; const INITIAL_STATE: UseInTableSearchMatchesState = { - matchesList: DEFAULT_MATCHES, + matchesList: [], matchesCount: null, - activeMatchPosition: DEFAULT_ACTIVE_MATCH_POSITION, + activeMatch: null, + columns: [], isProcessing: false, - cellsShadowPortal: null, + calculatedForSearchTerm: null, + renderCellsShadowPortal: null, }; export const useInTableSearchMatches = ( props: UseInTableSearchMatchesProps ): UseInTableSearchMatchesReturn => { - const { visibleColumns, rows, inTableSearchTerm, renderCellValue, scrollToActiveMatch } = props; + const { visibleColumns, rows, inTableSearchTerm, renderCellValue } = props; const [state, setState] = useState(INITIAL_STATE); - const { matchesList, matchesCount, activeMatchPosition, isProcessing, cellsShadowPortal } = state; + const { + matchesCount, + activeMatch, + calculatedForSearchTerm, + isProcessing, + renderCellsShadowPortal, + } = state; const numberOfRunsRef = useRef(0); - const scrollToMatch = useCallback( - ({ - matchPosition, - activeMatchesList, - activeColumns, - shouldJump, - }: { - matchPosition: number; - activeMatchesList: RowMatches[]; - activeColumns: string[]; - shouldJump: boolean; - }) => { - let traversedMatchesCount = 0; - - for (const rowMatch of activeMatchesList) { - const rowIndex = rowMatch.rowIndex; - - if (traversedMatchesCount + rowMatch.rowMatchesCount < matchPosition) { - // going faster to next row - traversedMatchesCount += rowMatch.rowMatchesCount; - continue; - } - - const matchesCountPerField = rowMatch.matchesCountPerField; - - for (const fieldName of activeColumns) { - // going slow to next field within the row - const matchesCountForFieldName = matchesCountPerField[fieldName] ?? 0; - - if ( - traversedMatchesCount < matchPosition && - traversedMatchesCount + matchesCountForFieldName >= matchPosition - ) { - // can even go slower to next match within the field within the row - scrollToActiveMatch({ - rowIndex: Number(rowIndex), - fieldName, - matchIndex: matchPosition - traversedMatchesCount - 1, - shouldJump, - }); - return; - } - - traversedMatchesCount += matchesCountForFieldName; - } - } - }, - [scrollToActiveMatch] - ); - - const goToPrevMatch = useCallback(() => { - setState((prevState) => { - if (typeof prevState.matchesCount !== 'number') { - return prevState; - } - - let nextMatchPosition = prevState.activeMatchPosition - 1; - - if (nextMatchPosition < 1) { - nextMatchPosition = prevState.matchesCount; // allow to endlessly circle though matches - } - - scrollToMatch({ - matchPosition: nextMatchPosition, - activeMatchesList: matchesList, - activeColumns: visibleColumns, - shouldJump: true, - }); - - return { - ...prevState, - activeMatchPosition: nextMatchPosition, - }; - }); - }, [setState, scrollToMatch, matchesList, visibleColumns]); - - const goToNextMatch = useCallback(() => { - setState((prevState) => { - if (typeof prevState.matchesCount !== 'number') { - return prevState; - } - - let nextMatchPosition = prevState.activeMatchPosition + 1; - - if (nextMatchPosition > prevState.matchesCount) { - nextMatchPosition = 1; // allow to endlessly circle though matches - } - - scrollToMatch({ - matchPosition: nextMatchPosition, - activeMatchesList: matchesList, - activeColumns: visibleColumns, - shouldJump: true, - }); - - return { - ...prevState, - activeMatchPosition: nextMatchPosition, - }; - }); - }, [setState, scrollToMatch, matchesList, visibleColumns]); - useEffect(() => { numberOfRunsRef.current += 1; @@ -188,13 +92,13 @@ export const useInTableSearchMatches = ( setState((prevState) => ({ ...prevState, isProcessing: true, - cellsShadowPortal: ( + renderCellsShadowPortal: () => ( { if (numberOfRuns < numberOfRunsRef.current) { return; @@ -203,24 +107,32 @@ export const useInTableSearchMatches = ( setState({ matchesList: nextMatchesList, matchesCount: totalMatchesCount, - activeMatchPosition: DEFAULT_ACTIVE_MATCH_POSITION, + activeMatch: + totalMatchesCount > 0 + ? getActiveMatch({ + matchPosition: 1, + matchesList: nextMatchesList, + columns: visibleColumns, + }) + : null, + columns: visibleColumns, isProcessing: false, - cellsShadowPortal: null, + calculatedForSearchTerm: inTableSearchTerm, + renderCellsShadowPortal: null, }); - - if (totalMatchesCount > 0) { - scrollToMatch({ - matchPosition: DEFAULT_ACTIVE_MATCH_POSITION, - activeMatchesList: nextMatchesList, - activeColumns: visibleColumns, - shouldJump: true, - }); - } }} /> ), })); - }, [setState, renderCellValue, scrollToMatch, visibleColumns, rows, inTableSearchTerm]); + }, [setState, renderCellValue, visibleColumns, rows, inTableSearchTerm]); + + const goToPrevMatch = useCallback(() => { + setState((prevState) => changeActiveMatchInState(prevState, 'prev')); + }, [setState]); + + const goToNextMatch = useCallback(() => { + setState((prevState) => changeActiveMatchInState(prevState, 'next')); + }, [setState]); const resetState = useCallback(() => { stopTimer(latestTimeoutTimer); @@ -229,15 +141,90 @@ export const useInTableSearchMatches = ( return { matchesCount, - activeMatchPosition, + activeMatch, goToPrevMatch, goToNextMatch, resetState, isProcessing, - cellsShadowPortal, + calculatedForSearchTerm, + renderCellsShadowPortal, }; }; +function getActiveMatch({ + matchPosition, + matchesList, + columns, +}: { + matchPosition: number; + matchesList: RowMatches[]; + columns: string[]; +}): UseInTableSearchMatchesState['activeMatch'] { + 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 { + position: matchPosition, + rowIndex: Number(rowIndex), + columnId, + matchIndexWithinCell: matchPosition - traversedMatchesCount - 1, + }; + } + + traversedMatchesCount += matchesCountInCell; + } + } + + // no match found for the requested position + return null; +} + +function changeActiveMatchInState( + prevState: UseInTableSearchMatchesState, + direction: 'prev' | 'next' +): UseInTableSearchMatchesState { + if (typeof prevState.matchesCount !== 'number' || !prevState.activeMatch) { + return prevState; + } + + let nextMatchPosition = + direction === 'prev' ? prevState.activeMatch.position - 1 : prevState.activeMatch.position + 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 + } + + return { + ...prevState, + activeMatch: getActiveMatch({ + matchPosition: nextMatchPosition, + matchesList: prevState.matchesList, + columns: prevState.columns, + }), + }; +} + type AllCellsProps = Pick< UseInTableSearchMatchesProps, 'inTableSearchTerm' | 'renderCellValue' | 'rows' | 'visibleColumns' @@ -254,7 +241,7 @@ function AllCellsHighlightsCounter( const remainingNumberOfResultsRef = useRef(rows.length * visibleColumns.length); const onHighlightsCountFound = useCallback( - (rowIndex: number, fieldName: string, count: number) => { + (rowIndex: number, columnId: string, count: number) => { remainingNumberOfResultsRef.current = remainingNumberOfResultsRef.current - 1; if (count === 0) { @@ -264,7 +251,7 @@ function AllCellsHighlightsCounter( if (!resultsMapRef.current[rowIndex]) { resultsMapRef.current[rowIndex] = {}; } - resultsMapRef.current[rowIndex][fieldName] = count; + resultsMapRef.current[rowIndex][columnId] = count; }, [] ); @@ -277,8 +264,8 @@ function AllCellsHighlightsCounter( .map((rowIndex) => Number(rowIndex)) .sort((a, b) => a - b) .forEach((rowIndex) => { - const matchesCountPerField = resultsMapRef.current[rowIndex]; - const rowMatchesCount = Object.values(matchesCountPerField).reduce( + const matchesCountPerColumnId = resultsMapRef.current[rowIndex]; + const rowMatchesCount = Object.values(matchesCountPerColumnId).reduce( (acc, count) => acc + count, 0 ); @@ -286,7 +273,7 @@ function AllCellsHighlightsCounter( newMatchesList.push({ rowIndex, rowMatchesCount, - matchesCountPerField, + matchesCountPerColumnId, }); totalMatchesCount += rowMatchesCount; }); @@ -310,8 +297,8 @@ function AllCellsHighlightsCounter( return createPortal( { - onHighlightsCountFound(rowIndex, fieldName, count); + onHighlightsCountFound={(rowIndex, columnId, count) => { + onHighlightsCountFound(rowIndex, columnId, count); if (remainingNumberOfResultsRef.current === 0) { stopTimer(timerRef.current); @@ -330,42 +317,39 @@ function AllCells({ renderCellValue, onHighlightsCountFound, }: AllCellsProps & { - onHighlightsCountFound: (rowIndex: number, fieldName: string, count: number) => void; + onHighlightsCountFound: (rowIndex: number, columnId: string, count: number) => void; }) { const UnifiedDataTableRenderCellValue = renderCellValue; - const contextValue = useMemo( - () => ({ inTableSearchTerm }), - [inTableSearchTerm] - ); return ( - + <> {(rows || []).flatMap((_, rowIndex) => { - return visibleColumns.map((fieldName) => { + return visibleColumns.map((columnId) => { return ( { - onHighlightsCountFound(rowIndex, fieldName, 0); + onHighlightsCountFound(rowIndex, columnId, 0); }} > {}} + inTableSearchTerm={inTableSearchTerm} onHighlightsCountFound={(count) => { - onHighlightsCountFound(rowIndex, fieldName, count); + onHighlightsCountFound(rowIndex, columnId, count); }} /> ); }); })} - + ); } diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 8b922a2f59a4c..838bbae049ced 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -27,7 +27,6 @@ import { DataTablePopoverCellValue } from '../components/data_table_cell_value'; import { InTableSearchHighlightsWrapper, InTableSearchHighlightsWrapperProps, - InTableSearchContext, } from '../components/in_table_search'; export const CELL_CLASS = 'unifiedDataTable__cellValue'; @@ -63,13 +62,13 @@ export const getRenderCellValueFn = ({ colIndex, isExpandable, isExpanded, + inTableSearchTerm, onHighlightsCountFound, }: EuiDataGridCellValueElementProps & - Pick) => { + Pick) => { const row = rows ? rows[rowIndex] : undefined; const field = dataView.fields.getByName(columnId); const ctx = useContext(UnifiedDataTableContext); - const { inTableSearchTerm } = useContext(InTableSearchContext); useEffect(() => { if (row?.isAnchor) { @@ -169,7 +168,7 @@ export const getRenderCellValueFn = ({ return ( From 938ba084f245a3690b8619e0b73d7a7f7f3ae2de Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 19:40:20 +0100 Subject: [PATCH 49/90] [Discover] Change back to a callback --- .../in_table_search_control.tsx | 383 +++++++++--------- .../use_in_table_search_matches.tsx | 102 +++-- 2 files changed, 259 insertions(+), 226 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 45001108eeef6..5f5697b453854 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -23,7 +23,6 @@ import { css, type SerializedStyles } from '@emotion/react'; import { useInTableSearchMatches, UseInTableSearchMatchesProps, - UseInTableSearchMatchesReturn, } from './use_in_table_search_matches'; import './in_table_search.scss'; @@ -39,58 +38,22 @@ export interface InTableSearchControlProps onChangeToExpectedPage: (pageIndex: number) => void; } -export const InTableSearchControl: React.FC = React.memo( - ({ - pageSize, - scrollToCell, - shouldOverrideCmdF, - onChange, - onChangeCss, - onChangeToExpectedPage, - ...props - }) => { - const inputRef = useRef(null); - const [buttonNode, setButtonNode] = useState(null); - const shouldReturnFocusToButtonRef = useRef(false); - const [isFocused, setIsFocused] = useState(false); - const processedActiveMatchRef = useRef(null); - - const [inTableSearchTerm, setInTableSearchTerm] = useState(''); - const onChangeSearchTerm = useCallback( - (value: string) => { - // sending the value to the grid and to the hook, so they can process it hopefully in parallel - onChange(value); - setInTableSearchTerm(value); - }, - [onChange, setInTableSearchTerm] - ); - const { inputValue, handleInputChange } = useDebouncedValue({ - onChange: onChangeSearchTerm, - value: inTableSearchTerm, - }); - - const { - matchesCount, - activeMatch, - isProcessing, - renderCellsShadowPortal, - goToPrevMatch, - goToNextMatch, - resetState, - } = useInTableSearchMatches({ ...props, inTableSearchTerm }); - - const isSearching = isProcessing; - const areArrowsDisabled = !matchesCount || isSearching; - - useEffect(() => { - if (!activeMatch || processedActiveMatchRef.current === activeMatch) { - return; - } - - processedActiveMatchRef.current = activeMatch; // prevent multiple executions - - const { rowIndex, columnId, matchIndexWithinCell } = activeMatch; +export const InTableSearchControl: React.FC = ({ + pageSize, + scrollToCell, + shouldOverrideCmdF, + onChange, + onChangeCss, + onChangeToExpectedPage, + ...props +}) => { + const inputRef = useRef(null); + const [buttonNode, setButtonNode] = useState(null); + const shouldReturnFocusToButtonRef = useRef(false); + const [isFocused, setIsFocused] = useState(false); + const onScrollToActiveMatch: UseInTableSearchMatchesProps['onScrollToActiveMatch'] = useCallback( + ({ rowIndex, columnId, matchIndexWithinCell }) => { if (typeof pageSize === 'number') { const expectedPageIndex = Math.floor(rowIndex / pageSize); onChangeToExpectedPage(expectedPageIndex); @@ -119,166 +82,200 @@ export const InTableSearchControl: React.FC = React.m columnIndex: Number(columnIndex), align: 'smart', }); - }, [activeMatch, scrollToCell, onChange, onChangeCss, onChangeToExpectedPage, pageSize]); + }, + [scrollToCell, onChangeCss, onChangeToExpectedPage, pageSize] + ); - const focusInput = useCallback(() => { - setIsFocused(true); - }, [setIsFocused]); + const [inTableSearchTerm, setInTableSearchTerm] = useState(''); + const onChangeSearchTerm = useCallback( + (value: string) => { + // sending the value to the grid and to the hook, so they can process it hopefully in parallel + onChange(value); + setInTableSearchTerm(value); + }, + [onChange, setInTableSearchTerm] + ); + const { inputValue, handleInputChange } = useDebouncedValue({ + onChange: onChangeSearchTerm, + value: inTableSearchTerm, + }); - const hideInput = useCallback( - (shouldReturnFocusToButton: boolean = false) => { - setIsFocused(false); - resetState(); - shouldReturnFocusToButtonRef.current = shouldReturnFocusToButton; - }, - [setIsFocused, resetState] - ); + const { + matchesCount, + activeMatchPosition, + isProcessing, + renderCellsShadowPortal, + goToPrevMatch, + goToNextMatch, + resetState, + } = useInTableSearchMatches({ ...props, inTableSearchTerm, onScrollToActiveMatch }); - const onInputChange = useCallback( - (event: ChangeEvent) => { - handleInputChange(event.target.value); - }, - [handleInputChange] - ); + const isSearching = isProcessing; + const areArrowsDisabled = !matchesCount || isSearching; - const onKeyUp = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === keys.ESCAPE) { - hideInput(true); - return; - } + const focusInput = useCallback(() => { + setIsFocused(true); + }, [setIsFocused]); - if (areArrowsDisabled) { - return; - } + const hideInput = useCallback( + (shouldReturnFocusToButton: boolean = false) => { + setIsFocused(false); + resetState(); + shouldReturnFocusToButtonRef.current = shouldReturnFocusToButton; + }, + [setIsFocused, resetState] + ); - if (event.key === keys.ENTER && event.shiftKey) { - goToPrevMatch(); - } else if (event.key === keys.ENTER) { - goToNextMatch(); - } - }, - [goToPrevMatch, goToNextMatch, hideInput, areArrowsDisabled] - ); + const onInputChange = useCallback( + (event: ChangeEvent) => { + handleInputChange(event.target.value); + }, + [handleInputChange] + ); - const onBlur = useCallback( - (event: FocusEvent) => { - if ( - (!event.relatedTarget || - event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && - !inputValue - ) { - hideInput(); - } - }, - [hideInput, inputValue] - ); + const onKeyUp = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === keys.ESCAPE) { + hideInput(true); + return; + } - 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 - focusInput(); - inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it - } - }; + if (areArrowsDisabled) { + return; + } - document.addEventListener('keydown', handleGlobalKeyDown); + if (event.key === keys.ENTER && event.shiftKey) { + goToPrevMatch(); + } else if (event.key === keys.ENTER) { + goToNextMatch(); + } + }, + [goToPrevMatch, goToNextMatch, hideInput, areArrowsDisabled] + ); - // Cleanup the event listener - return () => { - document.removeEventListener('keydown', handleGlobalKeyDown); - }; - }, [focusInput, shouldOverrideCmdF]); + const onBlur = useCallback( + (event: FocusEvent) => { + if ( + (!event.relatedTarget || + event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && + !inputValue + ) { + hideInput(); + } + }, + [hideInput, inputValue] + ); - const shouldRenderButton = !isFocused && !inputValue; + const onSetInputRef = useCallback((node: HTMLInputElement | null) => { + inputRef.current = node; + }, []); - useEffect(() => { - if (shouldReturnFocusToButtonRef.current && buttonNode && shouldRenderButton) { - shouldReturnFocusToButtonRef.current = false; - buttonNode.focus(); + 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 + focusInput(); + inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it } - }, [buttonNode, shouldRenderButton]); + }; - if (shouldRenderButton) { - return ( - - - - ); + document.addEventListener('keydown', handleGlobalKeyDown); + + // Cleanup the event listener + return () => { + document.removeEventListener('keydown', handleGlobalKeyDown); + }; + }, [focusInput, shouldOverrideCmdF]); + + const shouldRenderButton = !isFocused && !inputValue; + + useEffect(() => { + if (shouldReturnFocusToButtonRef.current && buttonNode && shouldRenderButton) { + shouldReturnFocusToButtonRef.current = false; + buttonNode.focus(); } + }, [buttonNode, shouldRenderButton]); + if (shouldRenderButton) { return ( -
- (inputRef.current = node)} - autoFocus - compressed - className="unifiedDataTable__inTableSearchInput" - isClearable={!isSearching} - isLoading={isSearching} - append={ - - - - {matchesCount && activeMatch ? `${activeMatch.position}/${matchesCount}` : '0/0'} -   - - - - - - - - - - } - placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { + + - {renderCellsShadowPortal ? renderCellsShadowPortal() : null} -
+ ); } -); + + return ( +
+ + + + {matchesCount && activeMatchPosition + ? `${activeMatchPosition}/${matchesCount}` + : '0/0'} +   + + + + + + + + + + } + placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { + defaultMessage: 'Search in the table', + })} + value={inputValue} + onChange={onInputChange} + onKeyUp={onKeyUp} + onBlur={onBlur} + /> + {renderCellsShadowPortal ? renderCellsShadowPortal() : null} +
+ ); +}; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 47cc0175cb63e..93d9a9c26ed6d 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -21,6 +21,12 @@ interface RowMatches { matchesCountPerColumnId: Record; } +interface ActiveMatch { + rowIndex: number; + columnId: string; + matchIndexWithinCell: number; +} + export interface UseInTableSearchMatchesProps { visibleColumns: string[]; rows: DataTableRecord[]; @@ -29,17 +35,13 @@ export interface UseInTableSearchMatchesProps { props: EuiDataGridCellValueElementProps & Pick ) => ReactNode; + onScrollToActiveMatch: (activeMatch: ActiveMatch) => void; } interface UseInTableSearchMatchesState { matchesList: RowMatches[]; matchesCount: number | null; - activeMatch: { - position: number; - rowIndex: number; - columnId: string; - matchIndexWithinCell: number; - } | null; + activeMatchPosition: number | null; columns: string[]; isProcessing: boolean; calculatedForSearchTerm: string | null; @@ -56,7 +58,7 @@ export interface UseInTableSearchMatchesReturn const INITIAL_STATE: UseInTableSearchMatchesState = { matchesList: [], matchesCount: null, - activeMatch: null, + activeMatchPosition: null, columns: [], isProcessing: false, calculatedForSearchTerm: null, @@ -66,11 +68,11 @@ const INITIAL_STATE: UseInTableSearchMatchesState = { export const useInTableSearchMatches = ( props: UseInTableSearchMatchesProps ): UseInTableSearchMatchesReturn => { - const { visibleColumns, rows, inTableSearchTerm, renderCellValue } = props; + const { visibleColumns, rows, inTableSearchTerm, renderCellValue, onScrollToActiveMatch } = props; const [state, setState] = useState(INITIAL_STATE); const { matchesCount, - activeMatch, + activeMatchPosition, calculatedForSearchTerm, isProcessing, renderCellsShadowPortal, @@ -104,35 +106,38 @@ export const useInTableSearchMatches = ( return; } + const nextActiveMatchPosition = totalMatchesCount > 0 ? 1 : null; setState({ matchesList: nextMatchesList, matchesCount: totalMatchesCount, - activeMatch: - totalMatchesCount > 0 - ? getActiveMatch({ - matchPosition: 1, - matchesList: nextMatchesList, - columns: visibleColumns, - }) - : null, + activeMatchPosition: nextActiveMatchPosition, columns: visibleColumns, isProcessing: false, calculatedForSearchTerm: inTableSearchTerm, renderCellsShadowPortal: null, }); + + if (totalMatchesCount > 0) { + updateActiveMatchPosition({ + matchPosition: nextActiveMatchPosition, + matchesList: nextMatchesList, + columns: visibleColumns, + onScrollToActiveMatch, + }); + } }} /> ), })); - }, [setState, renderCellValue, visibleColumns, rows, inTableSearchTerm]); + }, [setState, renderCellValue, visibleColumns, rows, inTableSearchTerm, onScrollToActiveMatch]); const goToPrevMatch = useCallback(() => { - setState((prevState) => changeActiveMatchInState(prevState, 'prev')); - }, [setState]); + setState((prevState) => changeActiveMatchInState(prevState, 'prev', onScrollToActiveMatch)); + }, [setState, onScrollToActiveMatch]); const goToNextMatch = useCallback(() => { - setState((prevState) => changeActiveMatchInState(prevState, 'next')); - }, [setState]); + setState((prevState) => changeActiveMatchInState(prevState, 'next', onScrollToActiveMatch)); + }, [setState, onScrollToActiveMatch]); const resetState = useCallback(() => { stopTimer(latestTimeoutTimer); @@ -141,7 +146,7 @@ export const useInTableSearchMatches = ( return { matchesCount, - activeMatch, + activeMatchPosition, goToPrevMatch, goToNextMatch, resetState, @@ -159,7 +164,7 @@ function getActiveMatch({ matchPosition: number; matchesList: RowMatches[]; columns: string[]; -}): UseInTableSearchMatchesState['activeMatch'] { +}): ActiveMatch | null { let traversedMatchesCount = 0; for (const rowMatch of matchesList) { @@ -183,7 +188,6 @@ function getActiveMatch({ ) { // can even go slower to next match within the cell return { - position: matchPosition, rowIndex: Number(rowIndex), columnId, matchIndexWithinCell: matchPosition - traversedMatchesCount - 1, @@ -198,16 +202,45 @@ function getActiveMatch({ return null; } +function updateActiveMatchPosition({ + matchPosition, + matchesList, + columns, + onScrollToActiveMatch, +}: { + matchPosition: number | null; + matchesList: RowMatches[]; + columns: string[]; + onScrollToActiveMatch: (activeMatch: ActiveMatch) => void; +}) { + if (typeof matchPosition !== 'number') { + return; + } + + setTimeout(() => { + const activeMatch = getActiveMatch({ + matchPosition, + matchesList, + columns, + }); + + if (activeMatch) { + onScrollToActiveMatch(activeMatch); + } + }, 0); +} + function changeActiveMatchInState( prevState: UseInTableSearchMatchesState, - direction: 'prev' | 'next' + direction: 'prev' | 'next', + onScrollToActiveMatch: (activeMatch: ActiveMatch) => void ): UseInTableSearchMatchesState { - if (typeof prevState.matchesCount !== 'number' || !prevState.activeMatch) { + if (typeof prevState.matchesCount !== 'number' || !prevState.activeMatchPosition) { return prevState; } let nextMatchPosition = - direction === 'prev' ? prevState.activeMatch.position - 1 : prevState.activeMatch.position + 1; + direction === 'prev' ? prevState.activeMatchPosition - 1 : prevState.activeMatchPosition + 1; if (nextMatchPosition < 1) { nextMatchPosition = prevState.matchesCount; // allow to endlessly circle though matches @@ -215,13 +248,16 @@ function changeActiveMatchInState( nextMatchPosition = 1; // allow to endlessly circle though matches } + updateActiveMatchPosition({ + matchPosition: nextMatchPosition, + matchesList: prevState.matchesList, + columns: prevState.columns, + onScrollToActiveMatch, + }); + return { ...prevState, - activeMatch: getActiveMatch({ - matchPosition: nextMatchPosition, - matchesList: prevState.matchesList, - columns: prevState.columns, - }), + activeMatchPosition: nextMatchPosition, }; } From 96ae264565cb6a3905a957ee574e7c007e66f410 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 20 Jan 2025 19:42:02 +0100 Subject: [PATCH 50/90] [Discover] Update tests --- .../src/utils/get_render_cell_value.test.tsx | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx index fc4b45e1e3a63..8ac1f86d8ee80 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx @@ -246,10 +246,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(componentWithDetails).toMatchInlineSnapshot(` - + Date: Tue, 21 Jan 2025 09:51:30 +0100 Subject: [PATCH 51/90] [Discover] Some updates --- .../in_table_search_control.tsx | 172 ++++-------------- .../in_table_search_highlights_wrapper.tsx | 26 +-- .../in_table_search/in_table_search_input.tsx | 144 +++++++++++++++ .../use_in_table_search_matches.tsx | 68 ++++--- 4 files changed, 238 insertions(+), 172 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 5f5697b453854..87eb27b75edfd 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -7,29 +7,21 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { ChangeEvent, FocusEvent, useCallback, useState, useEffect, useRef } from 'react'; -import { - EuiFieldSearch, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiToolTip, - EuiText, - keys, -} from '@elastic/eui'; -import { useDebouncedValue } from '@kbn/visualization-utils'; +import React, { useCallback, useState, useEffect, useRef } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css, type SerializedStyles } from '@emotion/react'; import { useInTableSearchMatches, UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; +import { InTableSearchInput } from './in_table_search_input'; import './in_table_search.scss'; const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; export interface InTableSearchControlProps - extends Omit { + extends Omit { pageSize: number | null; // null when the pagination is disabled scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'smart' }) => void; shouldOverrideCmdF: (element: HTMLElement) => boolean; @@ -47,8 +39,7 @@ export const InTableSearchControl: React.FC = ({ onChangeToExpectedPage, ...props }) => { - const inputRef = useRef(null); - const [buttonNode, setButtonNode] = useState(null); + // const [buttonNode, setButtonNode] = useState(null); const shouldReturnFocusToButtonRef = useRef(false); const [isFocused, setIsFocused] = useState(false); @@ -86,32 +77,29 @@ export const InTableSearchControl: React.FC = ({ [scrollToCell, onChangeCss, onChangeToExpectedPage, pageSize] ); - const [inTableSearchTerm, setInTableSearchTerm] = useState(''); - const onChangeSearchTerm = useCallback( - (value: string) => { - // sending the value to the grid and to the hook, so they can process it hopefully in parallel - onChange(value); - setInTableSearchTerm(value); - }, - [onChange, setInTableSearchTerm] - ); - const { inputValue, handleInputChange } = useDebouncedValue({ - onChange: onChangeSearchTerm, - value: inTableSearchTerm, - }); - const { matchesCount, activeMatchPosition, isProcessing, - renderCellsShadowPortal, goToPrevMatch, goToNextMatch, + renderCellsShadowPortal, resetState, - } = useInTableSearchMatches({ ...props, inTableSearchTerm, onScrollToActiveMatch }); + onChangeInTableSearchTerm, + } = useInTableSearchMatches({ ...props, onScrollToActiveMatch }); - const isSearching = isProcessing; - const areArrowsDisabled = !matchesCount || isSearching; + const onChangeSearchTerm = useCallback( + (value: string) => { + // sending the value to the grid and to the hook, so they can process it hopefully in parallel + setTimeout(() => { + onChange(value); + }, 0); + setTimeout(() => { + onChangeInTableSearchTerm(value); + }, 0); + }, + [onChange, onChangeInTableSearchTerm] + ); const focusInput = useCallback(() => { setIsFocused(true); @@ -126,50 +114,6 @@ export const InTableSearchControl: React.FC = ({ [setIsFocused, resetState] ); - const onInputChange = useCallback( - (event: ChangeEvent) => { - handleInputChange(event.target.value); - }, - [handleInputChange] - ); - - const onKeyUp = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === keys.ESCAPE) { - hideInput(true); - return; - } - - if (areArrowsDisabled) { - return; - } - - if (event.key === keys.ENTER && event.shiftKey) { - goToPrevMatch(); - } else if (event.key === keys.ENTER) { - goToNextMatch(); - } - }, - [goToPrevMatch, goToNextMatch, hideInput, areArrowsDisabled] - ); - - const onBlur = useCallback( - (event: FocusEvent) => { - if ( - (!event.relatedTarget || - event.relatedTarget.getAttribute('data-test-subj') !== 'clearSearchButton') && - !inputValue - ) { - hideInput(); - } - }, - [hideInput, inputValue] - ); - - const onSetInputRef = useCallback((node: HTMLInputElement | null) => { - inputRef.current = node; - }, []); - useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { if ( @@ -179,7 +123,8 @@ export const InTableSearchControl: React.FC = ({ ) { event.preventDefault(); // prevent default browser find-in-page behavior focusInput(); - inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it + // TODO: refactor + // inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it } }; @@ -191,14 +136,15 @@ export const InTableSearchControl: React.FC = ({ }; }, [focusInput, shouldOverrideCmdF]); - const shouldRenderButton = !isFocused && !inputValue; + const shouldRenderButton = !isFocused; - useEffect(() => { - if (shouldReturnFocusToButtonRef.current && buttonNode && shouldRenderButton) { - shouldReturnFocusToButtonRef.current = false; - buttonNode.focus(); - } - }, [buttonNode, shouldRenderButton]); + // TODO: refactor + // useEffect(() => { + // if (shouldReturnFocusToButtonRef.current && buttonNode && shouldRenderButton) { + // shouldReturnFocusToButtonRef.current = false; + // buttonNode.focus(); + // } + // }, [buttonNode, shouldRenderButton]); if (shouldRenderButton) { return ( @@ -209,7 +155,7 @@ export const InTableSearchControl: React.FC = ({ delay="long" > = ({ return (
- - - - {matchesCount && activeMatchPosition - ? `${activeMatchPosition}/${matchesCount}` - : '0/0'} -   - - - - - - - - - - } - placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { - defaultMessage: 'Search in the table', - })} - value={inputValue} - onChange={onInputChange} - onKeyUp={onKeyUp} - onBlur={onBlur} + {renderCellsShadowPortal ? renderCellsShadowPortal() : null}
diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx index a47ebfcd228e7..9ac92a0e67aac 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx @@ -31,12 +31,15 @@ export const InTableSearchHighlightsWrapper: React.FC { + const count = modifyDOMAndAddSearchHighlights( + cellNode, + inTableSearchTerm, + Boolean(onHighlightsCountFound) + ); + onHighlightsCountFound?.(count); + }, 0); } }, [inTableSearchTerm, onHighlightsCountFound]); @@ -63,17 +66,16 @@ function modifyDOMAndAddSearchHighlights( if (node.nodeType === Node.TEXT_NODE) { const nodeWithText = node as Text; - const parts = (nodeWithText.textContent || '').split(searchTermRegExp); + const textContent = nodeWithText.textContent || ''; if (dryRun) { - parts.forEach((part) => { - if (searchTermRegExp.test(part)) { - matchIndex++; - } - }); + const nodeMatchesCount = (textContent.match(searchTermRegExp) || []).length; + matchIndex += nodeMatchesCount; return; } + const parts = textContent.split(searchTermRegExp); + if (parts.length > 1) { const nodeWithHighlights = document.createDocumentFragment(); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx new file mode 100644 index 0000000000000..9439771fb6d38 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx @@ -0,0 +1,144 @@ +/* + * 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, useMemo, useState } from 'react'; +import { + EuiButtonIcon, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiText, + keys, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; + +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, setInputValue] = useState(''); + + const debouncedOnChangeSearchTerm = useMemo( + () => + debounce(onChangeSearchTerm, 300, { + leading: false, + trailing: true, + }), + [onChangeSearchTerm] + ); + + const onInputChange = useCallback( + (event: ChangeEvent) => { + const nextValue = event.target.value; + setInputValue(nextValue); + debouncedOnChangeSearchTerm(nextValue); + }, + [setInputValue, debouncedOnChangeSearchTerm] + ); + + 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('unifiedDataTable.inTableSearch.inputPlaceholder', { + defaultMessage: 'Search in the table', + })} + value={inputValue} + onChange={onInputChange} + onKeyUp={onKeyUp} + onBlur={onBlur} + /> + ); + } +); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 93d9a9c26ed6d..a1ced43adeb36 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useEffect, useState, ReactNode, useRef } from 'react'; -import { createPortal } from 'react-dom'; +import React, { useCallback, useEffect, useState, ReactNode, useRef, useMemo } from 'react'; +import { createPortal, unmountComponentAtNode } from 'react-dom'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; @@ -30,7 +30,6 @@ interface ActiveMatch { export interface UseInTableSearchMatchesProps { visibleColumns: string[]; rows: DataTableRecord[]; - inTableSearchTerm: string; renderCellValue: ( props: EuiDataGridCellValueElementProps & Pick @@ -44,7 +43,6 @@ interface UseInTableSearchMatchesState { activeMatchPosition: number | null; columns: string[]; isProcessing: boolean; - calculatedForSearchTerm: string | null; renderCellsShadowPortal: (() => ReactNode) | null; } @@ -53,6 +51,7 @@ export interface UseInTableSearchMatchesReturn goToPrevMatch: () => void; goToNextMatch: () => void; resetState: () => void; + onChangeInTableSearchTerm: (searchTerm: string) => void; } const INITIAL_STATE: UseInTableSearchMatchesState = { @@ -61,22 +60,16 @@ const INITIAL_STATE: UseInTableSearchMatchesState = { activeMatchPosition: null, columns: [], isProcessing: false, - calculatedForSearchTerm: null, renderCellsShadowPortal: null, }; export const useInTableSearchMatches = ( props: UseInTableSearchMatchesProps ): UseInTableSearchMatchesReturn => { - const { visibleColumns, rows, inTableSearchTerm, renderCellValue, onScrollToActiveMatch } = props; + const [inTableSearchTerm, setInTableSearchTerm] = useState(''); + const { visibleColumns, rows, renderCellValue, onScrollToActiveMatch } = props; const [state, setState] = useState(INITIAL_STATE); - const { - matchesCount, - activeMatchPosition, - calculatedForSearchTerm, - isProcessing, - renderCellsShadowPortal, - } = state; + const { matchesCount, activeMatchPosition, isProcessing, renderCellsShadowPortal } = state; const numberOfRunsRef = useRef(0); useEffect(() => { @@ -113,7 +106,6 @@ export const useInTableSearchMatches = ( activeMatchPosition: nextActiveMatchPosition, columns: visibleColumns, isProcessing: false, - calculatedForSearchTerm: inTableSearchTerm, renderCellsShadowPortal: null, }); @@ -144,16 +136,27 @@ export const useInTableSearchMatches = ( setState(INITIAL_STATE); }, [setState]); - return { - matchesCount, - activeMatchPosition, - goToPrevMatch, - goToNextMatch, - resetState, - isProcessing, - calculatedForSearchTerm, - renderCellsShadowPortal, - }; + return useMemo( + () => ({ + matchesCount, + activeMatchPosition, + goToPrevMatch, + goToNextMatch, + resetState, + isProcessing, + renderCellsShadowPortal, + onChangeInTableSearchTerm: setInTableSearchTerm, + }), + [ + matchesCount, + activeMatchPosition, + goToPrevMatch, + goToNextMatch, + resetState, + isProcessing, + renderCellsShadowPortal, + ] + ); }; function getActiveMatch({ @@ -235,7 +238,11 @@ function changeActiveMatchInState( direction: 'prev' | 'next', onScrollToActiveMatch: (activeMatch: ActiveMatch) => void ): UseInTableSearchMatchesState { - if (typeof prevState.matchesCount !== 'number' || !prevState.activeMatchPosition) { + if ( + typeof prevState.matchesCount !== 'number' || + !prevState.activeMatchPosition || + prevState.isProcessing + ) { return prevState; } @@ -263,8 +270,8 @@ function changeActiveMatchInState( type AllCellsProps = Pick< UseInTableSearchMatchesProps, - 'inTableSearchTerm' | 'renderCellValue' | 'rows' | 'visibleColumns' ->; + 'renderCellValue' | 'rows' | 'visibleColumns' +> & { inTableSearchTerm: string }; function AllCellsHighlightsCounter( props: AllCellsProps & { @@ -272,6 +279,9 @@ function AllCellsHighlightsCounter( } ) { const [container] = useState(() => document.createDocumentFragment()); + const containerRef = useRef(); + containerRef.current = container; + const { rows, visibleColumns, onFinish } = props; const resultsMapRef = useRef>>({}); const remainingNumberOfResultsRef = useRef(rows.length * visibleColumns.length); @@ -327,6 +337,10 @@ function AllCellsHighlightsCounter( useEffect(() => { return () => { stopTimer(timerRef.current); + + if (containerRef.current) { + unmountComponentAtNode(containerRef.current); + } }; }, []); From d3d207bbe3827fbd031a3a3eb8f7096291349619 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 10:03:59 +0100 Subject: [PATCH 52/90] [Discover] Change back to updating grid first --- .../src/components/data_table.tsx | 2 ++ .../in_table_search_control.tsx | 16 +------------ .../in_table_search/in_table_search_input.tsx | 23 +++++++------------ .../use_in_table_search_matches.tsx | 6 ++--- 4 files changed, 13 insertions(+), 34 deletions(-) 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 dc97b4609bace..fd474acdc3f34 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 @@ -759,6 +759,7 @@ export const UnifiedDataTable = ({ } return ( { diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 87eb27b75edfd..22c6f31369ba3 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -85,22 +85,8 @@ export const InTableSearchControl: React.FC = ({ goToNextMatch, renderCellsShadowPortal, resetState, - onChangeInTableSearchTerm, } = useInTableSearchMatches({ ...props, onScrollToActiveMatch }); - const onChangeSearchTerm = useCallback( - (value: string) => { - // sending the value to the grid and to the hook, so they can process it hopefully in parallel - setTimeout(() => { - onChange(value); - }, 0); - setTimeout(() => { - onChangeInTableSearchTerm(value); - }, 0); - }, - [onChange, onChangeInTableSearchTerm] - ); - const focusInput = useCallback(() => { setIsFocused(true); }, [setIsFocused]); @@ -178,7 +164,7 @@ export const InTableSearchControl: React.FC = ({ isProcessing={isProcessing} goToPrevMatch={goToPrevMatch} goToNextMatch={goToNextMatch} - onChangeSearchTerm={onChangeSearchTerm} + onChangeSearchTerm={onChange} onHideInput={hideInput} /> {renderCellsShadowPortal ? renderCellsShadowPortal() : null} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx index 9439771fb6d38..9b4a8a456a01b 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { ChangeEvent, FocusEvent, useCallback, useMemo, useState } from 'react'; +import React, { ChangeEvent, FocusEvent, useCallback } from 'react'; import { EuiButtonIcon, EuiFieldSearch, @@ -17,7 +17,7 @@ import { keys, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { debounce } from 'lodash'; +import { useDebouncedValue } from '@kbn/visualization-utils'; export interface InTableSearchInputProps { matchesCount: number | null; @@ -39,24 +39,17 @@ export const InTableSearchInput: React.FC = React.memo( onChangeSearchTerm, onHideInput, }) => { - const [inputValue, setInputValue] = useState(''); - - const debouncedOnChangeSearchTerm = useMemo( - () => - debounce(onChangeSearchTerm, 300, { - leading: false, - trailing: true, - }), - [onChangeSearchTerm] - ); + const { inputValue, handleInputChange } = useDebouncedValue({ + onChange: onChangeSearchTerm, + value: '', + }); const onInputChange = useCallback( (event: ChangeEvent) => { const nextValue = event.target.value; - setInputValue(nextValue); - debouncedOnChangeSearchTerm(nextValue); + handleInputChange(nextValue); }, - [setInputValue, debouncedOnChangeSearchTerm] + [handleInputChange] ); const onKeyUp = useCallback( diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index a1ced43adeb36..171552ab20131 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -28,6 +28,7 @@ interface ActiveMatch { } export interface UseInTableSearchMatchesProps { + inTableSearchTerm: string; visibleColumns: string[]; rows: DataTableRecord[]; renderCellValue: ( @@ -51,7 +52,6 @@ export interface UseInTableSearchMatchesReturn goToPrevMatch: () => void; goToNextMatch: () => void; resetState: () => void; - onChangeInTableSearchTerm: (searchTerm: string) => void; } const INITIAL_STATE: UseInTableSearchMatchesState = { @@ -66,8 +66,7 @@ const INITIAL_STATE: UseInTableSearchMatchesState = { export const useInTableSearchMatches = ( props: UseInTableSearchMatchesProps ): UseInTableSearchMatchesReturn => { - const [inTableSearchTerm, setInTableSearchTerm] = useState(''); - const { visibleColumns, rows, renderCellValue, onScrollToActiveMatch } = props; + const { inTableSearchTerm, visibleColumns, rows, renderCellValue, onScrollToActiveMatch } = props; const [state, setState] = useState(INITIAL_STATE); const { matchesCount, activeMatchPosition, isProcessing, renderCellsShadowPortal } = state; const numberOfRunsRef = useRef(0); @@ -145,7 +144,6 @@ export const useInTableSearchMatches = ( resetState, isProcessing, renderCellsShadowPortal, - onChangeInTableSearchTerm: setInTableSearchTerm, }), [ matchesCount, From 2cb0c29ca54e95a78325dac8bd71a918c4764279 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 10:32:11 +0100 Subject: [PATCH 53/90] [Discover] Avoid blocking the main thread --- .../use_in_table_search_matches.tsx | 219 +++++++++--------- 1 file changed, 104 insertions(+), 115 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 171552ab20131..f0431a3f0dd4f 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -13,8 +13,6 @@ import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; -let latestTimeoutTimer: NodeJS.Timeout | null = null; - interface RowMatches { rowIndex: number; rowMatchesCount: number; @@ -81,8 +79,6 @@ export const useInTableSearchMatches = ( const numberOfRuns = numberOfRunsRef.current; - stopTimer(latestTimeoutTimer); - setState((prevState) => ({ ...prevState, isProcessing: true, @@ -90,7 +86,7 @@ export const useInTableSearchMatches = ( { @@ -131,7 +127,6 @@ export const useInTableSearchMatches = ( }, [setState, onScrollToActiveMatch]); const resetState = useCallback(() => { - stopTimer(latestTimeoutTimer); setState(INITIAL_STATE); }, [setState]); @@ -268,8 +263,8 @@ function changeActiveMatchInState( type AllCellsProps = Pick< UseInTableSearchMatchesProps, - 'renderCellValue' | 'rows' | 'visibleColumns' -> & { inTableSearchTerm: string }; + 'renderCellValue' | 'visibleColumns' | 'inTableSearchTerm' +> & { rowsCount: number }; function AllCellsHighlightsCounter( props: AllCellsProps & { @@ -280,122 +275,128 @@ function AllCellsHighlightsCounter( const containerRef = useRef(); containerRef.current = container; - const { rows, visibleColumns, onFinish } = props; - const resultsMapRef = useRef>>({}); - const remainingNumberOfResultsRef = useRef(rows.length * visibleColumns.length); - - const onHighlightsCountFound = useCallback( - (rowIndex: number, columnId: string, count: number) => { - remainingNumberOfResultsRef.current = remainingNumberOfResultsRef.current - 1; - - if (count === 0) { - return; - } - - if (!resultsMapRef.current[rowIndex]) { - resultsMapRef.current[rowIndex] = {}; - } - resultsMapRef.current[rowIndex][columnId] = count; - }, - [] - ); - - const onComplete = useCallback(() => { - let totalMatchesCount = 0; - const newMatchesList: RowMatches[] = []; - - Object.keys(resultsMapRef.current) - .map((rowIndex) => Number(rowIndex)) - .sort((a, b) => a - b) - .forEach((rowIndex) => { - const matchesCountPerColumnId = resultsMapRef.current[rowIndex]; - const rowMatchesCount = Object.values(matchesCountPerColumnId).reduce( - (acc, count) => acc + count, - 0 - ); - - newMatchesList.push({ - rowIndex, - rowMatchesCount, - matchesCountPerColumnId, - }); - totalMatchesCount += rowMatchesCount; - }); - - onFinish({ matchesList: newMatchesList, totalMatchesCount }); - }, [onFinish]); - - const timerRef = useRef(null); - const [_] = useState(() => { - const newTimer = setTimeout(onComplete, 30000); - timerRef.current = newTimer; - return registerTimer(newTimer); - }); - useEffect(() => { return () => { - stopTimer(timerRef.current); - if (containerRef.current) { unmountComponentAtNode(containerRef.current); } }; }, []); - return createPortal( - { - onHighlightsCountFound(rowIndex, columnId, count); - - if (remainingNumberOfResultsRef.current === 0) { - stopTimer(timerRef.current); - onComplete(); - } - }} - />, - container - ); + return createPortal(, container); } function AllCells({ inTableSearchTerm, - rows, visibleColumns, renderCellValue, - onHighlightsCountFound, + rowsCount, + onFinish, }: AllCellsProps & { - onHighlightsCountFound: (rowIndex: number, columnId: string, count: number) => void; + onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; +}) { + const matchesListRef = useRef([]); + const totalMatchesCountRef = useRef(0); + const [rowIndex, setRowIndex] = useState(0); + + const onRowHighlightsCountFound = useCallback( + (rowMatches: RowMatches) => { + if (rowMatches.rowMatchesCount > 0) { + totalMatchesCountRef.current += rowMatches.rowMatchesCount; + matchesListRef.current.push(rowMatches); + } + + const nextRowIndex = rowIndex + 1; + + if (nextRowIndex < rowsCount) { + setRowIndex(nextRowIndex); + } else { + onFinish({ + matchesList: matchesListRef.current, + totalMatchesCount: totalMatchesCountRef.current, + }); + } + }, + [setRowIndex, rowIndex, rowsCount, onFinish] + ); + + // iterating through rows one at the time to avoid blocking the main thread + return ( + + ); +} + +function RowCells({ + rowIndex, + inTableSearchTerm, + visibleColumns, + renderCellValue, + onRowHighlightsCountFound, +}: Omit & { + rowIndex: number; + onRowHighlightsCountFound: (rowMatch: RowMatches) => void; }) { const UnifiedDataTableRenderCellValue = renderCellValue; + const resultsMapRef = useRef>({}); + const rowMatchesCountRef = useRef(0); + const remainingNumberOfResultsRef = useRef(visibleColumns.length); + + const onComplete = useCallback(() => { + onRowHighlightsCountFound({ + rowIndex, + rowMatchesCount: rowMatchesCountRef.current, + matchesCountPerColumnId: resultsMapRef.current, + }); + }, [rowIndex, onRowHighlightsCountFound]); + + const onCellHighlightsCountFound = useCallback( + (columnId: string, count: number) => { + remainingNumberOfResultsRef.current = remainingNumberOfResultsRef.current - 1; + + if (count > 0) { + resultsMapRef.current[columnId] = count; + rowMatchesCountRef.current += count; + } + + if (remainingNumberOfResultsRef.current === 0) { + onComplete(); + } + }, + [onComplete] + ); return ( <> - {(rows || []).flatMap((_, rowIndex) => { - return visibleColumns.map((columnId) => { - return ( - { - onHighlightsCountFound(rowIndex, columnId, 0); + {visibleColumns.map((columnId) => { + return ( + { + onCellHighlightsCountFound(columnId, 0); + }} + > + {}} + inTableSearchTerm={inTableSearchTerm} + onHighlightsCountFound={(count) => { + onCellHighlightsCountFound(columnId, count); }} - > - {}} - inTableSearchTerm={inTableSearchTerm} - onHighlightsCountFound={(count) => { - onHighlightsCountFound(rowIndex, columnId, count); - }} - /> - - ); - }); + /> + + ); })} ); @@ -431,15 +432,3 @@ export class ErrorBoundary extends React.Component< return this.props.children; } } - -function registerTimer(timer: NodeJS.Timeout) { - stopTimer(latestTimeoutTimer); - latestTimeoutTimer = timer; - return timer; -} - -function stopTimer(timer: NodeJS.Timeout | null) { - if (timer) { - clearTimeout(timer); - } -} From bd84a44e6ec308e155579dcf96804c20c9a17b81 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 10:38:13 +0100 Subject: [PATCH 54/90] [Discover] Reset styles when term changes --- .../src/components/data_table.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 fd474acdc3f34..f548e64fcbd73 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 @@ -750,8 +750,10 @@ export const UnifiedDataTable = ({ ); const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); - const [inTableSearchTerm, setInTableSearchTerm] = useState(''); - const [inTableSearchTermCss, setInTableSearchTermCss] = useState(); + const [{ inTableSearchTerm, inTableSearchTermCss }, setInTableSearchState] = useState<{ + inTableSearchTerm: string; + inTableSearchTermCss?: SerializedStyles; + }>(() => ({ inTableSearchTerm: '' })); const inTableSearchControl = useMemo(() => { if (!enableInTableSearch) { @@ -770,8 +772,10 @@ export const UnifiedDataTable = ({ shouldOverrideCmdF={(element) => { return dataGridWrapper?.contains?.(element) ?? false; }} - onChange={(searchTerm) => setInTableSearchTerm(searchTerm || '')} - onChangeCss={(styles) => setInTableSearchTermCss(styles)} + onChange={(searchTerm) => setInTableSearchState({ inTableSearchTerm: searchTerm || '' })} + onChangeCss={(styles) => + setInTableSearchState((prevState) => ({ ...prevState, inTableSearchTermCss: styles })) + } onChangeToExpectedPage={(expectedPageIndex) => { if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { changeCurrentPageIndex(expectedPageIndex); @@ -781,8 +785,7 @@ export const UnifiedDataTable = ({ ); }, [ enableInTableSearch, - setInTableSearchTerm, - setInTableSearchTermCss, + setInTableSearchState, displayedRows, renderCellValue, visibleColumns, From 17d1b749b14dab9e59dd2f25e63baa50ac72e407 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 11:10:44 +0100 Subject: [PATCH 55/90] [Discover] Add a timeout --- .../use_in_table_search_matches.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index f0431a3f0dd4f..42e6ffb2a6cec 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -333,6 +333,8 @@ function AllCells({ ); } +const TIMEOUT_PER_ROW = 2000; // 2 sec per row max + function RowCells({ rowIndex, inTableSearchTerm, @@ -344,7 +346,8 @@ function RowCells({ onRowHighlightsCountFound: (rowMatch: RowMatches) => void; }) { const UnifiedDataTableRenderCellValue = renderCellValue; - const resultsMapRef = useRef>({}); + const timerRef = useRef(); + const matchesCountPerColumnIdRef = useRef>({}); const rowMatchesCountRef = useRef(0); const remainingNumberOfResultsRef = useRef(visibleColumns.length); @@ -352,16 +355,18 @@ function RowCells({ onRowHighlightsCountFound({ rowIndex, rowMatchesCount: rowMatchesCountRef.current, - matchesCountPerColumnId: resultsMapRef.current, + matchesCountPerColumnId: matchesCountPerColumnIdRef.current, }); }, [rowIndex, onRowHighlightsCountFound]); + const onCompleteRef = useRef<() => void>(); + onCompleteRef.current = onComplete; const onCellHighlightsCountFound = useCallback( (columnId: string, count: number) => { remainingNumberOfResultsRef.current = remainingNumberOfResultsRef.current - 1; if (count > 0) { - resultsMapRef.current[columnId] = count; + matchesCountPerColumnIdRef.current[columnId] = count; rowMatchesCountRef.current += count; } @@ -372,6 +377,23 @@ function RowCells({ [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); + } + }; + }, []); + return ( <> {visibleColumns.map((columnId) => { From f91ab489fff5dd4c085bc28ed44cacdaf474a5d2 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 11:16:41 +0100 Subject: [PATCH 56/90] [Discover] Update types --- .../use_in_table_search_matches.tsx | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 42e6ffb2a6cec..118201f490e9c 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -264,13 +264,12 @@ function changeActiveMatchInState( type AllCellsProps = Pick< UseInTableSearchMatchesProps, 'renderCellValue' | 'visibleColumns' | 'inTableSearchTerm' -> & { rowsCount: number }; +> & { + rowsCount: number; + onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; +}; -function AllCellsHighlightsCounter( - props: AllCellsProps & { - onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; - } -) { +function AllCellsHighlightsCounter(props: AllCellsProps) { const [container] = useState(() => document.createDocumentFragment()); const containerRef = useRef(); containerRef.current = container; @@ -286,15 +285,8 @@ function AllCellsHighlightsCounter( return createPortal(, container); } -function AllCells({ - inTableSearchTerm, - visibleColumns, - renderCellValue, - rowsCount, - onFinish, -}: AllCellsProps & { - onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; -}) { +function AllCells(props: AllCellsProps) { + const { inTableSearchTerm, visibleColumns, renderCellValue, rowsCount, onFinish } = props; const matchesListRef = useRef([]); const totalMatchesCountRef = useRef(0); const [rowIndex, setRowIndex] = useState(0); @@ -320,7 +312,8 @@ function AllCells({ [setRowIndex, rowIndex, rowsCount, onFinish] ); - // iterating through rows one at the time to avoid blocking the main thread + // Iterating through rows one 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. return ( & { +}: Omit & { rowIndex: number; onRowHighlightsCountFound: (rowMatch: RowMatches) => void; }) { From 2f815276b89fdf8f99da659f2f1630e2d81ca16a Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 11:30:58 +0100 Subject: [PATCH 57/90] [Discover] Restore interactions --- .../in_table_search_control.tsx | 119 +++++++++--------- .../in_table_search/in_table_search_input.tsx | 3 + 2 files changed, 65 insertions(+), 57 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 22c6f31369ba3..4901fff285f01 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -15,7 +15,7 @@ import { useInTableSearchMatches, UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; -import { InTableSearchInput } from './in_table_search_input'; +import { InTableSearchInput, INPUT_TEST_SUBJ } from './in_table_search_input'; import './in_table_search.scss'; const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; @@ -39,9 +39,9 @@ export const InTableSearchControl: React.FC = ({ onChangeToExpectedPage, ...props }) => { - // const [buttonNode, setButtonNode] = useState(null); + const containerRef = useRef(null); const shouldReturnFocusToButtonRef = useRef(false); - const [isFocused, setIsFocused] = useState(false); + const [isInputVisible, setIsInputVisible] = useState(false); const onScrollToActiveMatch: UseInTableSearchMatchesProps['onScrollToActiveMatch'] = useCallback( ({ rowIndex, columnId, matchIndexWithinCell }) => { @@ -87,17 +87,17 @@ export const InTableSearchControl: React.FC = ({ resetState, } = useInTableSearchMatches({ ...props, onScrollToActiveMatch }); - const focusInput = useCallback(() => { - setIsFocused(true); - }, [setIsFocused]); + const showInput = useCallback(() => { + setIsInputVisible(true); + }, [setIsInputVisible]); const hideInput = useCallback( (shouldReturnFocusToButton: boolean = false) => { - setIsFocused(false); + setIsInputVisible(false); resetState(); shouldReturnFocusToButtonRef.current = shouldReturnFocusToButton; }, - [setIsFocused, resetState] + [setIsInputVisible, resetState] ); useEffect(() => { @@ -108,9 +108,14 @@ export const InTableSearchControl: React.FC = ({ shouldOverrideCmdF(event.target as HTMLElement) ) { event.preventDefault(); // prevent default browser find-in-page behavior - focusInput(); - // TODO: refactor - // inputRef.current?.focus(); // if it was already open before, make sure to shift the focus to it + 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(); } }; @@ -120,54 +125,54 @@ export const InTableSearchControl: React.FC = ({ return () => { document.removeEventListener('keydown', handleGlobalKeyDown); }; - }, [focusInput, shouldOverrideCmdF]); - - const shouldRenderButton = !isFocused; - - // TODO: refactor - // useEffect(() => { - // if (shouldReturnFocusToButtonRef.current && buttonNode && shouldRenderButton) { - // shouldReturnFocusToButtonRef.current = false; - // buttonNode.focus(); - // } - // }, [buttonNode, shouldRenderButton]); - - if (shouldRenderButton) { - return ( - - - - ); - } + }, [showInput, shouldOverrideCmdF]); + + useEffect(() => { + if (shouldReturnFocusToButtonRef.current && !isInputVisible) { + shouldReturnFocusToButtonRef.current = false; + ( + containerRef.current?.querySelector( + `[data-test-subj="${BUTTON_TEST_SUBJ}"]` + ) as HTMLButtonElement + )?.focus(); + } + }, [isInputVisible]); return ( -
- - {renderCellsShadowPortal ? renderCellsShadowPortal() : null} +
(containerRef.current = node)}> + {isInputVisible ? ( +
+ + {renderCellsShadowPortal ? renderCellsShadowPortal() : null} +
+ ) : ( + + + + )}
); }; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx index 9b4a8a456a01b..943be8b3d74dc 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx @@ -19,6 +19,8 @@ import { import { i18n } from '@kbn/i18n'; import { useDebouncedValue } from '@kbn/visualization-utils'; +export const INPUT_TEST_SUBJ = 'inTableSearchInput'; + export interface InTableSearchInputProps { matchesCount: number | null; activeMatchPosition: number | null; @@ -88,6 +90,7 @@ export const InTableSearchInput: React.FC = React.memo( autoFocus compressed className="unifiedDataTable__inTableSearchInput" + data-test-subj={INPUT_TEST_SUBJ} isClearable={!isProcessing} isLoading={isProcessing} append={ From d4743a500da557bcf1a10a5390505a1fe7ec345e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 12:15:47 +0100 Subject: [PATCH 58/90] [Discover] Add a comment --- .../components/in_table_search/use_in_table_search_matches.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index 118201f490e9c..aacf54bb6e0ca 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -313,7 +313,7 @@ function AllCells(props: AllCellsProps) { ); // Iterating through rows one 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. + // 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 ( {}} inTableSearchTerm={inTableSearchTerm} onHighlightsCountFound={(count) => { + // you can comment out the next line to observe that the row timeout is working as expected. onCellHighlightsCountFound(columnId, count); }} /> From 03785a3f531519bb27a793a90c2c0dc90eda6c7e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 21 Jan 2025 13:40:49 +0100 Subject: [PATCH 59/90] [Discover] Rerun the in-table search when visible columns change --- .../in_table_search_highlights_wrapper.tsx | 24 ++++++++----------- .../src/utils/get_render_cell_value.test.tsx | 24 ++++++++++++++----- .../src/utils/get_render_cell_value.tsx | 2 +- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx index 9ac92a0e67aac..b13042ae6e668 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx @@ -22,26 +22,22 @@ export const InTableSearchHighlightsWrapper: React.FC { const cellValueRef = useRef(null); - const renderedForSearchTerm = useRef(); + + const dryRun = Boolean(onHighlightsCountFound); // only to count highlights, not to modify the DOM + const shouldCallCallbackRef = useRef(dryRun); useEffect(() => { - if ( - inTableSearchTerm && - cellValueRef.current && - renderedForSearchTerm.current !== inTableSearchTerm - ) { - renderedForSearchTerm.current = inTableSearchTerm; + if (inTableSearchTerm && cellValueRef.current) { const cellNode = cellValueRef.current; setTimeout(() => { - const count = modifyDOMAndAddSearchHighlights( - cellNode, - inTableSearchTerm, - Boolean(onHighlightsCountFound) - ); - onHighlightsCountFound?.(count); + const count = modifyDOMAndAddSearchHighlights(cellNode, inTableSearchTerm, dryRun); + if (shouldCallCallbackRef.current) { + shouldCallCallbackRef.current = false; + onHighlightsCountFound?.(count); + } }, 0); } - }, [inTableSearchTerm, onHighlightsCountFound]); + }, [dryRun, inTableSearchTerm, children, onHighlightsCountFound]); return
{children}
; }; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx index 8ac1f86d8ee80..249ea072cfe07 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx @@ -246,7 +246,9 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(component).toMatchInlineSnapshot(` - + ); expect(componentWithDetails).toMatchInlineSnapshot(` - + From d1ed37623c03fd6c36ba76004eae31a4b1489d79 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 10:54:53 +0100 Subject: [PATCH 60/90] [Discover] Remove scss --- .../in_table_search/in_table_search.scss | 33 ------------- .../in_table_search_control.tsx | 46 ++++++++++++++++--- .../in_table_search_highlights_wrapper.tsx | 2 + 3 files changed, 41 insertions(+), 40 deletions(-) delete mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss deleted file mode 100644 index 030489034a5bc..0000000000000 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search.scss +++ /dev/null @@ -1,33 +0,0 @@ -.unifiedDataTable__inTableSearchMatch { - background-color: #E5FFC0; // TODO: Use a named color token - transition: background-color $euiAnimSpeedFast ease-in-out; -} - -.unifiedDataTable__inTableSearchMatchesCounter { - font-variant-numeric: tabular-nums; -} - -.unifiedDataTable__inTableSearchButton { - /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ - min-height: 2 * $euiSize; // input height -} - -.unifiedDataTable__inTableSearchInputContainer { - .unifiedDataTable__inTableSearchInput { - /* 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 & .unifiedDataTable__inTableSearchInput { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-right: 0; - } -} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 4901fff285f01..4090dff67d9ca 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useState, useEffect, useRef } from 'react'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; +import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css, type SerializedStyles } from '@emotion/react'; import { @@ -16,7 +16,6 @@ import { UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; import { InTableSearchInput, INPUT_TEST_SUBJ } from './in_table_search_input'; -import './in_table_search.scss'; const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; @@ -39,6 +38,7 @@ export const InTableSearchControl: React.FC = ({ onChangeToExpectedPage, ...props }) => { + const { euiTheme } = useEuiTheme(); const containerRef = useRef(null); const shouldReturnFocusToButtonRef = useRef(false); const [isInputVisible, setIsInputVisible] = useState(false); @@ -54,7 +54,7 @@ export const InTableSearchControl: React.FC = ({ onChangeCss(css` .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${columnId}'] .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndexWithinCell}'] { - background-color: #ffc30e; + background-color: #ffc30e !important; } `); @@ -138,10 +138,42 @@ export const InTableSearchControl: React.FC = ({ } }, [isInputVisible]); + const innerCss = useMemo( + () => css` + .unifiedDataTable__inTableSearchMatchesCounter { + font-variant-numeric: tabular-nums; + } + + .unifiedDataTable__inTableSearchButton { + /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ + min-height: 2 * ${euiTheme.size.base}; // input height + } + + .unifiedDataTable__inTableSearchInput { + /* 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 & .unifiedDataTable__inTableSearchInput { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; + } + `, + [euiTheme] + ); + return ( -
(containerRef.current = node)}> +
(containerRef.current = node)} css={innerCss}> {isInputVisible ? ( -
+ <> = ({ onHideInput={hideInput} /> {renderCellsShadowPortal ? renderCellsShadowPortal() : null} -
+ ) : ( Date: Wed, 22 Jan 2025 11:09:48 +0100 Subject: [PATCH 61/90] [Discover] Try another align param --- .../components/in_table_search/in_table_search_control.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 4090dff67d9ca..52b420f2bb79f 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -22,7 +22,7 @@ const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; export interface InTableSearchControlProps extends Omit { pageSize: number | null; // null when the pagination is disabled - scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'smart' }) => void; + scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'auto' }) => void; shouldOverrideCmdF: (element: HTMLElement) => boolean; onChange: (searchTerm: string | undefined) => void; onChangeCss: (styles: SerializedStyles) => void; @@ -71,7 +71,7 @@ export const InTableSearchControl: React.FC = ({ scrollToCell({ rowIndex: visibleRowIndex, columnIndex: Number(columnIndex), - align: 'smart', + align: 'auto', }); }, [scrollToCell, onChangeCss, onChangeToExpectedPage, pageSize] From 7b11780c22671efefc52c273a92856bc4e7fc0b9 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 11:35:39 +0100 Subject: [PATCH 62/90] [Discover] Add a slight animation for the active cell --- .../in_table_search_control.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 52b420f2bb79f..3fc67aa9aa776 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -10,19 +10,33 @@ import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { css, type SerializedStyles } from '@emotion/react'; +import { css, type SerializedStyles, keyframes } from '@emotion/react'; import { useInTableSearchMatches, UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; import { InTableSearchInput, INPUT_TEST_SUBJ } from './in_table_search_input'; +// An animation to highlight the active cell. +// It's useful when the active match is not visible due to the cell height. +const fadeOutIn = keyframes` + 0% { + opacity: 1; + } + 50% { + opacity: 0.6; + } + 100% { + opacity: 1; + } +`; + const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; export interface InTableSearchControlProps extends Omit { pageSize: number | null; // null when the pagination is disabled - scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'auto' }) => void; + scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'smart' }) => void; shouldOverrideCmdF: (element: HTMLElement) => boolean; onChange: (searchTerm: string | undefined) => void; onChangeCss: (styles: SerializedStyles) => void; @@ -52,9 +66,13 @@ export const InTableSearchControl: React.FC = ({ // TODO: use a named color token onChangeCss(css` - .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${columnId}'] + .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${columnId}'] { + .euiDataGridRowCell__content { + animation: 0.3s 1 forwards ${fadeOutIn}; + } .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndexWithinCell}'] { - background-color: #ffc30e !important; + background-color: #ffc30e !important; + } } `); @@ -71,7 +89,7 @@ export const InTableSearchControl: React.FC = ({ scrollToCell({ rowIndex: visibleRowIndex, columnIndex: Number(columnIndex), - align: 'auto', + align: 'smart', }); }, [scrollToCell, onChangeCss, onChangeToExpectedPage, pageSize] From 7d6f9a7d5fe64e65c5702afe6632d0d1722e2fe5 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 12:04:39 +0100 Subject: [PATCH 63/90] [Discover] Replace the animation with a cell border --- .../src/components/data_table.tsx | 3 ++ .../in_table_search_control.tsx | 44 +++++++------------ .../in_table_search_highlights_wrapper.tsx | 1 - 3 files changed, 19 insertions(+), 29 deletions(-) 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 f548e64fcbd73..64a373ce133a7 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 @@ -759,6 +759,8 @@ export const UnifiedDataTable = ({ if (!enableInTableSearch) { return undefined; } + const controlsCount = + dataGridWrapper?.querySelectorAll('.euiDataGridHeaderCell--controlColumn').length ?? 0; return ( visibleColumns.indexOf(columnId) + controlsCount} scrollToCell={(params) => { dataGridRef.current?.scrollToItem?.(params); }} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx index 3fc67aa9aa776..34a34787afe75 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx @@ -10,33 +10,20 @@ import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { css, type SerializedStyles, keyframes } from '@emotion/react'; +import { css, type SerializedStyles } from '@emotion/react'; import { useInTableSearchMatches, UseInTableSearchMatchesProps, } from './use_in_table_search_matches'; import { InTableSearchInput, INPUT_TEST_SUBJ } from './in_table_search_input'; -// An animation to highlight the active cell. -// It's useful when the active match is not visible due to the cell height. -const fadeOutIn = keyframes` - 0% { - opacity: 1; - } - 50% { - opacity: 0.6; - } - 100% { - opacity: 1; - } -`; - const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; export interface InTableSearchControlProps extends Omit { pageSize: number | null; // null when the pagination is disabled - scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'smart' }) => void; + 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; @@ -45,6 +32,7 @@ export interface InTableSearchControlProps export const InTableSearchControl: React.FC = ({ pageSize, + getColumnIndexFromId, scrollToCell, shouldOverrideCmdF, onChange, @@ -65,10 +53,17 @@ export const InTableSearchControl: React.FC = ({ } // TODO: use a named color token + // The cell border is useful when the active match is not visible due to the cell height. onChangeCss(css` .euiDataGridRowCell[data-gridcell-row-index='${rowIndex}'][data-gridcell-column-id='${columnId}'] { - .euiDataGridRowCell__content { - animation: 0.3s 1 forwards ${fadeOutIn}; + &:after { + content: ''; + z-index: 2; + pointer-events: none; + position: absolute; + inset: 0; + border: 2px solid #ffc30e !important; + border-radius: 3px; } .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndexWithinCell}'] { background-color: #ffc30e !important; @@ -76,23 +71,16 @@ export const InTableSearchControl: React.FC = ({ } `); - const anyCellForColumnId = document.querySelector( - `.euiDataGridRowCell[data-gridcell-column-id='${columnId}']` - ); - - // getting column index by column id - const columnIndex = anyCellForColumnId?.getAttribute('data-gridcell-column-index') ?? 0; - // getting rowIndex for the visible page const visibleRowIndex = typeof pageSize === 'number' ? rowIndex % pageSize : rowIndex; scrollToCell({ rowIndex: visibleRowIndex, - columnIndex: Number(columnIndex), - align: 'smart', + columnIndex: getColumnIndexFromId(columnId), + align: 'center', }); }, - [scrollToCell, onChangeCss, onChangeToExpectedPage, pageSize] + [getColumnIndexFromId, scrollToCell, onChangeCss, onChangeToExpectedPage, pageSize] ); const { diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx index 8f6c2dae5f437..933c423e21973 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx @@ -80,7 +80,6 @@ function modifyDOMAndAddSearchHighlights( const mark = document.createElement('mark'); mark.textContent = part; mark.style.backgroundColor = '#e5ffc0'; // TODO: Use a named color token - mark.style.transition = 'background-color 0.3s ease-in-out'; mark.setAttribute('class', 'unifiedDataTable__inTableSearchMatch'); mark.setAttribute('data-match-index', `${matchIndex++}`); nodeWithHighlights.appendChild(mark); From 8dee0bed5501e46de735147461524d283fc1e5cc Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 14:09:54 +0100 Subject: [PATCH 64/90] [Discover] Process rows in chunks of 10 at the time --- .../in_table_search_highlights_wrapper.tsx | 14 +- .../use_in_table_search_matches.tsx | 159 ++++++++++++------ 2 files changed, 114 insertions(+), 59 deletions(-) diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx index 933c423e21973..9125f547c7493 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx @@ -22,6 +22,7 @@ export const InTableSearchHighlightsWrapper: React.FC { 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); @@ -29,13 +30,20 @@ export const InTableSearchHighlightsWrapper: React.FC { if (inTableSearchTerm && cellValueRef.current) { const cellNode = cellValueRef.current; - setTimeout(() => { + + const searchForMatches = () => { const count = modifyDOMAndAddSearchHighlights(cellNode, inTableSearchTerm, dryRun); if (shouldCallCallbackRef.current) { shouldCallCallbackRef.current = false; onHighlightsCountFound?.(count); } - }, 0); + }; + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(searchForMatches, 0); } }, [dryRun, inTableSearchTerm, children, onHighlightsCountFound]); @@ -75,7 +83,7 @@ function modifyDOMAndAddSearchHighlights( if (parts.length > 1) { const nodeWithHighlights = document.createDocumentFragment(); - parts.forEach((part) => { + parts.forEach(function insertHighlights(part) { if (searchTermRegExp.test(part)) { const mark = document.createElement('mark'); mark.textContent = part; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index aacf54bb6e0ca..ebce13784defc 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -61,6 +61,14 @@ const INITIAL_STATE: UseInTableSearchMatchesState = { renderCellsShadowPortal: null, }; +type AllCellsProps = Pick< + UseInTableSearchMatchesProps, + 'renderCellValue' | 'visibleColumns' | 'inTableSearchTerm' +> & { + rowsCount: number; + onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; +}; + export const useInTableSearchMatches = ( props: UseInTableSearchMatchesProps ): UseInTableSearchMatchesReturn => { @@ -79,42 +87,49 @@ export const useInTableSearchMatches = ( 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 RenderCellsShadowPortal: UseInTableSearchMatchesState['renderCellsShadowPortal'] = () => ( + + ); + setState((prevState) => ({ ...prevState, isProcessing: true, - renderCellsShadowPortal: () => ( - { - 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, - }); - } - }} - /> - ), + renderCellsShadowPortal: RenderCellsShadowPortal, })); }, [setState, renderCellValue, visibleColumns, rows, inTableSearchTerm, onScrollToActiveMatch]); @@ -198,6 +213,8 @@ function getActiveMatch({ return null; } +let prevJumpTimer: NodeJS.Timeout | null = null; + function updateActiveMatchPosition({ matchPosition, matchesList, @@ -213,7 +230,11 @@ function updateActiveMatchPosition({ return; } - setTimeout(() => { + if (prevJumpTimer) { + clearTimeout(prevJumpTimer); + } + + prevJumpTimer = setTimeout(() => { const activeMatch = getActiveMatch({ matchPosition, matchesList, @@ -261,14 +282,6 @@ function changeActiveMatchInState( }; } -type AllCellsProps = Pick< - UseInTableSearchMatchesProps, - 'renderCellValue' | 'visibleColumns' | 'inTableSearchTerm' -> & { - rowsCount: number; - onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; -}; - function AllCellsHighlightsCounter(props: AllCellsProps) { const [container] = useState(() => document.createDocumentFragment()); const containerRef = useRef(); @@ -285,23 +298,50 @@ function AllCellsHighlightsCounter(props: AllCellsProps) { return createPortal(, container); } +const ROWS_CHUNK_SIZE = 10; // process 10 rows at the time + function AllCells(props: AllCellsProps) { const { inTableSearchTerm, visibleColumns, renderCellValue, rowsCount, onFinish } = props; const matchesListRef = useRef([]); const totalMatchesCountRef = useRef(0); - const [rowIndex, setRowIndex] = useState(0); + const initialChunkSize = Math.min(ROWS_CHUNK_SIZE, rowsCount); + const [{ chunkStartRowIndex, chunkSize }, setChunk] = useState<{ + chunkStartRowIndex: number; + chunkSize: number; + }>({ chunkStartRowIndex: 0, chunkSize: initialChunkSize }); + const chunkRowResultsMapRef = useRef>({}); + const chunkRemainingRowsCountRef = useRef(initialChunkSize); const onRowHighlightsCountFound = useCallback( (rowMatches: RowMatches) => { if (rowMatches.rowMatchesCount > 0) { totalMatchesCountRef.current += rowMatches.rowMatchesCount; - matchesListRef.current.push(rowMatches); + chunkRowResultsMapRef.current[rowMatches.rowIndex] = rowMatches; + } + + chunkRemainingRowsCountRef.current -= 1; + + if (chunkRemainingRowsCountRef.current > 0) { + // still waiting for more rows within the chunk to finish + return; } - const nextRowIndex = rowIndex + 1; + // 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 + ROWS_CHUNK_SIZE; if (nextRowIndex < rowsCount) { - setRowIndex(nextRowIndex); + const nextChunkSize = Math.min(ROWS_CHUNK_SIZE, rowsCount - nextRowIndex); + chunkRowResultsMapRef.current = {}; + chunkRemainingRowsCountRef.current = nextChunkSize; + setChunk({ chunkStartRowIndex: nextRowIndex, chunkSize: nextChunkSize }); } else { onFinish({ matchesList: matchesListRef.current, @@ -309,20 +349,27 @@ function AllCells(props: AllCellsProps) { }); } }, - [setRowIndex, rowIndex, rowsCount, onFinish] + [setChunk, chunkStartRowIndex, rowsCount, onFinish] ); - // Iterating through rows one at the time to avoid blocking the main thread. + // 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 ( + + ); + })} + ); } From 55ee25c77d18461c47011456139a470f9d734ab8 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 17:05:42 +0100 Subject: [PATCH 65/90] [Discover] Create a high level hook --- .../src/components/data_table.tsx | 81 ++++--------- .../src/components/in_table_search/index.ts | 9 +- .../use_data_grid_in_table_search.tsx | 114 ++++++++++++++++++ .../use_in_table_search_matches.tsx | 2 +- 4 files changed, 140 insertions(+), 66 deletions(-) create mode 100644 src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_data_grid_in_table_search.tsx 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 64a373ce133a7..029cb5a5364e1 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 @@ -36,7 +36,6 @@ import { useDataGridColumnsCellActions, type UseDataGridColumnsCellActionsProps, } from '@kbn/cell-actions'; -import type { SerializedStyles } from '@emotion/react'; import type { ToastsStart, IUiSettingsClient } from '@kbn/core/public'; import type { Serializable } from '@kbn/utility-types'; import type { DataTableRecord } from '@kbn/discover-utils/types'; @@ -95,7 +94,7 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; -import { InTableSearchControl } from './in_table_search'; +import { useDataGridInTableSearch } from './in_table_search'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; @@ -750,66 +749,28 @@ export const UnifiedDataTable = ({ ); const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); - const [{ inTableSearchTerm, inTableSearchTermCss }, setInTableSearchState] = useState<{ - inTableSearchTerm: string; - inTableSearchTermCss?: SerializedStyles; - }>(() => ({ inTableSearchTerm: '' })); - const inTableSearchControl = useMemo(() => { - if (!enableInTableSearch) { - return undefined; - } - const controlsCount = - dataGridWrapper?.querySelectorAll('.euiDataGridHeaderCell--controlColumn').length ?? 0; - return ( - visibleColumns.indexOf(columnId) + controlsCount} - scrollToCell={(params) => { - dataGridRef.current?.scrollToItem?.(params); - }} - shouldOverrideCmdF={(element) => { - return dataGridWrapper?.contains?.(element) ?? false; - }} - onChange={(searchTerm) => setInTableSearchState({ inTableSearchTerm: searchTerm || '' })} - onChangeCss={(styles) => - setInTableSearchState((prevState) => ({ ...prevState, inTableSearchTermCss: styles })) - } - onChangeToExpectedPage={(expectedPageIndex) => { - if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { - changeCurrentPageIndex(expectedPageIndex); - } - }} - /> - ); - }, [ - enableInTableSearch, - setInTableSearchState, - displayedRows, - renderCellValue, - visibleColumns, - dataGridRef, - dataGridWrapper, - currentPageSize, - changeCurrentPageIndex, - isPaginationEnabled, - inTableSearchTerm, - ]); - - const extendedCellContext: EuiDataGridProps['cellContext'] = useMemo(() => { - if (!inTableSearchTerm && !cellContext) { - return undefined; - } + const onChangeToExpectedPage = useCallback( + (expectedPageIndex: number) => { + if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { + changeCurrentPageIndex(expectedPageIndex); + } + }, + [isPaginationEnabled, changeCurrentPageIndex] + ); - return { - ...cellContext, - inTableSearchTerm, - }; - }, [cellContext, inTableSearchTerm]); + const { inTableSearchTermCss, inTableSearchControl, extendedCellContext } = + useDataGridInTableSearch({ + enableInTableSearch, + dataGridWrapper, + dataGridRef, + visibleColumns, + rows: displayedRows, + renderCellValue, + pageSize: isPaginationEnabled ? currentPageSize : null, + cellContext, + onChangeToExpectedPage, + }); const renderCustomPopover = useMemo( () => renderCellPopover ?? getCustomCellPopoverRenderer(), diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts index 678861b1eda73..cce16b96039d9 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts @@ -7,13 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { InTableSearchControl, type InTableSearchControlProps } from './in_table_search_control'; export { InTableSearchHighlightsWrapper, type InTableSearchHighlightsWrapperProps, } from './in_table_search_highlights_wrapper'; export { - useInTableSearchMatches, - type UseInTableSearchMatchesProps, - type UseInTableSearchMatchesReturn, -} from './use_in_table_search_matches'; + useDataGridInTableSearch, + type UseDataGridInTableSearchProps, + type UseDataGridInTableSearchReturn, +} from './use_data_grid_in_table_search'; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_data_grid_in_table_search.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_data_grid_in_table_search.tsx new file mode 100644 index 0000000000000..ee09a58d66a18 --- /dev/null +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_data_grid_in_table_search.tsx @@ -0,0 +1,114 @@ +/* + * 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, useState } from 'react'; +import type { SerializedStyles } from '@emotion/react'; +import type { EuiDataGridProps, EuiDataGridRefProps } from '@elastic/eui'; +import { InTableSearchControl } from './in_table_search_control'; +import { InTableSearchControlProps } from './in_table_search_control'; + +export interface UseDataGridInTableSearchProps + extends Pick< + InTableSearchControlProps, + 'rows' | 'visibleColumns' | 'renderCellValue' | 'pageSize' | 'onChangeToExpectedPage' + > { + enableInTableSearch?: boolean; + dataGridWrapper: HTMLElement | null; + dataGridRef: React.RefObject; + cellContext: EuiDataGridProps['cellContext'] | undefined; +} + +export interface UseDataGridInTableSearchState { + inTableSearchTerm: string; + inTableSearchTermCss?: SerializedStyles; +} + +export interface UseDataGridInTableSearchReturn { + inTableSearchTermCss?: UseDataGridInTableSearchState['inTableSearchTermCss']; + inTableSearchControl: React.JSX.Element | undefined; + extendedCellContext: EuiDataGridProps['cellContext']; +} + +export const useDataGridInTableSearch = ( + props: UseDataGridInTableSearchProps +): UseDataGridInTableSearchReturn => { + const { + enableInTableSearch, + dataGridWrapper, + dataGridRef, + visibleColumns, + rows, + renderCellValue, + pageSize, + cellContext, + onChangeToExpectedPage, + } = props; + 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={onChangeToExpectedPage} + /> + ); + }, [ + enableInTableSearch, + setInTableSearchState, + visibleColumns, + rows, + renderCellValue, + dataGridRef, + dataGridWrapper, + inTableSearchTerm, + pageSize, + onChangeToExpectedPage, + ]); + + const extendedCellContext: EuiDataGridProps['cellContext'] = useMemo(() => { + if (!inTableSearchTerm && !cellContext) { + return undefined; + } + + return { + ...cellContext, + inTableSearchTerm, + }; + }, [cellContext, inTableSearchTerm]); + + return useMemo( + () => ({ inTableSearchTermCss, inTableSearchControl, extendedCellContext }), + [inTableSearchTermCss, inTableSearchControl, extendedCellContext] + ); +}; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx index ebce13784defc..26deea4150f44 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx @@ -28,7 +28,7 @@ interface ActiveMatch { export interface UseInTableSearchMatchesProps { inTableSearchTerm: string; visibleColumns: string[]; - rows: DataTableRecord[]; + rows: DataTableRecord[] | unknown[]; renderCellValue: ( props: EuiDataGridCellValueElementProps & Pick From ccb006c2a9b9fda0179dda4f2fd10014649abde4 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 17:40:09 +0100 Subject: [PATCH 66/90] [Discover] Move to a separate package --- .i18nrc.json | 1 + package.json | 1 + .../kbn-data-grid-in-table-search/README.md | 15 +++++++++++++++ .../kbn-data-grid-in-table-search/index.ts | 16 ++++++++++++++++ .../kbn-data-grid-in-table-search/jest.config.js | 14 ++++++++++++++ .../kbn-data-grid-in-table-search/kibana.jsonc | 7 +++++++ .../kbn-data-grid-in-table-search/package.json | 7 +++++++ .../src}/in_table_search_control.tsx | 16 ++++++++-------- .../src}/in_table_search_highlights_wrapper.tsx | 2 +- .../src}/in_table_search_input.tsx | 10 +++++----- .../src}/index.ts | 0 .../src}/use_data_grid_in_table_search.tsx | 0 .../src}/use_in_table_search_matches.tsx | 3 +-- .../kbn-data-grid-in-table-search/tsconfig.json | 11 +++++++++++ .../src/components/data_table.tsx | 2 +- .../src/utils/get_render_cell_value.tsx | 8 ++++---- tsconfig.base.json | 2 ++ yarn.lock | 4 ++++ 18 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/README.md create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/index.ts create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/jest.config.js create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/kibana.jsonc create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/package.json rename src/platform/packages/shared/{kbn-unified-data-table/src/components/in_table_search => kbn-data-grid-in-table-search/src}/in_table_search_control.tsx (92%) rename src/platform/packages/shared/{kbn-unified-data-table/src/components/in_table_search => kbn-data-grid-in-table-search/src}/in_table_search_highlights_wrapper.tsx (97%) rename src/platform/packages/shared/{kbn-unified-data-table/src/components/in_table_search => kbn-data-grid-in-table-search/src}/in_table_search_input.tsx (90%) rename src/platform/packages/shared/{kbn-unified-data-table/src/components/in_table_search => kbn-data-grid-in-table-search/src}/index.ts (100%) rename src/platform/packages/shared/{kbn-unified-data-table/src/components/in_table_search => kbn-data-grid-in-table-search/src}/use_data_grid_in_table_search.tsx (100%) rename src/platform/packages/shared/{kbn-unified-data-table/src/components/in_table_search => kbn-data-grid-in-table-search/src}/use_in_table_search_matches.tsx (99%) create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/tsconfig.json diff --git a/.i18nrc.json b/.i18nrc.json index 14fa4c6f31cdc..7413a988f1d69 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -163,6 +163,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 3a6fe39409c06..df3273f44acc9 100644 --- a/package.json +++ b/package.json @@ -419,6 +419,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..81143715cfb36 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/README.md @@ -0,0 +1,15 @@ +# @kbn/data-grid-in-table-search + +This package allows to extend `EuiDataGrid` with in-table search. + +To start using it: + +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 `renderCellValue` to accept `inTableSearchTerm` and `onHighlightsCountFound` props +5. update your `renderCellValue` to wrap the cell content with `InTableSearchHighlightsWrapper` component. + + + + 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..72b9c779f167c --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { + InTableSearchHighlightsWrapper, + useDataGridInTableSearch, + type InTableSearchHighlightsWrapperProps, + type UseDataGridInTableSearchProps, + type UseDataGridInTableSearchReturn, +} from './src'; 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-unified-data-table/src/components/in_table_search/in_table_search_control.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx similarity index 92% rename from src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx index 34a34787afe75..a678bfc61f32a 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_control.tsx +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx @@ -65,7 +65,7 @@ export const InTableSearchControl: React.FC = ({ border: 2px solid #ffc30e !important; border-radius: 3px; } - .unifiedDataTable__inTableSearchMatch[data-match-index='${matchIndexWithinCell}'] { + .dataGridInTableSearch__match[data-match-index='${matchIndexWithinCell}'] { background-color: #ffc30e !important; } } @@ -146,16 +146,16 @@ export const InTableSearchControl: React.FC = ({ const innerCss = useMemo( () => css` - .unifiedDataTable__inTableSearchMatchesCounter { + .dataGridInTableSearch__matchesCounter { font-variant-numeric: tabular-nums; } - .unifiedDataTable__inTableSearchButton { + .dataGridInTableSearch__button { /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ min-height: 2 * ${euiTheme.size.base}; // input height } - .unifiedDataTable__inTableSearchInput { + .dataGridInTableSearch__input { /* to prevent the width from changing when entering the search term */ min-width: 210px; } @@ -167,7 +167,7 @@ export const InTableSearchControl: React.FC = ({ /* override borders style only if it's under the custom grid toolbar */ .unifiedDataTableToolbarControlIconButton & .euiFormControlLayout, - .unifiedDataTableToolbarControlIconButton & .unifiedDataTable__inTableSearchInput { + .unifiedDataTableToolbarControlIconButton & .dataGridInTableSearch__input { border-top-right-radius: 0; border-bottom-right-radius: 0; border-right: 0; @@ -193,7 +193,7 @@ export const InTableSearchControl: React.FC = ({ ) : ( = ({ iconType="search" size="xs" color="text" - className="unifiedDataTable__inTableSearchButton" - aria-label={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { + className="dataGridInTableSearch__button" + aria-label={i18n.translate('dataGridInTableSearch.buttonSearch', { defaultMessage: 'Search in the table', })} onClick={showInput} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.tsx similarity index 97% rename from src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.tsx index 9125f547c7493..8984b0620a038 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_highlights_wrapper.tsx +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.tsx @@ -88,7 +88,7 @@ function modifyDOMAndAddSearchHighlights( const mark = document.createElement('mark'); mark.textContent = part; mark.style.backgroundColor = '#e5ffc0'; // TODO: Use a named color token - mark.setAttribute('class', 'unifiedDataTable__inTableSearchMatch'); + mark.setAttribute('class', 'dataGridInTableSearch__match'); mark.setAttribute('data-match-index', `${matchIndex++}`); nodeWithHighlights.appendChild(mark); } else { diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.tsx similarity index 90% rename from src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.tsx index 943be8b3d74dc..691273f773c9f 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/in_table_search_input.tsx +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.tsx @@ -89,13 +89,13 @@ export const InTableSearchInput: React.FC = React.memo( - + {matchesCount && activeMatchPosition ? `${activeMatchPosition}/${matchesCount}` @@ -108,7 +108,7 @@ export const InTableSearchInput: React.FC = React.memo( iconType="arrowUp" color="text" disabled={areArrowsDisabled} - aria-label={i18n.translate('unifiedDataTable.inTableSearch.buttonPreviousMatch', { + aria-label={i18n.translate('dataGridInTableSearch.buttonPreviousMatch', { defaultMessage: 'Previous', })} onClick={goToPrevMatch} @@ -119,7 +119,7 @@ export const InTableSearchInput: React.FC = React.memo( iconType="arrowDown" color="text" disabled={areArrowsDisabled} - aria-label={i18n.translate('unifiedDataTable.inTableSearch.buttonNextMatch', { + aria-label={i18n.translate('dataGridInTableSearch.buttonNextMatch', { defaultMessage: 'Next', })} onClick={goToNextMatch} @@ -127,7 +127,7 @@ export const InTableSearchInput: React.FC = React.memo( } - placeholder={i18n.translate('unifiedDataTable.inTableSearch.inputPlaceholder', { + placeholder={i18n.translate('dataGridInTableSearch.inputPlaceholder', { defaultMessage: 'Search in the table', })} value={inputValue} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/index.ts similarity index 100% rename from src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/index.ts rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/index.ts diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/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 similarity index 100% rename from src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_data_grid_in_table_search.tsx rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.tsx diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx similarity index 99% rename from src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx index 26deea4150f44..1a7526226a9b8 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/components/in_table_search/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx @@ -9,7 +9,6 @@ import React, { useCallback, useEffect, useState, ReactNode, useRef, useMemo } from 'react'; import { createPortal, unmountComponentAtNode } from 'react-dom'; -import type { DataTableRecord } from '@kbn/discover-utils/types'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; @@ -28,7 +27,7 @@ interface ActiveMatch { export interface UseInTableSearchMatchesProps { inTableSearchTerm: string; visibleColumns: string[]; - rows: DataTableRecord[] | unknown[]; + rows: unknown[]; renderCellValue: ( props: EuiDataGridCellValueElementProps & Pick 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..b7878fd45eafe --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"], + "compilerOptions": { + "outDir": "target/types" + }, + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} 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 029cb5a5364e1..a55d3b26efc34 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, @@ -94,7 +95,6 @@ import { getAdditionalRowControlColumns, } from './custom_control_columns'; import { useSorting } from '../hooks/use_sorting'; -import { useDataGridInTableSearch } from './in_table_search'; const CONTROL_COLUMN_IDS_DEFAULT = [SELECT_ROW, OPEN_DETAILS]; const THEME_DEFAULT = { darkMode: false }; diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 0b870f5314d1c..2a9e0027d746b 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -19,15 +19,15 @@ import { import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { DataTableRecord, ShouldShowFieldInTableHandler } from '@kbn/discover-utils/types'; import { formatFieldValue } from '@kbn/discover-utils'; +import { + InTableSearchHighlightsWrapper, + InTableSearchHighlightsWrapperProps, +} from '@kbn/data-grid-in-table-search'; import { UnifiedDataTableContext } from '../table_context'; import type { CustomCellRenderer } from '../types'; import { SourceDocument } from '../components/source_document'; import SourcePopoverContent from '../components/source_popover_content'; import { DataTablePopoverCellValue } from '../components/data_table_cell_value'; -import { - InTableSearchHighlightsWrapper, - InTableSearchHighlightsWrapperProps, -} from '../components/in_table_search'; export const CELL_CLASS = 'unifiedDataTable__cellValue'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 06df1ec525415..38c3377cb272c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -698,6 +698,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 9626b6915a4db..5e4a5b6e9089f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5250,6 +5250,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 "" From b796e6c2adbf915d49d82466f9cf19a27d3aff92 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 19:13:04 +0100 Subject: [PATCH 67/90] [Discover] Simplify --- .../src/use_data_grid_in_table_search.tsx | 26 ++++++++++++------- .../src/components/data_table.tsx | 12 +-------- 2 files changed, 18 insertions(+), 20 deletions(-) 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 index ee09a58d66a18..e5e570f013e9f 100644 --- 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 @@ -7,21 +7,19 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import type { SerializedStyles } from '@emotion/react'; import type { EuiDataGridProps, EuiDataGridRefProps } from '@elastic/eui'; import { InTableSearchControl } from './in_table_search_control'; import { InTableSearchControlProps } from './in_table_search_control'; export interface UseDataGridInTableSearchProps - extends Pick< - InTableSearchControlProps, - 'rows' | 'visibleColumns' | 'renderCellValue' | 'pageSize' | 'onChangeToExpectedPage' - > { + extends Pick { enableInTableSearch?: boolean; dataGridWrapper: HTMLElement | null; dataGridRef: React.RefObject; cellContext: EuiDataGridProps['cellContext'] | undefined; + pagination: EuiDataGridProps['pagination'] | undefined; } export interface UseDataGridInTableSearchState { @@ -45,10 +43,15 @@ export const useDataGridInTableSearch = ( visibleColumns, rows, renderCellValue, - pageSize, + pagination, cellContext, - onChangeToExpectedPage, } = props; + const isPaginationEnabled = Boolean(pagination); + const pageSize = (isPaginationEnabled && pagination?.pageSize) || null; + const onChangePage = pagination?.onChangePage; + const pageIndexRef = useRef(); + pageIndexRef.current = pagination?.pageIndex ?? 0; + const [{ inTableSearchTerm, inTableSearchTermCss }, setInTableSearchState] = useState(() => ({ inTableSearchTerm: '' })); @@ -80,7 +83,11 @@ export const useDataGridInTableSearch = ( onChangeCss={(styles) => setInTableSearchState((prevState) => ({ ...prevState, inTableSearchTermCss: styles })) } - onChangeToExpectedPage={onChangeToExpectedPage} + onChangeToExpectedPage={(expectedPageIndex: number) => { + if (isPaginationEnabled && pageIndexRef.current !== expectedPageIndex) { + onChangePage?.(expectedPageIndex); + } + }} /> ); }, [ @@ -92,8 +99,9 @@ export const useDataGridInTableSearch = ( dataGridRef, dataGridWrapper, inTableSearchTerm, + isPaginationEnabled, pageSize, - onChangeToExpectedPage, + onChangePage, ]); const extendedCellContext: EuiDataGridProps['cellContext'] = useMemo(() => { 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 a55d3b26efc34..a19c495b6c43a 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 @@ -750,15 +750,6 @@ export const UnifiedDataTable = ({ const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); - const onChangeToExpectedPage = useCallback( - (expectedPageIndex: number) => { - if (isPaginationEnabled && currentPageIndexRef.current !== expectedPageIndex) { - changeCurrentPageIndex(expectedPageIndex); - } - }, - [isPaginationEnabled, changeCurrentPageIndex] - ); - const { inTableSearchTermCss, inTableSearchControl, extendedCellContext } = useDataGridInTableSearch({ enableInTableSearch, @@ -767,9 +758,8 @@ export const UnifiedDataTable = ({ visibleColumns, rows: displayedRows, renderCellValue, - pageSize: isPaginationEnabled ? currentPageSize : null, cellContext, - onChangeToExpectedPage, + pagination: paginationObj, }); const renderCustomPopover = useMemo( From 647a851ee822e601bbf0d341b2e7d3aac3662233 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 19:14:58 +0100 Subject: [PATCH 68/90] [Discover] Update defaults --- .../shared/kbn-data-grid-in-table-search/README.md | 8 +++++++- .../src/use_data_grid_in_table_search.tsx | 2 +- .../kbn-unified-data-table/src/components/data_table.tsx | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) 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 index 81143715cfb36..40a7c9774679d 100644 --- 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 @@ -2,7 +2,9 @@ This package allows to extend `EuiDataGrid` with in-table search. -To start using it: +If you are already using `UnifiedDataTable` component, you can enable in-table search simply by passing `enableInTableSearch` prop to it. + +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` @@ -10,6 +12,10 @@ To start using it: 4. update your `renderCellValue` to accept `inTableSearchTerm` and `onHighlightsCountFound` props 5. update your `renderCellValue` to wrap the cell content with `InTableSearchHighlightsWrapper` component. +Examples could be found inside `kbn-unified-data-table` package. + + + 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 index e5e570f013e9f..e47c9958df9f7 100644 --- 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 @@ -37,7 +37,7 @@ export const useDataGridInTableSearch = ( props: UseDataGridInTableSearchProps ): UseDataGridInTableSearchReturn => { const { - enableInTableSearch, + enableInTableSearch = true, dataGridWrapper, dataGridRef, visibleColumns, 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 a19c495b6c43a..b2678349a1d1e 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 @@ -499,7 +499,7 @@ export const UnifiedDataTable = ({ rowLineHeightOverride, customGridColumnsConfiguration, enableComparisonMode, - enableInTableSearch, + enableInTableSearch = false, cellContext, renderCellPopover, getRowIndicator, From d8f03637e28e2ab9681eb78e74397f44375dd630 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 19:18:54 +0100 Subject: [PATCH 69/90] [Discover] Clean up --- .../kbn-unified-data-table/src/components/data_table.tsx | 3 --- 1 file changed, 3 deletions(-) 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 b2678349a1d1e..a718c49515afb 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 @@ -533,13 +533,11 @@ export const UnifiedDataTable = ({ } = selectedDocsState; const [currentPageIndex, setCurrentPageIndex] = useState(0); - const currentPageIndexRef = useRef(0); const changeCurrentPageIndex = useCallback( (value: number) => { setCurrentPageIndex(value); onUpdatePageIndex?.(value); - currentPageIndexRef.current = value; }, [setCurrentPageIndex, onUpdatePageIndex] ); @@ -638,7 +636,6 @@ export const UnifiedDataTable = ({ const calculatedPageIndex = previousPageIndex > pageCount - 1 ? 0 : previousPageIndex; if (calculatedPageIndex !== previousPageIndex) { onUpdatePageIndex?.(calculatedPageIndex); - currentPageIndexRef.current = calculatedPageIndex; } return calculatedPageIndex; }); From 2c56de09341ee9ce20c0f78ee6a89da0347014f0 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 19:20:21 +0100 Subject: [PATCH 70/90] [Discover] Enable in-table search for Lens table --- .../lens/public/visualization_container.tsx | 33 +++++---- .../datatable/components/cell_value.tsx | 74 ++++++++++++------- .../datatable/components/table_basic.tsx | 57 +++++++++++--- 3 files changed, 113 insertions(+), 51 deletions(-) diff --git a/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx b/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx index a9020278db235..5b5a73f2d106d 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx @@ -7,21 +7,22 @@ import './visualization_container.scss'; -import React from 'react'; +import React, { forwardRef } from 'react'; import classNames from 'classnames'; -export function VisualizationContainer({ - children, - className, - ...rest -}: React.HTMLAttributes) { - return ( -
- {children} -
- ); -} +type VisualizationContainerProps = React.HTMLAttributes; + +export const VisualizationContainer = forwardRef( + function VisualizationContainer({ children, className, ...rest }, ref) { + return ( +
+ {children} +
+ ); + } +); diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx index 97e7e755ac36e..a8394057656d1 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx @@ -10,6 +10,10 @@ import { EuiDataGridCellValueElementProps, EuiLink } from '@elastic/eui'; import classNames from 'classnames'; import { PaletteOutput } from '@kbn/coloring'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import { + InTableSearchHighlightsWrapper, + InTableSearchHighlightsWrapperProps, +} from '@kbn/data-grid-in-table-search'; import type { FormatFactory } from '../../../../common/types'; import type { DatatableColumnConfig } from '../../../../common/expressions'; import type { DataContextType } from './types'; @@ -40,7 +44,15 @@ export const createGridCell = ( ) => CellColorFn, fitRowToContent?: boolean ) => { - return ({ rowIndex, columnId, setCellProps, isExpanded }: EuiDataGridCellValueElementProps) => { + return ({ + rowIndex, + columnId, + setCellProps, + isExpanded, + inTableSearchTerm, + onHighlightsCountFound, + }: EuiDataGridCellValueElementProps & + Pick) => { const { table, alignments, handleFilterClick } = useContext(DataContext); const rawRowValue = table?.rows[rowIndex]?.[columnId]; const rowValue = getParsedValue(rawRowValue); @@ -84,40 +96,52 @@ export const createGridCell = ( } }, [rowValue, columnId, setCellProps, colorMode, palette, colorMapping, isExpanded]); - if (filterOnClick) { + const render = () => { + if (filterOnClick) { + return ( +
+ { + handleFilterClick?.(columnId, rawRowValue, colIndex, rowIndex); + }} + > + {content} + +
+ ); + } + return (
- { - handleFilterClick?.(columnId, rawRowValue, colIndex, rowIndex); - }} - > - {content} - -
+ /> ); - } + }; return ( -
+ + {render()} + ); }; }; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx index dc6f818eb3519..7b916db3c6253 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx @@ -26,7 +26,9 @@ import { EuiDataGridColumn, EuiDataGridSorting, EuiDataGridStyle, + EuiDataGridProps, } from '@elastic/eui'; +import { useDataGridInTableSearch } from '@kbn/data-grid-in-table-search'; import { CustomPaletteState, EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; import { IconChartDatatable } from '@kbn/chart-icons'; @@ -81,6 +83,7 @@ const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 20, 30, 50, 100]; export const DatatableComponent = (props: DatatableRenderProps) => { const dataGridRef = useRef(null); + const [dataGridWrapper, setDataGridWrapper] = useState(null); const isInteractive = props.interactive; const theme = useObservable(props.theme.theme$, { @@ -509,6 +512,42 @@ export const DatatableComponent = (props: DatatableRenderProps) => { } }, [columnConfig.columns, alignments, props.data, columns]); + const paginationObj: EuiDataGridProps['pagination'] = useMemo( + () => + pagination + ? { + ...pagination, + pageSizeOptions: PAGE_SIZE_OPTIONS, + onChangeItemsPerPage, + onChangePage, + } + : undefined, + [pagination, onChangePage, onChangeItemsPerPage] + ); + + const { inTableSearchTermCss, inTableSearchControl, extendedCellContext } = + useDataGridInTableSearch({ + dataGridWrapper, + dataGridRef, + visibleColumns, + rows: firstLocalTable.rows, + cellContext: undefined, + renderCellValue, + pagination: paginationObj, + }); + + const toolbarVisibility: EuiDataGridProps['toolbarVisibility'] = useMemo( + () => ({ + additionalControls: inTableSearchControl ? { right: inTableSearchControl } : false, + showColumnSelector: false, + showKeyboardShortcuts: false, + showFullScreenSelector: false, + showDisplaySelector: false, + showSortSelector: false, + }), + [inTableSearchControl] + ); + if (isEmpty) { return ( @@ -524,7 +563,11 @@ export const DatatableComponent = (props: DatatableRenderProps) => { }); return ( - + setDataGridWrapper(node)} + css={inTableSearchTermCss} + > { gridStyle={gridStyle} schemaDetectors={schemaDetectors} sorting={sorting} - pagination={ - pagination && { - ...pagination, - pageSizeOptions: PAGE_SIZE_OPTIONS, - onChangeItemsPerPage, - onChangePage, - } - } + pagination={paginationObj} onColumnResize={onColumnResize} - toolbarVisibility={false} + toolbarVisibility={toolbarVisibility} + cellContext={extendedCellContext} renderFooterCellValue={renderSummaryRow} ref={dataGridRef} /> From 7335218a252019ef24e5cbbcdc5179e251f6fbb9 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 19:20:57 +0100 Subject: [PATCH 71/90] Revert "[Discover] Enable in-table search for Lens table" This reverts commit 2c56de09341ee9ce20c0f78ee6a89da0347014f0. --- .../lens/public/visualization_container.tsx | 33 ++++----- .../datatable/components/cell_value.tsx | 74 +++++++------------ .../datatable/components/table_basic.tsx | 57 +++----------- 3 files changed, 51 insertions(+), 113 deletions(-) diff --git a/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx b/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx index 5b5a73f2d106d..a9020278db235 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualization_container.tsx @@ -7,22 +7,21 @@ import './visualization_container.scss'; -import React, { forwardRef } from 'react'; +import React from 'react'; import classNames from 'classnames'; -type VisualizationContainerProps = React.HTMLAttributes; - -export const VisualizationContainer = forwardRef( - function VisualizationContainer({ children, className, ...rest }, ref) { - return ( -
- {children} -
- ); - } -); +export function VisualizationContainer({ + children, + className, + ...rest +}: React.HTMLAttributes) { + return ( +
+ {children} +
+ ); +} diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx index a8394057656d1..97e7e755ac36e 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/cell_value.tsx @@ -10,10 +10,6 @@ import { EuiDataGridCellValueElementProps, EuiLink } from '@elastic/eui'; import classNames from 'classnames'; import { PaletteOutput } from '@kbn/coloring'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; -import { - InTableSearchHighlightsWrapper, - InTableSearchHighlightsWrapperProps, -} from '@kbn/data-grid-in-table-search'; import type { FormatFactory } from '../../../../common/types'; import type { DatatableColumnConfig } from '../../../../common/expressions'; import type { DataContextType } from './types'; @@ -44,15 +40,7 @@ export const createGridCell = ( ) => CellColorFn, fitRowToContent?: boolean ) => { - return ({ - rowIndex, - columnId, - setCellProps, - isExpanded, - inTableSearchTerm, - onHighlightsCountFound, - }: EuiDataGridCellValueElementProps & - Pick) => { + return ({ rowIndex, columnId, setCellProps, isExpanded }: EuiDataGridCellValueElementProps) => { const { table, alignments, handleFilterClick } = useContext(DataContext); const rawRowValue = table?.rows[rowIndex]?.[columnId]; const rowValue = getParsedValue(rawRowValue); @@ -96,52 +84,40 @@ export const createGridCell = ( } }, [rowValue, columnId, setCellProps, colorMode, palette, colorMapping, isExpanded]); - const render = () => { - if (filterOnClick) { - return ( -
- { - handleFilterClick?.(columnId, rawRowValue, colIndex, rowIndex); - }} - > - {content} - -
- ); - } - + if (filterOnClick) { return (
+ > + { + handleFilterClick?.(columnId, rawRowValue, colIndex, rowIndex); + }} + > + {content} + +
); - }; + } return ( - - {render()} - +
); }; }; diff --git a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx index 7b916db3c6253..dc6f818eb3519 100644 --- a/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx +++ b/x-pack/platform/plugins/shared/lens/public/visualizations/datatable/components/table_basic.tsx @@ -26,9 +26,7 @@ import { EuiDataGridColumn, EuiDataGridSorting, EuiDataGridStyle, - EuiDataGridProps, } from '@elastic/eui'; -import { useDataGridInTableSearch } from '@kbn/data-grid-in-table-search'; import { CustomPaletteState, EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; import { IconChartDatatable } from '@kbn/chart-icons'; @@ -83,7 +81,6 @@ const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 20, 30, 50, 100]; export const DatatableComponent = (props: DatatableRenderProps) => { const dataGridRef = useRef(null); - const [dataGridWrapper, setDataGridWrapper] = useState(null); const isInteractive = props.interactive; const theme = useObservable(props.theme.theme$, { @@ -512,42 +509,6 @@ export const DatatableComponent = (props: DatatableRenderProps) => { } }, [columnConfig.columns, alignments, props.data, columns]); - const paginationObj: EuiDataGridProps['pagination'] = useMemo( - () => - pagination - ? { - ...pagination, - pageSizeOptions: PAGE_SIZE_OPTIONS, - onChangeItemsPerPage, - onChangePage, - } - : undefined, - [pagination, onChangePage, onChangeItemsPerPage] - ); - - const { inTableSearchTermCss, inTableSearchControl, extendedCellContext } = - useDataGridInTableSearch({ - dataGridWrapper, - dataGridRef, - visibleColumns, - rows: firstLocalTable.rows, - cellContext: undefined, - renderCellValue, - pagination: paginationObj, - }); - - const toolbarVisibility: EuiDataGridProps['toolbarVisibility'] = useMemo( - () => ({ - additionalControls: inTableSearchControl ? { right: inTableSearchControl } : false, - showColumnSelector: false, - showKeyboardShortcuts: false, - showFullScreenSelector: false, - showDisplaySelector: false, - showSortSelector: false, - }), - [inTableSearchControl] - ); - if (isEmpty) { return ( @@ -563,11 +524,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { }); return ( - setDataGridWrapper(node)} - css={inTableSearchTermCss} - > + { gridStyle={gridStyle} schemaDetectors={schemaDetectors} sorting={sorting} - pagination={paginationObj} + pagination={ + pagination && { + ...pagination, + pageSizeOptions: PAGE_SIZE_OPTIONS, + onChangeItemsPerPage, + onChangePage, + } + } onColumnResize={onColumnResize} - toolbarVisibility={toolbarVisibility} - cellContext={extendedCellContext} + toolbarVisibility={false} renderFooterCellValue={renderSummaryRow} ref={dataGridRef} /> From 619167ddc9bbed8ec01471e9ac978f053f7f5fbf Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 22 Jan 2025 19:22:34 +0100 Subject: [PATCH 72/90] [Discover] Update readme --- .../packages/shared/kbn-data-grid-in-table-search/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 40a7c9774679d..730aebdef43dd 100644 --- 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 @@ -12,7 +12,7 @@ If you are using `EuiDataGrid` directly, you can enable in-table search by impor 4. update your `renderCellValue` to accept `inTableSearchTerm` and `onHighlightsCountFound` props 5. update your `renderCellValue` to wrap the cell content with `InTableSearchHighlightsWrapper` component. -Examples could be found inside `kbn-unified-data-table` package. +Examples could be found inside `kbn-unified-data-table` package or in this [tmp commit](https://github.com/elastic/kibana/pull/206454/commits/2c56de09341ee9ce20c0f78ee6a89da0347014f0). From e099632ef8acf846f9ffdfdc88333c1e44ef6e3a Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 23 Jan 2025 15:32:45 +0100 Subject: [PATCH 73/90] [Discover] Use an increasing speed for rows processing. Allow to measure the in-table search speed. --- .../src/use_in_table_search_matches.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx index 1a7526226a9b8..02db7a783c619 100644 --- a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx @@ -84,6 +84,8 @@ export const useInTableSearchMatches = ( return; } + // const startTime = window.performance.now(); + const numberOfRuns = numberOfRunsRef.current; const onFinish: AllCellsProps['onFinish'] = ({ @@ -112,6 +114,9 @@ export const useInTableSearchMatches = ( onScrollToActiveMatch, }); } + + // const duration = window.performance.now() - startTime; + // console.log(duration); }; const RenderCellsShadowPortal: UseInTableSearchMatchesState['renderCellsShadowPortal'] = () => ( @@ -297,13 +302,19 @@ function AllCellsHighlightsCounter(props: AllCellsProps) { return createPortal(, container); } -const ROWS_CHUNK_SIZE = 10; // process 10 rows at the time +// Process 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 = 300; function AllCells(props: AllCellsProps) { const { inTableSearchTerm, visibleColumns, renderCellValue, rowsCount, onFinish } = props; const matchesListRef = useRef([]); const totalMatchesCountRef = useRef(0); - const initialChunkSize = Math.min(ROWS_CHUNK_SIZE, rowsCount); + const initialChunkSize = Math.min(INITIAL_ROWS_CHUNK_SIZE, rowsCount); const [{ chunkStartRowIndex, chunkSize }, setChunk] = useState<{ chunkStartRowIndex: number; chunkSize: number; @@ -334,10 +345,14 @@ function AllCells(props: AllCellsProps) { }); // moving to the next chunk if there are more rows to process - const nextRowIndex = chunkStartRowIndex + ROWS_CHUNK_SIZE; + const nextRowIndex = chunkStartRowIndex + chunkSize; if (nextRowIndex < rowsCount) { - const nextChunkSize = Math.min(ROWS_CHUNK_SIZE, rowsCount - nextRowIndex); + 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 }); @@ -348,7 +363,7 @@ function AllCells(props: AllCellsProps) { }); } }, - [setChunk, chunkStartRowIndex, rowsCount, onFinish] + [setChunk, chunkStartRowIndex, chunkSize, rowsCount, onFinish] ); // Iterating through rows one chunk at the time to avoid blocking the main thread. From 9990531f9dfca583e1d4e909911bc0be6a1b9171 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 24 Jan 2025 16:23:20 +0100 Subject: [PATCH 74/90] [Discover] Update styles --- .../src/in_table_search_control.tsx | 62 +++++++++---------- 1 file changed, 29 insertions(+), 33 deletions(-) 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 index a678bfc61f32a..64ac6ff0aa6af 100644 --- 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 @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; +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'; @@ -17,6 +17,30 @@ import { } from './use_in_table_search_matches'; import { InTableSearchInput, INPUT_TEST_SUBJ } from './in_table_search_input'; +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; + } +`; + const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; export interface InTableSearchControlProps @@ -144,38 +168,6 @@ export const InTableSearchControl: React.FC = ({ } }, [isInputVisible]); - const innerCss = useMemo( - () => css` - .dataGridInTableSearch__matchesCounter { - font-variant-numeric: tabular-nums; - } - - .dataGridInTableSearch__button { - /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ - min-height: 2 * ${euiTheme.size.base}; // input height - } - - .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; - } - `, - [euiTheme] - ); - return (
(containerRef.current = node)} css={innerCss}> {isInputVisible ? ( @@ -207,6 +199,10 @@ export const InTableSearchControl: React.FC = ({ aria-label={i18n.translate('dataGridInTableSearch.buttonSearch', { defaultMessage: 'Search in the table', })} + css={css` + /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ + min-height: calc(2 * ${euiTheme.size.base}); // input height + `} onClick={showInput} /> From 99219275492672b290ec202f59c2910d103364ec Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 24 Jan 2025 16:51:18 +0100 Subject: [PATCH 75/90] [Discover] Better reference management --- .../src/use_in_table_search_matches.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx index 02db7a783c619..85f7748367e83 100644 --- a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx @@ -287,19 +287,22 @@ function changeActiveMatchInState( } function AllCellsHighlightsCounter(props: AllCellsProps) { - const [container] = useState(() => document.createDocumentFragment()); - const containerRef = useRef(); - containerRef.current = container; + const containerRef = useRef(document.createDocumentFragment()); useEffect(() => { return () => { if (containerRef.current) { unmountComponentAtNode(containerRef.current); + containerRef.current = null; } }; }, []); - return createPortal(, container); + if (!containerRef.current) { + return null; + } + + return createPortal(, containerRef.current); } // Process rows in chunks: @@ -308,7 +311,7 @@ function AllCellsHighlightsCounter(props: AllCellsProps) { 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 = 300; +const ROWS_CHUNK_SIZE_MAX = 100; function AllCells(props: AllCellsProps) { const { inTableSearchTerm, visibleColumns, renderCellValue, rowsCount, onFinish } = props; @@ -446,7 +449,7 @@ function RowCells({ clearTimeout(timerRef.current); } }; - }, []); + }, [rowIndex]); return ( <> From 30228658adf31b3aa54df94e57eea65b163b0c73 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 24 Jan 2025 18:04:14 +0100 Subject: [PATCH 76/90] [Discover] Reorganize the code before adding tests --- .../kbn-data-grid-in-table-search/index.ts | 5 +- .../src/constants.ts | 13 + .../src/in_table_search_control.tsx | 31 +- .../in_table_search_highlights_wrapper.tsx | 19 +- .../src/matches/all_cells_matches_counter.tsx | 34 ++ .../src/matches/all_cells_renderer.tsx | 98 ++++ .../src/matches/row_cells_renderer.tsx | 137 +++++ .../src/matches/use_find_matches.tsx | 244 +++++++++ .../src/types.ts | 63 +++ .../src/use_in_table_search_matches.tsx | 514 ------------------ .../src/utils/get_render_cell_value.tsx | 12 +- 11 files changed, 623 insertions(+), 547 deletions(-) create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/constants.ts create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_matches_counter.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/use_find_matches.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/types.ts delete mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx 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 index 72b9c779f167c..d962fd0b1baf0 100644 --- 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 @@ -10,7 +10,10 @@ export { InTableSearchHighlightsWrapper, useDataGridInTableSearch, - type InTableSearchHighlightsWrapperProps, type UseDataGridInTableSearchProps, type UseDataGridInTableSearchReturn, } from './src'; +export type { + RenderCellValuePropsWithInTableSearch, + InTableSearchHighlightsWrapperProps, +} from './src/types'; 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..859861f47d41f --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/constants.ts @@ -0,0 +1,13 @@ +/* + * 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 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 index 64ac6ff0aa6af..ec542e07cf0b8 100644 --- 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 @@ -11,11 +11,14 @@ 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 { - useInTableSearchMatches, - UseInTableSearchMatchesProps, -} from './use_in_table_search_matches'; +import { useFindMatches } from './matches/use_find_matches'; import { InTableSearchInput, INPUT_TEST_SUBJ } from './in_table_search_input'; +import { UseFindMatchesProps } from './types'; +import { + ACTIVE_HIGHLIGHT_COLOR, + CELL_MATCH_INDEX_ATTRIBUTE, + HIGHLIGHT_CLASS_NAME, +} from './constants'; const innerCss = css` .dataGridInTableSearch__matchesCounter { @@ -44,7 +47,7 @@ const innerCss = css` const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; export interface InTableSearchControlProps - extends Omit { + extends Omit { pageSize: number | null; // null when the pagination is disabled getColumnIndexFromId: (columnId: string) => number; scrollToCell: (params: { rowIndex: number; columnIndex: number; align: 'center' }) => void; @@ -69,15 +72,14 @@ export const InTableSearchControl: React.FC = ({ const shouldReturnFocusToButtonRef = useRef(false); const [isInputVisible, setIsInputVisible] = useState(false); - const onScrollToActiveMatch: UseInTableSearchMatchesProps['onScrollToActiveMatch'] = useCallback( + const onScrollToActiveMatch: UseFindMatchesProps['onScrollToActiveMatch'] = useCallback( ({ rowIndex, columnId, matchIndexWithinCell }) => { if (typeof pageSize === 'number') { const expectedPageIndex = Math.floor(rowIndex / pageSize); onChangeToExpectedPage(expectedPageIndex); } - // TODO: use a named color token - // The cell border is useful when the active match is not visible due to the cell height. + // 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 { @@ -86,11 +88,11 @@ export const InTableSearchControl: React.FC = ({ pointer-events: none; position: absolute; inset: 0; - border: 2px solid #ffc30e !important; + border: 2px solid ${ACTIVE_HIGHLIGHT_COLOR} !important; border-radius: 3px; } - .dataGridInTableSearch__match[data-match-index='${matchIndexWithinCell}'] { - background-color: #ffc30e !important; + .${HIGHLIGHT_CLASS_NAME}[${CELL_MATCH_INDEX_ATTRIBUTE}='${matchIndexWithinCell}'] { + background-color: ${ACTIVE_HIGHLIGHT_COLOR} !important; } } `); @@ -115,7 +117,7 @@ export const InTableSearchControl: React.FC = ({ goToNextMatch, renderCellsShadowPortal, resetState, - } = useInTableSearchMatches({ ...props, onScrollToActiveMatch }); + } = useFindMatches({ ...props, onScrollToActiveMatch }); const showInput = useCallback(() => { setIsInputVisible(true); @@ -130,6 +132,7 @@ export const InTableSearchControl: React.FC = ({ [setIsInputVisible, resetState] ); + // listens for the cmd+f or ctrl+f keydown event to open the input useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { if ( @@ -151,12 +154,12 @@ export const InTableSearchControl: React.FC = ({ document.addEventListener('keydown', handleGlobalKeyDown); - // Cleanup the event listener 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; @@ -181,6 +184,8 @@ export const InTableSearchControl: React.FC = ({ onChangeSearchTerm={onChange} onHideInput={hideInput} /> + {/* 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.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.tsx index 8984b0620a038..a4f62634ee11c 100644 --- 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 @@ -7,15 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { ReactNode, useEffect, useRef } from 'react'; +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'; -export interface InTableSearchHighlightsWrapperProps { - inTableSearchTerm?: string; - onHighlightsCountFound?: (count: number) => void; - children: ReactNode; -} - +/** + * Counts and highlights search term matches in the children of the component + */ export const InTableSearchHighlightsWrapper: React.FC = ({ inTableSearchTerm, onHighlightsCountFound, @@ -87,9 +86,9 @@ function modifyDOMAndAddSearchHighlights( if (searchTermRegExp.test(part)) { const mark = document.createElement('mark'); mark.textContent = part; - mark.style.backgroundColor = '#e5ffc0'; // TODO: Use a named color token - mark.setAttribute('class', 'dataGridInTableSearch__match'); - mark.setAttribute('data-match-index', `${matchIndex++}`); + 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)); 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.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.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.tsx new file mode 100644 index 0000000000000..212de6225fe18 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.tsx @@ -0,0 +1,137 @@ +/* + * 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); + + // all cells in the row were processed + const onComplete = useCallback(() => { + 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) => { + return ( + { + onCellProcessed(columnId, 0); + }} + > + {}} + inTableSearchTerm={inTableSearchTerm} + onHighlightsCountFound={(count) => { + // 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; + } +} 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..75feeb7c757a4 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/types.ts @@ -0,0 +1,63 @@ +/* + * 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 interface UseFindMatchesProps { + inTableSearchTerm: string; + visibleColumns: string[]; + rows: unknown[]; + renderCellValue: (props: RenderCellValuePropsWithInTableSearch) => ReactNode; + 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_in_table_search_matches.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx deleted file mode 100644 index 85f7748367e83..0000000000000 --- a/src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_in_table_search_matches.tsx +++ /dev/null @@ -1,514 +0,0 @@ -/* - * 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, ReactNode, useRef, useMemo } from 'react'; -import { createPortal, unmountComponentAtNode } from 'react-dom'; -import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { InTableSearchHighlightsWrapperProps } from './in_table_search_highlights_wrapper'; - -interface RowMatches { - rowIndex: number; - rowMatchesCount: number; - matchesCountPerColumnId: Record; -} - -interface ActiveMatch { - rowIndex: number; - columnId: string; - matchIndexWithinCell: number; -} - -export interface UseInTableSearchMatchesProps { - inTableSearchTerm: string; - visibleColumns: string[]; - rows: unknown[]; - renderCellValue: ( - props: EuiDataGridCellValueElementProps & - Pick - ) => ReactNode; - onScrollToActiveMatch: (activeMatch: ActiveMatch) => void; -} - -interface UseInTableSearchMatchesState { - matchesList: RowMatches[]; - matchesCount: number | null; - activeMatchPosition: number | null; - columns: string[]; - isProcessing: boolean; - renderCellsShadowPortal: (() => ReactNode) | null; -} - -export interface UseInTableSearchMatchesReturn - extends Omit { - goToPrevMatch: () => void; - goToNextMatch: () => void; - resetState: () => void; -} - -const INITIAL_STATE: UseInTableSearchMatchesState = { - matchesList: [], - matchesCount: null, - activeMatchPosition: null, - columns: [], - isProcessing: false, - renderCellsShadowPortal: null, -}; - -type AllCellsProps = Pick< - UseInTableSearchMatchesProps, - 'renderCellValue' | 'visibleColumns' | 'inTableSearchTerm' -> & { - rowsCount: number; - onFinish: (params: { matchesList: RowMatches[]; totalMatchesCount: number }) => void; -}; - -export const useInTableSearchMatches = ( - props: UseInTableSearchMatchesProps -): UseInTableSearchMatchesReturn => { - 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: UseInTableSearchMatchesState['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 getActiveMatch({ - 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 = getActiveMatch({ - matchPosition, - matchesList, - columns, - }); - - if (activeMatch) { - onScrollToActiveMatch(activeMatch); - } - }, 0); -} - -function changeActiveMatchInState( - prevState: UseInTableSearchMatchesState, - direction: 'prev' | 'next', - onScrollToActiveMatch: (activeMatch: ActiveMatch) => void -): UseInTableSearchMatchesState { - 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, - }; -} - -function AllCellsHighlightsCounter(props: AllCellsProps) { - const containerRef = useRef(document.createDocumentFragment()); - - useEffect(() => { - return () => { - if (containerRef.current) { - unmountComponentAtNode(containerRef.current); - containerRef.current = null; - } - }; - }, []); - - if (!containerRef.current) { - return null; - } - - return createPortal(, containerRef.current); -} - -// Process 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; - -function AllCells(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); - - const onRowHighlightsCountFound = 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 ( - - ); - })} - - ); -} - -const TIMEOUT_PER_ROW = 2000; // 2 sec per row max - -function RowCells({ - rowIndex, - inTableSearchTerm, - visibleColumns, - renderCellValue, - onRowHighlightsCountFound, -}: Omit & { - rowIndex: number; - onRowHighlightsCountFound: (rowMatch: RowMatches) => void; -}) { - const UnifiedDataTableRenderCellValue = renderCellValue; - const timerRef = useRef(); - const matchesCountPerColumnIdRef = useRef>({}); - const rowMatchesCountRef = useRef(0); - const remainingNumberOfResultsRef = useRef(visibleColumns.length); - - const onComplete = useCallback(() => { - onRowHighlightsCountFound({ - rowIndex, - rowMatchesCount: rowMatchesCountRef.current, - matchesCountPerColumnId: matchesCountPerColumnIdRef.current, - }); - }, [rowIndex, onRowHighlightsCountFound]); - const onCompleteRef = useRef<() => void>(); - onCompleteRef.current = onComplete; - - const onCellHighlightsCountFound = 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) => { - return ( - { - onCellHighlightsCountFound(columnId, 0); - }} - > - {}} - inTableSearchTerm={inTableSearchTerm} - onHighlightsCountFound={(count) => { - // you can comment out the next line to observe that the row timeout is working as expected. - onCellHighlightsCountFound(columnId, count); - }} - /> - - ); - })} - - ); -} - -/** - * Renders nothing instead of a component which triggered an exception. - */ -export 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; - } -} diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 2a9e0027d746b..82d1919c35c64 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -10,18 +10,13 @@ import React, { useEffect, useContext, memo } from 'react'; import { i18n } from '@kbn/i18n'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { - EuiDataGridCellValueElementProps, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { DataTableRecord, ShouldShowFieldInTableHandler } from '@kbn/discover-utils/types'; import { formatFieldValue } from '@kbn/discover-utils'; import { InTableSearchHighlightsWrapper, - InTableSearchHighlightsWrapperProps, + RenderCellValuePropsWithInTableSearch, } from '@kbn/data-grid-in-table-search'; import { UnifiedDataTableContext } from '../table_context'; import type { CustomCellRenderer } from '../types'; @@ -64,8 +59,7 @@ export const getRenderCellValueFn = ({ isExpanded, inTableSearchTerm, onHighlightsCountFound, - }: EuiDataGridCellValueElementProps & - Pick) => { + }: RenderCellValuePropsWithInTableSearch) => { const row = rows ? rows[rowIndex] : undefined; const field = dataView.fields.getByName(columnId); const ctx = useContext(UnifiedDataTableContext); From 0e8def0d2102a8a23712c05263eb1c97f743cc66 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Fri, 24 Jan 2025 21:54:09 +0100 Subject: [PATCH 77/90] [Discover] Switch to a wrapper for renderCellValue --- .../kbn-data-grid-in-table-search/README.md | 55 ++- .../kbn-data-grid-in-table-search/index.ts | 5 - .../src/index.ts | 4 - .../src/types.ts | 4 +- .../src/use_data_grid_in_table_search.tsx | 36 +- .../src/wrap_render_cell_value.tsx | 36 ++ .../src/components/data_table.tsx | 30 +- .../src/utils/get_render_cell_value.test.tsx | 358 ++++++++---------- .../src/utils/get_render_cell_value.tsx | 151 ++++---- 9 files changed, 368 insertions(+), 311 deletions(-) create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/wrap_render_cell_value.tsx 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 index 730aebdef43dd..26e35fb83d83a 100644 --- 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 @@ -4,15 +4,64 @@ 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 `renderCellValue` to accept `inTableSearchTerm` and `onHighlightsCountFound` props -5. update your `renderCellValue` to wrap the cell content with `InTableSearchHighlightsWrapper` component. +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}> + +
+``` -Examples could be found inside `kbn-unified-data-table` package or in this [tmp commit](https://github.com/elastic/kibana/pull/206454/commits/2c56de09341ee9ce20c0f78ee6a89da0347014f0). +Examples can be found inside `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 index d962fd0b1baf0..5a7105da098a2 100644 --- 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 @@ -8,12 +8,7 @@ */ export { - InTableSearchHighlightsWrapper, useDataGridInTableSearch, type UseDataGridInTableSearchProps, type UseDataGridInTableSearchReturn, } from './src'; -export type { - RenderCellValuePropsWithInTableSearch, - InTableSearchHighlightsWrapperProps, -} from './src/types'; 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 index cce16b96039d9..02a61e25ce5af 100644 --- 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 @@ -7,10 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { - InTableSearchHighlightsWrapper, - type InTableSearchHighlightsWrapperProps, -} from './in_table_search_highlights_wrapper'; export { useDataGridInTableSearch, type UseDataGridInTableSearchProps, 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 index 75feeb7c757a4..2e70b7367e8e7 100644 --- 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 @@ -31,11 +31,13 @@ export interface InTableSearchHighlightsWrapperProps { export type RenderCellValuePropsWithInTableSearch = EuiDataGridCellValueElementProps & Pick; +export type RenderCellValueWrapper = (props: RenderCellValuePropsWithInTableSearch) => ReactNode; + export interface UseFindMatchesProps { inTableSearchTerm: string; visibleColumns: string[]; rows: unknown[]; - renderCellValue: (props: RenderCellValuePropsWithInTableSearch) => ReactNode; + renderCellValue: RenderCellValueWrapper; onScrollToActiveMatch: (activeMatch: ActiveMatch) => void; } 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 index e47c9958df9f7..2c33a49d14dd9 100644 --- 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 @@ -10,16 +10,18 @@ import React, { useMemo, useRef, useState } from 'react'; import type { SerializedStyles } from '@emotion/react'; import type { EuiDataGridProps, EuiDataGridRefProps } from '@elastic/eui'; -import { InTableSearchControl } from './in_table_search_control'; -import { InTableSearchControlProps } from './in_table_search_control'; +import { InTableSearchControl, InTableSearchControlProps } from './in_table_search_control'; +import { RenderCellValueWrapper } from './types'; +import { wrapRenderCellValueWithInTableSearchSupport } from './wrap_render_cell_value'; export interface UseDataGridInTableSearchProps - extends Pick { + extends Pick { enableInTableSearch?: boolean; dataGridWrapper: HTMLElement | null; dataGridRef: React.RefObject; cellContext: EuiDataGridProps['cellContext'] | undefined; pagination: EuiDataGridProps['pagination'] | undefined; + renderCellValue: EuiDataGridProps['renderCellValue']; } export interface UseDataGridInTableSearchState { @@ -30,7 +32,8 @@ export interface UseDataGridInTableSearchState { export interface UseDataGridInTableSearchReturn { inTableSearchTermCss?: UseDataGridInTableSearchState['inTableSearchTermCss']; inTableSearchControl: React.JSX.Element | undefined; - extendedCellContext: EuiDataGridProps['cellContext']; + cellContextWithInTableSearchSupport: EuiDataGridProps['cellContext']; + renderCellValueWithInTableSearchSupport: RenderCellValueWrapper; } export const useDataGridInTableSearch = ( @@ -52,6 +55,11 @@ export const useDataGridInTableSearch = ( const pageIndexRef = useRef(); pageIndexRef.current = pagination?.pageIndex ?? 0; + const renderCellValueWithInTableSearchSupport = useMemo( + () => wrapRenderCellValueWithInTableSearchSupport(renderCellValue), + [renderCellValue] + ); + const [{ inTableSearchTerm, inTableSearchTermCss }, setInTableSearchState] = useState(() => ({ inTableSearchTerm: '' })); @@ -67,7 +75,7 @@ export const useDataGridInTableSearch = ( inTableSearchTerm={inTableSearchTerm} visibleColumns={visibleColumns} rows={rows} - renderCellValue={renderCellValue} + renderCellValue={renderCellValueWithInTableSearchSupport} pageSize={pageSize} getColumnIndexFromId={(columnId) => visibleColumns.indexOf(columnId) + controlsCount} scrollToCell={(params) => { @@ -95,7 +103,7 @@ export const useDataGridInTableSearch = ( setInTableSearchState, visibleColumns, rows, - renderCellValue, + renderCellValueWithInTableSearchSupport, dataGridRef, dataGridWrapper, inTableSearchTerm, @@ -104,7 +112,7 @@ export const useDataGridInTableSearch = ( onChangePage, ]); - const extendedCellContext: EuiDataGridProps['cellContext'] = useMemo(() => { + const cellContextWithInTableSearchSupport: EuiDataGridProps['cellContext'] = useMemo(() => { if (!inTableSearchTerm && !cellContext) { return undefined; } @@ -116,7 +124,17 @@ export const useDataGridInTableSearch = ( }, [cellContext, inTableSearchTerm]); return useMemo( - () => ({ inTableSearchTermCss, inTableSearchControl, extendedCellContext }), - [inTableSearchTermCss, inTableSearchControl, extendedCellContext] + () => ({ + 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-unified-data-table/src/components/data_table.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/components/data_table.tsx index a718c49515afb..eda6c65551433 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 @@ -747,17 +747,21 @@ export const UnifiedDataTable = ({ const { dataGridId, dataGridWrapper, setDataGridWrapper } = useFullScreenWatcher(); - const { inTableSearchTermCss, inTableSearchControl, extendedCellContext } = - useDataGridInTableSearch({ - enableInTableSearch, - dataGridWrapper, - dataGridRef, - visibleColumns, - rows: displayedRows, - renderCellValue, - cellContext, - pagination: paginationObj, - }); + const { + inTableSearchTermCss, + inTableSearchControl, + cellContextWithInTableSearchSupport, + renderCellValueWithInTableSearchSupport, + } = useDataGridInTableSearch({ + enableInTableSearch, + dataGridWrapper, + dataGridRef, + visibleColumns, + rows: displayedRows, + renderCellValue, + cellContext, + pagination: paginationObj, + }); const renderCustomPopover = useMemo( () => renderCellPopover ?? getCustomCellPopoverRenderer(), @@ -1208,7 +1212,7 @@ export const UnifiedDataTable = ({ leadingControlColumns={leadingControlColumns} onColumnResize={onResize} pagination={paginationObj} - renderCellValue={renderCellValue} + renderCellValue={renderCellValueWithInTableSearchSupport} ref={dataGridRef} rowCount={rowCount} schemaDetectors={schemaDetectors} @@ -1219,7 +1223,7 @@ export const UnifiedDataTable = ({ renderCustomGridBody={renderCustomGridBody} renderCustomToolbar={renderCustomToolbarFn} trailingControlColumns={trailingControlColumns} - cellContext={extendedCellContext} + cellContext={cellContextWithInTableSearchSupport} renderCellPopover={renderCustomPopover} // Don't use row overscan when showing Document column since // rendering so much DOM content in each cell impacts performance diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx index 249ea072cfe07..0dfeb1f691e88 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.test.tsx @@ -130,7 +130,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
100
"` + `"100"` ); }); @@ -155,7 +155,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
100
"` + `"
100
"` ); }); @@ -181,7 +181,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
100
"` + `"
100
"` ); findTestSubject(component, 'docTableClosePopover').simulate('click'); expect(closePopoverMockFn).toHaveBeenCalledTimes(1); @@ -246,50 +246,46 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - - } - columnId="_source" - row={ - Object { - "flattened": Object { - "_index": "test", - "_score": 1, + + } + columnId="_source" + row={ + Object { + "flattened": Object { + "_index": "test", + "_score": 1, + "bytes": 100, + "extension": ".gz", + }, + "id": "test::1::", + "isAnchor": undefined, + "raw": Object { + "_id": "1", + "_index": "test", + "_score": 1, + "_source": Object { "bytes": 100, "extension": ".gz", }, - "id": "test::1::", - "isAnchor": undefined, - "raw": Object { - "_id": "1", - "_index": "test", - "_score": 1, - "_source": Object { - "bytes": 100, - "extension": ".gz", - }, - "highlight": Object { - "extension": Array [ - "@kibana-highlighted-field.gz@/kibana-highlighted-field", - ], - }, + "highlight": Object { + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], }, - } + }, } - useTopLevelObjectColumns={false} - /> - + } + useTopLevelObjectColumns={false} + /> `); }); @@ -431,26 +427,38 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - - } - columnId="_source" - row={ - Object { - "flattened": Object { - "_index": "test", - "_score": 1, + + } + columnId="_source" + row={ + Object { + "flattened": Object { + "_index": "test", + "_score": 1, + "bytes": Array [ + 100, + ], + "extension": Array [ + ".gz", + ], + }, + "id": "test::1::", + "isAnchor": undefined, + "raw": Object { + "_id": "1", + "_index": "test", + "_score": 1, + "_source": undefined, + "fields": Object { "bytes": Array [ 100, ], @@ -458,32 +466,16 @@ describe('Unified data table cell rendering', function () { ".gz", ], }, - "id": "test::1::", - "isAnchor": undefined, - "raw": Object { - "_id": "1", - "_index": "test", - "_score": 1, - "_source": undefined, - "fields": Object { - "bytes": Array [ - 100, - ], - "extension": Array [ - ".gz", - ], - }, - "highlight": Object { - "extension": Array [ - "@kibana-highlighted-field.gz@/kibana-highlighted-field", - ], - }, + "highlight": Object { + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], }, - } + }, } - useTopLevelObjectColumns={false} - /> - + } + useTopLevelObjectColumns={false} + /> `); }); @@ -588,26 +580,38 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - - } - columnId="object" - row={ - Object { - "flattened": Object { - "_index": "test", - "_score": 1, + + } + columnId="object" + row={ + Object { + "flattened": Object { + "_index": "test", + "_score": 1, + "extension": Array [ + ".gz", + ], + "object.value": Array [ + 100, + ], + }, + "id": "test::1::", + "isAnchor": undefined, + "raw": Object { + "_id": "1", + "_index": "test", + "_score": 1, + "_source": undefined, + "fields": Object { "extension": Array [ ".gz", ], @@ -615,32 +619,16 @@ describe('Unified data table cell rendering', function () { 100, ], }, - "id": "test::1::", - "isAnchor": undefined, - "raw": Object { - "_id": "1", - "_index": "test", - "_score": 1, - "_source": undefined, - "fields": Object { - "extension": Array [ - ".gz", - ], - "object.value": Array [ - 100, - ], - }, - "highlight": Object { - "extension": Array [ - "@kibana-highlighted-field.gz@/kibana-highlighted-field", - ], - }, + "highlight": Object { + "extension": Array [ + "@kibana-highlighted-field.gz@/kibana-highlighted-field", + ], }, - } + }, } - useTopLevelObjectColumns={true} - /> - + } + useTopLevelObjectColumns={true} + /> `); }); @@ -694,20 +682,16 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - - + } + /> `); }); @@ -732,7 +716,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
-
"` + `"-"` ); }); @@ -757,7 +741,7 @@ describe('Unified data table cell rendering', function () { /> ); expect(component.html()).toMatchInlineSnapshot( - `"
-
"` + `"-"` ); }); @@ -795,20 +779,16 @@ describe('Unified data table cell rendering', function () { /> ); expect(component).toMatchInlineSnapshot(` - - - + } + /> `); const componentWithDetails = shallow( @@ -823,42 +803,38 @@ describe('Unified data table cell rendering', function () { /> ); expect(componentWithDetails).toMatchInlineSnapshot(` - - - - - + + - - - - - - - + + + + + + `); }); }); diff --git a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx index 82d1919c35c64..117dd122b8a45 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx +++ b/src/platform/packages/shared/kbn-unified-data-table/src/utils/get_render_cell_value.tsx @@ -10,14 +10,15 @@ import React, { useEffect, useContext, memo } from 'react'; import { i18n } from '@kbn/i18n'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiDataGridCellValueElementProps, +} from '@elastic/eui'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { DataTableRecord, ShouldShowFieldInTableHandler } from '@kbn/discover-utils/types'; import { formatFieldValue } from '@kbn/discover-utils'; -import { - InTableSearchHighlightsWrapper, - RenderCellValuePropsWithInTableSearch, -} from '@kbn/data-grid-in-table-search'; import { UnifiedDataTableContext } from '../table_context'; import type { CustomCellRenderer } from '../types'; import { SourceDocument } from '../components/source_document'; @@ -57,9 +58,7 @@ export const getRenderCellValueFn = ({ colIndex, isExpandable, isExpanded, - inTableSearchTerm, - onHighlightsCountFound, - }: RenderCellValuePropsWithInTableSearch) => { + }: EuiDataGridCellValueElementProps) => { const row = rows ? rows[rowIndex] : undefined; const field = dataView.fields.getByName(columnId); const ctx = useContext(UnifiedDataTableContext); @@ -78,96 +77,78 @@ export const getRenderCellValueFn = ({ } }, [ctx, row, setCellProps]); - const render = () => { - if (typeof row === 'undefined') { - return -; - } + if (typeof row === 'undefined') { + return -; + } - const CustomCellRenderer = externalCustomRenderers?.[columnId]; - - if (CustomCellRenderer) { - return ( - - - - ); - } + const CustomCellRenderer = externalCustomRenderers?.[columnId]; - /** - * when using the fields api this code is used to show top level objects - * this is used for legacy stuff like displaying products of our ecommerce dataset - */ - const useTopLevelObjectColumns = Boolean( - !field && row?.raw.fields && !(row.raw.fields as Record)[columnId] - ); - - if (isDetails) { - return renderPopoverContent({ - row, - field, - columnId, - dataView, - useTopLevelObjectColumns, - fieldFormats, - closePopover, - }); - } - - if (field?.type === '_source' || useTopLevelObjectColumns) { - return ( - + - ); - } + + ); + } + + /** + * when using the fields api this code is used to show top level objects + * this is used for legacy stuff like displaying products of our ecommerce dataset + */ + const useTopLevelObjectColumns = Boolean( + !field && row?.raw.fields && !(row.raw.fields as Record)[columnId] + ); + if (isDetails) { + return renderPopoverContent({ + row, + field, + columnId, + dataView, + useTopLevelObjectColumns, + fieldFormats, + closePopover, + }); + } + + if (field?.type === '_source' || useTopLevelObjectColumns) { return ( - ); - }; + } return ( - - {render()} - + ); }; From 8614fddf19cc167ac2ea5f5f6d49d167889b06d8 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 27 Jan 2025 15:35:38 +0100 Subject: [PATCH 78/90] [Discover] Add unit tests for InTableSearchHighlightsWrapper --- ...n_table_search_highlights_wrapper.test.tsx | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_highlights_wrapper.test.tsx 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(); + }); + }); +}); From 9a2e0a8437db798df2bceda06d721394ea219a57 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 27 Jan 2025 16:54:47 +0100 Subject: [PATCH 79/90] [Discover] Add more unit tests --- .../in_table_search_input.test.tsx.snap | 186 ++++++++++++++++++ .../src/in_table_search_input.test.tsx | 153 ++++++++++++++ .../src/in_table_search_input.tsx | 11 +- .../src/matches/all_cells_renderer.test.tsx | 154 +++++++++++++++ .../src/matches/row_cells_renderer.test.tsx | 138 +++++++++++++ .../src/matches/row_cells_renderer.tsx | 15 +- 6 files changed, 653 insertions(+), 4 deletions(-) create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/__snapshots__/in_table_search_input.test.tsx.snap create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.test.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.test.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.test.tsx 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..9f7dbf3f84738 --- /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/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..80cd24c893d40 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.test.tsx @@ -0,0 +1,153 @@ +/* + * 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, + INPUT_TEST_SUBJ, + BUTTON_PREV_TEST_SUBJ, + BUTTON_NEXT_TEST_SUBJ, +} from './in_table_search_input'; + +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 index 691273f773c9f..ae1cfe1eeaae5 100644 --- 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 @@ -20,6 +20,9 @@ import { i18n } from '@kbn/i18n'; import { useDebouncedValue } from '@kbn/visualization-utils'; 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'; export interface InTableSearchInputProps { matchesCount: number | null; @@ -95,7 +98,11 @@ export const InTableSearchInput: React.FC = React.memo( isLoading={isProcessing} append={ - + {matchesCount && activeMatchPosition ? `${activeMatchPosition}/${matchesCount}` @@ -107,6 +114,7 @@ export const InTableSearchInput: React.FC = React.memo( = React.memo( { + const testData = Array.from({ length: 100 }, (_, i) => [`cell-a-${i}`, `cell-b-${i}`]); + + 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: 4, + matchesCountPerColumnId: { columnA: 2, columnB: 2 }, + })), + totalMatchesCount: testData.length * 2 * 2, // 2 matches in every cell + }); + }); + }); + + it('counts matches correctly', async () => { + const onFinish = jest.fn(); + const renderCellValue = jest.fn( + wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) + ); + const visibleColumns = ['columnA', 'columnB']; + const inTableSearchTerm = 'cell-a-1'; + + 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: 11, + }); + }); + }); + + 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/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..bb1d53239dd2b --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/row_cells_renderer.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 from 'react'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { render, waitFor } from '@testing-library/react'; +import { RowCellsRenderer } from './row_cells_renderer'; +import { wrapRenderCellValueWithInTableSearchSupport } from '../wrap_render_cell_value'; + +export function getRenderCellValueMock(testData: string[][]) { + return ({ colIndex, rowIndex }: EuiDataGridCellValueElementProps) => { + const cellValue = testData[rowIndex][colIndex]; + + if (!cellValue) { + throw new Error('Testing unexpected errors'); + } + + return
{cellValue}
; + }; +} + +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 index 212de6225fe18..9d0ed26db67c9 100644 --- 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 @@ -28,9 +28,14 @@ export function RowCellsRenderer({ 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, @@ -76,7 +81,7 @@ export function RowCellsRenderer({ return ( <> - {visibleColumns.map((columnId) => { + {visibleColumns.map((columnId, colIndex) => { return ( {}} + colIndex={colIndex} + setCellProps={setCellProps} inTableSearchTerm={inTableSearchTerm} onHighlightsCountFound={(count) => { // you can comment out the next line to observe that the row timeout is working as expected. @@ -135,3 +140,7 @@ class ErrorBoundary extends React.Component< return this.props.children; } } + +function setCellProps() { + // nothing to do here +} From c41387cd857dc07011e9bdae3067a4596eb740fb Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 27 Jan 2025 20:52:12 +0100 Subject: [PATCH 80/90] [Discover] Add functional tests --- .../kbn-data-grid-in-table-search/index.ts | 9 ++ .../src/constants.ts | 6 + .../src/in_table_search_control.tsx | 6 +- .../src/in_table_search_input.tsx | 11 +- .../_data_grid_in_table_search.ts | 150 ++++++++++++++++++ .../apps/discover/group2_data_grid2/index.ts | 1 + test/functional/services/data_grid.ts | 70 ++++++++ 7 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 test/functional/apps/discover/group2_data_grid2/_data_grid_in_table_search.ts 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 index 5a7105da098a2..e37b682f21bad 100644 --- 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 @@ -12,3 +12,12 @@ export { 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/src/constants.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/constants.ts index 859861f47d41f..1bb3b38da518c 100644 --- 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 @@ -11,3 +11,9 @@ 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 index ec542e07cf0b8..8733e9a496abe 100644 --- 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 @@ -12,12 +12,14 @@ 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, INPUT_TEST_SUBJ } from './in_table_search_input'; +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` @@ -44,8 +46,6 @@ const innerCss = css` } `; -const BUTTON_TEST_SUBJ = 'startInTableSearchButton'; - export interface InTableSearchControlProps extends Omit { pageSize: number | null; // null when the pagination is disabled 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 index ae1cfe1eeaae5..72b26e465ddfa 100644 --- 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 @@ -18,11 +18,12 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useDebouncedValue } from '@kbn/visualization-utils'; - -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'; +import { + COUNTER_TEST_SUBJ, + INPUT_TEST_SUBJ, + BUTTON_PREV_TEST_SUBJ, + BUTTON_NEXT_TEST_SUBJ, +} from './constants'; export interface InTableSearchInputProps { matchesCount: number | null; 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..fd71c2ffe0ac7 --- /dev/null +++ b/test/functional/apps/discover/group2_data_grid2/_data_grid_in_table_search.ts @@ -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 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 { common, discover, timePicker, unifiedFieldList, header } = getPageObjects([ + 'common', + 'discover', + 'timePicker', + 'unifiedFieldList', + 'header', + ]); + const defaultSettings = { defaultIndex: 'logstash-*' }; + const security = getService('security'); + + 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(); + + 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 4500bc5b97154..938223952e1df 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'; @@ -905,4 +913,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; + } } From 81e317ad2a4ad1f319f9a3a6d050b5564164a4fc Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:02:57 +0000 Subject: [PATCH 81/90] [CI] Auto-commit changed files from 'node scripts/generate codeowners' --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 046c2709ac8c7..d8c36403231f8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -448,6 +448,7 @@ src/platform/packages/shared/kbn-content-management-utils @elastic/kibana-data-d src/platform/packages/shared/kbn-crypto @elastic/kibana-security src/platform/packages/shared/kbn-crypto-browser @elastic/kibana-core src/platform/packages/shared/kbn-custom-icons @elastic/obs-ux-logs-team +src/platform/packages/shared/kbn-data-grid-in-table-search @elastic/kibana-data-discovery src/platform/packages/shared/kbn-data-service @elastic/kibana-visualizations @elastic/kibana-data-discovery src/platform/packages/shared/kbn-data-view-utils @elastic/kibana-data-discovery src/platform/packages/shared/kbn-datemath @elastic/kibana-data-discovery From b63a819ef9416c68fb0efd96e8a935ee225f9ccf Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:41:38 +0000 Subject: [PATCH 82/90] [CI] Auto-commit changed files from 'node scripts/notice' --- .../shared/kbn-data-grid-in-table-search/tsconfig.json | 5 ++++- .../packages/shared/kbn-unified-data-table/tsconfig.json | 2 +- test/tsconfig.json | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) 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 index b7878fd45eafe..9af05dde99eef 100644 --- 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 @@ -7,5 +7,8 @@ "exclude": [ "target/**/*" ], - "kbn_references": [] + "kbn_references": [ + "@kbn/i18n", + "@kbn/visualization-utils", + ] } diff --git a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json index bb699ca02fb5e..c3840e246552d 100644 --- a/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json +++ b/src/platform/packages/shared/kbn-unified-data-table/tsconfig.json @@ -43,6 +43,6 @@ "@kbn/core-notifications-browser", "@kbn/core-capabilities-browser-mocks", "@kbn/sort-predicates", - "@kbn/visualization-utils", + "@kbn/data-grid-in-table-search", ] } diff --git a/test/tsconfig.json b/test/tsconfig.json index 10fdefbb7b6ac..33f7900952789 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -76,5 +76,6 @@ "@kbn/default-nav-devtools", "@kbn/core-saved-objects-import-export-server-internal", "@kbn/core-deprecations-common", + "@kbn/data-grid-in-table-search", ] } From 850e6869c9e0dfcb52e649fbf8e2074edd0ca8dc Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Mon, 27 Jan 2025 22:32:42 +0100 Subject: [PATCH 83/90] [Discover] Fix refs --- .../src/in_table_search_input.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 index 80cd24c893d40..05b7e97f40174 100644 --- 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 @@ -9,12 +9,8 @@ import React from 'react'; import { render, fireEvent, screen, waitFor } from '@testing-library/react'; -import { - InTableSearchInput, - INPUT_TEST_SUBJ, - BUTTON_PREV_TEST_SUBJ, - BUTTON_NEXT_TEST_SUBJ, -} from './in_table_search_input'; +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 () => { From 2ae25b0e32db30adb8a9563e0e66b8e30ac5c71e Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 28 Jan 2025 11:57:56 +0100 Subject: [PATCH 84/90] Update src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx Co-authored-by: florent-leborgne --- .../src/in_table_search_control.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8733e9a496abe..be126bef436ea 100644 --- 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 @@ -191,7 +191,7 @@ export const InTableSearchControl: React.FC = ({ ) : ( From b5ad1046f82312fe01b7a3c6312f2cd9aacb02ad Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 28 Jan 2025 11:58:30 +0100 Subject: [PATCH 85/90] Update src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_control.tsx Co-authored-by: florent-leborgne --- .../src/in_table_search_control.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index be126bef436ea..cab302a30c7a9 100644 --- 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 @@ -202,7 +202,7 @@ export const InTableSearchControl: React.FC = ({ color="text" className="dataGridInTableSearch__button" aria-label={i18n.translate('dataGridInTableSearch.buttonSearch', { - defaultMessage: 'Search in the table', + defaultMessage: 'Find in table', })} css={css` /* to make the transition between the button and input more seamless for cases where a custom toolbar is not used */ From 062c4fe9f31f5224121cdf873fe2bf0e8b866ea6 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 28 Jan 2025 11:58:38 +0100 Subject: [PATCH 86/90] Update src/platform/packages/shared/kbn-data-grid-in-table-search/src/in_table_search_input.tsx Co-authored-by: florent-leborgne --- .../kbn-data-grid-in-table-search/src/in_table_search_input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 72b26e465ddfa..f189b82526a73 100644 --- 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 @@ -138,7 +138,7 @@ export const InTableSearchInput: React.FC = React.memo(
} placeholder={i18n.translate('dataGridInTableSearch.inputPlaceholder', { - defaultMessage: 'Search in the table', + defaultMessage: 'Find in table', })} value={inputValue} onChange={onInputChange} From c84e7b7d2fedd13035572360d7e552d09023798f Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 28 Jan 2025 13:33:35 +0100 Subject: [PATCH 87/90] [Discover] Fix test snapshots --- .../src/__snapshots__/in_table_search_input.test.tsx.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 9f7dbf3f84738..3ef95996170af 100644 --- 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 @@ -25,7 +25,7 @@ exports[`InTableSearchInput renders input 1`] = ` @@ -112,7 +112,7 @@ exports[`InTableSearchInput renders input when loading 1`] = ` From 60f8fd9b87f63205d33376d369ea459949ed9852 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 28 Jan 2025 15:09:39 +0100 Subject: [PATCH 88/90] [Discover] Fix flaky test --- .../group2_data_grid2/_data_grid_in_table_search.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index fd71c2ffe0ac7..5ea01974b47ec 100644 --- 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 @@ -20,6 +20,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 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', @@ -28,7 +30,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'header', ]); const defaultSettings = { defaultIndex: 'logstash-*' }; - const security = getService('security'); describe('discover data grid in-table search', function describeIndexTests() { before(async () => { @@ -80,6 +81,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 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'); From 179a6fdb156bc9f87934399220806797dfaa29e6 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 29 Jan 2025 17:27:18 +0100 Subject: [PATCH 89/90] [Discover] Add more tests --- .../kbn-data-grid-in-table-search/README.md | 3 +- .../__mocks__/data.ts | 22 +++ .../__mocks__/data_grid_example.tsx | 95 ++++++++++++ .../__mocks__/index.ts | 12 ++ .../__mocks__/render_cell_value_mock.tsx | 26 ++++ .../src/matches/all_cells_renderer.test.tsx | 16 +- .../src/matches/row_cells_renderer.test.tsx | 14 +- .../use_data_grid_in_table_search.test.tsx | 138 ++++++++++++++++++ .../src/use_data_grid_in_table_search.tsx | 2 +- .../src/components/data_table.test.tsx | 61 +++++++- 10 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data.ts create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data_grid_example.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/index.ts create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/render_cell_value_mock.tsx create mode 100644 src/platform/packages/shared/kbn-data-grid-in-table-search/src/use_data_grid_in_table_search.test.tsx 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 index 26e35fb83d83a..815b17440afc0 100644 --- 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 @@ -61,7 +61,8 @@ If you are using `EuiDataGrid` directly, you can enable in-table search by impor
``` -Examples can be found inside `kbn-unified-data-table` package. +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/__mocks__/data.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data.ts new file mode 100644 index 0000000000000..a578cd05b163a --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/__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/__mocks__/data_grid_example.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data_grid_example.tsx new file mode 100644 index 0000000000000..3f34fe11ddf10 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/__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 '../src/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/__mocks__/index.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/index.ts new file mode 100644 index 0000000000000..6ede0a0b63ea6 --- /dev/null +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/__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/__mocks__/render_cell_value_mock.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/__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/__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/matches/all_cells_renderer.test.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/matches/all_cells_renderer.test.tsx index cedb3a6e825f2..ee57a87786681 100644 --- 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 @@ -10,11 +10,11 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { AllCellsRenderer } from './all_cells_renderer'; -import { getRenderCellValueMock } from './row_cells_renderer.test'; +import { getRenderCellValueMock, generateMockData } from '../../__mocks__'; import { wrapRenderCellValueWithInTableSearchSupport } from '../wrap_render_cell_value'; describe('AllCellsRenderer', () => { - const testData = Array.from({ length: 100 }, (_, i) => [`cell-a-${i}`, `cell-b-${i}`]); + const testData = generateMockData(100, 2); const originalRenderCellValue = jest.fn(getRenderCellValueMock(testData)); @@ -74,21 +74,21 @@ describe('AllCellsRenderer', () => { expect(onFinish).toHaveBeenCalledWith({ matchesList: testData.map((rowData, rowIndex) => ({ rowIndex, - rowMatchesCount: 4, - matchesCountPerColumnId: { columnA: 2, columnB: 2 }, + rowMatchesCount: 10, + matchesCountPerColumnId: { columnA: 5, columnB: 5 }, })), - totalMatchesCount: testData.length * 2 * 2, // 2 matches in every cell + totalMatchesCount: testData.length * 5 * 2, // 5 matches per cell, 2 cells in a row }); }); }); - it('counts matches correctly', async () => { + it('counts a single match correctly', async () => { const onFinish = jest.fn(); const renderCellValue = jest.fn( wrapRenderCellValueWithInTableSearchSupport(originalRenderCellValue) ); const visibleColumns = ['columnA', 'columnB']; - const inTableSearchTerm = 'cell-a-1'; + const inTableSearchTerm = 'cell-in-row-10-col-0'; render( { }; }) .filter(Boolean), - totalMatchesCount: 11, + totalMatchesCount: 1, }); }); }); 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 index bb1d53239dd2b..b9c9f5d02d3c3 100644 --- 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 @@ -8,22 +8,10 @@ */ import React from 'react'; -import { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { render, waitFor } from '@testing-library/react'; import { RowCellsRenderer } from './row_cells_renderer'; import { wrapRenderCellValueWithInTableSearchSupport } from '../wrap_render_cell_value'; - -export function getRenderCellValueMock(testData: string[][]) { - return ({ colIndex, rowIndex }: EuiDataGridCellValueElementProps) => { - const cellValue = testData[rowIndex][colIndex]; - - if (!cellValue) { - throw new Error('Testing unexpected errors'); - } - - return
{cellValue}
; - }; -} +import { getRenderCellValueMock } from '../../__mocks__/render_cell_value_mock'; describe('RowCellsRenderer', () => { const testData = [ 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..b159a07aecffe --- /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 index 2c33a49d14dd9..13f7a138e8124 100644 --- 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 @@ -18,7 +18,7 @@ export interface UseDataGridInTableSearchProps extends Pick { enableInTableSearch?: boolean; dataGridWrapper: HTMLElement | null; - dataGridRef: React.RefObject; + dataGridRef: React.RefObject; cellContext: EuiDataGridProps['cellContext'] | undefined; pagination: EuiDataGridProps['pagination'] | undefined; renderCellValue: EuiDataGridProps['renderCellValue']; 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 0769e1b1ffad0..73b6fcf6a9bdc 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'; @@ -1460,4 +1467,56 @@ describe('UnifiedDataTable', () => { expect(onChangePageMock).toHaveBeenNthCalledWith(1, 0); }); }); + + describe('enableInTableSearch', () => { + it( + 'should render find-button if enableInTableSearch is true', + 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(() => { + 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'); + }); + }, + 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 + ); + }); }); From 90673c8973fb2cb9c8292fd1e88a07e656161be7 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 29 Jan 2025 17:59:33 +0100 Subject: [PATCH 90/90] [Discover] Update tests --- .../{ => src}/__mocks__/data.ts | 0 .../{ => src}/__mocks__/data_grid_example.tsx | 2 +- .../{ => src}/__mocks__/index.ts | 0 .../__mocks__/render_cell_value_mock.tsx | 0 .../src/matches/all_cells_renderer.test.tsx | 2 +- .../src/matches/row_cells_renderer.test.tsx | 2 +- .../use_data_grid_in_table_search.test.tsx | 2 +- .../src/components/data_table.test.tsx | 63 ++++++++++++++++--- 8 files changed, 58 insertions(+), 13 deletions(-) rename src/platform/packages/shared/kbn-data-grid-in-table-search/{ => src}/__mocks__/data.ts (100%) rename src/platform/packages/shared/kbn-data-grid-in-table-search/{ => src}/__mocks__/data_grid_example.tsx (97%) rename src/platform/packages/shared/kbn-data-grid-in-table-search/{ => src}/__mocks__/index.ts (100%) rename src/platform/packages/shared/kbn-data-grid-in-table-search/{ => src}/__mocks__/render_cell_value_mock.tsx (100%) diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data.ts similarity index 100% rename from src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data.ts rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data.ts diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data_grid_example.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data_grid_example.tsx similarity index 97% rename from src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data_grid_example.tsx rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data_grid_example.tsx index 3f34fe11ddf10..a495918688cbc 100644 --- a/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/data_grid_example.tsx +++ b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/data_grid_example.tsx @@ -11,7 +11,7 @@ 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 '../src/use_data_grid_in_table_search'; +import { useDataGridInTableSearch } from '../use_data_grid_in_table_search'; export interface DataGridWithInTableSearchExampleProps { rowsCount: number; diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/index.ts b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/index.ts similarity index 100% rename from src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/index.ts rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/index.ts diff --git a/src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/render_cell_value_mock.tsx b/src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/render_cell_value_mock.tsx similarity index 100% rename from src/platform/packages/shared/kbn-data-grid-in-table-search/__mocks__/render_cell_value_mock.tsx rename to src/platform/packages/shared/kbn-data-grid-in-table-search/src/__mocks__/render_cell_value_mock.tsx 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 index ee57a87786681..5d8bc7d95e30d 100644 --- 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 @@ -10,7 +10,7 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { AllCellsRenderer } from './all_cells_renderer'; -import { getRenderCellValueMock, generateMockData } from '../../__mocks__'; +import { getRenderCellValueMock, generateMockData } from '../__mocks__'; import { wrapRenderCellValueWithInTableSearchSupport } from '../wrap_render_cell_value'; describe('AllCellsRenderer', () => { 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 index b9c9f5d02d3c3..e759a0d668f28 100644 --- 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 @@ -11,7 +11,7 @@ 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__/render_cell_value_mock'; +import { getRenderCellValueMock } from '../__mocks__'; describe('RowCellsRenderer', () => { const testData = [ 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 index b159a07aecffe..2ee72a004ee25 100644 --- 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 @@ -13,7 +13,7 @@ import { DataGridWithInTableSearchExample, generateMockData, getRenderCellValueMock, -} from '../__mocks__'; +} from './__mocks__'; import { useDataGridInTableSearch } from './use_data_grid_in_table_search'; import { BUTTON_TEST_SUBJ, 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 73b6fcf6a9bdc..ddc0a3c44a366 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 @@ -1470,7 +1470,50 @@ describe('UnifiedDataTable', () => { describe('enableInTableSearch', () => { it( - 'should render find-button if enableInTableSearch is true', + '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'] }); @@ -1486,6 +1529,7 @@ describe('UnifiedDataTable', () => { expect(input).toHaveValue(searchTerm); await waitFor(() => { + // 3 results for `bytes` column with value `50` expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('1/3'); }); @@ -1505,16 +1549,17 @@ describe('UnifiedDataTable', () => { await waitFor(() => { expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('2/3'); }); - }, - EXTENDED_JEST_TIMEOUT - ); - it( - 'should not render find-button if enableInTableSearch is false', - async () => { - await renderDataTable({ enableInTableSearch: false, columns: ['bytes'] }); + const anotherSearchTerm = 'random'; + fireEvent.change(screen.getByTestId(INPUT_TEST_SUBJ), { + target: { value: anotherSearchTerm }, + }); + expect(screen.getByTestId(INPUT_TEST_SUBJ)).toHaveValue(anotherSearchTerm); - expect(screen.queryByTestId(BUTTON_TEST_SUBJ)).not.toBeInTheDocument(); + await waitFor(() => { + // no results + expect(screen.getByTestId(COUNTER_TEST_SUBJ)).toHaveTextContent('0/0'); + }); }, EXTENDED_JEST_TIMEOUT );