Skip to content

Commit

Permalink
[Discover-next] Add query editor extensions (#7034)
Browse files Browse the repository at this point in the history
### Description

see #6894 

This PR picks #6894, #6895, #6933, #6972 to main. Additionally,
- separates extensions from query enhancements
- adds banner support
- partially revert #6972 as it's pending on the data source commit to main
- renames search bar extension to query editor extension

A query editor extension can display a UI component above the query editor and/or a banner above the language selector. 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 UI Enhancements.

```ts
export interface QueryEditorExtensionDependencies {
  /**
   * Currently selected index patterns.
   */
  indexPatterns?: Array<IIndexPattern | string>;
  /**
   * Currently selected data source.
   */
  dataSource?: DataSource;
  /**
   * Currently selected query language.
   */
  language: string;
}

export interface QueryEditorExtensionConfig {
  /**
   * 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: (dependencies: QueryEditorExtensionDependencies) => Promise<boolean>;
  /**
   * A function that returns the search bar extension component. The component
   * will be displayed on top of the query editor in the search bar.
   * @param dependencies - The dependencies required for the extension.
   * @returns The component the search bar extension.
   */
  getComponent?: (dependencies: QueryEditorExtensionDependencies) => React.ReactElement | null;
  /**
   * A function that returns the search bar extension banner. The banner is a
   * component that will be displayed on top of the search bar.
   * @param dependencies - The dependencies required for the extension.
   * @returns The component the search bar extension.
   */
  getBanner?: (dependencies: QueryEditorExtensionDependencies) => React.ReactElement | null;
}

export interface UiEnhancements {
  query?: QueryEnhancement;
+ queryEditorExtension?: QueryEditorExtensionConfig;
}
```
Developers can utilize search bar extensions to add additional features to the search bar, such as query assist.

Issues resolved: #6077

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.

Signed-off-by: Joshua Li <joshuali925@gmail.com>
  • Loading branch information
joshuali925 authored Jun 20, 2024
1 parent 50f1066 commit 4f54049
Show file tree
Hide file tree
Showing 16 changed files with 460 additions and 37 deletions.
2 changes: 2 additions & 0 deletions changelogs/fragments/7034.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Add search bar extensions ([#7034](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7034))
1 change: 1 addition & 0 deletions src/plugins/data/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export {
TimeHistoryContract,
QueryStateChange,
QueryStart,
PersistedLog,
} from './query';

export { AggsStart } from './search/aggs';
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/data/public/ui/query_editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ export const QueryEditor = (props: QueryEditorProps) => (
</React.Suspense>
);
export type { QueryEditorProps };

export { QueryEditorExtensions, QueryEditorExtensionConfig } from './query_editor_extensions';
8 changes: 8 additions & 0 deletions src/plugins/data/public/ui/query_editor/query_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface QueryEditorProps {
isInvalid?: boolean;
queryEditorHeaderRef: React.RefObject<HTMLDivElement>;
queryEditorHeaderClassName?: string;
queryEditorBannerRef: React.RefObject<HTMLDivElement>;
queryEditorBannerClassName?: string;
}

interface Props extends QueryEditorProps {
Expand Down Expand Up @@ -253,8 +255,14 @@ export default class QueryEditorUI extends Component<Props, State> {
this.props.queryEditorHeaderClassName
);

const queryEditorBannerClassName = classNames(
'osdQueryEditorBanner',
this.props.queryEditorBannerClassName
);

return (
<div className={className}>
<div ref={this.props.queryEditorBannerRef} className={queryEditorBannerClassName} />
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { ComponentProps } from 'react';

const Fallback = () => <div />;

const LazyQueryEditorExtensions = React.lazy(() => import('./query_editor_extensions'));
export const QueryEditorExtensions = (props: ComponentProps<typeof LazyQueryEditorExtensions>) => (
<React.Suspense fallback={<Fallback />}>
<LazyQueryEditorExtensions {...props} />
</React.Suspense>
);

export { QueryEditorExtensionConfig } from './query_editor_extension';
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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 { QueryEditorExtension } from './query_editor_extension';

jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn((element) => element),
}));

type QueryEditorExtensionProps = ComponentProps<typeof QueryEditorExtension>;

const mockIndexPattern = {
id: '1234',
title: 'logstash-*',
fields: [
{
name: 'response',
type: 'number',
esTypes: ['integer'],
aggregatable: true,
filterable: true,
searchable: true,
},
],
} as IIndexPattern;

describe('QueryEditorExtension', () => {
const getComponentMock = jest.fn();
const getBannerMock = jest.fn();
const isEnabledMock = jest.fn();

const defaultProps: QueryEditorExtensionProps = {
config: {
id: 'test-extension',
order: 1,
isEnabled: isEnabledMock,
getComponent: getComponentMock,
getBanner: getBannerMock,
},
dependencies: {
indexPatterns: [mockIndexPattern],
language: 'Test',
},
componentContainer: document.createElement('div'),
bannerContainer: document.createElement('div'),
};

beforeEach(() => {
jest.clearAllMocks();
});

it('renders correctly when isEnabled is true', async () => {
isEnabledMock.mockResolvedValue(true);
getComponentMock.mockReturnValue(<div>Test Component</div>);
getBannerMock.mockReturnValue(<div>Test Banner</div>);

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

await waitFor(() => {
expect(getByText('Test Component')).toBeInTheDocument();
expect(getByText('Test Banner')).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(<QueryEditorExtension {...defaultProps} />);

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

expect(isEnabledMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { EuiErrorBoundary } from '@elastic/eui';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { IIndexPattern } from '../../../../common';
import { DataSource } from '../../../data_sources/datasource';

interface QueryEditorExtensionProps {
config: QueryEditorExtensionConfig;
dependencies: QueryEditorExtensionDependencies;
componentContainer: Element;
bannerContainer: Element;
}

export interface QueryEditorExtensionDependencies {
/**
* Currently selected index patterns.
*/
indexPatterns?: Array<IIndexPattern | string>;
/**
* Currently selected data source.
*/
dataSource?: DataSource;
/**
* Currently selected query language.
*/
language: string;
}

export interface QueryEditorExtensionConfig {
/**
* 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: (dependencies: QueryEditorExtensionDependencies) => Promise<boolean>;
/**
* A function that returns the search bar extension component. The component
* will be displayed on top of the query editor in the search bar.
* @param dependencies - The dependencies required for the extension.
* @returns The component the search bar extension.
*/
getComponent?: (dependencies: QueryEditorExtensionDependencies) => React.ReactElement | null;
/**
* A function that returns the search bar extension banner. The banner is a
* component that will be displayed on top of the search bar.
* @param dependencies - The dependencies required for the extension.
* @returns The component the search bar extension.
*/
getBanner?: (dependencies: QueryEditorExtensionDependencies) => React.ReactElement | null;
}

const QueryEditorExtensionPortal: React.FC<{ container: Element }> = (props) => {
if (!props.children) return null;

return ReactDOM.createPortal(
<EuiErrorBoundary>{props.children}</EuiErrorBoundary>,
props.container
);
};

export const QueryEditorExtension: React.FC<QueryEditorExtensionProps> = (props) => {
const [isEnabled, setIsEnabled] = useState(false);
const isMounted = useRef(false);

const banner = useMemo(() => props.config.getBanner?.(props.dependencies), [
props.config,
props.dependencies,
]);

const component = useMemo(() => props.config.getComponent?.(props.dependencies), [
props.config,
props.dependencies,
]);

useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);

useEffect(() => {
props.config.isEnabled(props.dependencies).then((enabled) => {
if (isMounted.current) setIsEnabled(enabled);
});
}, [props.dependencies, props.config]);

if (!isEnabled) return null;

return (
<>
<QueryEditorExtensionPortal container={props.bannerContainer}>
{banner}
</QueryEditorExtensionPortal>
<QueryEditorExtensionPortal container={props.componentContainer}>
{component}
</QueryEditorExtensionPortal>
</>
);
};
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 { QueryEditorExtension } from './query_editor_extension';
import QueryEditorExtensions from './query_editor_extensions';

type QueryEditorExtensionProps = ComponentProps<typeof QueryEditorExtension>;
type QueryEditorExtensionsProps = ComponentProps<typeof QueryEditorExtensions>;

jest.mock('./query_editor_extension', () => ({
QueryEditorExtension: jest.fn(({ config, dependencies }: QueryEditorExtensionProps) => (
<div>
Mocked QueryEditorExtension {config.id} with{' '}
{dependencies.indexPatterns?.map((i) => (typeof i === 'string' ? i : i.title)).join(', ')}
</div>
)),
}));

describe('QueryEditorExtensions', () => {
const defaultProps: QueryEditorExtensionsProps = {
indexPatterns: [
{
id: '1234',
title: 'logstash-*',
fields: [
{
name: 'response',
type: 'number',
esTypes: ['integer'],
aggregatable: true,
filterable: true,
searchable: true,
},
],
},
],
componentContainer: document.createElement('div'),
bannerContainer: document.createElement('div'),
language: 'Test',
};

beforeEach(() => {
jest.clearAllMocks();
});

it('renders without any configurations', () => {
const { container } = render(<QueryEditorExtensions {...defaultProps} />);
expect(container).toBeEmptyDOMElement();
});

it('renders without any items in map', () => {
const { container } = render(<QueryEditorExtensions {...defaultProps} configMap={{}} />);
expect(container).toBeEmptyDOMElement();
});

it('correctly orders configurations based on order property', () => {
const configMap = {
'1': { id: '1', order: 2, isEnabled: jest.fn(), getComponent: jest.fn() },
'2': { id: '2', order: 1, isEnabled: jest.fn(), getComponent: jest.fn() },
};

const { getAllByText } = render(
<QueryEditorExtensions {...defaultProps} configMap={configMap} />
);
const renderedExtensions = getAllByText(/Mocked QueryEditorExtension/);

expect(renderedExtensions).toHaveLength(2);
expect(renderedExtensions[0]).toHaveTextContent('2');
expect(renderedExtensions[1]).toHaveTextContent('1');
});

it('passes dependencies correctly to QueryEditorExtension', async () => {
const configMap = {
'1': { id: '1', order: 1, isEnabled: jest.fn(), getComponent: jest.fn() },
};

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

await waitFor(() => {
expect(getByText(/logstash-\*/)).toBeInTheDocument();
});

expect(QueryEditorExtension).toHaveBeenCalledWith(
expect.objectContaining({
dependencies: { indexPatterns: defaultProps.indexPatterns, language: 'Test' },
}),
expect.anything()
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useMemo } from 'react';
import {
QueryEditorExtension,
QueryEditorExtensionConfig,
QueryEditorExtensionDependencies,
} from './query_editor_extension';

interface QueryEditorExtensionsProps extends QueryEditorExtensionDependencies {
configMap?: Record<string, QueryEditorExtensionConfig>;
componentContainer: Element;
bannerContainer: Element;
}

const QueryEditorExtensions: React.FC<QueryEditorExtensionsProps> = React.memo((props) => {
const { configMap, componentContainer, bannerContainer, ...dependencies } = props;

const sortedConfigs = useMemo(() => {
if (!configMap || !Object.keys(configMap)) return [];
return Object.values(configMap).sort((a, b) => a.order - b.order);
}, [configMap]);

return (
<>
{sortedConfigs.map((config) => (
<QueryEditorExtension
key={config.id}
config={config}
dependencies={dependencies}
componentContainer={componentContainer}
bannerContainer={bannerContainer}
/>
))}
</>
);
});

// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default QueryEditorExtensions;
Loading

0 comments on commit 4f54049

Please sign in to comment.