Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Workspace]Add create workspace page #6179

Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231))
- [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171))
- [Multiple Datasource] 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))

### 🐛 Bug Fixes

Expand Down
6 changes: 6 additions & 0 deletions src/plugins/workspace/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error';
export const WORKSPACE_CREATE_APP_ID = 'workspace_create';
export const WORKSPACE_LIST_APP_ID = 'workspace_list';
export const WORKSPACE_UPDATE_APP_ID = 'workspace_update';
export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview';
/**
* Since every workspace always have overview and update page, these features will be selected by default
* and can't be changed in the workspace form feature selector
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of display them in disabled state, shall we just hide them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that means hide them on the workspace form feature selector?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I think this makes more sense to me, these are workspace fundamentals that each workspace should have.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

*/
export const DEFAULT_SELECTED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE_OVERVIEW_APP_ID];
export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace';
export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID =
'workspace_conflict_control';
14 changes: 14 additions & 0 deletions src/plugins/workspace/public/application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ import ReactDOM from 'react-dom';
import { AppMountParameters, ScopedHistory } from '../../../core/public';
import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public';
import { WorkspaceFatalError } from './components/workspace_fatal_error';
import { WorkspaceCreatorApp } from './components/workspace_creator_app';
import { Services } from './types';

export const renderCreatorApp = ({ element }: AppMountParameters, services: Services) => {
ReactDOM.render(
<OpenSearchDashboardsContextProvider services={services}>
<WorkspaceCreatorApp />
</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
@@ -0,0 +1,6 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export { WorkspaceCreator } from './workspace_creator';
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { PublicAppInfo } from 'opensearch-dashboards/public';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { BehaviorSubject } from 'rxjs';
import { WorkspaceCreator as WorkspaceCreatorComponent } from './workspace_creator';
import { coreMock } from '../../../../../core/public/mocks';
import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public';

const workspaceClientCreate = jest
.fn()
.mockReturnValue({ result: { id: 'successResult' }, success: true });

const navigateToApp = jest.fn();
const notificationToastsAddSuccess = jest.fn();
const notificationToastsAddDanger = jest.fn();
const PublicAPPInfoMap = new Map([
['app1', { id: 'app1', title: 'app1' }],
['app2', { id: 'app2', title: 'app2', category: { id: 'category1', label: 'category1' } }],
['app3', { id: 'app3', category: { id: 'category1', label: 'category1' } }],
['app4', { id: 'app4', category: { id: 'category2', label: 'category2' } }],
['app5', { id: 'app5', category: { id: 'category2', label: 'category2' } }],
]);

const mockCoreStart = coreMock.createStart();

const WorkspaceCreator = (props: any) => {
const { Provider } = createOpenSearchDashboardsReactContext({
...mockCoreStart,
...{
application: {
...mockCoreStart.application,
navigateToApp,
getUrlForApp: jest.fn(() => '/app/workspace_overview'),
applications$: new BehaviorSubject<Map<string, PublicAppInfo>>(PublicAPPInfoMap as any),
},
notifications: {
...mockCoreStart.notifications,
toasts: {
...mockCoreStart.notifications.toasts,
addDanger: notificationToastsAddDanger,
addSuccess: notificationToastsAddSuccess,
},
},
workspaceClient: {
...mockCoreStart.workspaces,
create: workspaceClientCreate,
},
},
});

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

function clearMockedFunctions() {
workspaceClientCreate.mockClear();
notificationToastsAddDanger.mockClear();
notificationToastsAddSuccess.mockClear();
}

describe('WorkspaceCreator', () => {
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/w/workspace/app/workspace_create',
set: setHrefSpy,
});
});

afterAll(() => {
window.location = location;
});

it('should not create workspace when name is empty', async () => {
const { getByTestId } = render(<WorkspaceCreator />);
fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton'));
expect(workspaceClientCreate).not.toHaveBeenCalled();
});

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

it('should not create workspace with invalid description', async () => {
const { getByTestId } = render(<WorkspaceCreator />);
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(workspaceClientCreate).not.toHaveBeenCalled();
});

it('cancel create workspace', async () => {
const { findByText, getByTestId } = render(<WorkspaceCreator />);
fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton'));
await findByText('Discard changes?');
fireEvent.click(getByTestId('confirmModalConfirmButton'));
expect(navigateToApp).toHaveBeenCalled();
});

it('create workspace with detailed information', async () => {
const { getByTestId } = render(<WorkspaceCreator />);
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'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is testId euiColorPickerAnchor-workspaceForm-workspaceDetails-colorPicker?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. The test-id should be euiColorPickerAnchor workspaceForm-workspaceDetails-colorPicker. The color picker will be append customized test id. Here is the code: https://github.com/opensearch-project/oui/blob/main/src/components/color_picker/color_picker.tsx#L272 Shall we remove the customized test-id, just use euiColorPickerAnchor directly?

);
fireEvent.input(colorSelector, {
target: { value: '#000000' },
});
fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton'));
expect(workspaceClientCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test workspace name',
color: '#000000',
description: 'test workspace description',
})
);
await waitFor(() => {
expect(notificationToastsAddSuccess).toHaveBeenCalled();
});
expect(notificationToastsAddDanger).not.toHaveBeenCalled();
});

