From 89bdd43ca38bb6355f49a2be3019020054b03729 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 18 Mar 2024 15:46:24 +0800 Subject: [PATCH 1/6] Add workspace create page Signed-off-by: Lin Wang --- src/plugins/workspace/common/constants.ts | 7 + .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 24 ++ .../components/workspace_creator/index.tsx | 6 + .../workspace_creator.test.tsx | 191 ++++++++++++++++ .../workspace_creator/workspace_creator.tsx | 93 ++++++++ .../components/workspace_creator_app.tsx | 35 +++ .../components/workspace_form/constants.ts | 14 ++ .../public/components/workspace_form/index.ts | 8 + .../public/components/workspace_form/types.ts | 38 ++++ .../workspace_form/use_workspace_form.ts | 150 +++++++++++++ .../public/components/workspace_form/utils.ts | 40 ++++ .../workspace_form/workspace_bottom_bar.tsx | 112 +++++++++ .../workspace_form/workspace_cancel_modal.tsx | 49 ++++ .../workspace_feature_selector.tsx | 212 ++++++++++++++++++ .../workspace_form/workspace_form.tsx | 151 +++++++++++++ src/plugins/workspace/public/hooks.ts | 19 ++ src/plugins/workspace/public/plugin.test.ts | 2 + src/plugins/workspace/public/plugin.ts | 36 ++- src/plugins/workspace/public/types.ts | 9 + src/plugins/workspace/public/utils.test.ts | 93 ++++++++ src/plugins/workspace/public/utils.ts | 57 +++++ 22 files changed, 1346 insertions(+), 2 deletions(-) create mode 100644 src/plugins/workspace/public/application.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/index.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/constants.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/types.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/utils.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_form.tsx create mode 100644 src/plugins/workspace/public/hooks.ts create mode 100644 src/plugins/workspace/public/types.ts create mode 100644 src/plugins/workspace/public/utils.test.ts create mode 100644 src/plugins/workspace/public/utils.ts diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index e60bb6aea0eb..fb831c9f7652 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +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 = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index f34106ab4fed..4443b7e99834 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,5 +7,5 @@ "savedObjects" ], "optionalPlugins": [], - "requiredBundles": [] + "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..8a53dcf2df13 --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +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); + }; +}; 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..bd9131e799e1 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -0,0 +1,191 @@ +/* + * 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(), + 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/', + set: setHrefSpy, + }); + }); + + afterAll(() => { + window.location = location; + }); + + it('cannot create workspace when name empty', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cannot 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('cannot 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 () => { + 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')); + 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(); + }); + + 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(); + }); +}); 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..d4237434b836 --- /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.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts new file mode 100644 index 000000000000..5c01c29071b8 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -0,0 +1,150 @@ +/* + * 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, isValidNameOrDescription } 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); + const [numberOfErrors, setNumberOfErrors] = useState(0); + // 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 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 (!isValidNameOrDescription(formData.name)) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.invalid', { + defaultMessage: 'Invalid workspace name', + }), + }; + } + if (!isValidNameOrDescription(formData.description)) { + currentFormErrors = { + ...currentFormErrors, + description: i18n.translate('workspace.form.detail.description.invalid', { + defaultMessage: 'Invalid workspace description', + }), + }; + } + const currentNumberOfErrors = getNumberOfErrors(currentFormErrors); + setFormErrors(currentFormErrors); + setNumberOfErrors(currentNumberOfErrors); + if (currentNumberOfErrors > 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.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts new file mode 100644 index 000000000000..7f9f34fa08cc --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DEFAULT_CHECKED_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 isDefaultCheckedFeatureId = (id: string) => { + return DEFAULT_CHECKED_FEATURES_IDS.indexOf(id) > -1; +}; + +export const appendDefaultFeatureIds = (ids: string[]) => { + // concat default checked ids and unique the result + return Array.from(new Set(ids.concat(DEFAULT_CHECKED_FEATURES_IDS))); +}; + +export const isValidNameOrDescription = (input?: string) => { + if (!input) { + return true; + } + const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; + return regex.test(input); +}; + +export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { + let numberOfErrors = 0; + if (formErrors.name) { + numberOfErrors += 1; + } + if (formErrors.description) { + numberOfErrors += 1; + } + return numberOfErrors; +}; 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..c55501725a52 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx @@ -0,0 +1,112 @@ +/* + * 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', + })} + + )} + + + + + +
+ ); +}; 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..040e46f9ddfc --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx @@ -0,0 +1,49 @@ +/* + * 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 { + visible: boolean; + application: ApplicationStart; + closeCancelModal: () => void; +} + +export const WorkspaceCancelModal = ({ + application, + visible, + closeCancelModal, +}: WorkspaceCancelModalProps) => { + if (!visible) { + return null; + } + + 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.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx new file mode 100644 index 000000000000..61181a7a749e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx @@ -0,0 +1,212 @@ +/* + * 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 { i18n } from '@osd/i18n'; +import { groupBy } from 'lodash'; + +import { + AppNavLinkStatus, + DEFAULT_APP_CATEGORIES, + PublicAppInfo, +} from '../../../../../core/public'; + +import { WorkspaceFeature, WorkspaceFeatureGroup } from './types'; +import { isDefaultCheckedFeatureId, isWorkspaceFeatureGroup } from './utils'; + +const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', +}); + +interface WorkspaceFeatureSelectorProps { + applications: PublicAppInfo[]; + selectedFeatures: string[]; + onChange: (newFeatures: string[]) => void; +} + +export const WorkspaceFeatureSelector = ({ + applications, + selectedFeatures, + onChange, +}: WorkspaceFeatureSelectorProps) => { + const featureOrGroups = useMemo(() => { + const transformedApplications = applications.map((app) => { + if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...app, + category: { + ...app.category, + label: libraryCategoryLabel, + }, + }; + } + return app; + }); + const category2Applications = groupBy(transformedApplications, 'category.label'); + return Object.keys(category2Applications).reduce< + Array + >((previousValue, currentKey) => { + const apps = category2Applications[currentKey]; + const features = apps + .filter( + ({ navLinkStatus, chromeless, category }) => + navLinkStatus !== AppNavLinkStatus.hidden && + !chromeless && + category?.id !== DEFAULT_APP_CATEGORIES.management.id + ) + .map(({ id, title }) => ({ + id, + name: title, + })); + if (features.length === 0) { + return previousValue; + } + if (currentKey === 'undefined') { + return [...previousValue, ...features]; + } + return [ + ...previousValue, + { + name: apps[0].category?.label || '', + features, + }, + ]; + }, []); + }, [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 = featureOrGroups.find( + (item) => isWorkspaceFeatureGroup(item) && item.name === e.target.id + ); + if (!featureOrGroup || !isWorkspaceFeatureGroup(featureOrGroup)) { + return; + } + const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); + // setSelectedFeatureIds((previousData) => { + 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))); + }, + [featureOrGroups, selectedFeatures, onChange] + ); + + return ( + <> + {featureOrGroups.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; + + const categoryToDescription: { [key: string]: string } = { + [libraryCategoryLabel]: i18n.translate( + 'workspace.form.featureVisibility.libraryCategory.Description', + { + defaultMessage: 'Workspace-owned library items', + } + ), + }; + + return ( + + +
+ + {featureOrGroup.name} + + {isWorkspaceFeatureGroup(featureOrGroup) && + categoryToDescription[featureOrGroup.name] && ( + {categoryToDescription[featureOrGroup.name]} + )} +
+
+ + 0 ? ` (${selectedIds.length}/${features.length})` : '' + }`} + checked={selectedIds.length > 0} + disabled={ + !isWorkspaceFeatureGroup(featureOrGroup) && + isDefaultCheckedFeatureId(featureOrGroup.id) + } + indeterminate={ + isWorkspaceFeatureGroup(featureOrGroup) && + selectedIds.length > 0 && + selectedIds.length < features.length + } + data-test-subj={`workspaceForm-workspaceFeatureVisibility-${featureOrGroupId}`} + /> + {isWorkspaceFeatureGroup(featureOrGroup) && ( + ({ + id: item.id, + label: item.name, + disabled: isDefaultCheckedFeatureId(item.id), + }))} + 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..e84ee46507ef --- /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(application: ApplicationStart) { + const applications = useObservable(application.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 e54a20552329..302b088e3805 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -20,6 +20,7 @@ describe('Workspace plugin', () => { const setupMock = getSetupMock(); const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); expect(WorkspaceClientMock).toBeCalledTimes(1); }); @@ -45,6 +46,7 @@ describe('Workspace plugin', () => { const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); expect(setupMock.workspaces.currentWorkspaceId$.getValue()).toEqual('workspaceId'); windowSpy.mockRestore(); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8a69d597c84b..38672e323826 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,9 +4,20 @@ */ import type { Subscription } from 'rxjs'; -import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; +import { i18n } from '@osd/i18n'; +import { + AppMountParameters, + AppNavLinkStatus, + CoreSetup, + CoreStart, + Plugin, +} from '../../../core/public'; +import { WORKSPACE_CREATE_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { WorkspaceClient } from './workspace_client'; +import { Services } from './types'; + +type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; @@ -36,6 +47,29 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { core.workspaces.currentWorkspaceId$.next(workspaceId); } + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + workspaceClient, + }; + + 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); + }, + }); + return {}; } diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts new file mode 100644 index 000000000000..1b3f38e50857 --- /dev/null +++ b/src/plugins/workspace/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; 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; +}; From ef47225d917f3c4d5ae331b7f124deb807010636 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 18 Mar 2024 17:05:08 +0800 Subject: [PATCH 2/6] Add change log for create workspace Signed-off-by: Lin Wang --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0adcbc43f515..d7c436c658e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) - Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) - Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) +- [Workspace] Add create workspace page ([#6179](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6179)) ### 🐛 Bug Fixes From e561f87d79e6d4834baf5814f57f749609a3a8ca Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Thu, 21 Mar 2024 14:30:38 +0800 Subject: [PATCH 3/6] Address PR comments Signed-off-by: Lin Wang --- src/plugins/workspace/common/constants.ts | 2 +- .../workspace_creator.test.tsx | 23 +++++- .../workspace_form/use_workspace_form.test.ts | 76 +++++++++++++++++++ .../workspace_form/use_workspace_form.ts | 10 +-- .../public/components/workspace_form/utils.ts | 14 ++-- .../workspace_form/workspace_cancel_modal.tsx | 4 +- .../workspace_feature_selector.tsx | 14 ++-- src/plugins/workspace/public/hooks.ts | 4 +- 8 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index fb831c9f7652..86d2d5bd9429 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -8,7 +8,7 @@ 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 DEFAULT_SELECTED_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 = 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 index bd9131e799e1..0cb60d0bb0a6 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -87,13 +87,13 @@ describe('WorkspaceCreator', () => { window.location = location; }); - it('cannot create workspace when name empty', async () => { + it('should not create workspace when name is empty', async () => { const { getByTestId } = render(); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).not.toHaveBeenCalled(); }); - it('cannot create workspace with invalid name', async () => { + it('should not create workspace with invalid name', async () => { const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { @@ -102,7 +102,7 @@ describe('WorkspaceCreator', () => { expect(workspaceClientCreate).not.toHaveBeenCalled(); }); - it('cannot create workspace with invalid description', async () => { + it('should not create workspace with invalid description', async () => { const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { @@ -188,4 +188,21 @@ describe('WorkspaceCreator', () => { }); 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_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 index 5c01c29071b8..305cae96db57 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -22,7 +22,6 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works const [color, setColor] = useState(defaultValues?.color); const [selectedTab, setSelectedTab] = useState(WorkspaceFormTabs.FeatureVisibility); - const [numberOfErrors, setNumberOfErrors] = useState(0); // The matched feature id list based on original feature config, // the feature category will be expanded to list of feature ids const defaultFeatures = useMemo(() => { @@ -39,6 +38,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works ); const [formErrors, setFormErrors] = useState({}); + const numberOfErrors = useMemo(() => getNumberOfErrors(formErrors), [formErrors]); const formIdRef = useRef(); const getFormData = () => ({ name, @@ -66,7 +66,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works }), }; } - if (!isValidNameOrDescription(formData.name)) { + if (formData.name && !isValidNameOrDescription(formData.name)) { currentFormErrors = { ...currentFormErrors, name: i18n.translate('workspace.form.detail.name.invalid', { @@ -74,7 +74,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works }), }; } - if (!isValidNameOrDescription(formData.description)) { + if (formData.description && !isValidNameOrDescription(formData.description)) { currentFormErrors = { ...currentFormErrors, description: i18n.translate('workspace.form.detail.description.invalid', { @@ -82,10 +82,8 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works }), }; } - const currentNumberOfErrors = getNumberOfErrors(currentFormErrors); setFormErrors(currentFormErrors); - setNumberOfErrors(currentNumberOfErrors); - if (currentNumberOfErrors > 0) { + if (getNumberOfErrors(currentFormErrors) > 0) { return; } diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 7f9f34fa08cc..74451d40ecae 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { DEFAULT_CHECKED_FEATURES_IDS } from '../../../common/constants'; +import { DEFAULT_SELECTED_FEATURES_IDS } from '../../../common/constants'; import { WorkspaceFeature, WorkspaceFeatureGroup, WorkspaceFormErrors } from './types'; @@ -11,21 +11,19 @@ export const isWorkspaceFeatureGroup = ( featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup ): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; -export const isDefaultCheckedFeatureId = (id: string) => { - return DEFAULT_CHECKED_FEATURES_IDS.indexOf(id) > -1; +export const isDefaultSelectedFeatureId = (id: string) => { + return DEFAULT_SELECTED_FEATURES_IDS.indexOf(id) > -1; }; export const appendDefaultFeatureIds = (ids: string[]) => { // concat default checked ids and unique the result - return Array.from(new Set(ids.concat(DEFAULT_CHECKED_FEATURES_IDS))); + return Array.from(new Set(ids.concat(DEFAULT_SELECTED_FEATURES_IDS))); }; +// Validate name and description fields according related field UI description export const isValidNameOrDescription = (input?: string) => { - if (!input) { - return true; - } const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; - return regex.test(input); + return typeof input === 'string' && regex.test(input); }; export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { 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 index 040e46f9ddfc..8a7bc03213a3 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx @@ -32,10 +32,10 @@ export const WorkspaceCancelModal = ({ })} onCancel={closeCancelModal} onConfirm={() => application?.navigateToApp(WORKSPACE_LIST_APP_ID)} - cancelButtonText={i18n.translate('workspace.form.cancelButtonText.', { + cancelButtonText={i18n.translate('workspace.form.cancelButtonText', { defaultMessage: 'Continue editing', })} - confirmButtonText={i18n.translate('workspace.form.confirmButtonText.', { + confirmButtonText={i18n.translate('workspace.form.confirmButtonText', { defaultMessage: 'Discard changes', })} buttonColor="danger" 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 index 61181a7a749e..5f72a75ae246 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx @@ -23,7 +23,7 @@ import { } from '../../../../../core/public'; import { WorkspaceFeature, WorkspaceFeatureGroup } from './types'; -import { isDefaultCheckedFeatureId, isWorkspaceFeatureGroup } from './utils'; +import { isDefaultSelectedFeatureId, isWorkspaceFeatureGroup } from './utils'; const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { defaultMessage: 'Library', @@ -40,7 +40,7 @@ export const WorkspaceFeatureSelector = ({ selectedFeatures, onChange, }: WorkspaceFeatureSelectorProps) => { - const featureOrGroups = useMemo(() => { + const featuresOrGroups = useMemo(() => { const transformedApplications = applications.map((app) => { if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { return { @@ -105,7 +105,7 @@ export const WorkspaceFeatureSelector = ({ const handleFeatureGroupChange = useCallback( (e) => { - const featureOrGroup = featureOrGroups.find( + const featureOrGroup = featuresOrGroups.find( (item) => isWorkspaceFeatureGroup(item) && item.name === e.target.id ); if (!featureOrGroup || !isWorkspaceFeatureGroup(featureOrGroup)) { @@ -122,12 +122,12 @@ export const WorkspaceFeatureSelector = ({ // Need to un-check these features, if all features in group has been selected onChange(selectedFeatures.filter((featureId) => !groupFeatureIds.includes(featureId))); }, - [featureOrGroups, selectedFeatures, onChange] + [featuresOrGroups, selectedFeatures, onChange] ); return ( <> - {featureOrGroups.map((featureOrGroup) => { + {featuresOrGroups.map((featureOrGroup) => { const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; const selectedIds = selectedFeatures.filter((id) => (isWorkspaceFeatureGroup(featureOrGroup) @@ -175,7 +175,7 @@ export const WorkspaceFeatureSelector = ({ checked={selectedIds.length > 0} disabled={ !isWorkspaceFeatureGroup(featureOrGroup) && - isDefaultCheckedFeatureId(featureOrGroup.id) + isDefaultSelectedFeatureId(featureOrGroup.id) } indeterminate={ isWorkspaceFeatureGroup(featureOrGroup) && @@ -189,7 +189,7 @@ export const WorkspaceFeatureSelector = ({ options={featureOrGroup.features.map((item) => ({ id: item.id, label: item.name, - disabled: isDefaultCheckedFeatureId(item.id), + disabled: isDefaultSelectedFeatureId(item.id), }))} idToSelectedMap={selectedIds.reduce( (previousValue, currentValue) => ({ diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts index e84ee46507ef..875e9b494f23 100644 --- a/src/plugins/workspace/public/hooks.ts +++ b/src/plugins/workspace/public/hooks.ts @@ -7,8 +7,8 @@ import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; import { useObservable } from 'react-use'; import { useMemo } from 'react'; -export function useApplications(application: ApplicationStart) { - const applications = useObservable(application.applications$); +export function useApplications(applicationInstance: ApplicationStart) { + const applications = useObservable(applicationInstance.applications$); return useMemo(() => { const apps: PublicAppInfo[] = []; applications?.forEach((app) => { From e58cea32e1038f43425e09c466affbb32b1dd393 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 22 Mar 2024 01:18:05 +0800 Subject: [PATCH 4/6] Update annotation for default selected features ids Signed-off-by: Lin Wang --- src/plugins/workspace/common/constants.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 1f289077c0a2..91db9f37fc40 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -8,7 +8,10 @@ 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. +/** + * 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 = From f8dd0b82fedc2c8f147a8d992047bc13cbd9edf8 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 22 Mar 2024 12:56:54 +0800 Subject: [PATCH 5/6] Address PR comments Signed-off-by: Lin Wang --- .../workspace_creator.test.tsx | 9 +- .../workspace_creator/workspace_creator.tsx | 2 +- .../workspace_form/use_workspace_form.ts | 6 +- .../components/workspace_form/utils.test.ts | 126 ++++++++++++++++++ .../public/components/workspace_form/utils.ts | 74 +++++++++- .../workspace_form/workspace_bottom_bar.tsx | 8 +- .../workspace_form/workspace_cancel_modal.tsx | 6 - .../workspace_feature_selector.test.tsx | 83 ++++++++++++ .../workspace_feature_selector.tsx | 87 ++---------- 9 files changed, 300 insertions(+), 101 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_form/utils.test.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx 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 index 0cb60d0bb0a6..9cc4f9b53f69 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -35,7 +35,7 @@ const WorkspaceCreator = (props: any) => { application: { ...mockCoreStart.application, navigateToApp, - getUrlForApp: jest.fn(), + getUrlForApp: jest.fn(() => '/app/workspace_overview'), applications$: new BehaviorSubject>(PublicAPPInfoMap as any), }, notifications: { @@ -78,7 +78,7 @@ describe('WorkspaceCreator', () => { } window.location = {} as Location; Object.defineProperty(window.location, 'href', { - get: () => 'http://localhost/', + get: () => 'http://localhost/w/workspace/app/workspace_create', set: setHrefSpy, }); }); @@ -154,6 +154,7 @@ describe('WorkspaceCreator', () => { }); it('create workspace with customized features', async () => { + setHrefSpy.mockReset(); const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { @@ -161,6 +162,7 @@ describe('WorkspaceCreator', () => { }); 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({ @@ -172,6 +174,9 @@ describe('WorkspaceCreator', () => { 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 () => { diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index d4237434b836..83d0f6675fe6 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -75,7 +75,7 @@ export const WorkspaceCreator = () => { hasShadow={false} /** * Since above EuiPageHeader has a maxWidth: 1000 style, - * add maxWidth: 100 below to align with the above page header + * add maxWidth: 1000 below to align with the above page header **/ style={{ width: '100%', maxWidth: 1000 }} > 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 index 305cae96db57..315f3486f83e 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -11,7 +11,7 @@ import { featureMatchesConfig } from '../../utils'; import { WorkspaceFormTabs } from './constants'; import { WorkspaceFormProps, WorkspaceFormErrors } from './types'; -import { appendDefaultFeatureIds, getNumberOfErrors, isValidNameOrDescription } from './utils'; +import { appendDefaultFeatureIds, getNumberOfErrors, isValidFormTextInput } from './utils'; const workspaceHtmlIdGenerator = htmlIdGenerator(); @@ -66,7 +66,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works }), }; } - if (formData.name && !isValidNameOrDescription(formData.name)) { + if (formData.name && !isValidFormTextInput(formData.name)) { currentFormErrors = { ...currentFormErrors, name: i18n.translate('workspace.form.detail.name.invalid', { @@ -74,7 +74,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works }), }; } - if (formData.description && !isValidNameOrDescription(formData.description)) { + if (formData.description && !isValidFormTextInput(formData.description)) { currentFormErrors = { ...currentFormErrors, description: i18n.translate('workspace.form.detail.description.invalid', { 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 index 74451d40ecae..5514e6a8fb9c 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -3,6 +3,11 @@ * 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'; @@ -11,17 +16,16 @@ export const isWorkspaceFeatureGroup = ( featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup ): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; -export const isDefaultSelectedFeatureId = (id: string) => { - return DEFAULT_SELECTED_FEATURES_IDS.indexOf(id) > -1; -}; - export const appendDefaultFeatureIds = (ids: string[]) => { // concat default checked ids and unique the result return Array.from(new Set(ids.concat(DEFAULT_SELECTED_FEATURES_IDS))); }; -// Validate name and description fields according related field UI description -export const isValidNameOrDescription = (input?: string) => { +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); }; @@ -36,3 +40,61 @@ export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { } 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 index c55501725a52..7e528d2214ee 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx @@ -102,11 +102,9 @@ export const WorkspaceBottomBar = ({ - + {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 index 8a7bc03213a3..11e835087cd6 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx @@ -10,20 +10,14 @@ import { ApplicationStart } from 'opensearch-dashboards/public'; import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; interface WorkspaceCancelModalProps { - visible: boolean; application: ApplicationStart; closeCancelModal: () => void; } export const WorkspaceCancelModal = ({ application, - visible, closeCancelModal, }: WorkspaceCancelModalProps) => { - if (!visible) { - return null; - } - return ( ) => { + 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 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 index 5f72a75ae246..da9deb174f52 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx @@ -13,24 +13,15 @@ import { EuiCheckboxGroupProps, EuiCheckboxProps, } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import { groupBy } from 'lodash'; -import { - AppNavLinkStatus, - DEFAULT_APP_CATEGORIES, - PublicAppInfo, -} from '../../../../../core/public'; - -import { WorkspaceFeature, WorkspaceFeatureGroup } from './types'; -import { isDefaultSelectedFeatureId, isWorkspaceFeatureGroup } from './utils'; +import { PublicAppInfo } from '../../../../../core/public'; -const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { - defaultMessage: 'Library', -}); +import { isWorkspaceFeatureGroup, convertApplicationsToFeaturesOrGroups } from './utils'; -interface WorkspaceFeatureSelectorProps { - applications: PublicAppInfo[]; +export interface WorkspaceFeatureSelectorProps { + applications: Array< + Pick + >; selectedFeatures: string[]; onChange: (newFeatures: string[]) => void; } @@ -40,50 +31,9 @@ export const WorkspaceFeatureSelector = ({ selectedFeatures, onChange, }: WorkspaceFeatureSelectorProps) => { - const featuresOrGroups = useMemo(() => { - const transformedApplications = applications.map((app) => { - if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { - return { - ...app, - category: { - ...app.category, - label: libraryCategoryLabel, - }, - }; - } - return app; - }); - const category2Applications = groupBy(transformedApplications, 'category.label'); - return Object.keys(category2Applications).reduce< - Array - >((previousValue, currentKey) => { - const apps = category2Applications[currentKey]; - const features = apps - .filter( - ({ navLinkStatus, chromeless, category }) => - navLinkStatus !== AppNavLinkStatus.hidden && - !chromeless && - category?.id !== DEFAULT_APP_CATEGORIES.management.id - ) - .map(({ id, title }) => ({ - id, - name: title, - })); - if (features.length === 0) { - return previousValue; - } - if (currentKey === 'undefined') { - return [...previousValue, ...features]; - } - return [ - ...previousValue, - { - name: apps[0].category?.label || '', - features, - }, - ]; - }, []); - }, [applications]); + const featuresOrGroups = useMemo(() => convertApplicationsToFeaturesOrGroups(applications), [ + applications, + ]); const handleFeatureChange = useCallback( (featureId) => { @@ -112,7 +62,6 @@ export const WorkspaceFeatureSelector = ({ return; } const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); - // setSelectedFeatureIds((previousData) => { const notExistsIds = groupFeatureIds.filter((id) => !selectedFeatures.includes(id)); // Check all not selected features if not been selected in current group. if (notExistsIds.length > 0) { @@ -139,15 +88,6 @@ export const WorkspaceFeatureSelector = ({ ? featureOrGroup.name : featureOrGroup.id; - const categoryToDescription: { [key: string]: string } = { - [libraryCategoryLabel]: i18n.translate( - 'workspace.form.featureVisibility.libraryCategory.Description', - { - defaultMessage: 'Workspace-owned library items', - } - ), - }; - return ( @@ -155,10 +95,6 @@ export const WorkspaceFeatureSelector = ({ {featureOrGroup.name} - {isWorkspaceFeatureGroup(featureOrGroup) && - categoryToDescription[featureOrGroup.name] && ( - {categoryToDescription[featureOrGroup.name]} - )} @@ -173,10 +109,6 @@ export const WorkspaceFeatureSelector = ({ features.length > 0 ? ` (${selectedIds.length}/${features.length})` : '' }`} checked={selectedIds.length > 0} - disabled={ - !isWorkspaceFeatureGroup(featureOrGroup) && - isDefaultSelectedFeatureId(featureOrGroup.id) - } indeterminate={ isWorkspaceFeatureGroup(featureOrGroup) && selectedIds.length > 0 && @@ -189,7 +121,6 @@ export const WorkspaceFeatureSelector = ({ options={featureOrGroup.features.map((item) => ({ id: item.id, label: item.name, - disabled: isDefaultSelectedFeatureId(item.id), }))} idToSelectedMap={selectedIds.reduce( (previousValue, currentValue) => ({ From fae3a3437c8b48e22140f39f5b998879ef43f5e8 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 22 Mar 2024 15:40:34 +0800 Subject: [PATCH 6/6] Add unit tests for unselected single feature Signed-off-by: Lin Wang --- .../workspace_form/workspace_feature_selector.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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 index 579dac634bd8..0875b0d1ff10 100644 --- 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 @@ -60,6 +60,15 @@ describe('WorkspaceFeatureSelector', () => { 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();