From a2ad97dca47c8421e8977e6986634a31fd41db46 Mon Sep 17 00:00:00 2001 From: Justin Kim Date: Wed, 15 Oct 2025 15:38:25 -0700 Subject: [PATCH] stashing changes Signed-off-by: Justin Kim --- package.json | 2 + .../components/field_editor/field_editor.tsx | 1 + .../dataset_context/dataset_context.tsx | 2 +- .../utils/hooks/use_page_initialization.ts | 115 +++---- .../add_visible_column_name.ts | 28 ++ .../columns/add_visible_column_name/index.ts | 6 + .../state_management/actions/columns/index.ts | 8 + .../index.ts | 6 + .../reconcile_visible_columns_with_dataset.ts | 42 +++ .../remove_visible_column_name/index.ts | 6 + .../remove_visible_column_name.ts | 28 ++ .../query_editor/run_query/run_query.ts | 5 +- .../middleware/dataset_change_middleware.ts | 15 +- .../state_management/selectors/index.test.ts | 144 +++++++-- .../utils/state_management/selectors/index.ts | 25 +- .../slices/legacy/legacy_slice.test.ts | 56 ---- .../slices/legacy/legacy_slice.ts | 36 --- .../slices/tab/tab_slice.test.ts | 282 ++++++++++++++++-- .../state_management/slices/tab/tab_slice.ts | 80 ++++- .../add_source_or_time_to_fields.ts | 35 +++ .../add_source_or_time_to_fields/index.ts | 6 + .../get_default_columns.ts | 47 +++ .../utils/get_default_columns/index.ts | 6 + .../utils/redux_persistence.test.ts | 101 ++++++- .../utils/redux_persistence.ts | 67 +++-- .../data_table/explore_data_table.test.tsx | 12 +- .../data_table/explore_data_table.tsx | 13 +- .../fields_selector/discover_field.tsx | 7 +- .../fields_selector_panel.test.tsx | 2 +- .../fields_selector/fields_selector_panel.tsx | 46 +-- .../body/expanded_document/index.ts | 6 + .../results_table_expanded_document.tsx | 44 +++ .../components/results_table/body/index.ts | 6 + .../body/results_table_body.scss | 37 +++ .../results_table/body/results_table_body.tsx | 118 ++++++++ .../column_actions_cell.scss | 17 ++ .../column_actions_cell.tsx | 63 ++++ .../columns/column_actions_cell/index.ts | 6 + .../columns/column_header/column_header.scss | 51 ++++ .../columns/column_header/column_header.tsx | 149 +++++++++ .../columns/column_header/index.ts | 6 + .../components/results_table/columns/index.ts | 7 + .../components/results_table/head/index.ts | 6 + .../head/results_table_head.scss | 56 ++++ .../results_table/head/results_table_head.tsx | 73 +++++ .../components/results_table/hooks/index.ts | 7 + .../results_table/hooks/use_columns/index.ts | 6 + .../hooks/use_columns/use_columns.tsx | 112 +++++++ .../use_columns/use_fields_list/index.ts | 6 + .../use_fields_list/use_fields_list.test.ts | 267 +++++++++++++++++ .../use_fields_list/use_fields_list.ts | 20 ++ .../hooks/use_processed_results/index.ts | 6 + .../use_processed_results.test.ts | 182 +++++++++++ .../use_processed_results.ts | 22 ++ .../hooks/use_raw_results/index.ts | 6 + .../use_raw_results/use_raw_results.test.ts | 185 ++++++++++++ .../hooks/use_raw_results/use_raw_results.ts | 17 ++ .../hooks/use_rows_data/index.ts | 6 + .../hooks/use_rows_data/use_rows_data.test.ts | 135 +++++++++ .../hooks/use_rows_data/use_rows_data.ts | 19 ++ .../public/components/results_table/index.ts | 6 + .../results_table/results_table.scss | 17 ++ .../results_table/results_table.tsx | 73 +++++ .../results_table/table_constants.ts | 7 + .../components/results_table/utils.scss | 22 ++ .../results_table/utils/css/css_utils.ts | 10 + .../results_table/utils/css/index.ts | 6 + .../get_column_id_from_field_name.ts | 8 + .../get_column_id_from_field_name/index.ts | 6 + .../results_action_bar.scss | 6 +- .../public/components/tabs/logs_tab.tsx | 10 +- .../public/helpers/save_explore.test.ts | 1 - .../public/saved_explore/transforms.ts | 3 +- .../public/types/saved_explore_types.ts | 2 +- yarn.lock | 24 ++ 75 files changed, 2767 insertions(+), 304 deletions(-) create mode 100644 src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/add_visible_column_name.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/index.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/actions/columns/index.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/index.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/reconcile_visible_columns_with_dataset.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/index.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/remove_visible_column_name.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/add_source_or_time_to_fields.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/index.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/get_default_columns.ts create mode 100644 src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/index.ts create mode 100644 src/plugins/explore/public/components/results_table/body/expanded_document/index.ts create mode 100644 src/plugins/explore/public/components/results_table/body/expanded_document/results_table_expanded_document.tsx create mode 100644 src/plugins/explore/public/components/results_table/body/index.ts create mode 100644 src/plugins/explore/public/components/results_table/body/results_table_body.scss create mode 100644 src/plugins/explore/public/components/results_table/body/results_table_body.tsx create mode 100644 src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.scss create mode 100644 src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.tsx create mode 100644 src/plugins/explore/public/components/results_table/columns/column_actions_cell/index.ts create mode 100644 src/plugins/explore/public/components/results_table/columns/column_header/column_header.scss create mode 100644 src/plugins/explore/public/components/results_table/columns/column_header/column_header.tsx create mode 100644 src/plugins/explore/public/components/results_table/columns/column_header/index.ts create mode 100644 src/plugins/explore/public/components/results_table/columns/index.ts create mode 100644 src/plugins/explore/public/components/results_table/head/index.ts create mode 100644 src/plugins/explore/public/components/results_table/head/results_table_head.scss create mode 100644 src/plugins/explore/public/components/results_table/head/results_table_head.tsx create mode 100644 src/plugins/explore/public/components/results_table/hooks/index.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_columns/index.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_columns/use_columns.tsx create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/index.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.test.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_processed_results/index.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.test.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_raw_results/index.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.test.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_rows_data/index.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.test.ts create mode 100644 src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.ts create mode 100644 src/plugins/explore/public/components/results_table/index.ts create mode 100644 src/plugins/explore/public/components/results_table/results_table.scss create mode 100644 src/plugins/explore/public/components/results_table/results_table.tsx create mode 100644 src/plugins/explore/public/components/results_table/table_constants.ts create mode 100644 src/plugins/explore/public/components/results_table/utils.scss create mode 100644 src/plugins/explore/public/components/results_table/utils/css/css_utils.ts create mode 100644 src/plugins/explore/public/components/results_table/utils/css/index.ts create mode 100644 src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/get_column_id_from_field_name.ts create mode 100644 src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/index.ts diff --git a/package.json b/package.json index 6cd8b4631f00..e005fd094722 100644 --- a/package.json +++ b/package.json @@ -234,6 +234,8 @@ "@osd/ui-framework": "1.0.0", "@osd/ui-shared-deps": "1.0.0", "@reduxjs/toolkit": "^1.6.1", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "@types/ndjson": "^2.0.4", "@types/yauzl": "^2.9.1", "@xyflow/react": "^12.8.2", diff --git a/src/plugins/dataset_management/public/components/field_editor/field_editor.tsx b/src/plugins/dataset_management/public/components/field_editor/field_editor.tsx index e8100f97fe3a..7cafe24c2e73 100644 --- a/src/plugins/dataset_management/public/components/field_editor/field_editor.tsx +++ b/src/plugins/dataset_management/public/components/field_editor/field_editor.tsx @@ -25,6 +25,7 @@ import { EuiSpacer, EuiText, EUI_MODAL_CONFIRM_BUTTON, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; diff --git a/src/plugins/explore/public/application/context/dataset_context/dataset_context.tsx b/src/plugins/explore/public/application/context/dataset_context/dataset_context.tsx index d1524406b47a..e842a2d99906 100644 --- a/src/plugins/explore/public/application/context/dataset_context/dataset_context.tsx +++ b/src/plugins/explore/public/application/context/dataset_context/dataset_context.tsx @@ -10,7 +10,7 @@ import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_re import { ExploreServices } from '../../../types'; import { RootState } from '../../utils/state_management/store'; -interface DatasetContextValue { +export interface DatasetContextValue { dataset: DataView | undefined; isLoading: boolean | null; error: string | null; diff --git a/src/plugins/explore/public/application/utils/hooks/use_page_initialization.ts b/src/plugins/explore/public/application/utils/hooks/use_page_initialization.ts index 629ffbc84a4b..c8653feaf966 100644 --- a/src/plugins/explore/public/application/utils/hooks/use_page_initialization.ts +++ b/src/plugins/explore/public/application/utils/hooks/use_page_initialization.ts @@ -18,12 +18,16 @@ import { clearLastExecutedData, setEditorMode, setUsingRegexPatterns, + setDefaultColumnNames, + resetEphemeralLogsState, + setVisibleColumnNames, } from '../state_management/slices'; import { executeQueries } from '../state_management/actions/query_actions'; import { ExploreFlavor } from '../../../../common'; import { useSetEditorText } from '../../hooks'; import { EditorMode } from '../state_management/types'; import { getVisualizationBuilder } from '../../../components/visualizations/visualization_builder'; +import { getDefaultColumnNames } from '../state_management/utils/get_default_columns'; export const useInitPage = () => { const dispatch = useDispatch(); @@ -35,67 +39,74 @@ export const useInitPage = () => { const visualizationBuilder = getVisualizationBuilder(); useEffect(() => { - if (savedExplore && !error) { - if (savedExplore.id) { - // Deserialize state from saved object - const { title } = savedExplore; + const loadSaveExplore = async () => { + if (savedExplore && !error) { + if (savedExplore.id) { + // Deserialize state from saved object + const { title } = savedExplore; - // Update browser title and breadcrumbs - chrome.docTitle.change(title); - chrome.setBreadcrumbs([{ text: 'Explore', href: '#/' }, { text: title }]); + // Update browser title and breadcrumbs + chrome.docTitle.change(title); + chrome.setBreadcrumbs([{ text: 'Explore', href: '#/' }, { text: title }]); - // Sync query from saved object to data plugin (explore doesn't use filters) - const searchSourceFields = savedExplore.kibanaSavedObjectMeta; - const queryFromUrl = services.osdUrlStateStorage?.get('_q') ?? {}; - if (searchSourceFields?.searchSourceJSON) { - const searchSource = JSON.parse(searchSourceFields.searchSourceJSON); - const queryFromSavedSearch = searchSource.query; - const query = { ...queryFromSavedSearch, ...queryFromUrl }; - if (query) { - dispatch(setQueryState(query)); - setEditorText(query.query); + // Sync query from saved object to data plugin (explore doesn't use filters) + const searchSourceFields = savedExplore.kibanaSavedObjectMeta; + const queryFromUrl = services.osdUrlStateStorage?.get('_q') ?? {}; + if (searchSourceFields?.searchSourceJSON) { + const searchSource = JSON.parse(searchSourceFields.searchSourceJSON); + const queryFromSavedSearch = searchSource.query; + const query = { ...queryFromSavedSearch, ...queryFromUrl }; + if (query) { + dispatch(setQueryState(query)); + setEditorText(query.query); + } + const defaultColumnNames = await getDefaultColumnNames(services, query.dataset); + dispatch(setDefaultColumnNames(defaultColumnNames)); + dispatch(setVisibleColumnNames(savedExplore.columns || defaultColumnNames)); } - } - // Update savedSearch to store just the ID (like discover) - // TODO: remove this once legacy state is not consumed any more - dispatch(setSavedSearch(savedExplore.id)); + // Update savedSearch to store just the ID (like discover) + // TODO: remove this once legacy state is not consumed any more + dispatch(setSavedSearch(savedExplore.id)); - // Init vis state and ui state - const visualization = savedExplore.visualization; - const uiState = savedExplore.uiState; - if (visualization) { - const { chartType, params, axesMapping } = JSON.parse(visualization); - visualizationBuilder.setVisConfig({ type: chartType, styles: params, axesMapping }); - } - if (uiState) { - const { activeTab } = JSON.parse(uiState); - dispatch(setActiveTab(activeTab)); - } + // Init vis state and ui state + const visualization = savedExplore.visualization; + const uiState = savedExplore.uiState; + if (visualization) { + const { chartType, params, axesMapping } = JSON.parse(visualization); + visualizationBuilder.setVisConfig({ type: chartType, styles: params, axesMapping }); + } + if (uiState) { + const { activeTab } = JSON.parse(uiState); + dispatch(setActiveTab(activeTab)); + } - // Add to recently accessed - chrome.recentlyAccessed.add( - `/app/explore/${savedExplore.type ?? ExploreFlavor.Logs}#/view/${savedExplore.id}`, - title, - savedExplore.id, - { type: 'explore' } - ); + // Add to recently accessed + chrome.recentlyAccessed.add( + `/app/explore/${savedExplore.type ?? ExploreFlavor.Logs}#/view/${savedExplore.id}`, + title, + savedExplore.id, + { type: 'explore' } + ); - dispatch(clearLastExecutedData()); - dispatch(setEditorMode(EditorMode.Query)); - dispatch(clearResults()); - dispatch(clearQueryStatusMap()); - dispatch(setUsingRegexPatterns(false)); - dispatch(executeQueries({ services })); + dispatch(clearLastExecutedData()); + dispatch(setEditorMode(EditorMode.Query)); + dispatch(clearResults()); + dispatch(clearQueryStatusMap()); + dispatch(setUsingRegexPatterns(false)); + dispatch(resetEphemeralLogsState()); + dispatch(executeQueries({ services })); + } } - } - if (error) { - // Navigate to management page for invalid IDs - // TODO: need to confirm the UI behavior for invalid ID, the current logic is copied from useSavedExplore hook - if (error.includes('Not found')) { - chrome.setBreadcrumbs([{ text: 'Explore', href: '#/' }, { text: 'Error' }]); + if (error) { + // Navigate to management page for invalid IDs + // TODO: need to confirm the UI behavior for invalid ID, the current logic is copied from useSavedExplore hook + if (error.includes('Not found')) { + chrome.setBreadcrumbs([{ text: 'Explore', href: '#/' }, { text: 'Error' }]); + } } - } + }; + loadSaveExplore(); }, [ chrome, data.query.queryString, diff --git a/src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/add_visible_column_name.ts b/src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/add_visible_column_name.ts new file mode 100644 index 000000000000..cedf3204ed36 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/add_visible_column_name.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppDispatch, RootState } from '../../../store'; +import { SOURCE_COLUMN_ID_AND_NAME } from '../../../../../../components/results_table/table_constants'; +import { setVisibleColumnNames } from '../../../slices'; + +export const addVisibleColumnName = (columnId: string) => ( + dispatch: AppDispatch, + getState: () => RootState +) => { + const { + tab: { + logs: { visibleColumnNames }, + }, + } = getState(); + + if (visibleColumnNames.includes(columnId)) { + return; + } + + // filter out _SOURCE since we are adding columns + const newColumns = visibleColumnNames.filter((column) => column !== SOURCE_COLUMN_ID_AND_NAME); + newColumns.push(columnId); + dispatch(setVisibleColumnNames(newColumns)); +}; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/index.ts b/src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/index.ts new file mode 100644 index 000000000000..50d81be1f486 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/actions/columns/add_visible_column_name/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './add_visible_column_name'; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/columns/index.ts b/src/plugins/explore/public/application/utils/state_management/actions/columns/index.ts new file mode 100644 index 000000000000..8ee83e500c76 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/actions/columns/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './reconcile_visible_columns_with_dataset'; +export * from './add_visible_column_name'; +export * from './remove_visible_column_name'; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/index.ts b/src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/index.ts new file mode 100644 index 000000000000..2dc589ed8a09 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './reconcile_visible_columns_with_dataset'; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/reconcile_visible_columns_with_dataset.ts b/src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/reconcile_visible_columns_with_dataset.ts new file mode 100644 index 000000000000..cfb2816c3951 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/actions/columns/reconcile_visible_columns_with_dataset/reconcile_visible_columns_with_dataset.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExploreServices } from '../../../../../../types'; +import { AppDispatch, RootState } from '../../../store'; +import { Dataset, DEFAULT_DATA } from '../../../../../../../../data/common'; +import { setVisibleColumnNames } from '../../../slices'; +import { addSourceOrTimeToFields } from '../../../utils/add_source_or_time_to_fields'; + +export const reconcileVisibleColumnsWithDataset = ( + services: ExploreServices, + dataset?: Dataset +) => async (dispatch: AppDispatch, getState: () => RootState) => { + const { dataViews } = services; + const { + tab: { + logs: { visibleColumnNames, defaultColumnNames }, + }, + } = getState(); + + if (!dataset || !visibleColumnNames.length) { + dispatch(setVisibleColumnNames(defaultColumnNames)); + return; + } + + const dataView = await dataViews.get( + dataset.id, + dataset.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); + + const fieldsNameFromDataset = dataView.fields.getAll().map((field) => field.name); + + // filter out any columns from previous visibleColumns that is not in the dataset and remove duplicates + const filteredColumnNames = [ + ...new Set(visibleColumnNames.filter((column) => fieldsNameFromDataset.includes(column))), + ]; + + const correctedFilteredColumns = addSourceOrTimeToFields(filteredColumnNames, dataset); + dispatch(setVisibleColumnNames(correctedFilteredColumns)); +}; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/index.ts b/src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/index.ts new file mode 100644 index 000000000000..8143e3f53755 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './remove_visible_column_name'; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/remove_visible_column_name.ts b/src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/remove_visible_column_name.ts new file mode 100644 index 000000000000..e7a7f902b2e2 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/actions/columns/remove_visible_column_name/remove_visible_column_name.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppDispatch, RootState } from '../../../store'; +import { setVisibleColumnNames } from '../../../slices'; + +export const removeVisibleColumnName = (columnId: string) => ( + dispatch: AppDispatch, + getState: () => RootState +) => { + const { + query: { dataset }, + tab: { + logs: { visibleColumnNames, defaultColumnNames }, + }, + } = getState(); + + const newColumns = visibleColumnNames.filter((col) => col !== columnId); + const newColumnsWithoutTime = newColumns.filter((col) => col !== dataset?.timeFieldName); + + if (!newColumnsWithoutTime.length) { + dispatch(setVisibleColumnNames(defaultColumnNames)); + } else { + dispatch(setVisibleColumnNames(newColumns)); + } +}; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/query_editor/run_query/run_query.ts b/src/plugins/explore/public/application/utils/state_management/actions/query_editor/run_query/run_query.ts index 64a9fbc001ec..c9fdd2cb00bd 100644 --- a/src/plugins/explore/public/application/utils/state_management/actions/query_editor/run_query/run_query.ts +++ b/src/plugins/explore/public/application/utils/state_management/actions/query_editor/run_query/run_query.ts @@ -7,13 +7,10 @@ import { AppDispatch, RootState } from '../../../store'; import { clearResults, setQueryStringWithHistory, - setActiveTab, setQueryExecutionButtonStatus, -} from '../../../slices'; -import { clearQueryStatusMap, setIsQueryEditorDirty, -} from '../../../slices/query_editor/query_editor_slice'; +} from '../../../slices'; import { executeQueries } from '../../query_actions'; import { ExploreServices } from '../../../../../../types'; import { detectAndSetOptimalTab } from '../../detect_optimal_tab'; diff --git a/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.ts b/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.ts index 02da729c9f56..5ce6a9c52a32 100644 --- a/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.ts +++ b/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.ts @@ -17,13 +17,18 @@ import { setSummaryAgentIsAvailable, setPatternsField, setUsingRegexPatterns, + resetEphemeralLogsState, + setDefaultColumnNames, + clearQueryStatusMap, + setBreakdownField, } from '../slices'; -import { clearQueryStatusMap, setBreakdownField } from '../slices/query_editor/query_editor_slice'; import { executeQueries } from '../actions/query_actions'; import { getPromptModeIsAvailable } from '../../get_prompt_mode_is_available'; import { getSummaryAgentIsAvailable } from '../../get_summary_agent_is_available'; import { detectAndSetOptimalTab } from '../actions/detect_optimal_tab'; import { resetLegacyStateActionCreator } from '../actions/reset_legacy_state'; +import { getDefaultColumnNames } from '../utils/get_default_columns'; +import { reconcileVisibleColumnsWithDataset } from '../actions/columns'; /** * Middleware to handle dataset changes and trigger necessary side effects @@ -62,6 +67,14 @@ export const createDatasetChangeMiddleware = ( store.dispatch(setBreakdownField(undefined)); store.dispatch((resetLegacyStateActionCreator(services) as unknown) as AnyAction); + const defaultColumnNames = await getDefaultColumnNames(services, dataset); + + store.dispatch(resetEphemeralLogsState()); + store.dispatch(setDefaultColumnNames(defaultColumnNames)); + store.dispatch( + (reconcileVisibleColumnsWithDataset(services, dataset) as unknown) as AnyAction + ); + const [newPromptModeIsAvailable, newSummaryAgentIsAvailable] = await Promise.allSettled([ getPromptModeIsAvailable(services), getSummaryAgentIsAvailable(services, currentDataset?.dataSource?.id || ''), diff --git a/src/plugins/explore/public/application/utils/state_management/selectors/index.test.ts b/src/plugins/explore/public/application/utils/state_management/selectors/index.test.ts index 1cf49659a89c..ce68aec988b5 100644 --- a/src/plugins/explore/public/application/utils/state_management/selectors/index.test.ts +++ b/src/plugins/explore/public/application/utils/state_management/selectors/index.test.ts @@ -6,17 +6,21 @@ import { selectUIState, selectTabState, + selectTabLogsState, selectQuery, selectQueryString, selectQueryLanguage, selectDataset, selectActiveTabId, selectShowHistogram, + selectPatternsField, + selectUsingRegexPatterns, selectActiveTab, selectResults, - selectColumns, - selectSort, + selectVisibleColumnNames, selectSavedSearch, + selectTabLogsExpandedRowsMap, + selectTabLogsSelectedRowsMap, } from './index'; import { RootState } from '../store'; @@ -47,23 +51,22 @@ describe('selectors/index', () => { }, }, tab: { - activeTab: 'test-tab', - tabs: [], - visualizations: { - styleOptions: { - color: 'blue', - size: 'medium', + logs: { + expandedRowsMap: { + row1: true, + row3: true, }, - chartType: 'bar', - axesMapping: { - x: 'field1', - y: 'field2', + selectedRowsMap: { + row2: true, }, + visibleColumns: ['field1', 'field2', 'field3'], + }, + patterns: { + patternsField: 'message', + usingRegexPatterns: true, }, }, legacy: { - columns: ['field1', 'field2', 'field3'], - sort: [['field1', 'asc']], savedSearch: { id: 'saved-search-1', title: 'Test Search', @@ -87,6 +90,107 @@ describe('selectors/index', () => { const result = selectTabState(mockState); expect(result).toBe(mockState.tab); }); + + it('should select tab logs state', () => { + const result = selectTabLogsState(mockState); + expect(result).toBe(mockState.tab.logs); + }); + }); + + describe('tab selectors', () => { + it('should select patterns field', () => { + const result = selectPatternsField(mockState); + expect(result).toBe('message'); + }); + + it('should select using regex patterns', () => { + const result = selectUsingRegexPatterns(mockState); + expect(result).toBe(true); + }); + + it('should select tab logs expanded rows map', () => { + const result = selectTabLogsExpandedRowsMap(mockState); + expect(result).toEqual({ + row1: true, + row3: true, + }); + }); + + it('should select tab logs selected rows map', () => { + const result = selectTabLogsSelectedRowsMap(mockState); + expect(result).toEqual({ + row2: true, + }); + }); + + it('should handle undefined patterns field', () => { + const stateWithoutPatternsField = { + ...mockState, + tab: { + ...mockState.tab, + patterns: { + ...mockState.tab.patterns, + patternsField: undefined, + }, + }, + }; + + const result = selectPatternsField(stateWithoutPatternsField); + expect(result).toBeUndefined(); + }); + + it('should handle false using regex patterns', () => { + const stateWithFalseRegex = { + ...mockState, + tab: { + ...mockState.tab, + patterns: { + ...mockState.tab.patterns, + usingRegexPatterns: false, + }, + }, + }; + + const result = selectUsingRegexPatterns(stateWithFalseRegex); + expect(result).toBe(false); + }); + + it('should handle empty expanded rows map', () => { + const stateWithEmptyExpandedRows = { + ...mockState, + tab: { + ...mockState.tab, + logs: { + ...mockState.tab.logs, + expandedRowsMap: {}, + }, + }, + }; + + const result = selectTabLogsExpandedRowsMap(stateWithEmptyExpandedRows); + expect(result).toEqual({}); + }); + + it('should handle empty selected rows map', () => { + const stateWithEmptySelectedRows = { + ...mockState, + tab: { + ...mockState.tab, + logs: { + ...mockState.tab.logs, + selectedRowsMap: {}, + }, + }, + }; + + const result = selectTabLogsSelectedRowsMap(stateWithEmptySelectedRows); + expect(result).toEqual({}); + }); + + it('should select visible columns', () => { + const result = selectVisibleColumnNames(mockState); + expect(result).toEqual(['field1', 'field2', 'field3']); + }); }); describe('query selectors', () => { @@ -190,16 +294,6 @@ describe('selectors/index', () => { }); describe('legacy selectors', () => { - it('should select columns', () => { - const result = selectColumns(mockState); - expect(result).toEqual(['field1', 'field2', 'field3']); - }); - - it('should select sort', () => { - const result = selectSort(mockState); - expect(result).toEqual([['field1', 'asc']]); - }); - it('should select saved search', () => { const result = selectSavedSearch(mockState); expect(result).toEqual({ @@ -219,8 +313,6 @@ describe('selectors/index', () => { }, } as any; - expect(selectColumns(stateWithoutLegacyProps)).toBeUndefined(); - expect(selectSort(stateWithoutLegacyProps)).toBeUndefined(); expect(selectSavedSearch(stateWithoutLegacyProps)).toBeUndefined(); }); }); diff --git a/src/plugins/explore/public/application/utils/state_management/selectors/index.ts b/src/plugins/explore/public/application/utils/state_management/selectors/index.ts index e308333ae7a9..488cc1cebf3a 100644 --- a/src/plugins/explore/public/application/utils/state_management/selectors/index.ts +++ b/src/plugins/explore/public/application/utils/state_management/selectors/index.ts @@ -14,6 +14,7 @@ export const selectUIState = (state: RootState) => state.ui; const selectResultsState = (state: RootState) => state.results; const selectLegacyState = (state: RootState) => state.legacy; export const selectTabState = (state: RootState) => state.tab; +export const selectTabLogsState = (state: RootState) => state.tab.logs; /** * Query selectors @@ -65,16 +66,28 @@ export const selectResults = createSelector([selectResultsState], (resultsState) /** * Legacy selectors */ -export const selectColumns = createSelector( +export const selectSavedSearch = createSelector( [selectLegacyState], - (legacyState) => legacyState.columns + (legacyState) => legacyState.savedSearch ); -export const selectSort = createSelector([selectLegacyState], (legacyState) => legacyState.sort); +/** + * Tab selectors + */ -export const selectSavedSearch = createSelector( - [selectLegacyState], - (legacyState) => legacyState.savedSearch +export const selectTabLogsExpandedRowsMap = createSelector( + [selectTabLogsState], + (logsState) => logsState.expandedRowsMap +); + +export const selectTabLogsSelectedRowsMap = createSelector( + [selectTabLogsState], + (logsState) => logsState.selectedRowsMap +); + +export const selectVisibleColumnNames = createSelector( + [selectTabLogsState], + (logsState) => logsState.visibleColumnNames ); export * from './query_editor'; diff --git a/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.test.ts b/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.test.ts index f99cbbfacf10..61e0c263c7cc 100644 --- a/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.test.ts +++ b/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.test.ts @@ -8,23 +8,15 @@ import { setLegacyState, setSavedSearch, setSavedQuery, - setColumns, - addColumn, - removeColumn, - moveColumn, - setSort, setInterval, setIsDirty, setLineCount, LegacyState, } from './legacy_slice'; -import { SortOrder } from '../../../../../types/saved_explore_types'; describe('legacySlice reducers', () => { const initialState: LegacyState = { savedSearch: undefined, - columns: ['a', 'b', 'c'], - sort: [['a', 'asc']], interval: 'auto', isDirty: false, lineCount: undefined, @@ -33,8 +25,6 @@ describe('legacySlice reducers', () => { it('setLegacyState replaces the entire state', () => { const newState: LegacyState = { savedSearch: 'id', - columns: ['x'], - sort: [], interval: '1h', isDirty: true, lineCount: 10, @@ -63,52 +53,6 @@ describe('legacySlice reducers', () => { expect(state).not.toHaveProperty('savedQuery'); }); - it('setColumns sets columns', () => { - const state = legacyReducer(initialState, setColumns(['x', 'y'])); - expect(state.columns).toEqual(['x', 'y']); - }); - - it('addColumn adds a new column if not present', () => { - const state = legacyReducer(initialState, addColumn({ column: 'd' })); - expect(state.columns).toContain('d'); - }); - - it('addColumn does not add duplicate column', () => { - const state = legacyReducer(initialState, addColumn({ column: 'a' })); - expect(state.columns.filter((c) => c === 'a').length).toBe(1); - }); - - it('removeColumn removes the specified column', () => { - const state = legacyReducer(initialState, removeColumn('b')); - expect(state.columns).toEqual(['a', 'c']); - }); - - it('removeColumn does nothing if column not present', () => { - const state = legacyReducer(initialState, removeColumn('z')); - expect(state.columns).toEqual(initialState.columns); - }); - - it('moveColumn moves a column to the specified destination', () => { - const state = legacyReducer(initialState, moveColumn({ columnName: 'a', destination: 2 })); - expect(state.columns).toEqual(['b', 'c', 'a']); - }); - - it('moveColumn does nothing if column not found', () => { - const state = legacyReducer(initialState, moveColumn({ columnName: 'z', destination: 1 })); - expect(state.columns).toEqual(initialState.columns); - }); - - it('moveColumn does nothing if destination is out of bounds', () => { - const state = legacyReducer(initialState, moveColumn({ columnName: 'a', destination: 10 })); - expect(state.columns).toEqual(initialState.columns); - }); - - it('setSort sets sort', () => { - const sort: SortOrder[] = [['b', 'desc']]; - const state = legacyReducer(initialState, setSort(sort)); - expect(state.sort).toEqual(sort); - }); - it('setInterval sets interval', () => { const state = legacyReducer(initialState, setInterval('5m')); expect(state.interval).toBe('5m'); diff --git a/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.ts b/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.ts index b69bdff2ac06..03d0ea963aae 100644 --- a/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.ts +++ b/src/plugins/explore/public/application/utils/state_management/slices/legacy/legacy_slice.ts @@ -4,7 +4,6 @@ */ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { SortOrder } from '../../../../../types/saved_explore_types'; /** * Legacy state interface @@ -17,12 +16,6 @@ export interface LegacyState { // Saved query ID (matches discover/vis_builder format) savedQuery?: string; - // Column configuration - columns: string[]; - - // Sort configuration - using SortOrder format to match Discover - sort: SortOrder[]; - // Interval configuration interval: string; @@ -36,8 +29,6 @@ export interface LegacyState { const initialState: LegacyState = { savedSearch: undefined, savedQuery: undefined, - columns: [], - sort: [], interval: 'auto', isDirty: false, lineCount: undefined, @@ -65,28 +56,6 @@ const legacySlice = createSlice({ }; } }, - setColumns: (state, action: PayloadAction) => { - state.columns = action.payload; - }, - addColumn: (state, action: PayloadAction<{ column: string }>) => { - if (!state.columns.includes(action.payload.column)) { - state.columns.push(action.payload.column); - } - }, - removeColumn: (state, action: PayloadAction) => { - state.columns = state.columns.filter((col) => col !== action.payload); - }, - moveColumn: (state, action: PayloadAction<{ columnName: string; destination: number }>) => { - const { columnName, destination } = action.payload; - const index = state.columns.indexOf(columnName); - if (index !== -1 && destination >= 0 && destination < state.columns.length) { - state.columns.splice(index, 1); - state.columns.splice(destination, 0, columnName); - } - }, - setSort: (state, action: PayloadAction) => { - state.sort = action.payload; - }, setInterval: (state, action: PayloadAction) => { state.interval = action.payload; }, @@ -103,11 +72,6 @@ export const { setLegacyState, setSavedSearch, setSavedQuery, - setColumns, - addColumn, - removeColumn, - moveColumn, - setSort, setInterval, setIsDirty, setLineCount, diff --git a/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.test.ts b/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.test.ts index 538f02697a75..ecde31151db5 100644 --- a/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.test.ts +++ b/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.test.ts @@ -3,26 +3,45 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { setTabState, tabReducer, TabState } from './tab_slice'; +import { + setTabState, + setPatternsField, + setUsingRegexPatterns, + tabReducer, + TabState, + resetEphemeralLogsState, + setExpandedRowState, + setSelectedRowState, + clearExpandedRowsState, + clearSelectedRowsState, + setVisibleColumnNames, + moveVisibleColumnName, + setDefaultColumnNames, +} from './tab_slice'; describe('tabSlice reducers', () => { const initialState: TabState = { - logs: {}, + logs: { + expandedRowsMap: {}, + selectedRowsMap: {}, + visibleColumnNames: ['a', 'b', 'c'], + defaultColumnNames: ['a', 'b', 'c'], + }, patterns: { patternsField: undefined, usingRegexPatterns: false }, }; - it('should return the initial state', () => { - // @ts-ignore - passing undefined action - expect(tabReducer(undefined, {})).toEqual(initialState); - }); - describe('setTabState', () => { it('should replace the entire state', () => { const newState: TabState = { - logs: { someProperty: 'value' }, + logs: { + expandedRowsMap: { row1: true }, + selectedRowsMap: { row2: true }, + visibleColumnNames: [], + defaultColumnNames: [], + }, patterns: { patternsField: 'message', - usingRegexPatterns: false, + usingRegexPatterns: true, }, }; @@ -32,9 +51,14 @@ describe('tabSlice reducers', () => { it('should handle different tab state configurations', () => { const newState: TabState = { - logs: {}, + logs: { + expandedRowsMap: {}, + selectedRowsMap: {}, + visibleColumnNames: [], + defaultColumnNames: [], + }, patterns: { - patternsField: 'message', + patternsField: 'level', usingRegexPatterns: false, }, }; @@ -44,40 +68,232 @@ describe('tabSlice reducers', () => { }); }); - describe('state immutability', () => { - it('should maintain proper state structure', () => { + describe('setPatternsField', () => { + it('should set the patterns field', () => { + const state = tabReducer(initialState, setPatternsField('message')); + expect(state.patterns.patternsField).toBe('message'); + expect(state.patterns.usingRegexPatterns).toBe(false); // should not change + expect(state.logs).toEqual(initialState.logs); // should not change + }); + + it('should update patterns field when already set', () => { + const stateWithField = { + ...initialState, + patterns: { patternsField: 'oldField', usingRegexPatterns: true }, + }; + + const state = tabReducer(stateWithField, setPatternsField('newField')); + expect(state.patterns.patternsField).toBe('newField'); + expect(state.patterns.usingRegexPatterns).toBe(true); // should remain unchanged + }); + }); + + describe('setUsingRegexPatterns', () => { + it('should set the using regex patterns flag to true', () => { + const state = tabReducer(initialState, setUsingRegexPatterns(true)); + expect(state.patterns.usingRegexPatterns).toBe(true); + expect(state.patterns.patternsField).toBeUndefined(); // should not change + expect(state.logs).toEqual(initialState.logs); // should not change + }); + + it('should set the using regex patterns flag to false', () => { + const stateWithRegex = { + ...initialState, + patterns: { patternsField: 'message', usingRegexPatterns: true }, + }; + + const state = tabReducer(stateWithRegex, setUsingRegexPatterns(false)); + expect(state.patterns.usingRegexPatterns).toBe(false); + expect(state.patterns.patternsField).toBe('message'); // should remain unchanged + }); + }); + + describe('resetEphemeralLogsState', () => { + it('should reset logs state to initial state for ephemeral state', () => { + const stateWithLogs = { + ...initialState, + logs: { + expandedRowsMap: { row1: true, row2: true }, + selectedRowsMap: { row3: true }, + visibleColumnNames: ['a', 'b', 'c', 'd'], + defaultColumnNames: ['a', 'b', 'c'], + }, + }; + + const state = tabReducer(stateWithLogs, resetEphemeralLogsState()); + expect(state.logs).toEqual({ + expandedRowsMap: {}, + selectedRowsMap: {}, + visibleColumnNames: ['a', 'b', 'c', 'd'], + defaultColumnNames: ['a', 'b', 'c'], + }); + expect(state.patterns).toEqual(initialState.patterns); // should not change + }); + }); + + describe('setExpandedRowState', () => { + it('should add expanded row when state is true', () => { + const state = tabReducer(initialState, setExpandedRowState({ id: 'row1', state: true })); + expect(state.logs.expandedRowsMap).toEqual({ row1: true }); + expect(state.logs.selectedRowsMap).toEqual({}); // should not change + }); + + it('should remove expanded row when state is false', () => { + const stateWithExpanded = { + ...initialState, + logs: { + expandedRowsMap: { row1: true, row2: true }, + selectedRowsMap: {}, + visibleColumnNames: ['a', 'b', 'c'], + defaultColumnNames: ['a', 'b', 'c'], + }, + }; + const state = tabReducer( - initialState, - setTabState({ logs: { someProperty: 'value' }, patterns: { usingRegexPatterns: false } }) + stateWithExpanded, + setExpandedRowState({ id: 'row1', state: false }) ); + expect(state.logs.expandedRowsMap).toEqual({ row2: true }); + }); - // Ensure the state structure is maintained - expect(state).toHaveProperty('logs'); + it('should remove expanded row when state is false but didnt exist', () => { + const stateWithExpanded = { + ...initialState, + logs: { + expandedRowsMap: { row1: true, row2: true }, + selectedRowsMap: {}, + visibleColumnNames: [], + defaultColumnNames: ['a', 'b', 'c'], + }, + }; + + const state = tabReducer( + stateWithExpanded, + setExpandedRowState({ id: 'row3', state: false }) + ); + expect(state.logs.expandedRowsMap).toEqual({ row1: true, row2: true }); }); - it('should not mutate the original state', () => { - const originalState = { ...initialState }; - tabReducer( - initialState, - setTabState({ logs: { someProperty: 'value' }, patterns: { usingRegexPatterns: false } }) + it('should handle multiple expanded rows', () => { + let state = tabReducer(initialState, setExpandedRowState({ id: 'row1', state: true })); + state = tabReducer(state, setExpandedRowState({ id: 'row2', state: true })); + + expect(state.logs.expandedRowsMap).toEqual({ row1: true, row2: true }); + }); + }); + + describe('setSelectedRowState', () => { + it('should add selected row when state is true', () => { + const state = tabReducer(initialState, setSelectedRowState({ id: 'row1', state: true })); + expect(state.logs.selectedRowsMap).toEqual({ row1: true }); + expect(state.logs.expandedRowsMap).toEqual({}); // should not change + }); + + it('should remove selected row when state is false', () => { + const stateWithSelected = { + ...initialState, + logs: { + expandedRowsMap: {}, + selectedRowsMap: { row1: true, row2: true }, + visibleColumnNames: [], + defaultColumnNames: ['a', 'b', 'c'], + }, + }; + + const state = tabReducer( + stateWithSelected, + setSelectedRowState({ id: 'row1', state: false }) ); + expect(state.logs.selectedRowsMap).toEqual({ row2: true }); + }); + + it('should handle multiple selected rows', () => { + let state = tabReducer(initialState, setSelectedRowState({ id: 'row1', state: true })); + state = tabReducer(state, setSelectedRowState({ id: 'row2', state: true })); - // Original state should remain unchanged - expect(initialState).toEqual(originalState); + expect(state.logs.selectedRowsMap).toEqual({ row1: true, row2: true }); }); + }); - it('should create new state objects for setTabState', () => { - const newState: TabState = { - logs: { test: 'value' }, - patterns: { - patternsField: 'message', - usingRegexPatterns: false, + describe('clearExpandedRowsState', () => { + it('should clear all expanded rows', () => { + const stateWithExpanded = { + ...initialState, + logs: { + expandedRowsMap: { row1: true, row2: true, row3: true }, + selectedRowsMap: { row4: true }, + visibleColumnNames: [], + defaultColumnNames: ['a', 'b', 'c'], }, }; - const result = tabReducer(initialState, setTabState(newState)); - expect(result).not.toBe(initialState); - expect(result).toEqual(newState); + const state = tabReducer(stateWithExpanded, clearExpandedRowsState()); + expect(state.logs.expandedRowsMap).toEqual({}); + expect(state.logs.selectedRowsMap).toEqual({ row4: true }); // should not change + }); + + it('should handle clearing empty expanded rows', () => { + const state = tabReducer(initialState, clearExpandedRowsState()); + expect(state.logs.expandedRowsMap).toEqual({}); + }); + }); + + describe('clearSelectedRowsState', () => { + it('should clear all selected rows', () => { + const stateWithSelected = { + ...initialState, + logs: { + expandedRowsMap: { row1: true }, + selectedRowsMap: { row2: true, row3: true, row4: true }, + visibleColumnNames: [], + defaultColumnNames: ['a', 'b', 'c'], + }, + }; + + const state = tabReducer(stateWithSelected, clearSelectedRowsState()); + expect(state.logs.selectedRowsMap).toEqual({}); + expect(state.logs.expandedRowsMap).toEqual({ row1: true }); // should not change + }); + + it('should handle clearing empty selected rows', () => { + const state = tabReducer(initialState, clearSelectedRowsState()); + expect(state.logs.selectedRowsMap).toEqual({}); + }); + }); + + describe('log columns', () => { + it('setVisibleColumnNames sets columns', () => { + const state = tabReducer(initialState, setVisibleColumnNames(['x', 'y'])); + expect(state.logs.visibleColumnNames).toEqual(['x', 'y']); + }); + + it('moveVisibleColumnName moves a column to the specified destination', () => { + const state = tabReducer( + initialState, + moveVisibleColumnName({ columnName: 'a', destination: 2 }) + ); + expect(state.logs.visibleColumnNames).toEqual(['b', 'c', 'a']); + }); + + it('moveVisibleColumnName does nothing if column not found', () => { + const state = tabReducer( + initialState, + moveVisibleColumnName({ columnName: 'z', destination: 1 }) + ); + expect(state.logs.visibleColumnNames).toEqual(initialState.logs.visibleColumnNames); + }); + + it('moveVisibleColumnName does nothing if destination is out of bounds', () => { + const state = tabReducer( + initialState, + moveVisibleColumnName({ columnName: 'a', destination: 10 }) + ); + expect(state.logs.visibleColumnNames).toEqual(initialState.logs.visibleColumnNames); + }); + + it('setDefaultColumnNames sets columns', () => { + const state = tabReducer(initialState, setDefaultColumnNames(['x', 'y'])); + expect(state.logs.defaultColumnNames).toEqual(['x', 'y']); }); }); }); diff --git a/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.ts b/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.ts index 5e662154c094..e53ae7aea7b3 100644 --- a/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.ts +++ b/src/plugins/explore/public/application/utils/state_management/slices/tab/tab_slice.ts @@ -6,15 +6,27 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; export interface TabState { - logs: {}; + logs: { + expandedRowsMap: { [id: string]: boolean }; + selectedRowsMap: { [id: string]: boolean }; + visibleColumnNames: string[]; + defaultColumnNames: string[]; + }; patterns: { patternsField?: string; // kept as string, patterns tab will check if the field matches one in the schema usingRegexPatterns: boolean; }; } +export const initialLogsState = { + expandedRowsMap: {}, + selectedRowsMap: {}, + visibleColumnNames: [], + defaultColumnNames: [], +}; + const initialState: TabState = { - logs: {}, + logs: initialLogsState, patterns: { patternsField: undefined, usingRegexPatterns: false, @@ -34,9 +46,71 @@ const tabSlice = createSlice({ setUsingRegexPatterns: (state, action: PayloadAction) => { state.patterns.usingRegexPatterns = action.payload; }, + resetEphemeralLogsState: (state) => { + state.logs.expandedRowsMap = {}; + state.logs.selectedRowsMap = {}; + }, + setExpandedRowState: (state, action: PayloadAction<{ id: string; state: boolean }>) => { + if (action.payload.state) { + state.logs.expandedRowsMap[action.payload.id] = true; + } else { + delete state.logs.expandedRowsMap[action.payload.id]; + } + }, + setSelectedRowState: (state, action: PayloadAction<{ id: string; state: boolean }>) => { + if (action.payload.state) { + state.logs.selectedRowsMap[action.payload.id] = true; + } else { + delete state.logs.selectedRowsMap[action.payload.id]; + } + }, + clearExpandedRowsState: (state) => { + state.logs.expandedRowsMap = {}; + }, + clearSelectedRowsState: (state) => { + state.logs.selectedRowsMap = {}; + }, + setVisibleColumnNames: (state, action: PayloadAction) => { + return { + ...state, + logs: { + ...state.logs, + visibleColumnNames: action.payload, + }, + }; + }, + moveVisibleColumnName: ( + state, + action: PayloadAction<{ columnName: string; destination: number }> + ) => { + const { columnName, destination } = action.payload; + if (destination < 0 || destination >= state.logs.visibleColumnNames.length) { + return; + } + const index = state.logs.visibleColumnNames.indexOf(columnName); + if (index !== -1) { + state.logs.visibleColumnNames.splice(index, 1); + state.logs.visibleColumnNames.splice(destination, 0, columnName); + } + }, + setDefaultColumnNames: (state, action: PayloadAction) => { + state.logs.defaultColumnNames = action.payload; + }, }, }); -export const { setTabState, setPatternsField, setUsingRegexPatterns } = tabSlice.actions; +export const { + setTabState, + setPatternsField, + setUsingRegexPatterns, + resetEphemeralLogsState, + setExpandedRowState, + setSelectedRowState, + clearExpandedRowsState, + clearSelectedRowsState, + setVisibleColumnNames, + moveVisibleColumnName, + setDefaultColumnNames, +} = tabSlice.actions; export const tabReducer = tabSlice.reducer; diff --git a/src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/add_source_or_time_to_fields.ts b/src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/add_source_or_time_to_fields.ts new file mode 100644 index 000000000000..fd6c1f507e19 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/add_source_or_time_to_fields.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Dataset } from '../../../../../../../data/common'; +import { SOURCE_COLUMN_ID_AND_NAME } from '../../../../../components/results_table/table_constants'; + +export const defaultColumnNames = [SOURCE_COLUMN_ID_AND_NAME]; + +export const addSourceOrTimeToFields = (columns: string[], dataset: Dataset): string[] => { + let output: string[] = [...columns]; + const columnsWithoutTime = columns.filter((column) => column !== dataset.timeFieldName); + + // handle _source: + // if empty, then should be default + // if there are more than 1 column, _source shouldn't exist + if (!columnsWithoutTime.length) { + output = dataset.timeFieldName + ? [dataset.timeFieldName, ...defaultColumnNames] + : [...defaultColumnNames]; + } else if ( + columnsWithoutTime.length > 1 && + columnsWithoutTime.includes(SOURCE_COLUMN_ID_AND_NAME) + ) { + output = columns.filter((col) => col !== SOURCE_COLUMN_ID_AND_NAME); + } + + // handle time field + const timeFieldName = dataset.timeFieldName; + if (!timeFieldName || output.includes(timeFieldName)) { + return output; + } + return [timeFieldName, ...output]; +}; diff --git a/src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/index.ts b/src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/index.ts new file mode 100644 index 000000000000..20af4de1ac13 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/utils/add_source_or_time_to_fields/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './add_source_or_time_to_fields'; diff --git a/src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/get_default_columns.ts b/src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/get_default_columns.ts new file mode 100644 index 000000000000..8ddf61b82d9f --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/get_default_columns.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExploreServices } from '../../../../../types'; +import { Dataset, DEFAULT_DATA } from '../../../../../../../data/common'; +import { getCurrentAppId, getFlavorFromAppId } from '../../../../../helpers/get_flavor_from_app_id'; +import { + DEFAULT_COLUMNS_SETTING, + DEFAULT_TRACE_COLUMNS_SETTING, + ExploreFlavor, +} from '../../../../../../common'; +import { addSourceOrTimeToFields, defaultColumnNames } from '../add_source_or_time_to_fields'; + +/** + * Grabs the columns that should be displayed for the given dataset as default + */ +export const getDefaultColumnNames = async ( + services: ExploreServices, + dataset?: Dataset +): Promise => { + if (!dataset) { + return defaultColumnNames; + } + + const { dataViews, uiSettings } = services; + const dataView = await dataViews.get( + dataset.id, + dataset.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); + const currentAppId = await getCurrentAppId(services); + const flavorFromAppId = getFlavorFromAppId(currentAppId); + + const columnNames: string[] = + flavorFromAppId === ExploreFlavor.Traces + ? uiSettings?.get(DEFAULT_TRACE_COLUMNS_SETTING) ?? [] + : uiSettings?.get(DEFAULT_COLUMNS_SETTING) ?? []; + const fieldsNameFromDataset = dataView.fields.getAll().map((field) => field.name); + + // filter out any columns from default setting that is not in the dataset and remove duplicates + const filteredColumnNames = [ + ...new Set(columnNames.filter((column) => fieldsNameFromDataset.includes(column))), + ]; + + return addSourceOrTimeToFields(filteredColumnNames, dataset); +}; diff --git a/src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/index.ts b/src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/index.ts new file mode 100644 index 000000000000..5bb195881d59 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/utils/get_default_columns/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './get_default_columns'; diff --git a/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.test.ts b/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.test.ts index 9384f8c89f9d..ba835401b005 100644 --- a/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.test.ts +++ b/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.test.ts @@ -11,6 +11,7 @@ import { ColorSchemas } from '../../../../components/visualizations/types'; import { EditorMode, QueryExecutionStatus } from '../types'; import { CORE_SIGNAL_TYPES } from '../../../../../../data/common'; import { of } from 'rxjs'; +import { SOURCE_COLUMN_ID_AND_NAME } from '../../../../components/results_table/table_constants'; jest.mock('../../../../components/visualizations/metric/metric_vis_config', () => ({ defaultMetricChartStyles: { @@ -67,6 +68,7 @@ describe('redux_persistence', () => { id: 'test-dataset', title: 'test-dataset', signalType: CORE_SIGNAL_TYPES.LOGS, + fields: { getAll: () => [] }, }) ), }, @@ -98,15 +100,18 @@ describe('redux_persistence', () => { }, results: {}, tab: { - logs: {}, + logs: { + expandedRowsMap: {}, + selectedRowsMap: {}, + visibleColumnNames: [SOURCE_COLUMN_ID_AND_NAME], + defaultColumnNames: [SOURCE_COLUMN_ID_AND_NAME], + }, patterns: { patternsField: undefined, usingRegexPatterns: false, }, }, legacy: { - columns: ['_source'], - sort: [], isDirty: false, interval: 'auto', savedSearch: undefined, @@ -144,7 +149,10 @@ describe('redux_persistence', () => { '_a', { ui: mockState.ui, - tab: mockState.tab, + tab: { + patterns: mockState.tab.patterns, + logs: { visibleColumnNames: [SOURCE_COLUMN_ID_AND_NAME] }, + }, legacy: mockState.legacy, }, { replace: true } @@ -220,9 +228,27 @@ describe('redux_persistence', () => { const result = await loadReduxState(mockServices); - expect(result.query).toEqual(mockQueryState); + expect(result.query).toEqual({ + ...mockQueryState, + dataset: { + id: 'test-dataset', + title: 'test-dataset', + type: 'INDEX_PATTERN', + timeFieldName: undefined, + dataSource: undefined, + }, + }); expect(result.ui).toEqual(mockAppState.ui); - expect(mockServices.data.query.queryString.setQuery).toHaveBeenCalledWith(mockQueryState); + expect(mockServices.data.query.queryString.setQuery).toHaveBeenCalledWith({ + ...mockQueryState, + dataset: { + id: 'test-dataset', + title: 'test-dataset', + type: 'INDEX_PATTERN', + timeFieldName: undefined, + dataSource: undefined, + }, + }); }); it('should fallback to preloaded state when URL storage is not available', async () => { @@ -248,7 +274,11 @@ describe('redux_persistence', () => { const result = await loadReduxState(mockServices); - expect(result.query).toEqual(mockQueryState); + expect(result.query).toEqual({ + dataset: undefined, + language: 'PPL', + query: '', + }); expect(result.ui.activeTabId).toBe(''); // Should use preloaded UI state }); @@ -285,8 +315,9 @@ describe('redux_persistence', () => { expect(result.query.language).toBe(EXPLORE_DEFAULT_LANGUAGE); expect(result.query.query).toBe(''); // Should be empty string expect(result.results).toEqual({}); - expect(result.tab.logs).toEqual({}); - expect(result.legacy.columns).toEqual(['_source']); + expect(result.tab.logs.selectedRowsMap).toEqual({}); + expect(result.tab.logs.expandedRowsMap).toEqual({}); + expect(result.tab.logs.visibleColumnNames).toEqual([SOURCE_COLUMN_ID_AND_NAME]); expect(result.queryEditor.promptModeIsAvailable).toBe(false); expect(result.queryEditor.queryStatusMap).toEqual({}); expect(result.queryEditor.overallQueryStatus).toEqual({ @@ -317,6 +348,14 @@ describe('redux_persistence', () => { })), }); + // Update the dataViews mock for this test to include proper dataset + (mockServices.data.dataViews!.get as jest.Mock).mockResolvedValue({ + id: 'test-dataset', + title: 'test-dataset', + signalType: CORE_SIGNAL_TYPES.LOGS, + fields: { getAll: () => [{ name: '_source' }] }, + }); + const result = await getPreloadedState(mockServices); expect(result.query.dataset).toEqual(mockDataset); @@ -340,6 +379,28 @@ describe('redux_persistence', () => { it('should use default columns from uiSettings', async () => { const customColumns = ['field1', 'field2']; + const mockDataset = { id: 'test-dataset', title: 'test-dataset', type: 'INDEX_PATTERN' }; + + // Mock dataset service to return a dataset + (mockServices.data.query.queryString.getDatasetService as jest.Mock).mockReturnValue({ + getType: jest.fn(() => ({ + fetch: jest.fn(() => + Promise.resolve({ + children: [{ id: 'pattern1', title: 'Pattern 1' }], + }) + ), + toDataset: jest.fn(() => mockDataset), + })), + }); + + // Mock dataViews to return fields that match the custom columns + (mockServices.data.dataViews!.get as jest.Mock).mockResolvedValue({ + id: 'test-dataset', + title: 'test-dataset', + signalType: CORE_SIGNAL_TYPES.LOGS, + fields: { getAll: () => [{ name: 'field1' }, { name: 'field2' }, { name: '_source' }] }, + }); + (mockServices.uiSettings!.get as jest.Mock).mockImplementation((key) => { if (key === 'defaultColumns') return customColumns; return undefined; @@ -347,7 +408,10 @@ describe('redux_persistence', () => { const result = await getPreloadedState(mockServices); - expect(result.legacy.columns).toEqual(customColumns); + expect(result.tab.logs.visibleColumnNames).toEqual([ + ...customColumns, + SOURCE_COLUMN_ID_AND_NAME, + ]); }); it('should fallback to default columns when uiSettings is not available', async () => { @@ -360,7 +424,7 @@ describe('redux_persistence', () => { const result = await getPreloadedState(servicesWithoutUiSettings); - expect(result.legacy.columns).toEqual(['_source']); + expect(result.tab.logs.visibleColumnNames).toEqual([SOURCE_COLUMN_ID_AND_NAME]); }); it('should set correct editor mode from DEFAULT_EDITOR_MODE', async () => { @@ -497,7 +561,12 @@ describe('redux_persistence', () => { data: { ...mockServices.data, dataViews: { - get: jest.fn(() => Promise.resolve({ signalType: CORE_SIGNAL_TYPES.TRACES })), + get: jest.fn(() => + Promise.resolve({ + signalType: CORE_SIGNAL_TYPES.TRACES, + fields: { getAll: () => [] }, + }) + ), }, }, } as any; @@ -620,6 +689,10 @@ describe('redux_persistence', () => { const tracesServices = { ...mockServices, core: { application: { currentAppId$: of('explore/traces') } }, + data: { + ...mockServices.data, + dataViews: mockServices.data.dataViews, + }, } as any; const mockQueryState = { @@ -677,6 +750,10 @@ describe('redux_persistence', () => { const logsServices = { ...mockServices, core: { application: { currentAppId$: of('explore/logs') } }, + data: { + ...mockServices.data, + dataViews: mockServices.data.dataViews, + }, } as any; const mockQueryState = { diff --git a/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.ts b/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.ts index c203255d0aeb..a122967973e9 100644 --- a/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.ts +++ b/src/plugins/explore/public/application/utils/state_management/utils/redux_persistence.ts @@ -8,6 +8,7 @@ import { RootState } from '../store'; import { AppState, QueryExecutionStatus } from '../types'; import { ExploreServices } from '../../../../types'; import { + initialLogsState, LegacyState, QueryEditorSliceState, QueryState, @@ -22,14 +23,37 @@ import { CORE_SIGNAL_TYPES, } from '../../../../../../data/common'; import { DatasetTypeConfig, IDataPluginServices } from '../../../../../../data/public'; -import { - DEFAULT_TRACE_COLUMNS_SETTING, - ExploreFlavor, - EXPLORE_DEFAULT_LANGUAGE, -} from '../../../../../common'; +import { ExploreFlavor, EXPLORE_DEFAULT_LANGUAGE } from '../../../../../common'; import { getPromptModeIsAvailable } from '../../get_prompt_mode_is_available'; import { getSummaryAgentIsAvailable } from '../../get_summary_agent_is_available'; import { DEFAULT_EDITOR_MODE } from '../constants'; +import { QueryWithQueryAsString } from '../../languages'; +import { getDefaultColumnNames } from './get_default_columns'; + +export const filterTabsStateToPersist = ( + tabState: TabState +): Omit & { logs: Pick } => { + return { + patterns: tabState.patterns, + logs: { + visibleColumnNames: tabState.logs.visibleColumnNames, + }, + }; +}; + +export const mergeTabState = (preloadedState: TabState, appTabState: Record) => { + return { + patterns: { + ...preloadedState.patterns, + ...appTabState?.patterns, + }, + logs: { + ...preloadedState.logs, + visibleColumnNames: + appTabState?.logs?.visibleColumnNames || preloadedState.logs.visibleColumnNames, + }, + }; +}; /** * Persists Redux state to URL @@ -45,7 +69,7 @@ export const persistReduxState = (state: RootState, services: ExploreServices) = '_a', { ui: state.ui, - tab: state.tab, + tab: filterTabsStateToPersist(state.tab), legacy: state.legacy, }, { replace: true } @@ -101,7 +125,10 @@ export const loadReduxState = async (services: ExploreServices): Promise { /** * Get preloaded tab state */ -const getPreloadedTabState = (services: ExploreServices): TabState => { +const getPreloadedTabState = async ( + services: ExploreServices, + queryState: QueryWithQueryAsString +): Promise => { + const defaultColumns = await getDefaultColumnNames(services, queryState.dataset); + return { - logs: {}, + logs: { + ...initialLogsState, + visibleColumnNames: defaultColumns, + defaultColumnNames: defaultColumns, + }, patterns: { patternsField: undefined, usingRegexPatterns: false, @@ -378,21 +414,10 @@ const getPreloadedTabState = (services: ExploreServices): TabState => { * Get preloaded legacy state (vis_builder approach - defaults only, no saved object loading) */ export const getPreloadedLegacyState = async (services: ExploreServices): Promise => { - // Only return defaults - NO saved object loading (like vis_builder) - const currentAppId = await getCurrentAppId(services); - const flavorFromAppId = getFlavorFromAppId(currentAppId); - - const defaultColumns = - flavorFromAppId === ExploreFlavor.Traces - ? services.uiSettings?.get(DEFAULT_TRACE_COLUMNS_SETTING) - : services.uiSettings?.get('defaultColumns'); - return { // Fields that exist in data_explorer + discover // TODO: load saved explore by id savedSearch: undefined, // Matches discover format - string ID, not object - columns: defaultColumns || ['_source'], - sort: [], isDirty: false, savedQuery: undefined, lineCount: undefined, // Flattened from metadata.lineCount diff --git a/src/plugins/explore/public/components/data_table/explore_data_table.test.tsx b/src/plugins/explore/public/components/data_table/explore_data_table.test.tsx index 40d8f314a087..08688dc14af7 100644 --- a/src/plugins/explore/public/components/data_table/explore_data_table.test.tsx +++ b/src/plugins/explore/public/components/data_table/explore_data_table.test.tsx @@ -13,6 +13,7 @@ import { uiReducer, queryReducer, resultsReducer, + tabReducer, } from '../../application/utils/state_management/slices'; // Mock the hooks and services @@ -98,13 +99,12 @@ describe('ExploreDataTable', () => { ui: uiReducer, query: queryReducer, results: resultsReducer, + tab: tabReducer, }, preloadedState: { legacy: { savedSearch: 'test-search', savedQuery: undefined, - columns: ['@timestamp', 'message'], - sort: [], interval: '1h', isDirty: false, lineCount: undefined, @@ -122,6 +122,14 @@ describe('ExploreDataTable', () => { type: 'INDEX_PATTERN', }, }, + tab: { + logs: { + expandedRowsMap: {}, + selectedRowsMap: {}, + visibleColumns: ['@timestamp', 'message'], + }, + patterns: { patternsField: 'pattern', usingRegexPatterns: false }, + }, results: hasResults ? { 'test-cache-key': { diff --git a/src/plugins/explore/public/components/data_table/explore_data_table.tsx b/src/plugins/explore/public/components/data_table/explore_data_table.tsx index 49258773625f..eb9baf853bdf 100644 --- a/src/plugins/explore/public/components/data_table/explore_data_table.tsx +++ b/src/plugins/explore/public/components/data_table/explore_data_table.tsx @@ -23,7 +23,7 @@ import { getLegacyDisplayedColumns } from '../../helpers/data_table_helper'; import { getDocViewsRegistry } from '../../application/legacy/discover/opensearch_dashboards_services'; import { ExploreServices } from '../../types'; import { - selectColumns, + selectVisibleColumnNames, selectSavedSearch, } from '../../application/utils/state_management/selectors'; import { RootState } from '../../application/utils/state_management/store'; @@ -33,7 +33,10 @@ import { } from '../../application/utils/state_management/actions/query_actions'; import { useChangeQueryEditor } from '../../application/hooks'; import { useDatasetContext } from '../../application/context'; -import { addColumn, removeColumn } from '../../application/utils/state_management/slices'; +import { + addVisibleColumnName, + removeVisibleColumnName, +} from '../../application/utils/state_management/actions/columns'; const ExploreDataTableComponent = () => { const { services } = useOpenSearchDashboards(); @@ -41,7 +44,7 @@ const ExploreDataTableComponent = () => { const { onAddFilter } = useChangeQueryEditor(); const savedSearch = useSelector(selectSavedSearch); - const columns = useSelector(selectColumns); + const columns = useSelector(selectVisibleColumnNames); const { dataset } = useDatasetContext(); const query = useSelector((state: RootState) => state.query); @@ -102,14 +105,14 @@ const ExploreDataTableComponent = () => { const dispatch = useDispatch(); const onAddColumn = useCallback( (col: string) => { - dispatch(addColumn({ column: col })); + dispatch(addVisibleColumnName(col)); }, [dispatch] ); const onRemoveColumn = useCallback( (col: string) => { - dispatch(removeColumn(col)); + dispatch(removeVisibleColumnName(col)); }, [dispatch] ); diff --git a/src/plugins/explore/public/components/fields_selector/discover_field.tsx b/src/plugins/explore/public/components/fields_selector/discover_field.tsx index 6ddbfbb56089..1020bf9494ec 100644 --- a/src/plugins/explore/public/components/fields_selector/discover_field.tsx +++ b/src/plugins/explore/public/components/fields_selector/discover_field.tsx @@ -125,6 +125,7 @@ export const DiscoverField = ({ } ); const isSourceField = field.name === '_source'; + const isTimeField = field.name === dataSet.timeFieldName; const [infoIsOpen, setOpen] = useState(false); @@ -155,7 +156,7 @@ export const DiscoverField = ({ ); let actionButton; - if (!isSourceField && !selected) { + if (!isSourceField && !isTimeField && !selected) { actionButton = ( ); - } else if (!isSourceField && selected) { + } else if (!isSourceField && !isTimeField && selected) { actionButton = ( {fieldName} - {!isSourceField && ( + {!isSourceField && !isTimeField && (
{showSummary && ( ({ })); jest.mock('../../application/utils/state_management/selectors', () => ({ - selectColumns: jest.fn(() => []), + selectVisibleColumns: jest.fn(() => []), selectQuery: jest.fn(() => ({})), })); diff --git a/src/plugins/explore/public/components/fields_selector/fields_selector_panel.tsx b/src/plugins/explore/public/components/fields_selector/fields_selector_panel.tsx index 8675b1132a2c..6e9c30417d26 100644 --- a/src/plugins/explore/public/components/fields_selector/fields_selector_panel.tsx +++ b/src/plugins/explore/public/components/fields_selector/fields_selector_panel.tsx @@ -3,26 +3,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect, useRef, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { UI_SETTINGS } from '../../../../data/public'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { - addColumn, - removeColumn, - moveColumn, - setColumns, -} from '../../application/utils/state_management/slices'; -import { selectColumns, selectQuery } from '../../application/utils/state_management/selectors'; + selectQuery, + selectVisibleColumnNames, +} from '../../application/utils/state_management/selectors'; import { DiscoverSidebar } from '.'; import { ExploreServices } from '../../types'; -import { buildColumns } from '../../application/legacy/discover/application/utils/columns'; import { useDatasetContext } from '../../application/context'; import { defaultResultsProcessor, defaultPrepareQueryString, } from '../../application/utils/state_management/actions/query_actions'; import { useChangeQueryEditor } from '../../application/hooks'; +import { moveVisibleColumnName } from '../../application/utils/state_management/slices'; +import { + addVisibleColumnName, + removeVisibleColumnName, +} from '../../application/utils/state_management/actions/columns'; export interface IDiscoverPanelProps { collapsePanel?: () => void; @@ -33,7 +34,7 @@ export function DiscoverPanel({ collapsePanel }: IDiscoverPanelProps) { const { uiSettings } = services; const { onAddFilter } = useChangeQueryEditor(); - const columns = useSelector(selectColumns); + const columns = useSelector(selectVisibleColumnNames); const query = useSelector(selectQuery); const results = useSelector((state: any) => state.results); const cacheKey = useMemo(() => defaultPrepareQueryString(query), [query]); @@ -54,29 +55,8 @@ export function DiscoverPanel({ collapsePanel }: IDiscoverPanelProps) { // Get fieldCounts and rows from processed results const fieldCounts = processedResults?.fieldCounts || {}; const rows = (processedResults as any)?.hits?.hits || []; - const prevColumns = useRef(columns); const dispatch = useDispatch(); - useEffect(() => { - const timeFieldname = dataset?.timeFieldName; - - if (columns !== prevColumns.current) { - let updatedColumns = buildColumns(columns); - if ( - columns && - timeFieldname && - !prevColumns.current.includes(timeFieldname) && - columns.includes(timeFieldname) - ) { - // Remove timeFieldname from columns if previously chosen columns does not include time field - updatedColumns = columns.filter((column: string) => column !== timeFieldname); - } - // Update the ref with the new columns - dispatch(setColumns(updatedColumns)); - prevColumns.current = columns; - } - }, [columns, dispatch, dataset?.timeFieldName]); - const isEnhancementsEnabledOverride = uiSettings.get(UI_SETTINGS.QUERY_ENHANCEMENTS_ENABLED); return ( @@ -85,15 +65,15 @@ export function DiscoverPanel({ collapsePanel }: IDiscoverPanelProps) { fieldCounts={(fieldCounts as any) || {}} hits={rows || []} onAddField={(fieldName) => { - dispatch(addColumn({ column: fieldName })); + dispatch(addVisibleColumnName(fieldName)); }} onRemoveField={(fieldName) => { - dispatch(removeColumn(fieldName)); + dispatch(removeVisibleColumnName(fieldName)); }} onReorderFields={(source, destination) => { const columnName = columns[source]; dispatch( - moveColumn({ + moveVisibleColumnName({ columnName, destination, }) diff --git a/src/plugins/explore/public/components/results_table/body/expanded_document/index.ts b/src/plugins/explore/public/components/results_table/body/expanded_document/index.ts new file mode 100644 index 000000000000..46c5622a4ef9 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/body/expanded_document/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './results_table_expanded_document'; diff --git a/src/plugins/explore/public/components/results_table/body/expanded_document/results_table_expanded_document.tsx b/src/plugins/explore/public/components/results_table/body/expanded_document/results_table_expanded_document.tsx new file mode 100644 index 000000000000..093bcde2ae3c --- /dev/null +++ b/src/plugins/explore/public/components/results_table/body/expanded_document/results_table_expanded_document.tsx @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Row } from '@tanstack/react-table'; +import { DocViewer } from '../../../doc_viewer/doc_viewer'; +import { getDocViewsRegistry } from '../../../../application/legacy/discover/opensearch_dashboards_services'; +import { selectVisibleColumnNames } from '../../../../application/utils/state_management/selectors'; +import { useDatasetContext } from '../../../../application/context'; +import { setExpandedRowState } from '../../../../application/utils/state_management/slices'; + +export interface ExploreResultsTableExpandedDocumentProps { + row: Row; +} + +export const ExploreResultsTableExpandedDocument = ({ + row, +}: ExploreResultsTableExpandedDocumentProps) => { + const dispatch = useDispatch(); + const visibleColumns = useSelector(selectVisibleColumnNames); + const docViewsRegistry = useMemo(getDocViewsRegistry, []); + const { dataset } = useDatasetContext(); + + // TODO: WORK ON THIS! + const collapseExpandedRow = () => { + dispatch(setExpandedRowState); + }; + + if (!dataset) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/plugins/explore/public/components/results_table/body/index.ts b/src/plugins/explore/public/components/results_table/body/index.ts new file mode 100644 index 000000000000..c275a7af000b --- /dev/null +++ b/src/plugins/explore/public/components/results_table/body/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './results_table_body'; diff --git a/src/plugins/explore/public/components/results_table/body/results_table_body.scss b/src/plugins/explore/public/components/results_table/body/results_table_body.scss new file mode 100644 index 000000000000..f058e7103af0 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/body/results_table_body.scss @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +@import "../utils"; + +.exploreResultsTableBody { + display: grid; + position: relative; + + &__tr { + display: flex; + position: absolute; + width: 100%; + + &--row { + border: 1px solid transparent; + + &:hover { + background-color: $euiColorLightestShade; + } + } + + &--doc { + border-bottom: $ouiBorderThin; + } + } + + &__td { + display: flex; + + &--row { + @include border-on-hover; + } + } +} diff --git a/src/plugins/explore/public/components/results_table/body/results_table_body.tsx b/src/plugins/explore/public/components/results_table/body/results_table_body.tsx new file mode 100644 index 000000000000..9d197f7e7130 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/body/results_table_body.tsx @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { memo, RefObject, useCallback } from 'react'; +import { flexRender, Row, Table } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; +import { selectTabLogsExpandedRowsMap } from '../../../application/utils/state_management/selectors'; +import { getColumnWidth } from '../utils/css'; +import './results_table_body.scss'; +import { ExploreResultsTableExpandedDocument } from './expanded_document'; + +export interface ResultsTableBodyProps { + table: Table<{ [p: string]: any }>; + tableContainerRef: RefObject; +} + +const RESULT_ROW_HEIGHT = 32; +const DOC_ROW_HEIGHT = 200; + +export const ExploreResultsTableBody = ({ table, tableContainerRef }: ResultsTableBodyProps) => { + const expandedRowsMap = useSelector(selectTabLogsExpandedRowsMap); + const { rows } = table.getRowModel(); + + const estimateSize = useCallback( + (i) => { + if (i % 2 === 0) { + return RESULT_ROW_HEIGHT; + } + + const rowIndex = (i - 1) / 2; + if (expandedRowsMap[rowIndex]) { + return DOC_ROW_HEIGHT; + } + // 1 to account for the border-bottom that the doc row has + return 1; + }, + [expandedRowsMap] + ); + + const rowVirtualizer = useVirtualizer({ + // double to account for expansion + count: rows.length * 2, + estimateSize, + getScrollElement: () => tableContainerRef.current, + measureElement: + typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1 + ? (element) => element?.getBoundingClientRect().height + : undefined, + overscan: 10, + }); + return ( + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const isRegularRow = virtualRow.index % 2 === 0; + const rowIndex = isRegularRow ? virtualRow.index / 2 : (virtualRow.index - 1) / 2; + const row = rows[rowIndex] as Row; + const rowIsExpanded = expandedRowsMap[rowIndex] ?? false; + + return ( + + {isRegularRow ? ( + row.getVisibleCells().map((cell) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + }) + ) : ( + + {rowIsExpanded && } + + )} + + ); + })} + + ); +}; + +export const MemoizedResultsTableBody = memo( + ExploreResultsTableBody, + (prev, next) => prev.table.options.data === next.table.options.data +); diff --git a/src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.scss b/src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.scss new file mode 100644 index 000000000000..de2fa372efd8 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.scss @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.exploreResultsTableColumnActionsCell { + display: flex; + gap: $euiSizeS; + padding-left: $euiSizeS; + padding-right: $euiSizeS; + + &__toggle { + border-radius: 0; + height: $euiSize; + width: $euiSize; + } +} diff --git a/src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.tsx b/src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.tsx new file mode 100644 index 000000000000..7ebfc0b5311a --- /dev/null +++ b/src/plugins/explore/public/components/results_table/columns/column_actions_cell/column_actions_cell.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiCheckbox, EuiSmallButtonIcon } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFlavorId } from '../../../../helpers/use_flavor_id'; +import { ExploreFlavor } from '../../../../../common'; +import { + selectTabLogsExpandedRowsMap, + selectTabLogsSelectedRowsMap, +} from '../../../../application/utils/state_management/selectors'; +import { + setExpandedRowState, + setSelectedRowState, +} from '../../../../application/utils/state_management/slices'; +import './column_actions_cell.scss'; + +export interface ExploreResultsTableColumnActionsCellProps { + rowId: string; +} + +export const ExploreResultsTableColumnActionsCell = ({ + rowId, +}: ExploreResultsTableColumnActionsCellProps) => { + const flavorId = useFlavorId(); + const dispatch = useDispatch(); + const expandedRowsMap = useSelector(selectTabLogsExpandedRowsMap); + const selectedRowsMap = useSelector(selectTabLogsSelectedRowsMap); + const isExpanded: boolean = expandedRowsMap[rowId] ?? false; + const isSelected: boolean = selectedRowsMap[rowId] ?? false; + + if (flavorId === ExploreFlavor.Traces) { + return null; + } + + return ( +
+ dispatch(setSelectedRowState({ id: rowId, state: !isSelected }))} + aria-label={i18n.translate('explore.defaultTable.docTableExpandCheckboxColumnLabel', { + defaultMessage: `Select row`, + })} + checked={isSelected} + /> + dispatch(setExpandedRowState({ id: rowId, state: !isExpanded }))} + iconType={isExpanded ? 'arrowDown' : 'arrowRight'} + aria-label={i18n.translate('explore.defaultTable.docTableExpandToggleColumnLabel', { + defaultMessage: `Toggle row details`, + })} + data-test-subj="docTableExpandToggleColumn" + /> +
+ ); +}; diff --git a/src/plugins/explore/public/components/results_table/columns/column_actions_cell/index.ts b/src/plugins/explore/public/components/results_table/columns/column_actions_cell/index.ts new file mode 100644 index 000000000000..c79b40b14531 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/columns/column_actions_cell/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './column_actions_cell'; diff --git a/src/plugins/explore/public/components/results_table/columns/column_header/column_header.scss b/src/plugins/explore/public/components/results_table/columns/column_header/column_header.scss new file mode 100644 index 000000000000..63e7e0fe4f19 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/columns/column_header/column_header.scss @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.exploreResultsTableColumnHeader { + display: flex; + align-items: center; + justify-content: space-between; + + &:hover { + .exploreResultsTableColumnHeader__contextButton:not(.exploreResultsTableColumnHeader__contextButton--hidden) { + opacity: 1 !important; + } + } + + &--popoverIsOpen { + .exploreResultsTableColumnHeader__contextButton { + opacity: 1 !important; + } + } + + &__title { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__contextButton { + cursor: pointer; + margin-right: $euiSizeXS; + opacity: 0 !important; // override default styling for svgs + } + + &__popover { + display: flex; + flex-direction: column; + align-items: stretch; + } + + &__popoverButton { + border: none; + border-radius: 0; + box-shadow: none !important; // override default styling for buttons + + & > span { + justify-content: flex-start; + } + } +} diff --git a/src/plugins/explore/public/components/results_table/columns/column_header/column_header.tsx b/src/plugins/explore/public/components/results_table/columns/column_header/column_header.tsx new file mode 100644 index 000000000000..e4cd0705893e --- /dev/null +++ b/src/plugins/explore/public/components/results_table/columns/column_header/column_header.tsx @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { EuiButton, EuiIcon, EuiPopover, EuiToolTip, IconType } from '@elastic/eui'; +import './column_header.scss'; +import classNames from 'classnames'; +import { i18n } from '@osd/i18n'; +import { useCopyToClipboard } from 'react-use'; +import { useDispatch } from 'react-redux'; +import { removeVisibleColumnName } from '../../../../application/utils/state_management/actions/columns'; +import { getColumnSizeVariableName } from '../../utils/css'; + +export interface ExploreResultsTableColumnHeaderProps { + displayName: string; + fieldName: string; + columnId: string; + isChangeable: boolean; + disableHoverState: boolean; +} + +const removeColumnLabel = i18n.translate('explore.resultsTable.header.removeColumnLabel', { + defaultMessage: 'Remove column', +}); +const copyNameLabel = i18n.translate('explore.resultsTable.header.copyNameLabel', { + defaultMessage: 'Copy name', +}); + +export const ExploreResultsTableColumnHeader = ({ + displayName, + fieldName, + columnId, + isChangeable, + disableHoverState, +}: ExploreResultsTableColumnHeaderProps) => { + const dispatch = useDispatch(); + const [, copyToClipboard] = useCopyToClipboard(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const onButtonClick = () => setIsPopoverOpen((state) => !state); + const closePopover = () => setIsPopoverOpen(false); + + const labelEl = ( + + {displayName} + + ); + + return ( +
+ {disableHoverState ? ( + labelEl + ) : ( + + {labelEl} + + )} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + anchorPosition="leftUp" + panelPaddingSize="s" + > +
+ dispatch(removeVisibleColumnName(columnId))} + closePopover={closePopover} + /> + + Move left + + + Move Right + + + Pin column + + copyToClipboard(fieldName)} + closePopover={closePopover} + /> +
+
+
+ ); +}; + +export const ExploreResultsTableHeaderPopoverButton = ({ + iconType, + label, + onClick, + disabled, + closePopover, +}: { + iconType: IconType; + label: string; + onClick: () => void; + disabled?: boolean; + closePopover: () => void; +}) => { + return ( + { + onClick(); + closePopover(); + }} + disabled={disabled} + > + {label} + + ); +}; diff --git a/src/plugins/explore/public/components/results_table/columns/column_header/index.ts b/src/plugins/explore/public/components/results_table/columns/column_header/index.ts new file mode 100644 index 000000000000..7439644bd75e --- /dev/null +++ b/src/plugins/explore/public/components/results_table/columns/column_header/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './column_header'; diff --git a/src/plugins/explore/public/components/results_table/columns/index.ts b/src/plugins/explore/public/components/results_table/columns/index.ts new file mode 100644 index 000000000000..88b0ee632cc3 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/columns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './column_actions_cell'; +export * from './column_header'; diff --git a/src/plugins/explore/public/components/results_table/head/index.ts b/src/plugins/explore/public/components/results_table/head/index.ts new file mode 100644 index 000000000000..f69b1a1bd130 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/head/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './results_table_head'; diff --git a/src/plugins/explore/public/components/results_table/head/results_table_head.scss b/src/plugins/explore/public/components/results_table/head/results_table_head.scss new file mode 100644 index 000000000000..7a3cb0071cd2 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/head/results_table_head.scss @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +@import "../utils"; + +.exploreResultsTableHead { + background-color: $ouiColorEmptyShade; + border-bottom: $ouiBorderThin; + display: grid; + position: sticky; + top: 0; + z-index: 1; + + &__tr { + display: flex; + width: 100%; + } + + &__th { + @include border-on-hover; + + display: flex; + position: relative; + + &:hover:not(.exploreResultsTableHead__th--disableHover) { + & > .exploreResultsTableHead__resizer { + opacity: 1; + } + } + } + + &__headerWrapper { + padding: $euiSizeXS; + flex: 1; + } + + &__resizer { + position: absolute; + top: 0; + height: 100%; + width: $euiSizeXS; + /* stylelint-disable-next-line @osd/stylelint/no_restricted_values */ + background: rgba(0, 0, 0, 50%); + cursor: col-resize; + user-select: none; + touch-action: none; + right: 0; + opacity: 0; + + &--resizing { + opacity: 1; + } + } +} diff --git a/src/plugins/explore/public/components/results_table/head/results_table_head.tsx b/src/plugins/explore/public/components/results_table/head/results_table_head.tsx new file mode 100644 index 000000000000..2f012317c7f7 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/head/results_table_head.tsx @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import classNames from 'classnames'; +import { flexRender, Table } from '@tanstack/react-table'; +import './results_table_head.scss'; +import { getColumnWidth } from '../utils/css'; + +export interface ResultsTableHeadProps { + table: Table<{ [p: string]: any }>; +} + +export const ExploreResultsTableHead = ({ table }: ResultsTableHeadProps) => { + const tableState = table.getState(); + const resizingColumnName = tableState.columnSizingInfo.isResizingColumn; + + return ( + + + {table.getFlatHeaders().map((header) => { + const columnIsResizing = header.column.getIsResizing(); + const resizeHandler = header.getResizeHandler(); + const columnId = header.column.id; + + return ( + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {header.column.getCanResize() && ( +
header.column.resetSize()} + onMouseDown={resizeHandler} + onTouchStart={resizeHandler} + className={classNames('exploreResultsTableHead__resizer', { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'exploreResultsTableHead__resizer--resizing': columnIsResizing, + })} + style={{ + transform: columnIsResizing + ? `translateX(${tableState.columnSizingInfo.deltaOffset ?? 0}px)` + : '', + }} + /> + )} + + ); + })} + + + ); +}; diff --git a/src/plugins/explore/public/components/results_table/hooks/index.ts b/src/plugins/explore/public/components/results_table/hooks/index.ts new file mode 100644 index 000000000000..808bf64105af --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './use_columns'; +export * from './use_rows_data'; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_columns/index.ts b/src/plugins/explore/public/components/results_table/hooks/use_columns/index.ts new file mode 100644 index 000000000000..1dc56685ea57 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_columns/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './use_columns'; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_columns/use_columns.tsx b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_columns.tsx new file mode 100644 index 000000000000..5366eb471f62 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_columns.tsx @@ -0,0 +1,112 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { createColumnHelper } from '@tanstack/react-table'; +import { useSelector } from 'react-redux'; +import { useFieldsList } from './use_fields_list'; +import { ACTION_COLUMN_ID, SOURCE_COLUMN_ID_AND_NAME } from '../../table_constants'; +import { selectVisibleColumnNames } from '../../../../application/utils/state_management/selectors'; +import { getColumnIdFromFieldName } from '../../utils/get_column_id_from_field_name'; +import { useDatasetContext } from '../../../../application/context'; +import { + ExploreResultsTableColumnActionsCell, + ExploreResultsTableColumnHeader, +} from '../../columns'; + +const columnHelper = createColumnHelper<{ [key: string]: any }>(); + +export const useColumns = () => { + const { dataset } = useDatasetContext(); + const visibleColumnNames = useSelector(selectVisibleColumnNames); + const fieldsList = useFieldsList(); + const [columnVisibility, setColumnVisibility] = useState<{ + [columnId: string]: boolean; + }>( + // initially set all fields invisible for perf + fieldsList.reduce( + (acc, field) => ({ ...acc, [getColumnIdFromFieldName(field.name)]: false }), + {} + ) + ); + + useEffect(() => { + const newColumnVisibility: { [columnId: string]: boolean } = fieldsList.reduce( + (acc, field) => ({ ...acc, [getColumnIdFromFieldName(field.name)]: false }), + {} + ); + for (const visibleColumnName of visibleColumnNames) { + newColumnVisibility[getColumnIdFromFieldName(visibleColumnName)] = true; + } + setColumnVisibility(newColumnVisibility); + }, [fieldsList, visibleColumnNames]); + + const columns = useMemo( + () => [ + // Display columns + columnHelper.display({ + id: ACTION_COLUMN_ID, + cell: ({ row }) => , + enableColumnFilter: false, + enableResizing: false, + size: 64, + }), + ...fieldsList.map((field) => { + const columnId = getColumnIdFromFieldName(field.name); + + if (field.type === SOURCE_COLUMN_ID_AND_NAME) { + return columnHelper.accessor(columnId, { + header: ({ table }) => ( + + ), + cell: ({ row }) => JSON.stringify(row.original), + }); + } + + if (field.name === dataset?.timeFieldName) { + return columnHelper.accessor(columnId, { + header: ({ table }) => ( + + ), + cell: (info) => info.getValue() || '', + }); + } + + return columnHelper.accessor(columnId, { + header: ({ table }) => ( + + ), + cell: (info) => info.getValue() || '', + }); + }), + ], + [fieldsList, dataset?.timeFieldName] + ); + + const columnOrder = useMemo( + () => [ACTION_COLUMN_ID, ...visibleColumnNames.map(getColumnIdFromFieldName)], + [visibleColumnNames] + ); + + return { columns, columnVisibility, columnOrder }; +}; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/index.ts b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/index.ts new file mode 100644 index 000000000000..11151215fa1d --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './use_fields_list'; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.test.ts b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.test.ts new file mode 100644 index 000000000000..b215114acb73 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.test.ts @@ -0,0 +1,267 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useFieldsList } from './use_fields_list'; +import { useProcessedResults } from '../../use_processed_results'; +import { DatasetContextValue, useDatasetContext } from '../../../../../application/context'; +import { getIndexPatternFieldList } from '../../../../fields_selector/lib/get_index_pattern_field_list'; +import { DataView, DataViewField } from '../../../../../../../data/common'; +import { ProcessedSearchResults } from '../../../../../application/utils/interfaces'; + +jest.mock('../../use_processed_results', () => ({ + useProcessedResults: jest.fn(), +})); + +jest.mock('../../../../../application/context', () => ({ + useDatasetContext: jest.fn(), +})); + +jest.mock('../../../../fields_selector/lib/get_index_pattern_field_list', () => ({ + getIndexPatternFieldList: jest.fn(), +})); + +describe('useFieldsList', () => { + const mockUseProcessedResults = useProcessedResults as jest.MockedFunction< + typeof useProcessedResults + >; + const mockUseDatasetContext = useDatasetContext as jest.MockedFunction; + const mockGetIndexPatternFieldList = getIndexPatternFieldList as jest.MockedFunction< + typeof getIndexPatternFieldList + >; + + const mockDataViewField1 = { + name: 'field1', + displayName: 'Field 1', + type: 'string', + aggregatable: true, + searchable: true, + } as DataViewField; + + const mockDataViewField2 = { + name: 'field2', + displayName: 'Field 2', + type: 'number', + aggregatable: true, + searchable: true, + } as DataViewField; + + const mockDataset = ({ + id: 'test-dataset', + title: 'Test Dataset', + fields: { + getAll: jest.fn(() => [mockDataViewField1, mockDataViewField2]), + getByName: jest.fn(), + }, + } as unknown) as DataView; + + const mockDatasetContext = { dataset: mockDataset } as DatasetContextValue; + + const mockProcessedResults = ({ + fieldCounts: { + field1: 5, + field2: 3, + unknownField: 2, + }, + hits: { hits: [], total: { value: 0, relation: 'eq' } }, + dataset: mockDataset, + elapsedMs: 10, + } as unknown) as ProcessedSearchResults; + + const mockFieldsList = [ + mockDataViewField1, + mockDataViewField2, + { + name: 'unknownField', + displayName: 'unknownField', + type: 'unknown', + } as DataViewField, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetIndexPatternFieldList.mockReturnValue(mockFieldsList); + }); + + it('should return fields list when both processedResults and dataset are available', () => { + mockUseProcessedResults.mockReturnValue(mockProcessedResults); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual(mockFieldsList); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith( + mockDataset, + mockProcessedResults.fieldCounts + ); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when processedResults is null', () => { + mockUseProcessedResults.mockReturnValue(null); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + mockGetIndexPatternFieldList.mockReturnValue([]); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual([]); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith(mockDataset, undefined); + }); + + it('should return empty array when dataset is null', () => { + mockUseProcessedResults.mockReturnValue(mockProcessedResults); + mockUseDatasetContext.mockReturnValue(({ dataset: null } as unknown) as DatasetContextValue); + mockGetIndexPatternFieldList.mockReturnValue([]); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual([]); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith( + null, + mockProcessedResults.fieldCounts + ); + }); + + it('should handle processedResults without fieldCounts', () => { + const processedResultsWithoutFieldCounts = ({ + ...mockProcessedResults, + fieldCounts: undefined, + } as unknown) as ProcessedSearchResults; + + mockUseProcessedResults.mockReturnValue(processedResultsWithoutFieldCounts); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + mockGetIndexPatternFieldList.mockReturnValue([]); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual([]); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith(mockDataset, undefined); + }); + + it('should handle empty fieldCounts', () => { + const processedResultsWithEmptyFieldCounts = { + ...mockProcessedResults, + fieldCounts: {}, + }; + + mockUseProcessedResults.mockReturnValue(processedResultsWithEmptyFieldCounts); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + + const fieldsListWithNoUnknownFields = [mockDataViewField1, mockDataViewField2]; + mockGetIndexPatternFieldList.mockReturnValue(fieldsListWithNoUnknownFields); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual(fieldsListWithNoUnknownFields); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith(mockDataset, {}); + }); + + it('should handle fieldCounts with only known fields', () => { + const processedResultsWithKnownFields = { + ...mockProcessedResults, + fieldCounts: { + field1: 10, + field2: 8, + }, + }; + + mockUseProcessedResults.mockReturnValue(processedResultsWithKnownFields); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + + const fieldsListWithKnownFields = [mockDataViewField1, mockDataViewField2]; + mockGetIndexPatternFieldList.mockReturnValue(fieldsListWithKnownFields); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual(fieldsListWithKnownFields); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith(mockDataset, { + field1: 10, + field2: 8, + }); + }); + + it('should handle fieldCounts with only unknown fields', () => { + const processedResultsWithUnknownFields = { + ...mockProcessedResults, + fieldCounts: { + unknownField1: 5, + unknownField2: 3, + }, + }; + + mockUseProcessedResults.mockReturnValue(processedResultsWithUnknownFields); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + + const fieldsListWithUnknownFields = [ + mockDataViewField1, + mockDataViewField2, + { name: 'unknownField1', displayName: 'unknownField1', type: 'unknown' } as DataViewField, + { name: 'unknownField2', displayName: 'unknownField2', type: 'unknown' } as DataViewField, + ]; + mockGetIndexPatternFieldList.mockReturnValue(fieldsListWithUnknownFields); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual(fieldsListWithUnknownFields); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith(mockDataset, { + unknownField1: 5, + unknownField2: 3, + }); + }); + + it('should handle complex dataset with many fields', () => { + const complexDataset = ({ + ...mockDataset, + fields: { + getAll: jest.fn(() => [ + mockDataViewField1, + mockDataViewField2, + { name: '@timestamp', type: 'date' } as DataViewField, + { name: 'user.name', type: 'string' } as DataViewField, + { name: 'user.age', type: 'number' } as DataViewField, + ]), + getByName: jest.fn(), + }, + } as unknown) as DataView; + + const complexProcessedResults = { + ...mockProcessedResults, + fieldCounts: { + field1: 10, + field2: 8, + '@timestamp': 12, + 'user.name': 9, + 'user.age': 7, + 'custom.field': 3, + 'dynamic.value': 1, + }, + }; + + mockUseProcessedResults.mockReturnValue(complexProcessedResults); + mockUseDatasetContext.mockReturnValue(({ + dataset: complexDataset, + } as unknown) as DatasetContextValue); + + const complexFieldsList = [ + mockDataViewField1, + mockDataViewField2, + { name: '@timestamp', type: 'date' } as DataViewField, + { name: 'user.name', type: 'string' } as DataViewField, + { name: 'user.age', type: 'number' } as DataViewField, + { name: 'custom.field', displayName: 'custom.field', type: 'unknown' } as DataViewField, + { name: 'dynamic.value', displayName: 'dynamic.value', type: 'unknown' } as DataViewField, + ]; + + mockGetIndexPatternFieldList.mockReturnValue(complexFieldsList); + + const { result } = renderHook(() => useFieldsList()); + + expect(result.current).toEqual(complexFieldsList); + expect(mockGetIndexPatternFieldList).toHaveBeenCalledWith( + complexDataset, + complexProcessedResults.fieldCounts + ); + }); +}); diff --git a/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.ts b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.ts new file mode 100644 index 000000000000..da5d288dabf3 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_columns/use_fields_list/use_fields_list.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import { useProcessedResults } from '../../use_processed_results'; +import { useDatasetContext } from '../../../../../application/context'; +import { getIndexPatternFieldList } from '../../../../fields_selector/lib/get_index_pattern_field_list'; +import { DataViewField } from '../../../../../../../data/common'; + +export const useFieldsList = () => { + const processedResults = useProcessedResults(); + const { dataset } = useDatasetContext(); + + return useMemo( + () => getIndexPatternFieldList(dataset, processedResults?.fieldCounts) as DataViewField[], + [processedResults?.fieldCounts, dataset] + ); +}; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_processed_results/index.ts b/src/plugins/explore/public/components/results_table/hooks/use_processed_results/index.ts new file mode 100644 index 000000000000..6e769618f7c4 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_processed_results/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './use_processed_results'; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.test.ts b/src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.test.ts new file mode 100644 index 000000000000..549d1081805d --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useProcessedResults } from './use_processed_results'; +import { useRawResults } from '../use_raw_results'; +import { DatasetContextValue, useDatasetContext } from '../../../../application/context'; +import { defaultResultsProcessor } from '../../../../application/utils/state_management/actions/query_actions'; +import { ISearchResult } from '../../../../application/utils/state_management/slices'; +import { ProcessedSearchResults } from '../../../../application/utils/interfaces'; +import { DataView } from '../../../../../../data/common'; + +jest.mock('../use_raw_results', () => ({ + useRawResults: jest.fn(), +})); + +jest.mock('../../../../application/context', () => ({ + useDatasetContext: jest.fn(), +})); + +jest.mock('../../../../application/utils/state_management/actions/query_actions', () => ({ + defaultResultsProcessor: jest.fn(), +})); + +describe('useProcessedResults', () => { + const mockUseRawResults = useRawResults as jest.MockedFunction; + const mockUseDatasetContext = useDatasetContext as jest.MockedFunction; + const mockDefaultResultsProcessor = defaultResultsProcessor as jest.MockedFunction< + typeof defaultResultsProcessor + >; + + const mockRawResults = ({ + hits: { + hits: [ + { _id: '1', _source: { field1: 'value1' } }, + { _id: '2', _source: { field2: 'value2' } }, + ], + total: { value: 2, relation: 'eq' }, + }, + took: 5, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + } as unknown) as ISearchResult; + + const mockDataset = ({ + id: 'test-dataset', + title: 'Test Dataset', + timeFieldName: '@timestamp', + fields: { + getByName: jest.fn(), + }, + flattenHit: jest.fn(), + } as unknown) as DataView; + + const mockDatasetContext = { dataset: mockDataset } as DatasetContextValue; + + const mockProcessedResults = ({ + hits: mockRawResults.hits, + fieldCounts: { + field1: 1, + field2: 1, + }, + dataset: mockDataset, + elapsedMs: 5, + } as unknown) as ProcessedSearchResults; + + beforeEach(() => { + jest.clearAllMocks(); + mockDefaultResultsProcessor.mockReturnValue(mockProcessedResults); + }); + + it('should return processed results when both rawResults and dataset are available', () => { + mockUseRawResults.mockReturnValue(mockRawResults); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + + const { result } = renderHook(() => useProcessedResults()); + + expect(result.current).toEqual(mockProcessedResults); + expect(mockDefaultResultsProcessor).toHaveBeenCalledWith(mockRawResults, mockDataset); + expect(mockDefaultResultsProcessor).toHaveBeenCalledTimes(1); + }); + + it('should return null when both rawResults and dataset are null', () => { + mockUseRawResults.mockReturnValue(null as any); + mockUseDatasetContext.mockReturnValue({ dataset: null } as any); + + const { result } = renderHook(() => useProcessedResults()); + + expect(result.current).toBeNull(); + expect(mockDefaultResultsProcessor).not.toHaveBeenCalled(); + }); + + it('should recompute when rawResults changes', () => { + mockUseRawResults.mockReturnValue(mockRawResults); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + + const { result, rerender } = renderHook(() => useProcessedResults()); + + expect(mockDefaultResultsProcessor).toHaveBeenCalledTimes(1); + + const updatedRawResults = { + ...mockRawResults, + hits: { + ...mockRawResults.hits, + hits: [{ _id: '3', _source: { field3: 'value3' } }], + }, + } as ISearchResult; + + const updatedProcessedResults = { + ...mockProcessedResults, + fieldCounts: { field3: 1 }, + }; + + mockUseRawResults.mockReturnValue(updatedRawResults); + mockDefaultResultsProcessor.mockReturnValue(updatedProcessedResults); + + rerender(); + + expect(result.current).toEqual(updatedProcessedResults); + expect(mockDefaultResultsProcessor).toHaveBeenCalledTimes(2); + expect(mockDefaultResultsProcessor).toHaveBeenLastCalledWith(updatedRawResults, mockDataset); + }); + + it('should recompute when dataset changes', () => { + mockUseRawResults.mockReturnValue(mockRawResults); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + + const { result, rerender } = renderHook(() => useProcessedResults()); + + expect(mockDefaultResultsProcessor).toHaveBeenCalledTimes(1); + + const updatedDataset = { + ...mockDataset, + id: 'updated-dataset', + title: 'Updated Dataset', + }; + + const updatedProcessedResults = { + ...mockProcessedResults, + dataset: updatedDataset, + } as ProcessedSearchResults; + + mockUseDatasetContext.mockReturnValue({ dataset: updatedDataset } as DatasetContextValue); + mockDefaultResultsProcessor.mockReturnValue(updatedProcessedResults); + + rerender(); + + expect(result.current).toEqual(updatedProcessedResults); + expect(mockDefaultResultsProcessor).toHaveBeenCalledTimes(2); + expect(mockDefaultResultsProcessor).toHaveBeenLastCalledWith(mockRawResults, updatedDataset); + }); + + it('should handle empty rawResults', () => { + const emptyRawResults = ({ + hits: { + hits: [], + total: { value: 0, relation: 'eq' }, + }, + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + } as unknown) as ISearchResult; + + const emptyProcessedResults = { + hits: emptyRawResults.hits, + fieldCounts: {}, + dataset: mockDataset, + elapsedMs: 1, + } as ProcessedSearchResults; + + mockUseRawResults.mockReturnValue(emptyRawResults); + mockUseDatasetContext.mockReturnValue(mockDatasetContext); + mockDefaultResultsProcessor.mockReturnValue(emptyProcessedResults); + + const { result } = renderHook(() => useProcessedResults()); + + expect(result.current).toEqual(emptyProcessedResults); + expect(mockDefaultResultsProcessor).toHaveBeenCalledWith(emptyRawResults, mockDataset); + }); +}); diff --git a/src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.ts b/src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.ts new file mode 100644 index 000000000000..a49cf37f5d2f --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_processed_results/use_processed_results.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import { useRawResults } from '../use_raw_results'; +import { useDatasetContext } from '../../../../application/context'; +import { defaultResultsProcessor } from '../../../../application/utils/state_management/actions/query_actions'; + +export const useProcessedResults = () => { + const rawResults = useRawResults(); + const { dataset } = useDatasetContext(); + + return useMemo(() => { + if (!rawResults || !dataset) { + return null; + } + + return defaultResultsProcessor(rawResults, dataset); + }, [rawResults, dataset]); +}; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_raw_results/index.ts b/src/plugins/explore/public/components/results_table/hooks/use_raw_results/index.ts new file mode 100644 index 000000000000..474c13a192e0 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_raw_results/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './use_raw_results'; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.test.ts b/src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.test.ts new file mode 100644 index 000000000000..6347b6ab060f --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.test.ts @@ -0,0 +1,185 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import { useRawResults } from './use_raw_results'; +import { defaultPrepareQueryString } from '../../../../application/utils/state_management/actions/query_actions'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../application/utils/state_management/actions/query_actions', () => ({ + defaultPrepareQueryString: jest.fn(), +})); + +jest.mock('../../../../application/utils/state_management/selectors', () => ({ + selectQuery: jest.fn(), +})); + +describe('useRawResults', () => { + const mockUseSelector = useSelector as jest.MockedFunction; + const mockDefaultPrepareQueryString = defaultPrepareQueryString as jest.MockedFunction< + typeof defaultPrepareQueryString + >; + + const mockQuery = { + language: 'PPL', + query: 'source=test-index | head 10', + dataset: { id: 'test-dataset' }, + }; + + const mockResultsData = { + hits: { + hits: [ + { _id: '1', _source: { field1: 'value1' } }, + { _id: '2', _source: { field2: 'value2' } }, + ], + total: { value: 2, relation: 'eq' }, + }, + took: 5, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + }; + + const mockResultsState = { + 'test-cache-key': mockResultsData, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockDefaultPrepareQueryString.mockReturnValue('test-cache-key'); + }); + + it('should return complete results data when results exist', () => { + mockUseSelector + .mockReturnValueOnce(mockQuery) // selectQuery + .mockReturnValueOnce(mockResultsState); // results state + + const { result } = renderHook(() => useRawResults()); + + expect(result.current).toEqual(mockResultsData); + expect(mockDefaultPrepareQueryString).toHaveBeenCalledWith(mockQuery); + }); + + it('should return undefined when no results exist for cache key', () => { + const emptyResultsState = {}; + + mockUseSelector + .mockReturnValueOnce(mockQuery) // selectQuery + .mockReturnValueOnce(emptyResultsState); // results state + + const { result } = renderHook(() => useRawResults()); + + expect(result.current).toBeUndefined(); + expect(mockDefaultPrepareQueryString).toHaveBeenCalledWith(mockQuery); + }); + + it('should handle different cache keys correctly', () => { + const differentCacheKey = 'different-cache-key'; + const differentResultsData = { + hits: { + hits: [{ _id: '10', _source: { differentField: 'differentValue' } }], + total: { value: 1, relation: 'eq' }, + }, + took: 8, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + }; + const differentResultsState = { + [differentCacheKey]: differentResultsData, + }; + + mockDefaultPrepareQueryString.mockReturnValue(differentCacheKey); + mockUseSelector + .mockReturnValueOnce(mockQuery) // selectQuery + .mockReturnValueOnce(differentResultsState); // results state with different cache key + + const { result } = renderHook(() => useRawResults()); + + expect(result.current).toEqual(differentResultsData); + expect(mockDefaultPrepareQueryString).toHaveBeenCalledWith(mockQuery); + }); + + it('should work with multiple results in state and return correct one', () => { + const multipleResultsState = { + 'cache-key-1': { + hits: { hits: [{ _id: '1', _source: { field1: 'value1' } }] }, + took: 2, + }, + 'test-cache-key': mockResultsData, + 'cache-key-3': { + hits: { hits: [{ _id: '4', _source: { field4: 'value4' } }] }, + took: 7, + }, + }; + + mockUseSelector + .mockReturnValueOnce(mockQuery) // selectQuery + .mockReturnValueOnce(multipleResultsState); // results state + + const { result } = renderHook(() => useRawResults()); + + // Should return only the results for the matching cache key + expect(result.current).toEqual(mockResultsData); + }); + + it('should handle results with error data', () => { + const errorResultsData = { + error: { + type: 'search_phase_execution_exception', + reason: 'all shards failed', + }, + took: 1, + timed_out: false, + }; + + const resultsStateWithError = { + 'test-cache-key': errorResultsData, + }; + + mockUseSelector + .mockReturnValueOnce(mockQuery) // selectQuery + .mockReturnValueOnce(resultsStateWithError); // results state + + const { result } = renderHook(() => useRawResults()); + + expect(result.current).toEqual(errorResultsData); + }); + + it('should handle results with aggregations', () => { + const resultsWithAggregations = { + hits: { + hits: [], + total: { value: 0, relation: 'eq' }, + }, + aggregations: { + status_counts: { + buckets: [ + { key: '200', doc_count: 150 }, + { key: '404', doc_count: 25 }, + { key: '500', doc_count: 5 }, + ], + }, + }, + took: 12, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + }; + + const resultsStateWithAggregations = { + 'test-cache-key': resultsWithAggregations, + }; + + mockUseSelector + .mockReturnValueOnce(mockQuery) // selectQuery + .mockReturnValueOnce(resultsStateWithAggregations); // results state + + const { result } = renderHook(() => useRawResults()); + + expect(result.current).toEqual(resultsWithAggregations); + }); +}); diff --git a/src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.ts b/src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.ts new file mode 100644 index 000000000000..27c70db87485 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_raw_results/use_raw_results.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { selectQuery } from '../../../../application/utils/state_management/selectors'; +import { defaultPrepareQueryString } from '../../../../application/utils/state_management/actions/query_actions'; +import { RootState } from '../../../../application/utils/state_management/store'; + +export const useRawResults = () => { + const query = useSelector(selectQuery); + const cacheKey = useMemo(() => defaultPrepareQueryString(query), [query]); + const resultsState = useSelector((state: RootState) => state.results); + return resultsState[cacheKey]; +}; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_rows_data/index.ts b/src/plugins/explore/public/components/results_table/hooks/use_rows_data/index.ts new file mode 100644 index 000000000000..4a17f540e0ed --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_rows_data/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './use_rows_data'; diff --git a/src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.test.ts b/src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.test.ts new file mode 100644 index 000000000000..c296f0681fb1 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useRowsData } from './use_rows_data'; +import { useRawResults } from '../use_raw_results'; +import { ISearchResult } from '../../../../application/utils/state_management/slices'; + +jest.mock('../use_raw_results', () => ({ + useRawResults: jest.fn(), +})); + +describe('useRowsData', () => { + const mockUseRawResults = useRawResults as jest.MockedFunction; + + const mockHit1 = { + _index: 'test-index', + _id: '1', + _score: 1.0, + _source: { field1: 'value1', field2: 'value2' }, + fields: { field1: ['value1'] }, + sort: ['2023-01-01'], + }; + + const mockHit2 = { + _index: 'test-index', + _id: '2', + _score: 0.8, + _source: { field3: 'value3', field4: 'value4' }, + fields: { field3: ['value3'] }, + sort: ['2023-01-02'], + }; + + const mockRawResults = ({ + hits: { + hits: [mockHit1, mockHit2], + total: { value: 2, relation: 'eq' }, + max_score: 1.0, + }, + took: 5, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + } as unknown) as ISearchResult; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return hits array when rawResults has hits', () => { + mockUseRawResults.mockReturnValue(mockRawResults); + + const { result } = renderHook(() => useRowsData()); + + expect(result.current).toEqual([mockHit1, mockHit2]); + expect(result.current).toHaveLength(2); + expect(mockUseRawResults).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when rawResults is null', () => { + mockUseRawResults.mockReturnValue(null as any); + + const { result } = renderHook(() => useRowsData()); + + expect(result.current).toEqual([]); + expect(result.current).toHaveLength(0); + }); + + it('should return empty array when rawResults exists but hits is undefined', () => { + const rawResultsWithoutHits = { + ...mockRawResults, + hits: undefined, + }; + + mockUseRawResults.mockReturnValue(rawResultsWithoutHits as any); + + const { result } = renderHook(() => useRowsData()); + + expect(result.current).toEqual([]); + expect(result.current).toHaveLength(0); + }); + + it('should return empty array when rawResults.hits exists but hits.hits is undefined', () => { + const rawResultsWithoutHitsArray = { + ...mockRawResults, + hits: { + total: { value: 0, relation: 'eq' }, + max_score: null, + hits: undefined, + }, + }; + + mockUseRawResults.mockReturnValue(rawResultsWithoutHitsArray as any); + + const { result } = renderHook(() => useRowsData()); + + expect(result.current).toEqual([]); + expect(result.current).toHaveLength(0); + }); + + it('should return empty array when rawResults.hits.hits is null', () => { + const rawResultsWithNullHits = { + ...mockRawResults, + hits: { + ...mockRawResults.hits, + hits: null, + }, + }; + + mockUseRawResults.mockReturnValue(rawResultsWithNullHits as any); + + const { result } = renderHook(() => useRowsData()); + + expect(result.current).toEqual([]); + expect(result.current).toHaveLength(0); + }); + + it('should return empty array when rawResults.hits.hits is empty array', () => { + const rawResultsWithEmptyHits = { + ...mockRawResults, + hits: { + ...mockRawResults.hits, + hits: [], + }, + }; + + mockUseRawResults.mockReturnValue(rawResultsWithEmptyHits); + + const { result } = renderHook(() => useRowsData()); + + expect(result.current).toEqual([]); + expect(result.current).toHaveLength(0); + }); +}); diff --git a/src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.ts b/src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.ts new file mode 100644 index 000000000000..4680d67b7043 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/hooks/use_rows_data/use_rows_data.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useRawResults } from '../use_raw_results'; +import { useDatasetContext } from '../../../../application/context'; + +export const useRowsData = (): Array<{ [field: string]: any }> => { + const { dataset } = useDatasetContext(); + const rawResults = useRawResults(); + + if (!dataset) { + return []; + } + + // TODO: This is inefficient, there has to be a better way + return (rawResults?.hits?.hits || []).map((hit) => dataset.flattenHit(hit)); +}; diff --git a/src/plugins/explore/public/components/results_table/index.ts b/src/plugins/explore/public/components/results_table/index.ts new file mode 100644 index 000000000000..5db6032d6a05 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './results_table'; diff --git a/src/plugins/explore/public/components/results_table/results_table.scss b/src/plugins/explore/public/components/results_table/results_table.scss new file mode 100644 index 000000000000..a7281962ef15 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/results_table.scss @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.exploreResultsTable { + display: grid; + position: sticky; + top: 0; + z-index: 1; + + &__container { + overflow: auto; + position: relative; + max-width: 100%; + } +} diff --git a/src/plugins/explore/public/components/results_table/results_table.tsx b/src/plugins/explore/public/components/results_table/results_table.tsx new file mode 100644 index 000000000000..e60981249d1d --- /dev/null +++ b/src/plugins/explore/public/components/results_table/results_table.tsx @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'; +import { getCoreRowModel, getFilteredRowModel, useReactTable } from '@tanstack/react-table'; +import { useRowsData, useColumns } from './hooks'; +import { ExploreResultsTableBody, MemoizedResultsTableBody } from './body'; +import { ExploreResultsTableHead } from './head'; +import { getColumnSizeVariableName } from './utils/css'; +import './results_table.scss'; + +export interface ExploreResultsTableProps { + parentContainerRef: RefObject; +} + +export const ExploreResultsTable = ({ parentContainerRef }: ExploreResultsTableProps) => { + const tableContainerRef = useRef(null); + const rowsData = useRowsData(); + const { columns, columnVisibility, columnOrder } = useColumns(); + const [containerHeight, setContainerHeight] = useState(1000); + + useEffect(() => { + if (parentContainerRef.current) { + setContainerHeight(parentContainerRef.current.getBoundingClientRect().height); + } + }, [parentContainerRef]); + + const table = useReactTable({ + data: rowsData, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { + columnVisibility, + columnOrder, + }, + columnResizeMode: 'onEnd', + columnResizeDirection: 'ltr', + defaultColumn: { + minSize: 50, + }, + }); + + const columnSizeVars = useMemo(() => { + const headers = table.getFlatHeaders(); + const colSizes: { [key: string]: number } = {}; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + colSizes[getColumnSizeVariableName(header.column.id)] = header.column.getSize(); + } + return colSizes; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [table.getState().columnSizingInfo, table.getState().columnSizing, columnVisibility]); + + return ( +
+ + + {table.getState().columnSizingInfo.isResizingColumn ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/plugins/explore/public/components/results_table/table_constants.ts b/src/plugins/explore/public/components/results_table/table_constants.ts new file mode 100644 index 000000000000..66df44e845ef --- /dev/null +++ b/src/plugins/explore/public/components/results_table/table_constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const SOURCE_COLUMN_ID_AND_NAME = '_source'; +export const ACTION_COLUMN_ID = '__OSDINTERNAL__actions'; diff --git a/src/plugins/explore/public/components/results_table/utils.scss b/src/plugins/explore/public/components/results_table/utils.scss new file mode 100644 index 000000000000..5c051c884854 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/utils.scss @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +@mixin border-on-hover { + $self: &; + + border: 2px solid transparent; + + &--__OSDINTERNAL__actions { + border-color: transparent !important; + } + + &:hover:not(#{$self}--disableHover) { + border-color: $euiColorPrimary; + } + + &--showBorder { + border-color: $euiColorPrimary; + } +} diff --git a/src/plugins/explore/public/components/results_table/utils/css/css_utils.ts b/src/plugins/explore/public/components/results_table/utils/css/css_utils.ts new file mode 100644 index 000000000000..c9d6cd7e55cf --- /dev/null +++ b/src/plugins/explore/public/components/results_table/utils/css/css_utils.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const getColumnSizeVariableName = (columnId: string) => + `--exploreResultsTable-col-${columnId}-size`; + +export const getColumnWidth = (columnId: string) => + `calc(var(${getColumnSizeVariableName(columnId)}) * 1px)`; diff --git a/src/plugins/explore/public/components/results_table/utils/css/index.ts b/src/plugins/explore/public/components/results_table/utils/css/index.ts new file mode 100644 index 000000000000..18b91cf33097 --- /dev/null +++ b/src/plugins/explore/public/components/results_table/utils/css/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './css_utils'; diff --git a/src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/get_column_id_from_field_name.ts b/src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/get_column_id_from_field_name.ts new file mode 100644 index 000000000000..46a4b4a6e7ef --- /dev/null +++ b/src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/get_column_id_from_field_name.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const getColumnIdFromFieldName = (fieldName: string) => { + return fieldName.replaceAll('.', '_'); +}; diff --git a/src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/index.ts b/src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/index.ts new file mode 100644 index 000000000000..08fa8e1bb9dc --- /dev/null +++ b/src/plugins/explore/public/components/results_table/utils/get_column_id_from_field_name/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './get_column_id_from_field_name'; diff --git a/src/plugins/explore/public/components/tabs/action_bar/results_action_bar/results_action_bar.scss b/src/plugins/explore/public/components/tabs/action_bar/results_action_bar/results_action_bar.scss index bfff6362f35b..e4bfb5947226 100644 --- a/src/plugins/explore/public/components/tabs/action_bar/results_action_bar/results_action_bar.scss +++ b/src/plugins/explore/public/components/tabs/action_bar/results_action_bar/results_action_bar.scss @@ -1,5 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + .explore-results-action-bar { - border-bottom: $ouiBorderThin; flex-grow: 0; padding: $euiSizeXS 0; diff --git a/src/plugins/explore/public/components/tabs/logs_tab.tsx b/src/plugins/explore/public/components/tabs/logs_tab.tsx index d261b8e422e6..7da0d0639d6e 100644 --- a/src/plugins/explore/public/components/tabs/logs_tab.tsx +++ b/src/plugins/explore/public/components/tabs/logs_tab.tsx @@ -2,18 +2,20 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { ExploreDataTable } from '../data_table/explore_data_table'; +import React, { useRef } from 'react'; import { ActionBar } from './action_bar/action_bar'; +import { ExploreResultsTable } from '../results_table'; /** * Logs tab component for displaying log entries */ export const LogsTab = () => { + const containerRef = useRef(null); + return ( -
+
- +
); }; diff --git a/src/plugins/explore/public/helpers/save_explore.test.ts b/src/plugins/explore/public/helpers/save_explore.test.ts index b23c959cf329..3dbb104a0c4e 100644 --- a/src/plugins/explore/public/helpers/save_explore.test.ts +++ b/src/plugins/explore/public/helpers/save_explore.test.ts @@ -33,7 +33,6 @@ const createMockServices = () => ({ getState: jest.fn(() => ({ legacy: { columns: ['column1', 'column2'], - sort: [['column1', 'asc']], }, })), dispatch: jest.fn(), diff --git a/src/plugins/explore/public/saved_explore/transforms.ts b/src/plugins/explore/public/saved_explore/transforms.ts index 905fd059ac68..0ecc1019668f 100644 --- a/src/plugins/explore/public/saved_explore/transforms.ts +++ b/src/plugins/explore/public/saved_explore/transforms.ts @@ -95,7 +95,6 @@ export const getLegacyPropertiesFromSavedObject = (savedExplore: SavedExplore) = if (!savedExplore.legacyState) { return { columns: [], - sort: [], }; } @@ -103,7 +102,7 @@ export const getLegacyPropertiesFromSavedObject = (savedExplore: SavedExplore) = const legacyState = JSON.parse(savedExplore.legacyState) as LegacyState; return { columns: legacyState.columns || [], - sort: legacyState.sort || [], + sort: [], }; } catch (error) { return { diff --git a/src/plugins/explore/public/types/saved_explore_types.ts b/src/plugins/explore/public/types/saved_explore_types.ts index 09e35b21b3f9..dad36b2813ba 100644 --- a/src/plugins/explore/public/types/saved_explore_types.ts +++ b/src/plugins/explore/public/types/saved_explore_types.ts @@ -23,7 +23,7 @@ export interface SavedExplore > { searchSource: ISearchSource; // This is optional in SavedObject, but required for SavedSearch description?: string; - legacyState?: string; // Serialized legacy state (columns, sort, interval, etc.) + legacyState?: string; // Serialized legacy state (interval, etc.) uiState?: string; // Serialized UI state queryState?: string; // Serialized query state version?: number; diff --git a/yarn.lock b/yarn.lock index cdda3be5b80a..0ffd06633ace 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7647,6 +7647,30 @@ dependencies: defer-to-connect "^2.0.0" +"@tanstack/react-table@^8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b" + integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww== + dependencies: + "@tanstack/table-core" "8.21.3" + +"@tanstack/react-virtual@^3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz#d372dc2783739cc04ec1a728ca8203937687a819" + integrity sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA== + dependencies: + "@tanstack/virtual-core" "3.13.12" + +"@tanstack/table-core@8.21.3": + version "8.21.3" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c" + integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg== + +"@tanstack/virtual-core@3.13.12": + version "3.13.12" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz#1dff176df9cc8f93c78c5e46bcea11079b397578" + integrity sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA== + "@testim/chrome-version@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@testim/chrome-version/-/chrome-version-1.1.4.tgz#86e04e677cd6c05fa230dd15ac223fa72d1d7090"