it('create workspace with customized features', async () => {
setHrefSpy.mockReset();
const { getByTestId } = render(<WorkspaceCreator />);
const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText');
fireEvent.input(nameInput, {
target: { value: 'test workspace name' },
});
fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1'));
fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1'));
expect(setHrefSpy).not.toHaveBeenCalled();
fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton'));
expect(workspaceClientCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test workspace name',
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 create workspace failed', async () => {
workspaceClientCreate.mockReturnValue({ result: { id: 'failResult' }, success: false });
const { getByTestId } = render(<WorkspaceCreator />);
const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText');
fireEvent.input(nameInput, {
target: { value: 'test workspace name' },
});
fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton'));
expect(workspaceClientCreate).toHaveBeenCalled();
await waitFor(() => {
expect(notificationToastsAddDanger).toHaveBeenCalled();
});
expect(notificationToastsAddSuccess).not.toHaveBeenCalled();
});

it('should show danger toasts after call create workspace API failed', async () => {
workspaceClientCreate.mockImplementation(async () => {
throw new Error();
});
const { getByTestId } = render(<WorkspaceCreator />);
const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText');
fireEvent.input(nameInput, {
target: { value: 'test workspace name' },
});
fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton'));
expect(workspaceClientCreate).toHaveBeenCalled();
await waitFor(() => {
expect(notificationToastsAddDanger).toHaveBeenCalled();
});
expect(notificationToastsAddSuccess).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useCallback } from 'react';
import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent, EuiSpacer } from '@elastic/eui';
import { i18n } from '@osd/i18n';
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';

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

const handleWorkspaceFormSubmit = useCallback(
async (data: WorkspaceFormSubmitData) => {
let result;
try {
result = await workspaceClient.create(data);
} catch (error) {
notifications?.toasts.addDanger({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add tests for cover error handling, such as server errors or unexpected responses here. thanks

title: i18n.translate('workspace.create.failed', {
defaultMessage: 'Failed to create workspace',
}),
text: error instanceof Error ? error.message : JSON.stringify(error),
});
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]
);

return (
<EuiPage paddingSize="none">
<EuiPageBody>
<EuiPageHeader restrictWidth pageTitle="Create Workspace" />
<EuiSpacer />
<EuiPageContent
verticalPosition="center"
horizontalPosition="center"
paddingSize="none"
color="subdued"
hasShadow={false}
/**
* Since above EuiPageHeader has a maxWidth: 1000 style,
* add maxWidth: 1000 below to align with the above page header
**/
style={{ width: '100%', maxWidth: 1000 }}
>
{application && (
<WorkspaceForm
application={application}
onSubmit={handleWorkspaceFormSubmit}
operationType={WorkspaceOperationType.Create}
/>
)}
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
};
Original file line number Diff line number Diff line change
@@ -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 { WorkspaceCreator } from './workspace_creator';

export const WorkspaceCreatorApp = () => {
const {
services: { chrome },
} = useOpenSearchDashboards();

/**
* set breadcrumbs to chrome
*/
useEffect(() => {
chrome?.setBreadcrumbs([
{
text: i18n.translate('workspace.workspaceCreateTitle', {
defaultMessage: 'Create workspace',
}),
},
]);
}, [chrome]);

return (
<I18nProvider>
<WorkspaceCreator />
</I18nProvider>
);
};
Loading
Loading