diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index 98ddb661086..5440d98e694 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 { WorkspaceUpdaterApp } from './components/workspace_updater_app'; import { WorkspaceListApp } from './components/workspace_list_app'; import { Services } from './types'; @@ -25,6 +26,19 @@ export const renderCreatorApp = ({ element }: AppMountParameters, services: Serv }; }; +export const renderUpdaterApp = ({ 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 }>; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 83d0f6675fe..0ba7ca9947d 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -22,6 +22,29 @@ export const WorkspaceCreator = () => { let result; try { result = await workspaceClient.create(data); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.success', { + defaultMessage: 'Create workspace successfully', + }), + }); + if (application && http) { + const newWorkspaceId = result.result.id; + // Redirect page after one second, leave one second time to show create successful toast. + window.setTimeout(() => { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + newWorkspaceId, + http.basePath + ); + }, 1000); + } + return; + } else { + throw new Error(result?.error ? result?.error : 'create workspace failed'); + } } catch (error) { notifications?.toasts.addDanger({ title: i18n.translate('workspace.create.failed', { @@ -31,33 +54,6 @@ export const WorkspaceCreator = () => { }); return; } - if (result?.success) { - notifications?.toasts.addSuccess({ - title: i18n.translate('workspace.create.success', { - defaultMessage: 'Create workspace successfully', - }), - }); - if (application && http) { - const newWorkspaceId = result.result.id; - // Redirect page after one second, leave one second time to show create successful toast. - window.setTimeout(() => { - window.location.href = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { - absolute: true, - }), - newWorkspaceId, - http.basePath - ); - }, 1000); - } - return; - } - notifications?.toasts.addDanger({ - title: i18n.translate('workspace.create.failed', { - defaultMessage: 'Failed to create workspace', - }), - text: result?.error, - }); }, [notifications?.toasts, http, application, workspaceClient] ); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx index 7e528d2214e..34d5ca6359e 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx @@ -92,7 +92,13 @@ export const WorkspaceBottomBar = ({ )} {operationType === WorkspaceOperationType.Update && ( - + {i18n.translate('workspace.form.bottomBar.saveChanges', { defaultMessage: 'Save changes', })} diff --git a/src/plugins/workspace/public/components/workspace_updater/index.tsx b/src/plugins/workspace/public/components/workspace_updater/index.tsx new file mode 100644 index 00000000000..711f19fd25f --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceUpdater } from './workspace_updater'; diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx new file mode 100644 index 00000000000..d829154426d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx @@ -0,0 +1,239 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceUpdater as WorkspaceCreatorComponent } from './workspace_updater'; +import { coreMock, workspacesServiceMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; + +const workspaceClientUpdate = jest.fn().mockReturnValue({ result: true, 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 createWorkspacesSetupContractMockWithValue = () => { + const currentWorkspaceId$ = new BehaviorSubject('abljlsds'); + const currentWorkspace: WorkspaceObject = { + id: 'abljlsds', + name: 'test1', + description: 'test1', + features: [], + color: '', + icon: '', + reserved: false, + }; + const workspaceList$ = new BehaviorSubject([currentWorkspace]); + const currentWorkspace$ = new BehaviorSubject(currentWorkspace); + const initialized$ = new BehaviorSubject(false); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; + +const mockCoreStart = coreMock.createStart(); + +const WorkspaceUpdater = (props: any) => { + const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); + const { Provider } = createOpenSearchDashboardsReactContext({ + ...mockCoreStart, + ...{ + application: { + ...mockCoreStart.application, + capabilities: { + ...mockCoreStart.application.capabilities, + }, + navigateToApp, + getUrlForApp: jest.fn(() => '/app/workspace_overview'), + applications$: new BehaviorSubject>(PublicAPPInfoMap as any), + }, + workspaces: workspacesService, + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + ...mockCoreStart.workspaces, + update: workspaceClientUpdate, + }, + }, + }); + + return ( + + + + ); +}; + +function clearMockedFunctions() { + workspaceClientUpdate.mockClear(); + notificationToastsAddDanger.mockClear(); + notificationToastsAddSuccess.mockClear(); +} + +describe('WorkspaceUpdater', () => { + beforeEach(() => clearMockedFunctions()); + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + + beforeAll(() => { + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/', + set: setHrefSpy, + }); + }); + + afterAll(() => { + window.location = location; + }); + + it('cannot render when the name of the current workspace is empty', async () => { + const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); + + it('cannot update workspace with invalid name', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: '~' }, + }); + expect(workspaceClientUpdate).not.toHaveBeenCalled(); + }); + + it('cannot update workspace with invalid description', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: '~' }, + }); + expect(workspaceClientUpdate).not.toHaveBeenCalled(); + }); + + it('cancel update workspace', async () => { + const { findByText, getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); + await findByText('Discard changes?'); + fireEvent.click(getByTestId('confirmModalConfirmButton')); + expect(navigateToApp).toHaveBeenCalled(); + }); + + it('update workspace successfully', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: 'test workspace description' }, + }); + + const colorSelector = getByTestId( + 'euiColorPickerAnchor workspaceForm-workspaceDetails-colorPicker' + ); + fireEvent.input(colorSelector, { + target: { value: '#000000' }, + }); + + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); + + fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); + expect(workspaceClientUpdate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + name: 'test workspace name', + color: '#000000', + description: 'test workspace description', + features: expect.arrayContaining(['app1', 'app2', 'app3']), + }) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + await waitFor(() => { + expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_overview$/)); + }); + }); + + it('should show danger toasts after update workspace failed', async () => { + workspaceClientUpdate.mockReturnValue({ result: false, success: false }); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); + expect(workspaceClientUpdate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); + + it('should show danger toasts after update workspace threw error', async () => { + workspaceClientUpdate.mockImplementation(() => { + throw new Error('update workspace failed'); + }); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); + expect(workspaceClientUpdate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); + + it('should show danger toasts when currentWorkspace is missing after click update button', async () => { + const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); + const { getByTestId } = render(); + + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); + mockedWorkspacesService.currentWorkspace$ = new BehaviorSubject(null); + expect(workspaceClientUpdate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx new file mode 100644 index 00000000000..d39ddd65036 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -0,0 +1,117 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { WorkspaceAttribute } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { of } from 'rxjs'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; +import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { WorkspaceClient } from '../../workspace_client'; +import { WorkspaceFormData } from '../workspace_form/types'; + +function getFormDataFromWorkspace( + currentWorkspace: WorkspaceAttribute | null | undefined +): WorkspaceFormData { + return (currentWorkspace || {}) as WorkspaceFormData; +} + +export const WorkspaceUpdater = () => { + const { + services: { application, workspaces, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState( + getFormDataFromWorkspace(currentWorkspace) + ); + + useEffect(() => { + setCurrentWorkspaceFormData(getFormDataFromWorkspace(currentWorkspace)); + }, [currentWorkspace]); + + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + if (!currentWorkspace) { + notifications?.toasts.addDanger({ + title: i18n.translate('Cannot find current workspace', { + defaultMessage: 'Cannot update workspace', + }), + }); + return; + } + + try { + const { ...attributes } = data; + result = await workspaceClient.update(currentWorkspace.id, attributes); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.update.success', { + defaultMessage: 'Update workspace successfully', + }), + }); + if (application && http) { + // Redirect page after one second, leave one second time to show update successful toast. + window.setTimeout(() => { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + currentWorkspace.id, + http.basePath + ); + }, 1000); + } + return; + } else { + throw new Error(result?.error ? result?.error : 'update workspace failed'); + } + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.update.failed', { + defaultMessage: 'Failed to update workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + }, + [notifications?.toasts, currentWorkspace, http, application, workspaceClient] + ); + + if (!currentWorkspaceFormData.name) { + return null; + } + + return ( + + + + + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_updater_app.tsx b/src/plugins/workspace/public/components/workspace_updater_app.tsx new file mode 100644 index 00000000000..e16c9ad72e0 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater_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 { WorkspaceUpdater } from './workspace_updater'; + +export const WorkspaceUpdaterApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceUpdateTitle', { + defaultMessage: 'Update workspace', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index b2c1984abac..50a6b5e70a5 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -26,7 +26,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectManagementSetupMock, }); - expect(setupMock.application.register).toBeCalledTimes(3); + expect(setupMock.application.register).toBeCalledTimes(4); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); }); @@ -39,7 +39,7 @@ describe('Workspace plugin', () => { workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); - expect(setupMock.application.register).toBeCalledTimes(3); + expect(setupMock.application.register).toBeCalledTimes(4); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); @@ -74,7 +74,7 @@ describe('Workspace plugin', () => { const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock, {}); - expect(setupMock.application.register).toBeCalledTimes(3); + expect(setupMock.application.register).toBeCalledTimes(4); 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 c28bc2f1cb1..5357aa08cbf 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_UPDATE_APP_ID, WORKSPACE_LIST_APP_ID, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; @@ -114,6 +115,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }, }); + // update + core.application.register({ + id: WORKSPACE_UPDATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceUpdate', { + defaultMessage: 'Update Workspace', + }), + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderUpdaterApp } = await import('./application'); + return mountWorkspaceApp(params, renderUpdaterApp); + }, + }); + // workspace fatal error core.application.register({ id: WORKSPACE_FATAL_ERROR_APP_ID,