From e2c0df28aa1055c3f840aa22ad6c96c6449d1452 Mon Sep 17 00:00:00 2001 From: tygao Date: Fri, 31 May 2024 09:22:09 +0800 Subject: [PATCH] [Multiple Datasource] Add data source selection service to support storing and getting selected data source updates (#6827) * add data source selection service Signed-off-by: tygao * export generateComponentId in util Signed-off-by: tygao * update tests and type Signed-off-by: tygao * update tests Signed-off-by: tygao * update bind in components Signed-off-by: tygao * Changeset file for PR #6827 created/updated * test: add tests for service Signed-off-by: tygao * move dataSourceSelection out of componoent props Signed-off-by: tygao * update service class name Signed-off-by: tygao * use getter to replace dataSourceSelection props Signed-off-by: tygao * add fallback for getter Signed-off-by: tygao * test: add selection test case for component Signed-off-by: tygao --------- Signed-off-by: tygao Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: Lu Yu --- changelogs/fragments/6827.yml | 2 + .../components/data_source_view_example.tsx | 2 + .../data_source_aggregated_view.test.tsx.snap | 103 ++++++++++++++++++ .../data_source_aggregated_view.test.tsx | 57 ++++++++++ .../data_source_aggregated_view.tsx | 11 +- .../create_data_source_menu.test.tsx | 7 ++ .../create_data_source_menu.tsx | 4 +- .../data_source_menu.test.tsx | 3 + .../data_source_multi_selectable.test.tsx | 41 ++++++- .../data_source_multi_selectable.tsx | 21 +++- .../data_source_selectable.test.tsx | 38 +++++++ .../data_source_selectable.tsx | 26 +++-- .../create_data_source_selector.test.tsx | 8 ++ .../data_source_selector.test.tsx | 25 +++++ .../data_source_selector.tsx | 26 ++++- .../data_source_view.test.tsx | 5 +- .../data_source_view/data_source_view.tsx | 45 ++++---- .../public/components/utils.test.ts | 20 ++++ .../public/components/utils.ts | 26 +++++ .../data_source_management/public/index.ts | 1 + .../data_source_management/public/plugin.ts | 14 ++- .../data_source_selection_service.test.ts | 48 ++++++++ .../service/data_source_selection_service.ts | 40 +++++++ .../components/tutorial_directory.test.tsx | 6 + .../__snapshots__/top_nav_menu.test.tsx.snap | 34 ++++++ .../public/top_nav_menu/top_nav_menu.test.tsx | 6 + 26 files changed, 574 insertions(+), 45 deletions(-) create mode 100644 changelogs/fragments/6827.yml create mode 100644 src/plugins/data_source_management/public/service/data_source_selection_service.test.ts create mode 100644 src/plugins/data_source_management/public/service/data_source_selection_service.ts diff --git a/changelogs/fragments/6827.yml b/changelogs/fragments/6827.yml new file mode 100644 index 000000000000..ce47fd32b7bd --- /dev/null +++ b/changelogs/fragments/6827.yml @@ -0,0 +1,2 @@ +feat: +- Add data source selection service to support storing and getting selected data source updates ([#6827](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6827)) \ No newline at end of file diff --git a/examples/multiple_data_source_examples/public/components/data_source_view_example.tsx b/examples/multiple_data_source_examples/public/components/data_source_view_example.tsx index b80a65bb1b1a..c7a2e0b7d94a 100644 --- a/examples/multiple_data_source_examples/public/components/data_source_view_example.tsx +++ b/examples/multiple_data_source_examples/public/components/data_source_view_example.tsx @@ -18,6 +18,7 @@ import { CoreStart, MountPoint } from 'opensearch-dashboards/public'; import { DataSourceManagementPluginSetup, DataSourceViewConfig, + DataSourceSelectionService, } from 'src/plugins/data_source_management/public'; import { ComponentProp } from './types'; import { COLUMNS } from './constants'; @@ -88,6 +89,7 @@ export const DataSourceViewExample = ({ setSelectedDataSources(ds); }, }} + dataSourceSelection={new DataSourceSelectionService()} /> ); }, [setActionMenu, notifications, savedObjects]); 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 index 23fee253ce08..d9084c4c76ec 100644 --- 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 @@ -950,6 +950,109 @@ exports[`DataSourceAggregatedView error state test no matter hide local cluster `; +exports[`DataSourceAggregatedView: dataSourceSelection) should render normally and call selectDataSource 1`] = ` + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + + +`; + exports[`DataSourceAggregatedView: read active view (displayAllCompatibleDataSources is set to false) should render normally with local cluster and active selections configured 1`] = ` { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -35,6 +36,7 @@ describe('DataSourceAggregatedView: read all view (displayAllCompatibleDataSourc const { toasts } = notificationServiceMock.createStartContract(); const uiSettings = uiSettingsServiceMock.createStartContract(); const application = applicationServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); const nextTick = () => new Promise((res) => process.nextTick(res)); beforeEach(() => { @@ -44,6 +46,7 @@ describe('DataSourceAggregatedView: read all view (displayAllCompatibleDataSourc mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); mockUiSettingsCalls(uiSettings, 'get', 'test1'); jest.spyOn(utils, 'getApplication').mockReturnValue(application); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelection); }); it.each([ @@ -163,6 +166,7 @@ describe('DataSourceAggregatedView: read active view (displayAllCompatibleDataSo let client: SavedObjectsClientContract; const { toasts } = notificationServiceMock.createStartContract(); const uiSettings = uiSettingsServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); const nextTick = () => new Promise((res) => process.nextTick(res)); beforeEach(() => { @@ -171,6 +175,7 @@ describe('DataSourceAggregatedView: read active view (displayAllCompatibleDataSo } as any; mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); mockUiSettingsCalls(uiSettings, 'get', 'test1'); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelection); }); it.each([ @@ -284,6 +289,7 @@ describe('DataSourceAggregatedView empty state test with local cluster hiding', const { toasts } = notificationServiceMock.createStartContract(); const uiSettings = uiSettingsServiceMock.createStartContract(); const application = applicationServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); const nextTick = () => new Promise((res) => process.nextTick(res)); beforeEach(() => { @@ -293,6 +299,7 @@ describe('DataSourceAggregatedView empty state test with local cluster hiding', mockResponseForSavedObjectsCalls(client, 'find', {}); mockUiSettingsCalls(uiSettings, 'get', 'test1'); jest.spyOn(utils, 'getApplication').mockReturnValue(application); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelection); }); afterEach(() => { @@ -369,6 +376,7 @@ describe('DataSourceAggregatedView empty state test due to filter out with local const { toasts } = notificationServiceMock.createStartContract(); const uiSettings = uiSettingsServiceMock.createStartContract(); const application = applicationServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); const nextTick = () => new Promise((res) => process.nextTick(res)); beforeEach(() => { @@ -378,6 +386,7 @@ describe('DataSourceAggregatedView empty state test due to filter out with local mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); mockUiSettingsCalls(uiSettings, 'get', 'test1'); jest.spyOn(utils, 'getApplication').mockReturnValue(application); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelection); }); afterEach(() => { @@ -439,6 +448,7 @@ describe('DataSourceAggregatedView error state test no matter hide local cluster const { toasts } = notificationServiceMock.createStartContract(); const uiSettings = uiSettingsServiceMock.createStartContract(); const application = applicationServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); const nextTick = () => new Promise((res) => process.nextTick(res)); beforeEach(() => { @@ -448,6 +458,7 @@ describe('DataSourceAggregatedView error state test no matter hide local cluster mockErrorResponseForSavedObjectsCalls(client, 'find'); mockUiSettingsCalls(uiSettings, 'get', 'test1'); jest.spyOn(utils, 'getApplication').mockReturnValue(application); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelection); }); afterEach(() => { @@ -514,6 +525,7 @@ describe('DataSourceAggregatedView error state test no matter hide local cluster describe('DataSourceAggregatedView warning messages', () => { const client = {} as any; const uiSettings = uiSettingsServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); const nextTick = () => new Promise((res) => process.nextTick(res)); let toasts: IToasts; const noDataSourcesConnectedMessage = `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; @@ -522,6 +534,7 @@ describe('DataSourceAggregatedView warning messages', () => { beforeEach(() => { toasts = notificationServiceMock.createStartContract().toasts; mockUiSettingsCalls(uiSettings, 'get', 'test1'); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelection); }); it.each([ @@ -571,3 +584,47 @@ describe('DataSourceAggregatedView warning messages', () => { } ); }); + +describe('DataSourceAggregatedView: dataSourceSelection)', () => { + let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); + dataSourceSelection.selectDataSource = jest.fn(); + const nextTick = () => new Promise((res) => process.nextTick(res)); + const activeDataSourceIds = ['test1', 'test2']; + const selectedOptions = [ + { checked: 'on', disabled: true, id: 'test1', label: 'test1' }, + { checked: 'on', disabled: true, id: 'test2', label: 'test2' }, + ]; + const componentId = 'component-id'; + beforeEach(() => { + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); + mockUiSettingsCalls(uiSettings, 'get', 'test1'); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelection); + jest.spyOn(utils, 'generateComponentId').mockReturnValue(componentId); + }); + + it('should render normally and call selectDataSource', async () => { + const component = shallow( + + ); + + // Should render normally + expect(component).toMatchSnapshot(); + await nextTick(); + + expect(dataSourceSelection.selectDataSource).toHaveBeenCalledWith(componentId, selectedOptions); + }); +}); 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 index 1d48965fa041..1100f55e8e49 100644 --- 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 @@ -16,6 +16,8 @@ import { getDataSourcesWithFields, handleDataSourceFetchError, handleNoAvailableDataSourceError, + generateComponentId, + getDataSourceSelection, } from '../utils'; import { SavedObject } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; @@ -46,6 +48,7 @@ interface DataSourceAggregatedViewState extends DataSourceBaseState { switchChecked: boolean; defaultDataSource: string | null; incompatibleDataSourcesExist: boolean; + componentId: string; } interface DataSourceOptionDisplay extends DataSourceOption { @@ -70,11 +73,13 @@ export class DataSourceAggregatedView extends React.Component< switchChecked: false, defaultDataSource: null, incompatibleDataSourcesExist: false, + componentId: generateComponentId(), }; } componentWillUnmount() { this._isMounted = false; + getDataSourceSelection().remove(this.state.componentId); } onDataSourcesClick() { @@ -188,7 +193,11 @@ export class DataSourceAggregatedView extends React.Component< }); } - const numSelectedItems = items.filter((item) => item.checked === 'on').length; + const selectedItems = items.filter((item) => item.checked === 'on'); + // For read-only cases, also need to set default selected result. + getDataSourceSelection().selectDataSource(this.state.componentId, selectedItems); + + const numSelectedItems = selectedItems.length; const titleComponent = ( { let client: SavedObjectsClientContract; const notifications = notificationServiceMock.createStartContract(); const { uiSettings } = coreMock.createSetup(); + const dataSourceSelection = new DataSourceSelectionService(); beforeAll(() => { jest @@ -47,6 +49,8 @@ describe('create data source menu', () => { spyOn(utils, 'getApplication').and.returnValue({ id: 'test2' }); spyOn(utils, 'getUiSettings').and.returnValue(uiSettings); spyOn(utils, 'getHideLocalCluster').and.returnValue({ enabled: true }); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + const TestComponent = createDataSourceMenu(); const component = render(); @@ -74,6 +78,7 @@ describe('create data source menu', () => { spyOn(utils, 'getApplication').and.returnValue({ id: 'test2' }); spyOn(utils, 'getUiSettings').and.returnValue(uiSettings); spyOn(utils, 'getHideLocalCluster').and.returnValue({ enabled: true }); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); const TestComponent = createDataSourceMenu(); await act(async () => { component = render(); @@ -98,6 +103,7 @@ describe('when setMenuMountPoint is provided', () => { let client: SavedObjectsClientContract; const notifications = notificationServiceMock.createStartContract(); const { uiSettings } = coreMock.createSetup(); + const dataSourceSelection = new DataSourceSelectionService(); const refresh = () => { new Promise(async (resolve) => { @@ -141,6 +147,7 @@ describe('when setMenuMountPoint is provided', () => { spyOn(utils, 'getApplication').and.returnValue({ id: 'test2' }); spyOn(utils, 'getUiSettings').and.returnValue(uiSettings); spyOn(utils, 'getHideLocalCluster').and.returnValue({ enabled: true }); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); const TestComponent = createDataSourceMenu(); const component = render(); diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx index a628a1bf2a15..1d053c0449b7 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx @@ -14,7 +14,9 @@ export function createDataSourceMenu() { const application = getApplication(); const uiSettings = getUiSettings(); const hideLocalCluster = getHideLocalCluster().enabled; - return (props: DataSourceMenuProps) => { + return ( + props: Omit, 'uiSettings' | 'hideLocalCluster' | 'application'> + ) => { if (props.setMenuMountPoint) { return ( diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx index c8325a5b14b4..6ed297f9f014 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx @@ -11,6 +11,7 @@ import { DataSourceMenu } from './data_source_menu'; import { render } from '@testing-library/react'; import { DataSourceComponentType } from './types'; import * as utils from '../utils'; +import { DataSourceSelectionService } from '../../service/data_source_selection_service'; describe('DataSourceMenu', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -18,11 +19,13 @@ describe('DataSourceMenu', () => { let client: SavedObjectsClientContract; const notifications = notificationServiceMock.createStartContract(); const application = applicationServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); beforeEach(() => { client = { find: jest.fn().mockResolvedValue([]), } as any; + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); }); it('should render data source selectable only with local cluster not hidden', () => { diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.test.tsx b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.test.tsx index 7a9f1f96ed0c..5ccf6d5d9dce 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.test.tsx @@ -4,7 +4,7 @@ */ import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; -import { fatalErrorsServiceMock, notificationServiceMock } from '../../../../../core/public/mocks'; +import { notificationServiceMock } from '../../../../../core/public/mocks'; import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls, @@ -14,6 +14,8 @@ import { ShallowWrapper, mount, shallow } from 'enzyme'; import { DataSourceMultiSelectable } from './data_source_multi_selectable'; import React from 'react'; import { render, fireEvent, screen } from '@testing-library/react'; +import { DataSourceSelectionService } from '../../service/data_source_selection_service'; +import * as utils from '../utils'; describe('DataSourceMultiSelectable', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -23,6 +25,7 @@ describe('DataSourceMultiSelectable', () => { const nextTick = () => new Promise((res) => process.nextTick(res)); const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); const uiSettings = mockedContext.uiSettings; + const dataSourceSelection = new DataSourceSelectionService(); beforeEach(() => { jest.clearAllMocks(); @@ -31,6 +34,7 @@ describe('DataSourceMultiSelectable', () => { find: jest.fn().mockResolvedValue([]), } as any; mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); }); it('should render normally with local cluster not hidden', () => { @@ -185,4 +189,39 @@ describe('DataSourceMultiSelectable', () => { expect(wrapper.state('selectedOptions')).toHaveLength(1); expect(wrapper.state('showEmptyState')).toBe(false); }); + + it('should call dataSourceSelection selectDataSource when selecting', async () => { + const dataSourceSelectionMock = new DataSourceSelectionService(); + const componentId = 'component-id'; + const selectedOptions = [ + { checked: 'on', id: 'test1', label: 'test1', visible: true }, + { checked: 'on', id: 'test2', label: 'test2', visible: true }, + { checked: 'on', id: 'test3', label: 'test3', visible: true }, + ]; + dataSourceSelectionMock.selectDataSource = jest.fn(); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelectionMock); + jest.spyOn(utils, 'generateComponentId').mockReturnValue(componentId); + + const container = render( + + ); + + await component.instance().componentDidMount!(); + expect(dataSourceSelectionMock.selectDataSource).toHaveBeenCalledWith( + componentId, + selectedOptions + ); + + const button = await container.findByTestId('dataSourceFilterGroupButton'); + button.click(); + fireEvent.click(screen.getByText('Deselect all')); + + expect(dataSourceSelectionMock.selectDataSource).toHaveBeenCalledWith(componentId, []); + }); }); diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx index 481df093d741..995a5f3ca731 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx @@ -16,6 +16,8 @@ import { getDataSourcesWithFields, handleDataSourceFetchError, handleNoAvailableDataSourceError, + generateComponentId, + getDataSourceSelection, } from '../utils'; import { DataSourceBaseState } from '../data_source_menu/types'; import { DataSourceErrorMenu } from '../data_source_error_menu'; @@ -35,6 +37,7 @@ interface DataSourceMultiSeletableState extends DataSourceBaseState { selectedOptions: SelectedDataSourceOption[]; defaultDataSource: string | null; incompatibleDataSourcesExist: boolean; + componentId: string; } export class DataSourceMultiSelectable extends React.Component< @@ -53,11 +56,21 @@ export class DataSourceMultiSelectable extends React.Component< showEmptyState: false, showError: false, incompatibleDataSourcesExist: false, + componentId: generateComponentId(), }; } componentWillUnmount() { this._isMounted = false; + const { componentId } = this.state; + getDataSourceSelection().remove(componentId); + } + + onSelectedDataSources(dataSources: SelectedDataSourceOption[]) { + getDataSourceSelection().selectDataSource(this.state.componentId, dataSources); + if (this.props.onSelectedDataSources) { + this.props.onSelectedDataSources(dataSources); + } } async componentDidMount() { @@ -96,7 +109,7 @@ export class DataSourceMultiSelectable extends React.Component< changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), notifications: this.props.notifications, application: this.props.application, - callback: this.props.onSelectedDataSources, + callback: this.onSelectedDataSources.bind(this), incompatibleDataSourcesExist: !!fetchedDataSources?.length, }); return; @@ -108,12 +121,12 @@ export class DataSourceMultiSelectable extends React.Component< defaultDataSource, }); - this.props.onSelectedDataSources(selectedOptions); + this.onSelectedDataSources(selectedOptions); } catch (error) { handleDataSourceFetchError( this.onError.bind(this), this.props.notifications, - this.props.onSelectedDataSources + this.onSelectedDataSources.bind(this) ); } } @@ -131,7 +144,7 @@ export class DataSourceMultiSelectable extends React.Component< this.setState({ selectedOptions, }); - this.props.onSelectedDataSources(selectedOptions.filter((option) => option.checked === 'on')); + this.onSelectedDataSources(selectedOptions.filter((option) => option.checked === 'on')); } render() { diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx index 6521aaddb258..a880a99ed71a 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.test.tsx @@ -19,21 +19,28 @@ import { NO_COMPATIBLE_DATASOURCES_MESSAGE, ADD_COMPATIBLE_DATASOURCES_MESSAGE, } from '../constants'; +import { DataSourceSelectionService } from '../../service/data_source_selection_service'; + +const mockGeneratedComponentId = 'component-id'; +jest.mock('uuid', () => ({ v4: () => mockGeneratedComponentId })); describe('DataSourceSelectable', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); const nextTick = () => new Promise((res) => process.nextTick(res)); const noDataSourcesConnectedMessage = `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; const noCompatibleDataSourcesMessage = `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}`; + const dataSourceSelection = new DataSourceSelectionService(); beforeEach(() => { client = { find: jest.fn().mockResolvedValue([]), } as any; mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); }); it('should render normally with local cluster not hidden', () => { @@ -138,6 +145,7 @@ describe('DataSourceSelectable', () => { containerInstance.onChange([{ id: 'test2', label: 'test2' }]); expect(onSelectedDataSource).toBeCalledTimes(1); expect(containerInstance.state).toEqual({ + componentId: mockGeneratedComponentId, dataSourceOptions: [ { id: 'test2', @@ -159,6 +167,7 @@ describe('DataSourceSelectable', () => { containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); expect(containerInstance.state).toEqual({ + componentId: mockGeneratedComponentId, dataSourceOptions: [ { checked: 'on', @@ -331,6 +340,7 @@ describe('DataSourceSelectable', () => { await nextTick(); const containerInstance = container.instance(); expect(containerInstance.state).toEqual({ + componentId: mockGeneratedComponentId, dataSourceOptions: [ { id: 'test1', @@ -380,6 +390,7 @@ describe('DataSourceSelectable', () => { expect(onSelectedDataSource).toBeCalledWith([]); expect(containerInstance.state).toEqual({ + componentId: mockGeneratedComponentId, dataSourceOptions: [], defaultDataSource: null, isPopoverOpen: false, @@ -391,6 +402,7 @@ describe('DataSourceSelectable', () => { containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); expect(containerInstance.state).toEqual({ + componentId: mockGeneratedComponentId, dataSourceOptions: [ { checked: 'on', @@ -464,4 +476,30 @@ describe('DataSourceSelectable', () => { expect(onSelectedDataSource).toBeCalledWith([]); } ); + + it('should call dataSourceSelection selectDataSource when selecting', async () => { + spyOn(utils, 'getDefaultDataSource').and.returnValue([{ id: 'test2', label: 'test2' }]); + const dataSourceSelectionMock = new DataSourceSelectionService(); + const componentId = 'component-id'; + const selectedOptions = [{ id: 'test2', label: 'test2' }]; + dataSourceSelectionMock.selectDataSource = jest.fn(); + jest.spyOn(utils, 'getDataSourceSelection').mockReturnValue(dataSourceSelectionMock); + jest.spyOn(utils, 'generateComponentId').mockReturnValue(componentId); + mount( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + await nextTick(); + expect(dataSourceSelectionMock.selectDataSource).toHaveBeenCalledWith( + componentId, + selectedOptions + ); + }); }); diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index fd1c685676f0..b181da74e542 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -24,6 +24,8 @@ import { getFilteredDataSources, handleDataSourceFetchError, handleNoAvailableDataSourceError, + generateComponentId, + getDataSourceSelection, } from '../utils'; import { LocalCluster } from '../data_source_selector/data_source_selector'; import { SavedObject } from '../../../../../core/public'; @@ -57,6 +59,7 @@ interface DataSourceSelectableState extends DataSourceBaseState { defaultDataSource: string | null; incompatibleDataSourcesExist: boolean; selectedOption?: DataSourceOption[]; + componentId: string; } export class DataSourceSelectable extends React.Component< @@ -76,6 +79,7 @@ export class DataSourceSelectable extends React.Component< showEmptyState: false, showError: false, incompatibleDataSourcesExist: false, + componentId: generateComponentId(), }; this.onChange.bind(this); @@ -83,6 +87,7 @@ export class DataSourceSelectable extends React.Component< componentWillUnmount() { this._isMounted = false; + getDataSourceSelection().remove(this.state.componentId); } onClick() { @@ -93,6 +98,11 @@ export class DataSourceSelectable extends React.Component< this.setState({ ...this.state, isPopoverOpen: false }); } + onSelectedDataSources(dataSources: DataSourceOption[]) { + getDataSourceSelection().selectDataSource(this.state.componentId, dataSources); + this.props.onSelectedDataSources(dataSources); + } + // Update the checked status of the selected data source. getUpdatedDataSourceOptions( selectedDataSourceId: string, @@ -119,7 +129,7 @@ export class DataSourceSelectable extends React.Component< selectedOption: [], defaultDataSource, }); - this.props.onSelectedDataSources([]); + this.onSelectedDataSources([]); return; } const updatedDataSourceOptions: DataSourceOption[] = this.getUpdatedDataSourceOptions( @@ -132,7 +142,7 @@ export class DataSourceSelectable extends React.Component< selectedOption: [{ id, label: dsOption.label }], defaultDataSource, }); - this.props.onSelectedDataSources([{ id, label: dsOption.label }]); + this.onSelectedDataSources([{ id, label: dsOption.label }]); } handleDefaultDataSource(dataSourceOptions: DataSourceOption[], defaultDataSource: string | null) { @@ -146,7 +156,7 @@ export class DataSourceSelectable extends React.Component< // no active option, show warning if (selectedDataSource.length === 0) { this.props.notifications.addWarning('No connected data source available.'); - this.props.onSelectedDataSources([]); + this.onSelectedDataSources([]); return; } @@ -162,7 +172,7 @@ export class DataSourceSelectable extends React.Component< defaultDataSource, }); - this.props.onSelectedDataSources(selectedDataSource); + this.onSelectedDataSources(selectedDataSource); } async componentDidMount() { @@ -193,7 +203,7 @@ export class DataSourceSelectable extends React.Component< changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), notifications: this.props.notifications, application: this.props.application, - callback: this.props.onSelectedDataSources, + callback: this.onSelectedDataSources.bind(this), incompatibleDataSourcesExist: !!fetchedDataSources?.length, }); return; @@ -212,7 +222,7 @@ export class DataSourceSelectable extends React.Component< handleDataSourceFetchError( this.onError.bind(this), this.props.notifications, - this.props.onSelectedDataSources + this.onSelectedDataSources.bind(this) ); } } @@ -237,9 +247,7 @@ export class DataSourceSelectable extends React.Component< isPopoverOpen: false, }); - this.props.onSelectedDataSources([ - { id: selectedDataSource.id!, label: selectedDataSource.label }, - ]); + this.onSelectedDataSources([{ id: selectedDataSource.id!, label: selectedDataSource.label }]); } } diff --git a/src/plugins/data_source_management/public/components/data_source_selector/create_data_source_selector.test.tsx b/src/plugins/data_source_management/public/components/data_source_selector/create_data_source_selector.test.tsx index 9049c6767c7c..06bd223d60eb 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/create_data_source_selector.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/create_data_source_selector.test.tsx @@ -12,6 +12,8 @@ import { mockDataSourcePluginSetupWithHideLocalCluster, mockDataSourcePluginSetupWithShowLocalCluster, } from '../../mocks'; +import { DataSourceSelectionService } from '../../service/data_source_selection_service'; +import * as utils from '../utils'; describe('create data source selector', () => { let client: SavedObjectsClientContract; @@ -33,6 +35,9 @@ describe('create data source selector', () => { hideLocalCluster: false, fullWidth: false, }; + const dataSourceSelection = new DataSourceSelectionService(); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + const TestComponent = createDataSourceSelector( uiSettings, mockDataSourcePluginSetupWithHideLocalCluster @@ -56,6 +61,9 @@ describe('create data source selector', () => { hideLocalCluster: true, fullWidth: false, }; + const dataSourceSelection = new DataSourceSelectionService(); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + const TestComponent = createDataSourceSelector( uiSettings, mockDataSourcePluginSetupWithShowLocalCluster diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx index 4f8df2542059..a99ccb4d5908 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.test.tsx @@ -15,12 +15,15 @@ import { } from '../../mocks'; import { AuthType } from 'src/plugins/data_source/common/data_sources'; import { EuiComboBox } from '@elastic/eui'; +import { DataSourceSelectionService } from '../../service/data_source_selection_service'; +import * as utils from '../utils'; describe('DataSourceSelector', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; let client: SavedObjectsClientContract; const { toasts } = notificationServiceMock.createStartContract(); + const dataSourceSelection = new DataSourceSelectionService(); beforeEach(() => { jest.clearAllMocks(); @@ -30,6 +33,7 @@ describe('DataSourceSelector', () => { }); it('should render normally with local cluster not hidden', () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { }); it('should render normally with local cluster is hidden', () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { const nextTick = () => new Promise((res) => process.nextTick(res)); const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); const uiSettings = mockedContext.uiSettings; + const dataSourceSelection = new DataSourceSelectionService(); beforeEach(async () => { jest.clearAllMocks(); @@ -88,6 +94,7 @@ describe('DataSourceSelector: check dataSource options', () => { }); it('should always place local cluster option as the first option when local cluster not hidden', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { }); it('should hide prepend if removePrepend is true', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { }); it('should show custom placeholder text if configured', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { }); it('should filter options if configured', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { }); it('should return empty options if filter out all options and hide local cluster', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { }); it('should get default datasource if uiSettings exists', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); spyOn(uiSettings, 'get').and.returnValue('test1'); component = shallow( { }); it('should not render options with default badge when id does not matches defaultDataSource', () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { const nextTick = () => new Promise((res) => process.nextTick(res)); const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); const uiSettings = mockedContext.uiSettings; + const dataSourceSelection = new DataSourceSelectionService(); const getMockedDataSourceOptions = () => { return getDataSourcesWithFieldsResponse.savedObjects.map((response) => { return { id: response.id, label: response.attributes.title }; @@ -245,6 +259,7 @@ describe('DataSourceSelector: check defaultOption behavior', () => { // When defaultOption is undefined it('should render defaultDataSource as the selected option', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); spyOn(uiSettings, 'get').and.returnValue('test1'); component = shallow( { }); it('should render Local Cluster as the selected option when hideLocalCluster is false', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); spyOn(uiSettings, 'get').and.returnValue(null); component = shallow( { }); it('should render random datasource as the selected option if defaultDataSource and Local Cluster are not present', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); spyOn(uiSettings, 'get').and.returnValue(null); component = shallow( { }); it('should return toast', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); spyOn(uiSettings, 'get').and.returnValue(null); component = shallow( { // When defaultOption is [] it('should render placeholder and all options when Local Cluster is not hidden', async () => { spyOn(uiSettings, 'get').and.returnValue('test1'); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { }); it('should render placeholder and all options when Local Cluster is hidden', async () => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); spyOn(uiSettings, 'get').and.returnValue('test1'); component = shallow( { id: 'non-existent-id', }, ])('should all throw a toast warning when the available dataSources is empty', async ({ id }) => { + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); spyOn(uiSettings, 'get').and.returnValue('test1'); component = shallow( { }, ])('should all throw a toast warning when the id is filtered out', async ({ id }) => { spyOn(uiSettings, 'get').and.returnValue('test1'); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { dataSourceFilter={(dataSource) => { return dataSource.attributes.title !== id; }} + dataSourceSelection={dataSourceSelection} // @ts-expect-error defaultOption={[{ id }]} /> @@ -490,6 +513,7 @@ describe('DataSourceSelector: check defaultOption behavior', () => { 'should handle selectedOption correctly when defaultOption = [{id}]', async ({ id, error, selectedOption }) => { spyOn(uiSettings, 'get').and.returnValue('test1'); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); component = shallow( { hideLocalCluster={false} fullWidth={false} uiSettings={uiSettings} + dataSourceSelection={dataSourceSelection} // @ts-expect-error defaultOption={[{ id }]} /> diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx index 8d2a90943b9f..5d9c1b17fbd9 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx @@ -8,7 +8,13 @@ import { i18n } from '@osd/i18n'; import { EuiComboBox } from '@elastic/eui'; import { SavedObjectsClientContract, ToastsStart, SavedObject } from 'opensearch-dashboards/public'; import { IUiSettingsClient } from 'src/core/public'; -import { getDataSourcesWithFields, getDefaultDataSource, getFilteredDataSources } from '../utils'; +import { + getDataSourcesWithFields, + getDefaultDataSource, + getFilteredDataSources, + generateComponentId, + getDataSourceSelection, +} from '../utils'; import { DataSourceAttributes } from '../../types'; import { DataSourceItem } from '../data_source_item'; import './data_source_selector.scss'; @@ -42,6 +48,7 @@ interface DataSourceSelectorState { allDataSources: Array>; defaultDataSource: string | null; dataSourceOptions: DataSourceOption[]; + componentId: string; } export class DataSourceSelector extends React.Component< @@ -58,11 +65,18 @@ export class DataSourceSelector extends React.Component< defaultDataSource: '', selectedOption: this.props.hideLocalCluster ? [] : [LocalCluster], dataSourceOptions: [], + componentId: generateComponentId(), }; } componentWillUnmount() { this._isMounted = false; + getDataSourceSelection().remove(this.state.componentId); + } + + onSelectedDataSource(dataSource: DataSourceOption[]) { + getDataSourceSelection().selectDataSource(this.state.componentId, dataSource); + this.props.onSelectedDataSource(dataSource); } handleSelectedOption( @@ -90,7 +104,7 @@ export class DataSourceSelector extends React.Component< defaultDataSource, allDataSources, }); - this.props.onSelectedDataSource(selectedOption); + this.onSelectedDataSource(selectedOption); } handleDefaultDataSource( @@ -108,7 +122,7 @@ export class DataSourceSelector extends React.Component< // No active option, did not find valid option if (selectedDataSource.length === 0) { this.props.notifications.addWarning('No connected data source available.'); - this.props.onSelectedDataSource([]); + this.onSelectedDataSource([]); return; } @@ -119,7 +133,7 @@ export class DataSourceSelector extends React.Component< defaultDataSource, allDataSources, }); - this.props.onSelectedDataSource(selectedDataSource); + this.onSelectedDataSource(selectedDataSource); } async componentDidMount() { @@ -146,7 +160,7 @@ export class DataSourceSelector extends React.Component< // 4. Error state if filter filters out everything if (!dataSourceOptions.length) { this.props.notifications.addWarning('No connected data source available.'); - this.props.onSelectedDataSource([]); + this.onSelectedDataSource([]); return; } @@ -185,7 +199,7 @@ export class DataSourceSelector extends React.Component< this.setState({ selectedOption: e, }); - this.props.onSelectedDataSource(e); + this.onSelectedDataSource(e); } render() { diff --git a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.test.tsx b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.test.tsx index 07c36e414141..a77b972ee00b 100644 --- a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.test.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ShallowWrapper, shallow, mount } from 'enzyme'; +import { ShallowWrapper, shallow } from 'enzyme'; import React from 'react'; import { DataSourceView } from './data_source_view'; import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; @@ -11,10 +11,12 @@ import { notificationServiceMock } from '../../../../../core/public/mocks'; import { getSingleDataSourceResponse, mockResponseForSavedObjectsCalls } from '../../mocks'; import { render } from '@testing-library/react'; import * as utils from '../utils'; +import { DataSourceSelectionService } from '../../service/data_source_selection_service'; describe('DataSourceView', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; let client: SavedObjectsClientContract; + const dataSourceSelection = new DataSourceSelectionService(); const { toasts } = notificationServiceMock.createStartContract(); beforeEach(() => { @@ -23,6 +25,7 @@ describe('DataSourceView', () => { get: jest.fn().mockResolvedValue([]), } as any; mockResponseForSavedObjectsCalls(client, 'get', getSingleDataSourceResponse); + spyOn(utils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); }); it('should render normally with local cluster not hidden', () => { diff --git a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx index f0d53ab92ddf..6080d91d0e4f 100644 --- a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx +++ b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx @@ -4,14 +4,7 @@ */ import React from 'react'; -import { i18n } from '@osd/i18n'; -import { - EuiPopover, - EuiButtonEmpty, - EuiContextMenuPanel, - EuiPanel, - EuiSelectable, -} from '@elastic/eui'; +import { EuiPopover, EuiContextMenuPanel, EuiPanel, EuiSelectable } from '@elastic/eui'; import { SavedObjectsClientContract, ToastsStart, @@ -20,7 +13,12 @@ import { import { IUiSettingsClient } from 'src/core/public'; import { DataSourceBaseState, DataSourceOption } from '../data_source_menu/types'; import { DataSourceErrorMenu } from '../data_source_error_menu'; -import { getDataSourceById, handleDataSourceFetchError } from '../utils'; +import { + getDataSourceById, + handleDataSourceFetchError, + generateComponentId, + getDataSourceSelection, +} from '../utils'; import { DataSourceDropDownHeader } from '../drop_down_header'; import { DataSourceItem } from '../data_source_item'; import { LocalCluster } from '../constants'; @@ -43,6 +41,7 @@ interface DataSourceViewState extends DataSourceBaseState { selectedOption: DataSourceOption[]; isPopoverOpen: boolean; defaultDataSource: string | null; + componentId: string; } export class DataSourceView extends React.Component { @@ -57,11 +56,13 @@ export class DataSourceView extends React.Component { expect(result).toEqual([{ id: '1', label: 'DataSource 1' }]); }); }); + + describe('getDataSourceSelection and setDataSourceSelection', () => { + it('should not throw error and return default fallback dataSourceSelection if value is not set', () => { + const result = getDataSourceSelection(); + expect(result).toEqual(defaultDataSourceSelection); + }); + + it('should return value normally if value is set', () => { + const dataSourceSelection = new DataSourceSelectionService(); + setDataSourceSelection(dataSourceSelection); + const result = getDataSourceSelection(); + expect(result).toEqual(dataSourceSelection); + }); + }); }); diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 7a4ec06d5cfb..ac048db0587b 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -13,6 +13,7 @@ import { CoreStart, } from 'src/core/public'; import { deepFreeze } from '@osd/std'; +import uuid from 'uuid'; import { DataSourceAttributes, DataSourceTableItem, @@ -31,6 +32,10 @@ import { NO_COMPATIBLE_DATASOURCES_MESSAGE, NO_DATASOURCES_CONNECTED_MESSAGE, } from './constants'; +import { + DataSourceSelectionService, + defaultDataSourceSelection, +} from '../service/data_source_selection_service'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -341,3 +346,24 @@ export interface HideLocalCluster { export const [getHideLocalCluster, setHideLocalCluster] = createGetterSetter( 'HideLocalCluster' ); + +// This will maintain an unified data source selection instance among components and export it to other plugin. +const [getDataSourceSelectionInstance, setDataSourceSelection] = createGetterSetter< + DataSourceSelectionService +>('DataSourceSelectionService'); + +const getDataSourceSelection = () => { + try { + // Usually set will be executed in the setup of DSM. + return getDataSourceSelectionInstance(); + } catch (e) { + // Since createGetterSetter doesn't support default value and will throw error if not found. + // As dataSourceSelection isn't main part of data selector, will use a default to fallback safely. + return defaultDataSourceSelection; + } +}; +export { getDataSourceSelection, setDataSourceSelection }; + +export const generateComponentId = () => { + return uuid.v4(); +}; diff --git a/src/plugins/data_source_management/public/index.ts b/src/plugins/data_source_management/public/index.ts index 0cc7370d2cdc..2607f5b8c7f9 100644 --- a/src/plugins/data_source_management/public/index.ts +++ b/src/plugins/data_source_management/public/index.ts @@ -24,3 +24,4 @@ export { DataSourceMultiSelectableConfig, createDataSourceMenu, } from './components/data_source_menu'; +export { DataSourceSelectionService } from './service/data_source_selection_service'; diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 7f0eb9760be2..2f08b942db15 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -21,7 +21,13 @@ import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod import { DataSourceSelectorProps } from './components/data_source_selector/data_source_selector'; import { createDataSourceMenu } from './components/data_source_menu/create_data_source_menu'; import { DataSourceMenuProps } from './components/data_source_menu'; -import { setApplication, setHideLocalCluster, setUiSettings } from './components/utils'; +import { + setApplication, + setHideLocalCluster, + setUiSettings, + setDataSourceSelection, +} from './components/utils'; +import { DataSourceSelectionService } from './service/data_source_selection_service'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -35,6 +41,7 @@ export interface DataSourceManagementPluginSetup { DataSourceSelector: React.ComponentType; getDataSourceMenu: () => React.ComponentType>; }; + dataSourceSelection: DataSourceSelectionService; } export interface DataSourceManagementPluginStart { @@ -53,6 +60,7 @@ export class DataSourceManagementPlugin > { private started = false; private authMethodsRegistry = new AuthenticationMethodRegistry(); + private dataSourceSelection = new DataSourceSelectionService(); public setup( core: CoreSetup, @@ -104,9 +112,13 @@ export class DataSourceManagementPlugin setHideLocalCluster({ enabled: dataSource.hideLocalCluster }); setUiSettings(uiSettings); + // This instance will be got in each data source selector component. + setDataSourceSelection(this.dataSourceSelection); return { registerAuthenticationMethod, + // Other plugins can get this instance from setupDeps and use to get selected data sources. + dataSourceSelection: this.dataSourceSelection, ui: { DataSourceSelector: createDataSourceSelector(uiSettings, dataSource), getDataSourceMenu: () => createDataSourceMenu(), diff --git a/src/plugins/data_source_management/public/service/data_source_selection_service.test.ts b/src/plugins/data_source_management/public/service/data_source_selection_service.test.ts new file mode 100644 index 000000000000..65957379517f --- /dev/null +++ b/src/plugins/data_source_management/public/service/data_source_selection_service.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { DataSourceSelectionService } from './data_source_selection_service'; +import { generateComponentId } from '../components/utils'; + +describe('DataSourceSelectionService service', () => { + it('basic set, get and remove', async () => { + const dataSourceSelection = new DataSourceSelectionService(); + const id = generateComponentId(); + const dataSource = { id: 'id', label: 'label' }; + expect(dataSourceSelection.getSelectionValue().get(id)).toBe(undefined); + dataSourceSelection.selectDataSource(id, [dataSource]); + expect(dataSourceSelection.getSelectionValue().get(id)).toStrictEqual([dataSource]); + dataSourceSelection.remove(id); + expect(dataSourceSelection.getSelectionValue().get(id)).toBe(undefined); + }); + + it('multiple set and get', async () => { + const dataSourceSelection = new DataSourceSelectionService(); + const id1 = generateComponentId(); + const id2 = generateComponentId(); + + const dataSource = { id: 'id', label: 'label' }; + expect(dataSourceSelection.getSelectionValue().get(id1)).toBe(undefined); + expect(dataSourceSelection.getSelectionValue().get(id2)).toBe(undefined); + dataSourceSelection.selectDataSource(id1, [dataSource]); + dataSourceSelection.selectDataSource(id2, [dataSource]); + expect(dataSourceSelection.getSelectionValue().get(id1)).toStrictEqual([dataSource]); + expect(dataSourceSelection.getSelectionValue().get(id2)).toStrictEqual([dataSource]); + dataSourceSelection.remove(id1); + expect(dataSourceSelection.getSelectionValue().get(id1)).toBe(undefined); + expect(dataSourceSelection.getSelectionValue().get(id2)).toStrictEqual([dataSource]); + }); + + it('support subscribing selected observable', (done) => { + const dataSourceSelection = new DataSourceSelectionService(); + const selectedDataSource$ = dataSourceSelection.getSelection$(); + const id = generateComponentId(); + const dataSource = { id: 'id', label: 'label' }; + dataSourceSelection.selectDataSource(id, [dataSource]); + selectedDataSource$.subscribe((newValue) => { + expect(newValue.get(id)).toStrictEqual([dataSource]); + done(); + }); + }); +}); diff --git a/src/plugins/data_source_management/public/service/data_source_selection_service.ts b/src/plugins/data_source_management/public/service/data_source_selection_service.ts new file mode 100644 index 000000000000..560216d88c3a --- /dev/null +++ b/src/plugins/data_source_management/public/service/data_source_selection_service.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { DataSourceOption } from '../components/data_source_menu/types'; + +export class DataSourceSelectionService { + private selectedDataSource$ = new BehaviorSubject(new Map()); + + public selectDataSource = (componentId: string, dataSource: DataSourceOption[]) => { + const newMap = new Map(this.selectedDataSource$.value); + newMap.set(componentId, dataSource); + this.selectedDataSource$.next(newMap); + }; + + public remove = (componentId: string) => { + const newMap = new Map(this.selectedDataSource$.value); + newMap.delete(componentId); + this.selectedDataSource$.next(newMap); + }; + + public getSelectionValue = () => { + return this.selectedDataSource$.value; + }; + + // Plugins can use returned subject to subscribe update. + public getSelection$ = () => { + return this.selectedDataSource$; + }; +} + +// This is an empty instance of DataSourceSelection for fallback. +export const defaultDataSourceSelection = { + selectDataSource: () => {}, + remove: () => {}, + getSelectionValue: () => {}, + getSelection$: () => {}, +}; diff --git a/src/plugins/home/public/application/components/tutorial_directory.test.tsx b/src/plugins/home/public/application/components/tutorial_directory.test.tsx index eacd50fc43d0..6114f463304d 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.test.tsx +++ b/src/plugins/home/public/application/components/tutorial_directory.test.tsx @@ -9,6 +9,8 @@ import { IntlProvider } from 'react-intl'; import { coreMock } from '../../../../../core/public/mocks'; import { setServices } from '../opensearch_dashboards_services'; import { getMockedServices } from '../opensearch_dashboards_services.mock'; +import * as utils from '../../../../../plugins/data_source_management/public/components/utils'; +import { DataSourceSelectionService } from '../../../../../plugins/data_source_management/public'; const makeProps = () => { const coreMocks = coreMock.createStart(); @@ -28,6 +30,8 @@ describe('', () => { it('should render home breadcrumbs when withoutHomeBreadCrumb is undefined', async () => { const finalProps = makeProps(); currentService.http.get.mockResolvedValueOnce([]); + spyOn(utils, 'getDataSourceSelection').and.returnValue(new DataSourceSelectionService()); + // @ts-ignore const { TutorialDirectory } = await import('./tutorial_directory'); render( @@ -49,6 +53,8 @@ describe('', () => { it('should not render home breadcrumbs when withoutHomeBreadCrumb is true', async () => { const finalProps = makeProps(); currentService.http.get.mockResolvedValueOnce([]); + spyOn(utils, 'getDataSourceSelection').and.returnValue(new DataSourceSelectionService()); + // @ts-ignore const { TutorialDirectory } = await import('./tutorial_directory'); render( diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap index 7a7063db0601..6397e6269129 100644 --- a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu.test.tsx.snap @@ -45,6 +45,23 @@ exports[`TopNavMenu mounts the data source menu as well as top nav menu 1`] = ` } } componentType="DataSourceView" + dataSourceSelection={ + DataSourceSelectionService { + "getSelection$": [Function], + "getSelectionValue": [Function], + "remove": [Function], + "selectDataSource": [Function], + "selectedDataSource$": BehaviorSubject { + "_isScalar": false, + "_value": Map {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + } + } /> @@ -71,6 +88,23 @@ exports[`TopNavMenu mounts the data source menu if showDataSourceMenu is true 1` } } componentType="DataSourceView" + dataSourceSelection={ + DataSourceSelectionService { + "getSelection$": [Function], + "getSelectionValue": [Function], + "remove": [Function], + "selectDataSource": [Function], + "selectedDataSource$": BehaviorSubject { + "_isScalar": false, + "_value": Map {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + } + } /> diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index a63aaf4d60d2..99644e49bde0 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -36,6 +36,7 @@ import { TopNavMenu } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; import * as testUtils from '../../../data_source_management/public/components/utils'; +import { DataSourceSelectionService } from '../../../data_source_management/public/service/data_source_selection_service'; const dataShim = { ui: { @@ -63,6 +64,7 @@ describe('TopNavMenu', () => { run: jest.fn(), }, ]; + const dataSourceSelection = new DataSourceSelectionService(); it('Should render nothing when no config is provided', () => { const component = shallowWithIntl(); @@ -122,6 +124,7 @@ describe('TopNavMenu', () => { spyOn(testUtils, 'getApplication').and.returnValue({ id: 'test2' }); spyOn(testUtils, 'getUiSettings').and.returnValue({ id: 'test2' }); spyOn(testUtils, 'getHideLocalCluster').and.returnValue(true); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); const component = shallowWithIntl( { fullWidth: true, activeOption: [{ label: 'what', id: '1' }], }, + dataSourceSelection, }} /> ); @@ -144,6 +148,7 @@ describe('TopNavMenu', () => { spyOn(testUtils, 'getApplication').and.returnValue({ id: 'test2' }); spyOn(testUtils, 'getUiSettings').and.returnValue({ id: 'test2' }); spyOn(testUtils, 'getHideLocalCluster').and.returnValue(true); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); const component = shallowWithIntl( { fullWidth: true, activeOption: [{ label: 'what', id: '1' }], }, + dataSourceSelection: new DataSourceSelectionService(), }} /> );