diff --git a/src/core/public/index.ts b/src/core/public/index.ts
index bcc04c313715..a5c06f3c96ed 100644
--- a/src/core/public/index.ts
+++ b/src/core/public/index.ts
@@ -357,3 +357,5 @@ export { __osdBootstrap__ } from './osd_bootstrap';
export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace';
export { WORKSPACE_TYPE, cleanWorkspaceId, DEFAULT_WORKSPACE_ID } from '../utils';
+
+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/public/application.tsx b/src/plugins/workspace/public/application.tsx
index a6f496304889..ad943786d0b6 100644
--- a/src/plugins/workspace/public/application.tsx
+++ b/src/plugins/workspace/public/application.tsx
@@ -7,6 +7,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters, ScopedHistory } from '../../../core/public';
import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public';
+import { WorkspaceListApp } from './components/workspace_list_app';
import { WorkspaceFatalError } from './components/workspace_fatal_error';
import { Services } from './types';
@@ -24,3 +25,16 @@ 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..15078b87bade
--- /dev/null
+++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx
@@ -0,0 +1,262 @@
+/*
+ * 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 } 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,
+ returnToHome: true,
+};
+
+const coreStartMock = coreMock.createStart();
+
+function getWrapWorkspaceDeleteModalInContext(
+ props: DeleteWorkspaceModalProps,
+ services = { ...coreStartMock }
+) {
+ return (
+
+
+
+ );
+}
+
+describe('DeleteWorkspaceModal', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should render normally', () => {
+ const { getByText, baseElement, getByTestId } = render(
+ getWrapWorkspaceDeleteModalInContext(defaultProps)
+ );
+
+ 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 navigate successfully', async () => {
+ const onCloseFn = jest.fn();
+ const newProps = {
+ ...defaultProps,
+ selectedWorkspace: {
+ id: 'test',
+ name: 'test',
+ },
+ onClose: onCloseFn,
+ };
+ 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(coreStartMock.application.navigateToUrl).toHaveBeenCalled();
+ });
+ });
+
+ it('should not navigate when successfully if returnToHome is false', async () => {
+ const newProps = {
+ ...defaultProps,
+ selectedWorkspace: {
+ id: 'test',
+ name: 'test',
+ },
+ returnToHome: false,
+ };
+ 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).toHaveBeenCalledWith('test');
+ await waitFor(() => {
+ expect(coreStartMock.notifications.toasts.addSuccess).toHaveBeenCalled();
+ expect(coreStartMock.application.navigateToUrl).not.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..4273134805a8
--- /dev/null
+++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx
@@ -0,0 +1,121 @@
+/*
+ * 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;
+ returnToHome: boolean;
+}
+
+export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) {
+ const [value, setValue] = useState('');
+ const { onClose, selectedWorkspace, returnToHome } = props;
+ const {
+ services: { application, notifications, http, 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 (http && application && returnToHome) {
+ const homeUrl = application.getUrlForApp('home', {
+ path: '/',
+ absolute: false,
+ });
+ const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), {
+ withoutWorkspace: true,
+ });
+ await application.navigateToUrl(targetUrl);
+ }
+ } 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..7b0e93c739c7
--- /dev/null
+++ b/src/plugins/workspace/public/components/utils/workspace.test.ts
@@ -0,0 +1,78 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { switchWorkspace, updateWorkspace } 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('updateWorkspace', () => {
+ it('should redirect if newUrl is returned', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ href: defaultUrl,
+ },
+ writable: true,
+ });
+ // @ts-ignore
+ formatUrlWithWorkspaceId.mockImplementation(() => 'new_url');
+ updateWorkspace({ 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(() => '');
+ updateWorkspace({ 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..fb47ca316cbe
--- /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 updateWorkspace = ({ 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..f9e5a388368e
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx
@@ -0,0 +1,119 @@
+/*
+ * 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, updateWorkspace } from '../utils/workspace';
+
+import { of } from 'rxjs';
+
+import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public';
+
+jest.mock('../utils/workspace');
+
+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(updateWorkspace).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);
+ await screen.findByTestId('delete-workspace-modal-header');
+ expect(screen.getByTestId('delete-workspace-modal-header')).toBeInTheDocument();
+ const cancelButton = screen.getByTestId('delete-workspace-modal-cancel-button');
+ fireEvent.click(cancelButton);
+ expect(screen.queryByTestId('delete-workspace-modal-header')).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..bc92a01f8f58
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_list/index.tsx
@@ -0,0 +1,221 @@
+/*
+ * 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, updateWorkspace } 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) {
+ updateWorkspace({ 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)}
+ returnToHome={false}
+ />
+ )}
+
+ );
+};
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 bf5426c73e8a..8fdcec53d7f6 100644
--- a/src/plugins/workspace/public/plugin.test.ts
+++ b/src/plugins/workspace/public/plugin.test.ts
@@ -23,7 +23,7 @@ describe('Workspace plugin', () => {
await workspacePlugin.setup(setupMock, {
savedObjectsManagement: savedObjectManagementSetupMock,
});
- expect(setupMock.application.register).toBeCalledTimes(1);
+ expect(setupMock.application.register).toBeCalledTimes(2);
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0);
expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1);
@@ -61,7 +61,7 @@ describe('Workspace plugin', () => {
await workspacePlugin.setup(setupMock, {
savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(),
});
- expect(setupMock.application.register).toBeCalledTimes(1);
+ expect(setupMock.application.register).toBeCalledTimes(2);
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 b1201b004ef2..a09b784fba39 100644
--- a/src/plugins/workspace/public/plugin.ts
+++ b/src/plugins/workspace/public/plugin.ts
@@ -15,7 +15,11 @@ import {
Plugin,
WorkspaceObject,
} from '../../../core/public';
-import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants';
+import {
+ WORKSPACE_FATAL_ERROR_APP_ID,
+ WORKSPACE_OVERVIEW_APP_ID,
+ WORKSPACE_LIST_APP_ID,
+} from '../common/constants';
import { getWorkspaceIdFromUrl } from '../../../core/public/utils';
import { renderWorkspaceMenu } from './render_workspace_menu';
import { Services } from './types';
@@ -146,6 +150,17 @@ export class WorkspacePlugin implements Plugin<{}, {}> {
return renderApp(params, services);
};
+ // 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);
+ },
+ });
+
// workspace fatal error
core.application.register({
id: WORKSPACE_FATAL_ERROR_APP_ID,