Skip to content

Commit

Permalink
Add update workspace page (#304)
Browse files Browse the repository at this point in the history
* Add update workspace page

Signed-off-by: gaobinlong <gbinlong@amazon.com>

* Fix test failure

Signed-off-by: gaobinlong <gbinlong@amazon.com>

* Add more unit test

Signed-off-by: gaobinlong <gbinlong@amazon.com>

---------

Signed-off-by: gaobinlong <gbinlong@amazon.com>
  • Loading branch information
gaobinlong authored Mar 21, 2024
1 parent 431fcdb commit d66c8fc
Show file tree
Hide file tree
Showing 8 changed files with 422 additions and 3 deletions.
14 changes: 14 additions & 0 deletions src/plugins/workspace/public/application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards
import { WorkspaceListApp } from './components/workspace_list_app';
import { WorkspaceFatalError } from './components/workspace_fatal_error';
import { WorkspaceCreatorApp } from './components/workspace_creator_app';
import { WorkspaceUpdaterApp } from './components/workspace_updater_app';
import { Services } from './types';

export const renderCreatorApp = ({ element }: AppMountParameters, services: Services) => {
Expand All @@ -25,6 +26,19 @@ export const renderCreatorApp = ({ element }: AppMountParameters, services: Serv
};
};

export const renderUpdaterApp = ({ element }: AppMountParameters, services: Services) => {
ReactDOM.render(
<OpenSearchDashboardsContextProvider services={services}>
<WorkspaceUpdaterApp />
</OpenSearchDashboardsContextProvider>,
element
);

return () => {
ReactDOM.unmountComponentAtNode(element);
};
};

export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => {
const { element } = params;
const history = params.history as ScopedHistory<{ error?: string }>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,13 @@ export const WorkspaceBottomBar = ({
</EuiButton>
)}
{operationType === WorkspaceOperationType.Update && (
<EuiButton form={formId} type="submit" fill color="primary">
<EuiButton
form={formId}
type="submit"
fill
color="primary"
data-test-subj="workspaceForm-bottomBar-updateButton"
>
{i18n.translate('workspace.form.bottomBar.saveChanges', {
defaultMessage: 'Save changes',
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export { WorkspaceUpdater } from './workspace_updater';
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
* 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<string>('abljlsds');
const currentWorkspace: WorkspaceObject = {
id: 'abljlsds',
name: 'test1',
description: 'test1',
features: [],
color: '',
icon: '',
reserved: false,
};
const workspaceList$ = new BehaviorSubject<WorkspaceObject[]>([currentWorkspace]);
const currentWorkspace$ = new BehaviorSubject<WorkspaceObject | null>(currentWorkspace);
const initialized$ = new BehaviorSubject<boolean>(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,
workspaces: {
permissionEnabled: true,
},
},
navigateToApp,
getUrlForApp: jest.fn(),
applications$: new BehaviorSubject<Map<string, PublicAppInfo>>(PublicAPPInfoMap as any),
},
workspaces: workspacesService,
notifications: {
...mockCoreStart.notifications,
toasts: {
...mockCoreStart.notifications.toasts,
addDanger: notificationToastsAddDanger,
addSuccess: notificationToastsAddSuccess,
},
},
workspaceClient: {
...mockCoreStart.workspaces,
update: workspaceClientUpdate,
},
},
});

return (
<Provider>
<WorkspaceCreatorComponent {...props} />
</Provider>
);
};

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(<WorkspaceUpdater workspacesService={mockedWorkspacesService} />);
expect(container).toMatchInlineSnapshot(`<div />`);
});

it('cannot create workspace with invalid name', async () => {
const { getByTestId } = render(<WorkspaceUpdater />);
const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText');
fireEvent.input(nameInput, {
target: { value: '~' },
});
expect(workspaceClientUpdate).not.toHaveBeenCalled();
});

it('update workspace successfully', async () => {
const { getByTestId, getByText } = render(<WorkspaceUpdater />);
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(getByText('Users & Permissions'));
fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew'));
const userIdInput = getByTestId('workspaceForm-permissionSettingPanel-0-userId');
fireEvent.click(userIdInput);
fireEvent.input(getByTestId('comboBoxSearchInput'), {
target: { value: 'test user id' },
});
fireEvent.blur(getByTestId('comboBoxSearchInput'));

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']),
}),
expect.arrayContaining([expect.objectContaining({ type: 'user', userId: 'test user id' })])
);
await waitFor(() => {
expect(notificationToastsAddSuccess).toHaveBeenCalled();
});
expect(notificationToastsAddDanger).not.toHaveBeenCalled();
});

it('should show danger toasts after update workspace failed', async () => {
workspaceClientUpdate.mockReturnValue({ result: false, success: false });
const { getByTestId } = render(<WorkspaceUpdater />);
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(<WorkspaceUpdater />);
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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* 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, WorkspacePermissionSetting } from '../workspace_form/types';

interface WorkspaceWithPermission extends WorkspaceAttribute {
permissions?: WorkspacePermissionSetting[];
}

function getFormDataFromWorkspace(
currentWorkspace: WorkspaceAttribute | null | undefined
): WorkspaceFormData {
const currentWorkspaceWithPermission = (currentWorkspace || {}) as WorkspaceWithPermission;
return {
...currentWorkspaceWithPermission,
permissions: currentWorkspaceWithPermission.permissions || [],
};
}

export const WorkspaceUpdater = () => {
const {
services: { application, workspaces, notifications, http, workspaceClient },
} = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>();

const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled;

const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null));
const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState<WorkspaceFormData>(
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 { permissions, ...attributes } = data;
result = await workspaceClient.update(currentWorkspace.id, attributes, permissions);
} 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;
}
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;
}
notifications?.toasts.addDanger({
title: i18n.translate('workspace.update.failed', {
defaultMessage: 'Failed to update workspace',
}),
text: result?.error,
});
},
[notifications?.toasts, currentWorkspace, http, application, workspaceClient]
);

if (!currentWorkspaceFormData.name) {
return null;
}

return (
<EuiPage paddingSize="none">
<EuiPageBody panelled>
<EuiPageHeader restrictWidth pageTitle="Update Workspace" />
<EuiPageContent
verticalPosition="center"
horizontalPosition="center"
paddingSize="none"
color="subdued"
hasShadow={false}
style={{ width: '100%', maxWidth: 1000 }}
>
{application && (
<WorkspaceForm
application={application}
defaultValues={currentWorkspaceFormData}
onSubmit={handleWorkspaceFormSubmit}
operationType={WorkspaceOperationType.Update}
permissionEnabled={isPermissionEnabled}
permissionLastAdminItemDeletable
/>
)}
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
};
Loading

0 comments on commit d66c8fc

Please sign in to comment.