diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1f58d242853..9922970bb5cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,6 +67,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171))
- [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))
- [Workspace] Add create workspace page ([#6179](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6179))
+- [Workspace] Add workspace list page ([#6182](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6182))
### 🐛 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/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 66672561616a..98ddb6610864 100644
--- a/src/plugins/workspace/public/application.tsx
+++ b/src/plugins/workspace/public/application.tsx
@@ -9,6 +9,7 @@ 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) => {
@@ -38,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_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap
new file mode 100644
index 000000000000..f90101772950
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,301 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`WorkspaceList should render title and table normally 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Create workspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No items found
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx
new file mode 100644
index 000000000000..d75ddf0d513f
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx
@@ -0,0 +1,126 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { WorkspaceList } from './index';
+import { coreMock } from '../../../../../core/public/mocks';
+import { render, fireEvent, screen } from '@testing-library/react';
+import { I18nProvider } from '@osd/i18n/react';
+import { switchWorkspace, navigateToWorkspaceUpdatePage } from '../utils/workspace';
+
+import { of } from 'rxjs';
+
+import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public';
+
+jest.mock('../utils/workspace');
+
+jest.mock('../delete_workspace_modal', () => ({
+ DeleteWorkspaceModal: ({ onClose }: { onClose: () => void }) => (
+
+
+
+ ),
+}));
+
+function getWrapWorkspaceListInContext(
+ workspaceList = [
+ { id: 'id1', name: 'name1' },
+ { id: 'id2', name: 'name2' },
+ ]
+) {
+ const coreStartMock = coreMock.createStart();
+
+ const services = {
+ ...coreStartMock,
+ workspaces: {
+ workspaceList$: of(workspaceList),
+ },
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+describe('WorkspaceList', () => {
+ it('should render title and table normally', () => {
+ const { getByText, getByRole, container } = render( );
+ expect(getByText('Workspaces')).toBeInTheDocument();
+ expect(getByRole('table')).toBeInTheDocument();
+ expect(container).toMatchSnapshot();
+ });
+ it('should render data in table based on workspace list data', async () => {
+ const { getByText } = render(getWrapWorkspaceListInContext());
+ expect(getByText('name1')).toBeInTheDocument();
+ expect(getByText('name2')).toBeInTheDocument();
+ });
+ it('should be able to apply debounce search after input', async () => {
+ const list = [
+ { id: 'id1', name: 'name1' },
+ { id: 'id2', name: 'name2' },
+ { id: 'id3', name: 'name3' },
+ { id: 'id4', name: 'name4' },
+ { id: 'id5', name: 'name5' },
+ { id: 'id6', name: 'name6' },
+ ];
+ const { getByText, getByRole, queryByText } = render(getWrapWorkspaceListInContext(list));
+ expect(getByText('name1')).toBeInTheDocument();
+ expect(queryByText('name6')).not.toBeInTheDocument();
+ const input = getByRole('searchbox');
+ fireEvent.change(input, {
+ target: { value: 'nam' },
+ });
+ fireEvent.change(input, {
+ target: { value: 'name6' },
+ });
+ expect(queryByText('name6')).not.toBeInTheDocument();
+ });
+
+ it('should be able to switch workspace after clicking name', async () => {
+ const { getByText } = render(getWrapWorkspaceListInContext());
+ const nameLink = getByText('name1');
+ fireEvent.click(nameLink);
+ expect(switchWorkspace).toBeCalled();
+ });
+
+ it('should be able to update workspace after clicking name', async () => {
+ const { getAllByTestId } = render(getWrapWorkspaceListInContext());
+ const editIcon = getAllByTestId('workspace-list-edit-icon')[0];
+ fireEvent.click(editIcon);
+ expect(navigateToWorkspaceUpdatePage).toBeCalled();
+ });
+
+ it('should be able to call delete modal after clicking delete button', async () => {
+ const { getAllByTestId } = render(getWrapWorkspaceListInContext());
+ const deleteIcon = getAllByTestId('workspace-list-delete-icon')[0];
+ fireEvent.click(deleteIcon);
+ expect(screen.queryByLabelText('mock delete workspace modal')).toBeInTheDocument();
+ const modalCancelButton = screen.getByLabelText('mock delete workspace modal button');
+ fireEvent.click(modalCancelButton);
+ expect(screen.queryByLabelText('mock delete workspace modal')).not.toBeInTheDocument();
+ });
+
+ it('should be able to pagination when clicking pagination button', async () => {
+ const list = [
+ { id: 'id1', name: 'name1' },
+ { id: 'id2', name: 'name2' },
+ { id: 'id3', name: 'name3' },
+ { id: 'id4', name: 'name4' },
+ { id: 'id5', name: 'name5' },
+ { id: 'id6', name: 'name6' },
+ ];
+ const { getByTestId, getByText, queryByText } = render(getWrapWorkspaceListInContext(list));
+ expect(getByText('name1')).toBeInTheDocument();
+ expect(queryByText('name6')).not.toBeInTheDocument();
+ const paginationButton = getByTestId('pagination-button-next');
+ fireEvent.click(paginationButton);
+ expect(queryByText('name1')).not.toBeInTheDocument();
+ expect(getByText('name6')).toBeInTheDocument();
+ });
+});
diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx
new file mode 100644
index 000000000000..b22a0fdb99fd
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_list/index.tsx
@@ -0,0 +1,220 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState, useMemo, useCallback } from 'react';
+import {
+ EuiPage,
+ EuiPageBody,
+ EuiPageHeader,
+ EuiPageContent,
+ EuiLink,
+ EuiButton,
+ EuiInMemoryTable,
+ EuiSearchBarProps,
+} from '@elastic/eui';
+import useObservable from 'react-use/lib/useObservable';
+import { of } from 'rxjs';
+import { i18n } from '@osd/i18n';
+import { debounce } from '../../../../../core/public';
+import { WorkspaceAttribute } from '../../../../../core/public';
+import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public';
+import { switchWorkspace, navigateToWorkspaceUpdatePage } from '../utils/workspace';
+
+import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants';
+
+import { cleanWorkspaceId } from '../../../../../core/public';
+import { DeleteWorkspaceModal } from '../delete_workspace_modal';
+
+const WORKSPACE_LIST_PAGE_DESCRIPTIOIN = i18n.translate('workspace.list.description', {
+ defaultMessage:
+ 'Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace.',
+});
+
+export const WorkspaceList = () => {
+ const {
+ services: { workspaces, application, http },
+ } = useOpenSearchDashboards();
+
+ const initialSortField = 'name';
+ const initialSortDirection = 'asc';
+ const workspaceList = useObservable(workspaces?.workspaceList$ ?? of([]), []);
+ const [queryInput, setQueryInput] = useState('');
+ const [pagination, setPagination] = useState({
+ pageIndex: 0,
+ pageSize: 5,
+ pageSizeOptions: [5, 10, 20],
+ });
+ const [deletedWorkspace, setDeletedWorkspace] = useState(null);
+
+ const handleSwitchWorkspace = useCallback(
+ (id: string) => {
+ if (application && http) {
+ switchWorkspace({ application, http }, id);
+ }
+ },
+ [application, http]
+ );
+
+ const handleUpdateWorkspace = useCallback(
+ (id: string) => {
+ if (application && http) {
+ navigateToWorkspaceUpdatePage({ application, http }, id);
+ }
+ },
+ [application, http]
+ );
+
+ const searchResult = useMemo(() => {
+ if (queryInput) {
+ const normalizedQuery = queryInput.toLowerCase();
+ const result = workspaceList.filter((item) => {
+ return (
+ item.id.toLowerCase().indexOf(normalizedQuery) > -1 ||
+ item.name.toLowerCase().indexOf(normalizedQuery) > -1
+ );
+ });
+ return result;
+ }
+ return workspaceList;
+ }, [workspaceList, queryInput]);
+
+ const columns = [
+ {
+ field: 'name',
+ name: 'Name',
+ sortable: true,
+ render: (name: string, item: WorkspaceAttribute) => (
+
+ handleSwitchWorkspace(item.id)}>{name}
+
+ ),
+ },
+ {
+ field: 'id',
+ name: 'ID',
+ sortable: true,
+ },
+ {
+ field: 'description',
+ name: 'Description',
+ truncateText: true,
+ },
+ {
+ field: 'features',
+ name: 'Features',
+ isExpander: true,
+ hasActions: true,
+ },
+ {
+ name: 'Actions',
+ field: '',
+ actions: [
+ {
+ name: 'Edit',
+ icon: 'pencil',
+ type: 'icon',
+ description: 'Edit workspace',
+ onClick: ({ id }: WorkspaceAttribute) => handleUpdateWorkspace(id),
+ 'data-test-subj': 'workspace-list-edit-icon',
+ },
+ {
+ name: 'Delete',
+ icon: 'trash',
+ type: 'icon',
+ description: 'Delete workspace',
+ onClick: (item: WorkspaceAttribute) => setDeletedWorkspace(item),
+ 'data-test-subj': 'workspace-list-delete-icon',
+ },
+ ],
+ },
+ ];
+
+ const workspaceCreateUrl = useMemo(() => {
+ if (!application || !http) {
+ return '';
+ }
+
+ const appUrl = application.getUrlForApp(WORKSPACE_CREATE_APP_ID, {
+ absolute: false,
+ });
+ if (!appUrl) return '';
+
+ return cleanWorkspaceId(appUrl);
+ }, [application, http]);
+
+ const debouncedSetQueryInput = useMemo(() => {
+ return debounce(setQueryInput, 300);
+ }, [setQueryInput]);
+
+ const handleSearchInput: EuiSearchBarProps['onChange'] = useCallback(
+ ({ query }) => {
+ debouncedSetQueryInput(query?.text ?? '');
+ },
+ [debouncedSetQueryInput]
+ );
+
+ const search: EuiSearchBarProps = {
+ onChange: handleSearchInput,
+ box: {
+ incremental: true,
+ },
+ toolsRight: [
+
+ Create workspace
+ ,
+ ],
+ };
+
+ return (
+
+
+
+
+
+ setPagination((prev) => {
+ return { ...prev, pageIndex: index, pageSize: size };
+ })
+ }
+ pagination={pagination}
+ sorting={{
+ sort: {
+ field: initialSortField,
+ direction: initialSortDirection,
+ },
+ }}
+ isSelectable={true}
+ search={search}
+ />
+
+
+ {deletedWorkspace && (
+ setDeletedWorkspace(null)}
+ />
+ )}
+
+ );
+};
diff --git a/src/plugins/workspace/public/components/workspace_list_app.tsx b/src/plugins/workspace/public/components/workspace_list_app.tsx
new file mode 100644
index 000000000000..8970cfc46fc7
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_list_app.tsx
@@ -0,0 +1,35 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useEffect } from 'react';
+import { I18nProvider } from '@osd/i18n/react';
+import { i18n } from '@osd/i18n';
+import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public';
+import { WorkspaceList } from './workspace_list';
+
+export const WorkspaceListApp = () => {
+ const {
+ services: { chrome },
+ } = useOpenSearchDashboards();
+
+ /**
+ * set breadcrumbs to chrome
+ */
+ useEffect(() => {
+ chrome?.setBreadcrumbs([
+ {
+ text: i18n.translate('workspace.workspaceListTitle', {
+ defaultMessage: 'Workspaces',
+ }),
+ },
+ ]);
+ }, [chrome]);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts
index 0ec007079c24..793ed5e0f09f 100644
--- a/src/plugins/workspace/public/plugin.test.ts
+++ b/src/plugins/workspace/public/plugin.test.ts
@@ -23,7 +23,7 @@ describe('Workspace plugin', () => {
const setupMock = getSetupMock();
const workspacePlugin = new WorkspacePlugin();
await workspacePlugin.setup(setupMock);
- expect(setupMock.application.register).toBeCalledTimes(2);
+ expect(setupMock.application.register).toBeCalledTimes(3);
expect(WorkspaceClientMock).toBeCalledTimes(1);
});
@@ -35,7 +35,7 @@ describe('Workspace plugin', () => {
workspacePlugin.start(coreStart);
coreStart.workspaces.currentWorkspaceId$.next('foo');
expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo');
- expect(setupMock.application.register).toBeCalledTimes(2);
+ expect(setupMock.application.register).toBeCalledTimes(3);
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0);
});
@@ -70,7 +70,7 @@ describe('Workspace plugin', () => {
const workspacePlugin = new WorkspacePlugin();
await workspacePlugin.setup(setupMock);
- expect(setupMock.application.register).toBeCalledTimes(2);
+ expect(setupMock.application.register).toBeCalledTimes(3);
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId');
expect(setupMock.getStartServices).toBeCalledTimes(1);
diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts
index eeaab74e8e44..bce606583b85 100644
--- a/src/plugins/workspace/public/plugin.ts
+++ b/src/plugins/workspace/public/plugin.ts
@@ -17,6 +17,7 @@ import {
WORKSPACE_FATAL_ERROR_APP_ID,
WORKSPACE_OVERVIEW_APP_ID,
WORKSPACE_CREATE_APP_ID,
+ WORKSPACE_LIST_APP_ID,
} from '../common/constants';
import { getWorkspaceIdFromUrl } from '../../../core/public/utils';
import { Services } from './types';
@@ -128,6 +129,17 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> {
return React.createElement(WorkspaceMenu, { coreStart: this.coreStart });
});
+ // workspace list
+ core.application.register({
+ id: WORKSPACE_LIST_APP_ID,
+ title: '',
+ navLinkStatus: AppNavLinkStatus.hidden,
+ async mount(params: AppMountParameters) {
+ const { renderListApp } = await import('./application');
+ return mountWorkspaceApp(params, renderListApp);
+ },
+ });
+
return {};
}