diff --git a/changelogs/fragments/8186.yml b/changelogs/fragments/8186.yml new file mode 100644 index 000000000000..0e194ad9c6f5 --- /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 978f2817bcf4..74e0d388a77e 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 edf98b8570f2..57881b08957f 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 d34271ef7568..c625310525e3 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 f68c601db185..6932cec906f8 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 be74558fae70..7a8fddfe7ee8 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 b9ea4750e601..1b595024ae26 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 9469a3a2771e..7c577db88834 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 057ff1e708d8..d191befad5e7 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 69d8fd3bd667..c585dd7c4bc3 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 000000000000..885e5c632416 --- /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 000000000000..bdf2a814806e --- /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 000000000000..b890ff01a5cd --- /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 acd3e6f75109..a3cec8802880 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 9094e54afa43..ee3543d870c0 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 6301c474eeb2..2507e44129b7 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 44036a11c859..367f44c6426b 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 884068070dab..5b3662fbff2d 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 000000000000..3173ac955e3e --- /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 000000000000..25a198288442 --- /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 000000000000..4ec5289a3fa0 --- /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 000000000000..d6ad2057a77d --- /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 a2076151efb3..04bbb49bebdc 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 000000000000..f4cedeb201e5 --- /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 6d915d96ff09..cc0c695e8fac 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 701a7ca94e7e..447d24714029 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 c31da11e6b15..6b9ec95793ee 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 {