diff --git a/CHANGELOG.md b/CHANGELOG.md index adb961a04b2b..74814a6bba4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,11 +78,16 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Enhanced data source selector with default datasource shows as first choice ([#6293](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6293)) - [Multiple Datasource] Add multi data source support to sample vega visualizations ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218)) - [Multiple Datasource] Fetch data source title for DataSourceView when only id is provided ([#6315](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6315) +- [Multiple Datasource] Get data source label when only id is provided in DataSourceSelectable ([#6358](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6358) - [Workspace] Add permission control logic ([#6052](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6052)) - [Multiple Datasource] Add default icon for selectable component and make sure the default datasource shows automatically ([#6327](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6327)) - [Multiple Datasource] Pass selected data sources to plugin consumers when the multi-select component initially loads ([#6333](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6333)) - [Multiple Datasource] Add installedPlugins list to data source saved object ([#6348](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6348)) +- [Multiple Datasource] Add default icon in multi-selectable picker ([#6357](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6357)) - [Workspace] Add APIs to support plugin state in request ([#6303](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6303)) +- [Workspace] Filter left nav menu items according to the current workspace ([#6234](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6234)) +- [Multiple Datasource] Refactor data source selector component to include placeholder and add tests ([#6372](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6372)) +- [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) ### 🐛 Bug Fixes @@ -106,6 +111,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Fix sslConfig for multiple datasource to handle when certificateAuthorities is unset ([#6282](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6282)) - [BUG][Multiple Datasource]Fix bug in data source aggregated view to change it to depend on displayAllCompatibleDataSources property to show the badge value ([#6291](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6291)) - [BUG][Multiple Datasource]Read hideLocalCluster setting from yml and set in data source selector and data source menu ([#6361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6361)) +- [BUG] Fix for checkForFunctionProperty so that order does not matter ([#6248](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6248)) ### 🚞 Infrastructure diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap index 28d689c660d5..31ae3a99d9cd 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap @@ -29,9 +29,7 @@ Object { /> - Local cluster - + /> @@ -63,9 +61,7 @@ Object { /> - Local cluster - + /> diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx index 75a223363a12..6d7200b182c5 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx @@ -74,8 +74,7 @@ describe('create data source menu', () => { perPage: 10000, type: 'data-source', }); - expect(notifications.toasts.addWarning).toBeCalledTimes(0); - expect(getByText(component.container, 'Local cluster')).toBeInTheDocument(); + expect(notifications.toasts.addWarning).toBeCalledTimes(2); }); }); 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 a9030ec6c9d9..fdc5d2c7508b 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 @@ -44,6 +44,7 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | savedObjectsClient={savedObjects!} notifications={notifications!.toasts} onSelectedDataSources={onSelectedDataSources!} + uiSettings={uiSettings} /> ); } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/types.ts b/src/plugins/data_source_management/public/components/data_source_menu/types.ts index 32edbddf09a7..17aa35d8b8d0 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/types.ts +++ b/src/plugins/data_source_management/public/components/data_source_menu/types.ts @@ -16,6 +16,11 @@ export interface DataSourceOption { label?: string; } +export interface DataSourceGroupLabelOption extends DataSourceOption { + label: string; + isGroupLabel: true; +} + export interface DataSourceBaseConfig { fullWidth: boolean; disabled?: boolean; diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_filter_group.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_filter_group.test.tsx.snap index 665f9cfa27dc..fe89b1409510 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_filter_group.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_filter_group.test.tsx.snap @@ -62,7 +62,17 @@ exports[`DataSourceFilterGroup should render normally 1`] = ` } } > - name1 + @@ -204,7 +214,33 @@ Object { - name1 +
+
+ name1 +
+
+ + + + Default + + + +
+
@@ -497,7 +533,15 @@ Object { - name1 +
+
+ name1 +
+
diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_multi_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_multi_selectable.test.tsx.snap index 627a1169c15b..9f457a898d74 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_multi_selectable.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_multi_selectable.test.tsx.snap @@ -2,6 +2,7 @@ exports[`DataSourceMultiSelectable should render normally with local cluster hidden 1`] = ` @@ -9,6 +10,7 @@ exports[`DataSourceMultiSelectable should render normally with local cluster hid exports[`DataSourceMultiSelectable should render normally with local cluster not hidden 1`] = ` diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.test.tsx b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.test.tsx index 62bf10ec8310..b75dbdf8a55c 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.test.tsx @@ -17,6 +17,7 @@ describe('DataSourceFilterGroup', () => { mockCallBack(items)} + defaultDataSource="1" /> ); expect(component).toMatchSnapshot(); @@ -28,6 +29,7 @@ describe('DataSourceFilterGroup', () => { mockCallBack(items)} + defaultDataSource="1" /> ); const button = await container.findByTestId('dataSourceFilterGroupButton'); diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.tsx b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.tsx index 8d0e22beadaf..948a788268d6 100644 --- a/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.tsx +++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.tsx @@ -16,6 +16,7 @@ import { EuiButtonEmpty, } from '@elastic/eui'; import { DataSourceOption } from '../data_source_selector/data_source_selector'; +import { DataSourceOptionItem } from '../data_source_option'; export interface SelectedDataSourceOption extends DataSourceOption { label: string; @@ -27,6 +28,7 @@ export interface SelectedDataSourceOption extends DataSourceOption { export interface DataSourceFilterGroupProps { selectedOptions: SelectedDataSourceOption[]; setSelectedOptions: (options: SelectedDataSourceOption[]) => void; + defaultDataSource: string | null; } type SelectionToggleOptionIds = 'select_all' | 'deselect_all'; @@ -45,6 +47,7 @@ const selectionToggleButtons = [ export const DataSourceFilterGroup: React.FC = ({ selectedOptions, setSelectedOptions, + defaultDataSource, }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [selectionToggleSelectedId, setSelectionToggleSelectedId] = useState< @@ -148,7 +151,7 @@ export const DataSourceFilterGroup: React.FC = ({ showIcons={true} style={itemStyle} > - {item.label} + ); })} 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 afe65f554626..448eb404d995 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 @@ -5,7 +5,11 @@ import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; import { notificationServiceMock } from '../../../../../core/public/mocks'; -import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls } from '../../mocks'; +import { + getDataSourcesWithFieldsResponse, + mockResponseForSavedObjectsCalls, + mockManagementPlugin, +} from '../../mocks'; import { ShallowWrapper, shallow } from 'enzyme'; import { DataSourceMultiSelectable } from './data_source_multi_selectable'; import React from 'react'; @@ -17,8 +21,12 @@ describe('DataSourceMultiSelectable', () => { let client: SavedObjectsClientContract; const { toasts } = notificationServiceMock.createStartContract(); const nextTick = () => new Promise((res) => process.nextTick(res)); + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + const uiSettings = mockedContext.uiSettings; beforeEach(() => { + jest.clearAllMocks(); + client = { find: jest.fn().mockResolvedValue([]), } as any; @@ -102,4 +110,40 @@ describe('DataSourceMultiSelectable', () => { expect(callbackMock).toBeCalledWith([]); }); + + it('should retrun correct state when ui Settings provided', async () => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + + ); + await component.instance().componentDidMount!(); + expect(uiSettings.get).toBeCalledWith('defaultDataSource', null); + expect(component.state('defaultDataSource')).toEqual('test1'); + expect(component.state('selectedOptions')).toHaveLength(3); + }); + + it('should retrun correct state when ui Settings provided and hide cluster is false', async () => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + + ); + await component.instance().componentDidMount!(); + expect(uiSettings.get).toBeCalledWith('defaultDataSource', null); + expect(component.state('defaultDataSource')).toEqual('test1'); + expect(component.state('selectedOptions')).toHaveLength(4); + }); }); 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 2bcf57899189..1a1d958b618c 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 @@ -6,6 +6,7 @@ import React from 'react'; import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public'; import { i18n } from '@osd/i18n'; +import { IUiSettingsClient } from 'src/core/public'; import { DataSourceFilterGroup, SelectedDataSourceOption } from './data_source_filter_group'; import { getDataSourcesWithFields } from '../utils'; @@ -15,11 +16,13 @@ export interface DataSourceMultiSeletableProps { onSelectedDataSources: (dataSources: SelectedDataSourceOption[]) => void; hideLocalCluster: boolean; fullWidth: boolean; + uiSettings?: IUiSettingsClient; } interface DataSourceMultiSeletableState { dataSourceOptions: SelectedDataSourceOption[]; selectedOptions: SelectedDataSourceOption[]; + defaultDataSource: string | null; } export class DataSourceMultiSelectable extends React.Component< @@ -34,6 +37,7 @@ export class DataSourceMultiSelectable extends React.Component< this.state = { dataSourceOptions: [], selectedOptions: [], + defaultDataSource: null, }; } @@ -43,46 +47,49 @@ export class DataSourceMultiSelectable extends React.Component< async componentDidMount() { this._isMounted = true; - getDataSourcesWithFields(this.props.savedObjectsClient, ['id', 'title', 'auth.type']) - .then((fetchedDataSources) => { - if (fetchedDataSources?.length) { - // all data sources are selected by default on initial page load - const selectedOptions: SelectedDataSourceOption[] = fetchedDataSources.map( - (dataSource) => ({ - id: dataSource.id, - label: dataSource.attributes?.title || '', - checked: 'on', - visible: true, - }) - ); + try { + const defaultDataSource = this.props.uiSettings?.get('defaultDataSource', null) ?? null; + let selectedOptions: SelectedDataSourceOption[] = []; + const fetchedDataSources = await getDataSourcesWithFields(this.props.savedObjectsClient, [ + 'id', + 'title', + 'auth.type', + ]); - if (!this.props.hideLocalCluster) { - selectedOptions.unshift({ - id: '', - label: 'Local cluster', - checked: 'on', - visible: true, - }); - } + if (fetchedDataSources?.length) { + selectedOptions = fetchedDataSources.map((dataSource) => ({ + id: dataSource.id, + label: dataSource.attributes?.title || '', + checked: 'on', + visible: true, + })); + } - if (!this._isMounted) return; - this.setState({ - ...this.state, - selectedOptions, - }); + if (!this.props.hideLocalCluster) { + selectedOptions.unshift({ + id: '', + label: 'Local cluster', + checked: 'on', + visible: true, + }); + } - this.props.onSelectedDataSources( - selectedOptions.filter((option) => option.checked === 'on') - ); - } - }) - .catch(() => { - this.props.notifications.addWarning( - i18n.translate('dataSource.fetchDataSourceError', { - defaultMessage: 'Unable to fetch existing data sources', - }) - ); + if (!this._isMounted) return; + + this.setState({ + ...this.state, + selectedOptions, + defaultDataSource, }); + + this.props.onSelectedDataSources(selectedOptions); + } catch (error) { + this.props.notifications.addWarning( + i18n.translate('dataSource.fetchDataSourceError', { + defaultMessage: 'Unable to fetch existing data sources', + }) + ); + } } onChange(selectedOptions: SelectedDataSourceOption[]) { @@ -98,6 +105,7 @@ export class DataSourceMultiSelectable extends React.Component< ); } diff --git a/src/plugins/data_source_management/public/components/data_source_option.test.tsx b/src/plugins/data_source_management/public/components/data_source_option.test.tsx new file mode 100644 index 000000000000..7d6375f47d29 --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_option.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { DataSourceOptionItem } from './data_source_option'; +import { SelectedDataSourceOption } from './data_source_multi_selectable/data_source_filter_group'; + +describe('Test on ShowDataSourceOption', () => { + it('should render the component with label', () => { + const item: SelectedDataSourceOption = { + id: '1', + label: 'DataSource 1', + visible: true, + }; + const defaultDataSource = null; + + const component = shallow( + + ); + + expect(component.find(EuiFlexGroup)).toHaveLength(1); + expect(component.find(EuiFlexItem)).toHaveLength(1); + expect(component.find(EuiBadge)).toHaveLength(0); + }); + + it('should render the component with label and default badge', () => { + const item = { + id: '1', + label: 'DataSource 1', + visible: true, + }; + const defaultDataSource = '1'; + + const component = shallow( + + ); + + expect(component.find(EuiFlexGroup)).toHaveLength(1); + expect(component.find(EuiFlexItem)).toHaveLength(2); + expect(component.find(EuiBadge)).toHaveLength(1); + }); +}); diff --git a/src/plugins/data_source_management/public/components/data_source_option.tsx b/src/plugins/data_source_management/public/components/data_source_option.tsx new file mode 100644 index 000000000000..cb5dac27d5ae --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_option.tsx @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import { SelectedDataSourceOption } from './data_source_multi_selectable/data_source_filter_group'; + +export interface DataSourceOptionItemProps { + item: SelectedDataSourceOption; + defaultDataSource: string | null; +} + +export const DataSourceOptionItem: React.FC = ({ + item, + defaultDataSource, +}) => { + return ( + + {item.label} + {item.id === defaultDataSource && ( + + Default + + )} + + ); +}; diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap index f76958715e77..851d37b3d71b 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap @@ -51,6 +51,11 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` onChange={[Function]} options={ Array [ + Object { + "id": "opensearchClusterGroupLabel", + "isGroupLabel": true, + "label": "OpenSearch cluster", + }, Object { "checked": "on", "id": "", @@ -96,9 +101,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde iconType="database" onClick={[Function]} size="s" - > - - + /> } closePopover={[Function]} @@ -162,9 +165,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd iconType="database" onClick={[Function]} size="s" - > - - + /> } closePopover={[Function]} 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 bbbd40c85857..78b63a57144c 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 @@ -7,11 +7,12 @@ import { ShallowWrapper, shallow, mount } from 'enzyme'; import { SavedObjectsClientContract } from '../../../../../core/public'; import { notificationServiceMock } from '../../../../../core/public/mocks'; import React from 'react'; -import { DataSourceSelectable } from './data_source_selectable'; +import { DataSourceSelectable, opensearchClusterGroupLabel } from './data_source_selectable'; import { AuthType } from '../../types'; import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls } from '../../mocks'; import { render } from '@testing-library/react'; import * as utils from '../utils'; +import { EuiSelectable } from '@elastic/eui'; describe('DataSourceSelectable', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -169,4 +170,265 @@ describe('DataSourceSelectable', () => { expect(onSelectedDataSource).toHaveBeenCalled(); expect(utils.getDefaultDataSource).toHaveBeenCalled(); }); + + it('should display selected option label normally', async () => { + const onSelectedDataSource = jest.fn(); + const container = render( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + + await nextTick(); + const button = await container.findByTestId('dataSourceSelectableContextMenuHeaderLink'); + expect(button).toHaveTextContent('test2'); + }); + + it('should render normally even only provide dataSourceId', async () => { + const onSelectedDataSource = jest.fn(); + const container = render( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + await nextTick(); + const button = await container.findByTestId('dataSourceSelectableContextMenuHeaderLink'); + expect(button).toHaveTextContent('test2'); + }); + + it('should render warning if provide undefined dataSourceId', async () => { + const onSelectedDataSource = jest.fn(); + const container = render( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + await nextTick(); + const button = await container.findByTestId('dataSourceSelectableContextMenuHeaderLink'); + expect(button).toHaveTextContent(''); + expect(toasts.addWarning).toBeCalledWith('Data source with id: undefined is not available'); + }); + + it('should render warning if provide empty object', async () => { + const onSelectedDataSource = jest.fn(); + const container = render( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + await nextTick(); + const button = await container.findByTestId('dataSourceSelectableContextMenuHeaderLink'); + expect(button).toHaveTextContent(''); + expect(toasts.addWarning).toBeCalledWith('Data source with id: undefined is not available'); + }); + it('should warning if only provide label', async () => { + const onSelectedDataSource = jest.fn(); + const container = render( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + await nextTick(); + expect(toasts.addWarning).toBeCalledWith('Data source with id: undefined is not available'); + }); + it('should warning if only provide empty label', async () => { + const onSelectedDataSource = jest.fn(); + const container = render( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + await nextTick(); + expect(toasts.addWarning).toBeCalledWith('Data source with id: undefined is not available'); + }); + + it('should warning if only provide empty array', async () => { + const onSelectedDataSource = jest.fn(); + const container = render( + + ); + await nextTick(); + expect(toasts.addWarning).toBeCalledWith('Data source with id: undefined is not available'); + }); + + it('should render the selected option when pass in the valid dataSourceId', async () => { + const onSelectedDataSource = jest.fn(); + const container = mount( + + ); + await nextTick(); + const containerInstance = container.instance(); + expect(containerInstance.state).toEqual({ + dataSourceOptions: [ + { + id: 'test1', + label: 'test1', + }, + { + checked: 'on', + id: 'test2', + label: 'test2', + }, + { + id: 'test3', + label: 'test3', + }, + ], + defaultDataSource: null, + isPopoverOpen: false, + selectedOption: [ + { + id: 'test2', + label: 'test2', + }, + ], + }); + }); + + it('should render nothing when no default option or activeOption', async () => { + const onSelectedDataSource = jest.fn(); + spyOn(utils, 'getDefaultDataSource').and.returnValue(undefined); + const container = mount( + ds.attributes.auth.type !== AuthType.NoAuth} + /> + ); + await nextTick(); + + const containerInstance = container.instance(); + + expect(onSelectedDataSource).toBeCalledTimes(0); + expect(containerInstance.state).toEqual({ + dataSourceOptions: [], + defaultDataSource: null, + isPopoverOpen: false, + selectedOption: [], + }); + + containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); + expect(containerInstance.state).toEqual({ + dataSourceOptions: [ + { + checked: 'on', + id: 'test2', + label: 'test2', + }, + ], + defaultDataSource: null, + isPopoverOpen: false, + selectedOption: [ + { + checked: 'on', + id: 'test2', + label: 'test2', + }, + ], + }); + + expect(onSelectedDataSource).toBeCalledWith([{ id: 'test2', label: 'test2' }]); + expect(onSelectedDataSource).toHaveBeenCalled(); + }); + + it('should render opensearch cluster group label at the top of options, when there are options availiable', async () => { + const onSelectedDataSource = jest.fn(); + component = shallow( + + ); + + component.instance().componentDidMount!(); + await nextTick(); + const optionsProp = component.find(EuiSelectable).prop('options'); + expect(optionsProp[0]).toEqual(opensearchClusterGroupLabel); + }); + + it('should not render opensearch cluster group label, when there is no option availiable', async () => { + const onSelectedDataSource = jest.fn(); + spyOn(utils, 'getDefaultDataSource').and.returnValue([]); + component = shallow( + + ); + + component.instance().componentDidMount!(); + await nextTick(); + const optionsProp = component.find(EuiSelectable).prop('options'); + expect(optionsProp).toEqual([]); + }); }); 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 5a4591597ab6..40a7edce7558 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 @@ -21,11 +21,11 @@ import { SavedObjectsClientContract, ToastsStart, } from 'opensearch-dashboards/public'; -import { getDataSourcesWithFields, getDefaultDataSource } from '../utils'; +import { getDataSourcesWithFields, getDefaultDataSource, getFilteredDataSources } from '../utils'; import { LocalCluster } from '../data_source_selector/data_source_selector'; import { SavedObject } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; -import { DataSourceOption } from '../data_source_menu/types'; +import { DataSourceGroupLabelOption, DataSourceOption } from '../data_source_menu/types'; interface DataSourceSelectableProps { savedObjectsClient: SavedObjectsClientContract; @@ -50,6 +50,12 @@ interface SelectedDataSourceOption extends DataSourceOption { checked?: string; } +export const opensearchClusterGroupLabel: DataSourceGroupLabelOption = { + id: 'opensearchClusterGroupLabel', + label: 'OpenSearch cluster', + isGroupLabel: true, +}; + export class DataSourceSelectable extends React.Component< DataSourceSelectableProps, DataSourceSelectableState @@ -81,67 +87,106 @@ export class DataSourceSelectable extends React.Component< this.setState({ ...this.state, isPopoverOpen: false }); } + // Update the checked status of the selected data source. + getUpdatedDataSourceOptions( + selectedDataSourceId: string, + dataSourceOptions: DataSourceOption[] + ): SelectedDataSourceOption[] { + return dataSourceOptions.map((option) => ({ + ...option, + ...(option.id === selectedDataSourceId && { checked: 'on' }), + })); + } + + handleSelectedOption(dataSourceOptions: DataSourceOption[], defaultDataSource: string | null) { + const [{ id }] = this.props.selectedOption!; + const dsOption = dataSourceOptions.find((ds) => ds.id === id); + if (!dsOption) { + this.props.notifications.addWarning( + i18n.translate('dataSource.fetchDataSourceError', { + defaultMessage: `Data source with id: ${id} is not available`, + }) + ); + this.setState({ + ...this.state, + dataSourceOptions, + selectedOption: [], + defaultDataSource, + }); + this.props.onSelectedDataSources([]); + return; + } + const updatedDataSourceOptions: SelectedDataSourceOption[] = this.getUpdatedDataSourceOptions( + id, + dataSourceOptions + ); + this.setState({ + ...this.state, + dataSourceOptions: updatedDataSourceOptions, + selectedOption: [{ id, label: dsOption.label }], + defaultDataSource, + }); + this.props.onSelectedDataSources([{ id, label: dsOption.label }]); + } + + handleDefaultDataSource(dataSourceOptions: DataSourceOption[], defaultDataSource: string | null) { + const selectedDataSource = getDefaultDataSource( + dataSourceOptions, + LocalCluster, + defaultDataSource, + this.props.hideLocalCluster + ); + + // no active option, show warning + if (selectedDataSource.length === 0) { + this.props.notifications.addWarning('No connected data source available.'); + this.props.onSelectedDataSources([]); + return; + } + + const updatedDataSourceOptions: SelectedDataSourceOption[] = this.getUpdatedDataSourceOptions( + selectedDataSource[0].id, + dataSourceOptions + ); + + this.setState({ + ...this.state, + selectedOption: selectedDataSource, + dataSourceOptions: updatedDataSourceOptions, + defaultDataSource, + }); + + this.props.onSelectedDataSources(selectedDataSource); + } + async componentDidMount() { this._isMounted = true; - try { - let filteredDataSources: Array> = []; - let dataSourceOptions: DataSourceOption[] = []; - // Fetch data sources with fields + try { const fetchedDataSources = await getDataSourcesWithFields(this.props.savedObjectsClient, [ 'id', 'title', 'auth.type', ]); - if (fetchedDataSources?.length) { - filteredDataSources = this.props.dataSourceFilter - ? fetchedDataSources.filter((ds) => this.props.dataSourceFilter!(ds)) - : fetchedDataSources; - dataSourceOptions = filteredDataSources - .map((dataSource) => ({ - id: dataSource.id, - label: dataSource.attributes?.title || '', - })) - .sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())); - } + const dataSourceOptions: DataSourceOption[] = getFilteredDataSources( + fetchedDataSources, + this.props.dataSourceFilter + ); - // Add local cluster to the list of data sources if it is not hidden. if (!this.props.hideLocalCluster) { dataSourceOptions.unshift(LocalCluster); } const defaultDataSource = this.props.uiSettings?.get('defaultDataSource', null) ?? null; - const selectedDataSource = getDefaultDataSource( - filteredDataSources, - LocalCluster, - this.props.uiSettings, - this.props.hideLocalCluster, - this.props.selectedOption - ); - if (selectedDataSource.length === 0) { - this.props.notifications.addWarning('No connected data source available.'); - } else { - // Update the checked status of the selected data source. - const updatedDataSourceOptions: SelectedDataSourceOption[] = dataSourceOptions.map( - (option) => ({ - ...option, - ...(option.id === selectedDataSource[0].id && { checked: 'on' }), - }) - ); - - if (!this._isMounted) return; - - this.setState({ - ...this.state, - dataSourceOptions: updatedDataSourceOptions, - selectedOption: selectedDataSource, - defaultDataSource, - }); - - this.props.onSelectedDataSources(selectedDataSource); + if (this.props.selectedOption?.length) { + this.handleSelectedOption(dataSourceOptions, defaultDataSource); + return; } + + // handle default data source if there is no valid active option + this.handleDefaultDataSource(dataSourceOptions, defaultDataSource); } catch (error) { this.props.notifications.addWarning( i18n.translate('dataSource.fetchDataSourceError', { @@ -168,6 +213,16 @@ export class DataSourceSelectable extends React.Component< } } + getOptionsWithGroupLabel = (dataSourceOptions: DataSourceOption[]): DataSourceOption[] => { + let optionsWithGroupLabel: DataSourceOption[] = []; + if (dataSourceOptions.length === 0) { + optionsWithGroupLabel = []; + } else { + optionsWithGroupLabel = [opensearchClusterGroupLabel, ...dataSourceOptions]; + } + return optionsWithGroupLabel; + }; + render() { const button = ( <> @@ -183,10 +238,9 @@ export class DataSourceSelectable extends React.Component< size="s" disabled={this.props.disabled || false} > - {(this.state.selectedOption && + {this.state.selectedOption && this.state.selectedOption.length > 0 && - this.state.selectedOption[0].label) || - ''} + this.state.selectedOption[0]?.label} ); @@ -210,7 +264,7 @@ export class DataSourceSelectable extends React.Component< searchProps={{ placeholder: 'Search', }} - options={this.state.dataSourceOptions} + options={this.getOptionsWithGroupLabel(this.state.dataSourceOptions)} onChange={(newOptions) => this.onChange(newOptions)} singleSelection={true} data-test-subj={'dataSourceSelectable'} diff --git a/src/plugins/data_source_management/public/components/data_source_selector/__snapshots__/data_source_selector.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selector/__snapshots__/data_source_selector.test.tsx.snap index 495331156fee..4f65f5d38085 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/__snapshots__/data_source_selector.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selector/__snapshots__/data_source_selector.test.tsx.snap @@ -174,6 +174,18 @@ exports[`DataSourceSelector: check dataSource options should get default datasou "id": "", "label": "Local cluster", }, + Object { + "id": "test1", + "label": "test1", + }, + Object { + "id": "test2", + "label": "test2", + }, + Object { + "id": "test3", + "label": "test3", + }, ] } placeholder="Select a data source" @@ -182,8 +194,8 @@ exports[`DataSourceSelector: check dataSource options should get default datasou selectedOptions={ Array [ Object { - "id": "", - "label": "Local cluster", + "id": "test1", + "label": "test1", }, ] } 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 d1203584d4b5..af5086f35a50 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 @@ -4,7 +4,7 @@ */ import { ShallowWrapper, shallow } from 'enzyme'; -import { DataSourceSelector } from './data_source_selector'; +import { DataSourceSelector, LocalCluster } from './data_source_selector'; import { SavedObjectsClientContract } from '../../../../../core/public'; import { notificationServiceMock } from '../../../../../core/public/mocks'; import React from 'react'; @@ -15,6 +15,7 @@ import { } from '../../mocks'; import { AuthType } from 'src/plugins/data_source/common/data_sources'; import * as utils from '../utils'; +import { EuiComboBox } from '@elastic/eui'; describe('DataSourceSelector', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -23,6 +24,7 @@ describe('DataSourceSelector', () => { const { toasts } = notificationServiceMock.createStartContract(); beforeEach(() => { + jest.clearAllMocks(); client = { find: jest.fn().mockResolvedValue([]), } as any; @@ -181,8 +183,6 @@ describe('DataSourceSelector: check dataSource options', () => { it('should get default datasource if uiSettings exists', async () => { spyOn(uiSettings, 'get').and.returnValue('test1'); - spyOn(utils, 'getFilteredDataSources').and.returnValue([]); - spyOn(utils, 'getDefaultDataSource').and.returnValue([]); component = shallow( { await nextTick(); expect(component).toMatchSnapshot(); expect(uiSettings.get).toBeCalledWith('defaultDataSource', null); - expect(utils.getFilteredDataSources).toHaveBeenCalled(); - expect(utils.getDefaultDataSource).toHaveBeenCalled(); - expect(toasts.addWarning).toHaveBeenCalled(); }); it('should not render options with default badge when id does not matches defaultDataSource', () => { @@ -220,3 +217,302 @@ describe('DataSourceSelector: check dataSource options', () => { expect(component.find('EuiComboBox').exists()).toBe(true); }); }); + +describe('DataSourceSelector: check defaultOption behavior', () => { + /** + * Test Cases + * - []: 2 cases + * - Some value: 4 * 3 = 12 cases + */ + let component: ShallowWrapper, React.Component<{}, {}, any>>; + let client: SavedObjectsClientContract; + const { toasts } = notificationServiceMock.createStartContract(); + const nextTick = () => new Promise((res) => process.nextTick(res)); + const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + const uiSettings = mockedContext.uiSettings; + const getMockedDataSourceOptions = () => { + return getDataSourcesWithFieldsResponse.savedObjects.map((response) => { + return { id: response.id, label: response.attributes.title }; + }); + }; + + beforeEach(async () => { + jest.clearAllMocks(); + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse); + }); + + // When defaultOption is undefined + it('should render defaultDataSource as the selected option', async () => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual( + expect.arrayContaining([ + { + id: 'test1', + label: 'test1', + }, + ]) + ); + }); + + it('should render Local Cluster as the selected option when hideLocalCluster is false', async () => { + spyOn(uiSettings, 'get').and.returnValue(null); + component = shallow( + + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual(expect.arrayContaining([LocalCluster])); + }); + + it('should render random datasource as the selected option if defaultDataSource and Local Cluster are not present', async () => { + spyOn(uiSettings, 'get').and.returnValue(null); + component = shallow( + { + return dataSource.id !== 'test1'; + }} + /> + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual( + expect.arrayContaining([ + { + id: 'test2', + label: 'test2', + }, + ]) + ); + }); + + it('should return toast', async () => { + spyOn(uiSettings, 'get').and.returnValue(null); + component = shallow( + { + return false; + }} + /> + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual(expect.arrayContaining([])); + expect(toasts.addWarning).toBeCalled(); + }); + + // When defaultOption is [] + it('should render placeholder and all options when Local Cluster is not hidden', async () => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual(expect.arrayContaining([])); + expect(euiComboBox.prop('options')).toEqual( + expect.arrayContaining(getMockedDataSourceOptions().concat([LocalCluster])) + ); + }); + + it('should render placeholder and all options when Local Cluster is hidden', async () => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual(expect.arrayContaining([])); + expect(euiComboBox.prop('options')).toEqual( + expect.arrayContaining(getMockedDataSourceOptions()) + ); + }); + + // When defaultOption is [{id}] + it.each([ + { + id: undefined, + }, + { + id: '', + }, + { + id: 'test2', + }, + { + id: 'non-existent-id', + }, + ])('should all throw a toast warning when the available dataSources is empty', async ({ id }) => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + { + return false; + }} + // @ts-expect-error + defaultOption={[{ id }]} + /> + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual(expect.arrayContaining([])); + expect(toasts.addWarning).toBeCalled(); + }); + + it.each([ + { + id: undefined, + }, + { + id: '', + }, + { + id: 'test2', + }, + { + id: 'non-existent-id', + }, + ])('should all throw a toast warning when the id is filtered out', async ({ id }) => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + { + return dataSource.attributes.title !== id; + }} + // @ts-expect-error + defaultOption={[{ id }]} + /> + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual(expect.arrayContaining([])); + expect(toasts.addWarning).toBeCalled(); + }); + + it.each([ + { + id: undefined, + error: true, + selectedOption: [], + }, + { + id: '', + error: false, + selectedOption: [LocalCluster], + }, + { + id: 'test2', + error: false, + selectedOption: [{ id: 'test2', label: 'test2' }], + }, + { + id: 'non-existent-id', + error: true, + selectedOption: [], + }, + ])( + 'should handle selectedOption correctly when defaultOption = [{id}]', + async ({ id, error, selectedOption }) => { + spyOn(uiSettings, 'get').and.returnValue('test1'); + component = shallow( + + ); + component.instance().componentDidMount!(); + await nextTick(); + const euiComboBox = component.find(EuiComboBox); + expect(euiComboBox.prop('selectedOptions')).toEqual(expect.arrayContaining(selectedOption)); + if (error) { + expect(toasts.addWarning).toBeCalled(); + } else { + expect(toasts.addWarning).toBeCalledTimes(0); + } + } + ); +}); 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 9674d4e0c9c8..fa0235a6ec3d 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 @@ -37,6 +37,7 @@ interface DataSourceSelectorState { selectedOption: DataSourceOption[]; allDataSources: Array>; defaultDataSource: string | null; + dataSourceOptions?: DataSourceOption[]; } export interface DataSourceOption { @@ -57,11 +58,7 @@ export class DataSourceSelector extends React.Component< this.state = { allDataSources: [], defaultDataSource: '', - selectedOption: this.props.defaultOption - ? this.props.defaultOption - : this.props.hideLocalCluster - ? [] - : [LocalCluster], + selectedOption: this.props.hideLocalCluster ? [] : [LocalCluster], }; } @@ -69,54 +66,122 @@ export class DataSourceSelector extends React.Component< this._isMounted = false; } - async componentDidMount() { - this._isMounted = true; + handleSelectedOption( + dataSourceOptions: DataSourceOption[], + allDataSources: Array>, + defaultDataSource: string | null + ) { + const [{ id }] = this.props.defaultOption!; + const dataSource = dataSourceOptions.find((ds) => ds.id === id); + const selectedOption = dataSource ? [{ id, label: dataSource.label }] : []; + + // Invalid/filtered out datasource + if (!dataSource) { + this.props.notifications.addWarning( + i18n.translate('dataSource.fetchDataSourceError', { + defaultMessage: 'Data source with id is not available', + }) + ); + } - const currentDefaultDataSource = this.props.uiSettings?.get('defaultDataSource', null) ?? null; this.setState({ ...this.state, - defaultDataSource: currentDefaultDataSource, + dataSourceOptions, + selectedOption, + defaultDataSource, + allDataSources, }); + this.props.onSelectedDataSource(selectedOption); + } - getDataSourcesWithFields(this.props.savedObjectsClient, ['id', 'title', 'auth.type']) - .then((fetchedDataSources) => { - if (fetchedDataSources?.length) { - if (!this._isMounted) return; - this.setState({ - ...this.state, - allDataSources: fetchedDataSources, - }); - } - const dataSources = getFilteredDataSources( - this.state.allDataSources, - this.props.dataSourceFilter - ); - const selectedDataSource = getDefaultDataSource( - dataSources, - LocalCluster, - this.props.uiSettings, - this.props.hideLocalCluster, - this.props.defaultOption - ); - if (selectedDataSource.length === 0) { - this.props.notifications.addWarning('No connected data source available.'); - } else { - this.props.onSelectedDataSource(selectedDataSource); - this.setState({ - selectedOption: selectedDataSource, - }); - } - }) - .catch(() => { - this.props.notifications.addWarning( - i18n.translate('dataSource.fetchDataSourceError', { - defaultMessage: 'Unable to fetch existing data sources', - }) - ); - }); + handleDefaultDataSource( + dataSourceOptions: DataSourceOption[], + allDataSources: Array>, + defaultDataSource: string | null + ) { + const selectedDataSource = getDefaultDataSource( + dataSourceOptions, + LocalCluster, + defaultDataSource, + this.props.hideLocalCluster + ); + + // No active option, did not find valid option + if (selectedDataSource.length === 0) { + this.props.notifications.addWarning('No connected data source available.'); + this.props.onSelectedDataSource([]); + return; + } + + this.setState({ + ...this.state, + dataSourceOptions, + selectedOption: selectedDataSource, + defaultDataSource, + allDataSources, + }); + this.props.onSelectedDataSource(selectedDataSource); } - onChange(e) { + async componentDidMount() { + this._isMounted = true; + try { + // 1. Fetch + const fetchedDataSources = await getDataSourcesWithFields(this.props.savedObjectsClient, [ + 'id', + 'title', + 'auth.type', + ]); + + // 2. Process + const dataSourceOptions = getFilteredDataSources( + fetchedDataSources, + this.props.dataSourceFilter + ); + + // 3. Add local cluster as option + if (!this.props.hideLocalCluster) { + dataSourceOptions.unshift(LocalCluster); + } + + // 4. Error state if filter filters out everything + if (!dataSourceOptions.length) { + this.props.notifications.addWarning('No connected data source available.'); + this.props.onSelectedDataSource([]); + return; + } + + const defaultDataSource = this.props.uiSettings?.get('defaultDataSource', null) ?? null; + // 5.1 Empty default option, [], just want to show placeholder + if (this.props.defaultOption?.length === 0) { + this.setState({ + ...this.state, + dataSourceOptions, + selectedOption: [], + defaultDataSource, + allDataSources: fetchedDataSources, + }); + return; + } + + // 5.2 Handle active option, [{}] + if (this.props.defaultOption?.length) { + this.handleSelectedOption(dataSourceOptions, fetchedDataSources, defaultDataSource); + return; + } + + // 5.3 Handle default data source + this.handleDefaultDataSource(dataSourceOptions, fetchedDataSources, defaultDataSource); + } catch (err) { + this.props.notifications.addWarning( + i18n.translate('dataSource.fetchDataSourceError', { + defaultMessage: 'Unable to fetch existing data sources', + }) + ); + } + } + + onChange(e: DataSourceOption[]) { if (!this._isMounted) return; this.setState({ selectedOption: e, @@ -131,12 +196,8 @@ export class DataSourceSelector extends React.Component< : this.props.placeholderText; // The filter condition can be changed, thus we filter again here to make sure each time we will get the filtered data sources before rendering - const dataSources = getFilteredDataSources( - this.state.allDataSources, - this.props.dataSourceFilter - ); + const options = getFilteredDataSources(this.state.allDataSources, this.props.dataSourceFilter); - const options = dataSources.map((ds) => ({ id: ds.id, label: ds.attributes?.title || '' })); if (!this.props.hideLocalCluster) { options.unshift(LocalCluster); } diff --git a/src/plugins/data_source_management/public/components/utils.test.ts b/src/plugins/data_source_management/public/components/utils.test.ts index 9dc3a8824cb6..63fac96be57e 100644 --- a/src/plugins/data_source_management/public/components/utils.test.ts +++ b/src/plugins/data_source_management/public/components/utils.test.ts @@ -31,6 +31,7 @@ import { mockUiSettingsCalls, getSingleDataSourceResponse, getDataSource, + getDataSourceOptions, } from '../mocks'; import { AuthType, @@ -518,24 +519,21 @@ describe('DataSourceManagement: Utils.ts', () => { const result = getFilteredDataSources(dataSources); - expect(result).toEqual(dataSources); + expect(result).toEqual([ + { + id: '1', + label: 'DataSource 1', + }, + ]); }); test('should return filtered data sources when a filter is provided', () => { const filter = (dataSource: SavedObject) => dataSource.id === '2'; const result = getFilteredDataSources(getDataSource, filter); - expect(result).toEqual([ { id: '2', - type: '', - references: [], - attributes: { - title: 'DataSource 2', - endpoint: '', - auth: { type: AuthType.NoAuth, credentials: undefined }, - name: AuthType.NoAuth, - }, + label: 'DataSource 2', }, ]); }); @@ -543,39 +541,34 @@ describe('DataSourceManagement: Utils.ts', () => { describe('getDefaultDataSource', () => { const LocalCluster = { id: 'local', label: 'Local Cluster' }; const hideLocalCluster = false; - const defaultOption = [{ id: '2', label: 'Default Option' }]; + const defaultOption = [{ id: '2', label: 'DataSource 2' }]; it('should return the default option if it exists in the data sources', () => { + mockUiSettingsCalls(uiSettings, 'get', '2'); const result = getDefaultDataSource( - getDataSource, + getDataSourceOptions, LocalCluster, - uiSettings, - hideLocalCluster, - defaultOption + '2', + hideLocalCluster ); expect(result).toEqual([defaultOption[0]]); }); it('should return local cluster if it exists and no default options in the data sources', () => { mockUiSettingsCalls(uiSettings, 'get', null); - const result = getDefaultDataSource( - getDataSource, - LocalCluster, - uiSettings, - hideLocalCluster - ); + const result = getDefaultDataSource(getDataSource, LocalCluster, null, hideLocalCluster); expect(result).toEqual([LocalCluster]); }); it('should return the default datasource if hideLocalCluster is false', () => { mockUiSettingsCalls(uiSettings, 'get', '2'); - const result = getDefaultDataSource(getDataSource, LocalCluster, uiSettings, true); + const result = getDefaultDataSource(getDataSourceOptions, LocalCluster, '2', false); expect(result).toEqual([{ id: '2', label: 'DataSource 2' }]); }); it('should return the first data source if no default option, hideLocalCluster is ture and no default datasource', () => { mockUiSettingsCalls(uiSettings, 'get', null); - const result = getDefaultDataSource(getDataSource, LocalCluster, uiSettings, true); + const result = getDefaultDataSource(getDataSourceOptions, LocalCluster, uiSettings, true); expect(result).toEqual([{ id: '1', label: 'DataSource 1' }]); }); }); diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index be004c989943..b8c76bcf858c 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -16,7 +16,7 @@ import { noAuthCredentialAuthMethod, } from '../types'; import { AuthenticationMethodRegistry } from '../auth_registry'; -import { DataSourceOption } from './data_source_menu/types'; +import { DataSourceOption } from './data_source_selector/data_source_selector'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -81,52 +81,44 @@ export async function setFirstDataSourceAsDefault( export function getFilteredDataSources( dataSources: Array>, - filter?: (dataSource: SavedObject) => boolean -) { - return filter ? dataSources.filter((ds) => filter!(ds)) : dataSources; + filter = (ds: SavedObject) => true +): DataSourceOption[] { + return dataSources + .filter((ds) => filter!(ds)) + .map((ds) => ({ + id: ds.id, + label: ds.attributes?.title || '', + })) + .sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())); } export function getDefaultDataSource( - dataSources: Array>, + dataSourcesOptions: DataSourceOption[], LocalCluster: DataSourceOption, - uiSettings?: IUiSettingsClient, - hideLocalCluster?: boolean, - defaultOption?: DataSourceOption[] + defaultDataSourceId: string | null, + hideLocalCluster?: boolean ) { - const defaultOptionId = defaultOption?.[0]?.id; - const defaultOptionDataSource = dataSources.find( - (dataSource) => dataSource.id === defaultOptionId - ); - - const defaultDataSourceId = uiSettings?.get('defaultDataSource', null) ?? null; - const defaultDataSourceAfterCheck = dataSources.find( + const defaultDataSourceAfterCheck = dataSourcesOptions.find( (dataSource) => dataSource.id === defaultDataSourceId ); - - if (defaultOptionDataSource) { - return [ - { - id: defaultOptionDataSource.id, - label: defaultOption?.[0]?.label || defaultOptionDataSource.attributes?.title, - }, - ]; - } if (defaultDataSourceAfterCheck) { return [ { id: defaultDataSourceAfterCheck.id, - label: defaultDataSourceAfterCheck.attributes?.title || '', + label: defaultDataSourceAfterCheck.label, }, ]; } + if (!hideLocalCluster) { return [LocalCluster]; } - if (dataSources.length > 0) { + + if (dataSourcesOptions.length > 0) { return [ { - id: dataSources[0].id, - label: dataSources[0].attributes.title, + id: dataSourcesOptions[0].id, + label: dataSourcesOptions[0].label, }, ]; } diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts index f0b65d7343c3..d6c89dc5c7dd 100644 --- a/src/plugins/data_source_management/public/mocks.ts +++ b/src/plugins/data_source_management/public/mocks.ts @@ -115,6 +115,21 @@ export const getDataSource = [ }, ]; +export const getDataSourceOptions = [ + { + id: '1', + label: 'DataSource 1', + }, + { + id: '2', + label: 'DataSource 2', + }, + { + id: '3', + label: 'DataSource 1', + }, +]; + /* Mock data responses - JSON*/ export const getDataSourcesResponse = { savedObjects: [ diff --git a/src/plugins/vis_type_vega/public/data_model/utils.test.js b/src/plugins/vis_type_vega/public/data_model/utils.test.js index 9fe648c71139..9d6d67d793bb 100644 --- a/src/plugins/vis_type_vega/public/data_model/utils.test.js +++ b/src/plugins/vis_type_vega/public/data_model/utils.test.js @@ -59,9 +59,7 @@ describe('Utils.handleInvalidQuery', () => { test('should return null if input object has function as property', async () => { const input = { key1: 'value1', - key2: () => { - alert('Hello!'); - }, + key2: () => jest.fn(), }; expect(Utils.handleInvalidQuery(input)).toBe(null); @@ -71,9 +69,7 @@ describe('Utils.handleInvalidQuery', () => { const input = { key1: 'value1', key2: { - func: () => { - alert('Hello!'); - }, + func: () => jest.fn(), }, }; expect(Utils.handleInvalidQuery(input)).toBe(null); @@ -91,4 +87,47 @@ describe('Utils.handleInvalidQuery', () => { }).toThrowError(); }); }); + + test('should identify object contains function properties', async () => { + const input1 = { + key1: 'value1', + key2: () => jest.fn(), + }; + + expect(Utils.checkForFunctionProperty(input1)).toBe(true); + + const input2 = { + key1: () => jest.fn(), + key2: 'value1', + }; + + expect(Utils.checkForFunctionProperty(input2)).toBe(true); + + const nestedInput = { + key1: { + nestedKey1: 'nestedValue1', + nestedKey2: () => jest.fn(), + }, + key2: 'value1', + }; + + expect(Utils.checkForFunctionProperty(nestedInput)).toBe(true); + + const inputWithoutFn = { + key1: 'value1', + key2: 'value2', + }; + + expect(Utils.checkForFunctionProperty(inputWithoutFn)).toBe(false); + + const nestedInputWithoutFn = { + key1: { + nestedKey1: 'nestedValue1', + nestedKey2: 'nestedValue2', + }, + key2: 'value2', + }; + + expect(Utils.checkForFunctionProperty(nestedInputWithoutFn)).toBe(false); + }); }); diff --git a/src/plugins/vis_type_vega/public/data_model/utils.ts b/src/plugins/vis_type_vega/public/data_model/utils.ts index 4e7a85062354..2aba9f9fa577 100644 --- a/src/plugins/vis_type_vega/public/data_model/utils.ts +++ b/src/plugins/vis_type_vega/public/data_model/utils.ts @@ -79,14 +79,14 @@ export class Utils { } static checkForFunctionProperty(object: object): boolean { - let result = false; - Object.values(object).forEach((value) => { - result = - typeof value === 'function' - ? true - : Utils.isObject(value) && Utils.checkForFunctionProperty(value); - }); - return result; + for (const value of Object.values(object)) { + if (typeof value === 'function') { + return true; + } else if (Utils.isObject(value) && Utils.checkForFunctionProperty(value)) { + return true; + } + } + return false; } static handleInvalidDate(date: unknown): number | string | Date | null { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 5357aa08cbf7..b27c4b3bdd4a 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import React from 'react'; import { i18n } from '@osd/i18n'; import { @@ -12,6 +12,8 @@ import { CoreSetup, AppMountParameters, AppNavLinkStatus, + AppUpdater, + AppStatus, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, @@ -26,6 +28,7 @@ import { WorkspaceClient } from './workspace_client'; import { SavedObjectsManagementPluginSetup } from '../../../plugins/saved_objects_management/public'; import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; import { getWorkspaceColumn } from './components/workspace_column'; +import { isAppAccessibleInWorkspace } from './utils'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; @@ -36,6 +39,8 @@ interface WorkspacePluginSetupDeps { export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; + private currentWorkspaceIdSubscription?: Subscription; + private appUpdater$ = new BehaviorSubject(() => undefined); private _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { @@ -46,9 +51,34 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> } } + /** + * Filter nav links by the current workspace, once the current workspace change, the nav links(left nav bar) + * should also be updated according to the configured features of the current workspace + */ + private filterNavLinks(core: CoreStart) { + const currentWorkspace$ = core.workspaces.currentWorkspace$; + this.currentWorkspaceSubscription?.unsubscribe(); + + this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { + if (currentWorkspace) { + this.appUpdater$.next((app) => { + if (isAppAccessibleInWorkspace(app, currentWorkspace)) { + return; + } + /** + * Change the app to `inaccessible` if it is not configured in the workspace + * If trying to access such app, an "Application Not Found" page will be displayed + */ + return { status: AppStatus.inaccessible }; + }); + } + }); + } + public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); + core.application.registerAppUpdater(this.appUpdater$); /** * Retrieve workspace id from url @@ -171,11 +201,16 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> public start(core: CoreStart) { this.coreStart = core; - this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); + this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); + + // When starts, filter the nav links based on the current workspace + this.filterNavLinks(core); + return {}; } public stop() { this.currentWorkspaceSubscription?.unsubscribe(); + this.currentWorkspaceIdSubscription?.unsubscribe(); } } diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 510a775cd745..f81e248c4469 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { featureMatchesConfig } from './utils'; +import { AppNavLinkStatus } from '../../../core/public'; +import { featureMatchesConfig, isAppAccessibleInWorkspace } from './utils'; describe('workspace utils: featureMatchesConfig', () => { it('feature configured with `*` should match any features', () => { @@ -91,3 +92,60 @@ describe('workspace utils: featureMatchesConfig', () => { ); }); }); + +describe('workspace utils: isAppAccessibleInWorkspace', () => { + it('any app is accessible when workspace has no features configured', () => { + expect( + isAppAccessibleInWorkspace( + { id: 'any_app', title: 'Any app', mount: jest.fn() }, + { id: 'workspace_id', name: 'workspace name' } + ) + ).toBe(true); + }); + + it('An app is accessible when the workspace has the app configured', () => { + expect( + isAppAccessibleInWorkspace( + { id: 'dev_tools', title: 'Any app', mount: jest.fn() }, + { id: 'workspace_id', name: 'workspace name', features: ['dev_tools'] } + ) + ).toBe(true); + }); + + it('An app is not accessible when the workspace does not have the app configured', () => { + expect( + isAppAccessibleInWorkspace( + { id: 'dev_tools', title: 'Any app', mount: jest.fn() }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(false); + }); + + it('An app is accessible if the nav link is hidden', () => { + expect( + isAppAccessibleInWorkspace( + { + id: 'dev_tools', + title: 'Any app', + mount: jest.fn(), + navLinkStatus: AppNavLinkStatus.hidden, + }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(true); + }); + + it('An app is accessible if it is chromeless', () => { + expect( + isAppAccessibleInWorkspace( + { + id: 'dev_tools', + title: 'Any app', + mount: jest.fn(), + chromeless: true, + }, + { id: 'workspace_id', name: 'workspace name', features: [] } + ) + ).toBe(true); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 444b3aadadf3..e70a26028525 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AppCategory } from '../../../core/public'; +import { App, AppCategory, AppNavLinkStatus, WorkspaceObject } from '../../../core/public'; /** * Checks if a given feature matches the provided feature configuration. @@ -25,6 +25,11 @@ export const featureMatchesConfig = (featureConfigs: string[]) => ({ }) => { let matched = false; + /** + * Iterate through each feature configuration to determine if the given feature matches any of them. + * Note: The loop will not break prematurely because the order of featureConfigs array matters. + * Later configurations may override previous ones, so each configuration must be evaluated in sequence. + */ for (const featureConfig of featureConfigs) { // '*' matches any feature if (featureConfig === '*') { @@ -55,3 +60,42 @@ export const featureMatchesConfig = (featureConfigs: string[]) => ({ return matched; }; + +/** + * Check if an app is accessible in a workspace based on the workspace configured features + */ +export function isAppAccessibleInWorkspace(app: App, workspace: WorkspaceObject) { + /** + * When workspace has no features configured, all apps are considered to be accessible + */ + if (!workspace.features) { + return true; + } + + /** + * The app is configured into a workspace, it is accessible after entering the workspace + */ + const featureMatcher = featureMatchesConfig(workspace.features); + if (featureMatcher({ id: app.id, category: app.category })) { + return true; + } + + /* + * An app with hidden nav link is not configurable by workspace, which means user won't be + * able to select/unselect it when configuring workspace features. Such apps are by default + * accessible when in a workspace. + */ + if (app.navLinkStatus === AppNavLinkStatus.hidden) { + return true; + } + + /** + * A chromeless app is not configurable by workspace, which means user won't be + * able to select/unselect it when configuring workspace features. Such apps are by default + * accessible when in a workspace. + */ + if (app.chromeless) { + return true; + } + return false; +}