diff --git a/src/plugins/data_source_management/public/components/constants.tsx b/src/plugins/data_source_management/public/components/constants.tsx index 0d22aed50179..720e2d175867 100644 --- a/src/plugins/data_source_management/public/components/constants.tsx +++ b/src/plugins/data_source_management/public/components/constants.tsx @@ -12,3 +12,8 @@ export const LocalCluster: DataSourceOption = { }), id: '', }; + +export const NO_DATASOURCES_CONNECTED_MESSAGE = 'No data sources connected yet.'; +export const CONNECT_DATASOURCES_MESSAGE = 'Connect your data sources to get started.'; +export const NO_COMPATIBLE_DATASOURCES_MESSAGE = 'No compatible data sources are available.'; +export const ADD_COMPATIBLE_DATASOURCES_MESSAGE = 'Add a compatible data source.'; diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx index 5251ed199cca..1f66dabe22ea 100644 --- a/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/data_source_aggregated_view.test.tsx @@ -5,8 +5,9 @@ import { ShallowWrapper, shallow } from 'enzyme'; import React from 'react'; +import { i18n } from '@osd/i18n'; import { DataSourceAggregatedView } from './data_source_aggregated_view'; -import { SavedObject, SavedObjectsClientContract } from '../../../../../core/public'; +import { IToasts, SavedObject, SavedObjectsClientContract } from '../../../../../core/public'; import { applicationServiceMock, notificationServiceMock, @@ -20,6 +21,12 @@ import { import * as utils from '../utils'; import { EuiSelectable, EuiSwitch } from '@elastic/eui'; import { DataSourceAttributes } from '../../types'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from '../constants'; describe('DataSourceAggregatedView: read all view (displayAllCompatibleDataSources is set to true)', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -269,3 +276,64 @@ describe('DataSourceAggregatedView: read active view (displayAllCompatibleDataSo } ); }); + +describe('DataSourceAggregatedView warning messages', () => { + const client = {} as any; + const uiSettings = uiSettingsServiceMock.createStartContract(); + const nextTick = () => new Promise((res) => process.nextTick(res)); + let toasts: IToasts; + const noDataSourcesConnectedMessage = `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; + const noCompatibleDataSourcesMessage = `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}`; + + beforeEach(() => { + toasts = notificationServiceMock.createStartContract().toasts; + mockUiSettingsCalls(uiSettings, 'get', 'test1'); + }); + + it.each([ + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + activeDataSourceIds: ['test2'], + }, + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + activeDataSourceIds: ['test2'], + }, + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + activeDataSourceIds: undefined, + }, + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + activeDataSourceIds: undefined, + }, + ])( + 'should display correct warning message when no datasource selections are available and local cluster is hidden', + async ({ findFunc, defaultMessage, activeDataSourceIds }) => { + client.find = findFunc; + shallow( + false} + uiSettings={uiSettings} + /> + ); + await nextTick(); + + expect(toasts.add).toBeCalledWith( + expect.objectContaining({ + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), + }) + ); + } + ); +}); 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 00eacb2ea844..72db5bd5b4c4 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 @@ -45,6 +45,7 @@ interface DataSourceAggregatedViewState extends DataSourceBaseState { allDataSourcesIdToTitleMap: Map; switchChecked: boolean; defaultDataSource: string | null; + hasIncompatibleDataSources: boolean; } interface DataSourceOptionDisplay extends DataSourceOption { @@ -68,6 +69,7 @@ export class DataSourceAggregatedView extends React.Component< showError: false, switchChecked: false, defaultDataSource: null, + hasIncompatibleDataSources: false, }; } @@ -113,11 +115,12 @@ export class DataSourceAggregatedView extends React.Component< } if (allDataSourcesIdToTitleMap.size === 0) { - handleNoAvailableDataSourceError( - this.onEmptyState.bind(this), - this.props.notifications, - this.props.application - ); + handleNoAvailableDataSourceError({ + changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), + notifications: this.props.notifications, + application: this.props.application, + hasIncompatibleDatasources: !!fetchedDataSources?.length, + }); return; } @@ -133,8 +136,8 @@ export class DataSourceAggregatedView extends React.Component< }); } - onEmptyState() { - this.setState({ showEmptyState: true }); + onEmptyState(hasIncompatibleDataSources: boolean) { + this.setState({ showEmptyState: true, hasIncompatibleDataSources }); } onError() { @@ -143,7 +146,12 @@ export class DataSourceAggregatedView extends React.Component< render() { if (this.state.showEmptyState) { - return ; + return ( + + ); } if (this.state.showError) { return ; 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 85506ec84b61..d0432ee2cb20 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 @@ -34,6 +34,7 @@ interface DataSourceMultiSeletableState extends DataSourceBaseState { dataSourceOptions: SelectedDataSourceOption[]; selectedOptions: SelectedDataSourceOption[]; defaultDataSource: string | null; + hasIncompatibleDatasources: boolean; } export class DataSourceMultiSelectable extends React.Component< @@ -51,6 +52,7 @@ export class DataSourceMultiSelectable extends React.Component< defaultDataSource: null, showEmptyState: false, showError: false, + hasIncompatibleDatasources: false, }; } @@ -90,12 +92,13 @@ export class DataSourceMultiSelectable extends React.Component< if (!this._isMounted) return; if (selectedOptions.length === 0) { - handleNoAvailableDataSourceError( - this.onEmptyState.bind(this), - this.props.notifications, - this.props.application, - this.props.onSelectedDataSources - ); + handleNoAvailableDataSourceError({ + changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), + notifications: this.props.notifications, + application: this.props.application, + callback: this.props.onSelectedDataSources, + hasIncompatibleDatasources: !!fetchedDataSources?.length, + }); return; } @@ -115,8 +118,8 @@ export class DataSourceMultiSelectable extends React.Component< } } - onEmptyState() { - this.setState({ showEmptyState: true }); + onEmptyState(hasIncompatibleDatasources: boolean) { + this.setState({ showEmptyState: true, hasIncompatibleDatasources }); } onError() { 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 f1cef722c3c1..f1f4bb06e138 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 @@ -4,6 +4,7 @@ */ import { ShallowWrapper, shallow, mount } from 'enzyme'; +import { i18n } from '@osd/i18n'; import { SavedObjectsClientContract } from '../../../../../core/public'; import { notificationServiceMock } from '../../../../../core/public/mocks'; import React from 'react'; @@ -12,6 +13,12 @@ import { AuthType } from '../../types'; import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls } from '../../mocks'; import { render } from '@testing-library/react'; import * as utils from '../utils'; +import { + NO_DATASOURCES_CONNECTED_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + ADD_COMPATIBLE_DATASOURCES_MESSAGE, +} from '../constants'; describe('DataSourceSelectable', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -19,6 +26,8 @@ describe('DataSourceSelectable', () => { 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}`; beforeEach(() => { client = { @@ -145,6 +154,7 @@ describe('DataSourceSelectable', () => { }, ], showError: false, + hasIncompatibleDatasources: false, }); containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); @@ -167,6 +177,7 @@ describe('DataSourceSelectable', () => { }, ], showError: false, + hasIncompatibleDatasources: false, }); expect(onSelectedDataSource).toBeCalledWith([{ id: 'test2', label: 'test2' }]); @@ -345,6 +356,7 @@ describe('DataSourceSelectable', () => { }, ], showError: false, + hasIncompatibleDatasources: false, }); }); @@ -374,6 +386,7 @@ describe('DataSourceSelectable', () => { selectedOption: [], showEmptyState: false, showError: true, + hasIncompatibleDatasources: false, }); containerInstance.onChange([{ id: 'test2', label: 'test2', checked: 'on' }]); @@ -396,27 +409,59 @@ describe('DataSourceSelectable', () => { }, ], showError: true, + hasIncompatibleDatasources: false, }); expect(onSelectedDataSource).toBeCalledWith([{ id: 'test2', label: 'test2' }]); expect(onSelectedDataSource).toHaveBeenCalled(); }); - it('should render no data source when no data source filtered out and hide local cluster', async () => { - const onSelectedDataSource = jest.fn(); - render( - false} - /> - ); - await nextTick(); - expect(toasts.add).toBeCalled(); - expect(onSelectedDataSource).toBeCalledWith([]); - }); + + it.each([ + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + selectedOption: undefined, + }, + { + findFunc: jest.fn().mockResolvedValue({ savedObjects: [] }), + defaultMessage: noDataSourcesConnectedMessage, + selectedOption: [{ id: 'test2' }], + }, + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + selectedOption: undefined, + }, + { + findFunc: jest.fn().mockResolvedValue(getDataSourcesWithFieldsResponse), + defaultMessage: noCompatibleDataSourcesMessage, + selectedOption: [{ id: 'test2' }], + }, + ])( + 'should render correct message when there are no datasource options available and local cluster is hidden', + async ({ findFunc, selectedOption, defaultMessage }) => { + client.find = findFunc; + const onSelectedDataSource = jest.fn(); + render( + false} + /> + ); + await nextTick(); + + expect(toasts.add).toBeCalledWith( + expect.objectContaining({ + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), + }) + ); + expect(onSelectedDataSource).toBeCalledWith([]); + } + ); }); 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 63b9eebf26c1..121f6724ce7a 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 @@ -56,6 +56,7 @@ interface DataSourceSelectableState extends DataSourceBaseState { isPopoverOpen: boolean; selectedOption?: DataSourceOption[]; defaultDataSource: string | null; + hasIncompatibleDatasources: boolean; } export class DataSourceSelectable extends React.Component< @@ -74,6 +75,7 @@ export class DataSourceSelectable extends React.Component< defaultDataSource: null, showEmptyState: false, showError: false, + hasIncompatibleDatasources: false, }; this.onChange.bind(this); @@ -187,12 +189,13 @@ export class DataSourceSelectable extends React.Component< } if (dataSourceOptions.length === 0) { - handleNoAvailableDataSourceError( - this.onEmptyState.bind(this), - this.props.notifications, - this.props.application, - this.props.onSelectedDataSources - ); + handleNoAvailableDataSourceError({ + changeState: this.onEmptyState.bind(this, !!fetchedDataSources?.length), + notifications: this.props.notifications, + application: this.props.application, + callback: this.props.onSelectedDataSources, + hasIncompatibleDatasources: !!fetchedDataSources?.length, + }); return; } @@ -214,8 +217,8 @@ export class DataSourceSelectable extends React.Component< } } - onEmptyState() { - this.setState({ showEmptyState: true }); + onEmptyState(hasIncompatibleDatasources: boolean) { + this.setState({ showEmptyState: true, hasIncompatibleDatasources }); } onError() { @@ -242,7 +245,12 @@ export class DataSourceSelectable extends React.Component< render() { if (this.state.showEmptyState) { - return ; + return ( + + ); } if (this.state.showError) { diff --git a/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap b/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap index ee8f2120012f..22719ee81b37 100644 --- a/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/no_data_source/__snapshots__/no_data_source.test.tsx.snap @@ -83,7 +83,90 @@ exports[`NoDataSource should render correctly with the provided totalDataSourceC `; -exports[`NoDataSource should render normally 1`] = ` +exports[`NoDataSource should render normally when hasIncompatibleDatasources is %b 1`] = ` + + } + closePopover={[Function]} + data-test-subj="dataSourceEmptyStatePopover" + display="inlineBlock" + hasArrow={true} + id="dataSourceEmptyStatePopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" +> + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`NoDataSource should render normally when hasIncompatibleDatasources is %b 2`] = ` { const nextTick = () => new Promise((res) => process.nextTick(res)); it('should render correctly with the provided totalDataSourceCount', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper).toMatchSnapshot(); }); it('should display popover when click "No data sources" button', async () => { const applicationMock = coreMock.createStart().application; const container = render( - + ); await nextTick(); @@ -39,7 +45,11 @@ describe('NoDataSource', () => { const navigateToAppMock = applicationMock.navigateToApp; const container = render( - + ); await nextTick(); @@ -55,8 +65,16 @@ describe('NoDataSource', () => { }); }); - it('should render normally', () => { - component = shallow(); - expect(component).toMatchSnapshot(); - }); + it.each([false, true])( + 'should render normally when hasIncompatibleDatasources is %b', + (hasIncompatibleDatasources) => { + component = shallow( + + ); + expect(component).toMatchSnapshot(); + } + ); }); diff --git a/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx b/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx index d10efe8c4a7b..9b12f22506d1 100644 --- a/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx +++ b/src/plugins/data_source_management/public/components/no_data_source/no_data_source.tsx @@ -20,12 +20,22 @@ import { FormattedMessage } from 'react-intl'; import { DataSourceDropDownHeader } from '../drop_down_header'; import { DSM_APP_ID } from '../../plugin'; import { EmptyIcon } from '../custom_database_icon'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from '../constants'; interface DataSourceDropDownHeaderProps { application?: ApplicationStart; + hasIncompatibleDatasources: boolean; } -export const NoDataSource: React.FC = ({ application }) => { +export const NoDataSource: React.FC = ({ + application, + hasIncompatibleDatasources, +}) => { const [showPopover, setShowPopover] = useState(false); const button = ( = ({ applicat { } @@ -72,7 +86,11 @@ export const NoDataSource: React.FC = ({ applicat { } 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 b2628e3d3062..19f9897f89c3 100644 --- a/src/plugins/data_source_management/public/components/utils.test.ts +++ b/src/plugins/data_source_management/public/components/utils.test.ts @@ -42,10 +42,17 @@ import { sigV4AuthMethod, usernamePasswordAuthMethod, } from '../types'; -import { HttpStart, SavedObject } from 'opensearch-dashboards/public'; +import { HttpStart, IToasts, SavedObject } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; import { AuthenticationMethod, AuthenticationMethodRegistry } from '../auth_registry'; import { deepEqual } from 'assert'; import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from './constants'; const { savedObjects } = coreMock.createStart(); const { uiSettings } = coreMock.createStart(); @@ -84,13 +91,40 @@ describe('DataSourceManagement: Utils.ts', () => { }); describe('Handle no available data source error', () => { - const { toasts } = notificationServiceMock.createStartContract(); + let toasts: IToasts; + const noDataSourcesConnectedMessage = `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; + const noCompatibleDataSourcesMessage = `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}`; - test('should send warning when data source is not available', () => { - const changeState = jest.fn(); - handleNoAvailableDataSourceError(changeState, toasts); - expect(toasts.add).toBeCalledTimes(1); - }); + beforeEach(() => { + toasts = notificationServiceMock.createStartContract().toasts; + }); + + test.each([ + { + hasIncompatibleDatasources: false, + defaultMessage: noDataSourcesConnectedMessage, + }, + { + hasIncompatibleDatasources: true, + defaultMessage: noCompatibleDataSourcesMessage, + }, + ])( + 'should send warning when data source is not available', + ({ hasIncompatibleDatasources, defaultMessage }) => { + const changeState = jest.fn(); + handleNoAvailableDataSourceError({ + changeState, + notifications: toasts, + hasIncompatibleDatasources, + }); + expect(toasts.add).toBeCalledTimes(1); + expect(toasts.add).toBeCalledWith( + expect.objectContaining({ + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), + }) + ); + } + ); }); describe('Get data source by ID', () => { diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 8f635f840aec..a7dc7fde9554 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -25,6 +25,12 @@ import { DataSourceGroupLabelOption } from './data_source_menu/types'; import { createGetterSetter } from '../../../opensearch_dashboards_utils/public'; import { toMountPoint } from '../../../opensearch_dashboards_react/public'; import { getManageDataSourceButton, getReloadButton } from './toast_button'; +import { + ADD_COMPATIBLE_DATASOURCES_MESSAGE, + CONNECT_DATASOURCES_MESSAGE, + NO_COMPATIBLE_DATASOURCES_MESSAGE, + NO_DATASOURCES_CONNECTED_MESSAGE, +} from './constants'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -87,18 +93,25 @@ export async function setFirstDataSourceAsDefault( } } -export function handleNoAvailableDataSourceError( - changeState: () => void, - notifications: ToastsStart, - application?: ApplicationStart, - callback?: (ds: DataSourceOption[]) => void -) { +export interface HandleNoAvailableDataSourceErrorProps { + changeState: () => void; + notifications: ToastsStart; + application?: ApplicationStart; + callback?: (ds: DataSourceOption[]) => void; + hasIncompatibleDatasources: boolean; +} + +export function handleNoAvailableDataSourceError(props: HandleNoAvailableDataSourceErrorProps) { + const { changeState, notifications, application, callback, hasIncompatibleDatasources } = props; + + const defaultMessage = hasIncompatibleDatasources + ? `${NO_COMPATIBLE_DATASOURCES_MESSAGE} ${ADD_COMPATIBLE_DATASOURCES_MESSAGE}` + : `${NO_DATASOURCES_CONNECTED_MESSAGE} ${CONNECT_DATASOURCES_MESSAGE}`; + changeState(); if (callback) callback([]); notifications.add({ - title: i18n.translate('dataSource.noAvailableDataSourceError', { - defaultMessage: 'No data sources connected yet. Connect your data sources to get started.', - }), + title: i18n.translate('dataSource.noAvailableDataSourceError', { defaultMessage }), text: toMountPoint(getManageDataSourceButton(application)), color: 'warning', });