diff --git a/CHANGELOG.md b/CHANGELOG.md index 665b35e8bc9d..b00f9c3ae93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index d2da08acb52d..91db9f37fc40 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -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 + */ +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'; diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index f70c627e02b0..66672561616a 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -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( + + + , + 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/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx new file mode 100644 index 000000000000..c8cdbfab65be --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceCreator } from './workspace_creator'; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx new file mode 100644 index 000000000000..9cc4f9b53f69 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -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>(PublicAPPInfoMap as any), + }, + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + ...mockCoreStart.workspaces, + create: workspaceClientCreate, + }, + }, + }); + + return ( + + + + ); +}; + +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(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('should not create workspace with invalid name', async () => { + const { getByTestId } = render(); + 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(); + 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(); + 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(); + 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', + }) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized features', async () => { + setHrefSpy.mockReset(); + const { getByTestId } = render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx new file mode 100644 index 000000000000..83d0f6675fe6 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -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({ + 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 ( + + + + + + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx new file mode 100644 index 000000000000..b74359929352 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator_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 { 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 ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts new file mode 100644 index 000000000000..83ae111e9c20 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum WorkspaceOperationType { + Create = 'create', + Update = 'update', +} + +export enum WorkspaceFormTabs { + NotSelected, + FeatureVisibility, +} diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts new file mode 100644 index 000000000000..6531d4a1c6f7 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceForm } from './workspace_form'; +export { WorkspaceFormSubmitData } from './types'; +export { WorkspaceOperationType } from './constants'; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts new file mode 100644 index 000000000000..8014c2321ad5 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WorkspaceOperationType } from './constants'; +import type { ApplicationStart } from '../../../../../core/public'; + +export interface WorkspaceFormSubmitData { + name: string; + description?: string; + features?: string[]; + color?: string; +} + +export interface WorkspaceFormData extends WorkspaceFormSubmitData { + id: string; + reserved?: boolean; +} + +export interface WorkspaceFeature { + id: string; + name: string; +} + +export interface WorkspaceFeatureGroup { + name: string; + features: WorkspaceFeature[]; +} + +export type WorkspaceFormErrors = { [key in keyof WorkspaceFormData]?: string }; + +export interface WorkspaceFormProps { + application: ApplicationStart; + onSubmit?: (formData: WorkspaceFormSubmitData) => void; + defaultValues?: WorkspaceFormData; + operationType?: WorkspaceOperationType; +} diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts new file mode 100644 index 000000000000..2bbef2d62fbd --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { applicationServiceMock } from '../../../../../core/public/mocks'; +import { WorkspaceFormData } from './types'; +import { useWorkspaceForm } from './use_workspace_form'; + +const setup = (defaultValues?: WorkspaceFormData) => { + const onSubmitMock = jest.fn(); + const renderResult = renderHook(useWorkspaceForm, { + initialProps: { + application: applicationServiceMock.createStartContract(), + defaultValues, + onSubmit: onSubmitMock, + }, + }); + return { + renderResult, + onSubmitMock, + }; +}; + +describe('useWorkspaceForm', () => { + it('should return "Invalid workspace name" and not call onSubmit when invalid name', async () => { + const { renderResult, onSubmitMock } = setup({ + id: 'foo', + name: '~', + }); + expect(renderResult.result.current.formErrors).toEqual({}); + + act(() => { + renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); + }); + expect(renderResult.result.current.formErrors).toEqual({ + name: 'Invalid workspace name', + }); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + it('should return "Invalid workspace description" and not call onSubmit when invalid description', async () => { + const { renderResult, onSubmitMock } = setup({ + id: 'foo', + name: 'test-workspace-name', + description: '~', + }); + expect(renderResult.result.current.formErrors).toEqual({}); + + act(() => { + renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); + }); + expect(renderResult.result.current.formErrors).toEqual({ + description: 'Invalid workspace description', + }); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + it('should call onSubmit with workspace name and features', async () => { + const { renderResult, onSubmitMock } = setup({ + id: 'foo', + name: 'test-workspace-name', + }); + expect(renderResult.result.current.formErrors).toEqual({}); + + act(() => { + renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); + }); + expect(onSubmitMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-workspace-name', + features: ['workspace_update', 'workspace_overview'], + }) + ); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts new file mode 100644 index 000000000000..315f3486f83e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useState, FormEventHandler, useRef, useMemo, useEffect } from 'react'; +import { htmlIdGenerator, EuiFieldTextProps, EuiColorPickerProps } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useApplications } from '../../hooks'; +import { featureMatchesConfig } from '../../utils'; + +import { WorkspaceFormTabs } from './constants'; +import { WorkspaceFormProps, WorkspaceFormErrors } from './types'; +import { appendDefaultFeatureIds, getNumberOfErrors, isValidFormTextInput } from './utils'; + +const workspaceHtmlIdGenerator = htmlIdGenerator(); + +export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: WorkspaceFormProps) => { + const applications = useApplications(application); + const [name, setName] = useState(defaultValues?.name); + const [description, setDescription] = useState(defaultValues?.description); + const [color, setColor] = useState(defaultValues?.color); + + const [selectedTab, setSelectedTab] = useState(WorkspaceFormTabs.FeatureVisibility); + // The matched feature id list based on original feature config, + // the feature category will be expanded to list of feature ids + const defaultFeatures = useMemo(() => { + // The original feature list, may contain feature id and category wildcard like @management, etc. + const defaultOriginalFeatures = defaultValues?.features ?? []; + return applications.filter(featureMatchesConfig(defaultOriginalFeatures)).map((app) => app.id); + }, [defaultValues?.features, applications]); + + const defaultFeaturesRef = useRef(defaultFeatures); + defaultFeaturesRef.current = defaultFeatures; + + const [selectedFeatureIds, setSelectedFeatureIds] = useState( + appendDefaultFeatureIds(defaultFeatures) + ); + + const [formErrors, setFormErrors] = useState({}); + const numberOfErrors = useMemo(() => getNumberOfErrors(formErrors), [formErrors]); + const formIdRef = useRef(); + const getFormData = () => ({ + name, + description, + features: selectedFeatureIds, + color, + }); + const getFormDataRef = useRef(getFormData); + getFormDataRef.current = getFormData; + + if (!formIdRef.current) { + formIdRef.current = workspaceHtmlIdGenerator(); + } + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + let currentFormErrors: WorkspaceFormErrors = {}; + const formData = getFormDataRef.current(); + if (!formData.name) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.empty', { + defaultMessage: "Name can't be empty.", + }), + }; + } + if (formData.name && !isValidFormTextInput(formData.name)) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.invalid', { + defaultMessage: 'Invalid workspace name', + }), + }; + } + if (formData.description && !isValidFormTextInput(formData.description)) { + currentFormErrors = { + ...currentFormErrors, + description: i18n.translate('workspace.form.detail.description.invalid', { + defaultMessage: 'Invalid workspace description', + }), + }; + } + setFormErrors(currentFormErrors); + if (getNumberOfErrors(currentFormErrors) > 0) { + return; + } + + const featureConfigChanged = + formData.features.length !== defaultFeatures.length || + formData.features.some((feat) => !defaultFeatures.includes(feat)); + + if (!featureConfigChanged) { + // If feature config not changed, set workspace feature config to the original value. + // The reason why we do this is when a workspace feature is configured by wildcard, + // such as `['@management']` or `['*']`. The form value `formData.features` will be + // expanded to array of individual feature id, if the feature hasn't changed, we will + // set the feature config back to the original value so that category wildcard won't + // expanded to feature ids + formData.features = defaultValues?.features ?? []; + } + + onSubmit?.({ ...formData, name: formData.name! }); + }, + [defaultFeatures, onSubmit, defaultValues?.features] + ); + + const handleNameInputChange = useCallback['onChange']>((e) => { + setName(e.target.value); + }, []); + + const handleDescriptionInputChange = useCallback['onChange']>((e) => { + setDescription(e.target.value); + }, []); + + const handleColorChange = useCallback['onChange']>((text) => { + setColor(text); + }, []); + + const handleTabFeatureClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.FeatureVisibility); + }, []); + + const handleFeaturesChange = useCallback((featureIds: string[]) => { + setSelectedFeatureIds(featureIds); + }, []); + + useEffect(() => { + // When applications changed, reset form feature selection to original value + setSelectedFeatureIds(appendDefaultFeatureIds(defaultFeaturesRef.current)); + }, [applications]); + + return { + formId: formIdRef.current, + formData: getFormData(), + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + handleDescriptionInputChange, + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts new file mode 100644 index 000000000000..6101bd078831 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES } from '../../../../../core/public'; +import { convertApplicationsToFeaturesOrGroups } from './utils'; + +describe('convertApplicationsToFeaturesOrGroups', () => { + it('should filter out invisible features', () => { + expect( + convertApplicationsToFeaturesOrGroups([ + { id: 'foo1', title: 'Foo 1', navLinkStatus: AppNavLinkStatus.hidden }, + { id: 'foo2', title: 'Foo 2', navLinkStatus: AppNavLinkStatus.visible, chromeless: true }, + { + id: 'foo3', + title: 'Foo 3', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.management, + }, + { + id: 'workspace_overview', + title: 'Workspace Overview', + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'bar', + title: 'Bar', + navLinkStatus: AppNavLinkStatus.visible, + }, + ]) + ).toEqual([ + { + id: 'bar', + name: 'Bar', + }, + ]); + }); + it('should group same category applications in same feature group', () => { + expect( + convertApplicationsToFeaturesOrGroups([ + { + id: 'foo', + title: 'Foo', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }, + { + id: 'bar', + title: 'Bar', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }, + { + id: 'baz', + title: 'Baz', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.observability, + }, + ]) + ).toEqual([ + { + name: 'OpenSearch Dashboards', + features: [ + { + id: 'foo', + name: 'Foo', + }, + { + id: 'bar', + name: 'Bar', + }, + ], + }, + { + name: 'Observability', + features: [ + { + id: 'baz', + name: 'Baz', + }, + ], + }, + ]); + }); + it('should return features if application without category', () => { + expect( + convertApplicationsToFeaturesOrGroups([ + { + id: 'foo', + title: 'Foo', + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'baz', + title: 'Baz', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.observability, + }, + { + id: 'bar', + title: 'Bar', + navLinkStatus: AppNavLinkStatus.visible, + }, + ]) + ).toEqual([ + { + id: 'foo', + name: 'Foo', + }, + { + id: 'bar', + name: 'Bar', + }, + { + name: 'Observability', + features: [ + { + id: 'baz', + name: 'Baz', + }, + ], + }, + ]); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts new file mode 100644 index 000000000000..5514e6a8fb9c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -0,0 +1,100 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AppNavLinkStatus, + DEFAULT_APP_CATEGORIES, + PublicAppInfo, +} from '../../../../../core/public'; +import { DEFAULT_SELECTED_FEATURES_IDS } from '../../../common/constants'; + +import { WorkspaceFeature, WorkspaceFeatureGroup, WorkspaceFormErrors } from './types'; + +export const isWorkspaceFeatureGroup = ( + featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup +): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; + +export const appendDefaultFeatureIds = (ids: string[]) => { + // concat default checked ids and unique the result + return Array.from(new Set(ids.concat(DEFAULT_SELECTED_FEATURES_IDS))); +}; + +export const isValidFormTextInput = (input?: string) => { + /** + * This regular expression is from the workspace form name and description field UI. + * It only accepts below characters. + **/ + const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; + return typeof input === 'string' && regex.test(input); +}; + +export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { + let numberOfErrors = 0; + if (formErrors.name) { + numberOfErrors += 1; + } + if (formErrors.description) { + numberOfErrors += 1; + } + return numberOfErrors; +}; + +export const convertApplicationsToFeaturesOrGroups = ( + applications: Array< + Pick + > +) => { + const UNDEFINED = 'undefined'; + + // Filter out all hidden applications and management applications and default selected features + const visibleApplications = applications.filter( + ({ navLinkStatus, chromeless, category, id }) => + navLinkStatus !== AppNavLinkStatus.hidden && + !chromeless && + !DEFAULT_SELECTED_FEATURES_IDS.includes(id) && + category?.id !== DEFAULT_APP_CATEGORIES.management.id + ); + + /** + * + * Convert applications to features map, the map use category label as + * map key and group all same category applications in one array after + * transfer application to feature. + * + **/ + const categoryLabel2Features = visibleApplications.reduce<{ + [key: string]: WorkspaceFeature[]; + }>((previousValue, application) => { + const label = application.category?.label || UNDEFINED; + + return { + ...previousValue, + [label]: [...(previousValue[label] || []), { id: application.id, name: application.title }], + }; + }, {}); + + /** + * + * Iterate all keys of categoryLabel2Features map, convert map to features or groups array. + * Features with category label will be converted to feature groups. Features without "undefined" + * category label will be converted to single features. Then append them to the result array. + * + **/ + return Object.keys(categoryLabel2Features).reduce< + Array + >((previousValue, categoryLabel) => { + const features = categoryLabel2Features[categoryLabel]; + if (categoryLabel === UNDEFINED) { + return [...previousValue, ...features]; + } + return [ + ...previousValue, + { + name: categoryLabel, + features, + }, + ]; + }, []); +}; 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 new file mode 100644 index 000000000000..7e528d2214ee --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx @@ -0,0 +1,110 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBottomBar, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WorkspaceOperationType } from '../workspace_form'; +import { WorkspaceCancelModal } from './workspace_cancel_modal'; + +interface WorkspaceBottomBarProps { + formId: string; + operationType?: WorkspaceOperationType; + numberOfErrors: number; + application: ApplicationStart; + numberOfUnSavedChanges?: number; +} + +export const WorkspaceBottomBar = ({ + formId, + operationType, + numberOfErrors, + numberOfUnSavedChanges, + application, +}: WorkspaceBottomBarProps) => { + const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); + const closeCancelModal = () => setIsCancelModalVisible(false); + const showCancelModal = () => setIsCancelModalVisible(true); + + return ( +
+ + + + + + + {operationType === WorkspaceOperationType.Update ? ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '{numberOfUnSavedChanges} Unsaved change(s)', + values: { + numberOfUnSavedChanges, + }, + })} + + ) : ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: '{numberOfErrors} Error(s)', + values: { + numberOfErrors, + }, + })} + + )} + + + + + + {i18n.translate('workspace.form.bottomBar.cancel', { + defaultMessage: 'Cancel', + })} + + + {operationType === WorkspaceOperationType.Create && ( + + {i18n.translate('workspace.form.bottomBar.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + )} + {operationType === WorkspaceOperationType.Update && ( + + {i18n.translate('workspace.form.bottomBar.saveChanges', { + defaultMessage: 'Save changes', + })} + + )} + + + + + {isCancelModalVisible && ( + + )} +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx new file mode 100644 index 000000000000..11e835087cd6 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiConfirmModal } from '@elastic/eui'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; + +interface WorkspaceCancelModalProps { + application: ApplicationStart; + closeCancelModal: () => void; +} + +export const WorkspaceCancelModal = ({ + application, + closeCancelModal, +}: WorkspaceCancelModalProps) => { + return ( + application?.navigateToApp(WORKSPACE_LIST_APP_ID)} + cancelButtonText={i18n.translate('workspace.form.cancelButtonText', { + defaultMessage: 'Continue editing', + })} + confirmButtonText={i18n.translate('workspace.form.confirmButtonText', { + defaultMessage: 'Discard changes', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('workspace.form.cancelModal.body', { + defaultMessage: 'This will discard all changes. Are you sure?', + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx new file mode 100644 index 000000000000..0875b0d1ff10 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { + WorkspaceFeatureSelector, + WorkspaceFeatureSelectorProps, +} from './workspace_feature_selector'; +import { AppNavLinkStatus } from '../../../../../core/public'; + +const setup = (options?: Partial) => { + const onChangeMock = jest.fn(); + const applications = [ + { + id: 'app-1', + title: 'App 1', + category: { id: 'category-1', label: 'Category 1' }, + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'app-2', + title: 'App 2', + category: { id: 'category-1', label: 'Category 1' }, + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'app-3', + title: 'App 3', + category: { id: 'category-2', label: 'Category 2' }, + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'app-4', + title: 'App 4', + navLinkStatus: AppNavLinkStatus.visible, + }, + ]; + const renderResult = render( + + ); + return { + renderResult, + onChangeMock, + }; +}; + +describe('WorkspaceFeatureSelector', () => { + it('should call onChange with clicked feature', () => { + const { renderResult, onChangeMock } = setup(); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('App 1')); + expect(onChangeMock).toHaveBeenCalledWith(['app-1']); + }); + it('should call onChange with empty array after selected feature clicked', () => { + const { renderResult, onChangeMock } = setup({ + selectedFeatures: ['app-2'], + }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('App 2')); + expect(onChangeMock).toHaveBeenCalledWith([]); + }); + it('should call onChange with features under clicked group', () => { + const { renderResult, onChangeMock } = setup(); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click( + renderResult.getByTestId('workspaceForm-workspaceFeatureVisibility-Category 1') + ); + expect(onChangeMock).toHaveBeenCalledWith(['app-1', 'app-2']); + }); + it('should call onChange without features under clicked group when group already selected', () => { + const { renderResult, onChangeMock } = setup({ + selectedFeatures: ['app-1', 'app-2', 'app-3'], + }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click( + renderResult.getByTestId('workspaceForm-workspaceFeatureVisibility-Category 1') + ); + expect(onChangeMock).toHaveBeenCalledWith(['app-3']); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx new file mode 100644 index 000000000000..da9deb174f52 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx @@ -0,0 +1,143 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiText, + EuiFlexItem, + EuiCheckbox, + EuiCheckboxGroup, + EuiFlexGroup, + EuiCheckboxGroupProps, + EuiCheckboxProps, +} from '@elastic/eui'; + +import { PublicAppInfo } from '../../../../../core/public'; + +import { isWorkspaceFeatureGroup, convertApplicationsToFeaturesOrGroups } from './utils'; + +export interface WorkspaceFeatureSelectorProps { + applications: Array< + Pick + >; + selectedFeatures: string[]; + onChange: (newFeatures: string[]) => void; +} + +export const WorkspaceFeatureSelector = ({ + applications, + selectedFeatures, + onChange, +}: WorkspaceFeatureSelectorProps) => { + const featuresOrGroups = useMemo(() => convertApplicationsToFeaturesOrGroups(applications), [ + applications, + ]); + + const handleFeatureChange = useCallback( + (featureId) => { + if (!selectedFeatures.includes(featureId)) { + onChange([...selectedFeatures, featureId]); + return; + } + onChange(selectedFeatures.filter((selectedId) => selectedId !== featureId)); + }, + [selectedFeatures, onChange] + ); + + const handleFeatureCheckboxChange = useCallback( + (e) => { + handleFeatureChange(e.target.id); + }, + [handleFeatureChange] + ); + + const handleFeatureGroupChange = useCallback( + (e) => { + const featureOrGroup = featuresOrGroups.find( + (item) => isWorkspaceFeatureGroup(item) && item.name === e.target.id + ); + if (!featureOrGroup || !isWorkspaceFeatureGroup(featureOrGroup)) { + return; + } + const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); + const notExistsIds = groupFeatureIds.filter((id) => !selectedFeatures.includes(id)); + // Check all not selected features if not been selected in current group. + if (notExistsIds.length > 0) { + onChange([...selectedFeatures, ...notExistsIds]); + return; + } + // Need to un-check these features, if all features in group has been selected + onChange(selectedFeatures.filter((featureId) => !groupFeatureIds.includes(featureId))); + }, + [featuresOrGroups, selectedFeatures, onChange] + ); + + return ( + <> + {featuresOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; + const selectedIds = selectedFeatures.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.features + : [featureOrGroup] + ).find((item) => item.id === id) + ); + const featureOrGroupId = isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.name + : featureOrGroup.id; + + return ( + + +
+ + {featureOrGroup.name} + +
+
+ + 0 ? ` (${selectedIds.length}/${features.length})` : '' + }`} + checked={selectedIds.length > 0} + indeterminate={ + isWorkspaceFeatureGroup(featureOrGroup) && + selectedIds.length > 0 && + selectedIds.length < features.length + } + data-test-subj={`workspaceForm-workspaceFeatureVisibility-${featureOrGroupId}`} + /> + {isWorkspaceFeatureGroup(featureOrGroup) && ( + ({ + id: item.id, + label: item.name, + }))} + idToSelectedMap={selectedIds.reduce( + (previousValue, currentValue) => ({ + ...previousValue, + [currentValue]: true, + }), + {} + )} + onChange={handleFeatureChange} + style={{ marginLeft: 40 }} + data-test-subj={`workspaceForm-workspaceFeatureVisibility-featureWithCategory-${featureOrGroupId}`} + /> + )} + +
+ ); + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx new file mode 100644 index 000000000000..69793c75395d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -0,0 +1,151 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiText, + EuiColorPicker, + EuiHorizontalRule, + EuiTab, + EuiTabs, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +import { WorkspaceBottomBar } from './workspace_bottom_bar'; +import { WorkspaceFormProps } from './types'; +import { WorkspaceFormTabs } from './constants'; +import { useWorkspaceForm } from './use_workspace_form'; +import { WorkspaceFeatureSelector } from './workspace_feature_selector'; + +export const WorkspaceForm = (props: WorkspaceFormProps) => { + const { application, defaultValues, operationType } = props; + const { + formId, + formData, + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + handleDescriptionInputChange, + } = useWorkspaceForm(props); + const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { + defaultMessage: 'Workspace Details', + }); + const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { + defaultMessage: 'Feature Visibility', + }); + + return ( + + + +

{workspaceDetailsTitle}

+
+ + + + + + + Description - optional + + } + helpText={i18n.translate('workspace.form.workspaceDetails.description.helpText', { + defaultMessage: + 'Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', + })} + isInvalid={!!formErrors.description} + error={formErrors.description} + > + + + +
+ + {i18n.translate('workspace.form.workspaceDetails.color.helpText', { + defaultMessage: 'Accent color for your workspace', + })} + + + +
+
+
+ + + + + {featureVisibilityTitle} + + + {selectedTab === WorkspaceFormTabs.FeatureVisibility && ( + + +

{featureVisibilityTitle}

+
+ + + +
+ )} + + +
+ ); +}; diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts new file mode 100644 index 000000000000..875e9b494f23 --- /dev/null +++ b/src/plugins/workspace/public/hooks.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { useMemo } from 'react'; + +export function useApplications(applicationInstance: ApplicationStart) { + const applications = useObservable(applicationInstance.applications$); + return useMemo(() => { + const apps: PublicAppInfo[] = []; + applications?.forEach((app) => { + apps.push(app); + }); + return apps; + }, [applications]); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 2306c460b88d..0ec007079c24 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -23,6 +23,7 @@ describe('Workspace plugin', () => { const setupMock = getSetupMock(); const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(2); expect(WorkspaceClientMock).toBeCalledTimes(1); }); @@ -34,7 +35,7 @@ describe('Workspace plugin', () => { workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); - expect(setupMock.application.register).toBeCalledTimes(1); + expect(setupMock.application.register).toBeCalledTimes(2); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); @@ -69,7 +70,7 @@ describe('Workspace plugin', () => { const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); - 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 f0c82bda90b7..eeaab74e8e44 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -5,6 +5,7 @@ import type { Subscription } from 'rxjs'; import React from 'react'; +import { i18n } from '@osd/i18n'; import { Plugin, CoreStart, @@ -12,7 +13,11 @@ import { AppMountParameters, AppNavLinkStatus, } 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_CREATE_APP_ID, +} from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; @@ -89,6 +94,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { return renderApp(params, services); }; + // create + core.application.register({ + id: WORKSPACE_CREATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceCreate', { + defaultMessage: 'Create Workspace', + }), + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderCreatorApp } = await import('./application'); + return mountWorkspaceApp(params, renderCreatorApp); + }, + }); + // workspace fatal error core.application.register({ id: WORKSPACE_FATAL_ERROR_APP_ID, diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts new file mode 100644 index 000000000000..510a775cd745 --- /dev/null +++ b/src/plugins/workspace/public/utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { featureMatchesConfig } from './utils'; + +describe('workspace utils: featureMatchesConfig', () => { + it('feature configured with `*` should match any features', () => { + const match = featureMatchesConfig(['*']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the config if feature id not matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match the config if feature id matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should match the config if feature category matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', '@management', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('should match any features but not the excluded feature id', () => { + const match = featureMatchesConfig(['*', '!discover']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(false); + }); + + it('should match any features but not the excluded feature category', () => { + const match = featureMatchesConfig(['*', '!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the excluded feature category', () => { + const match = featureMatchesConfig(['!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match features of a category but NOT the excluded feature', () => { + const match = featureMatchesConfig(['@management', '!dev_tools']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('a config presents later in the config array should override the previous config', () => { + // though `dev_tools` is excluded, but this config will override by '@management' as dev_tools has category 'management' + const match = featureMatchesConfig(['!dev_tools', '@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts new file mode 100644 index 000000000000..444b3aadadf3 --- /dev/null +++ b/src/plugins/workspace/public/utils.ts @@ -0,0 +1,57 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppCategory } from '../../../core/public'; + +/** + * Checks if a given feature matches the provided feature configuration. + * + * Rules: + * 1. `*` matches any feature. + * 2. Config starts with `@` matches category, for example, @management matches any feature of `management` category, + * 3. To match a specific feature, use the feature id, such as `discover`, + * 4. To exclude a feature or category, prepend with `!`, e.g., `!discover` or `!@management`. + * 5. The order of featureConfig array matters. From left to right, later configs override the previous ones. + * For example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management'. + */ +export const featureMatchesConfig = (featureConfigs: string[]) => ({ + id, + category, +}: { + id: string; + category?: AppCategory; +}) => { + let matched = false; + + for (const featureConfig of featureConfigs) { + // '*' matches any feature + if (featureConfig === '*') { + matched = true; + } + + // The config starts with `@` matches a category + if (category && featureConfig === `@${category.id}`) { + matched = true; + } + + // The config matches a feature id + if (featureConfig === id) { + matched = true; + } + + // If a config starts with `!`, such feature or category will be excluded + if (featureConfig.startsWith('!')) { + if (category && featureConfig === `!@${category.id}`) { + matched = false; + } + + if (featureConfig === `!${id}`) { + matched = false; + } + } + } + + return matched; +};