diff --git a/changelogs/fragments/10269.yml b/changelogs/fragments/10269.yml new file mode 100644 index 000000000000..9a84a0626f95 --- /dev/null +++ b/changelogs/fragments/10269.yml @@ -0,0 +1,2 @@ +fix: +- Move dataset select to query panel widgets ([#10269](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/10269)) \ No newline at end of file diff --git a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js index 86eb0aaf0202..c1e950737298 100644 --- a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js +++ b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/caching.spec.js @@ -66,7 +66,7 @@ const cachingTestSuite = () => { }); cy.getElementByTestId('datasetSelectButton').should('be.visible').click(); - cy.getElementByTestId('datasetSelectAdvancedButton').click(); + cy.getElementByTestId('datasetSelectAdvancedButton').should('be.visible').click(); cy.intercept('GET', '**/api/saved_objects/_find?fields*').as('getIndexPatternRequest'); cy.get(`[title="Index Patterns"]`).click(); diff --git a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/inspect.spec.js b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/inspect.spec.js index d427a44a563c..e946fb0cdbab 100644 --- a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/inspect.spec.js +++ b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/03/inspect.spec.js @@ -56,9 +56,9 @@ const inspectTestSuite = () => { isEnhancement: true, }); cy.getElementByTestId('discoverNewButton').click(); + setDatePickerDatesAndSearchIfRelevant(config.language); cy.explore.setDataset(config.dataset, DATASOURCE_NAME, config.datasetType); - setDatePickerDatesAndSearchIfRelevant(config.language); cy.intercept('POST', '**/search/*').as('docTablePostRequest'); cy.getElementByTestId('queryPanelFooterRunQueryButton').click(); diff --git a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/06/rule_matching_vis.spec.js b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/06/rule_matching_vis.spec.js index 48c6487cd704..52f16f35479c 100644 --- a/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/06/rule_matching_vis.spec.js +++ b/cypress/integration/core_opensearch_dashboards/opensearch_dashboards/apps/explore/06/rule_matching_vis.spec.js @@ -40,10 +40,11 @@ export const runCreateVisTests = () => { }); it('should create a metric visualization using a single metric query', () => { + setDatePickerDatesAndSearchIfRelevant('PPL'); + cy.wait(2000); // Setup dataset const datasetName = `${INDEX_WITH_TIME_1}*`; cy.explore.setDataset(datasetName, DATASOURCE_NAME, 'INDEX_PATTERN'); - setDatePickerDatesAndSearchIfRelevant('PPL'); cy.wait(2000); cy.explore.clearQueryEditor(); diff --git a/cypress/utils/commands.explore.js b/cypress/utils/commands.explore.js index 1c2cc00bfa8e..035aff78bfc1 100644 --- a/cypress/utils/commands.explore.js +++ b/cypress/utils/commands.explore.js @@ -148,7 +148,8 @@ cy.explore.add('setTopNavDate', (start, end, submit = true) => { .should('be.visible') .invoke('attr', 'data-test-subj') .then((testId) => { - cy.getElementByTestId(testId, opts).should('be.visible').click(opts); + cy.getElementByTestId(testId, opts).as('btn').click({force: true}); + cy.get('@btn').click({force: true}); }); /* While we surely are in the date selection mode, we don't know if the date selection dialog @@ -383,8 +384,8 @@ cy.explore.add( cy.explore.add( 'setIndexAsDataset', (index, dataSourceName, language, timeFieldName = 'timestamp', finalAction = 'submit') => { - cy.getElementByTestId('datasetSelectButton').should('be.visible').click(); - cy.getElementByTestId(`datasetSelectAdvancedButton`).should('be.visible').click(); + cy.getElementByTestId('datasetSelectButton').click({force: true}); + cy.getElementByTestId(`datasetSelectAdvancedButton`).click({force: true}); cy.get(`[title="Indexes"]`).click(); cy.get(`[title="${dataSourceName}"]`).click(); // this element is sometimes dataSourceName masked by another element @@ -412,8 +413,10 @@ cy.explore.add( ); cy.explore.add('setIndexPatternAsDataset', (indexPattern) => { - cy.getElementByTestId('datasetSelectButton').should('be.visible').click(); - cy.get(`[title="${indexPattern}"]`).should('be.visible').click(); + cy.getElementByTestId('datasetSelectButton').as('btn').click({force: true}); + cy.get('@btn').click({force:true}) + + cy.get(`[title="${indexPattern}"]`).click({force: true}); // verify that it has been selected cy.getElementByTestId('datasetSelectButton').should('contain.text', `${indexPattern}`); diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index 947e4016c94d..e991481439d7 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -72,6 +72,16 @@ export const DEFAULT_QUERY = { }, }; +export const EMPTY_QUERY = { + QUERY: '', + DATASET: { + TYPE: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN, + DATASOURCE: { + TYPE: DEFAULT_DATA.SOURCE_TYPES.OPENSEARCH, + }, + }, +}; + export const UI_SETTINGS = { META_FIELDS: 'metaFields', DOC_HIGHLIGHT: 'doc_table:highlight', diff --git a/src/plugins/data/common/data_views/data_view.stub.ts b/src/plugins/data/common/data_views/data_view.stub.ts index 69cfa536ca1a..58403fcf8d75 100644 --- a/src/plugins/data/common/data_views/data_view.stub.ts +++ b/src/plugins/data/common/data_views/data_view.stub.ts @@ -11,12 +11,6 @@ export const stubDataView: IDataView = { fields: stubFields, title: 'logstash-*', timeFieldName: '@timestamp', - getFieldByName: (name: string) => stubFields.find((field) => field.name === name), - getComputedFields: () => ({}), - getScriptedFields: () => stubFields.filter((field) => field.scripted), - getNonScriptedFields: () => stubFields.filter((field) => !field.scripted), - addScriptedField: async () => {}, - removeScriptedField: () => {}, }; export const stubDataViewWithFields: IDataView = { @@ -32,16 +26,4 @@ export const stubDataViewWithFields: IDataView = { searchable: true, }, ], - getFieldByName(name: string) { - return this.fields.find((field) => field.name === name); - }, - getComputedFields: () => ({}), - getScriptedFields() { - return this.fields.filter((field) => field.scripted); - }, - getNonScriptedFields() { - return this.fields.filter((field) => !field.scripted); - }, - addScriptedField: async () => {}, - removeScriptedField: () => {}, }; diff --git a/src/plugins/data/common/data_views/data_views/data_view.ts b/src/plugins/data/common/data_views/data_views/data_view.ts index 021aa30daebd..2e15f0ae5573 100644 --- a/src/plugins/data/common/data_views/data_views/data_view.ts +++ b/src/plugins/data/common/data_views/data_views/data_view.ts @@ -3,10 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SavedObjectsClientCommon } from '../..'; +import { SavedObjectsClientCommon, Dataset, DataSource } from '../..'; import { IndexPattern } from '../../index_patterns/index_patterns/index_pattern'; import { IDataView, DataViewSpec } from '../types'; import { FieldFormatsStartCommon } from '../../field_formats'; +import { extractDatasetTypeFromUri } from '../'; +import { DEFAULT_DATA } from '../../constants'; interface DataViewDeps { spec?: DataViewSpec; @@ -38,10 +40,7 @@ export class DataView extends IndexPattern implements IDataView { }); this.savedObjectsClient = savedObjectsClient; - - if (this.dataSourceRef?.id) { - this.initializeDataSourceRef(); - } + this.initializeDataSourceRef(); } public async initializeDataSourceRef(): Promise { @@ -53,8 +52,58 @@ export class DataView extends IndexPattern implements IDataView { const attributes = dataSourceSavedObject.attributes as any; this.dataSourceRef = { id: this.dataSourceRef.id, - type: this.dataSourceRef.type, + type: attributes.dataSourceEngineType || this.dataSourceRef.type, name: attributes.title || this.dataSourceRef.name || this.dataSourceRef.id, }; } + + /** + * Converts a DataView to a serializable Dataset object + * Maps dataSourceRef and includes only essential properties for backward compatibility + */ + public async toDataset(): Promise { + const defaultType = DEFAULT_DATA.SET_TYPES.INDEX_PATTERN; + const dataSourceReference = this.dataSourceRef || (this as any).dataSource; + + let dataSource: DataSource | undefined; + let datasetType = this.type || defaultType; + + if (dataSourceReference?.id) { + try { + const dataSourceSavedObject = await this.savedObjectsClient.get( + 'data-source', + dataSourceReference.id + ); + const attributes = dataSourceSavedObject.attributes as any; + + if (dataSourceReference.name) { + const extractedType = extractDatasetTypeFromUri(dataSourceReference.name); + if (extractedType) { + datasetType = extractedType; + } + } + + dataSource = { + id: dataSourceReference.id, + title: attributes.title || dataSourceReference.name || dataSourceReference.id, + type: attributes.dataSourceEngineType || 'OpenSearch', + }; + } catch (error) { + // If we can't fetch the data source, create a minimal version + dataSource = { + id: dataSourceReference.id, + title: dataSourceReference.name || dataSourceReference.id, + type: dataSourceReference.type || 'OpenSearch', + }; + } + } + + return { + id: this.id || '', + title: this.title, + type: datasetType, + timeFieldName: this.timeFieldName, + dataSource, + }; + } } diff --git a/src/plugins/data/common/data_views/data_views/data_views.ts b/src/plugins/data/common/data_views/data_views/data_views.ts index 94c05cdde38b..2fd2c0091887 100644 --- a/src/plugins/data/common/data_views/data_views/data_views.ts +++ b/src/plugins/data/common/data_views/data_views/data_views.ts @@ -5,7 +5,7 @@ import { i18n } from '@osd/i18n'; import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; -import { SavedObjectsClientCommon, Dataset, DEFAULT_DATA } from '../..'; +import { SavedObjectsClientCommon, Dataset, DEFAULT_DATA, UI_SETTINGS, SavedObject } from '../..'; import { DataView } from './data_view'; import { createEnsureDefaultDataView, EnsureDefaultDataView } from './ensure_default_data_view'; import { IndexPatternsService } from '../../index_patterns'; @@ -23,7 +23,6 @@ import { } from '../types'; import { FieldFormatMap } from '../../index_patterns/types'; import { FieldFormatsStartCommon } from '../../field_formats'; -import { UI_SETTINGS, SavedObject } from '../..'; import { SavedObjectNotFound } from '../../../../opensearch_dashboards_utils/common'; import { DataViewMissingIndices } from '../lib'; import { findByTitle, getDataViewTitle } from '../utils'; @@ -135,7 +134,7 @@ export class DataViewsService { findDataSourceByTitle = async (title: string, size: number = 10) => { const savedObjectsResponse = await this.savedObjectsClient.find({ type: 'data-source', - fields: ['title'], + fields: ['title', 'dataSourceEngineType'], search: title, searchFields: ['title'], perPage: size, @@ -432,7 +431,7 @@ export class DataViewsService { throw new SavedObjectNotFound( savedObjectType, id, - 'management/opensearch-dashboards/dataViews' + 'management/opensearch-dashboards/indexPatterns' ); } @@ -722,7 +721,11 @@ export class DataViewsService { * @experimental This method is experimental and may change in future versions * @param dataView DataView object to convert to Dataset */ - convertToDataset(dataView: DataView): Dataset { + async convertToDataset(dataView: DataView): Promise { + if (dataView.toDataset) { + return await dataView.toDataset(); + } + return { id: dataView.id || '', title: dataView.title, @@ -737,37 +740,6 @@ export class DataViewsService { }), }; } - - /** - * Create a DataView from a Dataset object - * @experimental This method is experimental and may change in future versions - * @param dataset Dataset object to create DataView from - */ - async createFromDataset(dataset: Dataset): Promise { - const cached = await this.patterns.get(dataset.id, true); - if (cached) { - return cached as DataView; - } - - const spec: DataViewSpec = { - id: dataset.id, - title: dataset.title, - type: dataset.type, - timeFieldName: dataset.timeFieldName, - dataSourceRef: dataset.dataSource - ? { - id: dataset.dataSource.id!, - name: dataset.dataSource.title, - type: dataset.dataSource.type, - } - : undefined, - }; - - const dataView = await this.create(spec, false); - this.patterns.saveToCache(dataset.id, dataView); - - return dataView; - } } export type DataViewsContract = PublicMethodsOf; diff --git a/src/plugins/data/common/data_views/index.ts b/src/plugins/data/common/data_views/index.ts index 2afc2980609c..2bfaa0fdd934 100644 --- a/src/plugins/data/common/data_views/index.ts +++ b/src/plugins/data/common/data_views/index.ts @@ -8,4 +8,13 @@ export * from './types'; export { DataViewsService } from './data_views'; export type { DataView } from './data_views'; export * from './errors'; -export { validateDataViewDataSourceReference, getDataViewTitle } from './utils'; +export { + validateDataViewDataSourceReference, + getDataViewTitle, + findByTitle, + getDataSourceReference, + extractDatasetTypeFromUri, + extractDataSourceInfoFromUri, + constructDataSourceUri, + getDatasetTypeFromReference, +} from './utils'; diff --git a/src/plugins/data/common/data_views/lib/get_from_saved_object.ts b/src/plugins/data/common/data_views/lib/get_from_saved_object.ts index aef7a94c380a..1955bd4d28bb 100644 --- a/src/plugins/data/common/data_views/lib/get_from_saved_object.ts +++ b/src/plugins/data/common/data_views/lib/get_from_saved_object.ts @@ -19,11 +19,5 @@ export function getFromSavedObject( id: savedObject.id, fields, title: savedObject.attributes.title, - getFieldByName: (name: string) => fields.find((field: any) => field.name === name), - getComputedFields: () => ({}), - getScriptedFields: () => fields.filter((field: any) => field.scripted), - getNonScriptedFields: () => fields.filter((field: any) => !field.scripted), - addScriptedField: async () => {}, - removeScriptedField: () => {}, }; } diff --git a/src/plugins/data/common/data_views/types.ts b/src/plugins/data/common/data_views/types.ts index 23dd795fa906..5700f7795740 100644 --- a/src/plugins/data/common/data_views/types.ts +++ b/src/plugins/data/common/data_views/types.ts @@ -26,6 +26,7 @@ import { IndexPatternSpec, SourceFilter, } from '../index_patterns/types'; +import { Dataset } from '../datasets/types'; /** * @experimental DataView functionality is experimental and may change in future versions @@ -40,6 +41,13 @@ export interface IDataView extends IIndexPattern { * @experimental This method is experimental and may change in future versions */ initializeDataSourceRef?(): Promise; + + /** + * Converts a DataView to a serializable Dataset object + * Maps dataSourceRef and includes only essential properties for backward compatibility + * @experimental This method is experimental and may change in future versions + */ + toDataset?(): Promise; } /** diff --git a/src/plugins/data/common/data_views/utils.ts b/src/plugins/data/common/data_views/utils.ts index caa5b3db4a05..b71b0b4defc4 100644 --- a/src/plugins/data/common/data_views/utils.ts +++ b/src/plugins/data/common/data_views/utils.ts @@ -41,7 +41,6 @@ export async function findByTitle( } } -// This is used to validate datasource reference of index pattern export const validateDataViewDataSourceReference = ( dataView: SavedObject, dataSourceId?: string @@ -50,8 +49,6 @@ export const validateDataViewDataSourceReference = ( if (dataSourceId) { return references.some((ref) => ref.id === dataSourceId && ref.type === 'data-source'); } else { - // No datasource id passed as input meaning we are getting index pattern from default cluster, - // and it's supposed to be an empty array return references.length === 0; } }; @@ -64,7 +61,6 @@ export const getDataViewTitle = async ( let dataSourceTitle; const dataSourceReference = getDataSourceReference(references); - // If an index-pattern references datasource, prepend data source name with index pattern name for display purpose if (dataSourceReference) { const dataSourceId = dataSourceReference.id; try { @@ -74,22 +70,79 @@ export const getDataViewTitle = async ( } = await getDataSource(dataSourceId); dataSourceTitle = error ? dataSourceId : title; } catch (e) { - // use datasource id as title when failing to fetch datasource dataSourceTitle = dataSourceId; } return concatDataSourceWithDataView(dataSourceTitle, dataViewTitle); } else { - // if index pattern doesn't reference datasource, return as it is. return dataViewTitle; } }; export const concatDataSourceWithDataView = (dataSourceTitle: string, dataViewTitle: string) => { - const DATA_SOURCE_INDEX_PATTERN_DELIMITER = '::'; + const DATA_SOURCE_DATA_VIEW_DELIMITER = '::'; - return dataSourceTitle.concat(DATA_SOURCE_INDEX_PATTERN_DELIMITER).concat(dataViewTitle); + return dataSourceTitle.concat(DATA_SOURCE_DATA_VIEW_DELIMITER).concat(dataViewTitle); }; export const getDataSourceReference = (references: DataViewSavedObjectReference[]) => { return references.find((ref) => ref.type === 'data-source'); }; + +/** + * Extracts dataset type from a URI pattern + * @param uri - URI in format "type://name" (e.g., "s3://my-bucket", "index-pattern://logs-*") + * @returns The dataset type in uppercase or undefined if not found + */ +export const extractDatasetTypeFromUri = (uri?: string): string | undefined => { + if (!uri || !uri.includes('://')) { + return undefined; + } + + const [type] = uri.split('://'); + return type?.toUpperCase(); +}; + +/** + * Extracts data source information from a URI pattern + * @param uri - URI in format "type://name/path" + * @returns Object containing type and name + */ +export const extractDataSourceInfoFromUri = (uri?: string): { type?: string; name?: string } => { + if (!uri) return {}; + + if (uri.includes('://')) { + const parts = uri.split('://'); + if (parts.length >= 2) { + const type = parts[0].toUpperCase(); + const pathParts = parts[1].split('/'); + const name = pathParts[0]; + return { type, name }; + } + } + + return { name: uri }; +}; + +/** + * Constructs a data source URI from type and name + * @param type - Dataset type (e.g., "S3", "INDEX_PATTERN") + * @param name - Data source name + * @returns URI in format "type://name" + */ +export const constructDataSourceUri = (type: string, name: string): string => { + return `${type.toLowerCase()}://${name}`; +}; + +/** + * Gets the dataset type from a data source reference + * @param dataSourceRef - The data source reference object + * @returns The dataset type or undefined + */ +export const getDatasetTypeFromReference = ( + dataSourceRef?: DataViewSavedObjectReference +): string | undefined => { + if (!dataSourceRef?.name) { + return undefined; + } + return extractDatasetTypeFromUri(dataSourceRef.name); +}; diff --git a/src/plugins/data/common/index_patterns/index_pattern.stub.ts b/src/plugins/data/common/index_patterns/index_pattern.stub.ts index 1d7453bdf36d..0eb3fd373c95 100644 --- a/src/plugins/data/common/index_patterns/index_pattern.stub.ts +++ b/src/plugins/data/common/index_patterns/index_pattern.stub.ts @@ -36,12 +36,6 @@ export const stubIndexPattern: IIndexPattern = { fields: stubFields, title: 'logstash-*', timeFieldName: '@timestamp', - getFieldByName: (name: string) => stubFields.find((field) => field.name === name), - getComputedFields: () => ({}), - getScriptedFields: () => stubFields.filter((field) => field.scripted), - getNonScriptedFields: () => stubFields.filter((field) => !field.scripted), - addScriptedField: async () => {}, - removeScriptedField: () => {}, }; export const stubIndexPatternWithFields: IIndexPattern = { @@ -57,16 +51,4 @@ export const stubIndexPatternWithFields: IIndexPattern = { searchable: true, }, ], - getFieldByName(name: string) { - return this.fields.find((field) => field.name === name); - }, - getComputedFields: () => ({}), - getScriptedFields() { - return this.fields.filter((field) => field.scripted); - }, - getNonScriptedFields() { - return this.fields.filter((field) => !field.scripted); - }, - addScriptedField: async () => {}, - removeScriptedField: () => {}, }; diff --git a/src/plugins/data/common/index_patterns/lib/get_from_saved_object.ts b/src/plugins/data/common/index_patterns/lib/get_from_saved_object.ts index 0bdc4a1fbdd7..7a25d36bb162 100644 --- a/src/plugins/data/common/index_patterns/lib/get_from_saved_object.ts +++ b/src/plugins/data/common/index_patterns/lib/get_from_saved_object.ts @@ -44,11 +44,5 @@ export function getFromSavedObject( id: savedObject.id, fields, title: savedObject.attributes.title, - getFieldByName: (name: string) => fields.find((field: any) => field.name === name), - getComputedFields: () => ({}), - getScriptedFields: () => fields.filter((field: any) => field.scripted), - getNonScriptedFields: () => fields.filter((field: any) => !field.scripted), - addScriptedField: async () => {}, - removeScriptedField: () => {}, }; } diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 75b3ff71fd7a..d26708b3afe7 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -51,13 +51,6 @@ export interface IIndexPattern { getFormatterForField?: ( field: IndexPatternField | IndexPatternField['spec'] | IFieldType ) => FieldFormat; - - getFieldByName(name: string): IFieldType | undefined; - getComputedFields(): any; - getScriptedFields(): IFieldType[]; - getNonScriptedFields(): IFieldType[]; - addScriptedField(name: string, script: string, fieldType?: string): Promise; - removeScriptedField(fieldName: string): void; } export interface IndexPatternAttributes { diff --git a/src/plugins/data/public/data_views/data_views/redirect_no_data_view.tsx b/src/plugins/data/public/data_views/data_views/redirect_no_data_view.tsx index a1a23cefa0f4..910a5f5e0da2 100644 --- a/src/plugins/data/public/data_views/data_views/redirect_no_data_view.tsx +++ b/src/plugins/data/public/data_views/data_views/redirect_no_data_view.tsx @@ -21,7 +21,7 @@ export const onRedirectNoDataView = ( ) => () => { const canManageDataViews = capabilities.management.opensearchDashboards.dataViews; const redirectTarget = canManageDataViews - ? '/management/opensearch-dashboards/dataViews' + ? '/management/opensearch-dashboards/indexPatterns' : '/home'; let timeoutId: NodeJS.Timeout | undefined; @@ -31,10 +31,10 @@ export const onRedirectNoDataView = ( const bannerMessage = i18n.translate('data.dataViews.ensureDefaultDataView.bannerLabel', { defaultMessage: - 'To visualize and explore data in OpenSearch Dashboards, you must create an index pattern to retrieve data from OpenSearch.', + 'To visualize and explore data in OpenSearch Dashboards, you must create a dataset to retrieve data from the data source.', }); - // Avoid being hostile to new users who don't have an index pattern setup yet + // Avoid being hostile to new users who don't have a dataset setup yet // give them a friendly info message instead of a terse error message bannerId = overlays.banners.replace( bannerId, @@ -51,7 +51,7 @@ export const onRedirectNoDataView = ( navigateToApp('home'); } else { navigateToApp('management', { - path: `/opensearch-dashboards/dataViews?bannerMessage=${bannerMessage}`, + path: `/opensearch-dashboards/indexPatterns?bannerMessage=${bannerMessage}`, }); } diff --git a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts index b33749e279c6..bd620fe8f8eb 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.ts @@ -146,7 +146,6 @@ export class DatasetService { } else { const temporaryDataView = await services.data?.dataViews.create(spec, true); - // Load schema asynchronously if it's an async index pattern if (asyncType && temporaryDataView) { type! .fetchFields(dataset, services) diff --git a/src/plugins/data/public/ui/dataset_select/_dataset_details.scss b/src/plugins/data/public/ui/dataset_select/_dataset_details.scss index e7a180fbb3c1..39d04aa509dd 100644 --- a/src/plugins/data/public/ui/dataset_select/_dataset_details.scss +++ b/src/plugins/data/public/ui/dataset_select/_dataset_details.scss @@ -4,21 +4,61 @@ */ .datasetDetails { - &__button { - margin-left: $euiSizeXS; - white-space: nowrap; - flex-shrink: 0; - width: $euiSizeXL; - height: $euiSizeXL; - } - &__panel { - width: 400px; + width: 100%; max-width: 90vw; } - &__timeField { - font-size: $euiFontSizeXS; + &__header { + > * { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: $euiFontWeightSemiBold; + width: 100%; + } + + > :first-child { + text-align: left; + } + + > :nth-child(2) { + text-align: right; + } + } + + &__list { + margin-top: 0; + } + + &__title { + > * { + font-weight: $euiFontWeightSemiBold; + } + } + + &__listTitle { + width: 100%; + + > * { + font-weight: $euiFontWeightSemiBold; + } + } + + &__dataDefinition { + background-color: $euiColorLightestShade; + align-items: center; + border-radius: $euiBorderRadius; + } + + &__icon { + margin: $euiSizeXS $euiSizeXS 0 0; + height: 100%; + flex-shrink: 0; + } + + &__description { + font-size: $euiFontSizeXS - 2px; } &__textTruncate { diff --git a/src/plugins/data/public/ui/dataset_select/_dataset_select.scss b/src/plugins/data/public/ui/dataset_select/_dataset_select.scss index b20da8b44590..f72a597c0e5a 100644 --- a/src/plugins/data/public/ui/dataset_select/_dataset_select.scss +++ b/src/plugins/data/public/ui/dataset_select/_dataset_select.scss @@ -4,61 +4,11 @@ */ .datasetSelect { - margin: 0; - border: $euiBorderThin; - border-color: $euiColorLightestShade; - background-color: $euiPageBackgroundColor; - width: 400px; - max-width: 400px; - height: $euiSizeXL - 1px; + width: 300px; + max-width: 300px; - &__container { - width: 100%; - overflow: hidden; - } - - &__detailsContainer { - width: $euiSizeXL; - max-width: $euiSizeXL; - } - - div:is([class$="labelWrapper"]) { - width: $euiSizeXL; - height: $euiSizeXL - 1px; - padding: 0 $euiSizeXS; - margin-right: $euiSizeXS; - - :first-child { - word-wrap: normal; - word-break: normal; - height: $euiSizeXL - 1px; - display: block; - line-height: $euiSizeXL - 1px; - } - } - - div:is([class$="fieldWrapper"]) { - width: 100%; - padding: 0; - display: flex; - flex-direction: row; - } - - &__mainContent { - flex: 1; - min-width: 0; - max-width: calc(100% - 32px); - } - - &__button { - background-color: $euiColorEmptyShade; - width: 100%; - height: $euiSizeXL; - min-width: 0; - display: flex; - align-items: center; - justify-content: flex-start; - text-align: left; + &__tooltip { + min-width: 400px; } &__icon { @@ -72,13 +22,12 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: $euiFontSizeS; flex: 1; width: 250px; min-width: 0; - max-width: calc(100% - $euiSizeXL); - height: $euiSizeXL - 1px; - line-height: $euiSizeXL - 1px; + max-width: calc(100% - $euiSizeM); + height: $euiSizeL; + line-height: $euiSizeL; } &__checkbox { @@ -87,7 +36,7 @@ } &__selectable { - width: 365px; + width: 400px; padding: $euiSizeXS; overflow-y: auto; @@ -109,10 +58,6 @@ overflow-y: auto; } - &__infoIcon { - margin-left: $euiSizeXS; - } - &__advancedButton { width: 100%; font-size: $euiFontSizeXS; diff --git a/src/plugins/data/public/ui/dataset_select/dataset_details.tsx b/src/plugins/data/public/ui/dataset_select/dataset_details.tsx index 351897efc8aa..7f36b870c5ea 100644 --- a/src/plugins/data/public/ui/dataset_select/dataset_details.tsx +++ b/src/plugins/data/public/ui/dataset_select/dataset_details.tsx @@ -3,69 +3,50 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { - EuiSmallButtonEmpty, - EuiPopover, - EuiPopoverTitle, EuiTitle, EuiText, EuiFlexGroup, EuiFlexItem, EuiDescriptionList, EuiBadge, + EuiButtonEmpty, + EuiHorizontalRule, + EuiPanel, + EuiSplitPanel, + EuiIcon, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { DataView as Dataset } from '../../../common/data_views'; +import { DetailedDataset } from './dataset_select'; import { DEFAULT_DATA } from '../../../common/constants'; import { IDataPluginServices } from '../../types'; export interface DatasetDetailsProps { - dataset?: Dataset; + dataset?: DetailedDataset; isDefault: boolean | false; } export const DatasetDetails: React.FC = ({ dataset, isDefault }) => { const { services } = useOpenSearchDashboards(); - const [isOpen, setIsOpen] = useState(false); const { - dataViews, query: { queryString }, } = services.data; const datasetService = queryString.getDatasetService(); - const togglePopover = useCallback(() => setIsOpen(!isOpen), [isOpen]); - const closePopover = useCallback(() => setIsOpen(false), []); - const handleDataDefinitionClicked = useCallback(async () => { - if (!dataset || !dataset.dataSourceRef) { + if (!dataset || !dataset.dataSource) { return; } - services.application!.navigateToApp('dataSources', { path: `/${dataset.dataSourceRef.id}` }); + services.application!.navigateToApp('dataSources', { path: `/${dataset.dataSource.id}` }); }, [dataset, services.application]); - const getTypeFromUri = useCallback((uri?: string): string | undefined => { - if (!uri) return undefined; - - if (uri.includes('://')) { - const parts = uri.split('://'); - if (parts.length >= 2) { - return parts[0].toUpperCase(); - } - } - - return undefined; - }, []); - if (!dataset) { return null; } - - const datasetType = - getTypeFromUri(dataset.dataSourceRef?.name) || dataViews.convertToDataset(dataset).type; - const dataSourceName = dataset.dataSourceRef?.name || `default`; + const dataSourceName = dataset.dataSource?.title || `default`; const datasetTitle = dataset.displayName || dataset.title; const datasetDescription = dataset.description || ''; const timeFieldName = @@ -75,37 +56,28 @@ export const DatasetDetails: React.FC = ({ dataset, isDefau }); return ( - - } - isOpen={isOpen} - closePopover={closePopover} - anchorPosition="downRight" - panelPaddingSize="s" - panelClassName="datasetDetails__panel" + - - - - { - - <>{datasetTitle} - - } - - + + + + <>{datasetTitle} + + + {isDefault && ( + @@ -113,9 +85,11 @@ export const DatasetDetails: React.FC = ({ dataset, isDefau defaultMessage: 'Default', })} - - - + + )} + + + = ({ dataset, isDefau ? [ { title: ( - + {i18n.translate('data.datasetDetails.descriptionTitle', { defaultMessage: 'Description', })} ), description: ( - +

{datasetDescription}

), @@ -144,40 +118,44 @@ export const DatasetDetails: React.FC = ({ dataset, isDefau : []), { title: ( - + {i18n.translate('data.datasetDetails.dataDefinitionTitle', { defaultMessage: 'Data definition', })} ), description: ( - - - - - {dataSourceName} - - - - + + + {dataSourceName} + + ), }, { title: ( - + {i18n.translate('data.datasetDetails.timeFieldTitle', { defaultMessage: 'Time field', })} @@ -186,7 +164,10 @@ export const DatasetDetails: React.FC = ({ dataset, isDefau description: ( - + {timeFieldName} @@ -195,30 +176,6 @@ export const DatasetDetails: React.FC = ({ dataset, isDefau }, ]} /> - - {/* TODO: Add footer back when dataset management is ready */} - {/* - - - - {i18n.translate('data.datasetDetails.viewDatasetButton', { - defaultMessage: 'View dataset', - })} - - - - */} -
+ ); }; diff --git a/src/plugins/data/public/ui/dataset_select/dataset_select.stories.tsx b/src/plugins/data/public/ui/dataset_select/dataset_select.stories.tsx index e1bb17c92a42..52d9881a06e2 100644 --- a/src/plugins/data/public/ui/dataset_select/dataset_select.stories.tsx +++ b/src/plugins/data/public/ui/dataset_select/dataset_select.stories.tsx @@ -21,11 +21,11 @@ import { monaco } from '@osd/monaco'; import _ from 'lodash'; import { OpenSearchDashboardsContextProvider } from '../../../../opensearch_dashboards_react/public'; import DatasetSelect from './dataset_select'; -import { DataView as Dataset } from '../../../common/data_views'; +import { DataView, Dataset } from '../../../common'; import { IDataPluginServices } from '../../types'; const mockDatasets = [ - // Basic Dataset example + // Basic DataView example ({ id: '6c57d0bc-7b82-4e38-983c-b9a8c1ea7d5f', title: 'prod_logs-*', @@ -192,9 +192,9 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, - // Dataset with no display name + // DataView with no display name ({ id: '8f4b9c1d-3e5a-4f2b-9d6c-7a8b2e1c3d4f', title: 'metrics-*', @@ -359,9 +359,9 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, - // Dataset with very long display name + // DataView with very long display name ({ id: '5e6f7a8b-9c0d-4e5f-8a9b-1c2d3e4f5a6b', title: 's3://my-bucket/data/*', @@ -545,7 +545,7 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, // OpenSearch Indices example ({ @@ -750,7 +750,7 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, // OpenSearch Traces example ({ @@ -1010,7 +1010,7 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, // S3 Tables example ({ @@ -1270,7 +1270,7 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, // CloudWatch Logs example ({ @@ -1438,7 +1438,7 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, // Prometheus Metrics example ({ @@ -1634,9 +1634,9 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, - // Virtual Dataset example + // Virtual DataView example ({ id: '8a20a579-1e5b-4d19-bdce-0eac99f5cfbe::prod-cluster-1::logs', title: 'prod-cluster-1', @@ -1739,7 +1739,7 @@ const mockDatasets = [ }, }, getFormatterForField: () => ({}), - } as unknown) as Dataset, + } as unknown) as DataView, ]; const editorOptions: monaco.editor.IEditorConstructionOptions = { @@ -1752,19 +1752,19 @@ const editorOptions: monaco.editor.IEditorConstructionOptions = { automaticLayout: true, }; -const createMockDatasetsService = (datasets: Dataset[] = [], shouldError: boolean = false) => ({ +const createMockDatasetsService = (datasets: DataView[] = [], shouldError: boolean = false) => ({ getIds: async (includeHidden?: boolean) => { if (shouldError) throw new Error('Failed to fetch dataset IDs'); return datasets.map((d) => d.id || ''); }, get: async (id: string) => { - if (shouldError) throw new Error(`Dataset ${id} not found`); + if (shouldError) throw new Error(`DataView ${id} not found`); return datasets.find((d) => d.id === id); }, getDefault: async () => { return datasets.length > 0 ? datasets[0] : undefined; }, - saveToCache: (id: string, dataset: Dataset) => {}, + saveToCache: (id: string, dataset: DataView) => {}, create: async (spec: any, temporary?: boolean) => { return datasets.find((d) => d.id === spec.id); }, @@ -1923,7 +1923,7 @@ const createMockQueryService = () => { }; }; -const createMockServices = (datasets: Dataset[] = [], error: boolean = false) => +const createMockServices = (datasets: DataView[] = [], error: boolean = false) => (({ appName: 'opensearch-dashboards', uiSettings: { @@ -1959,17 +1959,17 @@ const DatasetSelectWithDetails = ({ selectedDataset, }: { services: IDataPluginServices; - selectedDataset?: Dataset; + selectedDataset?: DataView; }) => { const [currentDataset, setCurrentDataset] = useState(selectedDataset); const [selectedTabId, setSelectedTabId] = useState('json'); const handleSelect = (dataset: Dataset) => { - setCurrentDataset(dataset); + setCurrentDataset(dataset as DataView); action('dataset-selected')(dataset); }; - const formatForDisplay = (dataset: Dataset) => { + const formatForDisplay = (dataset: DataView) => { if (!dataset) return ''; const datasetWithFields = { diff --git a/src/plugins/data/public/ui/dataset_select/dataset_select.tsx b/src/plugins/data/public/ui/dataset_select/dataset_select.tsx index 0b2d7d98a8f8..7170eec1deea 100644 --- a/src/plugins/data/public/ui/dataset_select/dataset_select.tsx +++ b/src/plugins/data/public/ui/dataset_select/dataset_select.tsx @@ -6,7 +6,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { EuiButton, - EuiSmallButtonEmpty, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIcon, @@ -15,10 +15,10 @@ import { EuiSelectable, EuiSelectableOption, EuiToolTip, - EuiFormRow, EuiBadge, EuiHighlight, EuiTextColor, + EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -26,15 +26,19 @@ import { useOpenSearchDashboards, toMountPoint, } from '../../../../opensearch_dashboards_react/public'; -import { DataView, Query } from '../../../common'; -import { DEFAULT_DATA } from '../../../common/constants'; +import { Dataset, DEFAULT_DATA, Query } from '../../../common'; import { IDataPluginServices } from '../../types'; import { DatasetDetails } from './dataset_details'; import { AdvancedSelector } from '../dataset_selector/advanced_selector'; import './_index.scss'; +export interface DetailedDataset extends Dataset { + description?: string; + displayName?: string; +} + export interface DatasetSelectProps { - onSelect: (dataset: DataView) => void; + onSelect: (dataset: Dataset) => void; appName: string; } @@ -44,12 +48,10 @@ export interface DatasetSelectProps { const DatasetSelect: React.FC = ({ onSelect, appName }) => { const { services } = useOpenSearchDashboards(); const isMounted = useRef(true); - const initialDatasetSet = useRef(false); const [isOpen, setIsOpen] = useState(false); - const [datasets, setDatasets] = useState([]); - const [selectedDataset, setSelectedDataset] = useState(); + const [datasets, setDatasets] = useState([]); + const [selectedDataset, setSelectedDataset] = useState(); const [defaultDatasetId, setDefaultDatasetId] = useState(); - const [, setIsLoading] = useState(true); const { data, overlays } = services; const { dataViews, @@ -57,136 +59,94 @@ const DatasetSelect: React.FC = ({ onSelect, appName }) => { } = data; const datasetService = queryString.getDatasetService(); - const extractDataSourceInfo = useCallback((uri?: string): { type?: string; name?: string } => { - if (!uri) return {}; + const currentQuery = queryString.getQuery(); + const currentDataset = currentQuery.dataset; - if (uri.includes('://')) { - const parts = uri.split('://'); - if (parts.length >= 2) { - const type = parts[0].toUpperCase(); - const pathParts = parts[1].split('/'); - const name = pathParts[0]; - return { type, name }; + useEffect(() => { + const updateSelectedDataset = async () => { + if (!currentDataset) { + setSelectedDataset(undefined); + return; } - } - return { name: uri }; - }, []); + const matchingDataset = datasets.find((d) => d.id === currentDataset.id); + if (matchingDataset) { + setSelectedDataset(matchingDataset); + return; + } - const getTypeFromUri = useCallback( - (uri?: string): string | undefined => { - return extractDataSourceInfo(uri).type; - }, - [extractDataSourceInfo] - ); + const dataView = await dataViews.get( + currentDataset.id, + currentDataset.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); + + setSelectedDataset({ + ...currentDataset, + description: dataView.description, + displayName: dataView.displayName, + } as DetailedDataset); + }; + updateSelectedDataset(); + }, [currentDataset, dataViews, datasets]); + + const datasetIcon = + datasetService.getType(selectedDataset?.sourceDatasetRef?.type || selectedDataset?.type || '') + ?.meta.icon.type || 'database'; + + useEffect(() => { + isMounted.current = true; + const fetchDatasets = async () => { + if (!isMounted.current) return; - const fetchDatasets = useCallback(async () => { - setIsLoading(true); - try { const datasetIds = await dataViews.getIds(true); - const fetchedDataViews = []; + const fetchedDatasets: DetailedDataset[] = []; for (const id of datasetIds) { - try { - const dataView = await dataViews.get(id); - fetchedDataViews.push(dataView); - } catch (e) { - throw new Error( - i18n.translate('data.datasetSelect.fetchError', { - defaultMessage: 'Failed to fetch dataset with ID {id}: {error}', - values: { - id, - error: e.message, - }, - }) - ); - } + const dataView = await dataViews.get(id); + const dataset = await dataViews.convertToDataset(dataView); + + fetchedDatasets.push({ + ...dataset, + description: dataView.description, + displayName: dataView.displayName, + }); } const defaultDataView = await dataViews.getDefault(); - if (defaultDataView && isMounted.current) { + if (defaultDataView) { setDefaultDatasetId(defaultDataView.id); } - if (isMounted.current) { - setDatasets(fetchedDataViews); - if (!initialDatasetSet.current && fetchedDataViews.length > 0) { - // Check if there's already a dataset from query string (URL state) - const currentQuery = queryString.getQuery(); - const existingDataset = currentQuery?.dataset; + setDatasets(fetchedDatasets); - if (existingDataset) { - // If there's already a dataset from URL, find it in the fetched datasets - const urlDataset = fetchedDataViews.find((d) => d.id === existingDataset.id); - if (urlDataset) { - setSelectedDataset(urlDataset); - // Don't call onSelect during initialization if dataset exists in URL - } else { - // Fallback to first dataset if URL dataset not found - setSelectedDataset(fetchedDataViews[0]); - onSelect(fetchedDataViews[0]); - } - } else { - // No dataset in URL, select first one and call onSelect - setSelectedDataset(fetchedDataViews[0]); - onSelect(fetchedDataViews[0]); - } - initialDatasetSet.current = true; - } - setIsLoading(false); - } - } catch (e) { - if (isMounted.current) { - setIsLoading(false); + if (!currentDataset && defaultDataView) { + const defaultDataset = await dataViews.convertToDataset(defaultDataView); + onSelect(defaultDataset); } - } - }, [dataViews, onSelect, queryString]); + }; - useEffect(() => { - isMounted.current = true; fetchDatasets(); return () => { isMounted.current = false; }; - }, [fetchDatasets]); + }, [datasetService, dataViews, currentDataset, onSelect]); const togglePopover = useCallback(() => setIsOpen(!isOpen), [isOpen]); const closePopover = useCallback(() => setIsOpen(false), []); - const handleOptionChange = useCallback( - (newOptions: EuiSelectableOption[]) => { - const selectedOption = newOptions.find((option) => option.checked === 'on'); - if (selectedOption) { - const dataset = datasets.find((d) => d.id === selectedOption.key); - if (dataset) { - setSelectedDataset(dataset); - closePopover(); - onSelect(dataset); - } - } - }, - [datasets, closePopover, onSelect] - ); - - const buildOptions = useCallback((): EuiSelectableOption[] => { + const options = useMemo(() => { return datasets.map((dataset) => { - const { id, title, displayName, description } = dataset; + const { id, title, type, description, displayName } = dataset; const isSelected = id === selectedDataset?.id; const isDefault = id === defaultDatasetId; - - const datasetType = - getTypeFromUri(dataset.dataSourceRef?.name) || - dataset.type || - DEFAULT_DATA.SET_TYPES.INDEX_PATTERN; - const iconType = datasetService.getType(datasetType)?.meta.icon.type || 'database'; - + const iconType = datasetService.getType(type)?.meta?.icon?.type || 'database'; const label = displayName || title; return { label, searchableLabel: description || title, key: id, - checked: isSelected ? 'on' : undefined, + checked: isSelected ? ('on' as const) : undefined, prepend: , 'data-test-subj': `datasetOption-${id}`, append: isDefault ? ( @@ -198,14 +158,21 @@ const DatasetSelect: React.FC = ({ onSelect, appName }) => { ) : undefined, }; }); - }, [datasets, selectedDataset, defaultDatasetId, getTypeFromUri, datasetService]); - - const datasetType = - getTypeFromUri(selectedDataset?.dataSourceRef?.name) || - selectedDataset?.type || - DEFAULT_DATA.SET_TYPES.INDEX_PATTERN; + }, [datasets, selectedDataset?.id, defaultDatasetId, datasetService]); - const datasetIcon = datasetService.getType(datasetType)?.meta.icon.type || 'database'; + const handleOptionChange = useCallback( + async (newOptions: EuiSelectableOption[]) => { + const selectedOption = newOptions.find((option) => option.checked === 'on'); + if (selectedOption) { + const dataset = datasets.find((d) => d.id === selectedOption.key); + if (dataset) { + closePopover(); + onSelect(dataset); + } + } + }, + [datasets, closePopover, onSelect] + ); const datasetTitle = useMemo(() => { if (!selectedDataset) { @@ -222,178 +189,163 @@ const DatasetSelect: React.FC = ({ onSelect, appName }) => { }, [selectedDataset]); return ( - - - - - - - {datasetTitle} - - - } - isOpen={isOpen} - closePopover={closePopover} - anchorPosition="downLeft" - display="block" - panelPaddingSize="none" + button={ + + } + > + - { - const description = - option.searchableLabel && option.searchableLabel !== option.label - ? option.searchableLabel - : undefined; + + + {datasetTitle} + + + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="downLeft" + panelPaddingSize="none" + > + { + const description = + option.searchableLabel && option.searchableLabel !== option.label + ? option.searchableLabel + : undefined; - return ( + return ( + d.id === option.key) : undefined} + isDefault={option.key ? option.key === defaultDatasetId : false} + /> + } + > + <> + {option.label} + {description && ( <> - {option.label} - {description && ( - <> -
- - - {description} - - - - )} +
+ + + {description} + + - ); - }} - listProps={{ - showIcons: false, - rowHeight: 40, - }} - searchProps={{ - placeholder: i18n.translate('data.datasetSelect.searchPlaceholder', { - defaultMessage: 'Search', - }), - compressed: true, - }} - > - {(list, search) => ( - <> -
{search}
- {list} - - )} -
- - - - { - closePopover(); - const overlay = overlays?.openModal( - toMountPoint( - ) => { - overlay?.close(); - if (query?.dataset) { - try { - const { dataSource, timeFieldName } = query.dataset; - const updatedDataset = { - ...query.dataset, - ...(dataSource && { - dataSourceRef: { - id: dataSource.id || '', - name: `${query.dataset.type.toLowerCase()}://${ - dataSource.title - }`, - type: dataSource.type, - }, - }), - type: query.dataset.type, - timeFieldName, - }; + )} + + + ); + }} + listProps={{ + showIcons: false, + rowHeight: 40, + }} + searchProps={{ + placeholder: i18n.translate('data.datasetSelect.searchPlaceholder', { + defaultMessage: 'Search', + }), + compressed: true, + }} + > + {(list, search) => ( + <> +
{search}
+ {list} + + )} + + + + + { + closePopover(); + const overlay = overlays?.openModal( + toMountPoint( + ) => { + overlay?.close(); + if (query?.dataset) { + try { + await datasetService.cacheDataset(query.dataset, services, false); + const dataView = await data.dataViews.get( + query.dataset.id, + query.dataset.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); - await datasetService.cacheDataset( - updatedDataset, - services, - false - ); - setSelectedDataset(updatedDataset as DataView); - onSelect(updatedDataset as DataView); - } catch (error) { - services.notifications?.toasts.addError(error, { - title: i18n.translate('data.datasetSelect.errorTitle', { - defaultMessage: 'Error selecting dataset', - }), - }); - } - } - }} - onCancel={() => overlay?.close()} - /> - ), - { - maxWidth: false, - className: 'datasetSelect__advancedModal', + if (dataView) { + onSelect(query.dataset); + } + } catch (error) { + services.notifications?.toasts.addError(error, { + title: i18n.translate('data.datasetSelect.errorTitle', { + defaultMessage: 'Error selecting dataset', + }), + }); + } } - ); - }} - > - overlay?.close()} /> - - - - -
-
- - - -
-
+ ), + { + maxWidth: false, + className: 'datasetSelect__advancedModal', + } + ); + }} + > + + + + + + ); }; diff --git a/src/plugins/data/public/ui/dataset_select/index.tsx b/src/plugins/data/public/ui/dataset_select/index.tsx index 89d9d6d95e13..68ae0d2a28ee 100644 --- a/src/plugins/data/public/ui/dataset_select/index.tsx +++ b/src/plugins/data/public/ui/dataset_select/index.tsx @@ -16,4 +16,4 @@ export const DatasetSelect = (props: DatasetSelectProps) => ( ); export * from './create_dataset_select'; -export type { DatasetSelectProps } from './dataset_select'; +export type { DatasetSelectProps, DetailedDataset } from './dataset_select'; 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 de19957c9e61..d1524406b47a 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 @@ -5,7 +5,7 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; -import { DataView } from 'src/plugins/data/common'; +import { DataView, DEFAULT_DATA } from '../../../../../data/common'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { ExploreServices } from '../../../types'; import { RootState } from '../../utils/state_management/store'; @@ -30,7 +30,10 @@ const DatasetContext = createContext({ */ export const DatasetProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { services } = useOpenSearchDashboards(); - const { dataViews } = services.data; + const { + dataViews, + query: { queryString }, + } = services.data; const [dataset, setDataset] = useState(undefined); const [isLoading, setIsLoading] = useState(null); const [error, setError] = useState(null); @@ -52,14 +55,33 @@ export const DatasetProvider: React.FC<{ children: React.ReactNode }> = ({ child setError(null); try { - const view = await dataViews - .get(datasetFromState.id) - .catch(() => dataViews.createFromDataset(datasetFromState)); + let dataView = await dataViews.get( + datasetFromState.id, + datasetFromState.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); + if (!dataView) { + await queryString.getDatasetService().cacheDataset( + datasetFromState, + { + uiSettings: services.uiSettings, + savedObjects: services.savedObjects, + notifications: services.notifications, + http: services.http, + data: services.data, + }, + false + ); + + dataView = await dataViews.get( + datasetFromState.id, + datasetFromState.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); + } if (!isMounted) return; - setDataset(view || undefined); - setError(view ? null : `Failed to fetch dataset: ${datasetFromState.id}`); + setDataset(dataView || undefined); + setError(dataView ? null : `Failed to fetch dataset: ${datasetFromState.id}`); } catch (err) { if (!isMounted) return; setError(`Error fetching dataset: ${(err as Error).message}`); @@ -73,7 +95,7 @@ export const DatasetProvider: React.FC<{ children: React.ReactNode }> = ({ child return () => { isMounted = false; }; - }, [datasetFromState, dataViews]); + }, [datasetFromState, dataViews, queryString, services]); const contextValue = useMemo( () => ({ diff --git a/src/plugins/explore/public/application/legacy/discover/opensearch_dashboards_services.ts b/src/plugins/explore/public/application/legacy/discover/opensearch_dashboards_services.ts index 40df678127a3..b6801dfa2597 100644 --- a/src/plugins/explore/public/application/legacy/discover/opensearch_dashboards_services.ts +++ b/src/plugins/explore/public/application/legacy/discover/opensearch_dashboards_services.ts @@ -105,7 +105,7 @@ export { ISearchSource, OpenSearchQuerySortValue, SortDirection, - DataViewsContract as DatasetsContract, + DataViewsContract, IDataView as IDataset, DataView as Dataset, dataViews as datasets, diff --git a/src/plugins/explore/public/application/utils/state_management/actions/query_actions.ts b/src/plugins/explore/public/application/utils/state_management/actions/query_actions.ts index 3bf62dac8330..e05a62e1f04c 100644 --- a/src/plugins/explore/public/application/utils/state_management/actions/query_actions.ts +++ b/src/plugins/explore/public/application/utils/state_management/actions/query_actions.ts @@ -263,7 +263,6 @@ const executeQueryBase = async ( // Store controller by cacheKey for individual query abort activeQueryAbortControllers.set(cacheKey, abortController); - // Reset inspector adapter services.inspectorAdapters.requests.reset(); const title = i18n.translate('explore.discover.inspectorRequestDataTitle', { diff --git a/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/set_dataset.test.ts b/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/set_dataset.test.ts deleted file mode 100644 index 90ca2eff3a65..000000000000 --- a/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/set_dataset.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { setDatasetActionCreator } from './set_dataset'; -import { ExploreServices } from '../../../../../types'; -import { EditorMode } from '../../types'; -import { - clearResults, - setPromptModeIsAvailable, - setQueryWithHistory, - setActiveTab, - clearLastExecutedData, - setSummaryAgentIsAvailable, -} from '../../slices'; -import { clearQueryStatusMap } from '../../slices/query_editor/query_editor_slice'; -import { detectAndSetOptimalTab } from '../detect_optimal_tab'; -import { executeQueries } from '../query_actions'; -import { getPromptModeIsAvailable } from '../../../get_prompt_mode_is_available'; -import { getSummaryAgentIsAvailable } from '../../../get_summary_agent_is_available'; -import { AppDispatch, RootState } from '../../store'; - -// Mock dependencies -jest.mock('../../slices', () => ({ - clearResults: jest.fn(), - setPromptModeIsAvailable: jest.fn(), - setQueryWithHistory: jest.fn(), - setActiveTab: jest.fn(), - clearLastExecutedData: jest.fn(), - setSummaryAgentIsAvailable: jest.fn(), -})); - -jest.mock('../../slices/query_editor/query_editor_slice', () => ({ - clearQueryStatusMap: jest.fn(), -})); - -jest.mock('../query_actions', () => ({ - executeQueries: jest.fn(), -})); - -jest.mock('../detect_optimal_tab', () => ({ - detectAndSetOptimalTab: jest.fn(), -})); - -jest.mock('../../../get_prompt_mode_is_available', () => ({ - getPromptModeIsAvailable: jest.fn(), -})); - -jest.mock('../../../get_summary_agent_is_available', () => ({ - getSummaryAgentIsAvailable: jest.fn(), -})); - -describe('setDatasetActionCreator', () => { - let services: jest.Mocked; - let mockClearEditors: jest.MockedFunction; - let mockDispatch: jest.MockedFunction; - let mockGetState: jest.MockedFunction<() => RootState>; - - const mockQueryState = { - query: 'SELECT * FROM test', - language: 'sql', - }; - - const mockRootState = { - queryEditor: { - editorMode: EditorMode.Query, - promptModeIsAvailable: false, - summaryAgentIsAvailable: false, - queryStatusMap: {}, - overallQueryStatus: { - status: 'UNINITIALIZED' as any, - elapsedMs: undefined, - startTime: undefined, - body: undefined, - }, - promptToQueryIsLoading: false, - lastExecutedPrompt: '', - lastExecutedTranslatedQuery: '', - }, - query: { - query: 'SELECT * FROM test', - language: 'PPL', - dataset: { - id: 'test-dataset', - type: 'INDEX_PATTERN', - dataSource: { id: 'test-datasource' }, - }, - }, - ui: { - activeTabId: 'test-tab', - showHistogram: true, - }, - results: {}, - tab: { - logs: {}, - visualizations: { - styleOptions: undefined, - chartType: undefined, - axesMapping: {}, - }, - }, - legacy: { - columns: [], - sort: [], - interval: 'auto', - }, - }; - - beforeEach(() => { - services = { - data: { - dataViews: { - ensureDefaultDataView: jest.fn().mockResolvedValue(undefined), - get: jest.fn().mockResolvedValue({ id: 'test-dataview' }), - createFromDataset: jest.fn().mockResolvedValue({ id: 'test-dataview' }), - getDefault: jest.fn().mockResolvedValue({ id: 'default-dataview' }), - convertToDataset: jest - .fn() - .mockReturnValue({ id: 'test-dataset', title: 'test-dataset', type: 'INDEX_PATTERN' }), - }, - query: { - queryString: { - getQuery: jest.fn().mockReturnValue(mockQueryState), - }, - }, - }, - } as any; - - mockClearEditors = jest.fn(); - - mockDispatch = jest.fn(); - mockGetState = jest.fn().mockReturnValue(mockRootState); - - // Reset all mocks - jest.clearAllMocks(); - }); - - it('should dispatch all initial cleanup actions', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(setActiveTab).toHaveBeenCalledWith(''); - expect(clearResults).toHaveBeenCalledTimes(1); - expect(clearQueryStatusMap).toHaveBeenCalledTimes(1); - expect(clearLastExecutedData).toHaveBeenCalledTimes(1); - }); - - it('should dispatch setQueryWithHistory with dataset', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(setQueryWithHistory).toHaveBeenCalledWith({ - ...mockQueryState, - dataset: { id: 'test-dataset', title: 'test-dataset', type: 'INDEX_PATTERN' }, - }); - }); - - it('should dispatch setPromptModeIsAvailable when prompt mode availability changes', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(true); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(setPromptModeIsAvailable).toHaveBeenCalledWith(true); - }); - - it('should not dispatch setPromptModeIsAvailable when prompt mode availability stays the same', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(setPromptModeIsAvailable).not.toHaveBeenCalled(); - }); - - it('should call clearEditors', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(mockClearEditors).toHaveBeenCalledTimes(1); - }); - - it('should dispatch executeQueries and detectAndSetOptimalTab actions', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(executeQueries).toHaveBeenCalledWith({ services }); - expect(detectAndSetOptimalTab).toHaveBeenCalledWith({ services }); - }); - - it('should handle prompt mode availability change from true to false', async () => { - const stateWithPromptMode = { - queryEditor: { - promptModeIsAvailable: true, - summaryAgentIsAvailable: false, - queryStatusMap: {}, - overallQueryStatus: { - status: 'UNINITIALIZED' as any, - elapsedMs: undefined, - startTime: undefined, - body: undefined, - }, - editorMode: EditorMode.Prompt, - promptToQueryIsLoading: false, - lastExecutedPrompt: '', - lastExecutedTranslatedQuery: '', - }, - query: { - query: 'SELECT * FROM test', - language: 'PPL', - dataset: undefined, - }, - ui: { - activeTabId: 'test-tab', - showHistogram: true, - }, - results: {}, - tab: { - logs: {}, - visualizations: { - styleOptions: undefined, - chartType: undefined, - axesMapping: {}, - }, - }, - legacy: { - columns: [], - sort: [], - interval: 'auto', - }, - }; - mockGetState.mockReturnValue(stateWithPromptMode); - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(setPromptModeIsAvailable).toHaveBeenCalledWith(false); - }); - - it('should handle dataset creation from existing dataset query', async () => { - const stateWithDataset = { - ...mockRootState, - query: { - ...mockRootState.query, - dataset: { id: 'existing-dataset', title: 'existing-dataset', type: 'INDEX_PATTERN' }, - }, - }; - mockGetState.mockReturnValue(stateWithDataset); - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(services.data.dataViews.get).toHaveBeenCalledWith('existing-dataset', false); - }); - - it('should dispatch setSummaryAgentIsAvailable when summary agent availability changes', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(true); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(setSummaryAgentIsAvailable).toHaveBeenCalledWith(true); - }); - - it('should not dispatch setSummaryAgentIsAvailable when summary agent availability stays the same', async () => { - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(setSummaryAgentIsAvailable).not.toHaveBeenCalled(); - }); - - it('should call Promise.allSettled to run availability checks in parallel', async () => { - const promiseAllSettledSpy = jest.spyOn(Promise, 'allSettled'); - (getPromptModeIsAvailable as jest.MockedFunction).mockResolvedValue(false); - (getSummaryAgentIsAvailable as jest.MockedFunction).mockResolvedValue(false); - - const actionCreator = setDatasetActionCreator(services, mockClearEditors); - await actionCreator(mockDispatch, mockGetState); - - expect(promiseAllSettledSpy).toHaveBeenCalled(); - const callArgs = promiseAllSettledSpy.mock.calls[0][0]; - expect(Array.isArray(callArgs)).toBe(true); - expect((callArgs as any).length).toBe(2); - promiseAllSettledSpy.mockRestore(); - }); -}); diff --git a/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/set_dataset.ts b/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/set_dataset.ts deleted file mode 100644 index 87ad7a033c6a..000000000000 --- a/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/set_dataset.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { ExploreServices } from '../../../../../types'; -import { AppDispatch, RootState } from '../../store'; -import { - clearResults, - setPromptModeIsAvailable, - setQueryWithHistory, - setActiveTab, - clearLastExecutedData, - setSummaryAgentIsAvailable, -} from '../../slices'; -import { clearQueryStatusMap } from '../../slices/query_editor/query_editor_slice'; -import { executeQueries } from '../query_actions'; -import { getPromptModeIsAvailable } from '../../../get_prompt_mode_is_available'; -import { useClearEditors } from '../../../../hooks'; -import { detectAndSetOptimalTab } from '../detect_optimal_tab'; -import { getSummaryAgentIsAvailable } from '../../../get_summary_agent_is_available'; - -export const setDatasetActionCreator = ( - services: ExploreServices, - clearEditors: ReturnType -) => async (dispatch: AppDispatch, getState: () => RootState) => { - const { - data: { - dataViews, - query: { queryString }, - }, - } = services; - const currentQuery = queryString.getQuery(); - const { - queryEditor: { promptModeIsAvailable, summaryAgentIsAvailable }, - query, - } = getState(); - - dispatch(setActiveTab('')); - dispatch(clearResults()); - dispatch(clearQueryStatusMap()); - dispatch(clearLastExecutedData()); - - await dataViews.ensureDefaultDataView(); - const dataView = query.dataset - ? await dataViews - .get(query.dataset.id, query.dataset.type !== 'INDEX_PATTERN') - .catch(() => dataViews.createFromDataset(query.dataset!)) - : await dataViews.getDefault(); - - const updatedQuery = { - ...currentQuery, - ...(dataView ? { dataset: dataViews.convertToDataset(dataView) } : {}), - }; - - dispatch(setQueryWithHistory(updatedQuery)); - - const [newPromptModeIsAvailable, newSummaryAgentIsAvailable] = await Promise.allSettled([ - getPromptModeIsAvailable(services), - getSummaryAgentIsAvailable(services, query.dataset?.dataSource?.id!), - ]); - - if ( - newPromptModeIsAvailable.status === 'fulfilled' && - newPromptModeIsAvailable.value !== promptModeIsAvailable - ) { - dispatch(setPromptModeIsAvailable(newPromptModeIsAvailable.value)); - } - - if ( - newSummaryAgentIsAvailable.status === 'fulfilled' && - newSummaryAgentIsAvailable.value !== summaryAgentIsAvailable - ) { - dispatch(setSummaryAgentIsAvailable(newSummaryAgentIsAvailable.value)); - } - - clearEditors(); - - await dispatch(executeQueries({ services })); - dispatch(detectAndSetOptimalTab({ services })); -}; diff --git a/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.test.ts b/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.test.ts new file mode 100644 index 000000000000..4faa3216de2d --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDatasetChangeMiddleware } from './dataset_change_middleware'; +import { + setQueryState, + setQueryWithHistory, + clearResults, + setActiveTab, + setPromptModeIsAvailable, + setSummaryAgentIsAvailable, + clearLastExecutedData, +} from '../slices'; +import { clearQueryStatusMap } from '../slices/query_editor/query_editor_slice'; +import { createMockExploreServices, createMockStore, MockStore } from '../__mocks__'; +import { DEFAULT_DATA } from '../../../../../../data/common'; +import { getPromptModeIsAvailable } from '../../get_prompt_mode_is_available'; +import { getSummaryAgentIsAvailable } from '../../get_summary_agent_is_available'; +import * as queryActions from '../actions/query_actions'; +import * as tabActions from '../actions/detect_optimal_tab'; + +jest.mock('../../get_prompt_mode_is_available', () => ({ + getPromptModeIsAvailable: jest.fn().mockResolvedValue(true), +})); + +jest.mock('../../get_summary_agent_is_available', () => ({ + getSummaryAgentIsAvailable: jest.fn().mockResolvedValue(true), +})); + +jest.mock('../actions/query_actions', () => ({ + executeQueries: jest.fn().mockReturnValue({ type: 'mock/executeQueries' }), +})); + +jest.mock('../actions/detect_optimal_tab', () => ({ + detectAndSetOptimalTab: jest.fn().mockReturnValue({ type: 'mock/detectOptimalTab' }), +})); + +const mockedExecuteQueries = queryActions.executeQueries as jest.MockedFunction< + typeof queryActions.executeQueries +>; +const mockedDetectAndSetOptimalTab = tabActions.detectAndSetOptimalTab as jest.MockedFunction< + typeof tabActions.detectAndSetOptimalTab +>; +const mockedGetPromptModeIsAvailable = getPromptModeIsAvailable as jest.MockedFunction< + typeof getPromptModeIsAvailable +>; +const mockedGetSummaryAgentIsAvailable = getSummaryAgentIsAvailable as jest.MockedFunction< + typeof getSummaryAgentIsAvailable +>; + +describe('createDatasetChangeMiddleware', () => { + let mockServices: ReturnType; + let mockStore: MockStore; + let mockNext: jest.MockedFunction<(action: any) => any>; + let middleware: (action: any) => any; + let mockCacheDataset: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockCacheDataset = jest.fn(); + mockServices = createMockExploreServices(); + mockServices.data.query.queryString.getDatasetService = jest.fn().mockReturnValue({ + cacheDataset: mockCacheDataset, + }); + + mockStore = createMockStore(); + mockStore.getState = jest.fn().mockReturnValue({ + query: { + dataset: { id: 'test-dataset', type: 'index_pattern' }, + }, + queryEditor: { + promptModeIsAvailable: false, + summaryAgentIsAvailable: false, + }, + }); + + mockStore.dispatch = jest.fn(); + mockNext = jest.fn().mockImplementation((action) => action); + middleware = createDatasetChangeMiddleware(mockServices)(mockStore)(mockNext); + }); + + it('should pass action to next middleware', async () => { + const action = { type: 'some/otherAction' }; + await middleware(action); + expect(mockNext).toHaveBeenCalledWith(action); + }); + + it('should trigger side effects when dataset changes with setQueryState', async () => { + // Setup a new dataset that's different from the current one + const newDataset = { id: 'new-dataset', type: 'index_pattern' }; + mockStore.getState = jest.fn().mockReturnValue({ + query: { dataset: newDataset }, + queryEditor: { + promptModeIsAvailable: false, + summaryAgentIsAvailable: false, + }, + }); + + const action = setQueryState({ query: 'source=hello', language: 'PPL' }); + await middleware(action); + + // Verify the clearing actions were dispatched + expect(mockStore.dispatch).toHaveBeenCalledWith(setActiveTab('')); + expect(mockStore.dispatch).toHaveBeenCalledWith(clearResults()); + expect(mockStore.dispatch).toHaveBeenCalledWith(clearQueryStatusMap()); + expect(mockStore.dispatch).toHaveBeenCalledWith(clearLastExecutedData()); + expect(mockStore.dispatch).toHaveBeenCalledWith(setPromptModeIsAvailable(true)); + expect(mockStore.dispatch).toHaveBeenCalledWith(setSummaryAgentIsAvailable(true)); + + // Verify the executeQueries action was dispatched + expect(mockedExecuteQueries).toHaveBeenCalledWith({ services: mockServices }); + expect(mockStore.dispatch).toHaveBeenCalledWith({ type: 'mock/executeQueries' }); + + // Verify the detectAndSetOptimalTab action was dispatched + expect(mockedDetectAndSetOptimalTab).toHaveBeenCalledWith({ services: mockServices }); + expect(mockStore.dispatch).toHaveBeenCalledWith({ type: 'mock/detectOptimalTab' }); + }); + + it('should trigger side effects when dataset changes with setQueryWithHistory', async () => { + // Setup a new dataset that's different from the current one + const newDataset = { id: 'new-dataset', type: 'index_pattern' }; + mockStore.getState = jest.fn().mockReturnValue({ + query: { dataset: newDataset }, + queryEditor: { + promptModeIsAvailable: false, + summaryAgentIsAvailable: false, + }, + }); + + const action = setQueryWithHistory({ query: 'source=hello', language: 'PPL' }); + await middleware(action); + + // Verify the clearing actions were dispatched + expect(mockStore.dispatch).toHaveBeenCalledWith(setActiveTab('')); + expect(mockStore.dispatch).toHaveBeenCalledWith(clearResults()); + expect(mockStore.dispatch).toHaveBeenCalledWith(clearQueryStatusMap()); + expect(mockStore.dispatch).toHaveBeenCalledWith(clearLastExecutedData()); + + // Verify the executeQueries action was dispatched + expect(mockedExecuteQueries).toHaveBeenCalledWith({ services: mockServices }); + expect(mockStore.dispatch).toHaveBeenCalledWith({ type: 'mock/executeQueries' }); + }); + + it('should not trigger side effects if the dataset has not changed', async () => { + // First action with new dataset + const firstDataset = { id: 'first-dataset', type: 'index_pattern' }; + mockStore.getState = jest.fn().mockReturnValue({ + query: { dataset: firstDataset }, + queryEditor: { + promptModeIsAvailable: false, + summaryAgentIsAvailable: false, + }, + }); + + await middleware(setQueryState({ query: 'source=hello', language: 'PPL' })); + + // Reset the mocks + jest.clearAllMocks(); + + // Second action with the same dataset + await middleware(setQueryState({ query: 'source=updated', language: 'PPL' })); + + // Verify that no clearing actions were dispatched since dataset didn't change + expect(mockStore.dispatch).not.toHaveBeenCalledWith(setActiveTab('')); + expect(mockStore.dispatch).not.toHaveBeenCalledWith(clearResults()); + expect(mockStore.dispatch).not.toHaveBeenCalledWith(clearQueryStatusMap()); + }); + + it('should not cache dataset when type is index pattern', async () => { + const indexPatternDataset = { + id: 'index-pattern', + type: DEFAULT_DATA.SET_TYPES.INDEX_PATTERN, + dataSource: { id: 'data-source-id' }, + }; + mockStore.getState = jest.fn().mockReturnValue({ + query: { dataset: indexPatternDataset }, + queryEditor: { + promptModeIsAvailable: false, + summaryAgentIsAvailable: false, + }, + }); + + const action = setQueryState({ query: 'source=hello', language: 'PPL' }); + await middleware(action); + + // Verify the dataset was not cached + expect(mockCacheDataset).not.toHaveBeenCalled(); + }); + + it('should handle missing dataset', async () => { + mockStore.getState = jest.fn().mockReturnValue({ + query: { dataset: null }, + queryEditor: { + promptModeIsAvailable: false, + summaryAgentIsAvailable: false, + }, + }); + + const action = setQueryState({ query: 'source=hello', language: 'PPL' }); + await middleware(action); + + // Verify that execute queries was not called + expect(mockedExecuteQueries).not.toHaveBeenCalled(); + expect(mockedDetectAndSetOptimalTab).not.toHaveBeenCalled(); + }); + + it('should update prompt mode availability only if different from current state', async () => { + mockedGetPromptModeIsAvailable.mockResolvedValueOnce(true); + + mockStore.getState = jest.fn().mockReturnValue({ + query: { dataset: { id: 'new-dataset', type: 'index_pattern' } }, + queryEditor: { + promptModeIsAvailable: true, // Already true + summaryAgentIsAvailable: false, + }, + }); + + const action = setQueryState({ query: 'source=hello', language: 'PPL' }); + await middleware(action); + + // Should not dispatch action to set prompt mode availability since it's already true + expect(mockStore.dispatch).not.toHaveBeenCalledWith(setPromptModeIsAvailable(true)); + }); + + it('should update summary agent availability only if different from current state', async () => { + mockedGetSummaryAgentIsAvailable.mockResolvedValueOnce(true); + + mockStore.getState = jest.fn().mockReturnValue({ + query: { + dataset: { + id: 'new-dataset', + type: 'index_pattern', + dataSource: { id: 'data-source-id' }, + }, + }, + queryEditor: { + promptModeIsAvailable: false, + summaryAgentIsAvailable: true, // Already true + }, + }); + + const action = setQueryState({ query: 'source=hello', language: 'PPL' }); + await middleware(action); + + // Should not dispatch action to set summary agent availability since it's already true + expect(mockStore.dispatch).not.toHaveBeenCalledWith(setSummaryAgentIsAvailable(true)); + // But should call getSummaryAgentIsAvailable with the correct data source ID + expect(mockedGetSummaryAgentIsAvailable).toHaveBeenCalledWith(mockServices, 'data-source-id'); + }); +}); 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 new file mode 100644 index 000000000000..b4c94b25d9b5 --- /dev/null +++ b/src/plugins/explore/public/application/utils/state_management/middleware/dataset_change_middleware.ts @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Middleware } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash'; +import { Dataset, DEFAULT_DATA } from '../../../../../../data/common'; +import { RootState } from '../store'; +import { ExploreServices } from '../../../../types'; +import { + clearResults, + setPromptModeIsAvailable, + setActiveTab, + clearLastExecutedData, + setSummaryAgentIsAvailable, +} from '../slices'; +import { clearQueryStatusMap } 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'; + +/** + * Middleware to handle dataset changes and trigger necessary side effects + * TODO: followup with if this is necessary, or this can be removed in favor for the query sync middleware + */ +export const createDatasetChangeMiddleware = ( + services: ExploreServices +): Middleware<{}, RootState> => { + const { + data: { + query: { queryString }, + }, + } = services; + const datasetService = queryString.getDatasetService(); + let currentDataset: Dataset | undefined; + + return (store) => (next) => async (action) => { + const result = next(action); + + if (action.type === 'query/setQueryState' || action.type === 'query/setQueryWithHistory') { + const { + queryEditor, + query: { dataset }, + } = store.getState(); + + if (isEqual(currentDataset, dataset)) return result; + + currentDataset = dataset; + + store.dispatch(setActiveTab('')); + store.dispatch(clearResults()); + store.dispatch(clearQueryStatusMap()); + store.dispatch(clearLastExecutedData()); + + const [newPromptModeIsAvailable, newSummaryAgentIsAvailable] = await Promise.allSettled([ + getPromptModeIsAvailable(services), + getSummaryAgentIsAvailable(services, currentDataset?.dataSource?.id || ''), + ]); + + if ( + newPromptModeIsAvailable.status === 'fulfilled' && + newPromptModeIsAvailable.value !== queryEditor.promptModeIsAvailable + ) { + store.dispatch(setPromptModeIsAvailable(newPromptModeIsAvailable.value)); + } + + if ( + newSummaryAgentIsAvailable.status === 'fulfilled' && + newSummaryAgentIsAvailable.value !== queryEditor.summaryAgentIsAvailable + ) { + store.dispatch(setSummaryAgentIsAvailable(newSummaryAgentIsAvailable.value)); + } + + if (currentDataset) { + try { + if (datasetService && currentDataset.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN) { + await datasetService.cacheDataset( + currentDataset, + { + ...services, + storage: services.storage as any, + }, + false + ); + } + + await store.dispatch(executeQueries({ services }) as any); + store.dispatch(detectAndSetOptimalTab({ services }) as any); + } catch (error) { + services.notifications?.toasts.addError(error, { + title: 'Error loading dataset', + }); + } + } + } + + return result; + }; +}; diff --git a/src/plugins/explore/public/application/utils/state_management/store.test.ts b/src/plugins/explore/public/application/utils/state_management/store.test.ts index d7fbb353d2f1..92d5a4382a1a 100644 --- a/src/plugins/explore/public/application/utils/state_management/store.test.ts +++ b/src/plugins/explore/public/application/utils/state_management/store.test.ts @@ -16,6 +16,10 @@ jest.mock('./middleware/persistence_middleware', () => ({ createPersistenceMiddleware: jest.fn(() => () => (next: any) => (action: any) => next(action)), })); +jest.mock('./middleware/dataset_change_middleware', () => ({ + createDatasetChangeMiddleware: jest.fn(() => () => (next: any) => (action: any) => next(action)), +})); + jest.mock('./middleware/query_sync_middleware', () => ({ createQuerySyncMiddleware: jest.fn(() => () => (next: any) => (action: any) => next(action)), })); diff --git a/src/plugins/explore/public/application/utils/state_management/store.ts b/src/plugins/explore/public/application/utils/state_management/store.ts index 89c4fdad6b12..28f9c058d4a3 100644 --- a/src/plugins/explore/public/application/utils/state_management/store.ts +++ b/src/plugins/explore/public/application/utils/state_management/store.ts @@ -23,6 +23,7 @@ import { loadReduxState } from './utils/redux_persistence'; import { createQuerySyncMiddleware } from './middleware/query_sync_middleware'; import { createPersistenceMiddleware } from './middleware/persistence_middleware'; import { createOverallStatusMiddleware } from './middleware/overall_status_middleware'; +import { createDatasetChangeMiddleware } from './middleware/dataset_change_middleware'; import { ExploreServices } from '../../../types'; const resetState = createAction('app/resetState'); @@ -59,6 +60,7 @@ export const configurePreloadedStore = ( ? getDefaultMiddleware() .concat(createPersistenceMiddleware(services)) .concat(createQuerySyncMiddleware(services)) + .concat(createDatasetChangeMiddleware(services)) .concat(createOverallStatusMiddleware()) : getDefaultMiddleware(), }); diff --git a/src/plugins/explore/public/build_services.ts b/src/plugins/explore/public/build_services.ts index bdb450d531b4..9dbf3f32f3b7 100644 --- a/src/plugins/explore/public/build_services.ts +++ b/src/plugins/explore/public/build_services.ts @@ -42,7 +42,7 @@ export function buildServices( docLinks: core.docLinks, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, - datasets: plugins.data.dataViews, + dataViews: plugins.data.dataViews, indexPatterns: plugins.data.indexPatterns, getSavedExploreById: async (id?: string) => { return savedObjectService.get(id); diff --git a/src/plugins/explore/public/components/data_table/data_view_adapter.tsx b/src/plugins/explore/public/components/data_table/data_view_adapter.tsx index 2e1c7f33b1d3..afaccab9eb21 100644 --- a/src/plugins/explore/public/components/data_table/data_view_adapter.tsx +++ b/src/plugins/explore/public/components/data_table/data_view_adapter.tsx @@ -4,8 +4,7 @@ */ import React from 'react'; -import { IDataView } from '../../../../data/common'; -import { IIndexPattern } from '../../../../data/common/index_patterns/types'; +import { IDataView, IIndexPattern } from '../../../../data/common'; interface DataViewAdapterProps { dataView: IDataView; diff --git a/src/plugins/explore/public/components/fields_selector/lib/get_index_pattern_field_list.ts b/src/plugins/explore/public/components/fields_selector/lib/get_index_pattern_field_list.ts index 4a52051c2c1d..3162f25ee9e0 100644 --- a/src/plugins/explore/public/components/fields_selector/lib/get_index_pattern_field_list.ts +++ b/src/plugins/explore/public/components/fields_selector/lib/get_index_pattern_field_list.ts @@ -29,10 +29,10 @@ */ import { difference } from 'lodash'; -import { DataView as Dataset, IndexPattern, IndexPatternField } from '../../../../../data/public'; +import { DataView, IndexPattern, IndexPatternField } from '../../../../../data/public'; export function getIndexPatternFieldList( - indexPattern?: IndexPattern | Dataset, + indexPattern?: IndexPattern | DataView, fieldCounts?: Record ) { if (!indexPattern || !fieldCounts) return []; diff --git a/src/plugins/explore/public/components/query_panel/mock_provider.mocks.tsx b/src/plugins/explore/public/components/query_panel/mock_provider.mocks.tsx index 7b5093a437f8..700683f8952f 100644 --- a/src/plugins/explore/public/components/query_panel/mock_provider.mocks.tsx +++ b/src/plugins/explore/public/components/query_panel/mock_provider.mocks.tsx @@ -80,6 +80,18 @@ const mockServices = { fields: [], }), }, + dataViews: { + get: () => + Promise.resolve({ + id: 'mock-index-pattern', + title: 'mock-logs-*', + timeFieldName: '@timestamp', + fields: [], + }), + }, + ui: { + DatasetSelect: () =>
Mock Dataset Select
, + }, }, indexPatterns: { get: () => @@ -90,6 +102,15 @@ const mockServices = { fields: [], }), }, + dataViews: { + get: () => + Promise.resolve({ + id: 'mock-index-pattern', + title: 'mock-logs-*', + timeFieldName: '@timestamp', + fields: [], + }), + }, savedObjects: { client: { get: () => Promise.resolve({}), diff --git a/src/plugins/explore/public/components/query_panel/query_panel_editor/use_query_panel_editor/use_query_panel_editor.ts b/src/plugins/explore/public/components/query_panel/query_panel_editor/use_query_panel_editor/use_query_panel_editor.ts index 47e63fa655f8..b409236a849d 100644 --- a/src/plugins/explore/public/components/query_panel/query_panel_editor/use_query_panel_editor/use_query_panel_editor.ts +++ b/src/plugins/explore/public/components/query_panel/query_panel_editor/use_query_panel_editor/use_query_panel_editor.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { monaco } from '@osd/monaco'; import { useDispatch, useSelector } from 'react-redux'; import { i18n } from '@osd/i18n'; +import { DEFAULT_DATA } from '../../../../../../data/common'; import { selectIsPromptEditorMode, selectPromptModeIsAvailable, @@ -16,7 +17,6 @@ import { import { promptEditorOptions, queryEditorOptions } from './editor_options'; import { useEditorRef } from '../../../../application/hooks'; -import { useDatasetContext } from '../../../../application/context'; import { useOpenSearchDashboards } from '../../../../../../opensearch_dashboards_react/public'; import { ExploreServices } from '../../../../types'; import { getEffectiveLanguageForAutoComplete } from '../../../../../../data/public'; @@ -90,12 +90,17 @@ export interface UseQueryPanelEditorReturnType { export const useQueryPanelEditor = (): UseQueryPanelEditorReturnType => { const { promptIsTyping, handleChangeForPromptIsTyping } = usePromptIsTyping(); - const { dataset } = useDatasetContext(); const promptModeIsAvailable = useSelector(selectPromptModeIsAvailable); const { services } = useOpenSearchDashboards(); - const queryString = useSelector(selectQueryString); - const [editorText, setEditorText] = useState(queryString); + const userQueryString = useSelector(selectQueryString); + const [editorText, setEditorText] = useState(userQueryString); const [editorIsFocused, setEditorIsFocused] = useState(false); + const { + data: { + dataViews, + query: { queryString }, + }, + } = services; // The 'onRun' functions in editorDidMount uses the context values when the editor is mounted. // Using a ref will ensure it always uses the latest value const editorTextRef = useRef(editorText); @@ -155,21 +160,11 @@ export const useQueryPanelEditor = (): UseQueryPanelEditorReturnType => { const effectiveLanguage = getEffectiveLanguageForAutoComplete(queryLanguage, 'explore'); // Get the current dataset from Query Service to avoid stale closure values - const currentDataView = services.data.query.queryString.getQuery().dataset; - - // Get the current dataset from services to avoid stale closure values - let currentDataset = dataset; - if (currentDataView) { - try { - currentDataset = await services?.datasets?.get( - currentDataView.id, - currentDataView.type !== 'INDEX_PATTERN' - ); - } catch (error) { - // Fallback to the prop dataset if fetching fails - currentDataset = dataset; - } - } + const currentDataset = queryString.getQuery().dataset; + const currentDataView = await dataViews.get( + currentDataset?.id!, + currentDataset?.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); // Use the current Dataset to avoid stale data const suggestions = await services?.data?.autocomplete?.getQuerySuggestions({ @@ -177,8 +172,8 @@ export const useQueryPanelEditor = (): UseQueryPanelEditorReturnType => { selectionStart: model.getOffsetAt(position), selectionEnd: model.getOffsetAt(position), language: effectiveLanguage, - indexPattern: currentDataset as any, - datasetType: currentDataView?.type, + indexPattern: currentDataView, + datasetType: currentDataset?.type, position, services: services as any, // ExploreServices storage type incompatible with IDataPluginServices.DataStorage }); @@ -223,7 +218,7 @@ export const useQueryPanelEditor = (): UseQueryPanelEditorReturnType => { return { suggestions: [], incomplete: false }; } }, - [isPromptModeRef, queryLanguage, dataset, services] + [isPromptModeRef, queryLanguage, queryString, dataViews, services] ); // We need to manually register completion provider if it gets re-created, diff --git a/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.stories.tsx b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.stories.tsx new file mode 100644 index 000000000000..b2fe67585a40 --- /dev/null +++ b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.stories.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { DatasetSelectWidget } from './dataset_select'; +import { StorybookProviders } from '../../mock_provider.mocks'; + +const meta: Meta = { + title: 'src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select', + component: DatasetSelectWidget, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.test.tsx b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.test.tsx new file mode 100644 index 000000000000..1e7637ed1437 --- /dev/null +++ b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; + +const mockDispatch = jest.fn(); +const mockHandleTimeChange = jest.fn(); +const mockSetQueryWithHistory = jest.fn(); +const mockSelectQuery = jest.fn(); +const mockGetDataView = jest.fn(); +const mockCacheDataset = jest.fn(); +const mockGetInitialQueryByDataset = jest.fn(); +const mockSetQuery = jest.fn(); +const mockGetQuery = jest.fn(); +const mockToastAddError = jest.fn(); +const mockToastAddWarning = jest.fn(); + +jest.doMock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + useSelector: (selector: any) => { + if (selector === mockSelectQuery) { + return { dataset: { id: 'test-id', type: 'index_pattern' } }; + } + return {}; + }, + }; +}); + +jest.doMock('../../../../../../opensearch_dashboards_react/public', () => ({ + useOpenSearchDashboards: () => ({ + services: { + data: { + query: { + queryString: { + getQuery: mockGetQuery, + setQuery: mockSetQuery, + getInitialQueryByDataset: mockGetInitialQueryByDataset, + getQueryHistory: jest.fn(() => [ + { query: 'source = table1 | head 10', language: 'PPL' }, + { query: 'source = table2 | head 10', language: 'PPL' }, + ]), + getDatasetService: () => ({ + cacheDataset: mockCacheDataset, + }), + }, + }, + ui: { + DatasetSelect: ({ onSelect }: { onSelect: (dataset: any) => void }) => ( +
+ +
+ ), + }, + dataViews: { + get: mockGetDataView, + }, + }, + notifications: { + toasts: { + addError: mockToastAddError, + addWarning: mockToastAddWarning, + }, + }, + uiSettings: {}, + savedObjects: {}, + http: {}, + }, + }), +})); + +jest.doMock('../../utils', () => ({ + useTimeFilter: () => ({ + handleTimeChange: mockHandleTimeChange, + }), +})); + +jest.doMock('../../../../application/utils/state_management/slices', () => ({ + setQueryWithHistory: mockSetQueryWithHistory, +})); + +jest.doMock('../../../../application/utils/state_management/selectors', () => ({ + selectQuery: mockSelectQuery, +})); + +jest.doMock('../../../../../../data/common', () => ({ + Dataset: class {}, + DEFAULT_DATA: { + SET_TYPES: { + INDEX_PATTERN: 'index_pattern', + }, + }, + EMPTY_QUERY: { + QUERY: '', + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { DatasetSelectWidget } = require('./dataset_select'); + +const createMockStore = () => { + return configureStore({ + reducer: { + query: (state = {}) => state, + }, + }); +}; + +const renderWithStore = () => { + const mockStore = createMockStore(); + return render( + + + + ); +}; + +describe('DatasetSelectWidget', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetQuery.mockReturnValue({ query: 'test query', language: 'PPL' }); + mockGetInitialQueryByDataset.mockReturnValue({ query: 'initial query', language: 'PPL' }); + }); + + it('renders the dataset select component', () => { + renderWithStore(); + expect(screen.getByTestId('dataset-select')).toBeInTheDocument(); + }); + + it('attempts to get dataView on component mount', async () => { + mockGetDataView.mockResolvedValue({ id: 'test-id' }); + + renderWithStore(); + + await waitFor(() => { + expect(mockGetDataView).toHaveBeenCalledWith('test-id', false); + }); + }); + + it('caches dataset if dataView does not exist', async () => { + mockGetDataView.mockResolvedValue(null); + + renderWithStore(); + + await waitFor(() => { + expect(mockCacheDataset).toHaveBeenCalledWith( + { id: 'test-id', type: 'index_pattern' }, + expect.objectContaining({ + uiSettings: {}, + savedObjects: {}, + notifications: expect.anything(), + http: {}, + data: expect.anything(), + }), + false + ); + }); + }); + + it('handles dataset selection correctly', async () => { + renderWithStore(); + + const button = screen.getByTestId('dataset-select-button'); + fireEvent.click(button); + + await waitFor(() => { + expect(mockGetInitialQueryByDataset).toHaveBeenCalledWith({ + id: 'test-dataset', + type: 'index_pattern', + }); + expect(mockSetQuery).toHaveBeenCalledWith({ + query: '', + language: 'PPL', + dataset: { id: 'test-dataset', type: 'index_pattern' }, + }); + expect(mockDispatch).toHaveBeenCalledWith(mockSetQueryWithHistory()); + }); + }); +}); diff --git a/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.tsx b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.tsx new file mode 100644 index 000000000000..b20f116eaa06 --- /dev/null +++ b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/dataset_select.tsx @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useOpenSearchDashboards } from '../../../../../../opensearch_dashboards_react/public'; +import { Dataset, DEFAULT_DATA, EMPTY_QUERY } from '../../../../../../data/common'; +import { ExploreServices } from '../../../../types'; +import { setQueryWithHistory } from '../../../../application/utils/state_management/slices'; +import { selectQuery } from '../../../../application/utils/state_management/selectors'; + +export const DatasetSelectWidget = () => { + const { services } = useOpenSearchDashboards(); + const dispatch = useDispatch(); + const currentQuery = useSelector(selectQuery); + + const { + data: { + ui: { DatasetSelect }, + query: { queryString }, + dataViews, + }, + } = services; + + useEffect(() => { + let isMounted = true; + + const handleDataset = async () => { + if (currentQuery.dataset) { + const dataView = await dataViews.get( + currentQuery.dataset.id, + currentQuery.dataset.type !== DEFAULT_DATA.SET_TYPES.INDEX_PATTERN + ); + + if (!dataView) { + await queryString.getDatasetService().cacheDataset( + currentQuery.dataset, + { + uiSettings: services.uiSettings, + savedObjects: services.savedObjects, + notifications: services.notifications, + http: services.http, + data: services.data, + }, + false + ); + } + } + }; + + try { + handleDataset(); + } catch (error) { + if (isMounted) { + services.notifications?.toasts.addWarning( + `Error fetching dataset: ${(error as Error).message}` + ); + } + } + + return () => { + isMounted = false; + }; + }, [currentQuery, dataViews, queryString, services]); + + const handleDatasetSelect = useCallback( + async (dataset: Dataset) => { + if (!dataset) return; + + try { + const initialQuery = queryString.getInitialQueryByDataset(dataset); + + queryString.setQuery({ + ...initialQuery, + query: EMPTY_QUERY.QUERY, + dataset, + }); + + dispatch( + setQueryWithHistory({ + ...queryString.getQuery(), + }) + ); + } catch (error) { + services.notifications?.toasts.addError(error, { + title: 'Error selecting dataset', + }); + } + }, + [queryString, dispatch, services] + ); + + return ; +}; diff --git a/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/index.ts b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/index.tsx similarity index 60% rename from src/plugins/explore/public/application/utils/state_management/actions/set_dataset/index.ts rename to src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/index.tsx index d81199f37c3c..4a2d3bc54064 100644 --- a/src/plugins/explore/public/application/utils/state_management/actions/set_dataset/index.ts +++ b/src/plugins/explore/public/components/query_panel/query_panel_widgets/dataset_select/index.tsx @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './set_dataset'; +export { DatasetSelectWidget } from './dataset_select'; diff --git a/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.test.tsx b/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.test.tsx index 12fe1248ba14..9a48f8c3ba23 100644 --- a/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.test.tsx +++ b/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.test.tsx @@ -22,6 +22,10 @@ jest.mock('../../../application/utils/state_management/selectors', () => ({ })); // Mock all child components +jest.mock('./dataset_select', () => ({ + DatasetSelectWidget: () =>
Dataset Select
, +})); + jest.mock('./save_query', () => ({ SaveQueryButton: () =>
Save Query
, })); @@ -85,6 +89,7 @@ describe('QueryPanelWidgets', () => { expect(container.querySelector('.exploreQueryPanelWidgets')).toBeInTheDocument(); // Check left section components + expect(screen.getByTestId('dataset-select-widget')).toBeInTheDocument(); expect(screen.getByTestId('recent-queries-button')).toBeInTheDocument(); expect(screen.getByTestId('save-query-button')).toBeInTheDocument(); expect(screen.getByTestId('selected-language')).toBeInTheDocument(); diff --git a/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.tsx b/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.tsx index 843eebf6f66b..79e9da27f220 100644 --- a/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.tsx +++ b/src/plugins/explore/public/components/query_panel/query_panel_widgets/query_panel_widgets.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { SaveQueryButton } from './save_query'; import { DateTimeRangePicker } from './date_time_range_picker'; import { RunQueryButton } from './run_query_button'; +import { DatasetSelectWidget } from './dataset_select'; import { RecentQueriesButton } from './recent_queries_button'; import { useDatasetContext } from '../../../application/context'; import { SelectedLanguage } from './selected_language'; @@ -22,6 +23,8 @@ export const QueryPanelWidgets = () => {
{/* Left Section */}
+ +
diff --git a/src/plugins/explore/public/components/top_nav/top_nav.tsx b/src/plugins/explore/public/components/top_nav/top_nav.tsx index 1857c3978206..828b77cdcd3b 100644 --- a/src/plugins/explore/public/components/top_nav/top_nav.tsx +++ b/src/plugins/explore/public/components/top_nav/top_nav.tsx @@ -3,10 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { i18n } from '@osd/i18n'; import { AppMountParameters } from 'opensearch-dashboards/public'; -import { useSelector as useNewStateSelector, useDispatch } from 'react-redux'; +import { useSelector as useNewStateSelector } from 'react-redux'; import { DataView } from '../../../../data/common'; import { useSyncQueryStateWithUrl } from '../../../../data/public'; import { createOsdUrlStateStorage } from '../../../../opensearch_dashboards_utils/public'; @@ -24,8 +24,6 @@ import { import { useFlavorId } from '../../helpers/use_flavor_id'; import { getTopNavLinks } from './top_nav_links'; import { SavedExplore } from '../../saved_explore'; -import { setQueryState } from '../../application/utils/state_management/slices'; -import { setDatasetActionCreator } from '../../application/utils/state_management/actions/set_dataset'; import { useClearEditors } from '../../application/hooks'; export interface TopNavProps { @@ -150,27 +148,6 @@ export const TopNav = ({ setHeaderActionMenu = () => {}, savedExplore }: TopNavP const showDatePicker = useMemo(() => dataset?.isTimeBased() ?? false, [dataset]); - const dispatch = useDispatch(); - - const handleDatasetSelect = useCallback( - async (view: DataView) => { - if (!view) return; - - const currentQuery = queryString.getQuery(); - - const newDataset = data.dataViews.convertToDataset(view); - dispatch( - setQueryState({ - ...currentQuery, - query: queryString.getInitialQueryByDataset(newDataset).query, - dataset: newDataset, - }) - ); - dispatch(setDatasetActionCreator(services, clearEditors)); - }, - [queryString, data.dataViews, dispatch, services, clearEditors] - ); - return ( {}, savedExplore }: TopNavP showSearchBar={false} showDatePicker={showDatePicker && TopNavMenuItemRenderType.IN_PORTAL} showSaveQuery={false} - showDatasetSelect={true} - datasetSelectProps={{ - onSelect: handleDatasetSelect, - }} + showDatasetSelect={false} useDefaultBehaviors setMenuMountPoint={setHeaderActionMenu} indexPatterns={dataset ? [dataset] : datasets} diff --git a/src/plugins/explore/public/types.ts b/src/plugins/explore/public/types.ts index cf8846e1de00..a4dc8de2384a 100644 --- a/src/plugins/explore/public/types.ts +++ b/src/plugins/explore/public/types.ts @@ -16,7 +16,7 @@ import { ChartsPluginStart } from 'src/plugins/charts/public'; import { DataPublicPluginSetup, DataPublicPluginStart, - DataViewsContract as DatasetsContract, + DataViewsContract, IndexPatternsContract, FilterManager, TimefilterContract, @@ -129,7 +129,7 @@ export interface ExploreServices { history: () => History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; - datasets: DatasetsContract; + dataViews: DataViewsContract; indexPatterns: IndexPatternsContract; inspector: InspectorPublicPluginStart; inspectorAdapters: { diff --git a/src/plugins/query_enhancements/public/search/filters/filter_utils.test.ts b/src/plugins/query_enhancements/public/search/filters/filter_utils.test.ts index 30ea49bec719..fc075cde7a3a 100644 --- a/src/plugins/query_enhancements/public/search/filters/filter_utils.test.ts +++ b/src/plugins/query_enhancements/public/search/filters/filter_utils.test.ts @@ -98,12 +98,6 @@ const mockIndexPattern: IIndexPattern = { return fields.some(predicate); }, } as any, - getFieldByName: (name: string) => undefined, - getComputedFields: () => ({}), - getScriptedFields: () => [], - getNonScriptedFields: () => [], - addScriptedField: async () => {}, - removeScriptedField: () => {}, }; describe('convertFiltersToClause', () => {