From cea972fe24f3752e9558c5c84d223e037764955f Mon Sep 17 00:00:00 2001 From: Viraj Sanghvi Date: Thu, 19 Sep 2024 15:15:09 -0700 Subject: [PATCH 01/19] chore: Update oui to 1.13 (#8246) * chore: Update oui to 1.13 Signed-off-by: Viraj Sanghvi * Changeset file for PR #8246 created/updated * update snapshot tests Signed-off-by: Viraj Sanghvi * update snapshot tests Signed-off-by: Viraj Sanghvi --------- Signed-off-by: Viraj Sanghvi Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8246.yml | 2 ++ package.json | 2 +- packages/osd-ui-framework/package.json | 2 +- packages/osd-ui-shared-deps/package.json | 2 +- .../header/__snapshots__/header.test.tsx.snap | 36 ++++++++++--------- .../data_source_table.test.tsx.snap | 4 +-- ...query_data_connections_table.test.tsx.snap | 8 ++--- .../plugins/osd_tp_run_pipeline/package.json | 2 +- .../osd_sample_panel_action/package.json | 2 +- .../osd_tp_custom_visualizations/package.json | 2 +- yarn.lock | 8 ++--- 11 files changed, 38 insertions(+), 32 deletions(-) create mode 100644 changelogs/fragments/8246.yml diff --git a/changelogs/fragments/8246.yml b/changelogs/fragments/8246.yml new file mode 100644 index 00000000000..21f8bf3c710 --- /dev/null +++ b/changelogs/fragments/8246.yml @@ -0,0 +1,2 @@ +chore: +- Update oui to 1.13 ([#8246](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8246)) \ No newline at end of file diff --git a/package.json b/package.json index e4e163d42f0..60ed1c744e3 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "dependencies": { "@aws-crypto/client-node": "^3.1.1", "@elastic/datemath": "5.0.3", - "@elastic/eui": "npm:@opensearch-project/oui@1.12.0", + "@elastic/eui": "npm:@opensearch-project/oui@1.13.0", "@elastic/good": "^9.0.1-kibana3", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@elastic/request-crypto": "2.0.0", diff --git a/packages/osd-ui-framework/package.json b/packages/osd-ui-framework/package.json index 12915ecbc8b..64d4673522a 100644 --- a/packages/osd-ui-framework/package.json +++ b/packages/osd-ui-framework/package.json @@ -23,7 +23,7 @@ "enzyme-adapter-react-16": "^1.9.1" }, "devDependencies": { - "@elastic/eui": "npm:@opensearch-project/oui@1.12.0", + "@elastic/eui": "npm:@opensearch-project/oui@1.13.0", "@osd/babel-preset": "1.0.0", "@osd/optimizer": "1.0.0", "comment-stripper": "^0.0.4", diff --git a/packages/osd-ui-shared-deps/package.json b/packages/osd-ui-shared-deps/package.json index ee83ece884c..9b30aae4694 100644 --- a/packages/osd-ui-shared-deps/package.json +++ b/packages/osd-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "31.1.0", - "@elastic/eui": "npm:@opensearch-project/oui@1.12.0", + "@elastic/eui": "npm:@opensearch-project/oui@1.13.0", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@opensearch/datemath": "5.0.3", "@osd/i18n": "1.0.0", diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 5c83515c157..8d002125bfe 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -20246,25 +20246,29 @@ exports[`Header renders page header with application title 1`] = `
-
- - - test - - -
+ + + test + +
-
+ preserveAspectRatio="none" + viewBox="0 0 8 12" + xmlns="http://www.w3.org/2000/svg" + > + + diff --git a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap index b4757bc469a..e437be94ff0 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap @@ -762,10 +762,10 @@ exports[`DataSourceTable should get datasources successful should render normall
Date: Thu, 19 Sep 2024 16:24:31 -0700 Subject: [PATCH 02/19] Use @osd/std to prettify objects for display (#8232) * Use @osd/std to stringify JSON when formatting objects for display Signed-off-by: Miki * Changeset file for PR #8232 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8232.yml | 2 ++ .../common/field_formats/utils/as_pretty_string.test.ts | 6 +++++- .../data/common/field_formats/utils/as_pretty_string.ts | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/8232.yml diff --git a/changelogs/fragments/8232.yml b/changelogs/fragments/8232.yml new file mode 100644 index 00000000000..3c4766037b6 --- /dev/null +++ b/changelogs/fragments/8232.yml @@ -0,0 +1,2 @@ +fix: +- Use @osd/std to prettify objects for display ([#8232](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8232)) \ No newline at end of file diff --git a/src/plugins/data/common/field_formats/utils/as_pretty_string.test.ts b/src/plugins/data/common/field_formats/utils/as_pretty_string.test.ts index dba46aa5131..d0461193178 100644 --- a/src/plugins/data/common/field_formats/utils/as_pretty_string.test.ts +++ b/src/plugins/data/common/field_formats/utils/as_pretty_string.test.ts @@ -42,7 +42,11 @@ describe('asPrettyString', () => { }); test('Converts objects values into presentable strings', () => { - expect(asPrettyString({ key: 'value' })).toBe('{\n "key": "value"\n}'); + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + expect(asPrettyString({ key: 'value', longPositive, longNegative })).toBe( + `{\n "key": "value",\n "longPositive": ${longPositive.toString()},\n "longNegative": ${longNegative.toString()}\n}` + ); }); test('Converts other non-string values into strings', () => { diff --git a/src/plugins/data/common/field_formats/utils/as_pretty_string.ts b/src/plugins/data/common/field_formats/utils/as_pretty_string.ts index 9696130fda2..dd6a2e5e5e7 100644 --- a/src/plugins/data/common/field_formats/utils/as_pretty_string.ts +++ b/src/plugins/data/common/field_formats/utils/as_pretty_string.ts @@ -28,6 +28,8 @@ * under the License. */ +import { stringify } from '@osd/std'; + /** * Convert a value to a presentable string */ @@ -37,7 +39,7 @@ export function asPrettyString(val: any): string { case 'string': return val; case 'object': - return JSON.stringify(val, null, ' '); + return stringify(val, null, ' '); default: return '' + val; } From c7c16c977303c714d78c855d3b57e7e659c7490d Mon Sep 17 00:00:00 2001 From: Suchit Sahoo <38322563+LDrago27@users.noreply.github.com> Date: Thu, 19 Sep 2024 18:12:30 -0700 Subject: [PATCH 03/19] Set Legacy Discover Table for Discover 2.0 (#8233) Signed-off-by: Suchit Sahoo --- .../application/components/chart/chart.tsx | 1 - .../components/data_grid/data_grid_table.tsx | 59 ++++++++++--------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/plugins/discover/public/application/components/chart/chart.tsx b/src/plugins/discover/public/application/components/chart/chart.tsx index d26f1508c22..92e7b12dc0a 100644 --- a/src/plugins/discover/public/application/components/chart/chart.tsx +++ b/src/plugins/discover/public/application/components/chart/chart.tsx @@ -118,7 +118,6 @@ export const DiscoverChart = ({ {isTimeBased && timeChartHeader} - {discoverOptions} ); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx index a999e1c5037..cd92fb46115 100644 --- a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx @@ -6,6 +6,7 @@ import { i18n } from '@osd/i18n'; import React, { useState } from 'react'; import { EuiPanel } from '@elastic/eui'; +import { QUERY_ENHANCEMENT_ENABLED_SETTING } from '../../../../common'; import { IndexPattern, getServices } from '../../../opensearch_dashboards_services'; import { DataGridFlyout } from './data_grid_table_flyout'; import { DiscoverGridContextProvider } from './data_grid_table_context'; @@ -71,35 +72,37 @@ export const DataGridTable = ({ } const newDiscoverEnabled = getNewDiscoverSetting(services.storage); + const isQueryEnhancementEnabled = services.uiSettings.get(QUERY_ENHANCEMENT_ENABLED_SETTING); - const panelContent = newDiscoverEnabled ? ( - - ) : ( - setInspectedHit(undefined)} - showPagination={showPagination} - scrollToTop={scrollToTop} - /> - ); + const panelContent = + isQueryEnhancementEnabled || !newDiscoverEnabled ? ( + setInspectedHit(undefined)} + showPagination={showPagination} + scrollToTop={scrollToTop} + /> + ) : ( + + ); const tablePanelProps = { paddingSize: 'none' as const, From 5ea00ad54184d5cb6bd4ecd5f96ae8155ddde84b Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 20 Sep 2024 10:51:53 +0800 Subject: [PATCH 04/19] [Workspace] Drop user personal settings page (#8236) * drop user personal settings page Signed-off-by: Hailong Cui * Changeset file for PR #8236 created/updated * remove unuse code Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8236.yml | 2 ++ .../advanced_settings/public/plugin.ts | 8 +++++++- .../advanced_settings/server/plugin.ts | 19 ++++++------------- 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 changelogs/fragments/8236.yml diff --git a/changelogs/fragments/8236.yml b/changelogs/fragments/8236.yml new file mode 100644 index 00000000000..3847544e546 --- /dev/null +++ b/changelogs/fragments/8236.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace] drop user personal settings page ([#8236](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8236)) \ No newline at end of file diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 6811290ac0f..4882f038256 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -46,7 +46,12 @@ import { AdvancedSettingsPluginStart, } from './types'; import { setupTopNavThemeButton } from './register_nav_control'; -import { DEFAULT_NAV_GROUPS, AppNavLinkStatus, WorkspaceAvailability } from '../../../core/public'; +import { + DEFAULT_NAV_GROUPS, + AppNavLinkStatus, + WorkspaceAvailability, + AppStatus, +} from '../../../core/public'; import { getScopedBreadcrumbs } from '../../opensearch_dashboards_react/public'; import { setupUserSettingsPage } from './management_app/user_settings'; @@ -193,6 +198,7 @@ export class AdvancedSettingsPlugin const userSettingsEnabled = core.application.capabilities.userSettings?.enabled; if (app.id === USER_SETTINGS_APPID) { return { + status: userSettingsEnabled ? AppStatus.accessible : AppStatus.inaccessible, navLinkStatus: userSettingsEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, }; } diff --git a/src/plugins/advanced_settings/server/plugin.ts b/src/plugins/advanced_settings/server/plugin.ts index 42f60d9eea9..c4d94ad6506 100644 --- a/src/plugins/advanced_settings/server/plugin.ts +++ b/src/plugins/advanced_settings/server/plugin.ts @@ -40,12 +40,10 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { capabilitiesProvider } from './capabilities_provider'; import { UserUISettingsClientWrapper } from './saved_objects/user_ui_settings_client_wrapper'; -import { extractUserName } from './utils'; export class AdvancedSettingsServerPlugin implements Plugin { private readonly logger: Logger; private userUiSettingsClientWrapper?: UserUISettingsClientWrapper; - private coreStart: CoreStart | undefined; private readonly globalConfig$: Observable; constructor(initializerContext: PluginInitializerContext) { @@ -81,16 +79,12 @@ export class AdvancedSettingsServerPlugin implements Plugin { ); core.capabilities.registerSwitcher(async (request, capabilities) => { - const userName = extractUserName(request, this.coreStart); - if (userName) { - return { - ...capabilities, - userSettings: { - enabled: true, - }, - }; - } - return capabilities; + return { + ...capabilities, + userSettings: { + enabled: false, + }, + }; }); return {}; @@ -98,7 +92,6 @@ export class AdvancedSettingsServerPlugin implements Plugin { public start(core: CoreStart) { this.logger.debug('advancedSettings: Started'); - this.coreStart = core; this.userUiSettingsClientWrapper?.setCore(core); return {}; From 8d52134734857561ad580d2567c68c01fe3e9fa8 Mon Sep 17 00:00:00 2001 From: Liyun Xiu Date: Fri, 20 Sep 2024 12:44:08 +0800 Subject: [PATCH 05/19] Add data summary panel in discover (#8186) * Add data summary panel in discover Signed-off-by: Liyun Xiu * Fix UTs Signed-off-by: Liyun Xiu * Add changelog Signed-off-by: Liyun Xiu * Fix UTs Signed-off-by: Liyun Xiu * Address comments Signed-off-by: Liyun Xiu * Address comments Signed-off-by: Liyun Xiu * Address comments Signed-off-by: Liyun Xiu --------- Signed-off-by: Liyun Xiu --- changelogs/fragments/8186.yml | 2 + src/plugins/data/common/data_frames/types.ts | 2 + .../data/public/search/search_service.ts | 30 +- src/plugins/data/public/search/types.ts | 1 + .../public/ui/query_editor/query_editor.tsx | 21 +- .../query_editor_extension.tsx | 4 + .../query_enhancements/common/config.ts | 3 + .../common/query_assist/index.ts | 2 +- .../common/query_assist/types.ts | 6 + .../opensearch_dashboards.json | 3 +- .../public/assets/sparkle_hollow.svg | 11 + .../public/assets/sparkle_mark.svg | 32 ++ .../public/assets/sparkle_solid.svg | 21 ++ .../query_enhancements/public/plugin.tsx | 9 +- .../public/query_assist/_index.scss | 10 + .../public/query_assist/components/index.ts | 2 + .../components/query_assist_bar.test.tsx | 12 + .../components/query_assist_bar.tsx | 5 +- .../components/query_assist_button.test.tsx | 69 ++++ .../components/query_assist_button.tsx | 43 +++ .../components/query_assist_summary.test.tsx | 321 +++++++++++++++++ .../components/query_assist_summary.tsx | 326 ++++++++++++++++++ .../public/query_assist/hooks/index.ts | 1 + .../query_assist/hooks/use_query_assist.ts | 18 + .../utils/create_extension.test.tsx | 46 ++- .../query_assist/utils/create_extension.tsx | 91 ++++- .../query_enhancements/public/types.ts | 2 + 27 files changed, 1073 insertions(+), 20 deletions(-) create mode 100644 changelogs/fragments/8186.yml create mode 100644 src/plugins/query_enhancements/public/assets/sparkle_hollow.svg create mode 100644 src/plugins/query_enhancements/public/assets/sparkle_mark.svg create mode 100644 src/plugins/query_enhancements/public/assets/sparkle_solid.svg create mode 100644 src/plugins/query_enhancements/public/query_assist/components/query_assist_button.test.tsx create mode 100644 src/plugins/query_enhancements/public/query_assist/components/query_assist_button.tsx create mode 100644 src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.test.tsx create mode 100644 src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.tsx create mode 100644 src/plugins/query_enhancements/public/query_assist/hooks/use_query_assist.ts diff --git a/changelogs/fragments/8186.yml b/changelogs/fragments/8186.yml new file mode 100644 index 00000000000..0e194ad9c6f --- /dev/null +++ b/changelogs/fragments/8186.yml @@ -0,0 +1,2 @@ +feat: +- Add data summary panel in discover ([#8186](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8186)) diff --git a/src/plugins/data/common/data_frames/types.ts b/src/plugins/data/common/data_frames/types.ts index 978f2817bcf..74e0d388a77 100644 --- a/src/plugins/data/common/data_frames/types.ts +++ b/src/plugins/data/common/data_frames/types.ts @@ -4,6 +4,7 @@ */ import { SearchResponse } from 'elasticsearch'; +import { BehaviorSubject } from 'rxjs'; import { IFieldType } from './fields'; export * from './_df_cache'; @@ -19,6 +20,7 @@ export interface DataFrameService { get: () => IDataFrame | undefined; set: (dataFrame: IDataFrame) => void; clear: () => void; + df$: BehaviorSubject; } /** diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index edf98b8570f..57881b08957 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -85,6 +85,7 @@ export class SearchService implements Plugin { private searchInterceptor!: ISearchInterceptor; private defaultSearchInterceptor!: ISearchInterceptor; private usageCollector?: SearchUsageCollector; + private dataFrame$ = new BehaviorSubject(undefined); constructor(private initializerContext: PluginInitializerContext) {} @@ -120,6 +121,23 @@ export class SearchService implements Plugin { expressions.registerFunction(aggShardDelay); } + const dfService: DataFrameService = { + get: () => { + const df = this.dfCache.get(); + this.dataFrame$.next(df); + return df; + }, + set: (dataFrame: IDataFrame) => { + this.dfCache.set(dataFrame); + }, + clear: () => { + if (this.dfCache.get() === undefined) return; + this.dfCache.clear(); + this.dataFrame$.next(undefined); + }, + df$: this.dataFrame$, + }; + return { aggs, usageCollector: this.usageCollector!, @@ -127,6 +145,7 @@ export class SearchService implements Plugin { this.searchInterceptor = enhancements.searchInterceptor; }, getDefaultSearchInterceptor: () => this.defaultSearchInterceptor, + df: dfService, }; } @@ -152,16 +171,21 @@ export class SearchService implements Plugin { const loadingCount$ = new BehaviorSubject(0); http.addLoadingCountSource(loadingCount$); - const dfService: DataFrameService = { - get: () => this.dfCache.get(), - set: async (dataFrame: IDataFrame) => { + get: () => { + const df = this.dfCache.get(); + this.dataFrame$.next(df); + return df; + }, + set: (dataFrame: IDataFrame) => { this.dfCache.set(dataFrame); }, clear: () => { if (this.dfCache.get() === undefined) return; this.dfCache.clear(); + this.dataFrame$.next(undefined); }, + df$: this.dataFrame$, }; const searchSourceDependencies: SearchSourceDependencies = { diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index d34271ef756..c625310525e 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -55,6 +55,7 @@ export interface ISearchSetup { */ __enhance: (enhancements: SearchEnhancements) => void; getDefaultSearchInterceptor: () => ISearchInterceptor; + df: DataFrameService; } /** diff --git a/src/plugins/data/public/ui/query_editor/query_editor.tsx b/src/plugins/data/public/ui/query_editor/query_editor.tsx index f68c601db18..6932cec906f 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor.tsx @@ -14,7 +14,6 @@ import { EuiText, PopoverAnchorPosition, } from '@elastic/eui'; -import { BehaviorSubject } from 'rxjs'; import classNames from 'classnames'; import { isEqual } from 'lodash'; import React, { Component, createRef, RefObject } from 'react'; @@ -337,6 +336,25 @@ export default class QueryEditorUI extends Component { return ; }; + private renderExtensionSearchBarButton = () => { + if (!this.extensionMap || Object.keys(this.extensionMap).length === 0) return null; + const sortedConfigs = Object.values(this.extensionMap).sort((a, b) => a.order - b.order); + return ( + <> + {sortedConfigs.map((config) => { + return config.getSearchBarButton + ? config.getSearchBarButton({ + language: this.props.query.language, + onSelectLanguage: this.onSelectLanguage, + isCollapsed: this.state.isCollapsed, + setIsCollapsed: this.setIsCollapsed, + }) + : null; + })} + + ); + }; + public render() { const className = classNames(this.props.className); @@ -455,6 +473,7 @@ export default class QueryEditorUI extends Component { {this.renderQueryControls(languageEditor.TopBar.Controls)} {!languageEditor.TopBar.Expanded && this.renderToggleIcon()} + {!languageEditor.TopBar.Expanded && this.renderExtensionSearchBarButton()} {this.props.savedQueryManagement}
diff --git a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx index be74558fae7..7a8fddfe7ee 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx @@ -69,6 +69,10 @@ export interface QueryEditorExtensionConfig { * @returns The component the query editor extension. */ getBanner?: (dependencies: QueryEditorExtensionDependencies) => React.ReactElement | null; + + getSearchBarButton?: ( + dependencies: QueryEditorExtensionDependencies + ) => React.ReactElement | null; } const QueryEditorExtensionPortal: React.FC<{ container: Element }> = (props) => { if (!props.children) return null; diff --git a/src/plugins/query_enhancements/common/config.ts b/src/plugins/query_enhancements/common/config.ts index b9ea4750e60..1b595024ae2 100644 --- a/src/plugins/query_enhancements/common/config.ts +++ b/src/plugins/query_enhancements/common/config.ts @@ -17,6 +17,9 @@ export const configSchema = schema.object({ defaultValue: [{ language: 'PPL', agentConfig: 'os_query_assist_ppl' }], } ), + summary: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), }), }); diff --git a/src/plugins/query_enhancements/common/query_assist/index.ts b/src/plugins/query_enhancements/common/query_assist/index.ts index 9469a3a2771..7c577db8883 100644 --- a/src/plugins/query_enhancements/common/query_assist/index.ts +++ b/src/plugins/query_enhancements/common/query_assist/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { QueryAssistParameters, QueryAssistResponse } from './types'; +export { QueryAssistParameters, QueryAssistResponse, QueryAssistContextType } from './types'; diff --git a/src/plugins/query_enhancements/common/query_assist/types.ts b/src/plugins/query_enhancements/common/query_assist/types.ts index 057ff1e708d..d191befad5e 100644 --- a/src/plugins/query_enhancements/common/query_assist/types.ts +++ b/src/plugins/query_enhancements/common/query_assist/types.ts @@ -17,3 +17,9 @@ export interface QueryAssistParameters { // for MDS dataSourceId?: string; } + +export enum QueryAssistContextType { + QUESTION, + QUERY, + DATA, +} diff --git a/src/plugins/query_enhancements/opensearch_dashboards.json b/src/plugins/query_enhancements/opensearch_dashboards.json index 69d8fd3bd66..c585dd7c4bc 100644 --- a/src/plugins/query_enhancements/opensearch_dashboards.json +++ b/src/plugins/query_enhancements/opensearch_dashboards.json @@ -4,6 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["data", "opensearchDashboardsReact", "opensearchDashboardsUtils", "savedObjects", "uiActions"], - "optionalPlugins": ["dataSource"] + "optionalPlugins": ["dataSource", "usageCollection"], + "configPath": ["queryEnhancements"] } diff --git a/src/plugins/query_enhancements/public/assets/sparkle_hollow.svg b/src/plugins/query_enhancements/public/assets/sparkle_hollow.svg new file mode 100644 index 00000000000..885e5c63241 --- /dev/null +++ b/src/plugins/query_enhancements/public/assets/sparkle_hollow.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/plugins/query_enhancements/public/assets/sparkle_mark.svg b/src/plugins/query_enhancements/public/assets/sparkle_mark.svg new file mode 100644 index 00000000000..bdf2a814806 --- /dev/null +++ b/src/plugins/query_enhancements/public/assets/sparkle_mark.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/query_enhancements/public/assets/sparkle_solid.svg b/src/plugins/query_enhancements/public/assets/sparkle_solid.svg new file mode 100644 index 00000000000..b890ff01a5c --- /dev/null +++ b/src/plugins/query_enhancements/public/assets/sparkle_solid.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/query_enhancements/public/plugin.tsx b/src/plugins/query_enhancements/public/plugin.tsx index acd3e6f7510..a3cec880288 100644 --- a/src/plugins/query_enhancements/public/plugin.tsx +++ b/src/plugins/query_enhancements/public/plugin.tsx @@ -38,7 +38,7 @@ export class QueryEnhancementsPlugin public setup( core: CoreSetup, - { data }: QueryEnhancementsPluginSetupDependencies + { data, usageCollection }: QueryEnhancementsPluginSetupDependencies ): QueryEnhancementsPluginSetup { const { queryString } = data.query; const pplSearchInterceptor = new PPLSearchInterceptor({ @@ -105,7 +105,12 @@ export class QueryEnhancementsPlugin data.__enhance({ editor: { - queryEditorExtension: createQueryAssistExtension(core.http, data, this.config.queryAssist), + queryEditorExtension: createQueryAssistExtension( + core, + data, + this.config.queryAssist, + usageCollection + ), }, }); diff --git a/src/plugins/query_enhancements/public/query_assist/_index.scss b/src/plugins/query_enhancements/public/query_assist/_index.scss index 9094e54afa4..ee3543d870c 100644 --- a/src/plugins/query_enhancements/public/query_assist/_index.scss +++ b/src/plugins/query_enhancements/public/query_assist/_index.scss @@ -4,6 +4,16 @@ */ .queryAssist { + &.queryAssist__summary { + margin-top: $euiSizeXS; + } + + &.queryAssist__summary_banner { + /* stylelint-disable @osd/stylelint/no_restricted_values */ + background: lightOrDarkTheme(linear-gradient(to left, #edf7ff, #f9f4ff), $euiColorEmptyShade); + padding: $euiSizeXS; + } + &.queryAssist__callout { margin-top: $euiSizeXS; } diff --git a/src/plugins/query_enhancements/public/query_assist/components/index.ts b/src/plugins/query_enhancements/public/query_assist/components/index.ts index 6301c474eeb..2507e44129b 100644 --- a/src/plugins/query_enhancements/public/query_assist/components/index.ts +++ b/src/plugins/query_enhancements/public/query_assist/components/index.ts @@ -5,3 +5,5 @@ export { QueryAssistBar } from './query_assist_bar'; export { QueryAssistBanner } from './query_assist_banner'; +export { QueryAssistSummary } from './query_assist_summary'; +export { QueryAssistButton } from './query_assist_button'; diff --git a/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.test.tsx b/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.test.tsx index 44036a11c85..367f44c6426 100644 --- a/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.test.tsx +++ b/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.test.tsx @@ -17,6 +17,7 @@ import { setData, setStorage } from '../../services'; import { useGenerateQuery } from '../hooks'; import { AgentError, ProhibitedQueryError } from '../utils'; import { QueryAssistInput } from './query_assist_input'; +import { useQueryAssist } from '../hooks'; jest.mock('../../../../opensearch_dashboards_react/public', () => ({ useOpenSearchDashboards: jest.fn(), @@ -25,6 +26,9 @@ jest.mock('../../../../opensearch_dashboards_react/public', () => ({ jest.mock('../hooks', () => ({ useGenerateQuery: jest.fn().mockReturnValue({ generateQuery: jest.fn(), loading: false }), + useQueryAssist: jest + .fn() + .mockReturnValue({ updateQuestion: jest.fn(), isQueryAssistCollapsed: false }), })); jest.mock('./query_assist_input', () => ({ @@ -86,6 +90,14 @@ describe('QueryAssistBar', () => { expect(component.container).toBeEmptyDOMElement(); }); + it('renders null if question assist is collapsed', () => { + useQueryAssist.mockReturnValueOnce({ updateQuestion: jest.fn(), isQueryAssistCollapsed: true }); + const { component } = renderQueryAssistBar({ + dependencies: { ...dependencies, isCollapsed: false }, + }); + expect(component.container).toBeEmptyDOMElement(); + }); + it('matches snapshot', () => { const { component } = renderQueryAssistBar(); expect(component.container).toMatchSnapshot(); diff --git a/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.tsx b/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.tsx index 884068070da..5b3662fbff2 100644 --- a/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.tsx +++ b/src/plugins/query_enhancements/public/query_assist/components/query_assist_bar.tsx @@ -19,6 +19,7 @@ import { getPersistedLog, AgentError, ProhibitedQueryError } from '../utils'; import { QueryAssistCallOut, QueryAssistCallOutType } from './call_outs'; import { QueryAssistInput } from './query_assist_input'; import { QueryAssistSubmitButton } from './submit_button'; +import { useQueryAssist } from '../hooks'; interface QueryAssistInputProps { dependencies: QueryEditorExtensionDependencies; @@ -42,6 +43,7 @@ export const QueryAssistBar: React.FC = (props) => { ); const selectedIndex = selectedDataset?.title; const previousQuestionRef = useRef(); + const { updateQuestion, isQueryAssistCollapsed } = useQueryAssist(); useEffect(() => { const subscription = queryString.getUpdates$().subscribe((query) => { @@ -64,6 +66,7 @@ export const QueryAssistBar: React.FC = (props) => { setAgentError(undefined); previousQuestionRef.current = inputRef.current.value; persistedLog.add(inputRef.current.value); + updateQuestion(inputRef.current.value); const params: QueryAssistParameters = { question: inputRef.current.value, index: selectedIndex, @@ -90,7 +93,7 @@ export const QueryAssistBar: React.FC = (props) => { } }; - if (props.dependencies.isCollapsed) return null; + if (props.dependencies.isCollapsed || isQueryAssistCollapsed) return null; return ( diff --git a/src/plugins/query_enhancements/public/query_assist/components/query_assist_button.test.tsx b/src/plugins/query_enhancements/public/query_assist/components/query_assist_button.test.tsx new file mode 100644 index 00000000000..3173ac955e3 --- /dev/null +++ b/src/plugins/query_enhancements/public/query_assist/components/query_assist_button.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { fireEvent, render, screen } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { QueryAssistButton } from './query_assist_button'; +import { useQueryAssist } from '../hooks'; + +jest.mock('../hooks', () => ({ + useQueryAssist: jest.fn(), +})); + +describe('query assist button', () => { + const setIsCollapsed = jest.fn(); + const updateIsQueryAssistCollapsed = jest.fn(); + + const props: ComponentProps = { + dependencies: { + isCollapsed: false, + setIsCollapsed, + }, + }; + const renderQueryAssistButton = (isCollapsed: boolean) => { + const component = render( +
+ +
+ ); + return component; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('if query editor collapsed, click button to expand', async () => { + useQueryAssist.mockImplementationOnce(() => ({ + isQueryAssistCollapsed: true, + updateIsQueryAssistCollapsed, + })); + renderQueryAssistButton(true); + expect(screen.getByTestId('queryAssist_summary_button')).toBeInTheDocument(); + await screen.getByTestId('queryAssist_summary_button'); + fireEvent.click(screen.getByTestId('queryAssist_summary_button')); + expect(setIsCollapsed).toHaveBeenCalledWith(false); + expect(updateIsQueryAssistCollapsed).toHaveBeenCalledWith(false); + }); + + [true, false].forEach((isQueryAssistCollapsed) => { + it('if query editor expanded, click button to switch', async () => { + useQueryAssist.mockImplementationOnce(() => ({ + isQueryAssistCollapsed, + updateIsQueryAssistCollapsed, + })); + renderQueryAssistButton(false); + expect(screen.getByTestId('queryAssist_summary_button')).toBeInTheDocument(); + await screen.getByTestId('queryAssist_summary_button'); + fireEvent.click(screen.getByTestId('queryAssist_summary_button')); + expect(setIsCollapsed).not.toHaveBeenCalled(); + expect(updateIsQueryAssistCollapsed).toHaveBeenCalledWith(!isQueryAssistCollapsed); + }); + }); +}); diff --git a/src/plugins/query_enhancements/public/query_assist/components/query_assist_button.tsx b/src/plugins/query_enhancements/public/query_assist/components/query_assist_button.tsx new file mode 100644 index 00000000000..25a19828844 --- /dev/null +++ b/src/plugins/query_enhancements/public/query_assist/components/query_assist_button.tsx @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import { useQueryAssist } from '../hooks'; +import collapsedIcon from '../../assets/sparkle_solid.svg'; +import expandIcon from '../../assets/sparkle_mark.svg'; +import { QueryEditorExtensionDependencies } from '../../../../data/public'; + +interface QueryAssistButtonProps { + dependencies: QueryEditorExtensionDependencies; +} + +export const QueryAssistButton: React.FC = (props) => { + const { isQueryAssistCollapsed, updateIsQueryAssistCollapsed } = useQueryAssist(); + + const onClick = useCallback(() => { + if (props.dependencies.isCollapsed) { + props.dependencies.setIsCollapsed(false); + updateIsQueryAssistCollapsed(false); + } else { + updateIsQueryAssistCollapsed(!isQueryAssistCollapsed); + } + }, [props.dependencies, isQueryAssistCollapsed, updateIsQueryAssistCollapsed]); + + return ( + + + + ); +}; diff --git a/src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.test.tsx b/src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.test.tsx new file mode 100644 index 00000000000..4ec5289a3fa --- /dev/null +++ b/src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.test.tsx @@ -0,0 +1,321 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { fireEvent, render, screen } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { BehaviorSubject } from 'rxjs'; +import { QueryAssistSummary, convertResult } from './query_assist_summary'; +import { useQueryAssist } from '../hooks'; +import { IDataFrame, Query } from '../../../../data/common'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useState: jest.fn((value) => [value, () => null]), + useRef: jest.fn(() => ({ current: null })), +})); + +jest.mock('../hooks', () => ({ + useQueryAssist: jest.fn(), +})); + +describe('query assist summary', () => { + const PPL = 'ppl'; + const question = 'Are there any errors in my logs?'; + const dataFrame = { + fields: [{ name: 'name', values: ['value'] }], + size: 1, + }; + const emptyDataFrame = { + fields: [], + size: 0, + }; + + const coreSetupMock = coreMock.createSetup({}); + const httpMock = coreSetupMock.http; + const data$ = new BehaviorSubject(undefined); + const question$ = new BehaviorSubject(''); + const query$ = new BehaviorSubject(undefined); + const reportUiStatsMock = jest.fn(); + const setSummary = jest.fn(); + const setLoading = jest.fn(); + const setFeedback = jest.fn(); + const setIsAssistantEnabledByCapability = jest.fn(); + const getQuery = jest.fn(); + const dataMock = { + query: { + queryString: { + getUpdates$: () => query$, + getQuery, + }, + }, + search: { + df: { + df$: data$, + }, + }, + }; + + afterEach(() => { + data$.next(undefined); + question$.next(''); + query$.next(undefined); + jest.clearAllMocks(); + }); + + const usageCollectionMock = { + reportUiStats: reportUiStatsMock, + METRIC_TYPE: { + CLICK: 'click', + }, + }; + const props: ComponentProps = { + data: dataMock, + http: httpMock, + usageCollection: usageCollectionMock, + dependencies: { + isCollapsed: false, + isSummaryCollapsed: false, + }, + core: coreSetupMock, + }; + + const LOADING = { + YES: true, + NO: false, + }; + const COLLAPSED = { + YES: true, + NO: false, + }; + const FEEDBACK = { + YES: true, + NO: false, + }; + + const renderQueryAssistSummary = (isCollapsed: boolean) => { + const component = render( +
+ +
+ ); + return component; + }; + + const sleep = (ms) => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }; + const WAIT_TIME = 100; + + const mockUseState = ( + summary, + loading, + feedback, + isAssistantEnabledByCapability = true, + isQueryAssistCollapsed = COLLAPSED.NO + ) => { + React.useState.mockImplementationOnce(() => [summary, setSummary]); + React.useState.mockImplementationOnce(() => [loading, setLoading]); + React.useState.mockImplementationOnce(() => [feedback, setFeedback]); + React.useState.mockImplementationOnce(() => [ + isAssistantEnabledByCapability, + setIsAssistantEnabledByCapability, + ]); + useQueryAssist.mockImplementationOnce(() => ({ + question: 'question', + question$, + isQueryAssistCollapsed, + })); + }; + + const defaultUseStateMock = () => { + mockUseState(null, LOADING.NO, FEEDBACK.NO); + }; + + it('should not show if collapsed is true', () => { + defaultUseStateMock(); + renderQueryAssistSummary(COLLAPSED.YES); + const summaryPanels = screen.queryAllByTestId('queryAssist__summary'); + expect(summaryPanels).toHaveLength(0); + }); + + it('should not show if assistant is disabled by capability', () => { + mockUseState(null, LOADING.NO, FEEDBACK.NO, false); + renderQueryAssistSummary(COLLAPSED.NO); + const summaryPanels = screen.queryAllByTestId('queryAssist__summary'); + expect(summaryPanels).toHaveLength(0); + }); + + it('should not show if query assistant is collapsed', () => { + mockUseState(null, LOADING.NO, FEEDBACK.NO, true, COLLAPSED.YES); + renderQueryAssistSummary(COLLAPSED.NO); + const summaryPanels = screen.queryAllByTestId('queryAssist__summary'); + expect(summaryPanels).toHaveLength(0); + }); + + it('should show if collapsed is false', () => { + defaultUseStateMock(); + renderQueryAssistSummary(COLLAPSED.NO); + const summaryPanels = screen.queryAllByTestId('queryAssist__summary'); + expect(summaryPanels).toHaveLength(1); + }); + + it('should display loading view if loading state is true', () => { + mockUseState(null, LOADING.YES, FEEDBACK.NO); + renderQueryAssistSummary(COLLAPSED.NO); + expect(screen.getByTestId('queryAssist_summary_loading')).toBeInTheDocument(); + expect(screen.queryAllByTestId('queryAssist_summary_result')).toHaveLength(0); + expect(screen.queryAllByTestId('queryAssist_summary_empty_text')).toHaveLength(0); + }); + + it('should display loading view if loading state is true even with summary', () => { + mockUseState('summary', LOADING.YES, FEEDBACK.NO); + renderQueryAssistSummary(COLLAPSED.NO); + expect(screen.getByTestId('queryAssist_summary_loading')).toBeInTheDocument(); + expect(screen.queryAllByTestId('queryAssist_summary_result')).toHaveLength(0); + expect(screen.queryAllByTestId('queryAssist_summary_empty_text')).toHaveLength(0); + }); + + it('should display initial view if loading state is false and no summary', () => { + defaultUseStateMock(); + renderQueryAssistSummary(COLLAPSED.NO); + expect(screen.getByTestId('queryAssist_summary_empty_text')).toBeInTheDocument(); + expect(screen.queryAllByTestId('queryAssist_summary_result')).toHaveLength(0); + expect(screen.queryAllByTestId('queryAssist_summary_loading')).toHaveLength(0); + }); + + it('should display summary result', () => { + mockUseState('summary', LOADING.NO, FEEDBACK.NO); + renderQueryAssistSummary(COLLAPSED.NO); + expect(screen.getByTestId('queryAssist_summary_result')).toBeInTheDocument(); + expect(screen.getByTestId('queryAssist_summary_result')).toHaveTextContent('summary'); + expect(screen.queryAllByTestId('queryAssist_summary_empty_text')).toHaveLength(0); + expect(screen.queryAllByTestId('queryAssist_summary_loading')).toHaveLength(0); + }); + + it('should report metric for thumbup click', async () => { + mockUseState('summary', LOADING.NO, FEEDBACK.NO); + renderQueryAssistSummary(COLLAPSED.NO); + expect(screen.getByTestId('queryAssist_summary_result')).toBeInTheDocument(); + await screen.getByTestId('queryAssist_summary_buttons_thumbup'); + fireEvent.click(screen.getByTestId('queryAssist_summary_buttons_thumbup')); + expect(setFeedback).toHaveBeenCalledWith(true); + expect(reportUiStatsMock).toHaveBeenCalledWith( + 'query-assist', + 'click', + expect.stringMatching(/^thumbup/) + ); + }); + + it('should report metric for thumbdown click', async () => { + mockUseState('summary', LOADING.NO, FEEDBACK.NO); + renderQueryAssistSummary(COLLAPSED.NO); + expect(screen.getByTestId('queryAssist_summary_result')).toBeInTheDocument(); + await screen.getByTestId('queryAssist_summary_buttons_thumbdown'); + fireEvent.click(screen.getByTestId('queryAssist_summary_buttons_thumbdown')); + expect(setFeedback).toHaveBeenCalledWith(true); + expect(reportUiStatsMock).toHaveBeenCalledWith( + 'query-assist', + 'click', + expect.stringMatching(/^thumbdown/) + ); + }); + + it('should not fetch summary if data is empty', async () => { + mockUseState(null, LOADING.NO, FEEDBACK.NO); + renderQueryAssistSummary(COLLAPSED.NO); + question$.next(question); + query$.next({ query: PPL, language: 'PPL' }); + data$.next(emptyDataFrame); + expect(httpMock.post).toBeCalledTimes(0); + }); + + it('should fetch summary with expected payload and response', async () => { + mockUseState('summary', LOADING.NO, FEEDBACK.NO); + const RESPONSE_TEXT = 'response'; + httpMock.post.mockResolvedValue(RESPONSE_TEXT); + renderQueryAssistSummary(COLLAPSED.NO); + question$.next(question); + query$.next({ query: PPL, language: 'PPL' }); + data$.next(dataFrame as IDataFrame); + await sleep(WAIT_TIME); + expect(httpMock.post).toBeCalledWith('/api/assistant/data2summary', { + body: JSON.stringify({ + sample_data: `'${JSON.stringify(convertResult(dataFrame))}'`, + sample_count: 1, + total_count: 1, + question, + ppl: PPL, + }), + query: { + dataSourceId: undefined, + }, + }); + expect(setSummary).toHaveBeenNthCalledWith(1, null); + expect(setSummary).toHaveBeenNthCalledWith(2, RESPONSE_TEXT); + expect(setLoading).toHaveBeenNthCalledWith(1, true); + expect(setLoading).toHaveBeenNthCalledWith(2, false); + }); + + it('should handle fetch summary error', async () => { + mockUseState('summary', LOADING.NO, FEEDBACK.NO); + httpMock.post.mockRejectedValueOnce({}); + renderQueryAssistSummary(COLLAPSED.NO); + question$.next(question); + query$.next({ query: PPL, language: 'PPL' }); + data$.next(dataFrame as IDataFrame); + await sleep(WAIT_TIME); + expect(setSummary).toBeCalledTimes(1); + expect(setLoading).toHaveBeenNthCalledWith(1, true); + expect(setLoading).toHaveBeenNthCalledWith(2, false); + }); + + it('should not update queryResults if subscription changed not in order', async () => { + mockUseState('summary', LOADING.NO, FEEDBACK.NO); + const RESPONSE_TEXT = 'response'; + httpMock.post.mockResolvedValue(RESPONSE_TEXT); + renderQueryAssistSummary(COLLAPSED.NO); + data$.next(dataFrame as IDataFrame); + question$.next(question); + query$.next({ query: PPL, language: 'PPL' }); + await sleep(WAIT_TIME); + expect(httpMock.post).toHaveBeenCalledTimes(0); + }); + + it('should update queryResults if subscriptions changed in order', async () => { + mockUseState('summary', LOADING.NO, FEEDBACK.NO); + const RESPONSE_TEXT = 'response'; + httpMock.post.mockResolvedValue(RESPONSE_TEXT); + renderQueryAssistSummary(COLLAPSED.NO); + question$.next(question); + query$.next({ query: PPL, language: 'PPL' }); + data$.next(dataFrame as IDataFrame); + await sleep(WAIT_TIME); + expect(httpMock.post).toHaveBeenCalledTimes(1); + data$.next(undefined); + question$.next(question); + query$.next({ query: PPL, language: 'PPL' }); + data$.next(dataFrame as IDataFrame); + await sleep(WAIT_TIME); + expect(httpMock.post).toHaveBeenCalledTimes(2); + }); + + it('should reset feedback state if re-fetch summary', async () => { + mockUseState('summary', LOADING.NO, FEEDBACK.YES); + const RESPONSE_TEXT = 'response'; + httpMock.post.mockResolvedValue(RESPONSE_TEXT); + renderQueryAssistSummary(COLLAPSED.NO); + question$.next(question); + query$.next({ query: PPL, language: 'PPL' }); + data$.next(dataFrame as IDataFrame); + await sleep(WAIT_TIME); + expect(setFeedback).toHaveBeenCalledWith(FEEDBACK.NO); + }); +}); diff --git a/src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.tsx b/src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.tsx new file mode 100644 index 00000000000..d6ad2057a77 --- /dev/null +++ b/src/plugins/query_enhancements/public/query_assist/components/query_assist_summary.tsx @@ -0,0 +1,326 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + EuiSplitPanel, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiIcon, + EuiIconTip, + EuiSmallButtonIcon, + EuiSpacer, + EuiCopy, +} from '@elastic/eui'; + +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { i18n } from '@osd/i18n'; +import { IDataFrame } from 'src/plugins/data/common'; +import { v4 as uuidv4 } from 'uuid'; +import { isEmpty } from 'lodash'; +import { merge, of } from 'rxjs'; +import { filter, distinctUntilChanged, mergeMap } from 'rxjs/operators'; +import { HttpSetup } from 'opensearch-dashboards/public'; +import { useQueryAssist } from '../hooks'; +import { DataPublicPluginSetup, QueryEditorExtensionDependencies } from '../../../../data/public'; +import { UsageCollectionSetup } from '../../../../usage_collection/public'; +import { CoreSetup } from '../../../../../core/public'; +import { QueryAssistContextType } from '../../../common/query_assist'; +import sparkleHollowSvg from '../../assets/sparkle_hollow.svg'; +import sparkleSolidSvg from '../../assets/sparkle_solid.svg'; + +export interface QueryContext { + question: string; + query: string; + queryResults: any; +} + +interface QueryAssistSummaryProps { + data: DataPublicPluginSetup; + http: HttpSetup; + usageCollection?: UsageCollectionSetup; + dependencies: QueryEditorExtensionDependencies; + core: CoreSetup; +} + +export const convertResult = (body: IDataFrame) => { + const data = body as IDataFrame; + const hits: any[] = []; + + if (data && data.fields && data.fields.length > 0) { + for (let index = 0; index < data.size; index++) { + const hit: { [key: string]: any } = {}; + data.fields.forEach((field) => { + hit[field.name] = field.values[index]; + }); + hits.push({ + _index: data.name, + _source: hit, + }); + } + } + return hits; +}; + +export const QueryAssistSummary: React.FC = (props) => { + const { query, search } = props.data; + const [summary, setSummary] = useState(null); // store fetched data + const [loading, setLoading] = useState(false); // track loading state + const [feedback, setFeedback] = useState(false); + const [isEnabledByCapability, setIsEnabledByCapability] = useState(false); + const selectedDataset = useRef(query.queryString.getQuery()?.dataset); + const { question$, isQueryAssistCollapsed } = useQueryAssist(); + const METRIC_APP = `query-assist`; + const afterFeedbackTip = i18n.translate('queryEnhancements.queryAssist.summary.afterFeedback', { + defaultMessage: + 'Thank you for the feedback. Try again by adjusting your question so that I have the opportunity to better assist you.', + }); + + const sampleSize = 10; + + const reportMetric = useCallback( + (metric: string) => { + if (props.usageCollection) { + props.usageCollection.reportUiStats( + METRIC_APP, + props.usageCollection.METRIC_TYPE.CLICK, + metric + '-' + uuidv4() + ); + } + }, + [props.usageCollection, METRIC_APP] + ); + + const reportCountMetric = useCallback( + (metric: string, count: number) => { + if (props.usageCollection) { + props.usageCollection.reportUiStats( + METRIC_APP, + props.usageCollection.METRIC_TYPE.COUNT, + metric + '-' + uuidv4(), + count + ); + } + }, + [props.usageCollection, METRIC_APP] + ); + + useEffect(() => { + const subscription = query.queryString.getUpdates$().subscribe((_query) => { + selectedDataset.current = _query?.dataset; + }); + return () => subscription.unsubscribe(); + }, [query.queryString]); + + const fetchSummary = useCallback( + async (queryContext: QueryContext) => { + if (isEmpty(queryContext?.queryResults)) return; + setLoading(true); + setSummary(null); + setFeedback(false); + const SUCCESS_METRIC = 'fetch_summary_success'; + try { + const actualSampleSize = Math.min(sampleSize, queryContext?.queryResults?.length); + const dataString = JSON.stringify(queryContext?.queryResults?.slice(0, actualSampleSize)); + const payload = `'${dataString}'`; + const response = await props.http.post('/api/assistant/data2summary', { + body: JSON.stringify({ + sample_data: payload, + sample_count: actualSampleSize, + total_count: queryContext?.queryResults?.length, + question: queryContext?.question, + ppl: queryContext?.query, + }), + query: { + dataSourceId: selectedDataset.current?.dataSource?.id, + }, + }); + setSummary(response); + reportCountMetric(SUCCESS_METRIC, 1); + } catch (error) { + reportCountMetric(SUCCESS_METRIC, 0); + } finally { + setLoading(false); + } + }, + [props.http, reportCountMetric] + ); + + useEffect(() => { + let dataStack: Array = []; + const subscription = merge( + question$.pipe( + filter((value) => !isEmpty(value)), + mergeMap((value) => of({ type: QueryAssistContextType.QUESTION as const, data: value })) + ), + query.queryString.getUpdates$().pipe( + filter((value) => !isEmpty(value)), + mergeMap((value) => of({ type: QueryAssistContextType.QUERY as const, data: value })) + ), + search.df?.df$?.pipe( + distinctUntilChanged(), + filter((value) => !isEmpty(value) && !isEmpty(value?.fields)), + mergeMap((value) => of({ type: QueryAssistContextType.DATA as const, data: value })) + ) + ).subscribe((value) => { + // to ensure we only trigger summary when user hits the query assist button with natural language input + switch (value.type) { + case QueryAssistContextType.QUESTION: + dataStack = [value.data]; + break; + case QueryAssistContextType.QUERY: + if (dataStack.length === 1) { + dataStack.push(value.data.query as string); + } + break; + case QueryAssistContextType.DATA: + if (dataStack.length === 2) { + dataStack.push(value.data); + fetchSummary({ + question: dataStack[0] as string, + query: dataStack[1] as string, + queryResults: convertResult(dataStack[2] as IDataFrame), + }); + dataStack = []; + } + break; + default: + break; + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [question$, query.queryString, search.df?.df$, fetchSummary]); + + useEffect(() => { + props.core.getStartServices().then(([coreStart, depsStart]) => { + const assistantEnabled = !!coreStart.application.capabilities?.assistant?.enabled; + setIsEnabledByCapability(assistantEnabled); + }); + }, [props.core]); + + const onFeedback = useCallback( + (satisfied: boolean) => { + if (feedback) return; + setFeedback(true); + reportMetric(satisfied ? 'thumbup' : 'thumbdown'); + }, + [feedback, reportMetric] + ); + + if (props.dependencies.isCollapsed || isQueryAssistCollapsed || !isEnabledByCapability) + return null; + const isDarkMode = props.core.uiSettings.get('theme:darkMode'); + return ( + + + + + + + + + + {i18n.translate('queryEnhancements.queryAssist.summary.panelTitle', { + defaultMessage: 'Response', + })} + + + + {summary && !loading && ( + + + + + + + onFeedback(true)} + data-test-subj="queryAssist_summary_buttons_thumbup" + /> + + + onFeedback(false)} + data-test-subj="queryAssist_summary_buttons_thumbdown" + /> + + + + + {(copy) => ( + + )} + + + + + )} + + + + {!summary && !loading && ( + + {i18n.translate('queryEnhancements.queryAssist.summary.placeholder', { + defaultMessage: `Ask a question to generate summary.`, + })} + + )} + {loading && ( + + {i18n.translate('queryEnhancements.queryAssist.summary.generating', { + defaultMessage: `Generating response...`, + })} + + )} + {summary && !loading && ( + + {summary} + + )} + + + ); +}; diff --git a/src/plugins/query_enhancements/public/query_assist/hooks/index.ts b/src/plugins/query_enhancements/public/query_assist/hooks/index.ts index a2076151efb..04bbb49bebd 100644 --- a/src/plugins/query_enhancements/public/query_assist/hooks/index.ts +++ b/src/plugins/query_enhancements/public/query_assist/hooks/index.ts @@ -4,3 +4,4 @@ */ export * from './use_generate'; +export * from './use_query_assist'; diff --git a/src/plugins/query_enhancements/public/query_assist/hooks/use_query_assist.ts b/src/plugins/query_enhancements/public/query_assist/hooks/use_query_assist.ts new file mode 100644 index 00000000000..f4cedeb201e --- /dev/null +++ b/src/plugins/query_enhancements/public/query_assist/hooks/use_query_assist.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; + +export interface QueryAssistContextValue { + question: string; + question$: BehaviorSubject; + updateQuestion: (question: string) => void; + isQueryAssistCollapsed: boolean; + updateIsQueryAssistCollapsed: (isCollapsed: boolean) => void; +} +export const QueryAssistContext = React.createContext( + {} as QueryAssistContextValue +); +export const useQueryAssist = () => React.useContext(QueryAssistContext); diff --git a/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx b/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx index 6d915d96ff0..cc0c695e8fa 100644 --- a/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx +++ b/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx @@ -45,6 +45,7 @@ queryStringMock.getUpdates$.mockReturnValue(of(mockQueryWithIndexPattern)); jest.mock('../components', () => ({ QueryAssistBar: jest.fn(() =>
QueryAssistBar
), QueryAssistBanner: jest.fn(() =>
QueryAssistBanner
), + QueryAssistSummary: jest.fn(() =>
QueryAssistSummary
), })); describe('CreateExtension', () => { @@ -61,11 +62,12 @@ describe('CreateExtension', () => { const config: ConfigSchema['queryAssist'] = { supportedLanguages: [{ language: 'PPL', agentConfig: 'os_query_assist_ppl' }], + summary: { enabled: false }, }; it('should be enabled if at least one language is configured', async () => { httpMock.get.mockResolvedValueOnce({ configuredLanguages: ['PPL'] }); - const extension = createQueryAssistExtension(httpMock, dataMock, config); + const extension = createQueryAssistExtension(coreSetupMock, dataMock, config); const isEnabled = await firstValueFrom(extension.isEnabled$(dependencies)); expect(isEnabled).toBeTruthy(); expect(httpMock.get).toBeCalledWith('/api/enhancements/assist/languages', { @@ -75,7 +77,7 @@ describe('CreateExtension', () => { it('should be disabled when there is an error', async () => { httpMock.get.mockRejectedValueOnce(new Error('network failure')); - const extension = createQueryAssistExtension(httpMock, dataMock, config); + const extension = createQueryAssistExtension(coreSetupMock, dataMock, config); const isEnabled = await firstValueFrom(extension.isEnabled$(dependencies)); expect(isEnabled).toBeFalsy(); expect(httpMock.get).toBeCalledWith('/api/enhancements/assist/languages', { @@ -85,7 +87,7 @@ describe('CreateExtension', () => { it('creates data structure meta', async () => { httpMock.get.mockResolvedValueOnce({ configuredLanguages: ['PPL'] }); - const extension = createQueryAssistExtension(httpMock, dataMock, config); + const extension = createQueryAssistExtension(coreSetupMock, dataMock, config); const meta = await extension.getDataStructureMeta?.('mock-data-source-id2'); expect(meta).toMatchInlineSnapshot(` Object { @@ -103,7 +105,7 @@ describe('CreateExtension', () => { it('does not send multiple requests for the same data source', async () => { httpMock.get.mockResolvedValueOnce({ configuredLanguages: ['PPL'] }); - const extension = createQueryAssistExtension(httpMock, dataMock, config); + const extension = createQueryAssistExtension(coreSetupMock, dataMock, config); const metas = await Promise.all( Array.from({ length: 10 }, () => extension.getDataStructureMeta?.('mock-data-source-id2')) ); @@ -114,7 +116,7 @@ describe('CreateExtension', () => { it('should render the component if language is supported', async () => { httpMock.get.mockResolvedValueOnce({ configuredLanguages: ['PPL'] }); - const extension = createQueryAssistExtension(httpMock, dataMock, config); + const extension = createQueryAssistExtension(coreSetupMock, dataMock, config); const component = extension.getComponent?.(dependencies); if (!component) throw new Error('QueryEditorExtensions Component is undefined'); @@ -128,7 +130,7 @@ describe('CreateExtension', () => { it('should render the banner if language is not supported', async () => { httpMock.get.mockResolvedValueOnce({ configuredLanguages: ['PPL'] }); - const extension = createQueryAssistExtension(httpMock, dataMock, config); + const extension = createQueryAssistExtension(coreSetupMock, dataMock, config); const banner = extension.getBanner?.({ ...dependencies, language: 'DQL', @@ -142,4 +144,36 @@ describe('CreateExtension', () => { expect(screen.getByText('QueryAssistBanner')).toBeInTheDocument(); }); + + it('should not render the summary panel if it is not enabled', async () => { + httpMock.get.mockResolvedValueOnce({ configuredLanguages: ['PPL'] }); + const extension = createQueryAssistExtension(coreSetupMock, dataMock, config); + const component = extension.getComponent?.(dependencies); + + if (!component) throw new Error('QueryEditorExtensions Component is undefined'); + + await act(async () => { + render(component); + }); + const summaryPanels = screen.queryAllByText('QueryAssistSummary'); + expect(summaryPanels).toHaveLength(0); + }); + + it('should render the summary panel if it is enabled', async () => { + httpMock.get.mockResolvedValueOnce({ configuredLanguages: ['PPL'] }); + const modifiedConfig: ConfigSchema['queryAssist'] = { + supportedLanguages: [{ language: 'PPL', agentConfig: 'os_query_assist_ppl' }], + summary: { enabled: true }, + }; + const extension = createQueryAssistExtension(coreSetupMock, dataMock, modifiedConfig); + const component = extension.getComponent?.(dependencies); + + if (!component) throw new Error('QueryEditorExtensions Component is undefined'); + + await act(async () => { + render(component); + }); + + expect(screen.getByText('QueryAssistSummary')).toBeInTheDocument(); + }); }); diff --git a/src/plugins/query_enhancements/public/query_assist/utils/create_extension.tsx b/src/plugins/query_enhancements/public/query_assist/utils/create_extension.tsx index 701a7ca94e7..447d2471402 100644 --- a/src/plugins/query_enhancements/public/query_assist/utils/create_extension.tsx +++ b/src/plugins/query_enhancements/public/query_assist/utils/create_extension.tsx @@ -6,6 +6,7 @@ import { i18n } from '@osd/i18n'; import { HttpSetup } from 'opensearch-dashboards/public'; import React, { useEffect, useState } from 'react'; +import { BehaviorSubject } from 'rxjs'; import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; import { DATA_STRUCTURE_META_TYPES, DEFAULT_DATA } from '../../../../data/common'; import { @@ -16,7 +17,15 @@ import { import { API } from '../../../common'; import { ConfigSchema } from '../../../common/config'; import assistantMark from '../../assets/query_assist_mark.svg'; -import { QueryAssistBanner, QueryAssistBar } from '../components'; +import { + QueryAssistBanner, + QueryAssistBar, + QueryAssistSummary, + QueryAssistButton, +} from '../components'; +import { UsageCollectionSetup } from '../../../../usage_collection/public'; +import { QueryAssistContext } from '../hooks/use_query_assist'; +import { CoreSetup } from '../../../../../core/public'; const [getAvailableLanguagesForDataSource, clearCache] = (() => { const availableLanguagesByDataSource: Map = new Map(); @@ -77,10 +86,14 @@ const getAvailableLanguages$ = (http: HttpSetup, data: DataPublicPluginSetup) => ); export const createQueryAssistExtension = ( - http: HttpSetup, + core: CoreSetup, data: DataPublicPluginSetup, - config: ConfigSchema['queryAssist'] + config: ConfigSchema['queryAssist'], + usageCollection?: UsageCollectionSetup ): QueryEditorExtensionConfig => { + const http: HttpSetup = core.http; + const isQueryAssistCollapsed$ = new BehaviorSubject(false); + const question$ = new BehaviorSubject(''); return { id: 'query-assist', order: 1000, @@ -103,8 +116,23 @@ export const createQueryAssistExtension = ( getComponent: (dependencies) => { // only show the component if user is on a supported language. return ( - + + {config.summary.enabled && ( + + )} ); }, @@ -119,6 +147,18 @@ export const createQueryAssistExtension = ( ); }, + getSearchBarButton: (dependencies) => { + return ( + + + + ); + }, }; }; @@ -127,10 +167,37 @@ interface QueryAssistWrapperProps { http: HttpSetup; data: DataPublicPluginSetup; invert?: boolean; + isQueryAssistCollapsed$?: BehaviorSubject; + question$?: BehaviorSubject; } const QueryAssistWrapper: React.FC = (props) => { const [visible, setVisible] = useState(false); + const [question, setQuestion] = useState(''); + const [isQueryAssistCollapsed, setIsQueryAssistCollapsed] = useState(true); + const updateQuestion = (newQuestion: string) => { + props.question$?.next(newQuestion); + }; + const question$ = props.question$; + + const updateIsQueryAssistCollapsed = (isCollapsed: boolean) => { + props.isQueryAssistCollapsed$?.next(isCollapsed); + }; + + useEffect(() => { + const subscription = props.isQueryAssistCollapsed$?.subscribe((isCollapsed) => { + setIsQueryAssistCollapsed(isCollapsed); + }); + const questionSubscription = props.question$?.subscribe((newQuestion) => { + setQuestion(newQuestion); + }); + + return () => { + questionSubscription?.unsubscribe(); + subscription?.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { let mounted = true; @@ -147,5 +214,19 @@ const QueryAssistWrapper: React.FC = (props) => { }, [props]); if (!visible) return null; - return <>{props.children}; + return ( + <> + + {props.children} + + + ); }; diff --git a/src/plugins/query_enhancements/public/types.ts b/src/plugins/query_enhancements/public/types.ts index c31da11e6b1..6b9ec95793e 100644 --- a/src/plugins/query_enhancements/public/types.ts +++ b/src/plugins/query_enhancements/public/types.ts @@ -6,6 +6,7 @@ import { CoreSetup, CoreStart } from 'opensearch-dashboards/public'; import { DataSourcePluginStart } from 'src/plugins/data_source/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface QueryEnhancementsPluginSetup {} @@ -15,6 +16,7 @@ export interface QueryEnhancementsPluginStart {} export interface QueryEnhancementsPluginSetupDependencies { data: DataPublicPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface QueryEnhancementsPluginStartDependencies { From 2213b573d4103a581cde9232ecdb6ec20a991a76 Mon Sep 17 00:00:00 2001 From: yuboluo Date: Fri, 20 Sep 2024 15:55:57 +0800 Subject: [PATCH 06/19] [Bug] Fix: use workspace type icons and compressed input (#8215) * fix home page issue Signed-off-by: yubonluo * Changeset file for PR #8215 created/updated --------- Signed-off-by: yubonluo Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8215.yml | 2 ++ .../use_case_card_title.tsx | 19 ++++++------------- 2 files changed, 8 insertions(+), 13 deletions(-) create mode 100644 changelogs/fragments/8215.yml diff --git a/changelogs/fragments/8215.yml b/changelogs/fragments/8215.yml new file mode 100644 index 00000000000..9ade384eea7 --- /dev/null +++ b/changelogs/fragments/8215.yml @@ -0,0 +1,2 @@ +fix: +- Fix: use workspace type icons and compressed input ([#8215](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8215)) \ No newline at end of file diff --git a/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx b/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx index 44d34711fef..82156d3d01a 100644 --- a/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx +++ b/src/plugins/workspace/public/components/home_get_start_card/use_case_card_title.tsx @@ -3,19 +3,19 @@ * SPDX-License-Identifier: Apache-2.0 */ +import './setup_get_start_card.scss'; import { EuiText, EuiTitle, EuiPanel, - EuiAvatar, EuiPopover, EuiFlexItem, EuiFlexGroup, - EuiFieldSearch, EuiContextMenu, EuiButtonIcon, EuiSmallButton, EuiPopoverTitle, + EuiCompressedFieldSearch, } from '@elastic/eui'; import React, { useMemo, useState } from 'react'; import { i18n } from '@osd/i18n'; @@ -111,23 +111,16 @@ export const UseCaseCardTitle = ({ filterWorkspaces, useCase, core }: UseCaseCar return { name: ( - + {workspaceName} ), key: workspace.id, - icon: ( - - ), + icon: useCase.icon || 'logoOpenSearch', onClick: () => { window.location.assign(useCaseUrl); }, + className: 'homeGettingStartedWorkspaceCardsIcon', }; }; const panels = [ @@ -158,7 +151,7 @@ export const UseCaseCardTitle = ({ filterWorkspaces, useCase, core }: UseCaseCar })} - Date: Fri, 20 Sep 2024 17:42:37 +0800 Subject: [PATCH 07/19] [Workspace] Integrate workspace front end with data connection type saved object (#8013) * feat: integrate workspace with data connections Signed-off-by: tygao * update workspace pages and hooks to integrate with data connection Signed-off-by: tygao * Changeset file for PR #8013 created/updated * remove extra comments Signed-off-by: tygao * update data source import Signed-off-by: tygao * test: update tests Signed-off-by: tygao * Changeset file for PR #8013 created/updated * unify connection type icon and connectionType Signed-off-by: tygao * test: add tests Signed-off-by: tygao * display text directly instead of link for data connection in table Signed-off-by: tygao * Apply suggestions from code review Co-authored-by: SuZhou-Joe Signed-off-by: Tianyu Gao * update Signed-off-by: tygao --------- Signed-off-by: tygao Signed-off-by: Tianyu Gao Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: SuZhou-Joe --- changelogs/fragments/8013.yml | 2 + src/plugins/data_source/common/index.ts | 2 +- src/plugins/workspace/common/types.ts | 10 + .../public/assets/cloudwatch_logo.svg | 9 + .../public/assets/security_lake_logo.svg | 9 + .../workspace_creator.test.tsx | 81 ++++++++ .../workspace_creator/workspace_creator.tsx | 8 + .../association_data_source_modal.test.tsx | 28 +++ .../association_data_source_modal.tsx | 18 +- .../workspace_detail_connection_table.tsx | 3 +- .../connection_type_icon.test.tsx.snap | 17 ++ .../connection_type_icon.test.tsx | 16 ++ .../workspace_form/connection_type_icon.tsx | 29 +++ .../data_source_connection_table.tsx | 10 +- .../direct_query_connection_icon.tsx | 21 -- .../public/components/workspace_form/index.ts | 2 +- src/plugins/workspace/public/utils.test.ts | 179 +++++++++++++++++- src/plugins/workspace/public/utils.ts | 96 +++++++--- 18 files changed, 485 insertions(+), 55 deletions(-) create mode 100644 changelogs/fragments/8013.yml create mode 100644 src/plugins/workspace/public/assets/cloudwatch_logo.svg create mode 100644 src/plugins/workspace/public/assets/security_lake_logo.svg create mode 100644 src/plugins/workspace/public/components/workspace_form/__snapshots__/connection_type_icon.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/workspace_form/connection_type_icon.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/connection_type_icon.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_form/direct_query_connection_icon.tsx diff --git a/changelogs/fragments/8013.yml b/changelogs/fragments/8013.yml new file mode 100644 index 00000000000..607966b614f --- /dev/null +++ b/changelogs/fragments/8013.yml @@ -0,0 +1,2 @@ +feat: +- Integrate workspace with data connections in front end ([#8013](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8013)) \ No newline at end of file diff --git a/src/plugins/data_source/common/index.ts b/src/plugins/data_source/common/index.ts index 4a35e5a483f..856ca5b7d80 100644 --- a/src/plugins/data_source/common/index.ts +++ b/src/plugins/data_source/common/index.ts @@ -6,4 +6,4 @@ export const PLUGIN_ID = 'dataSource'; export const PLUGIN_NAME = 'data_source'; export const DATA_SOURCE_SAVED_OBJECT_TYPE = 'data-source'; -export { DATA_CONNECTION_SAVED_OBJECT_TYPE } from './data_connections'; +export { DATA_CONNECTION_SAVED_OBJECT_TYPE, DataConnectionType } from './data_connections'; diff --git a/src/plugins/workspace/common/types.ts b/src/plugins/workspace/common/types.ts index 50b9e838038..220192029c3 100644 --- a/src/plugins/workspace/common/types.ts +++ b/src/plugins/workspace/common/types.ts @@ -4,6 +4,8 @@ */ import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; +import { DataConnectionSavedObjectAttributes } from 'src/plugins/data_source/common/data_connections'; +import { DataConnectionType } from '../../data_source/common'; export type DataSource = Pick< DataSourceAttributes, @@ -13,6 +15,14 @@ export type DataSource = Pick< id: string; }; +export type DataConnection = Pick & { + type: string; + id: string; + connectionType: DataConnectionType; + description?: string; + title: string; +}; + export enum DataSourceConnectionType { OpenSearchConnection, DirectQueryConnection, diff --git a/src/plugins/workspace/public/assets/cloudwatch_logo.svg b/src/plugins/workspace/public/assets/cloudwatch_logo.svg new file mode 100644 index 00000000000..4331ae3eb4f --- /dev/null +++ b/src/plugins/workspace/public/assets/cloudwatch_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/plugins/workspace/public/assets/security_lake_logo.svg b/src/plugins/workspace/public/assets/security_lake_logo.svg new file mode 100644 index 00000000000..47e1152a6e7 --- /dev/null +++ b/src/plugins/workspace/public/assets/security_lake_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx index 4efbc30680c..405d7d3b70a 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -55,6 +55,19 @@ const dataSourcesList = [ return 'ds2'; }, }, + { + id: 'id3', + title: 'dqs1', + description: 'Description of data connection 1', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], + type: 'data-connection', + connectionType: 'AWS Security Lake', + get: () => { + return 'ds2'; + }, + }, ]; const dataSourceConnectionsList = [ @@ -71,6 +84,13 @@ const dataSourceConnectionsList = [ connectionType: DataSourceConnectionType.OpenSearchConnection, type: 'OpenSearch', }, + { + id: 'id3', + name: 'dqs1', + description: 'Description of data connection 1', + connectionType: DataSourceConnectionType.DataConnection, + type: 'AWS Security Lake', + }, ]; const mockCoreStart = coreMock.createStart(); @@ -243,6 +263,7 @@ describe('WorkspaceCreator', () => { }), { dataSources: [], + dataConnections: [], permissions: { library_write: { users: ['%me%'] }, write: { users: ['%me%'] }, @@ -318,6 +339,7 @@ describe('WorkspaceCreator', () => { name: 'test workspace name', }), { + dataConnections: [], dataSources: [], permissions: { write: { @@ -375,6 +397,7 @@ describe('WorkspaceCreator', () => { name: 'test workspace name', }), { + dataConnections: [], dataSources: ['id1'], permissions: { library_write: { @@ -392,6 +415,64 @@ describe('WorkspaceCreator', () => { expect(notificationToastsAddDanger).not.toHaveBeenCalled(); }); + it('create workspace with customized selected data connections', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByTestId, getAllByText, getByText } = render( + + ); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceUseCase-observability')); + fireEvent.click(getByTestId('workspace-creator-dqc-assign-button')); + await waitFor(() => { + expect( + getByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeInTheDocument(); + expect(getByText(dataSourcesList[2].title)).toBeInTheDocument(); + }); + fireEvent.click(getByText(dataSourcesList[2].title)); + fireEvent.click(getAllByText('Associate data sources')[1]); + + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + }), + { + dataConnections: ['id3'], + dataSources: [], + permissions: { + library_write: { + users: ['%me%'], + }, + write: { + users: ['%me%'], + }, + }, + } + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + it('should not create workspace API when submitting', async () => { workspaceClientCreate.mockImplementationOnce( () => diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index ed4370a7b3f..13b868c164c 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -80,8 +80,16 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { .map(({ id }) => { return id; }); + const selectedDataConnectionIds = (selectedDataSourceConnections ?? []) + .filter( + ({ connectionType }) => connectionType === DataSourceConnectionType.DataConnection + ) + .map(({ id }) => { + return id; + }); result = await workspaceClient.create(attributes, { dataSources: selectedDataSourceIds, + dataConnections: selectedDataConnectionIds, permissions: convertPermissionSettingsToPermissions(permissionSettings), }); if (result?.success) { diff --git a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.test.tsx b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.test.tsx index 7a35d449730..a1bf6502e7b 100644 --- a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.test.tsx @@ -52,6 +52,12 @@ const setupAssociationDataSourceModal = ({ connectionType: DataSourceConnectionType.OpenSearchConnection, type: 'OpenSearch', }, + { + id: 'dqs1', + name: 'Data Connection 1', + connectionType: DataSourceConnectionType.DataConnection, + type: 'AWS Security Lake', + }, ]); const { logos } = chromeServiceMock.createStartContract(); render( @@ -178,4 +184,26 @@ describe('AssociationDataSourceModal', () => { }, ]); }); + + it('should call handleAssignDataSourceConnections with data connections after assigned', async () => { + const handleAssignDataSourceConnectionsMock = jest.fn(); + setupAssociationDataSourceModal({ + handleAssignDataSourceConnections: handleAssignDataSourceConnectionsMock, + mode: AssociationDataSourceModalMode.DirectQueryConnections, + }); + + await waitFor(() => { + fireEvent.click(screen.getByRole('option', { name: 'Data Connection 1' })); + fireEvent.click(screen.getByRole('button', { name: 'Associate data sources' })); + }); + + expect(handleAssignDataSourceConnectionsMock).toHaveBeenCalledWith([ + { + id: 'dqs1', + name: 'Data Connection 1', + connectionType: DataSourceConnectionType.DataConnection, + type: 'AWS Security Lake', + }, + ]); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx index f573a46487d..eacef4f0deb 100644 --- a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx @@ -30,7 +30,7 @@ import { DataSourceConnection, DataSourceConnectionType } from '../../../common/ import { HttpStart, NotificationsStart, SavedObjectsStart } from '../../../../../core/public'; import { AssociationDataSourceModalMode } from '../../../common/constants'; import { Logos } from '../../../../../core/common'; -import { DirectQueryConnectionIcon } from '../workspace_form'; +import { ConnectionTypeIcon } from '../workspace_form'; const ConnectionIcon = ({ connection: { connectionType, type }, @@ -42,9 +42,13 @@ const ConnectionIcon = ({ if (connectionType === DataSourceConnectionType.OpenSearchConnection) { return ; } - if (connectionType === DataSourceConnectionType.DirectQueryConnection) { - return ; + if ( + connectionType === DataSourceConnectionType.DirectQueryConnection || + connectionType === DataSourceConnectionType.DataConnection + ) { + return ; } + return null; }; @@ -143,6 +147,14 @@ const convertConnectionsToOptions = ({ ) { return []; } + + if (connection.connectionType === DataSourceConnectionType.DataConnection) { + if (showDirectQueryConnections) { + return [connection]; + } + return []; + } + if (showDirectQueryConnections) { if (!connection.relatedConnections || connection.relatedConnections.length === 0) { return []; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_connection_table.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_connection_table.tsx index 48ff7ee4408..d30fd9252e5 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_connection_table.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_connection_table.tsx @@ -36,7 +36,8 @@ export const WorkspaceDetailConnectionTable = ({ return dataSourceConnections.filter((dsc) => connectionType === AssociationDataSourceModalMode.OpenSearchConnections ? dsc.connectionType === DataSourceConnectionType.OpenSearchConnection - : dsc?.relatedConnections && dsc.relatedConnections?.length > 0 + : dsc.connectionType === DataSourceConnectionType.DataConnection || + (dsc?.relatedConnections && dsc.relatedConnections?.length > 0) ); }, [connectionType, dataSourceConnections]); diff --git a/src/plugins/workspace/public/components/workspace_form/__snapshots__/connection_type_icon.test.tsx.snap b/src/plugins/workspace/public/components/workspace_form/__snapshots__/connection_type_icon.test.tsx.snap new file mode 100644 index 00000000000..04c42042988 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/__snapshots__/connection_type_icon.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionTypeIcon should render normally 1`] = ``; + +exports[`ConnectionTypeIcon should render normally 2`] = ` + + + + + +`; diff --git a/src/plugins/workspace/public/components/workspace_form/connection_type_icon.test.tsx b/src/plugins/workspace/public/components/workspace_form/connection_type_icon.test.tsx new file mode 100644 index 00000000000..569dc9b8c3d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/connection_type_icon.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { ConnectionTypeIcon } from './connection_type_icon'; + +describe('ConnectionTypeIcon', () => { + it('should render normally', () => { + expect(mount()).toMatchSnapshot(); + expect(mount()).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/connection_type_icon.tsx b/src/plugins/workspace/public/components/workspace_form/connection_type_icon.tsx new file mode 100644 index 00000000000..1d3b275453b --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/connection_type_icon.tsx @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiIcon } from '@elastic/eui'; + +import prometheusLogo from '../../assets/prometheus_logo.svg'; +import s3Logo from '../../assets/s3_logo.svg'; +import cloudWatchLogo from '../../assets/cloudwatch_logo.svg'; +import securityLakeLogo from '../../assets/security_lake_logo.svg'; +import { DataConnectionType } from '../../../../data_source/common/'; + +// Direct query connection and data connection both have different types, each type has a corresponding icon +export const ConnectionTypeIcon = ({ type }: { type?: string }) => { + switch (type) { + case 'Amazon S3': + return ; + case 'Prometheus': + return ; + case DataConnectionType.CloudWatch: + return ; + case DataConnectionType.SecurityLake: + return ; + default: + return null; + } +}; diff --git a/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx index 395b349a5cc..18c9fe308c9 100644 --- a/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx +++ b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx @@ -22,7 +22,7 @@ import { import { i18n } from '@osd/i18n'; import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { AssociationDataSourceModalMode } from '../../../common/constants'; -import { DirectQueryConnectionIcon } from './direct_query_connection_icon'; +import { ConnectionTypeIcon } from './connection_type_icon'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { CoreStart } from '../../../../../core/public'; @@ -115,6 +115,10 @@ export const DataSourceConnectionTable = forwardRef< }), truncateText: true, render: (name: string, record) => { + // There is not a detail page for data connection, so we won't display a link here. + if (record.connectionType === DataSourceConnectionType.DataConnection) { + return name; + } let url: string; if (record.connectionType === DataSourceConnectionType.OpenSearchConnection) { url = http.basePath.prepend(`/app/dataSources/${record.id}`); @@ -167,7 +171,7 @@ export const DataSourceConnectionTable = forwardRef< togglePopover(record.id)} > @@ -191,7 +195,7 @@ export const DataSourceConnectionTable = forwardRef< key={item.id} size="xs" label={item.name} - icon={} + icon={} style={{ maxHeight: '30px' }} /> ))} diff --git a/src/plugins/workspace/public/components/workspace_form/direct_query_connection_icon.tsx b/src/plugins/workspace/public/components/workspace_form/direct_query_connection_icon.tsx deleted file mode 100644 index 34dd2bbde93..00000000000 --- a/src/plugins/workspace/public/components/workspace_form/direct_query_connection_icon.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { EuiIcon } from '@elastic/eui'; - -import prometheusLogo from '../../assets/prometheus_logo.svg'; -import s3Logo from '../../assets/s3_logo.svg'; - -export const DirectQueryConnectionIcon = ({ type }: { type?: string }) => { - switch (type) { - case 'Amazon S3': - return ; - case 'Prometheus': - return ; - default: - return null; - } -}; diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index 2fae243a37a..225d17bfe54 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -10,7 +10,7 @@ export { WorkspaceUseCase } from './workspace_use_case'; export { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; export { WorkspaceCancelModal } from './workspace_cancel_modal'; export { WorkspaceNameField, WorkspaceDescriptionField } from './fields'; -export { DirectQueryConnectionIcon } from './direct_query_connection_icon'; +export { ConnectionTypeIcon } from './connection_type_icon'; export { DataSourceConnectionTable } from './data_source_connection_table'; export { WorkspaceFormSubmitData, WorkspaceFormProps, WorkspaceFormDataState } from './types'; diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 5f6b28ee59e..f6b5ff28d92 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -15,12 +15,21 @@ import { isEqualWorkspaceUseCase, prependWorkspaceToBreadcrumbs, getIsOnlyAllowEssentialUseCase, + mergeDataSourcesWithConnections, } from './utils'; import { WorkspaceAvailability } from '../../../core/public'; import { coreMock } from '../../../core/public/mocks'; import { WORKSPACE_DETAIL_APP_ID, USE_CASE_PREFIX } from '../common/constants'; -import { SigV4ServiceName } from '../../../plugins/data_source/common/data_sources'; +import { + SigV4ServiceName, + DataSourceEngineType, +} from '../../../plugins/data_source/common/data_sources'; import { createMockedRegisteredUseCases } from './mocks'; +import { + DATA_SOURCE_SAVED_OBJECT_TYPE, + DATA_CONNECTION_SAVED_OBJECT_TYPE, +} from '../../data_source/common'; +import { DataSourceConnectionType } from '../common/types'; const startMock = coreMock.createStart(); const STATIC_USE_CASES = createMockedRegisteredUseCases(); @@ -375,6 +384,7 @@ describe('workspace utils: getDataSourcesList', () => { savedObjects: [ { id: 'id1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, get: (param: string) => { switch (param) { case 'title': @@ -383,6 +393,8 @@ describe('workspace utils: getDataSourcesList', () => { return 'description1'; case 'dataSourceEngineType': return 'dataSourceEngineType1'; + case 'type': + return 'connectionType1'; case 'auth': return 'mock_value'; } @@ -394,9 +406,66 @@ describe('workspace utils: getDataSourcesList', () => { { id: 'id1', title: 'title1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, auth: 'mock_value', description: 'description1', dataSourceEngineType: 'dataSourceEngineType1', + connectionType: 'connectionType1', + workspaces: [], + }, + ]); + }); + + it('should return title for data source object and connectionId as title for data connection object', async () => { + mockedSavedObjectClient.find = jest.fn().mockResolvedValue({ + savedObjects: [ + { + id: 'id1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + get: (param: string) => { + switch (param) { + case 'title': + return 'title1'; + default: + return 'mock_value'; + } + }, + }, + { + id: 'id2', + type: DATA_CONNECTION_SAVED_OBJECT_TYPE, + get: (param: string) => { + switch (param) { + case 'connectionId': + return 'connectionId1'; + case 'title': + return undefined; + default: + return 'mock_value'; + } + }, + }, + ], + }); + expect(await getDataSourcesList(mockedSavedObjectClient, [])).toStrictEqual([ + { + id: 'id1', + title: 'title1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + auth: 'mock_value', + description: 'mock_value', + dataSourceEngineType: 'mock_value', + connectionType: 'mock_value', + workspaces: [], + }, + { + id: 'id2', + title: 'connectionId1', + type: DATA_CONNECTION_SAVED_OBJECT_TYPE, + auth: 'mock_value', + description: 'mock_value', + dataSourceEngineType: 'mock_value', + connectionType: 'mock_value', workspaces: [], }, ]); @@ -742,3 +811,111 @@ describe('workspace utils: prependWorkspaceToBreadcrumbs', () => { expect(enrichedBreadcrumbs?.[1].text).toEqual(workspaceWithAllUseCase.name); }); }); + +describe('workspace utils: mergeDataSourcesWithConnections', () => { + it('should merge data sources with direct query connections', () => { + const dataSources = [ + { + id: 'id1', + title: 'title1', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + dataSourceEngineType: 'OpenSearch' as DataSourceEngineType, + description: '', + }, + { + id: 'id2', + title: 'title2', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + dataSourceEngineType: 'OpenSearch' as DataSourceEngineType, + description: '', + }, + ]; + const directQueryConnections = [ + { + id: 'id3', + title: 'title3', + name: 'name1', + parentId: 'id1', + description: 'direct_query_connections_1', + type: 'Amazon S3', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }, + ]; + const result = mergeDataSourcesWithConnections(dataSources, directQueryConnections); + expect(result).toStrictEqual([ + { + connectionType: 1, + id: 'id3', + name: 'name1', + parentId: 'id1', + title: 'title3', + description: 'direct_query_connections_1', + type: 'Amazon S3', + }, + { + connectionType: 0, + description: '', + id: 'id1', + name: 'title1', + relatedConnections: [ + { + connectionType: 1, + id: 'id3', + name: 'name1', + parentId: 'id1', + title: 'title3', + type: 'Amazon S3', + description: 'direct_query_connections_1', + }, + ], + type: 'OpenSearch', + }, + { + connectionType: 0, + description: '', + id: 'id2', + name: 'title2', + relatedConnections: [], + type: 'OpenSearch', + }, + ]); + }); + + it('should not merge data sources or data connections if no direct query connections', () => { + const dataSources = [ + { + id: 'id1', + title: 'title1', + dataSourceEngineType: 'OpenSearch' as DataSourceEngineType, + description: '', + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + }, + { + id: 'id2', + title: 'title2', + type: DATA_CONNECTION_SAVED_OBJECT_TYPE, + connectionType: 'AWS CloudWatch', + description: '', + }, + ]; + + const result = mergeDataSourcesWithConnections(dataSources, []); + expect(result).toStrictEqual([ + { + connectionType: 0, + description: '', + id: 'id1', + name: 'title1', + relatedConnections: [], + type: 'OpenSearch', + }, + { + connectionType: 2, + description: '', + id: 'id2', + name: 'title2', + type: 'AWS CloudWatch', + }, + ]); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 49132b64f46..8dd5a0b1d24 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -36,7 +36,12 @@ import { DATACONNECTIONS_BASE, DatasourceTypeToDisplayName, } from '../../data_source_management/public'; -import { DataSource, DataSourceConnection, DataSourceConnectionType } from '../common/types'; +import { + DataSource, + DataSourceConnection, + DataSourceConnectionType, + DataConnection, +} from '../common/types'; import { ANALYTICS_ALL_OVERVIEW_PAGE_ID, ESSENTIAL_OVERVIEW_PAGE_ID, @@ -44,6 +49,11 @@ import { SEARCH_OVERVIEW_PAGE_ID, SECURITY_ANALYTICS_OVERVIEW_PAGE_ID, } from '../../../plugins/content_management/public'; +import { WORKSPACE_DATA_SOURCE_AND_CONNECTION_OBJECT_TYPES } from '../common/constants'; +import { + DATA_SOURCE_SAVED_OBJECT_TYPE, + DATA_CONNECTION_SAVED_OBJECT_TYPE, +} from '../../data_source/common'; export const isUseCaseFeatureConfig = (featureConfig: string) => featureConfig.startsWith(USE_CASE_PREFIX); @@ -220,8 +230,16 @@ export const getDataSourcesList = ( ) => { return client .find({ - type: 'data-source', - fields: ['id', 'title', 'auth', 'description', 'dataSourceEngineType'], + type: WORKSPACE_DATA_SOURCE_AND_CONNECTION_OBJECT_TYPES, + fields: [ + 'id', + 'title', + 'auth', + 'description', + 'dataSourceEngineType', + 'type', + 'connectionId', + ], perPage: 10000, workspaces: targetWorkspaces, }) @@ -230,11 +248,15 @@ export const getDataSourcesList = ( if (objects) { return objects.map((source) => { const id = source.id; - const title = source.get('title'); + // Data connection doesn't have title for now, would use connectionId instead to display. + const title = source.get('title') ?? source.get('connectionId') ?? ''; const workspaces = source.workspaces ?? []; const auth = source.get('auth'); const description = source.get('description'); const dataSourceEngineType = source.get('dataSourceEngineType'); + const type = source.type; + // This is a field only for detail type of data connection in order not to mix with saved object type. + const connectionType = source.get('type'); return { id, title, @@ -242,6 +264,8 @@ export const getDataSourcesList = ( description, dataSourceEngineType, workspaces, + type, + connectionType, }; }); } else { @@ -266,19 +290,37 @@ export const getDirectQueryConnections = async (dataSourceId: string, http: Http return directQueryConnections; }; -export const convertDataSourcesToOpenSearchConnections = ( - dataSources: DataSource[] -): DataSourceConnection[] => - dataSources.map((ds) => { - return { - id: ds.id, - type: ds.dataSourceEngineType, - connectionType: DataSourceConnectionType.OpenSearchConnection, - name: ds.title, - description: ds.description, - relatedConnections: [], - }; - }); +export const convertDataSourcesToOpenSearchAndDataConnections = ( + dataSources: DataConnection[] | DataSource[] +): Record<'openSearchConnections' | 'dataConnections', DataSourceConnection[]> => { + const openSearchConnections = dataSources + .filter((ds) => ds.type === DATA_SOURCE_SAVED_OBJECT_TYPE) + .map((ds: DataSource) => { + return { + id: ds.id, + type: ds.dataSourceEngineType, + connectionType: DataSourceConnectionType.OpenSearchConnection, + name: ds.title, + description: ds.description, + relatedConnections: [], + }; + }); + const dataConnections = dataSources + .filter((ds) => ds.type === DATA_CONNECTION_SAVED_OBJECT_TYPE) + .map((ds) => { + return { + id: ds.id, + type: (ds as DataConnection).connectionType, + connectionType: DataSourceConnectionType.DataConnection, + name: ds.title, + description: ds.description, + }; + }); + return { + openSearchConnections, + dataConnections, + }; +}; export const fulfillRelatedConnections = ( connections: DataSourceConnection[], @@ -297,15 +339,20 @@ export const fulfillRelatedConnections = ( // Helper function to merge data sources with direct query connections export const mergeDataSourcesWithConnections = ( - dataSources: DataSource[], + dataSources: DataSource[] | DataConnection[], directQueryConnections: DataSourceConnection[] ): DataSourceConnection[] => { - const openSearchConnections = convertDataSourcesToOpenSearchConnections(dataSources); - - return [ + const { + openSearchConnections, + dataConnections, + } = convertDataSourcesToOpenSearchAndDataConnections(dataSources); + const result = [ ...fulfillRelatedConnections(openSearchConnections, directQueryConnections), ...directQueryConnections, + ...dataConnections, ].sort((a, b) => a.name.localeCompare(b.name)); + + return result; }; // If all connected data sources are serverless, will only allow to select essential use case. @@ -505,16 +552,17 @@ export const fetchDataSourceConnectionsByDataSourceIds = async ( }; export const fetchDataSourceConnections = async ( - assignedDataSources: DataSource[], + dataSources: DataSource[], http: HttpSetup | undefined, notifications: NotificationsStart | undefined ) => { try { const directQueryConnections = await fetchDataSourceConnectionsByDataSourceIds( - assignedDataSources.map((ds) => ds.id), + // Only data source saved object type needs to fetch data source connections, data connection type object not. + dataSources.filter((ds) => ds.type === DATA_SOURCE_SAVED_OBJECT_TYPE).map((ds) => ds.id), http ); - return mergeDataSourcesWithConnections(assignedDataSources, directQueryConnections); + return mergeDataSourcesWithConnections(dataSources, directQueryConnections); } catch (error) { notifications?.toasts.addDanger( i18n.translate('workspace.detail.dataSources.error.message', { From ea8d5c46c474b300973caebe35a34ac26fbdb266 Mon Sep 17 00:00:00 2001 From: yuboluo Date: Fri, 20 Sep 2024 18:22:47 +0800 Subject: [PATCH 08/19] [Bug] Fix initial page UI issues (#8217) * fix initial page ui issue Signed-off-by: yubonluo * Changeset file for PR #8217 created/updated * fix test error Signed-off-by: yubonluo --------- Signed-off-by: yubonluo Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8217.yml | 2 + .../workspace_initial.test.tsx.snap | 126 +++++++++--------- .../workspace_initial/workspace_initial.tsx | 31 ++--- 3 files changed, 78 insertions(+), 81 deletions(-) create mode 100644 changelogs/fragments/8217.yml diff --git a/changelogs/fragments/8217.yml b/changelogs/fragments/8217.yml new file mode 100644 index 00000000000..ae85b91ecd4 --- /dev/null +++ b/changelogs/fragments/8217.yml @@ -0,0 +1,2 @@ +fix: +- Fix initial page UI issues ([#8217](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8217)) \ No newline at end of file diff --git a/src/plugins/workspace/public/components/workspace_initial/__snapshots__/workspace_initial.test.tsx.snap b/src/plugins/workspace/public/components/workspace_initial/__snapshots__/workspace_initial.test.tsx.snap index 5f294beb33a..d2e81457be1 100644 --- a/src/plugins/workspace/public/components/workspace_initial/__snapshots__/workspace_initial.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_initial/__snapshots__/workspace_initial.test.tsx.snap @@ -10,7 +10,8 @@ exports[`WorkspaceInitial render workspace initial page normally when theme is d class="euiPageBody euiPageBody--borderRadiusNone" >
- Create a workspace to get started + Getting started with OpenSearch
- Welcome to OpenSearch! This interface supports you to easily explore, enrich and visualize your data with developer-friendly tools and powerful integrations for machine learning, data process, and more. To begin, create a workspace for your use case. + OpenSearch is a flexible, scalable, open-source way to build solutions for data-intensive search and analytics applications. Explore, enrich, and visualize your data, using developer-friendly tools and powerful integrations for machine learning, data processing, and more.
@@ -313,6 +308,9 @@ exports[`WorkspaceInitial render workspace initial page normally when theme is d
+
@@ -657,6 +650,9 @@ exports[`WorkspaceInitial render workspace initial page normally when user is da +
@@ -987,6 +978,9 @@ exports[`WorkspaceInitial render workspace initial page normally when user is no +
, } } - className="euiHeaderSectionItemButton newAppTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l" + className="euiHeaderSectionItemButton newAppTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl" color="text" data-test-subj="toggleNavButton" flush="both" @@ -10503,7 +10503,7 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` aria-expanded={false} aria-label="Toggle primary navigation" aria-pressed={false} - className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--flushBoth euiHeaderSectionItemButton newAppTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l" + className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--flushBoth euiHeaderSectionItemButton newAppTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl" data-test-subj="toggleNavButton" disabled={false} isSmallScreen={true} @@ -19316,7 +19316,7 @@ exports[`Header renders page header with application title 1`] = ` aria-expanded={false} aria-label="Toggle primary navigation" aria-pressed={false} - className="newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l" + className="newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl" data-test-subj="toggleNavButton" flush="both" isSmallScreen={true} @@ -19334,7 +19334,7 @@ exports[`Header renders page header with application title 1`] = ` aria-expanded="false" aria-label="Toggle primary navigation" aria-pressed="false" - class="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--flushBoth euiHeaderSectionItemButton newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l" + class="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--flushBoth euiHeaderSectionItemButton newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl" data-test-subj="toggleNavButton" type="button" > @@ -19365,7 +19365,7 @@ exports[`Header renders page header with application title 1`] = ` , } } - className="euiHeaderSectionItemButton newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l" + className="euiHeaderSectionItemButton newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl" color="text" data-test-subj="toggleNavButton" flush="both" @@ -19377,7 +19377,7 @@ exports[`Header renders page header with application title 1`] = ` aria-expanded={false} aria-label="Toggle primary navigation" aria-pressed={false} - className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--flushBoth euiHeaderSectionItemButton newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l" + className="euiButtonEmpty euiButtonEmpty--text euiButtonEmpty--flushBoth euiHeaderSectionItemButton newPageTopNavExpander navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl" data-test-subj="toggleNavButton" disabled={false} isSmallScreen={true} diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx index 5908d78a6de..d08e2a413d4 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -11,6 +11,7 @@ import { EuiSpacer, EuiHideFor, EuiFlyoutProps, + EuiShowFor, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React, { useMemo } from 'react'; @@ -368,7 +369,7 @@ export function CollapsibleNavGroupEnabled({ return ( <> {rendeLeftNav()} - + {isNavOpen ? rendeLeftNav({ type: 'overlay', @@ -378,7 +379,7 @@ export function CollapsibleNavGroupEnabled({ ownFocus: true, }) : null} - + ); } diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 556fb830a74..bdfa8d8ec30 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -282,7 +282,8 @@ export function Header({ })} {renderNavToggleWithExtraProps({ flush: 'both', - className: 'navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l', + className: + 'navToggleInSmallScreen eui-hideFor--xl eui-hideFor--l eui-hideFor--xxl eui-hideFor--xxxl', isSmallScreen: true, })} From 9a88bf5f95ebc07aacb24fbbeee270917e5474d1 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Wed, 25 Sep 2024 01:24:10 +0800 Subject: [PATCH 19/19] [navigation]fix: optimize the logic to detect current nav group (#8189) * fix: optimize the logic to detect current nav group Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * Changeset file for PR #8189 created/updated --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8189.yml | 3 + .../nav_group/nav_group_service.test.ts | 88 ++++++++++++++++++- .../chrome/nav_group/nav_group_service.ts | 36 ++++++-- 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 changelogs/fragments/8189.yml diff --git a/changelogs/fragments/8189.yml b/changelogs/fragments/8189.yml new file mode 100644 index 00000000000..fc93e6127a5 --- /dev/null +++ b/changelogs/fragments/8189.yml @@ -0,0 +1,3 @@ +fix: +- Current nav group will be mapped to global system nav group even if user is in a workspace. ([#8189](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8189)) +- Current nav group will be mapped to a nav group even when user is out of a workspace. ([#8189](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8189)) \ No newline at end of file diff --git a/src/core/public/chrome/nav_group/nav_group_service.test.ts b/src/core/public/chrome/nav_group/nav_group_service.test.ts index 06712058fb2..1cda022e750 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.test.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.test.ts @@ -326,7 +326,7 @@ describe('ChromeNavGroupService#start()', () => { expect(currentNavGroup).toBeUndefined(); }); - it('should set current nav group automatically if application only belongs 1 nav group', async () => { + it('should set current nav group automatically if application only belongs to 1 visible nav group', async () => { const uiSettings = uiSettingsServiceMock.createSetupContract(); const navGroupEnabled$ = new Rx.BehaviorSubject(true); uiSettings.get$.mockImplementation(() => navGroupEnabled$); @@ -424,6 +424,39 @@ describe('ChromeNavGroupService#start()', () => { expect(currentNavGroup?.id).toEqual('bar-group'); }); + it('should be able to find the right nav group when visible nav group length is 1 and is not all nav group', async () => { + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const navGroupEnabled$ = new Rx.BehaviorSubject(true); + uiSettings.get$.mockImplementation(() => navGroupEnabled$); + + const chromeNavGroupService = new ChromeNavGroupService(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup( + { + id: 'foo', + title: 'fooGroupTitle', + description: 'foo description', + }, + [mockedNavLinkFoo] + ); + + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + application: mockedApplicationService, + breadcrumbsEnricher$: new Rx.BehaviorSubject(undefined), + workspaces: workspacesServiceMock.createStartContract(), + }); + mockedApplicationService.navigateToApp(mockedNavLinkFoo.id); + + const currentNavGroup = await chromeNavGroupServiceStart + .getCurrentNavGroup$() + .pipe(first()) + .toPromise(); + + expect(currentNavGroup?.id).toEqual('foo'); + }); + it('should erase current nav group if application can not be found in any of the visible nav groups', async () => { const uiSettings = uiSettingsServiceMock.createSetupContract(); const navGroupEnabled$ = new Rx.BehaviorSubject(true); @@ -469,6 +502,59 @@ describe('ChromeNavGroupService#start()', () => { expect(currentNavGroup).toBeFalsy(); }); + it('should erase current nav group if application can only be found in use case but outside workspace', async () => { + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const navGroupEnabled$ = new Rx.BehaviorSubject(true); + uiSettings.get$.mockImplementation(() => navGroupEnabled$); + + const chromeNavGroupService = new ChromeNavGroupService(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup( + { + id: 'foo-group', + title: 'fooGroupTitle', + description: 'foo description', + }, + [mockedNavLinkFoo] + ); + + chromeNavGroupServiceSetup.addNavLinksToGroup( + { + id: 'bar-group', + title: 'barGroupTitle', + description: 'bar description', + }, + [mockedNavLinkFoo, mockedNavLinkBar] + ); + + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + application: { + ...mockedApplicationService, + capabilities: Object.freeze({ + ...mockedApplicationService.capabilities, + workspaces: { + ...mockedApplicationService.capabilities.workspaces, + enabled: true, + }, + }), + }, + breadcrumbsEnricher$: new Rx.BehaviorSubject(undefined), + workspaces: workspacesServiceMock.createStartContract(), + }); + + chromeNavGroupServiceStart.setCurrentNavGroup('foo-group'); + + mockedApplicationService.navigateToApp(mockedNavLinkBar.id); + const currentNavGroup = await chromeNavGroupServiceStart + .getCurrentNavGroup$() + .pipe(first()) + .toPromise(); + + expect(currentNavGroup).toBeFalsy(); + }); + it('should set breadcrumbs enricher when nav group is enabled', async () => { const uiSettings = uiSettingsServiceMock.createSetupContract(); const navGroupEnabled$ = new Rx.BehaviorSubject(true); diff --git a/src/core/public/chrome/nav_group/nav_group_service.ts b/src/core/public/chrome/nav_group/nav_group_service.ts index 729239081b4..23e7d5ed794 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.ts @@ -21,7 +21,7 @@ import { } from '../utils'; import { ChromeNavLinks } from '../nav_links'; import { InternalApplicationStart } from '../../application'; -import { NavGroupStatus } from '../../../../core/types'; +import { NavGroupStatus, NavGroupType } from '../../../../core/types'; import { ChromeBreadcrumb, ChromeBreadcrumbEnricher } from '../chrome_service'; import { ALL_USE_CASE_ID } from '../../../utils'; @@ -270,10 +270,15 @@ export class ChromeNavGroupService { appIdNavGroupMap.set(navLinkId, navGroupSet); }); }; - if (visibleUseCases.length === 1 && visibleUseCases[0].id === ALL_USE_CASE_ID) { - // If the only visible use case is all use case - // All the other nav groups will be visible because all use case can visit all of the nav groups. - Object.values(navGroupMap).forEach((navGroup) => mapAppIdToNavGroup(navGroup)); + if (visibleUseCases.length === 1) { + if (visibleUseCases[0].id === ALL_USE_CASE_ID) { + // If the only visible use case is all use case + // All the other nav groups will be visible because all use case can visit all of the nav groups. + Object.values(navGroupMap).forEach((navGroup) => mapAppIdToNavGroup(navGroup)); + } else { + // It means we are in a workspace, we should only use the visible use cases + visibleUseCases.forEach((navGroup) => mapAppIdToNavGroup(navGroup)); + } } else { // Nav group of Hidden status should be filtered out when counting navGroups the currentApp belongs to Object.values(navGroupMap).forEach((navGroup) => { @@ -287,7 +292,26 @@ export class ChromeNavGroupService { const navGroups = appIdNavGroupMap.get(appId); if (navGroups && navGroups.size === 1) { - setCurrentNavGroup(navGroups.values().next().value); + const navGroupId = navGroups.values().next().value as string; + /** + * If + * 1. workspace enabled + * 2. outside of workspace: visibleUseCases.length > 1 + * 3. the matched nav group is a use case nav group + * + * It means a workspace application is incorrectly opened in global place. + * We need to set current nav group to undefined to not show the use case nav. + */ + const navGroupInfo = navGroupMap[navGroupId]; + if ( + application.capabilities.workspaces.enabled && + visibleUseCases.length > 1 && + navGroupInfo.type !== NavGroupType.SYSTEM + ) { + setCurrentNavGroup(undefined); + } else { + setCurrentNavGroup(navGroupId); + } } else if (!navGroups) { setCurrentNavGroup(undefined); }