diff --git a/CHANGELOG.md b/CHANGELOG.md
index 010eea83d02b..63b69c240dc4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,11 +59,18 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164))
- [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170))
- [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6058))
+- [Multiple Datasource] Remove arrow down icon from data source selectable component ([#6257](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6257))
- [Multiple Datasource] Add import support for Vega when specifying a datasource ([#6123](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6123))
- [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154))
- [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150))
- [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231))
- [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171))
+- [Multiple Datasource] Add multi selectable data source component ([#6211](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6211))
+- [Multiple Datasource] Refactor data source menu and interface to allow cleaner selection of component and related configurations ([#6256](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6256))
+- [Multiple Datasource] Allow top nav menu to mount data source menu for use case when both menus are mounted ([#6268](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6268))
+- [Workspace] Add create workspace page ([#6179](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6179))
+- [Multiple Datasource] Make sure customer always have a default datasource ([#6237](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6237))
+- [Workspace] Add workspace list page ([#6182](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6182))
- [Multiple Datasource] Append `data_source_name` to the Vega spec and add datasource reference when installing sample data ([#6218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6218))
### 🐛 Bug Fixes
diff --git a/src/core/public/index.ts b/src/core/public/index.ts
index c82457ef2184..cc51c7215964 100644
--- a/src/core/public/index.ts
+++ b/src/core/public/index.ts
@@ -94,7 +94,7 @@ export type { Logos } from '../common';
export { PackageInfo, EnvironmentMode } from '../server/types';
/** @interal */
export { CoreContext, CoreSystem } from './core_system';
-export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils';
+export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE, cleanWorkspaceId } from '../utils';
export {
AppCategory,
UiSettingsParams,
@@ -358,3 +358,5 @@ export {
export { __osdBootstrap__ } from './osd_bootstrap';
export { WorkspacesStart, WorkspacesSetup, WorkspacesService, WorkspaceObject } from './workspace';
+
+export { debounce } from './utils';
diff --git a/src/core/public/utils/debounce.test.ts b/src/core/public/utils/debounce.test.ts
new file mode 100644
index 000000000000..7722a26bd0e5
--- /dev/null
+++ b/src/core/public/utils/debounce.test.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { debounce } from './debounce';
+
+describe('debounce', () => {
+ let fn: Function;
+ beforeEach(() => {
+ fn = jest.fn();
+ jest.useFakeTimers();
+ });
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ test('it should call the debounced fn once at the end of the quiet time', () => {
+ const debounced = debounce(fn, 1000);
+
+ for (let i = 0; i < 100; i++) {
+ debounced(i);
+ }
+
+ jest.advanceTimersByTime(1001);
+ expect(fn).toBeCalledTimes(1);
+ expect(fn).toBeCalledWith(99);
+ });
+
+ test("with a leading invocation, it should call the debounced fn once, if the time doens't pass", () => {
+ const debounced = debounce(fn, 1000, true);
+
+ for (let i = 0; i < 100; i++) {
+ debounced(i);
+ }
+
+ jest.advanceTimersByTime(999);
+
+ expect(fn).toBeCalledTimes(1);
+ expect(fn).toBeCalledWith(0);
+ });
+
+ test('with a leading invocation, it should call the debounced fn twice (at the beginning and at the end)', () => {
+ const debounced = debounce(fn, 1000, true);
+
+ for (let i = 0; i < 100; i++) {
+ debounced(i);
+ }
+
+ jest.advanceTimersByTime(1500);
+
+ expect(fn).toBeCalledTimes(2);
+ expect(fn).toBeCalledWith(0);
+ expect(fn).toBeCalledWith(99);
+ });
+});
diff --git a/src/core/public/utils/debounce.ts b/src/core/public/utils/debounce.ts
new file mode 100644
index 000000000000..95e1a81dcab8
--- /dev/null
+++ b/src/core/public/utils/debounce.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @param func The function to be debounced.
+ * @param delay The time in milliseconds to wait before invoking the function again after the last invocation.
+ * @param leading An optional parameter that, when true, allows the function to be invoked immediately upon the first call.
+
+ */
+export const debounce = (func: Function, delay: number, leading?: boolean) => {
+ let timerId: NodeJS.Timeout;
+
+ return (...args: any) => {
+ if (!timerId && leading) {
+ func(...args);
+ }
+ clearTimeout(timerId);
+
+ timerId = setTimeout(() => func(...args), delay);
+ };
+};
diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts
index 30055b0ff81c..4c64728feb16 100644
--- a/src/core/public/utils/index.ts
+++ b/src/core/public/utils/index.ts
@@ -38,3 +38,4 @@ export {
getWorkspaceIdFromUrl,
cleanWorkspaceId,
} from '../../utils';
+export { debounce } from './debounce';
diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap
index 262deaaaeaf0..e7e215dfb6bb 100644
--- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap
+++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap
@@ -952,6 +952,7 @@ exports[`Dashboard top nav render in embed mode 1`] = `
onQuerySubmit={[Function]}
onSavedQueryIdChange={[Function]}
screenTitle="Dashboard Test Title"
+ showDataSourceMenu={false}
showDatePicker={false}
showFilterBar={false}
showQueryBar={false}
@@ -1919,6 +1920,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] =
onQuerySubmit={[Function]}
onSavedQueryIdChange={[Function]}
screenTitle="Dashboard Test Title"
+ showDataSourceMenu={false}
showDatePicker={false}
showFilterBar={false}
showQueryBar={false}
@@ -2886,6 +2888,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b
onQuerySubmit={[Function]}
onSavedQueryIdChange={[Function]}
screenTitle="Dashboard Test Title"
+ showDataSourceMenu={false}
showDatePicker={false}
showFilterBar={false}
showQueryBar={false}
@@ -3853,6 +3856,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu
onQuerySubmit={[Function]}
onSavedQueryIdChange={[Function]}
screenTitle="Dashboard Test Title"
+ showDataSourceMenu={false}
showDatePicker={false}
showFilterBar={false}
showQueryBar={false}
@@ -4820,6 +4824,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be
onQuerySubmit={[Function]}
onSavedQueryIdChange={[Function]}
screenTitle="Dashboard Test Title"
+ showDataSourceMenu={false}
showDatePicker={false}
showFilterBar={false}
showQueryBar={false}
@@ -5786,6 +5791,7 @@ exports[`Dashboard top nav render with all components 1`] = `
onQuerySubmit={[Function]}
onSavedQueryIdChange={[Function]}
screenTitle="Dashboard Test Title"
+ showDataSourceMenu={false}
showDatePicker={false}
showFilterBar={true}
showQueryBar={false}
diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx
index 1fe6e4f5d499..aa4e128094c7 100644
--- a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx
+++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx
@@ -53,7 +53,7 @@ describe('Datasource Management: Create Datasource Wizard', () => {
test('should create datasource successfully', async () => {
spyOn(utils, 'createSingleDataSource').and.returnValue({});
-
+ spyOn(utils, 'handleSetDefaultDatasource').and.returnValue({});
await act(async () => {
// @ts-ignore
await component.find(formIdentifier).first().prop('handleSubmit')(
@@ -62,6 +62,7 @@ describe('Datasource Management: Create Datasource Wizard', () => {
});
expect(utils.createSingleDataSource).toHaveBeenCalled();
expect(history.push).toBeCalledWith('');
+ expect(utils.handleSetDefaultDatasource).toHaveBeenCalled();
});
test('should fail to create datasource', async () => {
diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx
index 06b77efd9b94..58f9ae108083 100644
--- a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx
+++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx
@@ -21,6 +21,7 @@ import {
getDataSources,
testConnection,
fetchDataSourceVersion,
+ handleSetDefaultDatasource,
} from '../utils';
import { LoadingMask } from '../loading_mask';
@@ -35,6 +36,7 @@ export const CreateDataSourceWizard: React.FunctionComponent().services;
/* State Variables */
@@ -76,6 +78,8 @@ export const CreateDataSourceWizard: React.FunctionComponent
+
+
+
+
+
+
+
+ All
+
+
+
+
@@ -62,83 +223,120 @@ Object {
`;
exports[`DataSourceMenu should render data source selectable only with local cluster is hidden 1`] = `
-
-
-
-
-
+
`;
exports[`DataSourceMenu should render data source selectable only with local cluster not hidden 1`] = `
-
-
-
-
-
+
`;
exports[`DataSourceMenu should render data source view only 1`] = `
-
-
-
-
-
+
+`;
+
+exports[`DataSourceMenu should render nothing 1`] = `
+Object {
+ "asFragment": [Function],
+ "baseElement":
+
+ ,
+ "container": ,
+ "debug": [Function],
+ "findAllByAltText": [Function],
+ "findAllByDisplayValue": [Function],
+ "findAllByLabelText": [Function],
+ "findAllByPlaceholderText": [Function],
+ "findAllByRole": [Function],
+ "findAllByTestId": [Function],
+ "findAllByText": [Function],
+ "findAllByTitle": [Function],
+ "findByAltText": [Function],
+ "findByDisplayValue": [Function],
+ "findByLabelText": [Function],
+ "findByPlaceholderText": [Function],
+ "findByRole": [Function],
+ "findByTestId": [Function],
+ "findByText": [Function],
+ "findByTitle": [Function],
+ "getAllByAltText": [Function],
+ "getAllByDisplayValue": [Function],
+ "getAllByLabelText": [Function],
+ "getAllByPlaceholderText": [Function],
+ "getAllByRole": [Function],
+ "getAllByTestId": [Function],
+ "getAllByText": [Function],
+ "getAllByTitle": [Function],
+ "getByAltText": [Function],
+ "getByDisplayValue": [Function],
+ "getByLabelText": [Function],
+ "getByPlaceholderText": [Function],
+ "getByRole": [Function],
+ "getByTestId": [Function],
+ "getByText": [Function],
+ "getByTitle": [Function],
+ "queryAllByAltText": [Function],
+ "queryAllByDisplayValue": [Function],
+ "queryAllByLabelText": [Function],
+ "queryAllByPlaceholderText": [Function],
+ "queryAllByRole": [Function],
+ "queryAllByTestId": [Function],
+ "queryAllByText": [Function],
+ "queryAllByTitle": [Function],
+ "queryByAltText": [Function],
+ "queryByDisplayValue": [Function],
+ "queryByLabelText": [Function],
+ "queryByPlaceholderText": [Function],
+ "queryByRole": [Function],
+ "queryByTestId": [Function],
+ "queryByText": [Function],
+ "queryByTitle": [Function],
+ "rerender": [Function],
+ "unmount": [Function],
+}
`;
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_selectable.test.tsx.snap
index 944750bde432..56c0e85d0219 100644
--- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_selectable.test.tsx.snap
+++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_selectable.test.tsx.snap
@@ -5,16 +5,13 @@ exports[`DataSourceSelectable should filter options if configured 1`] = `
anchorPosition="downLeft"
button={
-
@@ -43,6 +40,7 @@ exports[`DataSourceSelectable should filter options if configured 1`] = `
/>
-
@@ -119,6 +114,7 @@ exports[`DataSourceSelectable should render normally with local cluster is hidde
/>
-
@@ -180,6 +173,7 @@ exports[`DataSourceSelectable should render normally with local cluster not hidd
/>
`;
+
+exports[`DataSourceSelectable should show popover when click on button 1`] = `
+Object {
+ "asFragment": [Function],
+ "baseElement":
+
+
+
+
+
+
+
+
+ You are in a dialog. To close this dialog, hit escape.
+
+
+
+
+
+
+
+ ,
+ "container":
+
+
,
+ "debug": [Function],
+ "findAllByAltText": [Function],
+ "findAllByDisplayValue": [Function],
+ "findAllByLabelText": [Function],
+ "findAllByPlaceholderText": [Function],
+ "findAllByRole": [Function],
+ "findAllByTestId": [Function],
+ "findAllByText": [Function],
+ "findAllByTitle": [Function],
+ "findByAltText": [Function],
+ "findByDisplayValue": [Function],
+ "findByLabelText": [Function],
+ "findByPlaceholderText": [Function],
+ "findByRole": [Function],
+ "findByTestId": [Function],
+ "findByText": [Function],
+ "findByTitle": [Function],
+ "getAllByAltText": [Function],
+ "getAllByDisplayValue": [Function],
+ "getAllByLabelText": [Function],
+ "getAllByPlaceholderText": [Function],
+ "getAllByRole": [Function],
+ "getAllByTestId": [Function],
+ "getAllByText": [Function],
+ "getAllByTitle": [Function],
+ "getByAltText": [Function],
+ "getByDisplayValue": [Function],
+ "getByLabelText": [Function],
+ "getByPlaceholderText": [Function],
+ "getByRole": [Function],
+ "getByTestId": [Function],
+ "getByText": [Function],
+ "getByTitle": [Function],
+ "queryAllByAltText": [Function],
+ "queryAllByDisplayValue": [Function],
+ "queryAllByLabelText": [Function],
+ "queryAllByPlaceholderText": [Function],
+ "queryAllByRole": [Function],
+ "queryAllByTestId": [Function],
+ "queryAllByText": [Function],
+ "queryAllByTitle": [Function],
+ "queryByAltText": [Function],
+ "queryByDisplayValue": [Function],
+ "queryByLabelText": [Function],
+ "queryByPlaceholderText": [Function],
+ "queryByRole": [Function],
+ "queryByTestId": [Function],
+ "queryByText": [Function],
+ "queryByTitle": [Function],
+ "rerender": [Function],
+ "unmount": [Function],
+}
+`;
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 1e4c43389847..52aaefe5ea75 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
@@ -4,10 +4,12 @@
*/
import { createDataSourceMenu } from './create_data_source_menu';
-import { SavedObjectsClientContract } from '../../../../../core/public';
+import { MountPoint, SavedObjectsClientContract } from '../../../../../core/public';
import { notificationServiceMock } from '../../../../../core/public/mocks';
import React from 'react';
-import { render } from '@testing-library/react';
+import { act, render } from '@testing-library/react';
+import { DataSourceComponentType, DataSourceSelectableConfig } from './types';
+import { ReactWrapper } from 'enzyme';
describe('create data source menu', () => {
let client: SavedObjectsClientContract;
@@ -19,18 +21,18 @@ describe('create data source menu', () => {
} as any;
});
- it('should render normally', () => {
+ it('should render data source selectable normally', () => {
const props = {
- showDataSourceSelectable: true,
- appName: 'myapp',
- savedObjects: client,
- notifications,
- fullWidth: true,
- hideLocalCluster: true,
- disableDataSourceSelectable: false,
- className: 'myclass',
+ componentType: DataSourceComponentType.DataSourceSelectable,
+ componentConfig: {
+ fullWidth: true,
+ hideLocalCluster: true,
+ onSelectedDataSources: jest.fn(),
+ savedObjects: client,
+ notifications,
+ },
};
- const TestComponent = createDataSourceMenu();
+ const TestComponent = createDataSourceMenu();
const component = render();
expect(component).toMatchSnapshot();
expect(client.find).toBeCalledWith({
@@ -41,3 +43,68 @@ describe('create data source menu', () => {
expect(notifications.toasts.addWarning).toBeCalledTimes(0);
});
});
+
+describe('when setMenuMountPoint is provided', () => {
+ let portalTarget: HTMLElement;
+ let mountPoint: MountPoint;
+ let setMountPoint: jest.Mock<(mountPoint: MountPoint) => void>;
+ let dom: ReactWrapper;
+
+ let client: SavedObjectsClientContract;
+ const notifications = notificationServiceMock.createStartContract();
+
+ const refresh = () => {
+ new Promise(async (resolve) => {
+ if (dom) {
+ act(() => {
+ dom.update();
+ });
+ }
+ setImmediate(() => resolve(dom)); // flushes any pending promises
+ });
+ };
+
+ beforeEach(() => {
+ portalTarget = document.createElement('div');
+ document.body.append(portalTarget);
+ setMountPoint = jest.fn().mockImplementation((mp) => (mountPoint = mp));
+ client = {
+ find: jest.fn().mockResolvedValue([]),
+ } as any;
+ });
+
+ afterEach(() => {
+ if (portalTarget) {
+ portalTarget.remove();
+ }
+ });
+
+ it('should mount data source component', async () => {
+ const props = {
+ setMenuMountPoint: setMountPoint,
+ componentType: DataSourceComponentType.DataSourceSelectable,
+ componentConfig: {
+ fullWidth: true,
+ hideLocalCluster: true,
+ onSelectedDataSources: jest.fn(),
+ savedObjects: client,
+ notifications,
+ },
+ };
+ const TestComponent = createDataSourceMenu();
+ const component = render();
+ act(() => {
+ mountPoint(portalTarget);
+ });
+
+ await refresh();
+ expect(component).toMatchSnapshot();
+ expect(client.find).toBeCalledWith({
+ fields: ['id', 'title', 'auth.type'],
+ perPage: 10000,
+ type: 'data-source',
+ });
+ expect(notifications.toasts.addWarning).toBeCalledTimes(0);
+ expect(setMountPoint).toBeCalledTimes(1);
+ });
+});
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 7d5972f8e068..2e76c2cf23c6 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
@@ -4,10 +4,22 @@
*/
import React from 'react';
-import { DataSourceMenu, DataSourceMenuProps } from './data_source_menu';
+import { EuiHeaderLinks } from '@elastic/eui';
+import { DataSourceMenu } from './data_source_menu';
+import { DataSourceMenuProps } from './types';
+import { MountPointPortal } from '../../../../opensearch_dashboards_react/public';
-export function createDataSourceMenu() {
- return (props: DataSourceMenuProps) => {
+export function createDataSourceMenu() {
+ return (props: DataSourceMenuProps) => {
+ if (props.setMenuMountPoint) {
+ return (
+
+
+
+
+
+ );
+ }
return ;
};
}
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx
index a5ec61107b3e..4f7914148ca8 100644
--- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx
+++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx
@@ -9,6 +9,7 @@ import { notificationServiceMock } from '../../../../../core/public/mocks';
import React from 'react';
import { DataSourceMenu } from './data_source_menu';
import { render } from '@testing-library/react';
+import { DataSourceComponentType } from './types';
describe('DataSourceMenu', () => {
let component: ShallowWrapper, React.Component<{}, {}, any>>;
@@ -25,15 +26,14 @@ describe('DataSourceMenu', () => {
it('should render data source selectable only with local cluster not hidden', () => {
component = shallow(
);
expect(component).toMatchSnapshot();
@@ -42,15 +42,14 @@ describe('DataSourceMenu', () => {
it('should render data source selectable only with local cluster is hidden', () => {
component = shallow(
);
expect(component).toMatchSnapshot();
@@ -59,10 +58,8 @@ describe('DataSourceMenu', () => {
it('should render data source view only', () => {
component = shallow(
);
expect(component).toMatchSnapshot();
@@ -71,7 +68,37 @@ describe('DataSourceMenu', () => {
it('should render data source aggregated view', () => {
const container = render(
+ );
+ expect(container).toMatchSnapshot();
+ });
+
+ it('should render nothing', () => {
+ const container = render(
+
+ );
+ expect(container).toMatchSnapshot();
+ });
+
+ it('should render data source multi select component', () => {
+ const container = render(
+ void;
- disableDataSourceSelectable?: boolean;
- className?: string;
- selectedOption?: DataSourceOption[];
- setMenuMountPoint?: (menuMount: MountPoint | undefined) => void;
- dataSourceFilter?: (dataSource: SavedObject) => boolean;
- displayAllCompatibleDataSources?: boolean;
-}
+import { DataSourceMultiSelectable } from '../data_source_multi_selectable/data_source_multi_selectable';
+import {
+ DataSourceAggregatedViewConfig,
+ DataSourceComponentType,
+ DataSourceMenuProps,
+ DataSourceMultiSelectableConfig,
+ DataSourceSelectableConfig,
+ DataSourceViewConfig,
+} from './types';
-export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null {
- const {
- savedObjects,
- notifications,
- dataSourceCallBackFunc,
- showDataSourceSelectable,
- disableDataSourceSelectable,
- showDataSourceAggregatedView,
- fullWidth,
- hideLocalCluster,
- selectedOption,
- showDataSourceView,
- dataSourceFilter,
- activeDataSourceIds,
- displayAllCompatibleDataSources,
- } = props;
+export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null {
+ const { componentType, componentConfig } = props;
- if (!showDataSourceSelectable && !showDataSourceView && !showDataSourceAggregatedView) {
- return null;
+ function renderDataSourceView(config: DataSourceViewConfig): ReactElement | null {
+ const { activeOption, fullWidth } = config;
+ return (
+ 0 ? activeOption : undefined}
+ fullWidth={fullWidth}
+ />
+ );
}
- function renderDataSourceView(className: string): ReactElement | null {
- if (!showDataSourceView) return null;
+ function renderDataSourceMultiSelectable(
+ config: DataSourceMultiSelectableConfig
+ ): ReactElement | null {
+ const {
+ fullWidth,
+ hideLocalCluster,
+ savedObjects,
+ notifications,
+ onSelectedDataSources,
+ } = config;
return (
-
- 0 ? selectedOption : undefined}
- />
-
+
);
}
- function renderDataSourceSelectable(className: string): ReactElement | null {
- if (!showDataSourceSelectable) return null;
+ function renderDataSourceSelectable(config: DataSourceSelectableConfig): ReactElement | null {
+ const {
+ onSelectedDataSources,
+ disabled,
+ activeOption,
+ hideLocalCluster,
+ fullWidth,
+ savedObjects,
+ notifications,
+ dataSourceFilter,
+ } = config;
return (
-
- 0 ? selectedOption : undefined}
- dataSourceFilter={dataSourceFilter}
- />
-
+ 0 ? activeOption : undefined}
+ dataSourceFilter={dataSourceFilter}
+ hideLocalCluster={hideLocalCluster || false}
+ fullWidth={fullWidth}
+ />
);
}
- function renderDataSourceAggregatedView(): ReactElement | null {
- if (!showDataSourceAggregatedView) return null;
+ function renderDataSourceAggregatedView(
+ config: DataSourceAggregatedViewConfig
+ ): ReactElement | null {
+ const {
+ fullWidth,
+ hideLocalCluster,
+ activeDataSourceIds,
+ displayAllCompatibleDataSources,
+ savedObjects,
+ notifications,
+ dataSourceFilter,
+ } = config;
return (
-
- {renderDataSourceAggregatedView()}
- {renderDataSourceSelectable(menuClassName)}
- {renderDataSourceView(menuClassName)}
-
- >
- );
- } else {
- return (
- <>
- {renderDataSourceSelectable(menuClassName)}
- {renderDataSourceView(menuClassName)}
- >
- );
+ function renderLayout(): ReactElement | null {
+ switch (componentType) {
+ case DataSourceComponentType.DataSourceAggregatedView:
+ return renderDataSourceAggregatedView(componentConfig as DataSourceAggregatedViewConfig);
+ case DataSourceComponentType.DataSourceSelectable:
+ return renderDataSourceSelectable(componentConfig as DataSourceSelectableConfig);
+ case DataSourceComponentType.DataSourceView:
+ return renderDataSourceView(componentConfig as DataSourceViewConfig);
+ case DataSourceComponentType.DataSourceMultiSelectable:
+ return renderDataSourceMultiSelectable(componentConfig as DataSourceMultiSelectableConfig);
+ default:
+ return null;
}
}
return renderLayout();
}
-
-DataSourceMenu.defaultProps = {
- disableDataSourceSelectable: false,
- showDataSourceAggregatedView: false,
- showDataSourceSelectable: false,
- displayAllCompatibleDataSources: false,
- showDataSourceView: false,
- hideLocalCluster: false,
-};
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx
index 9b0215d157e8..74c6d5d9e5a6 100644
--- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx
+++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx
@@ -10,6 +10,7 @@ import React from 'react';
import { DataSourceSelectable } from './data_source_selectable';
import { AuthType } from '../../types';
import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls } from '../../mocks';
+import { render } from '@testing-library/react';
describe('DataSourceSelectable', () => {
let component: ShallowWrapper, React.Component<{}, {}, any>>;
@@ -30,7 +31,7 @@ describe('DataSourceSelectable', () => {
{
{
{
expect(component).toMatchSnapshot();
expect(toasts.addWarning).toBeCalledTimes(0);
});
+
+ it('should show popover when click on button', async () => {
+ const onSelectedDataSource = jest.fn();
+ const container = render(
+ ds.attributes.auth.type !== AuthType.NoAuth}
+ />
+ );
+ const button = await container.findByTestId('dataSourceSelectableContextMenuHeaderLink');
+ button.click();
+ expect(container).toMatchSnapshot();
+ });
});
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx
index 9c71c1f0aaa1..b2c8cdbe9db0 100644
--- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx
+++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.tsx
@@ -16,14 +16,15 @@ import {
} from '@elastic/eui';
import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public';
import { getDataSourcesWithFields } from '../utils';
-import { DataSourceOption, LocalCluster } from '../data_source_selector/data_source_selector';
+import { LocalCluster } from '../data_source_selector/data_source_selector';
import { SavedObject } from '../../../../../core/public';
import { DataSourceAttributes } from '../../types';
+import { DataSourceOption } from './types';
interface DataSourceSelectableProps {
savedObjectsClient: SavedObjectsClientContract;
notifications: ToastsStart;
- onSelectedDataSource: (dataSource: DataSourceOption) => void;
+ onSelectedDataSources: (dataSources: DataSourceOption[]) => void;
disabled: boolean;
hideLocalCluster: boolean;
fullWidth: boolean;
@@ -119,13 +120,13 @@ export class DataSourceSelectable extends React.Component<
this.setState({
selectedOption: [selectedDataSource],
});
- this.props.onSelectedDataSource({ ...selectedDataSource });
+
+ this.props.onSelectedDataSources([selectedDataSource]);
}
render() {
const button = (
<>
-
@@ -167,6 +168,7 @@ export class DataSourceSelectable extends React.Component<
options={this.state.dataSourceOptions}
onChange={(newOptions) => this.onChange(newOptions)}
singleSelection={true}
+ data-test-subj={'dataSourceSelectable'}
>
{(list, search) => (
<>
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/index.ts b/src/plugins/data_source_management/public/components/data_source_menu/index.ts
index 21951dc8d29e..216cf33c6ee3 100644
--- a/src/plugins/data_source_management/public/components/data_source_menu/index.ts
+++ b/src/plugins/data_source_management/public/components/data_source_menu/index.ts
@@ -4,3 +4,11 @@
*/
export { DataSourceMenu } from './data_source_menu';
+export {
+ DataSourceSelectableConfig,
+ DataSourceAggregatedViewConfig,
+ DataSourceComponentType,
+ DataSourceViewConfig,
+ DataSourceMenuProps,
+ DataSourceMultiSelectableConfig,
+} from './types';
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
new file mode 100644
index 000000000000..4121edc0c863
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_menu/types.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+ MountPoint,
+ NotificationsStart,
+ SavedObjectsClientContract,
+ SavedObject,
+} from '../../../../../core/public';
+import { DataSourceAttributes } from '../../types';
+
+export interface DataSourceOption {
+ id: string;
+ label?: string;
+}
+
+export interface DataSourceBaseConfig {
+ fullWidth: boolean;
+ disabled?: boolean;
+}
+
+export interface DataSourceMenuProps {
+ componentType: DataSourceComponentType;
+ componentConfig: T;
+ setMenuMountPoint?: (menuMount: MountPoint | undefined) => void;
+}
+
+export const DataSourceComponentType = {
+ DataSourceSelectable: 'DataSourceSelectable',
+ DataSourceView: 'DataSourceView',
+ DataSourceAggregatedView: 'DataSourceAggregatedView',
+ DataSourceMultiSelectable: 'DataSourceMultiSelectable',
+} as const;
+
+export type DataSourceComponentType = typeof DataSourceComponentType[keyof typeof DataSourceComponentType];
+
+export interface DataSourceViewConfig extends DataSourceBaseConfig {
+ activeOption: DataSourceOption[];
+ savedObjects?: SavedObjectsClientContract;
+ notifications?: NotificationsStart;
+}
+
+export interface DataSourceAggregatedViewConfig extends DataSourceBaseConfig {
+ savedObjects: SavedObjectsClientContract;
+ notifications: NotificationsStart;
+ activeDataSourceIds?: string[];
+ hideLocalCluster?: boolean;
+ displayAllCompatibleDataSources?: boolean;
+ dataSourceFilter?: (dataSource: SavedObject) => boolean;
+}
+
+export interface DataSourceSelectableConfig extends DataSourceBaseConfig {
+ onSelectedDataSources: (dataSources: DataSourceOption[]) => void;
+ savedObjects: SavedObjectsClientContract;
+ notifications: NotificationsStart;
+ activeOption?: DataSourceOption[];
+ hideLocalCluster?: boolean;
+ dataSourceFilter?: (dataSource: SavedObject) => boolean;
+}
+
+export interface DataSourceMultiSelectableConfig extends DataSourceBaseConfig {
+ onSelectedDataSources: (dataSources: DataSourceOption[]) => void;
+ savedObjects: SavedObjectsClientContract;
+ notifications: NotificationsStart;
+ hideLocalCluster?: 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
new file mode 100644
index 000000000000..665f9cfa27dc
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_filter_group.test.tsx.snap
@@ -0,0 +1,664 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DataSourceFilterGroup should render normally 1`] = `
+
+
+ Data sources
+
+
+ 1
+
+
+ }
+ closePopover={[Function]}
+ display="inlineBlock"
+ hasArrow={true}
+ id="popoverExampleMultiSelect"
+ isOpen={false}
+ ownFocus={true}
+ panelPaddingSize="none"
+>
+
+
+
+
+
+ name1
+
+
+
+
+
+
+`;
+
+exports[`DataSourceFilterGroup should render popup when clicking on button 1`] = `
+Object {
+ "asFragment": [Function],
+ "baseElement":
+
+
+
+
+
+
+ You are in a dialog. To close this dialog, hit escape.
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ "container": ,
+ "debug": [Function],
+ "findAllByAltText": [Function],
+ "findAllByDisplayValue": [Function],
+ "findAllByLabelText": [Function],
+ "findAllByPlaceholderText": [Function],
+ "findAllByRole": [Function],
+ "findAllByTestId": [Function],
+ "findAllByText": [Function],
+ "findAllByTitle": [Function],
+ "findByAltText": [Function],
+ "findByDisplayValue": [Function],
+ "findByLabelText": [Function],
+ "findByPlaceholderText": [Function],
+ "findByRole": [Function],
+ "findByTestId": [Function],
+ "findByText": [Function],
+ "findByTitle": [Function],
+ "getAllByAltText": [Function],
+ "getAllByDisplayValue": [Function],
+ "getAllByLabelText": [Function],
+ "getAllByPlaceholderText": [Function],
+ "getAllByRole": [Function],
+ "getAllByTestId": [Function],
+ "getAllByText": [Function],
+ "getAllByTitle": [Function],
+ "getByAltText": [Function],
+ "getByDisplayValue": [Function],
+ "getByLabelText": [Function],
+ "getByPlaceholderText": [Function],
+ "getByRole": [Function],
+ "getByTestId": [Function],
+ "getByText": [Function],
+ "getByTitle": [Function],
+ "queryAllByAltText": [Function],
+ "queryAllByDisplayValue": [Function],
+ "queryAllByLabelText": [Function],
+ "queryAllByPlaceholderText": [Function],
+ "queryAllByRole": [Function],
+ "queryAllByTestId": [Function],
+ "queryAllByText": [Function],
+ "queryAllByTitle": [Function],
+ "queryByAltText": [Function],
+ "queryByDisplayValue": [Function],
+ "queryByLabelText": [Function],
+ "queryByPlaceholderText": [Function],
+ "queryByRole": [Function],
+ "queryByTestId": [Function],
+ "queryByText": [Function],
+ "queryByTitle": [Function],
+ "rerender": [Function],
+ "unmount": [Function],
+}
+`;
+
+exports[`DataSourceFilterGroup should toggle all when clicking on button and should search 1`] = `
+Object {
+ "asFragment": [Function],
+ "baseElement":
+
+
+
+
+
+
+ You are in a dialog. To close this dialog, hit escape.
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ "container": ,
+ "debug": [Function],
+ "findAllByAltText": [Function],
+ "findAllByDisplayValue": [Function],
+ "findAllByLabelText": [Function],
+ "findAllByPlaceholderText": [Function],
+ "findAllByRole": [Function],
+ "findAllByTestId": [Function],
+ "findAllByText": [Function],
+ "findAllByTitle": [Function],
+ "findByAltText": [Function],
+ "findByDisplayValue": [Function],
+ "findByLabelText": [Function],
+ "findByPlaceholderText": [Function],
+ "findByRole": [Function],
+ "findByTestId": [Function],
+ "findByText": [Function],
+ "findByTitle": [Function],
+ "getAllByAltText": [Function],
+ "getAllByDisplayValue": [Function],
+ "getAllByLabelText": [Function],
+ "getAllByPlaceholderText": [Function],
+ "getAllByRole": [Function],
+ "getAllByTestId": [Function],
+ "getAllByText": [Function],
+ "getAllByTitle": [Function],
+ "getByAltText": [Function],
+ "getByDisplayValue": [Function],
+ "getByLabelText": [Function],
+ "getByPlaceholderText": [Function],
+ "getByRole": [Function],
+ "getByTestId": [Function],
+ "getByText": [Function],
+ "getByTitle": [Function],
+ "queryAllByAltText": [Function],
+ "queryAllByDisplayValue": [Function],
+ "queryAllByLabelText": [Function],
+ "queryAllByPlaceholderText": [Function],
+ "queryAllByRole": [Function],
+ "queryAllByTestId": [Function],
+ "queryAllByText": [Function],
+ "queryAllByTitle": [Function],
+ "queryByAltText": [Function],
+ "queryByDisplayValue": [Function],
+ "queryByLabelText": [Function],
+ "queryByPlaceholderText": [Function],
+ "queryByRole": [Function],
+ "queryByTestId": [Function],
+ "queryByText": [Function],
+ "queryByTitle": [Function],
+ "rerender": [Function],
+ "unmount": [Function],
+}
+`;
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
new file mode 100644
index 000000000000..627a1169c15b
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/__snapshots__/data_source_multi_selectable.test.tsx.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DataSourceMultiSelectable should render normally with local cluster hidden 1`] = `
+
+`;
+
+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
new file mode 100644
index 000000000000..62bf10ec8310
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.test.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { ShallowWrapper, shallow } from 'enzyme';
+import React from 'react';
+import { DataSourceFilterGroup } from './data_source_filter_group';
+import { render, fireEvent, screen } from '@testing-library/react';
+
+describe('DataSourceFilterGroup', () => {
+ let component: ShallowWrapper, React.Component<{}, {}, any>>;
+
+ it('should render normally', () => {
+ const mockCallBack = jest.fn();
+ component = shallow(
+ mockCallBack(items)}
+ />
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it('should render popup when clicking on button', async () => {
+ const mockCallBack = jest.fn();
+ const container = render(
+ mockCallBack(items)}
+ />
+ );
+ const button = await container.findByTestId('dataSourceFilterGroupButton');
+ button.click();
+ expect(container).toMatchSnapshot();
+ expect(mockCallBack).toBeCalledTimes(0);
+
+ fireEvent.click(screen.getByText('name1'));
+ expect(mockCallBack).toBeCalledWith([
+ { checked: undefined, id: '1', label: 'name1', visible: true },
+ ]);
+ });
+
+ it('should toggle all when clicking on button and should search', async () => {
+ const mockCallBack = jest.fn();
+ const container = render(
+ mockCallBack(items)}
+ />
+ );
+ const button = await container.findByTestId('dataSourceFilterGroupButton');
+ button.click();
+
+ fireEvent.click(screen.getByText('Deselect all'));
+ expect(mockCallBack).toBeCalledWith([
+ { checked: undefined, id: '1', label: 'name1', visible: true },
+ ]);
+
+ fireEvent.click(screen.getByText('Select all'));
+ expect(mockCallBack).toBeCalledWith([
+ { checked: 'on', id: '1', label: 'name1', visible: true },
+ ]);
+
+ const input = screen.getByTestId('dataSourceMultiSelectFieldSearch');
+ fireEvent.change(input, { target: { value: 'random input' } });
+ fireEvent.keyDown(input, { key: 'enter', keyCode: 13 });
+
+ expect(mockCallBack).toBeCalledWith([
+ { checked: 'on', id: '1', label: 'name1', visible: false },
+ ]);
+
+ expect(container).toMatchSnapshot();
+ });
+});
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
new file mode 100644
index 000000000000..8d0e22beadaf
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_filter_group.tsx
@@ -0,0 +1,166 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import {
+ EuiNotificationBadge,
+ EuiFilterSelectItem,
+ EuiPopover,
+ EuiPopoverTitle,
+ EuiFieldSearch,
+ FilterChecked,
+ EuiPopoverFooter,
+ EuiButtonGroup,
+ EuiButtonEmpty,
+} from '@elastic/eui';
+import { DataSourceOption } from '../data_source_selector/data_source_selector';
+
+export interface SelectedDataSourceOption extends DataSourceOption {
+ label: string;
+ id: string;
+ visible: boolean;
+ checked?: FilterChecked;
+}
+
+export interface DataSourceFilterGroupProps {
+ selectedOptions: SelectedDataSourceOption[];
+ setSelectedOptions: (options: SelectedDataSourceOption[]) => void;
+}
+
+type SelectionToggleOptionIds = 'select_all' | 'deselect_all';
+
+const selectionToggleButtons = [
+ {
+ id: 'select_all',
+ label: 'Select all',
+ },
+ {
+ id: 'deselect_all',
+ label: 'Deselect all',
+ },
+];
+
+export const DataSourceFilterGroup: React.FC = ({
+ selectedOptions,
+ setSelectedOptions,
+}) => {
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [selectionToggleSelectedId, setSelectionToggleSelectedId] = useState<
+ SelectionToggleOptionIds
+ >('select_all');
+
+ const onButtonClick = () => {
+ setIsPopoverOpen(!isPopoverOpen);
+ };
+
+ const closePopover = () => {
+ setIsPopoverOpen(false);
+ };
+
+ function toggleItem(index: number) {
+ if (!selectedOptions[index]) {
+ return;
+ }
+
+ const newItems = [...selectedOptions];
+
+ if (newItems[index].checked === 'on') {
+ newItems[index] = {
+ ...newItems[index],
+ checked: undefined,
+ };
+ } else {
+ newItems[index] = {
+ ...newItems[index],
+ checked: 'on',
+ };
+ }
+
+ setSelectedOptions(newItems);
+ }
+
+ function onSelectionToggleChange(optionId: string) {
+ setSelectionToggleSelectedId(optionId as SelectionToggleOptionIds);
+ toggleAll(optionId === 'select_all' ? 'on' : undefined);
+ }
+
+ function toggleAll(state: 'on' | undefined) {
+ const optionsAfterToggle = selectedOptions.map((option) => ({
+ ...option,
+ checked: state,
+ }));
+
+ setSelectedOptions(optionsAfterToggle);
+ }
+
+ function search(term: string) {
+ const optionsAfterSearch = selectedOptions.map((option) => {
+ option.visible = option.label.toLowerCase().includes(term.toLowerCase());
+ return option;
+ });
+ setSelectedOptions(optionsAfterSearch);
+ }
+
+ const numActiveSelections = selectedOptions.filter((option) => option.checked === 'on').length;
+ const button = (
+ <>
+
+ {'Data sources'}
+
+ {numActiveSelections}
+ >
+ );
+
+ return (
+
+
+
+
+
+ {selectedOptions.map((item, index) => {
+ const itemStyle: any = {};
+ itemStyle.display = !item.visible ? 'none' : itemStyle.display;
+
+ return (
+ toggleItem(index)}
+ 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
new file mode 100644
index 000000000000..afe65f554626
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.test.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { SavedObjectsClientContract } from 'opensearch-dashboards/public';
+import { notificationServiceMock } from '../../../../../core/public/mocks';
+import { getDataSourcesWithFieldsResponse, mockResponseForSavedObjectsCalls } from '../../mocks';
+import { ShallowWrapper, shallow } from 'enzyme';
+import { DataSourceMultiSelectable } from './data_source_multi_selectable';
+import React from 'react';
+import { render, fireEvent, screen } from '@testing-library/react';
+
+describe('DataSourceMultiSelectable', () => {
+ let component: ShallowWrapper, React.Component<{}, {}, any>>;
+
+ let client: SavedObjectsClientContract;
+ const { toasts } = notificationServiceMock.createStartContract();
+ const nextTick = () => new Promise((res) => process.nextTick(res));
+
+ beforeEach(() => {
+ client = {
+ find: jest.fn().mockResolvedValue([]),
+ } as any;
+ mockResponseForSavedObjectsCalls(client, 'find', getDataSourcesWithFieldsResponse);
+ });
+
+ it('should render normally with local cluster not hidden', () => {
+ component = shallow(
+
+ );
+ expect(component).toMatchSnapshot();
+ expect(client.find).toBeCalledWith({
+ fields: ['id', 'title', 'auth.type'],
+ perPage: 10000,
+ type: 'data-source',
+ });
+ expect(toasts.addWarning).toBeCalledTimes(0);
+ });
+
+ it('should render normally with local cluster hidden', () => {
+ component = shallow(
+
+ );
+ expect(component).toMatchSnapshot();
+ expect(client.find).toBeCalledWith({
+ fields: ['id', 'title', 'auth.type'],
+ perPage: 10000,
+ type: 'data-source',
+ });
+ expect(toasts.addWarning).toBeCalledTimes(0);
+ });
+
+ it('should show toasts when exception happens', async () => {
+ const errorClient = {
+ find: () => {
+ return new Promise((resolve, reject) => {
+ reject('error');
+ });
+ },
+ } as any;
+
+ component = shallow(
+
+ );
+ await nextTick();
+ expect(toasts.addWarning).toBeCalledTimes(1);
+ });
+
+ it('should callback when onChange happens', async () => {
+ const callbackMock = jest.fn();
+ const container = render(
+
+ );
+ const button = await container.findByTestId('dataSourceFilterGroupButton');
+ button.click();
+ fireEvent.click(screen.getByText('Deselect all'));
+
+ expect(callbackMock).toBeCalledWith([]);
+ });
+});
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
new file mode 100644
index 000000000000..8405a37a43c2
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/data_source_multi_selectable.tsx
@@ -0,0 +1,100 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { SavedObjectsClientContract, ToastsStart } from 'opensearch-dashboards/public';
+import { i18n } from '@osd/i18n';
+import { DataSourceFilterGroup, SelectedDataSourceOption } from './data_source_filter_group';
+import { getDataSourcesWithFields } from '../utils';
+
+export interface DataSourceMultiSeletableProps {
+ savedObjectsClient: SavedObjectsClientContract;
+ notifications: ToastsStart;
+ onSelectedDataSources: (dataSources: SelectedDataSourceOption[]) => void;
+ hideLocalCluster: boolean;
+ fullWidth: boolean;
+}
+
+interface DataSourceMultiSeletableState {
+ dataSourceOptions: SelectedDataSourceOption[];
+ selectedOptions: SelectedDataSourceOption[];
+}
+
+export class DataSourceMultiSelectable extends React.Component<
+ DataSourceMultiSeletableProps,
+ DataSourceMultiSeletableState
+> {
+ private _isMounted: boolean = false;
+
+ constructor(props: DataSourceMultiSeletableProps) {
+ super(props);
+
+ this.state = {
+ dataSourceOptions: [],
+ selectedOptions: [],
+ };
+ }
+
+ componentWillUnmount() {
+ this._isMounted = false;
+ }
+
+ 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,
+ })
+ );
+
+ if (!this.props.hideLocalCluster) {
+ selectedOptions.unshift({
+ id: '',
+ label: 'Local cluster',
+ checked: 'on',
+ visible: true,
+ });
+ }
+
+ if (!this._isMounted) return;
+ this.setState({
+ ...this.state,
+ selectedOptions,
+ });
+ }
+ })
+ .catch(() => {
+ this.props.notifications.addWarning(
+ i18n.translate('dataSource.fetchDataSourceError', {
+ defaultMessage: 'Unable to fetch existing data sources',
+ })
+ );
+ });
+ }
+
+ onChange(selectedOptions: SelectedDataSourceOption[]) {
+ if (!this._isMounted) return;
+ this.setState({
+ selectedOptions,
+ });
+ this.props.onSelectedDataSources(selectedOptions.filter((option) => option.checked === 'on'));
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/src/plugins/data_source_management/public/components/data_source_multi_selectable/index.ts b/src/plugins/data_source_management/public/components/data_source_multi_selectable/index.ts
new file mode 100644
index 000000000000..440cbfdc8202
--- /dev/null
+++ b/src/plugins/data_source_management/public/components/data_source_multi_selectable/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { DataSourceMultiSelectable } from './data_source_multi_selectable';
diff --git a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx
index dd33c28b6890..b0cb56bdac62 100644
--- a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx
+++ b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx
@@ -128,7 +128,7 @@ describe('DataSourceTable', () => {
it('should delete confirm modal confirm button work normally', async () => {
spyOn(utils, 'deleteMultipleDataSources').and.returnValue(Promise.resolve({}));
-
+ spyOn(utils, 'setFirstDataSourceAsDefault').and.returnValue({});
act(() => {
// @ts-ignore
component.find(tableIdentifier).props().selection.onSelectionChange(getMappedDataSources);
@@ -143,10 +143,12 @@ describe('DataSourceTable', () => {
});
component.update();
expect(component.find(confirmModalIdentifier).exists()).toBe(false);
+ expect(utils.setFirstDataSourceAsDefault).toHaveBeenCalled();
});
it('should delete datasources & fail', async () => {
spyOn(utils, 'deleteMultipleDataSources').and.returnValue(Promise.reject({}));
+ spyOn(utils, 'setFirstDataSourceAsDefault').and.returnValue({});
act(() => {
// @ts-ignore
component.find(tableIdentifier).props().selection.onSelectionChange(getMappedDataSources);
@@ -162,6 +164,7 @@ describe('DataSourceTable', () => {
});
component.update();
expect(utils.deleteMultipleDataSources).toHaveBeenCalled();
+ expect(utils.setFirstDataSourceAsDefault).not.toHaveBeenCalled();
// @ts-ignore
expect(component.find(confirmModalIdentifier).exists()).toBe(false);
});
diff --git a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx
index b27f957fe142..9b9fd9488290 100644
--- a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx
+++ b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx
@@ -29,7 +29,7 @@ import {
} from '../../../../opensearch_dashboards_react/public';
import { DataSourceManagementContext, DataSourceTableItem, ToastMessageItem } from '../../types';
import { CreateButton } from '../create_button';
-import { deleteMultipleDataSources, getDataSources } from '../utils';
+import { deleteMultipleDataSources, getDataSources, setFirstDataSourceAsDefault } from '../utils';
import { LoadingMask } from '../loading_mask';
/* Table config */
@@ -232,6 +232,9 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
// Fetch data sources
fetchDataSources();
setConfirmDeleteVisible(false);
+ // Check if default data source is deleted or not.
+ // if yes, then set the first existing datasource as default datasource.
+ setDefaultDataSource();
})
.catch(() => {
handleDisplayToastMessage({
@@ -245,6 +248,23 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => {
});
};
+ const setDefaultDataSource = async () => {
+ try {
+ for (const dataSource of selectedDataSources) {
+ if (uiSettings.get('defaultDataSource') === dataSource.id) {
+ await setFirstDataSourceAsDefault(savedObjects.client, uiSettings, true);
+ }
+ }
+ } catch (e) {
+ handleDisplayToastMessage({
+ id: 'dataSourcesManagement.editDataSource.setDefaultDataSourceFailMsg',
+ defaultMessage: 'Unable to find a default datasource. Please set a new default datasource.',
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
/* Table selection handlers */
const onSelectionChange = (selected: DataSourceTableItem[]) => {
setSelectedDataSources(selected);
diff --git a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx
index 068842ef26a6..23bb2e34feab 100644
--- a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx
+++ b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx
@@ -6,7 +6,7 @@
import React from 'react';
import { i18n } from '@osd/i18n';
import { EuiPopover, EuiButtonEmpty, EuiButtonIcon, EuiContextMenu } from '@elastic/eui';
-import { DataSourceOption } from '../data_source_selector/data_source_selector';
+import { DataSourceOption } from '../data_source_menu/types';
interface DataSourceViewProps {
fullWidth: boolean;
diff --git a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx
index 833886fff7f3..d8d175a920d9 100644
--- a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx
+++ b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx
@@ -138,7 +138,8 @@ describe('Datasource Management: Edit Datasource Wizard', () => {
});
test('should delete datasource successfully', async () => {
spyOn(utils, 'deleteDataSourceById').and.returnValue({});
-
+ spyOn(utils, 'setFirstDataSourceAsDefault').and.returnValue({});
+ spyOn(uiSettings, 'get').and.returnValue('test1');
await act(async () => {
// @ts-ignore
await component.find(formIdentifier).first().prop('onDeleteDataSource')(
@@ -147,9 +148,12 @@ describe('Datasource Management: Edit Datasource Wizard', () => {
});
expect(utils.deleteDataSourceById).toHaveBeenCalled();
expect(history.push).toBeCalledWith('');
+ expect(utils.setFirstDataSourceAsDefault).toHaveBeenCalled();
});
test('should fail to delete datasource', async () => {
spyOn(utils, 'deleteDataSourceById').and.throwError('error');
+ spyOn(utils, 'setFirstDataSourceAsDefault').and.returnValue({});
+ spyOn(uiSettings, 'get').and.returnValue('test1');
await act(async () => {
// @ts-ignore
await component.find(formIdentifier).first().prop('onDeleteDataSource')(
@@ -158,6 +162,7 @@ describe('Datasource Management: Edit Datasource Wizard', () => {
});
component.update();
expect(utils.deleteDataSourceById).toHaveBeenCalled();
+ expect(utils.setFirstDataSourceAsDefault).not.toHaveBeenCalled();
});
test('should test connection', () => {
spyOn(utils, 'testConnection');
diff --git a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx
index 46e253b2b85b..ab1e8531c801 100644
--- a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx
+++ b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx
@@ -17,6 +17,7 @@ import {
getDataSources,
testConnection,
updateDataSourceById,
+ setFirstDataSourceAsDefault,
} from '../utils';
import { getEditBreadcrumbs } from '../breadcrumbs';
import { EditDataSourceForm } from './components/edit_form/edit_data_source_form';
@@ -109,6 +110,10 @@ export const EditDataSource: React.FunctionComponent {
+ try {
+ if (uiSettings.get('defaultDataSource') === dataSourceID) {
+ await setFirstDataSourceAsDefault(savedObjects.client, uiSettings, true);
+ }
+ } catch (e) {
+ setIsLoading(false);
+ handleDisplayToastMessage({
+ id: 'dataSourcesManagement.editDataSource.setDefaultDataSourceFailMsg',
+ defaultMessage: 'Unable to find a default datasource. Please set a new default datasource.',
+ });
+ }
+ };
+
/* Handle Test connection */
const handleTestConnection = async (attributes: DataSourceAttributes) => {
await testConnection(http, attributes, dataSourceID);
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 a4069f01907b..f2b1f709cb18 100644
--- a/src/plugins/data_source_management/public/components/utils.test.ts
+++ b/src/plugins/data_source_management/public/components/utils.test.ts
@@ -14,6 +14,8 @@ import {
isValidUrl,
testConnection,
updateDataSourceById,
+ handleSetDefaultDatasource,
+ setFirstDataSourceAsDefault,
} from './utils';
import { coreMock } from '../../../../core/public/mocks';
import {
@@ -24,6 +26,8 @@ import {
mockDataSourceAttributesWithAuth,
mockErrorResponseForSavedObjectsCalls,
mockResponseForSavedObjectsCalls,
+ mockUiSettingsCalls,
+ getSingleDataSourceResponse,
} from '../mocks';
import {
AuthType,
@@ -36,6 +40,7 @@ import { AuthenticationMethod, AuthenticationMethodRegistry } from '../auth_regi
import { deepEqual } from 'assert';
const { savedObjects } = coreMock.createStart();
+const { uiSettings } = coreMock.createStart();
describe('DataSourceManagement: Utils.ts', () => {
describe('Get data source', () => {
@@ -274,7 +279,50 @@ describe('DataSourceManagement: Utils.ts', () => {
expect(getDefaultAuthMethod(authenticationMethodRegistry)?.name).toBe(AuthType.NoAuth);
});
});
-
+ describe('handle set default datasource', () => {
+ beforeEach(() => {
+ jest.clearAllMocks(); // Reset all mock calls before each test
+ });
+ test('should set default datasource when it does not have default datasource ', async () => {
+ mockUiSettingsCalls(uiSettings, 'get', null);
+ mockResponseForSavedObjectsCalls(savedObjects.client, 'find', getDataSourcesResponse);
+ await handleSetDefaultDatasource(savedObjects.client, uiSettings);
+ expect(uiSettings.set).toHaveBeenCalled();
+ });
+ test('should not set default datasource when it has default datasouce', async () => {
+ mockUiSettingsCalls(uiSettings, 'get', 'test');
+ mockResponseForSavedObjectsCalls(savedObjects.client, 'find', getDataSourcesResponse);
+ await handleSetDefaultDatasource(savedObjects.client, uiSettings);
+ expect(uiSettings.set).not.toHaveBeenCalled();
+ });
+ });
+ describe('set first aataSource as default', () => {
+ beforeEach(() => {
+ jest.clearAllMocks(); // Reset all mock calls before each test
+ });
+ test('should set defaultDataSource if more than one data source exists', async () => {
+ mockResponseForSavedObjectsCalls(savedObjects.client, 'find', getDataSourcesResponse);
+ await setFirstDataSourceAsDefault(savedObjects.client, uiSettings, true);
+ expect(uiSettings.set).toHaveBeenCalled();
+ });
+ test('should set defaultDataSource if only one data source exists', async () => {
+ mockResponseForSavedObjectsCalls(savedObjects.client, 'find', getSingleDataSourceResponse);
+ await setFirstDataSourceAsDefault(savedObjects.client, uiSettings, true);
+ expect(uiSettings.set).toHaveBeenCalled();
+ });
+ test('should not set defaultDataSource if no data source exists', async () => {
+ mockResponseForSavedObjectsCalls(savedObjects.client, 'find', { savedObjects: [] });
+ await setFirstDataSourceAsDefault(savedObjects.client, uiSettings, true);
+ expect(uiSettings.remove).toHaveBeenCalled();
+ expect(uiSettings.set).not.toHaveBeenCalled();
+ });
+ test('should not set defaultDataSource if no data source exists and no default datasouce', async () => {
+ mockResponseForSavedObjectsCalls(savedObjects.client, 'find', { savedObjects: [] });
+ await setFirstDataSourceAsDefault(savedObjects.client, uiSettings, false);
+ expect(uiSettings.remove).not.toHaveBeenCalled();
+ expect(uiSettings.set).not.toHaveBeenCalled();
+ });
+ });
describe('Check extractRegisteredAuthTypeCredentials method', () => {
test('Should extract credential field successfully', () => {
const authTypeToBeTested = 'Some Auth Type';
diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts
index d7d6e94265e2..b911203cd288 100644
--- a/src/plugins/data_source_management/public/components/utils.ts
+++ b/src/plugins/data_source_management/public/components/utils.ts
@@ -3,7 +3,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { HttpStart, SavedObjectsClientContract, SavedObject } from 'src/core/public';
+import {
+ HttpStart,
+ SavedObjectsClientContract,
+ SavedObject,
+ IUiSettingsClient,
+} from 'src/core/public';
import {
DataSourceAttributes,
DataSourceTableItem,
@@ -49,6 +54,30 @@ export async function getDataSourcesWithFields(
return response?.savedObjects;
}
+export async function handleSetDefaultDatasource(
+ savedObjectsClient: SavedObjectsClientContract,
+ uiSettings: IUiSettingsClient
+) {
+ if (uiSettings.get('defaultDataSource', null) === null) {
+ return await setFirstDataSourceAsDefault(savedObjectsClient, uiSettings, false);
+ }
+}
+
+export async function setFirstDataSourceAsDefault(
+ savedObjectsClient: SavedObjectsClientContract,
+ uiSettings: IUiSettingsClient,
+ exists: boolean
+) {
+ if (exists) {
+ uiSettings.remove('defaultDataSource');
+ }
+ const listOfDataSources: DataSourceTableItem[] = await getDataSources(savedObjectsClient);
+ if (Array.isArray(listOfDataSources) && listOfDataSources.length >= 1) {
+ const datasourceId = listOfDataSources[0].id;
+ return await uiSettings.set('defaultDataSource', datasourceId);
+ }
+}
+
export async function getDataSourceById(
id: string,
savedObjectsClient: SavedObjectsClientContract
diff --git a/src/plugins/data_source_management/public/index.ts b/src/plugins/data_source_management/public/index.ts
index 5e2e9b647396..471792ddd726 100644
--- a/src/plugins/data_source_management/public/index.ts
+++ b/src/plugins/data_source_management/public/index.ts
@@ -10,7 +10,16 @@ import { DataSourceManagementPlugin } from './plugin';
export function plugin() {
return new DataSourceManagementPlugin();
}
+
export { DataSourceManagementPluginStart } from './types';
export { DataSourceSelector } from './components/data_source_selector';
export { DataSourceMenu } from './components/data_source_menu';
export { DataSourceManagementPlugin, DataSourceManagementPluginSetup } from './plugin';
+export {
+ DataSourceSelectableConfig,
+ DataSourceComponentType,
+ DataSourceAggregatedViewConfig,
+ DataSourceViewConfig,
+ DataSourceMenuProps,
+ DataSourceMultiSelectableConfig,
+} from './components/data_source_menu';
diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts
index 90368762698f..d04fc2a362d3 100644
--- a/src/plugins/data_source_management/public/mocks.ts
+++ b/src/plugins/data_source_management/public/mocks.ts
@@ -6,6 +6,7 @@
import React from 'react';
import { throwError } from 'rxjs';
import { SavedObjectsClientContract } from 'opensearch-dashboards/public';
+import { IUiSettingsClient } from 'src/core/public';
import { AuthType, DataSourceAttributes } from './types';
import { coreMock } from '../../../core/public/mocks';
import {
@@ -62,6 +63,21 @@ export const mockManagementPlugin = {
docLinks,
};
+export const getSingleDataSourceResponse = {
+ savedObjects: [
+ {
+ id: 'test',
+ type: 'data-source',
+ description: 'test datasource',
+ title: 'test',
+ get(field: string) {
+ const me: any = this || {};
+ return me[field];
+ },
+ },
+ ],
+};
+
/* Mock data responses - JSON*/
export const getDataSourcesResponse = {
savedObjects: [
@@ -263,6 +279,14 @@ export const mockErrorResponseForSavedObjectsCalls = (
);
};
+export const mockUiSettingsCalls = (
+ uiSettings: IUiSettingsClient,
+ uiSettingsMethodName: 'get' | 'set',
+ response: any
+) => {
+ (uiSettings[uiSettingsMethodName] as jest.Mock).mockReturnValue(response);
+};
+
export interface TestPluginReturn {
setup: DataSourceManagementPluginSetup;
doStart: () => DataSourceManagementPluginStart;
diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts
index cf53bfc2e76e..9e6da39dc08b 100644
--- a/src/plugins/data_source_management/public/plugin.ts
+++ b/src/plugins/data_source_management/public/plugin.ts
@@ -20,7 +20,7 @@ import {
import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod } from './types';
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/data_source_menu';
+import { DataSourceMenuProps } from './components/data_source_menu';
export interface DataSourceManagementSetupDependencies {
management: ManagementSetup;
@@ -32,7 +32,7 @@ export interface DataSourceManagementPluginSetup {
registerAuthenticationMethod: (authMethodValues: AuthenticationMethod) => void;
ui: {
DataSourceSelector: React.ComponentType;
- DataSourceMenu: React.ComponentType;
+ getDataSourceMenu: () => React.ComponentType>;
};
}
@@ -103,7 +103,7 @@ export class DataSourceManagementPlugin
registerAuthenticationMethod,
ui: {
DataSourceSelector: createDataSourceSelector(),
- DataSourceMenu: createDataSourceMenu(),
+ getDataSourceMenu: () => createDataSourceMenu(),
},
};
}
diff --git a/src/plugins/navigation/opensearch_dashboards.json b/src/plugins/navigation/opensearch_dashboards.json
index e2acd55e376a..304739dca986 100644
--- a/src/plugins/navigation/opensearch_dashboards.json
+++ b/src/plugins/navigation/opensearch_dashboards.json
@@ -4,5 +4,5 @@
"server": false,
"ui": true,
"requiredPlugins": ["data"],
- "requiredBundles": ["opensearchDashboardsReact"]
+ "requiredBundles": ["opensearchDashboardsReact", "dataSourceManagement"]
}
diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx
index 5fa97eb0a7d2..7b4269da9636 100644
--- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx
+++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx
@@ -117,6 +117,46 @@ describe('TopNavMenu', () => {
expect(component.find('.myCoolClass').length).toBeTruthy();
});
+ it('mounts the data source menu if showDataSourceMenu is true', async () => {
+ const component = shallowWithIntl(
+
+ );
+
+ expect(component.find('DataSourceMenu').length).toBe(1);
+ });
+
+ it('mounts the data source menu as well as top nav menu', async () => {
+ const component = shallowWithIntl(
+
+ );
+
+ expect(component.find('DataSourceMenu').length).toBe(1);
+ expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(menuItems.length);
+ });
+
describe('when setMenuMountPoint is provided', () => {
let portalTarget: HTMLElement;
let mountPoint: MountPoint;
diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx
index 15b6c6bff057..6814d254510f 100644
--- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx
+++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx
@@ -41,15 +41,18 @@ import {
} from '../../../data/public';
import { TopNavMenuData } from './top_nav_menu_data';
import { TopNavMenuItem } from './top_nav_menu_item';
+import { DataSourceMenu, DataSourceMenuProps } from '../../../data_source_management/public';
export type TopNavMenuProps = StatefulSearchBarProps &
Omit & {
config?: TopNavMenuData[];
+ dataSourceMenuConfig?: DataSourceMenuProps;
showSearchBar?: boolean;
showQueryBar?: boolean;
showQueryInput?: boolean;
showDatePicker?: boolean;
showFilterBar?: boolean;
+ showDataSourceMenu?: boolean;
data?: DataPublicPluginStart;
className?: string;
/**
@@ -83,9 +86,19 @@ export type TopNavMenuProps = StatefulSearchBarProps &
**/
export function TopNavMenu(props: TopNavMenuProps): ReactElement | null {
- const { config, showSearchBar, ...searchBarProps } = props;
+ const {
+ config,
+ showSearchBar,
+ showDataSourceMenu,
+ dataSourceMenuConfig,
+ ...searchBarProps
+ } = props;
- if ((!config || config.length === 0) && (!showSearchBar || !props.data)) {
+ if (
+ (!config || config.length === 0) &&
+ (!showSearchBar || !props.data) &&
+ (!showDataSourceMenu || !dataSourceMenuConfig)
+ ) {
return null;
}
@@ -97,16 +110,18 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null {
}
function renderMenu(className: string): ReactElement | null {
- if (!config || config.length === 0) return null;
+ if ((!config || config.length === 0) && (!showDataSourceMenu || !dataSourceMenuConfig))
+ return null;
return (
{renderItems()}
+ {showDataSourceMenu && }
);
}
function renderSearchBar(): ReactElement | null {
- // Validate presense of all required fields
+ // Validate presence of all required fields
if (!showSearchBar || !props.data) return null;
const { SearchBar } = props.data.ui;
return ;
@@ -143,5 +158,6 @@ TopNavMenu.defaultProps = {
showQueryInput: true,
showDatePicker: true,
showFilterBar: true,
+ showDataSourceMenu: false,
screenTitle: '',
};
diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts
index d2da08acb52d..91db9f37fc40 100644
--- a/src/plugins/workspace/common/constants.ts
+++ b/src/plugins/workspace/common/constants.ts
@@ -6,7 +6,13 @@
export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error';
export const WORKSPACE_CREATE_APP_ID = 'workspace_create';
export const WORKSPACE_LIST_APP_ID = 'workspace_list';
+export const WORKSPACE_UPDATE_APP_ID = 'workspace_update';
export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview';
+/**
+ * Since every workspace always have overview and update page, these features will be selected by default
+ * and can't be changed in the workspace form feature selector
+ */
+export const DEFAULT_SELECTED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE_OVERVIEW_APP_ID];
export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace';
export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID =
'workspace_conflict_control';
diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json
index 4443b7e99834..efb5cef5fdbe 100644
--- a/src/plugins/workspace/opensearch_dashboards.json
+++ b/src/plugins/workspace/opensearch_dashboards.json
@@ -4,7 +4,8 @@
"server": true,
"ui": true,
"requiredPlugins": [
- "savedObjects"
+ "savedObjects",
+ "opensearchDashboardsReact"
],
"optionalPlugins": [],
"requiredBundles": ["opensearchDashboardsReact"]
diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx
index f70c627e02b0..98ddb6610864 100644
--- a/src/plugins/workspace/public/application.tsx
+++ b/src/plugins/workspace/public/application.tsx
@@ -8,8 +8,23 @@ import ReactDOM from 'react-dom';
import { AppMountParameters, ScopedHistory } from '../../../core/public';
import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public';
import { WorkspaceFatalError } from './components/workspace_fatal_error';
+import { WorkspaceCreatorApp } from './components/workspace_creator_app';
+import { WorkspaceListApp } from './components/workspace_list_app';
import { Services } from './types';
+export const renderCreatorApp = ({ element }: AppMountParameters, services: Services) => {
+ ReactDOM.render(
+
+
+ ,
+ element
+ );
+
+ return () => {
+ ReactDOM.unmountComponentAtNode(element);
+ };
+};
+
export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => {
const { element } = params;
const history = params.history as ScopedHistory<{ error?: string }>;
@@ -24,3 +39,15 @@ export const renderFatalErrorApp = (params: AppMountParameters, services: Servic
ReactDOM.unmountComponentAtNode(element);
};
};
+export const renderListApp = ({ element }: AppMountParameters, services: Services) => {
+ ReactDOM.render(
+
+
+ ,
+ element
+ );
+
+ return () => {
+ ReactDOM.unmountComponentAtNode(element);
+ };
+};
diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/__snapshots__/delete_workspace_modal.test.tsx.snap b/src/plugins/workspace/public/components/delete_workspace_modal/__snapshots__/delete_workspace_modal.test.tsx.snap
new file mode 100644
index 000000000000..efa63c2f1d08
--- /dev/null
+++ b/src/plugins/workspace/public/components/delete_workspace_modal/__snapshots__/delete_workspace_modal.test.tsx.snap
@@ -0,0 +1,134 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DeleteWorkspaceModal should render normally 1`] = `
+
+
+
+
+`;
diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx
new file mode 100644
index 000000000000..0304aa238ada
--- /dev/null
+++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx
@@ -0,0 +1,228 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { DeleteWorkspaceModal, DeleteWorkspaceModalProps } from './delete_workspace_modal';
+import { coreMock } from '../../../../../core/public/mocks';
+import { render, fireEvent, waitFor, screen } from '@testing-library/react';
+import { workspaceClientMock } from '../../../public/workspace_client.mock';
+import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public';
+
+const defaultProps: DeleteWorkspaceModalProps = {
+ onClose: jest.fn(),
+ selectedWorkspace: null,
+ onDeleteSuccess: jest.fn(),
+};
+
+const coreStartMock = coreMock.createStart();
+
+function getWrapWorkspaceDeleteModalInContext(
+ props: DeleteWorkspaceModalProps,
+ services = { ...coreStartMock }
+) {
+ return (
+
+
+
+ );
+}
+
+describe('DeleteWorkspaceModal', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render normally', async () => {
+ const { getByText, baseElement, getByTestId } = render(
+ getWrapWorkspaceDeleteModalInContext(defaultProps)
+ );
+ await screen.findByTestId('delete-workspace-modal-header');
+ expect(getByText('Delete workspace')).toBeInTheDocument();
+ expect(getByTestId('delete-workspace-modal-header')).toBeInTheDocument();
+ expect(getByTestId('delete-workspace-modal-body')).toBeInTheDocument();
+ expect(baseElement).toMatchSnapshot();
+ });
+
+ it('should emit onClose when clicking cancel button', () => {
+ const onClose = jest.fn();
+ const newProps = {
+ ...defaultProps,
+ onClose,
+ };
+ const { getByTestId } = render(getWrapWorkspaceDeleteModalInContext(newProps));
+ expect(onClose).not.toHaveBeenCalled();
+ const cancelButton = getByTestId('delete-workspace-modal-cancel-button');
+ fireEvent.click(cancelButton);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should be able to delete workspace and emit onDeleteSuccess', async () => {
+ const onCloseFn = jest.fn();
+ const onDeleteSuccessFn = jest.fn();
+ const newProps = {
+ ...defaultProps,
+ selectedWorkspace: {
+ id: 'test',
+ name: 'test',
+ },
+ onClose: onCloseFn,
+ onDeleteSuccess: onDeleteSuccessFn,
+ };
+ const deleteFn = jest.fn().mockReturnValue({
+ success: true,
+ });
+ const newServices = {
+ ...coreStartMock,
+ workspaceClient: {
+ ...workspaceClientMock,
+ delete: deleteFn,
+ },
+ };
+ const { getByTestId, findByTestId } = render(
+ getWrapWorkspaceDeleteModalInContext(newProps, newServices)
+ );
+ await findByTestId('delete-workspace-modal-input');
+ const input = getByTestId('delete-workspace-modal-input');
+ fireEvent.change(input, {
+ target: { value: 'delete' },
+ });
+ const confirmButton = getByTestId('delete-workspace-modal-confirm');
+ expect(deleteFn).not.toHaveBeenCalled();
+ fireEvent.click(confirmButton);
+ expect(deleteFn).toHaveBeenCalledWith('test');
+ await waitFor(() => {
+ expect(coreStartMock.notifications.toasts.addSuccess).toHaveBeenCalled();
+ expect(onCloseFn).toHaveBeenCalled();
+ expect(onDeleteSuccessFn).toHaveBeenCalled();
+ });
+ });
+
+ it('should not call deleteWorkspace if passed selectedWorkspace is null', async () => {
+ const newProps = {
+ ...defaultProps,
+ selectedWorkspace: null,
+ };
+ const deleteFn = jest.fn().mockReturnValue({
+ success: true,
+ });
+ const newServices = {
+ ...coreStartMock,
+ workspaceClient: {
+ ...workspaceClientMock,
+ delete: deleteFn,
+ },
+ };
+ const { getByTestId, findByTestId } = render(
+ getWrapWorkspaceDeleteModalInContext(newProps, newServices)
+ );
+ await findByTestId('delete-workspace-modal-input');
+ const input = getByTestId('delete-workspace-modal-input');
+ fireEvent.change(input, {
+ target: { value: 'delete' },
+ });
+ const confirmButton = getByTestId('delete-workspace-modal-confirm');
+ fireEvent.click(confirmButton);
+ expect(deleteFn).not.toHaveBeenCalled();
+ });
+
+ it('should add danger is returned data is unsuccess', async () => {
+ const newProps = {
+ ...defaultProps,
+ selectedWorkspace: {
+ id: 'test',
+ name: 'test',
+ },
+ };
+ const deleteFn = jest.fn().mockReturnValue({
+ success: false,
+ });
+ const newServices = {
+ ...coreStartMock,
+ workspaceClient: {
+ ...workspaceClientMock,
+ delete: deleteFn,
+ },
+ };
+ const { getByTestId, findByTestId } = render(
+ getWrapWorkspaceDeleteModalInContext(newProps, newServices)
+ );
+ await findByTestId('delete-workspace-modal-input');
+ const input = getByTestId('delete-workspace-modal-input');
+ fireEvent.change(input, {
+ target: { value: 'delete' },
+ });
+ const confirmButton = getByTestId('delete-workspace-modal-confirm');
+ fireEvent.click(confirmButton);
+ expect(deleteFn).toHaveBeenCalledWith('test');
+ await waitFor(() => {
+ expect(coreStartMock.notifications.toasts.addSuccess).not.toHaveBeenCalled();
+ expect(coreStartMock.notifications.toasts.addDanger).toHaveBeenCalled();
+ });
+ });
+
+ it('confirm button should be disabled if not input delete', async () => {
+ const newProps = {
+ ...defaultProps,
+ selectedWorkspace: {
+ id: 'test',
+ name: 'test',
+ },
+ };
+ const deleteFn = jest.fn().mockReturnValue({
+ success: false,
+ });
+ const newServices = {
+ ...coreStartMock,
+ workspaceClient: {
+ ...workspaceClientMock,
+ delete: deleteFn,
+ },
+ };
+ const { getByTestId, findByTestId } = render(
+ getWrapWorkspaceDeleteModalInContext(newProps, newServices)
+ );
+ await findByTestId('delete-workspace-modal-input');
+ const input = getByTestId('delete-workspace-modal-input');
+ fireEvent.change(input, {
+ target: { value: 'delet' },
+ });
+ const confirmButton = getByTestId('delete-workspace-modal-confirm');
+ expect(confirmButton.hasAttribute('disabled'));
+ });
+
+ it('should catch error and add danger', async () => {
+ const onCloseFn = jest.fn();
+ const newProps = {
+ ...defaultProps,
+ selectedWorkspace: {
+ id: 'test',
+ name: 'test',
+ },
+ onclose: onCloseFn,
+ };
+ const deleteFn = jest.fn().mockImplementation(() => {
+ throw new Error('error');
+ });
+ const newServices = {
+ ...coreStartMock,
+ workspaceClient: {
+ ...workspaceClientMock,
+ delete: deleteFn,
+ },
+ };
+ const { getByTestId, findByTestId } = render(
+ getWrapWorkspaceDeleteModalInContext(newProps, newServices)
+ );
+ await findByTestId('delete-workspace-modal-input');
+ const input = getByTestId('delete-workspace-modal-input');
+ fireEvent.change(input, {
+ target: { value: 'delete' },
+ });
+ const confirmButton = getByTestId('delete-workspace-modal-confirm');
+ fireEvent.click(confirmButton);
+ expect(deleteFn).toHaveBeenCalledWith('test');
+ expect(coreStartMock.notifications.toasts.addDanger).toHaveBeenCalled();
+ });
+});
diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx
new file mode 100644
index 000000000000..157c2ca8570a
--- /dev/null
+++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx
@@ -0,0 +1,114 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFieldText,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { WorkspaceAttribute } from 'opensearch-dashboards/public';
+import { i18n } from '@osd/i18n';
+import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public';
+import { WorkspaceClient } from '../../workspace_client';
+
+export interface DeleteWorkspaceModalProps {
+ onClose: () => void;
+ selectedWorkspace?: WorkspaceAttribute | null;
+ onDeleteSuccess?: () => void;
+}
+
+export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) {
+ const [value, setValue] = useState('');
+ const { onClose, selectedWorkspace, onDeleteSuccess } = props;
+ const {
+ services: { notifications, workspaceClient },
+ } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>();
+
+ const deleteWorkspace = async () => {
+ if (selectedWorkspace?.id) {
+ let result;
+ try {
+ result = await workspaceClient.delete(selectedWorkspace?.id);
+ } catch (error) {
+ notifications?.toasts.addDanger({
+ title: i18n.translate('workspace.delete.failed', {
+ defaultMessage: 'Failed to delete workspace',
+ }),
+ text: error instanceof Error ? error.message : JSON.stringify(error),
+ });
+ return onClose();
+ }
+ if (result?.success) {
+ notifications?.toasts.addSuccess({
+ title: i18n.translate('workspace.delete.success', {
+ defaultMessage: 'Delete workspace successfully',
+ }),
+ });
+ onClose();
+ if (onDeleteSuccess) {
+ onDeleteSuccess();
+ }
+ } else {
+ notifications?.toasts.addDanger({
+ title: i18n.translate('workspace.delete.failed', {
+ defaultMessage: 'Failed to delete workspace',
+ }),
+ text: result?.error,
+ });
+ }
+ }
+ };
+
+ return (
+
+
+ Delete workspace
+
+
+
+
+
The following workspace will be permanently deleted. This action cannot be undone.
+
+ {selectedWorkspace?.name ? - {selectedWorkspace.name}
: null}
+
+
+
+ To confirm your action, type delete.
+
+
setValue(e.target.value)}
+ />
+
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+ );
+}
diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/index.ts b/src/plugins/workspace/public/components/delete_workspace_modal/index.ts
new file mode 100644
index 000000000000..3466e180c54a
--- /dev/null
+++ b/src/plugins/workspace/public/components/delete_workspace_modal/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './delete_workspace_modal';
diff --git a/src/plugins/workspace/public/components/utils/workspace.test.ts b/src/plugins/workspace/public/components/utils/workspace.test.ts
new file mode 100644
index 000000000000..926455feed34
--- /dev/null
+++ b/src/plugins/workspace/public/components/utils/workspace.test.ts
@@ -0,0 +1,84 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { switchWorkspace, navigateToWorkspaceUpdatePage } from './workspace';
+import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils';
+jest.mock('../../../../../core/public/utils');
+
+import { coreMock } from '../../../../../core/public/mocks';
+
+const coreStartMock = coreMock.createStart();
+let mockNavigateToUrl = jest.fn();
+
+const defaultUrl = 'localhost://';
+
+describe('workspace utils', () => {
+ beforeEach(() => {
+ mockNavigateToUrl = jest.fn();
+ coreStartMock.application.navigateToUrl = mockNavigateToUrl;
+ });
+
+ describe('switchWorkspace', () => {
+ it('should redirect if newUrl is returned', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: defaultUrl,
+ },
+ writable: true,
+ });
+ // @ts-ignore
+ formatUrlWithWorkspaceId.mockImplementation(() => 'new_url');
+ switchWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, '');
+ expect(mockNavigateToUrl).toHaveBeenCalledWith('new_url');
+ });
+
+ it('should not redirect if newUrl is not returned', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: defaultUrl,
+ },
+ writable: true,
+ });
+ // @ts-ignore
+ formatUrlWithWorkspaceId.mockImplementation(() => '');
+ switchWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, '');
+ expect(mockNavigateToUrl).not.toBeCalled();
+ });
+ });
+
+ describe('navigateToWorkspaceUpdatePage', () => {
+ it('should redirect if newUrl is returned', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: defaultUrl,
+ },
+ writable: true,
+ });
+ // @ts-ignore
+ formatUrlWithWorkspaceId.mockImplementation(() => 'new_url');
+ navigateToWorkspaceUpdatePage(
+ { application: coreStartMock.application, http: coreStartMock.http },
+ ''
+ );
+ expect(mockNavigateToUrl).toHaveBeenCalledWith('new_url');
+ });
+
+ it('should not redirect if newUrl is not returned', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: defaultUrl,
+ },
+ writable: true,
+ });
+ // @ts-ignore
+ formatUrlWithWorkspaceId.mockImplementation(() => '');
+ navigateToWorkspaceUpdatePage(
+ { application: coreStartMock.application, http: coreStartMock.http },
+ ''
+ );
+ expect(mockNavigateToUrl).not.toBeCalled();
+ });
+ });
+});
diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts
new file mode 100644
index 000000000000..63ed5953dbfa
--- /dev/null
+++ b/src/plugins/workspace/public/components/utils/workspace.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_UPDATE_APP_ID } from '../../../common/constants';
+import { CoreStart } from '../../../../../core/public';
+import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils';
+
+type Core = Pick;
+
+export const switchWorkspace = ({ application, http }: Core, id: string) => {
+ const newUrl = formatUrlWithWorkspaceId(
+ application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, {
+ absolute: true,
+ }),
+ id,
+ http.basePath
+ );
+ if (newUrl) {
+ application.navigateToUrl(newUrl);
+ }
+};
+
+export const navigateToWorkspaceUpdatePage = ({ application, http }: Core, id: string) => {
+ const newUrl = formatUrlWithWorkspaceId(
+ application.getUrlForApp(WORKSPACE_UPDATE_APP_ID, {
+ absolute: true,
+ }),
+ id,
+ http.basePath
+ );
+ if (newUrl) {
+ application.navigateToUrl(newUrl);
+ }
+};
diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx
new file mode 100644
index 000000000000..c8cdbfab65be
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx
@@ -0,0 +1,6 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { WorkspaceCreator } from './workspace_creator';
diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx
new file mode 100644
index 000000000000..9cc4f9b53f69
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx
@@ -0,0 +1,213 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { PublicAppInfo } from 'opensearch-dashboards/public';
+import { fireEvent, render, waitFor } from '@testing-library/react';
+import { BehaviorSubject } from 'rxjs';
+import { WorkspaceCreator as WorkspaceCreatorComponent } from './workspace_creator';
+import { coreMock } from '../../../../../core/public/mocks';
+import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public';
+
+const workspaceClientCreate = jest
+ .fn()
+ .mockReturnValue({ result: { id: 'successResult' }, success: true });
+
+const navigateToApp = jest.fn();
+const notificationToastsAddSuccess = jest.fn();
+const notificationToastsAddDanger = jest.fn();
+const PublicAPPInfoMap = new Map([
+ ['app1', { id: 'app1', title: 'app1' }],
+ ['app2', { id: 'app2', title: 'app2', category: { id: 'category1', label: 'category1' } }],
+ ['app3', { id: 'app3', category: { id: 'category1', label: 'category1' } }],
+ ['app4', { id: 'app4', category: { id: 'category2', label: 'category2' } }],
+ ['app5', { id: 'app5', category: { id: 'category2', label: 'category2' } }],
+]);
+
+const mockCoreStart = coreMock.createStart();
+
+const WorkspaceCreator = (props: any) => {
+ const { Provider } = createOpenSearchDashboardsReactContext({
+ ...mockCoreStart,
+ ...{
+ application: {
+ ...mockCoreStart.application,
+ navigateToApp,
+ getUrlForApp: jest.fn(() => '/app/workspace_overview'),
+ applications$: new BehaviorSubject