Skip to content

Commit

Permalink
Add workspace create page (opensearch-project#284)
Browse files Browse the repository at this point in the history
* Add workspace create page

Signed-off-by: Lin Wang <[email protected]>

* Address PR comments

Signed-off-by: Lin Wang <[email protected]>

* Add more comments

Signed-off-by: Lin Wang <[email protected]>

* Add example for dependencies field in App

Signed-off-by: Lin Wang <[email protected]>

* Separate workspace feature selector

Signed-off-by: Lin Wang <[email protected]>

* Correct example for dependencies

Signed-off-by: Lin Wang <[email protected]>

* Remove unclear icon and defaultVISTheme input

Signed-off-by: Lin Wang <[email protected]>

* Remove unclear dependencies feature

Signed-off-by: Lin Wang <[email protected]>

* Remove states and fix onChange fired after mount

Signed-off-by: Lin Wang <[email protected]>

---------

Signed-off-by: Lin Wang <[email protected]>
  • Loading branch information
wanglam authored Mar 15, 2024
1 parent f7009e6 commit 540114b
Show file tree
Hide file tree
Showing 21 changed files with 1,836 additions and 2 deletions.
2 changes: 2 additions & 0 deletions src/plugins/workspace/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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';
// These features will be checked and disabled in checkbox on default.
export const DEFAULT_CHECKED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE_OVERVIEW_APP_ID];
export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error';
export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace';
export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID =
Expand Down
14 changes: 14 additions & 0 deletions src/plugins/workspace/public/application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,22 @@ 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 { 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,226 @@
/*
* 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,
capabilities: {
...mockCoreStart.application.capabilities,
workspaces: {
permissionEnabled: true,
},
},
navigateToApp,
getUrlForApp: jest.fn(),
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/',
set: setHrefSpy,
});
});

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

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

it('cannot 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('cannot 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'
);
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',
}),
expect.any(Array)
);
await waitFor(() => {
expect(notificationToastsAddSuccess).toHaveBeenCalled();
});
expect(notificationToastsAddDanger).not.toHaveBeenCalled();
});

it('create workspace with customized features', async () => {
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'));
fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton'));
expect(workspaceClientCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test workspace name',
features: expect.arrayContaining(['app1', 'app2', 'app3']),
}),
expect.any(Array)
);
await waitFor(() => {
expect(notificationToastsAddSuccess).toHaveBeenCalled();
});
expect(notificationToastsAddDanger).not.toHaveBeenCalled();
});

it('create workspace with customized permissions', async () => {
const { getByTestId, getByText } = render(<WorkspaceCreator />);
const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText');
fireEvent.input(nameInput, {
target: { value: 'test workspace name' },
});
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-createButton'));
expect(workspaceClientCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test workspace name',
}),
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 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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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 isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled;
const handleWorkspaceFormSubmit = useCallback(
async (data: WorkspaceFormSubmitData) => {
let result;
try {
const { permissions, ...attributes } = data;
result = await workspaceClient.create(attributes, permissions);
} catch (error) {
notifications?.toasts.addDanger({
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: 100 below to align with the above page header
**/
style={{ width: '100%', maxWidth: 1000 }}
>
{application && (
<WorkspaceForm
application={application}
onSubmit={handleWorkspaceFormSubmit}
operationType={WorkspaceOperationType.Create}
permissionEnabled={isPermissionEnabled}
permissionLastAdminItemDeletable
/>
)}
</EuiPageContent>
</EuiPageBody>
</EuiPage>
);
};
35 changes: 35 additions & 0 deletions src/plugins/workspace/public/components/workspace_creator_app.tsx
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

0 comments on commit 540114b

Please sign in to comment.