From 05abf5e8839e4db6c9afe0ac9483df57564e888a Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Fri, 15 Mar 2024 19:03:34 -0700 Subject: [PATCH] [Multiple Datasource] Add data source aggregated view to show all compatible data sources or only show used data sources (#6129) * add data source aggregated view to show all compatible data sources or only used data sources Signed-off-by: Lu Yu * add change log Signed-off-by: Lu Yu * address comments and add more tests Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu --- CHANGELOG.md | 2 + .../data_source_aggregated_view.test.tsx.snap | 449 ++++++++++++++++++ .../data_source_aggregated_view.test.tsx | 119 +++++ .../data_source_aggregated_view.tsx | 173 +++++++ .../data_source_aggregated_view/index.ts | 6 + .../data_source_menu.test.tsx.snap | 61 +++ .../data_source_menu.test.tsx | 15 + .../data_source_menu/data_source_menu.tsx | 38 +- .../data_source_selectable.test.tsx | 2 +- .../data_source_selectable.tsx | 10 +- 10 files changed, 866 insertions(+), 9 deletions(-) create mode 100644 src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx create mode 100644 src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx create mode 100644 src/plugins/data_source_management/public/components/data_source_aggregated_view/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e9fc1dfa7ed..0adcbc43f515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,10 +42,12 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add delete saved objects by workspace functionality([#6013](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6013)) - [Workspace] Add a workspace client in workspace plugin ([#6094](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6094)) - [Multiple Datasource] Add component to show single selected data source in read only mode ([#6125](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6125)) +- [Multiple Datasource] Add data source aggregated view to show all compatible data sources or only show used data sources ([#6129](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6129)) - [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) - Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) - Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) + ### 🐛 Bug Fixes - [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap new file mode 100644 index 000000000000..2be5b7c11b8a --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap @@ -0,0 +1,449 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 1`] = ` + + + Data sources + + + 1 + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + +`; + +exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 2`] = ` + + + Data sources + + + All + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + +`; + +exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 3`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ + + 1 + +
+
+ +
+
+
+
+
+ +
+ , + "container":
+ + + 1 + +
+
+ +
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`DataSourceAggregatedView should render normally with local cluster hidden and all options 1`] = ` + + + Data sources + + + All + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + +`; + +exports[`DataSourceAggregatedView should render normally with local cluster not hidden and all options 1`] = ` + + + Data sources + + + All + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + +`; diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx new file mode 100644 index 000000000000..3f44bac61e5b --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ShallowWrapper, shallow } from 'enzyme'; +import React from 'react'; +import { DataSourceAggregatedView } from './data_source_aggregated_view'; +import { SavedObjectsClientContract } from '../../../../../core/public'; +import { notificationServiceMock } from '../../../../../core/public/mocks'; +import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls } from '../../mocks'; +import { render } from '@testing-library/react'; + +describe('DataSourceAggregatedView', () => { + let component: ShallowWrapper, React.Component<{}, {}, any>>; + let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); + + beforeEach(() => { + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); + }); + + it('should render normally with local cluster not hidden and all options', () => { + component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(client.find).toBeCalledWith({ + fields: ['id', 'title', 'auth.type'], + perPage: 10000, + type: 'data-source', + }); + expect(toasts.addWarning).toBeCalledTimes(0); + }); + + it('should render normally with local cluster hidden and all options', () => { + component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(client.find).toBeCalledWith({ + fields: ['id', 'title', 'auth.type'], + perPage: 10000, + type: 'data-source', + }); + expect(toasts.addWarning).toBeCalledTimes(0); + }); + + it('should render normally with local cluster and actice selections', () => { + component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(client.find).toBeCalledWith({ + fields: ['id', 'title', 'auth.type'], + perPage: 10000, + type: 'data-source', + }); + expect(toasts.addWarning).toBeCalledTimes(0); + }); + + it('should render normally with data source filter', () => { + component = shallow( + ds.attributes.auth.type !== 'no_auth'} + /> + ); + expect(component).toMatchSnapshot(); + expect(client.find).toBeCalledWith({ + fields: ['id', 'title', 'auth.type'], + perPage: 10000, + type: 'data-source', + }); + expect(toasts.addWarning).toBeCalledTimes(0); + }); + + it('should render popup when clicking on info icon', async () => { + const container = render( + + ); + const infoIcon = await container.findByTestId('dataSourceAggregatedViewInfoButton'); + infoIcon.click(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx new file mode 100644 index 000000000000..015441773600 --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.tsx @@ -0,0 +1,173 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenu, + EuiNotificationBadge, + EuiPopover, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public'; +import { getDataSourcesWithFields } from '../utils'; +import { SavedObject } from '../../../../../core/public'; +import { DataSourceAttributes } from '../../types'; + +interface DataSourceAggregatedViewProps { + savedObjectsClient: SavedObjectsClientContract; + notifications: ToastsStart; + hideLocalCluster: boolean; + fullWidth: boolean; + activeDataSourceIds?: string[]; + dataSourceFilter?: (dataSource: SavedObject) => boolean; + displayAllCompatibleDataSources: boolean; +} + +interface DataSourceAggregatedViewState { + isPopoverOpen: boolean; + allDataSourcesIdToTitleMap: Map; +} + +export class DataSourceAggregatedView extends React.Component< + DataSourceAggregatedViewProps, + DataSourceAggregatedViewState +> { + private _isMounted: boolean = false; + + constructor(props: DataSourceAggregatedViewProps) { + super(props); + + this.state = { + isPopoverOpen: false, + allDataSourcesIdToTitleMap: new Map(), + }; + } + + componentWillUnmount() { + this._isMounted = false; + } + + onClick() { + this.setState({ ...this.state, isPopoverOpen: !this.state.isPopoverOpen }); + } + + closePopover() { + this.setState({ ...this.state, isPopoverOpen: false }); + } + + async componentDidMount() { + this._isMounted = true; + getDataSourcesWithFields(this.props.savedObjectsClient, ['id', 'title', 'auth.type']) + .then((fetchedDataSources) => { + if (fetchedDataSources?.length) { + let filteredDataSources = fetchedDataSources; + if (this.props.dataSourceFilter) { + filteredDataSources = fetchedDataSources.filter((ds) => + this.props.dataSourceFilter!(ds) + ); + } + + const allDataSourcesIdToTitleMap = new Map(); + + filteredDataSources.forEach((ds) => { + allDataSourcesIdToTitleMap.set(ds.id, ds.attributes!.title || ''); + }); + + if (!this.props.hideLocalCluster) { + allDataSourcesIdToTitleMap.set('', 'Local cluster'); + } + + if (!this._isMounted) return; + this.setState({ + ...this.state, + allDataSourcesIdToTitleMap, + }); + } + }) + .catch(() => { + this.props.notifications.addWarning( + i18n.translate('dataSource.fetchDataSourceError', { + defaultMessage: 'Unable to fetch existing data sources', + }) + ); + }); + } + + render() { + const button = ( + + ); + + let items = []; + + // only display active data sources + if (this.props.activeDataSourceIds && this.props.activeDataSourceIds.length > 0) { + items = this.props.activeDataSourceIds.map((id) => { + return { + name: this.state.allDataSourcesIdToTitleMap.get(id), + disabled: true, + }; + }); + } else { + items = [...this.state.allDataSourcesIdToTitleMap.values()].map((title) => { + return { + name: title, + disabled: true, + }; + }); + } + + const title = this.props.displayAllCompatibleDataSources + ? `Data sources (${this.state.allDataSourcesIdToTitleMap.size})` + : 'Selected data sources'; + + const panels = [ + { + id: 0, + title, + items, + }, + ]; + + return ( + <> + + {'Data sources'} + + + {this.props.activeDataSourceIds?.length || 'All'} + + + + + + ); + } +} diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/index.ts b/src/plugins/data_source_management/public/components/data_source_aggregated_view/index.ts new file mode 100644 index 000000000000..3df2a2a133fe --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { DataSourceAggregatedView } from './data_source_aggregated_view'; diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap index a55591a5df26..ea00d96c5798 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap @@ -1,5 +1,66 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`DataSourceMenu should render data source aggregated view 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + exports[`DataSourceMenu should render data source selectable only with local cluster is hidden 1`] = ` { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -66,4 +67,18 @@ describe('DataSourceMenu', () => { ); expect(component).toMatchSnapshot(); }); + + it('should render data source aggregated view', () => { + const container = render( + + ); + expect(container).toMatchSnapshot(); + }); }); diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx index 185cc63a7981..9be65882a9b8 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx @@ -11,15 +11,20 @@ import { MountPoint, NotificationsStart, SavedObjectsClientContract, + SavedObject, } from '../../../../../core/public'; import { MountPointPortal } from '../../../../opensearch_dashboards_react/public'; import { DataSourceSelectable } from './data_source_selectable'; import { DataSourceOption } from '../data_source_selector/data_source_selector'; +import { DataSourceAggregatedView } from '../data_source_aggregated_view'; import { DataSourceView } from '../data_source_view'; +import { DataSourceAttributes } from '../../types'; export interface DataSourceMenuProps { showDataSourceSelectable?: boolean; showDataSourceView?: boolean; + showDataSourceAggregatedView?: boolean; + activeDataSourceIds?: string[]; appName: string; savedObjects?: SavedObjectsClientContract; notifications?: NotificationsStart; @@ -30,7 +35,8 @@ export interface DataSourceMenuProps { className?: string; selectedOption?: DataSourceOption[]; setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; - filterFn?: (dataSource: any) => boolean; + dataSourceFilter?: (dataSource: SavedObject) => boolean; + displayAllCompatibleDataSources?: boolean; } export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null { @@ -40,14 +46,17 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null dataSourceCallBackFunc, showDataSourceSelectable, disableDataSourceSelectable, + showDataSourceAggregatedView, fullWidth, hideLocalCluster, selectedOption, showDataSourceView, - filterFn, + dataSourceFilter, + activeDataSourceIds, + displayAllCompatibleDataSources, } = props; - if (!showDataSourceSelectable && !showDataSourceView) { + if (!showDataSourceSelectable && !showDataSourceView && !showDataSourceAggregatedView) { return null; } @@ -75,12 +84,27 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null onSelectedDataSource={dataSourceCallBackFunc!} disabled={disableDataSourceSelectable || false} selectedOption={selectedOption && selectedOption.length > 0 ? selectedOption : undefined} - filterFn={filterFn} + dataSourceFilter={dataSourceFilter} /> ); } + function renderDataSourceAggregatedView(): ReactElement | null { + if (!showDataSourceAggregatedView) return null; + return ( + + ); + } + function renderLayout() { const { setMenuMountPoint } = props; const menuClassName = classNames('osdTopNavMenu', props.className); @@ -88,6 +112,7 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null return ( <> + {renderDataSourceAggregatedView()} {renderDataSourceSelectable(menuClassName)} {renderDataSourceView(menuClassName)} @@ -108,6 +133,9 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null DataSourceMenu.defaultProps = { disableDataSourceSelectable: false, - showDataSourceView: false, + showDataSourceAggregatedView: false, showDataSourceSelectable: false, + displayAllCompatibleDataSources: false, + showDataSourceView: false, + hideLocalCluster: false, }; diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx index 7f3a182cf23f..9b0215d157e8 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx @@ -74,7 +74,7 @@ describe('DataSourceSelectable', () => { disabled={false} hideLocalCluster={false} fullWidth={false} - filterFn={(ds) => ds.attributes.auth.type !== AuthType.NoAuth} + dataSourceFilter={(ds) => ds.attributes.auth.type !== AuthType.NoAuth} /> ); component.instance().componentDidMount!(); diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx index 2aab73b0dd7d..9c71c1f0aaa1 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx @@ -17,6 +17,8 @@ import { import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public'; import { getDataSourcesWithFields } from '../utils'; import { DataSourceOption, LocalCluster } from '../data_source_selector/data_source_selector'; +import { SavedObject } from '../../../../../core/public'; +import { DataSourceAttributes } from '../../types'; interface DataSourceSelectableProps { savedObjectsClient: SavedObjectsClientContract; @@ -26,7 +28,7 @@ interface DataSourceSelectableProps { hideLocalCluster: boolean; fullWidth: boolean; selectedOption?: DataSourceOption[]; - filterFn?: (dataSource: any) => boolean; + dataSourceFilter?: (dataSource: SavedObject) => boolean; } interface DataSourceSelectableState { @@ -74,8 +76,10 @@ export class DataSourceSelectable extends React.Component< .then((fetchedDataSources) => { if (fetchedDataSources?.length) { let filteredDataSources = []; - if (this.props.filterFn) { - filteredDataSources = fetchedDataSources.filter((ds) => this.props.filterFn!(ds)); + if (this.props.dataSourceFilter) { + filteredDataSources = fetchedDataSources.filter((ds) => + this.props.dataSourceFilter!(ds) + ); } if (filteredDataSources.length === 0) {