diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e7c3e72778b..a1a272a4ac14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [CSP Handler] Update CSP handler to only query and modify frame ancestors instead of all CSP directives ([#6398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6398)) - [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400)) - [Workspace] Support workspace in saved objects client in server side. ([#6365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6365)) +- [MD] Add dropdown header to data source single selector ([#6431](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6431)) ### 🐛 Bug Fixes @@ -119,7 +120,9 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [BUG][Multiple Datasource] Refactor read-only component to cover more edge cases ([#6416](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6416)) - [BUG] Fix for checkForFunctionProperty so that order does not matter ([#6248](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6248)) - [BUG][Multiple Datasource] Modify the button of selectable component to fix the title overflow issue ([#6465](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6465)) +- [Dynamic Configurations] Fix dynamic config API calls to pass correct input ([#6474](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6474)) - [BUG][Multiple Datasource] Validation succeed as long as status code in response is 200 ([#6399](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6399)) +- [BUG][Multiple Datasource] Add validation for title length to be no longer than 32 characters [#6452](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6452)) ### 🚞 Infrastructure diff --git a/src/plugins/application_config/server/routes/index.test.ts b/src/plugins/application_config/server/routes/index.test.ts index 0aa161bf560f..37fc7981ea16 100644 --- a/src/plugins/application_config/server/routes/index.test.ts +++ b/src/plugins/application_config/server/routes/index.test.ts @@ -79,6 +79,8 @@ describe('application config routes', () => { getConfig: jest.fn().mockReturnValue(configurations), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = {}; const okResponse = { @@ -91,7 +93,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetConfig(client, request, response, logger); + const returnedResponse = await handleGetConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -100,6 +107,8 @@ describe('application config routes', () => { value: configurations, }, }); + + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client throws error', async () => { @@ -111,6 +120,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = {}; const response = { @@ -119,7 +130,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetConfig(client, request, response, logger); + const returnedResponse = await handleGetConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -131,6 +147,7 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + expect(getConfigurationClient).toBeCalledWith(request); }); }); @@ -140,6 +157,8 @@ describe('application config routes', () => { getEntityConfig: jest.fn().mockReturnValue(ENTITY_VALUE), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const okResponse = { statusCode: 200, }; @@ -156,7 +175,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + const returnedResponse = await handleGetEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -165,6 +189,8 @@ describe('application config routes', () => { value: ENTITY_VALUE, }, }); + + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client throws error', async () => { @@ -176,6 +202,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = { params: { entity: ENTITY_NAME, @@ -188,7 +216,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetEntityConfig(client, request, response, logger); + const returnedResponse = await handleGetEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -200,6 +233,8 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + + expect(getConfigurationClient).toBeCalledWith(request); }); }); @@ -209,6 +244,8 @@ describe('application config routes', () => { updateEntityConfig: jest.fn().mockReturnValue(ENTITY_NEW_VALUE), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const okResponse = { statusCode: 200, }; @@ -228,7 +265,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + const returnedResponse = await handleUpdateEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -241,6 +283,8 @@ describe('application config routes', () => { }); expect(logger.error).not.toBeCalled(); + + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client fails', async () => { @@ -252,6 +296,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = { params: { entity: ENTITY_NAME, @@ -267,7 +313,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleUpdateEntityConfig(client, request, response, logger); + const returnedResponse = await handleUpdateEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -279,6 +330,8 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + + expect(getConfigurationClient).toBeCalledWith(request); }); }); @@ -288,6 +341,8 @@ describe('application config routes', () => { deleteEntityConfig: jest.fn().mockReturnValue(ENTITY_NAME), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const okResponse = { statusCode: 200, }; @@ -304,7 +359,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + const returnedResponse = await handleDeleteEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(okResponse); @@ -317,6 +377,7 @@ describe('application config routes', () => { }); expect(logger.error).not.toBeCalled(); + expect(getConfigurationClient).toBeCalledWith(request); }); it('return error response when client fails', async () => { @@ -328,6 +389,8 @@ describe('application config routes', () => { }), }; + const getConfigurationClient = jest.fn().mockReturnValue(client); + const request = { params: { entity: ENTITY_NAME, @@ -340,7 +403,12 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleDeleteEntityConfig(client, request, response, logger); + const returnedResponse = await handleDeleteEntityConfig( + getConfigurationClient, + request, + response, + logger + ); expect(returnedResponse).toBe(ERROR_RESPONSE); @@ -352,6 +420,8 @@ describe('application config routes', () => { }); expect(logger.error).toBeCalledWith(error); + + expect(getConfigurationClient).toBeCalledWith(request); }); }); }); diff --git a/src/plugins/application_config/server/routes/index.ts b/src/plugins/application_config/server/routes/index.ts index b6ec638e1aa9..82c9a98bc445 100644 --- a/src/plugins/application_config/server/routes/index.ts +++ b/src/plugins/application_config/server/routes/index.ts @@ -6,7 +6,6 @@ import { schema } from '@osd/config-schema'; import { IRouter, - IScopedClusterClient, Logger, OpenSearchDashboardsRequest, OpenSearchDashboardsResponseFactory, @@ -15,7 +14,7 @@ import { ConfigurationClient } from '../types'; export function defineRoutes( router: IRouter, - getConfigurationClient: (configurationClient: IScopedClusterClient) => ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, logger: Logger ) { router.get( @@ -24,9 +23,7 @@ export function defineRoutes( validate: false, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleGetConfig(client, request, response, logger); + return await handleGetConfig(getConfigurationClient, request, response, logger); } ); router.get( @@ -39,9 +36,7 @@ export function defineRoutes( }, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleGetEntityConfig(client, request, response, logger); + return await handleGetEntityConfig(getConfigurationClient, request, response, logger); } ); router.post( @@ -57,9 +52,7 @@ export function defineRoutes( }, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleUpdateEntityConfig(client, request, response, logger); + return await handleUpdateEntityConfig(getConfigurationClient, request, response, logger); } ); router.delete( @@ -72,21 +65,21 @@ export function defineRoutes( }, }, async (context, request, response) => { - const client = getConfigurationClient(context.core.opensearch.client); - - return await handleDeleteEntityConfig(client, request, response, logger); + return await handleDeleteEntityConfig(getConfigurationClient, request, response, logger); } ); } export async function handleGetEntityConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger ) { logger.info(`Received a request to get entity config for ${request.params.entity}.`); + const client = getConfigurationClient(request); + try { const result = await client.getEntityConfig(request.params.entity, { headers: request.headers, @@ -103,7 +96,7 @@ export async function handleGetEntityConfig( } export async function handleUpdateEntityConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger @@ -112,6 +105,8 @@ export async function handleUpdateEntityConfig( `Received a request to update entity ${request.params.entity} with new value ${request.body.newValue}.` ); + const client = getConfigurationClient(request); + try { const result = await client.updateEntityConfig(request.params.entity, request.body.newValue, { headers: request.headers, @@ -128,13 +123,15 @@ export async function handleUpdateEntityConfig( } export async function handleDeleteEntityConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger ) { logger.info(`Received a request to delete entity ${request.params.entity}.`); + const client = getConfigurationClient(request); + try { const result = await client.deleteEntityConfig(request.params.entity, { headers: request.headers, @@ -151,13 +148,15 @@ export async function handleDeleteEntityConfig( } export async function handleGetConfig( - client: ConfigurationClient, + getConfigurationClient: (request?: OpenSearchDashboardsRequest) => ConfigurationClient, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger ) { logger.info('Received a request to get all configurations.'); + const client = getConfigurationClient(request); + try { const result = await client.getConfig({ headers: request.headers }); return response.ok({ diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index cfcfdd2ce430..824f9eacc9f6 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -5,6 +5,6 @@ "ui": true, "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], "optionalPlugins": [], - "requiredBundles": ["opensearchDashboardsReact", "dataSource"], + "requiredBundles": ["opensearchDashboardsReact", "dataSource", "opensearchDashboardsUtils"], "extraPublicDirs": ["public/components/utils"] } 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 6d7200b182c5..df299687928b 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 @@ -5,18 +5,29 @@ import { createDataSourceMenu } from './create_data_source_menu'; import { MountPoint, SavedObjectsClientContract } from '../../../../../core/public'; -import { coreMock, notificationServiceMock } from '../../../../../core/public/mocks'; +import { + applicationServiceMock, + coreMock, + notificationServiceMock, +} from '../../../../../core/public/mocks'; import React from 'react'; -import { act, getByText, render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import { DataSourceComponentType, DataSourceSelectableConfig } from './types'; import { ReactWrapper } from 'enzyme'; import { mockDataSourcePluginSetupWithShowLocalCluster } from '../../mocks'; +import * as utils from '../utils'; describe('create data source menu', () => { let client: SavedObjectsClientContract; const notifications = notificationServiceMock.createStartContract(); const { uiSettings } = coreMock.createSetup(); + beforeAll(() => { + jest + .spyOn(utils, 'getApplication') + .mockImplementation(() => applicationServiceMock.createStartContract()); + }); + beforeEach(() => { client = { find: jest.fn().mockResolvedValue([]), 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 56fd7a7a7cb3..51fcb0db0857 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 @@ -10,11 +10,13 @@ import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { DataSourceMenu } from './data_source_menu'; import { DataSourceMenuProps } from './types'; import { MountPointPortal } from '../../../../opensearch_dashboards_react/public'; +import { getApplication } from '../utils'; export function createDataSourceMenu( uiSettings: IUiSettingsClient, dataSourcePluginSetup: DataSourcePluginSetup ) { + const application = getApplication(); return (props: DataSourceMenuProps) => { const { hideLocalCluster } = dataSourcePluginSetup; if (props.setMenuMountPoint) { @@ -25,13 +27,19 @@ export function createDataSourceMenu( {...props} uiSettings={uiSettings} hideLocalCluster={hideLocalCluster} + application={application} /> ); } return ( - + ); }; } 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 9511090e6c5a..bc645a0b885f 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 @@ -19,8 +19,7 @@ import { import { DataSourceSelectable } from '../data_source_selectable'; export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null { - const { componentType, componentConfig, uiSettings, hideLocalCluster } = props; - + const { componentType, componentConfig, uiSettings, hideLocalCluster, application } = props; function renderDataSourceView(config: DataSourceViewConfig): ReactElement | null { const { activeOption, @@ -81,6 +80,7 @@ export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | hideLocalCluster={hideLocalCluster || false} fullWidth={fullWidth} uiSettings={uiSettings} + application={application} /> ); } 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 a37d7d0dd0c3..e5f34a3a2979 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 @@ -8,6 +8,7 @@ import { SavedObjectsClientContract, SavedObject, IUiSettingsClient, + ApplicationStart, } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; @@ -32,6 +33,7 @@ export interface DataSourceMenuProps { componentConfig: T; hideLocalCluster?: boolean; uiSettings?: IUiSettingsClient; + application?: ApplicationStart; setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; } 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 e49222c37d7c..4601e70491d2 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 @@ -24,6 +24,7 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -37,6 +38,12 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` color="transparent" paddingSize="s" > + + @@ -70,6 +77,7 @@ exports[`DataSourceSelectable should filter options if configured 1`] = ` renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -105,6 +113,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -118,6 +127,12 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde color="transparent" paddingSize="s" > + + @@ -130,6 +145,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -165,6 +181,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd display="inlineBlock" hasArrow={true} id="dataSourceSelectableContextMenuPopover" + initialFocus=".euiSelectableSearch" isOpen={false} ownFocus={true} panelPaddingSize="none" @@ -178,6 +195,12 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd color="transparent" paddingSize="s" > + + @@ -190,6 +213,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd renderOption={[Function]} searchProps={ Object { + "compressed": true, "placeholder": "Search", } } @@ -273,6 +297,35 @@ Object {
+
+
+ DATA SOURCES + ( + 3 + ) +
+
+
+ +
+
+
@@ -281,7 +334,7 @@ Object { data-test-subj="dataSourceSelectable" >
{ let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -409,7 +410,7 @@ describe('DataSourceSelectable', () => { component.instance().componentDidMount!(); await nextTick(); const optionsProp = component.find(EuiSelectable).prop('options'); - expect(optionsProp[0]).toEqual(opensearchClusterGroupLabel); + expect(optionsProp[0]).toEqual(dataSourceOptionGroupLabel.opensearchCluster); }); it('should not render opensearch cluster group label, when there is no option availiable', async () => { @@ -431,4 +432,50 @@ describe('DataSourceSelectable', () => { const optionsProp = component.find(EuiSelectable).prop('options'); expect(optionsProp).toEqual([]); }); + + it('should render group lablel normally after onChange', async () => { + const onSelectedDataSource = jest.fn(); + component = shallow( + + ); + const componentInstance = component.instance(); + + componentInstance.componentDidMount!(); + await nextTick(); + const optionsPropBefore = component.find(EuiSelectable).prop('options'); + expect(optionsPropBefore).toEqual([ + dataSourceOptionGroupLabel.opensearchCluster, + { + id: 'test1', + label: 'test1', + checked: 'on', + }, + { + id: 'test2', + label: 'test2', + }, + { + id: 'test3', + label: 'test3', + }, + ]); + componentInstance.onChange([ + dataSourceOptionGroupLabel.opensearchCluster, + { id: 'test2', label: 'test2', checked: 'on' }, + ]); + await nextTick(); + const optionsPropAfter = component.find(EuiSelectable).prop('options'); + expect(optionsPropAfter).toEqual([ + dataSourceOptionGroupLabel.opensearchCluster, + { id: 'test2', label: 'test2', checked: 'on' }, + ]); + }); }); 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 5afb7482945c..4a339aae8176 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 @@ -12,13 +12,20 @@ import { EuiButtonEmpty, EuiSelectable, EuiSpacer, + EuiHorizontalRule, } from '@elastic/eui'; import { + ApplicationStart, IUiSettingsClient, SavedObjectsClientContract, ToastsStart, } from 'opensearch-dashboards/public'; -import { getDataSourcesWithFields, getDefaultDataSource, getFilteredDataSources } from '../utils'; +import { + dataSourceOptionGroupLabel, + getDataSourcesWithFields, + getDefaultDataSource, + getFilteredDataSources, +} from '../utils'; import { LocalCluster } from '../data_source_selector/data_source_selector'; import { SavedObject } from '../../../../../core/public'; import { DataSourceAttributes } from '../../types'; @@ -26,6 +33,7 @@ import { DataSourceGroupLabelOption, DataSourceOption } from '../data_source_men import { DataSourceItem } from '../data_source_item'; import './data_source_selectable.scss'; import '../button_title.scss'; +import { DataSourceDropDownHeader } from '../drop_down_header'; interface DataSourceSelectableProps { savedObjectsClient: SavedObjectsClientContract; @@ -34,6 +42,7 @@ interface DataSourceSelectableProps { disabled: boolean; hideLocalCluster: boolean; fullWidth: boolean; + application?: ApplicationStart; selectedOption?: DataSourceOption[]; dataSourceFilter?: (dataSource: SavedObject) => boolean; uiSettings?: IUiSettingsClient; @@ -194,9 +203,12 @@ export class DataSourceSelectable extends React.Component< onChange(options: DataSourceOption[]) { if (!this._isMounted) return; + const optionsWithoutGroupLabel = options.filter( + (option) => !option.hasOwnProperty('isGroupLabel') + ); const selectedDataSource = options.find(({ checked }) => checked); - this.setState({ dataSourceOptions: options }); + this.setState({ dataSourceOptions: optionsWithoutGroupLabel }); if (selectedDataSource) { this.setState({ @@ -214,7 +226,7 @@ export class DataSourceSelectable extends React.Component< if (dataSourceOptions.length === 0) { optionsWithGroupLabel = []; } else { - optionsWithGroupLabel = [opensearchClusterGroupLabel, ...dataSourceOptions]; + optionsWithGroupLabel = [dataSourceOptionGroupLabel.opensearchCluster, ...dataSourceOptions]; } return optionsWithGroupLabel; }; @@ -243,6 +255,7 @@ export class DataSourceSelectable extends React.Component< return ( + + this.onChange(newOptions)} 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 af5086f35a50..4f8df2542059 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 @@ -14,7 +14,6 @@ import { mockResponseForSavedObjectsCalls, } from '../../mocks'; import { AuthType } from 'src/plugins/data_source/common/data_sources'; -import * as utils from '../utils'; import { EuiComboBox } from '@elastic/eui'; describe('DataSourceSelector', () => { diff --git a/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap b/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap new file mode 100644 index 000000000000..213c946d09b4 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/__snapshots__/drop_down_header.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSourceDropDownHeader should render correctly with the provided totalDataSourceCount 1`] = ` + + + + DATA SOURCES + ( + 5 + ) + +
+ + + Manage + + + + +`; + +exports[`DataSourceDropDownHeader should render the activeDataSourceCount/totalDataSourceCount when both provided 1`] = ` + + + +
+ +
+ DATA SOURCES + ( + 2/5 + ) +
+
+
+ +
+ + + +
+
+
+ + + +`; diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss new file mode 100644 index 000000000000..244ca77b90e1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.scss @@ -0,0 +1,3 @@ +.dataSourceDropDownHeaderInvisibleFocusable { + opacity: 0; +} diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx new file mode 100644 index 000000000000..920ad09271e1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import { coreMock } from '../../../../../core/public/mocks'; +import { DSM_APP_ID } from '../../plugin'; +import { DataSourceDropDownHeader } from '.'; + +describe('DataSourceDropDownHeader', () => { + it('should render correctly with the provided totalDataSourceCount', () => { + const totalDataSourceCount = 5; + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render "DATA SOURCES" when totalDataSourceCount is greater than 1', () => { + const totalDataSourceCount = 5; + const wrapper = mount(); + expect(wrapper.text()).toContain('DATA SOURCES'); + }); + + it.each([1, 0])( + 'should render "DATA SOURCE" when totalDataSourceCount is %s', + (totalDataSourceCount) => { + const wrapper = mount( + + ); + expect(wrapper.text()).toContain('DATA SOURCE'); + } + ); + + it('should render the activeDataSourceCount/totalDataSourceCount when both provided', () => { + const totalDataSourceCount = 5; + const activeDataSourceCount = 2; + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.text()).toContain(`${activeDataSourceCount}/${totalDataSourceCount}`); + }); + + it('should call application.navigateToApp when the "Manage" link is clicked', () => { + const totalDataSourceCount = 5; + const applicationMock = coreMock.createStart().application; + const navigateToAppMock = applicationMock.navigateToApp; + + const wrapper = mount( + + ); + + wrapper.find('EuiLink').simulate('click'); + expect(navigateToAppMock).toHaveBeenCalledWith('management', { + path: `opensearch-dashboards/${DSM_APP_ID}`, + }); + }); +}); diff --git a/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx new file mode 100644 index 000000000000..2eb457c47511 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/drop_down_header.tsx @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './drop_down_header.scss'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import React from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { DSM_APP_ID } from '../../plugin'; + +interface DataSourceOptionItemProps { + totalDataSourceCount: number; + activeDataSourceCount?: number; + application?: ApplicationStart; +} + +export const DataSourceDropDownHeader: React.FC = ({ + activeDataSourceCount, + totalDataSourceCount, + application, +}) => { + const dataSourceCounterPrefix = totalDataSourceCount === 1 ? 'DATA SOURCE' : 'DATA SOURCES'; + const dataSourceCounter = + activeDataSourceCount !== undefined + ? `${activeDataSourceCount}/${totalDataSourceCount}` + : totalDataSourceCount; + + return ( + + + + {dataSourceCounterPrefix} ({dataSourceCounter}) + +
+ + + application?.navigateToApp('management', { + path: `opensearch-dashboards/${DSM_APP_ID}`, + }) + } + > + Manage + + + + + ); +}; diff --git a/src/plugins/data_source_management/public/components/drop_down_header/index.ts b/src/plugins/data_source_management/public/components/drop_down_header/index.ts new file mode 100644 index 000000000000..3fc657904637 --- /dev/null +++ b/src/plugins/data_source_management/public/components/drop_down_header/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { DataSourceDropDownHeader } from './drop_down_header'; diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index e433cdff52c5..98c23cc140c3 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -9,8 +9,10 @@ import { SavedObject, IUiSettingsClient, ToastsStart, + ApplicationStart, } from 'src/core/public'; import { i18n } from '@osd/i18n'; +import { deepFreeze } from '@osd/std'; import { DataSourceAttributes, DataSourceTableItem, @@ -19,6 +21,8 @@ import { } from '../types'; import { AuthenticationMethodRegistry } from '../auth_registry'; import { DataSourceOption } from './data_source_menu/types'; +import { DataSourceGroupLabelOption } from './data_source_menu/types'; +import { createGetterSetter } from '../../../opensearch_dashboards_utils/public'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -279,3 +283,18 @@ export const extractRegisteredAuthTypeCredentials = ( return registeredCredentials; }; + +interface DataSourceOptionGroupLabel { + [key: string]: DataSourceGroupLabelOption; +} + +export const dataSourceOptionGroupLabel = deepFreeze>({ + opensearchCluster: { + id: 'opensearchClusterGroupLabel', + label: 'OpenSearch cluster', + isGroupLabel: true, + }, + // TODO: add other group labels if needed +}); + +export const [getApplication, setApplication] = createGetterSetter('Application'); diff --git a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts index d0ef842ad4f5..7b92a355e908 100644 --- a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts +++ b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts @@ -40,6 +40,11 @@ describe('DataSourceManagement: Form Validation', () => { ); expect(result).toBe(false); }); + test('should fail validation when title is longer than 32 characters', () => { + form.title = 'test'.repeat(10); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistry); + expect(result).toBe(false); + }); test('should fail validation when endpoint is not valid', () => { form.endpoint = mockDataSourceAttributesWithAuth.endpoint; const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistry); diff --git a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts index 32c87fbee7d1..c3a7f78db2cb 100644 --- a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts +++ b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts @@ -50,8 +50,14 @@ export const isTitleValid = ( error: '', }; /* Title validation */ - if (!title?.trim?.().length) { + if (!title.trim().length) { isValid.valid = false; + } else if (title.length > 32) { + /* title length validation */ + isValid.valid = false; + isValid.error = i18n.translate('dataSourcesManagement.validation.titleLength', { + defaultMessage: 'Title must be no longer than 32 characters', + }); } else if ( title.toLowerCase() !== existingTitle.toLowerCase() && Array.isArray(existingDatasourceNamesList) && diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index abcc532b8a7e..2461044a680b 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -21,6 +21,7 @@ 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 } from './components/utils'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -40,7 +41,7 @@ export interface DataSourceManagementPluginStart { getAuthenticationMethodRegistry: () => IAuthenticationMethodRegistry; } -const DSM_APP_ID = 'dataSources'; +export const DSM_APP_ID = 'dataSourceManagement'; export class DataSourceManagementPlugin implements @@ -111,6 +112,7 @@ export class DataSourceManagementPlugin public start(core: CoreStart) { this.started = true; + setApplication(core.application); return { getAuthenticationMethodRegistry: () => this.authMethodsRegistry, };