Skip to content

Commit

Permalink
[Discover-next] Add search bar extensions (opensearch-project#6894)
Browse files Browse the repository at this point in the history
A search bar extension can display a UI component above the query bar. The component has the ability to read and write discover search bar states to enhance the search experience for users. The configuration is part of Query Enhancements.

```ts
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<boolean>;
  /**
   * 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 interface QueryEnhancement {
  ...
  searchBar?: {
    ...
    extensions?: SearchBarExtensionConfig[];
  };
}

Signed-off-by: Joshua Li <joshuali925@gmail.com>
  • Loading branch information
joshuali925 authored and kavilla committed Jun 5, 2024
1 parent 309e27b commit e748e81
Show file tree
Hide file tree
Showing 11 changed files with 367 additions and 23 deletions.
3 changes: 3 additions & 0 deletions src/plugins/data/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,3 +527,6 @@ export {
DataSourceGroup,
DataSourceOption,
} from './data_sources/datasource_selector';

export { SuggestionsComponent } from './ui';
export { PersistedLog } from './query';
2 changes: 1 addition & 1 deletion src/plugins/data/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export class DataPublicPlugin
},
]);

const dataServices = {
const dataServices: Omit<DataPublicPluginStart, 'ui'> = {
actions: {
createFiltersFromValueClickAction,
createFiltersFromRangeSelectAction,
Expand Down
25 changes: 12 additions & 13 deletions src/plugins/data/public/ui/query_editor/query_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IIndexPattern | string>;
Expand All @@ -57,6 +54,7 @@ export interface QueryEditorProps {
size?: SuggestionsListSize;
className?: string;
isInvalid?: boolean;
queryEditorRef: React.RefObject<HTMLDivElement>;
}

interface Props extends QueryEditorProps {
Expand Down Expand Up @@ -521,6 +519,7 @@ export default class QueryEditorUI extends Component<Props, State> {
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem onClick={this.onClickInput} grow={true}>
<div ref={this.props.queryEditorRef} />
<CodeEditor
height={70}
languageId="xjson"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface QueryEditorTopRowProps {
isDirty: boolean;
timeHistory?: TimeHistoryContract;
indicateNoData?: boolean;
queryEditorRef: React.RefObject<HTMLDivElement>;
}

// Needed for React.lazy
Expand Down Expand Up @@ -238,6 +239,7 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) {
getQueryStringInitialValue={getQueryStringInitialValue}
persistedLog={persistedLog}
dataTestSubj={props.dataTestSubj}
queryEditorRef={props.queryEditorRef}
/>
</EuiFlexItem>
);
Expand Down
46 changes: 37 additions & 9 deletions src/plugins/data/public/ui/search_bar/search_bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import { QueryEnhancement, Settings } from '../types';

interface SearchBarInjectedDeps {
Expand Down Expand Up @@ -125,6 +123,12 @@ class SearchBarUI extends Component<SearchBarProps, State> {

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<HTMLDivElement>();
public filterBarRef: Element | null = null;
public filterBarWrapperRef: Element | null = null;

Expand Down Expand Up @@ -239,6 +243,15 @@ class SearchBarUI extends Component<SearchBarProps, State> {
);
}

private shouldRenderExtensions() {
return (
this.props.isEnhancementsEnabled &&
(!!this.props.queryEnhancements?.get(this.state.query?.language!)?.searchBar?.extensions
?.length ??
false)
);
}

/*
* This Function is here to show the toggle in saved query form
* in case you the date range (from/to)
Expand Down Expand Up @@ -512,6 +525,20 @@ class SearchBarUI extends Component<SearchBarProps, State> {
filterBar={filterBar}
dataTestSubj={this.props.dataTestSubj}
indicateNoData={this.props.indicateNoData}
queryEditorRef={this.queryEditorRef}
/>
);
}

let searchBarExtensions;
if (this.shouldRenderExtensions() && this.queryEditorRef.current) {
searchBarExtensions = (
<SearchBarExtensions
configs={
this.props.queryEnhancements?.get(this.state.query?.language!)?.searchBar?.extensions
}
dependencies={{ indexPatterns: this.props.indexPatterns }}
portalInsert={{ sibling: this.queryEditorRef.current, position: 'before' }}
/>
);
}
Expand All @@ -521,6 +548,7 @@ class SearchBarUI extends Component<SearchBarProps, State> {
return (
<div className={className} data-test-subj="globalQueryBar">
{queryBar}
{searchBarExtensions}
{queryEditor}
{!!!this.props.isEnhancementsEnabled && filterBar}

Expand Down
7 changes: 7 additions & 0 deletions src/plugins/data/public/ui/search_bar_extensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export { SearchBarExtensionConfig } from './search_bar_extension';
export { SearchBarExtensions } from './search_bar_extensions';
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{children}</div>),
EuiErrorBoundary: jest.fn(({ children }) => <div>{children}</div>),
}));

type SearchBarExtensionProps = ComponentProps<typeof SearchBarExtension>;

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(<div>Test Component</div>);

const { getByText } = render(<SearchBarExtension {...defaultProps} />);

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(<div>Test Component</div>);

const { queryByText } = render(<SearchBarExtension {...defaultProps} />);

await waitFor(() => {
expect(queryByText('Test Component')).toBeNull();
});

expect(isEnabledMock).toHaveBeenCalled();
});

it('calls isEnabled and getComponent correctly', async () => {
isEnabledMock.mockResolvedValue(true);
getComponentMock.mockReturnValue(<div>Test Component</div>);

render(<SearchBarExtension {...defaultProps} />);

await waitFor(() => {
expect(isEnabledMock).toHaveBeenCalled();
});

expect(getComponentMock).toHaveBeenCalledWith(defaultProps.dependencies);
});
});
Original file line number Diff line number Diff line change
@@ -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<boolean>;
/**
* A function that returns the mount point for the search bar extension.
* @param dependencies - The dependencies required for the extension.
* @returns The component the search bar extension.
*/
getComponent: (dependencies: SearchBarExtensionDependencies) => React.ReactElement;
}

export const SearchBarExtension: React.FC<SearchBarExtensionProps> = (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 (
<EuiPortal insert={props.portalInsert}>
<EuiErrorBoundary>{component}</EuiErrorBoundary>
</EuiPortal>
);
};
Loading

0 comments on commit e748e81

Please sign in to comment.