From fcc7ef9a120d008078cdd0e412cfd6b935376471 Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Fri, 25 Aug 2023 17:07:22 -0700 Subject: [PATCH] [Data Explorer][Discover 2.0] Enable sort and cell actions for table (#4787) * [Data Explorer][Discover 2.0] Enable sort and cell actions for table Signed-off-by: ananzh * update test snapshot Signed-off-by: ananzh --------- Signed-off-by: ananzh (cherry picked from commit 87663957200bb1e06253dc4fbe7e471a268fd142) --- .../components/data_grid/data_grid_table.tsx | 5 +- .../data_grid_table_cell_actions.tsx | 76 ++++++ .../data_grid_table_columns.test.tsx | 18 +- .../data_grid/data_grid_table_columns.tsx | 3 +- .../utils/state_management/discover_slice.tsx | 5 +- .../view_components/canvas/discover_table.tsx | 3 +- .../view_components/utils/use_search.ts | 231 ++++++++++-------- .../discover/public/saved_searches/types.ts | 2 +- 8 files changed, 221 insertions(+), 122 deletions(-) create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx index 3f99c009e56a..98943784237f 100644 --- a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx @@ -16,6 +16,7 @@ import { DocViewFilterFn } from '../../doc_views/doc_views_types'; import { DiscoverServices } from '../../../build_services'; import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; import { usePagination } from '../utils/use_pagination'; +import { SortOrder } from '../../../saved_searches/types'; export interface DataGridTableProps { columns: string[]; @@ -23,10 +24,10 @@ export interface DataGridTableProps { onAddColumn: (column: string) => void; onFilter: DocViewFilterFn; onRemoveColumn: (column: string) => void; - onSort: (sort: Array<[string, 'asc' | 'desc']>) => void; + onSort: (sort: SortOrder[]) => void; rows: OpenSearchSearchHit[]; onSetColumns: (columns: string[]) => void; - sort: Array<[string, 'asc' | 'desc']>; + sort: SortOrder[]; displayTimeColumn: boolean; services: DiscoverServices; isToolbarVisible?: boolean; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx new file mode 100644 index 000000000000..3d81ba1afb33 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { IndexPatternField } from '../../../../../data/common'; +import { useDataGridContext } from './data_grid_table_context'; + +export function getCellActions(field: IndexPatternField) { + const cellActions = field.filterable + ? [ + ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => { + const { indexPattern, rows, onFilter } = useDataGridContext(); + + const filterForValueText = i18n.translate('discover.filterForValue', { + defaultMessage: 'Filter for value', + }); + const filterForValueLabel = i18n.translate('discover.filterForValueLabel', { + defaultMessage: 'Filter for value: {value}', + values: { value: columnId }, + }); + + return ( + { + const row = rows[rowIndex]; + const flattened = indexPattern.flattenHit(row); + + if (flattened) { + onFilter(columnId, flattened[columnId], '+'); + } + }} + iconType="plusInCircle" + aria-label={filterForValueLabel} + data-test-subj="filterForValue" + > + {filterForValueText} + + ); + }, + ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => { + const { indexPattern, rows, onFilter } = useDataGridContext(); + + const filterOutValueText = i18n.translate('discover.filterOutValue', { + defaultMessage: 'Filter out value', + }); + const filterOutValueLabel = i18n.translate('discover.filterOutValueLabel', { + defaultMessage: 'Filter out value: {value}', + values: { value: columnId }, + }); + + return ( + { + const row = rows[rowIndex]; + const flattened = indexPattern.flattenHit(row); + + if (flattened) { + onFilter(columnId, flattened[columnId], '-'); + } + }} + iconType="minusInCircle" + aria-label={filterOutValueLabel} + data-test-subj="filterOutValue" + > + {filterOutValueText} + + ); + }, + ] + : undefined; + return cellActions; +} diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx index f5580b990282..9a53bff57e16 100644 --- a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx @@ -61,7 +61,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": undefined, "id": "name", "isSortable": undefined, @@ -73,7 +73,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": undefined, "id": "currency", "isSortable": undefined, @@ -102,7 +102,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": "Time (order_date)", "id": "order_date", "initialWidth": 200, @@ -115,7 +115,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": undefined, "id": "name", "isSortable": undefined, @@ -127,7 +127,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": undefined, "id": "currency", "isSortable": undefined, @@ -139,7 +139,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": "Source", "id": "_source", "isSortable": undefined, @@ -166,7 +166,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": undefined, "id": "name", "isSortable": undefined, @@ -178,7 +178,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": undefined, "id": "currency", "isSortable": undefined, @@ -190,7 +190,7 @@ describe('Testing buildDataGridColumns function ', () => { "showMoveLeft": false, "showMoveRight": false, }, - "cellActions": Array [], + "cellActions": undefined, "display": "Time (order_date)", "id": "order_date", "initialWidth": 200, diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx index 1561a7e838da..f38debbd7be3 100644 --- a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx @@ -6,6 +6,7 @@ import { EuiDataGridColumn } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { getCellActions } from './data_grid_table_cell_actions'; export function buildDataGridColumns( columnNames: string[], @@ -37,7 +38,7 @@ export function generateDataGridTableColumn(colName: string, idxPattern: IndexPa showMoveLeft: false, showMoveRight: false, }, - cellActions: [], + cellActions: idxPatternField ? getCellActions(idxPatternField) : [], }; if (dataGridCol.id === idxPattern.timeFieldName) { diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx index 38c8092d5f73..73cc1af35e13 100644 --- a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -10,6 +10,7 @@ import { DiscoverServices } from '../../../build_services'; import { RootState, DefaultViewState } from '../../../../../data_explorer/public'; import { buildColumns } from '../columns'; import * as utils from './common'; +import { SortOrder } from '../../../saved_searches/types'; export interface DiscoverState { /** @@ -31,7 +32,7 @@ export interface DiscoverState { /** * Array of the used sorting [[field,direction],...] */ - sort: Array<[string, string]>; + sort: SortOrder[]; /** * id of the used saved search */ @@ -129,7 +130,7 @@ export const discoverSlice = createSlice({ columns, }; }, - setSort(state, action: PayloadAction>) { + setSort(state, action: PayloadAction) { return { ...state, sort: action.payload, diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx index 814788406f7c..f4e28a59b08d 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -20,6 +20,7 @@ import { import { ResultStatus, SearchData } from '../utils/use_search'; import { IndexPatternField, opensearchFilters } from '../../../../../data/public'; import { DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { SortOrder } from '../../../saved_searches/types'; interface Props { history: History; @@ -40,7 +41,7 @@ export const DiscoverTable = ({ history }: Props) => { const onRemoveColumn = (col: string) => dispatch(removeColumn(col)); const onSetColumns = (cols: string[]) => dispatch(setColumns({ timefield: indexPattern.timeFieldName, columns: cols })); - const onSetSort = (s: Array<[string, string]>) => dispatch(setSort(s)); + const onSetSort = (s: SortOrder[]) => dispatch(setSort(s)); const onAddFilter = useCallback( (field: IndexPatternField, values: string, operation: '+' | '-') => { const newFilters = opensearchFilters.generateFilters( diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts index af845fb1ad2f..50ad55bf4048 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -30,6 +30,7 @@ import { getResponseInspectorStats, } from '../../../opensearch_dashboards_services'; import { SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../common'; +import { SortOrder } from '../../../saved_searches/types'; export enum ResultStatus { UNINITIALIZED = 'uninitialized', @@ -67,7 +68,7 @@ export type RefetchSubject = Subject; */ export const useSearch = (services: DiscoverServices) => { const [savedSearch, setSavedSearch] = useState(undefined); - const savedSearchId = useSelector((state) => state.discover.savedSearch); + const { savedSearch: savedSearchId, sort } = useSelector((state) => state.discover); const indexPattern = useIndexPattern(services); const { data, filterManager, getSavedSearchById, core, toastNotifications } = services; const timefilter = data.query.timefilter.timefilter; @@ -101,122 +102,125 @@ export const useSearch = (services: DiscoverServices) => { [shouldSearchOnPageLoad] ); const refetch$ = useMemo(() => new Subject(), []); + const sort$ = useMemo(() => new Subject(), []); - const fetch = useCallback(async () => { - if (!indexPattern) { - data$.next({ - status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, - }); - return; - } - - if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { - return data$.next({ - status: ResultStatus.NO_RESULTS, - rows: [], - }); - } - - // Abort any in-progress requests before fetching again - if (fetchStateRef.current.abortController) fetchStateRef.current.abortController.abort(); - fetchStateRef.current.abortController = new AbortController(); - const sort = undefined; - const histogramConfigs = indexPattern.timeFieldName - ? createHistogramConfigs(indexPattern, 'auto', data) - : undefined; - const searchSource = await updateSearchSource({ - indexPattern, - services, - sort, - searchSource: savedSearch?.searchSource, - histogramConfigs, - }); + const fetch = useCallback( + async (sortArr: SortOrder[]) => { + if (!indexPattern) { + data$.next({ + status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, + }); + return; + } - try { - // Only show loading indicator if we are fetching when the rows are empty - if (fetchStateRef.current.rows?.length === 0) { - data$.next({ status: ResultStatus.LOADING }); + if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { + return data$.next({ + status: ResultStatus.NO_RESULTS, + rows: [], + }); } - // Initialize inspect adapter for search source - inspectorAdapters.requests.reset(); - const title = i18n.translate('discover.inspectorRequestDataTitle', { - defaultMessage: 'data', - }); - const description = i18n.translate('discover.inspectorRequestDescription', { - defaultMessage: 'This request queries OpenSearch to fetch the data for the search.', - }); - const inspectorRequest = inspectorAdapters.requests.start(title, { description }); - inspectorRequest.stats(getRequestInspectorStats(searchSource)); - searchSource.getSearchRequestBody().then((body) => { - inspectorRequest.json(body); + // Abort any in-progress requests before fetching again + if (fetchStateRef.current.abortController) fetchStateRef.current.abortController.abort(); + fetchStateRef.current.abortController = new AbortController(); + const histogramConfigs = indexPattern.timeFieldName + ? createHistogramConfigs(indexPattern, 'auto', data) + : undefined; + const searchSource = await updateSearchSource({ + indexPattern, + services, + sort: sortArr, + searchSource: savedSearch?.searchSource, + histogramConfigs, }); - // Execute the search - const fetchResp = await searchSource.fetch({ - abortSignal: fetchStateRef.current.abortController.signal, - }); + try { + // Only show loading indicator if we are fetching when the rows are empty + if (fetchStateRef.current.rows?.length === 0) { + data$.next({ status: ResultStatus.LOADING }); + } + + // Initialize inspect adapter for search source + inspectorAdapters.requests.reset(); + const title = i18n.translate('discover.inspectorRequestDataTitle', { + defaultMessage: 'data', + }); + const description = i18n.translate('discover.inspectorRequestDescription', { + defaultMessage: 'This request queries OpenSearch to fetch the data for the search.', + }); + const inspectorRequest = inspectorAdapters.requests.start(title, { description }); + inspectorRequest.stats(getRequestInspectorStats(searchSource)); + searchSource.getSearchRequestBody().then((body) => { + inspectorRequest.json(body); + }); - inspectorRequest - .stats(getResponseInspectorStats(fetchResp, searchSource)) - .ok({ json: fetchResp }); - const hits = fetchResp.hits.total as number; - const rows = fetchResp.hits.hits; - let bucketInterval = {}; - let chartData; - for (const row of rows) { - const fields = Object.keys(indexPattern.flattenHit(row)); - for (const fieldName of fields) { - fetchStateRef.current.fieldCounts[fieldName] = - (fetchStateRef.current.fieldCounts[fieldName] || 0) + 1; + // Execute the search + const fetchResp = await searchSource.fetch({ + abortSignal: fetchStateRef.current.abortController.signal, + }); + + inspectorRequest + .stats(getResponseInspectorStats(fetchResp, searchSource)) + .ok({ json: fetchResp }); + const hits = fetchResp.hits.total as number; + const rows = fetchResp.hits.hits; + let bucketInterval = {}; + let chartData; + for (const row of rows) { + const fields = Object.keys(indexPattern.flattenHit(row)); + for (const fieldName of fields) { + fetchStateRef.current.fieldCounts[fieldName] = + (fetchStateRef.current.fieldCounts[fieldName] || 0) + 1; + } } - } - if (histogramConfigs) { - const bucketAggConfig = histogramConfigs.aggs[1]; - const tabifiedData = tabifyAggResponse(histogramConfigs, fetchResp); - const dimensions = getDimensions(histogramConfigs, data); - if (dimensions) { - if (bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)) { - bucketInterval = bucketAggConfig.buckets?.getInterval(); + if (histogramConfigs) { + const bucketAggConfig = histogramConfigs.aggs[1]; + const tabifiedData = tabifyAggResponse(histogramConfigs, fetchResp); + const dimensions = getDimensions(histogramConfigs, data); + if (dimensions) { + if (bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)) { + bucketInterval = bucketAggConfig.buckets?.getInterval(); + } + // @ts-ignore tabifiedData is compatible but due to the way it is typed typescript complains + chartData = buildPointSeriesData(tabifiedData, dimensions); } - // @ts-ignore tabifiedData is compatible but due to the way it is typed typescript complains - chartData = buildPointSeriesData(tabifiedData, dimensions); } - } - fetchStateRef.current.fieldCounts = fetchStateRef.current.fieldCounts!; - fetchStateRef.current.rows = rows; - data$.next({ - status: rows.length > 0 ? ResultStatus.READY : ResultStatus.NO_RESULTS, - fieldCounts: fetchStateRef.current.fieldCounts, - hits, - rows, - bucketInterval, - chartData, - }); - } catch (error) { - // If the request was aborted then no need to surface this error in the UI - if (error instanceof Error && error.name === 'AbortError') return; + fetchStateRef.current.fieldCounts = fetchStateRef.current.fieldCounts!; + fetchStateRef.current.rows = rows; + data$.next({ + status: rows.length > 0 ? ResultStatus.READY : ResultStatus.NO_RESULTS, + fieldCounts: fetchStateRef.current.fieldCounts, + hits, + rows, + bucketInterval, + chartData, + }); + } catch (error) { + // If the request was aborted then no need to surface this error in the UI + if (error instanceof Error && error.name === 'AbortError') return; - data$.next({ - status: ResultStatus.NO_RESULTS, - rows: [], - }); + data$.next({ + status: ResultStatus.NO_RESULTS, + rows: [], + }); - data.search.showError(error as Error); - } - }, [ - indexPattern, - timefilter, - toastNotifications, - data, - services, - savedSearch?.searchSource, - data$, - shouldSearchOnPageLoad, - inspectorAdapters.requests, - ]); + data.search.showError(error as Error); + } + }, + [ + indexPattern, + timefilter, + toastNotifications, + data, + services, + savedSearch?.searchSource, + data$, + shouldSearchOnPageLoad, + inspectorAdapters.requests, + ] + ); useEffect(() => { const fetch$ = merge( @@ -225,13 +229,14 @@ export const useSearch = (services: DiscoverServices) => { timefilter.getFetch$(), timefilter.getTimeUpdate$(), timefilter.getAutoRefreshFetch$(), - data.query.queryString.getUpdates$() + data.query.queryString.getUpdates$(), + sort$ ).pipe(debounceTime(100)); const subscription = fetch$.subscribe(() => { (async () => { try { - await fetch(); + await fetch(sort); } catch (error) { core.fatalErrors.add(error as Error); } @@ -244,7 +249,17 @@ export const useSearch = (services: DiscoverServices) => { return () => { subscription.unsubscribe(); }; - }, [data$, data.query.queryString, filterManager, refetch$, timefilter, fetch, core.fatalErrors]); + }, [ + data$, + data.query.queryString, + filterManager, + refetch$, + sort, + sort$, + timefilter, + fetch, + core.fatalErrors, + ]); // Get savedSearch if it exists useEffect(() => { @@ -256,6 +271,10 @@ export const useSearch = (services: DiscoverServices) => { return () => {}; }, [getSavedSearchById, savedSearchId]); + useEffect(() => { + sort$.next(sort); + }, [sort, sort$]); + return { data$, refetch$, diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index 97bd4966e94c..112d7d998c97 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -31,7 +31,7 @@ import { SavedObject } from '../../../saved_objects/public'; import { ISearchSource } from '../../../data/public'; -export type SortOrder = [string, string]; +export type SortOrder = [string, 'asc' | 'desc']; export interface SavedSearch extends Pick { searchSource: ISearchSource; // This is optional in SavedObject, but required for SavedSearch