diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index e85c2b279a1d..3cd7c54410f4 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -527,3 +527,6 @@ export { DataSourceGroup, DataSourceOption, } from './data_sources/datasource_selector'; + +export { SuggestionsComponent } from './ui'; +export { PersistedLog } from './query'; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index f22a61423d1d..ea0470d0e1ae 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -234,7 +234,7 @@ export class DataPublicPlugin }, ]); - const dataServices = { + const dataServices: Omit = { actions: { createFiltersFromValueClickAction, createFiltersFromRangeSelectAction, 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 c1fca9bb5a95..02c495c430f1 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor.tsx @@ -3,36 +3,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { Component, RefObject, createRef } from 'react'; -import { i18n } from '@osd/i18n'; - -import classNames from 'classnames'; import { - PopoverAnchorPosition, + EuiButton, EuiFlexGroup, EuiFlexItem, - EuiButton, EuiLink, htmlIdGenerator, + PopoverAnchorPosition, } from '@elastic/eui'; - +import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; +import classNames from 'classnames'; import { isEqual, isFunction } from 'lodash'; +import React, { Component, createRef, RefObject } from 'react'; import { Toast } from 'src/core/public'; +import { Settings } from '..'; import { IDataPluginServices, IIndexPattern, Query, TimeRange } from '../..'; -import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; - import { CodeEditor, OpenSearchDashboardsReactContextValue, toMountPoint, } from '../../../../opensearch_dashboards_react/public'; -import { fetchIndexPatterns } from './fetch_index_patterns'; -import { QueryLanguageSwitcher } from './language_switcher'; -import { PersistedLog, getQueryLog, matchPairs, toUser, fromUser } from '../../query'; +import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; +import { fromUser, getQueryLog, matchPairs, PersistedLog, toUser } from '../../query'; import { SuggestionsListSize } from '../typeahead/suggestions_component'; -import { Settings } from '..'; import { DataSettings, QueryEnhancement } from '../types'; +import { fetchIndexPatterns } from './fetch_index_patterns'; +import { QueryLanguageSwitcher } from './language_switcher'; export interface QueryEditorProps { indexPatterns: Array; @@ -57,6 +54,7 @@ export interface QueryEditorProps { size?: SuggestionsListSize; className?: string; isInvalid?: boolean; + queryEditorRef: React.RefObject; } interface Props extends QueryEditorProps { @@ -521,6 +519,7 @@ export default class QueryEditorUI extends Component { +
; } // Needed for React.lazy @@ -238,6 +239,7 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { getQueryStringInitialValue={getQueryStringInitialValue} persistedLog={persistedLog} dataTestSubj={props.dataTestSubj} + queryEditorRef={props.queryEditorRef} /> ); diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 1cb5de2d685d..22487c8399f8 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -28,26 +28,24 @@ * under the License. */ -import { compact } from 'lodash'; import { InjectedIntl, injectI18n } from '@osd/i18n/react'; import classNames from 'classnames'; +import { compact, get, isEqual } from 'lodash'; import React, { Component } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; -import { get, isEqual } from 'lodash'; - import { - withOpenSearchDashboards, OpenSearchDashboardsReactContextValue, + withOpenSearchDashboards, } from '../../../../opensearch_dashboards_react/public'; - -import QueryBarTopRow from '../query_string_input/query_bar_top_row'; -import QueryEditorTopRow from '../query_editor/query_editor_top_row'; -import { SavedQueryAttributes, TimeHistoryContract, SavedQuery } from '../../query'; +import { Filter, IIndexPattern, Query, TimeRange } from '../../../common'; +import { SavedQuery, SavedQueryAttributes, TimeHistoryContract } from '../../query'; import { IDataPluginServices } from '../../types'; -import { TimeRange, Query, Filter, IIndexPattern } from '../../../common'; import { FilterBar } from '../filter_bar/filter_bar'; +import QueryEditorTopRow from '../query_editor/query_editor_top_row'; +import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form'; import { SavedQueryManagementComponent } from '../saved_query_management'; +import { SearchBarExtensions } from '../search_bar_extensions/search_bar_extensions'; import { QueryEnhancement, Settings } from '../types'; interface SearchBarInjectedDeps { @@ -125,6 +123,12 @@ class SearchBarUI extends Component { private services = this.props.opensearchDashboards.services; private savedQueryService = this.services.data.query.savedQueries; + /** + * queryEditorRef can't be bound to the actual editor + * https://github.com/react-monaco-editor/react-monaco-editor/blob/v0.27.0/src/editor.js#L113, + * currently it is an element above. + */ + public queryEditorRef = React.createRef(); public filterBarRef: Element | null = null; public filterBarWrapperRef: Element | null = null; @@ -239,6 +243,15 @@ class SearchBarUI extends Component { ); } + private shouldRenderExtensions() { + return ( + this.props.isEnhancementsEnabled && + (!!this.props.queryEnhancements?.get(this.state.query?.language?.toUpperCase()!)?.searchBar + ?.extensions?.length ?? + false) + ); + } + /* * This Function is here to show the toggle in saved query form * in case you the date range (from/to) @@ -512,6 +525,21 @@ class SearchBarUI extends Component { filterBar={filterBar} dataTestSubj={this.props.dataTestSubj} indicateNoData={this.props.indicateNoData} + queryEditorRef={this.queryEditorRef} + /> + ); + } + + let searchBarExtensions; + if (this.shouldRenderExtensions() && this.queryEditorRef.current) { + searchBarExtensions = ( + ); } @@ -521,6 +549,7 @@ class SearchBarUI extends Component { return (
{queryBar} + {searchBarExtensions} {queryEditor} {!!!this.props.isEnhancementsEnabled && filterBar} diff --git a/src/plugins/data/public/ui/search_bar_extensions/index.ts b/src/plugins/data/public/ui/search_bar_extensions/index.ts new file mode 100644 index 000000000000..15d03718aa4d --- /dev/null +++ b/src/plugins/data/public/ui/search_bar_extensions/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { SearchBarExtensionConfig } from './search_bar_extension'; diff --git a/src/plugins/data/public/ui/search_bar_extensions/search_bar_extension.test.tsx b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extension.test.tsx new file mode 100644 index 000000000000..5abbe0200f27 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extension.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render, waitFor } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { IIndexPattern } from '../../../common'; +import { SearchBarExtension } from './search_bar_extension'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + EuiPortal: jest.fn(({ children }) =>
{children}
), + EuiErrorBoundary: jest.fn(({ children }) =>
{children}
), +})); + +type SearchBarExtensionProps = ComponentProps; + +const mockIndexPattern = { + id: '1234', + title: 'logstash-*', + fields: [ + { + name: 'response', + type: 'number', + esTypes: ['integer'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], +} as IIndexPattern; + +describe('SearchBarExtension', () => { + const getComponentMock = jest.fn(); + const isEnabledMock = jest.fn(); + + const defaultProps: SearchBarExtensionProps = { + config: { + id: 'test-extension', + order: 1, + isEnabled: isEnabledMock, + getComponent: getComponentMock, + }, + dependencies: { + indexPatterns: [mockIndexPattern], + }, + portalInsert: { sibling: document.createElement('div'), position: 'after' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly when isEnabled is true', async () => { + isEnabledMock.mockResolvedValue(true); + getComponentMock.mockReturnValue(
Test Component
); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('Test Component')).toBeInTheDocument(); + }); + + expect(isEnabledMock).toHaveBeenCalled(); + expect(getComponentMock).toHaveBeenCalledWith(defaultProps.dependencies); + }); + + it('does not render when isEnabled is false', async () => { + isEnabledMock.mockResolvedValue(false); + getComponentMock.mockReturnValue(
Test Component
); + + const { queryByText } = render(); + + await waitFor(() => { + expect(queryByText('Test Component')).toBeNull(); + }); + + expect(isEnabledMock).toHaveBeenCalled(); + }); + + it('calls isEnabled and getComponent correctly', async () => { + isEnabledMock.mockResolvedValue(true); + getComponentMock.mockReturnValue(
Test Component
); + + render(); + + await waitFor(() => { + expect(isEnabledMock).toHaveBeenCalled(); + }); + + expect(getComponentMock).toHaveBeenCalledWith(defaultProps.dependencies); + }); +}); diff --git a/src/plugins/data/public/ui/search_bar_extensions/search_bar_extension.tsx b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extension.tsx new file mode 100644 index 000000000000..ffe122be48e7 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extension.tsx @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiErrorBoundary, EuiPortal } from '@elastic/eui'; +import { EuiPortalProps } from '@opensearch-project/oui'; +import React, { useEffect, useMemo, useState } from 'react'; +import { IIndexPattern } from '../../../common'; + +interface SearchBarExtensionProps { + config: SearchBarExtensionConfig; + dependencies: SearchBarExtensionDependencies; + portalInsert: EuiPortalProps['insert']; +} + +export interface SearchBarExtensionDependencies { + /** + * Currently selected index patterns. + */ + indexPatterns?: IIndexPattern[]; +} + +export interface SearchBarExtensionConfig { + /** + * The id for the search bar extension. + */ + id: string; + /** + * Lower order indicates higher position on UI. + */ + order: number; + /** + * A function that determines if the search bar extension is enabled and should be rendered on UI. + * @returns whether the extension is enabled. + */ + isEnabled: () => Promise; + /** + * A function that returns the mount point for the search bar extension. + * @param dependencies - The dependencies required for the extension. + * @returns The mount point for the search bar extension. + */ + getComponent: (dependencies: SearchBarExtensionDependencies) => React.ReactElement; +} + +export const SearchBarExtension: React.FC = (props) => { + const [isEnabled, setIsEnabled] = useState(false); + + const component = useMemo(() => props.config.getComponent(props.dependencies), [ + props.config, + props.dependencies, + ]); + + useEffect(() => { + props.config.isEnabled().then(setIsEnabled); + }, [props.dependencies, props.config]); + + if (!isEnabled) return null; + + return ( + + {component} + + ); +}; diff --git a/src/plugins/data/public/ui/search_bar_extensions/search_bar_extensions.test.tsx b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extensions.test.tsx new file mode 100644 index 000000000000..96d8aa5d0cd7 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extensions.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { SearchBarExtensions } from './search_bar_extensions'; + +type SearchBarExtensionsProps = ComponentProps; + +jest.mock('./search_bar_extension', () => ({ + SearchBarExtension: jest.fn(({ config }) =>
{`Mock SearchBarExtension ${config.id}`}
), +})); + +describe('SearchBarExtensions', () => { + const defaultProps: SearchBarExtensionsProps = { + dependencies: { indexPatterns: [] }, + portalInsert: { sibling: document.createElement('div'), position: 'after' }, + configs: [ + { id: '1', order: 2, isEnabled: jest.fn().mockResolvedValue(true), getComponent: jest.fn() }, + { id: '2', order: 1, isEnabled: jest.fn().mockResolvedValue(true), getComponent: jest.fn() }, + ], + }; + + it('renders correctly with a list of SearchBarExtensionConfig', () => { + const { getAllByText } = render(); + + expect(getAllByText(/^Mock SearchBarExtension/)).toHaveLength(2); + }); + + it('does not render when configs is not provided', () => { + const { queryByText } = render( + + ); + + expect(queryByText(/^Mock SearchBarExtension/)).toBeNull(); + }); + + it('sorts configs by order and removes duplicates by id', () => { + const configsWithDuplicates = [ + { id: '1', order: 3, isEnabled: jest.fn().mockResolvedValue(true), getComponent: jest.fn() }, + { id: '2', order: 1, isEnabled: jest.fn().mockResolvedValue(true), getComponent: jest.fn() }, + { id: '1', order: 2, isEnabled: jest.fn().mockResolvedValue(true), getComponent: jest.fn() }, // Duplicate + ]; + + const { getAllByText } = render( + + ); + + const renderedElements = getAllByText(/^Mock SearchBarExtension/); + expect(renderedElements).toHaveLength(2); + + const renderedTexts = renderedElements.map((element) => element.textContent); + expect(renderedTexts).toEqual(['Mock SearchBarExtension 2', 'Mock SearchBarExtension 1']); + }); +}); diff --git a/src/plugins/data/public/ui/search_bar_extensions/search_bar_extensions.tsx b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extensions.tsx new file mode 100644 index 000000000000..d715b438141d --- /dev/null +++ b/src/plugins/data/public/ui/search_bar_extensions/search_bar_extensions.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPortalProps } from '@elastic/eui'; +import React from 'react'; +import { + SearchBarExtension, + SearchBarExtensionConfig, + SearchBarExtensionDependencies, +} from './search_bar_extension'; + +interface SearchBarExtensionsProps { + configs?: SearchBarExtensionConfig[]; + dependencies: SearchBarExtensionDependencies; + portalInsert: EuiPortalProps['insert']; +} + +export const SearchBarExtensions: React.FC = (props) => { + if (!props.configs) return null; + + const configs = props.configs + .sort((a, b) => a.order - b.order) + .reduce((acc, config) => { + if (!acc.some((item) => item.id === config.id)) { + acc.push(config); + } + // TODO inform user of duplicates? + return acc; + }, [] as SearchBarExtensionConfig[]); + + return ( + <> + {configs.map((config) => ( + + ))} + + ); +}; diff --git a/src/plugins/data/public/ui/types.ts b/src/plugins/data/public/ui/types.ts index 46fff99de366..f03cd85169ac 100644 --- a/src/plugins/data/public/ui/types.ts +++ b/src/plugins/data/public/ui/types.ts @@ -8,6 +8,7 @@ import { SearchInterceptor } from '../search'; import { IndexPatternSelectProps } from './index_pattern_select'; import { StatefulSearchBarProps } from './search_bar'; import { Settings } from './settings'; +import { SearchBarExtensionConfig } from './search_bar_extensions/search_bar_extension'; export * from './settings'; @@ -31,6 +32,7 @@ export interface QueryEnhancement { initialFrom?: string; initialTo?: string; }; + extensions?: SearchBarExtensionConfig[]; }; fields?: { filterable?: boolean;