From 14f217820df1bd571f67db75d421bdae04102a98 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 14 Aug 2024 10:49:09 +0800 Subject: [PATCH 1/7] Display features in workspace use case card Signed-off-by: Lin Wang --- .../use_case_footer.test.tsx | 17 +-- .../home_get_start_card/use_case_footer.tsx | 2 +- .../workspace_creator.test.tsx | 78 +++++++--- .../workspace_creator/workspace_creator.tsx | 49 ++++-- .../workspace_detail.test.tsx | 9 +- .../workspace_updater.test.tsx | 9 +- .../public/components/workspace_form/types.ts | 5 + .../use_form_available_use_cases.test.ts | 140 ++++++++++++++++++ .../use_form_available_use_cases.ts | 71 +++++++++ .../workspace_form/use_workspace_form.ts | 2 + .../workspace_form/workspace_form.test.tsx | 4 +- .../workspace_form/workspace_form.tsx | 26 +++- .../workspace_use_case.test.tsx | 108 +++++++++++--- .../workspace_form/workspace_use_case.tsx | 120 +++++++++------ .../components/workspace_list/index.test.tsx | 12 +- .../workspace_menu/workspace_menu.test.tsx | 8 +- .../workspace_menu/workspace_menu.tsx | 2 +- src/plugins/workspace/public/mocks.ts | 21 +++ src/plugins/workspace/public/plugin.test.ts | 2 +- src/plugins/workspace/public/plugin.ts | 3 +- .../public/services/use_case_service.test.ts | 29 ++-- .../public/services/use_case_service.ts | 40 ++++- src/plugins/workspace/public/types.ts | 2 +- src/plugins/workspace/public/utils.test.ts | 34 +++-- src/plugins/workspace/public/utils.ts | 10 +- 25 files changed, 611 insertions(+), 192 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.test.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts create mode 100644 src/plugins/workspace/public/mocks.ts diff --git a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx index 75d2cd0e11a3..914fa4751ff8 100644 --- a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx +++ b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.test.tsx @@ -4,24 +4,17 @@ */ import React from 'react'; +import { IntlProvider } from 'react-intl'; import { render, screen, fireEvent } from '@testing-library/react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createMockedRegisteredUseCases$ } from '../../mocks'; + import { UseCaseFooter as UseCaseFooterComponent, UseCaseFooterProps } from './use_case_footer'; -import { coreMock, httpServiceMock } from '../../../../../core/public/mocks'; -import { IntlProvider } from 'react-intl'; -import { WorkspaceUseCase } from '../../types'; -import { CoreStart } from 'opensearch-dashboards/public'; -import { BehaviorSubject } from 'rxjs'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; describe('UseCaseFooter', () => { // let coreStartMock: CoreStart; const navigateToApp = jest.fn(); - const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.essentials, - WORKSPACE_USE_CASES.search, - ]); + const registeredUseCases$ = createMockedRegisteredUseCases$(); const getMockCore = (isDashboardAdmin: boolean = true) => { const coreStartMock = coreMock.createStart(); diff --git a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx index 922cf3e66f7b..5a32d1534ce4 100644 --- a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx +++ b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx @@ -56,7 +56,7 @@ export const UseCaseFooter = ({ const closePopover = () => setPopover(false); const appId = - availableUseCases?.find((useCase) => useCase.id === useCaseId)?.features[0] ?? + availableUseCases?.find((useCase) => useCase.id === useCaseId)?.features[0].id ?? WORKSPACE_DETAIL_APP_ID; const filterWorkspaces = useMemo( 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 24692db47229..918194d62023 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 @@ -5,15 +5,16 @@ import React from 'react'; import { PublicAppInfo } from 'opensearch-dashboards/public'; -import { fireEvent, render, waitFor, act } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; +import { createMockedRegisteredUseCases$ } from '../../mocks'; + import { WorkspaceCreator as WorkspaceCreatorComponent, WorkspaceCreatorProps, } from './workspace_creator'; -import { coreMock } from '../../../../../core/public/mocks'; -import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; const workspaceClientCreate = jest .fn() @@ -93,12 +94,7 @@ const WorkspaceCreator = ({ dataSourceManagement: {}, }, }); - const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.essentials, - WORKSPACE_USE_CASES.search, - ]); + const registeredUseCases$ = createMockedRegisteredUseCases$(); return ( @@ -136,33 +132,44 @@ describe('WorkspaceCreator', () => { 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(); + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { - target: { value: '~' }, + target: { + value: '', + }, }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).not.toHaveBeenCalled(); }); - it('should not create workspace without use cases', async () => { - setHrefSpy.mockReset(); + it('should not create workspace with invalid name', async () => { const { getByTestId } = render(); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, + target: { value: '~' }, }); - expect(setHrefSpy).not.toHaveBeenCalled(); - fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).not.toHaveBeenCalled(); }); it('cancel create workspace', async () => { const { findByText, getByTestId } = render(); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); await findByText('Discard changes?'); fireEvent.click(getByTestId('confirmModalConfirmButton')); @@ -171,6 +178,11 @@ describe('WorkspaceCreator', () => { it('create workspace with detailed information', async () => { const { getByTestId } = render(); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -211,6 +223,11 @@ describe('WorkspaceCreator', () => { it('should show danger toasts after create workspace failed', async () => { workspaceClientCreate.mockReturnValueOnce({ result: { id: 'failResult' }, success: false }); const { getByTestId } = render(); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -229,6 +246,11 @@ describe('WorkspaceCreator', () => { throw new Error(); }); const { getByTestId } = render(); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -244,6 +266,11 @@ describe('WorkspaceCreator', () => { it('create workspace with customized permissions', async () => { const { getByTestId } = render(); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -277,6 +304,11 @@ describe('WorkspaceCreator', () => { const { getByTestId, getByTitle, getByText } = render( ); + + // Ensure workspace create form rendered + await waitFor(() => { + expect(getByTestId('workspaceForm-bottomBar-createButton')).toBeInTheDocument(); + }); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -284,9 +316,7 @@ describe('WorkspaceCreator', () => { fireEvent.click(getByTestId('workspaceUseCase-observability')); fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); fireEvent.click(getByTestId('workspaceForm-select-dataSource-comboBox')); - await act(() => { - fireEvent.click(getByText('Select')); - }); + fireEvent.click(getByText('Select')); fireEvent.click(getByTitle(dataSourcesList[0].title)); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); 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 c46d17b701ac..2e1f2f189e1f 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -12,7 +12,6 @@ import { euiPaletteColorBlind, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import { useObservable } from 'react-use'; import { BehaviorSubject } from 'rxjs'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; @@ -25,12 +24,15 @@ import { DataSource } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { WorkspaceUseCase } from '../../types'; import { WorkspaceFormData } from '../workspace_form/types'; +import { getUseCaseFeatureConfig } from '../../utils'; +import { useFormAvailableUseCases } from '../workspace_form/use_form_available_use_cases'; export interface WorkspaceCreatorProps { registeredUseCases$: BehaviorSubject; } export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { + const { registeredUseCases$ } = props; const { services: { application, @@ -45,13 +47,24 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { dataSourceManagement?: DataSourceManagementPluginSetup; }>(); + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + const { isOnlyAllowEssential, availableUseCases } = useFormAvailableUseCases({ + savedObjects, + registeredUseCases$, + onlyAllowEssentialEnabled: true, + }); + + const defaultSelectedUseCase = availableUseCases?.[0]; const defaultWorkspaceFormValues: Partial = { color: euiPaletteColorBlind()[0], + ...(defaultSelectedUseCase + ? { + name: defaultSelectedUseCase.title, + features: [getUseCaseFeatureConfig(defaultSelectedUseCase.id)], + } + : {}), }; - const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; - const availableUseCases = useObservable(props.registeredUseCases$, []); - const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { let result; @@ -110,18 +123,22 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { color="subdued" hasShadow={false} > - {application && savedObjects && ( - - )} + {application && + savedObjects && + // Default values only worked for component mount, should wait for isOnlyAllowEssential and availableUseCases loaded + isOnlyAllowEssential !== undefined && + availableUseCases !== undefined && ( + + )} diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx index 20f739273182..ece051f2f4e4 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx @@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; import { coreMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import { createMockedRegisteredUseCases$ } from '../../mocks'; import { WorkspaceDetail } from './workspace_detail'; // all applications @@ -72,12 +72,7 @@ const WorkspaceDetailPage = (props: any) => { }, }); - const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.essentials, - WORKSPACE_USE_CASES.search, - ]); + const registeredUseCases$ = createMockedRegisteredUseCases$(); return ( diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx index e2ecbd0a32cd..b0784aba571e 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx @@ -11,7 +11,7 @@ import { BehaviorSubject } from 'rxjs'; import { coreMock, workspacesServiceMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; import { DetailTab } from '../workspace_form/constants'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import { createMockedRegisteredUseCases$ } from '../../mocks'; import { WorkspaceUpdater as WorkspaceUpdaterComponent, WorkspaceUpdaterProps, @@ -124,12 +124,7 @@ const WorkspaceUpdater = ( dataSourceManagement: {}, }, }); - const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.essentials, - WORKSPACE_USE_CASES.search, - ]); + const registeredUseCases$ = createMockedRegisteredUseCases$(); return ( diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index cbcf7e8ded26..5a170e35127e 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -91,3 +91,8 @@ export interface WorkspaceFormProps { export interface WorkspaceDetailedFormProps extends WorkspaceFormProps { defaultValues?: WorkspaceFormData; } + +export interface AvailableUseCaseItem + extends Pick { + disabled?: boolean; +} diff --git a/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.test.ts b/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.test.ts new file mode 100644 index 000000000000..45192c293e9f --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { renderHook } from '@testing-library/react-hooks'; +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceUseCase } from '../../types'; +import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; +import { savedObjectsServiceMock } from '../../../../../core/public/mocks'; +import { getIsOnlyAllowEssentialUseCase } from '../../utils'; + +import { useFormAvailableUseCases } from './use_form_available_use_cases'; + +jest.mock('../../utils', () => ({ + getIsOnlyAllowEssentialUseCase: jest.fn(), +})); + +describe('useFormAvailableUseCases', () => { + const mockSavedObjectsClient = savedObjectsServiceMock.createStartContract(); + + const mockUseCases: WorkspaceUseCase[] = [ + { + id: 'useCase1', + title: 'Use Case 1', + description: 'Use Case 1 description', + systematic: false, + features: [], + }, + { + id: 'useCase2', + title: 'Use Case 2', + description: 'Use Case 2 description', + features: [], + systematic: true, + }, + { + ...DEFAULT_NAV_GROUPS.essentials, + features: [], + }, + { + ...DEFAULT_NAV_GROUPS.all, + features: [], + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return available use cases when onlyAllowEssentialEnabled is false', () => { + const registeredUseCases$ = new BehaviorSubject(mockUseCases); + const { result } = renderHook(() => + useFormAvailableUseCases({ + onlyAllowEssentialEnabled: false, + registeredUseCases$, + }) + ); + + expect(result.current.availableUseCases).toEqual([ + expect.objectContaining({ + id: 'useCase1', + title: 'Use Case 1', + systematic: false, + }), + expect.objectContaining(DEFAULT_NAV_GROUPS.essentials), + expect.objectContaining(DEFAULT_NAV_GROUPS.all), + ]); + }); + + it('should return only essential use case when onlyAllowEssentialEnabled is true', async () => { + const registeredUseCases$ = new BehaviorSubject(mockUseCases); + (getIsOnlyAllowEssentialUseCase as jest.Mock).mockResolvedValue(true); + + const { result, waitForNextUpdate } = renderHook(() => + useFormAvailableUseCases({ + onlyAllowEssentialEnabled: true, + savedObjects: mockSavedObjectsClient, + registeredUseCases$, + }) + ); + + await waitForNextUpdate(); + + expect(result.current.isOnlyAllowEssential).toBe(true); + expect(result.current.availableUseCases).toEqual([ + expect.objectContaining({ + ...DEFAULT_NAV_GROUPS.essentials, + disabled: true, + }), + ]); + }); + + it('should handle error when fetching isOnlyAllowEssential', async () => { + const registeredUseCases$ = new BehaviorSubject(mockUseCases); + (getIsOnlyAllowEssentialUseCase as jest.Mock).mockRejectedValue(new Error('Failed to fetch')); + + const { result, waitForNextUpdate } = renderHook(() => + useFormAvailableUseCases({ + onlyAllowEssentialEnabled: true, + savedObjects: mockSavedObjectsClient, + registeredUseCases$, + }) + ); + + await waitForNextUpdate(); + + expect(result.current.isOnlyAllowEssential).toBe(false); + expect(result.current.availableUseCases).toEqual([ + expect.objectContaining({ + id: 'useCase1', + title: 'Use Case 1', + systematic: false, + }), + expect.objectContaining(DEFAULT_NAV_GROUPS.essentials), + expect.objectContaining(DEFAULT_NAV_GROUPS.all), + ]); + }); + + it('should not update isOnlyAllowEssential after unmount', async () => { + const registeredUseCases$ = new BehaviorSubject(mockUseCases); + const getIsOnlyAllowEssentialUseCaseMock = (getIsOnlyAllowEssentialUseCase as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve(false), 0); + }) + ); + const { unmount, result } = renderHook(() => + useFormAvailableUseCases({ + onlyAllowEssentialEnabled: true, + savedObjects: mockSavedObjectsClient, + registeredUseCases$, + }) + ); + + expect(result.current.isOnlyAllowEssential).toBeUndefined(); + unmount(); + await getIsOnlyAllowEssentialUseCaseMock.mock.results[0].value; + expect(result.current.isOnlyAllowEssential).toBeUndefined(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts b/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts new file mode 100644 index 000000000000..bbbf92c82079 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useObservable } from 'react-use'; +import { BehaviorSubject } from 'rxjs'; + +import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS, SavedObjectsStart } from '../../../../../core/public'; +import { WorkspaceUseCase } from '../../types'; +import { getIsOnlyAllowEssentialUseCase } from '../../utils'; +import { AvailableUseCaseItem } from './types'; + +interface UseFormAvailableUseCasesOptions { + onlyAllowEssentialEnabled?: boolean; + savedObjects?: SavedObjectsStart; + registeredUseCases$: BehaviorSubject; +} + +export const useFormAvailableUseCases = ({ + onlyAllowEssentialEnabled = false, + savedObjects, + registeredUseCases$, +}: UseFormAvailableUseCasesOptions) => { + const [isOnlyAllowEssential, setIsOnlyAllowEssential] = useState(); + const registeredUseCases = useObservable(registeredUseCases$, undefined); + + useEffect(() => { + let shouldUpdate = true; + if (!onlyAllowEssentialEnabled || !savedObjects) { + return; + } + const updateEssential = (payload: boolean) => { + if (shouldUpdate) { + setIsOnlyAllowEssential(payload); + } + }; + (async () => { + try { + const result = await getIsOnlyAllowEssentialUseCase(savedObjects.client); + updateEssential(result); + } catch (e) { + // Set to false is failed to fetch is only allow essential use case + updateEssential(false); + } + })(); + return () => { + shouldUpdate = false; + }; + }, [savedObjects, onlyAllowEssentialEnabled]); + + const availableUseCases = useMemo(() => { + if (!registeredUseCases) { + return undefined; + } + if (onlyAllowEssentialEnabled && isOnlyAllowEssential) { + return registeredUseCases.flatMap((useCase) => + useCase.id === DEFAULT_NAV_GROUPS.essentials.id ? [{ ...useCase, disabled: true }] : [] + ); + } + return registeredUseCases.filter( + (useCase) => !useCase.systematic || useCase.id === ALL_USE_CASE_ID + ); + }, [registeredUseCases, isOnlyAllowEssential, onlyAllowEssentialEnabled]); + + return { + isOnlyAllowEssential, + availableUseCases, + }; +}; 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 e0b01454fa0f..3b638904d9db 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 @@ -152,6 +152,8 @@ export const useWorkspaceForm = ({ applications, numberOfErrors, numberOfChanges, + setName, + setDescription, handleFormSubmit, handleColorChange, handleUseCaseChange, diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx index 05191dbd189a..1a8585036c29 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import { WorkspaceForm } from './workspace_form'; import { coreMock } from '../../../../../core/public/mocks'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import { createMockedRegisteredUseCases } from '../../mocks'; import { WorkspaceOperationType } from './constants'; const mockCoreStart = coreMock.createStart(); @@ -41,7 +41,7 @@ const setup = ( application={application} savedObjects={savedObjects} operationType={WorkspaceOperationType.Create} - availableUseCases={[WORKSPACE_USE_CASES.essentials]} + availableUseCases={createMockedRegisteredUseCases()} dataSourceManagement={dataSourceManagement} /> ); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index 70d2a34d7585..7489892c725b 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useRef } from 'react'; +import React, { useCallback, useRef } from 'react'; import { EuiPanel, EuiSpacer, EuiTitle, EuiForm } from '@elastic/eui'; import { WorkspaceBottomBar } from './workspace_bottom_bar'; @@ -39,19 +39,37 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { formErrors, numberOfErrors, numberOfChanges, + setName, handleFormSubmit, handleColorChange, - handleUseCaseChange, - handleNameInputChange, + handleUseCaseChange: handleUseCaseChangeInHook, setPermissionSettings, setSelectedDataSources, handleDescriptionChange, } = useWorkspaceForm(props); + const nameManualChangedRef = useRef(false); const disabledUserOrGroupInputIdsRef = useRef( defaultValues?.permissionSettings?.map((item) => item.id) ?? [] ); const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin ?? false; + const handleNameInputChange = useCallback( + (e) => { + setName(e.target.value); + nameManualChangedRef.current = true; + }, + [setName] + ); + const handleUseCaseChange = useCallback( + (newUseCase) => { + handleUseCaseChangeInHook(newUseCase); + const useCase = availableUseCases.find((item) => newUseCase === item.id); + if (!nameManualChangedRef.current && useCase) { + setName(useCase.title); + } + }, + [handleUseCaseChangeInHook, availableUseCases, setName] + ); return ( @@ -89,8 +107,6 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { onChange={handleUseCaseChange} formErrors={formErrors} availableUseCases={availableUseCases} - savedObjects={savedObjects} - operationType={operationType} /> diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx index 83bd8482f7d4..8a0b14782e91 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx @@ -5,41 +5,31 @@ import React from 'react'; import { fireEvent, render, waitFor } from '@testing-library/react'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; import { WorkspaceUseCase, WorkspaceUseCaseProps } from './workspace_use_case'; import { WorkspaceFormErrors } from './types'; -import { coreMock } from '../../../../../core/public/mocks'; -import { WorkspaceOperationType } from './constants'; -import { getIsOnlyAllowEssentialUseCase } from '../../utils'; - -jest.mock('../../utils', () => ({ - getIsOnlyAllowEssentialUseCase: jest.fn().mockResolvedValue(false), -})); -const mockCoreStart = coreMock.createStart(); const setup = (options?: Partial) => { const onChangeMock = jest.fn(); const formErrors: WorkspaceFormErrors = {}; - const savedObjects = mockCoreStart.savedObjects; const renderResult = render( ); @@ -50,7 +40,7 @@ const setup = (options?: Partial) => { }; describe('WorkspaceUseCase', () => { - it('should render four use cases', () => { + it('should render passed use cases', () => { const { renderResult } = setup(); expect(renderResult.getByText('Observability')).toBeInTheDocument(); @@ -74,13 +64,87 @@ describe('WorkspaceUseCase', () => { fireEvent.click(renderResult.getByText('Observability')); expect(onChangeMock).not.toHaveBeenCalled(); }); - it('should only display essential use case when creating workspace if getIsOnlyAllowEssentialUseCase returns true', async () => { - (getIsOnlyAllowEssentialUseCase as jest.Mock).mockResolvedValue(true); - const { renderResult } = setup(); + it('should render disabled essential use case card', async () => { + const { renderResult } = setup({ + availableUseCases: [ + { + ...DEFAULT_NAV_GROUPS.essentials, + features: [], + disabled: true, + }, + ], + }); + await waitFor(() => { + expect(renderResult.getByText('Essentials')).toHaveClass( + 'euiCheckableCard__label-isDisabled' + ); + }); + }); + + it('should be able to toggle use case features', async () => { + const { renderResult } = setup({ + availableUseCases: [ + { + ...DEFAULT_NAV_GROUPS.observability, + features: [ + { id: 'feature1', title: 'Feature 1' }, + { id: 'feature2', title: 'Feature 2' }, + ], + }, + ], + }); + await waitFor(() => { + expect(renderResult.getByText('See more....')).toBeInTheDocument(); + expect(renderResult.queryByText('Feature 1')).toBe(null); + expect(renderResult.queryByText('Feature 2')).toBe(null); + }); + + fireEvent.click(renderResult.getByText('See more....')); + + await waitFor(() => { + expect(renderResult.getByText('See less....')).toBeInTheDocument(); + expect(renderResult.getByText('Feature 1')).toBeInTheDocument(); + expect(renderResult.getByText('Feature 2')).toBeInTheDocument(); + }); + + fireEvent.click(renderResult.getByText('See less....')); + + await waitFor(() => { + expect(renderResult.getByText('See more....')).toBeInTheDocument(); + expect(renderResult.queryByText('Feature 1')).toBe(null); + expect(renderResult.queryByText('Feature 2')).toBe(null); + }); + }); + + it('should show static all use case features', async () => { + const { renderResult } = setup({ + availableUseCases: [ + { + ...DEFAULT_NAV_GROUPS.all, + features: [ + { id: 'feature1', title: 'Feature 1' }, + { id: 'feature2', title: 'Feature 2' }, + ], + }, + ], + }); + + fireEvent.click(renderResult.getByText('See more....')); + await waitFor(() => { - expect(renderResult.queryByText('Essentials')).toBeInTheDocument(); - expect(renderResult.queryByText('Observability')).not.toBeInTheDocument(); + expect(renderResult.getByText('Discover')).toBeInTheDocument(); + expect(renderResult.getByText('Dashboards')).toBeInTheDocument(); + expect(renderResult.getByText('Visualize')).toBeInTheDocument(); + expect( + renderResult.getByText('Observability services, metrics, traces, and more') + ).toBeInTheDocument(); + expect( + renderResult.getByText('Security analytics threat alerts, findings, correlations, and more') + ).toBeInTheDocument(); + expect( + renderResult.getByText('Search studio, relevance tuning, vector search, and more') + ).toBeInTheDocument(); }); }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx index 1a222238fc0c..24d1e7c80e64 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useState, useMemo } from 'react'; import { i18n } from '@osd/i18n'; import { EuiCheckableCard, @@ -11,34 +11,71 @@ import { EuiFlexItem, EuiCompressedFormRow, EuiText, + EuiLink, } from '@elastic/eui'; -import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; -import { WorkspaceUseCase as WorkspaceUseCaseObject } from '../../types'; -import { WorkspaceFormErrors } from './types'; +import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS } from '../../../../../core/public'; +import { WorkspaceFormErrors, AvailableUseCaseItem } from './types'; import './workspace_use_case.scss'; -import type { SavedObjectsStart } from '../../../../../core/public'; -import { getIsOnlyAllowEssentialUseCase } from '../../utils'; -import { WorkspaceOperationType } from './constants'; interface WorkspaceUseCaseCardProps { id: string; title: string; checked: boolean; + disabled?: boolean; description: string; + features: Array<{ id: string; title?: string }>; onChange: (id: string) => void; } const WorkspaceUseCaseCard = ({ id, title, + features, description, checked, + disabled, onChange, }: WorkspaceUseCaseCardProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const featureItems = useMemo(() => { + if (id === DEFAULT_NAV_GROUPS.essentials.id) { + return []; + } + if (id === ALL_USE_CASE_ID) { + return [ + i18n.translate('workspace.form.useCase.feature.all.discover', { + defaultMessage: 'Discover', + }), + i18n.translate('workspace.form.useCase.feature.all.dashboards', { + defaultMessage: 'Dashboards', + }), + i18n.translate('workspace.form.useCase.feature.all.visualize', { + defaultMessage: 'Visualize', + }), + i18n.translate('workspace.form.useCase.feature.all.observability', { + defaultMessage: 'Observability services, metrics, traces, and more', + }), + i18n.translate('workspace.form.useCase.feature.all.securityAnalytics', { + defaultMessage: 'Security analytics threat alerts, findings, correlations, and more', + }), + i18n.translate('workspace.form.useCase.feature.all.search', { + defaultMessage: 'Search studio, relevance tuning, vector search, and more', + }), + ]; + } + + const featureTitles = features.flatMap((feature) => (feature.title ? [feature.title] : [])); + return featureTitles; + }, [features, id]); + const handleChange = useCallback(() => { onChange(id); }, [id, onChange]); + const toggleExpanded = useCallback(() => { + setIsExpanded((flag) => !flag); + }, []); + return ( - - {description} - + {description} + {featureItems.length > 0 && ( + + {isExpanded && ( + <> + {i18n.translate('workspace.form.useCase.featureExpandedTitle', { + defaultMessage: 'Feature includes:', + })} +
    + {featureItems.map((feature, index) => ( +
  • {feature}
  • + ))} +
+ + )} + + + {isExpanded + ? i18n.translate('workspace.form.useCase.showLessButton', { + defaultMessage: 'See less....', + }) + : i18n.translate('workspace.form.useCase.showMoreButton', { + defaultMessage: 'See more....', + })} + + +
+ )}
); }; -type AvailableUseCase = Pick; - export interface WorkspaceUseCaseProps { value: string | undefined; onChange: (newValue: string) => void; formErrors: WorkspaceFormErrors; - availableUseCases: AvailableUseCase[]; - savedObjects: SavedObjectsStart; - operationType: WorkspaceOperationType; + availableUseCases: AvailableUseCaseItem[]; } export const WorkspaceUseCase = ({ @@ -73,32 +132,7 @@ export const WorkspaceUseCase = ({ onChange, formErrors, availableUseCases, - savedObjects, - operationType, }: WorkspaceUseCaseProps) => { - const [isOnlyAllowEssential, setIsOnlyAllowEssential] = useState(false); - - useEffect(() => { - if (operationType === WorkspaceOperationType.Create) { - getIsOnlyAllowEssentialUseCase(savedObjects.client).then((result: boolean) => { - setIsOnlyAllowEssential(result); - }); - } - }, [savedObjects, operationType]); - - const displayedUseCases = useMemo(() => { - let allAvailableUseCases = availableUseCases - .filter((item) => !item.systematic) - .concat(DEFAULT_NAV_GROUPS.all); - // When creating and isOnlyAllowEssential is true, only display essential use case - if (isOnlyAllowEssential && operationType === WorkspaceOperationType.Create) { - allAvailableUseCases = allAvailableUseCases.filter( - (item) => item.id === DEFAULT_NAV_GROUPS.essentials.id - ); - } - return allAvailableUseCases; - }, [availableUseCases, isOnlyAllowEssential, operationType]); - return ( - - {displayedUseCases.map(({ id, title, description }) => ( + + {availableUseCases.map(({ id, title, description, features, disabled }) => ( ))} diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index bbff833da7b1..4ec1d703274f 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -4,13 +4,13 @@ */ import React from 'react'; -import { BehaviorSubject, of } from 'rxjs'; +import { of } from 'rxjs'; import { render, fireEvent, screen } from '@testing-library/react'; import { I18nProvider } from '@osd/i18n/react'; import { coreMock } from '../../../../../core/public/mocks'; import { navigateToWorkspaceDetail } from '../utils/workspace'; +import { createMockedRegisteredUseCases$ } from '../../mocks'; import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; import { WorkspaceList } from './index'; jest.mock('../utils/workspace'); @@ -49,9 +49,7 @@ function getWrapWorkspaceListInContext( return ( - + ); @@ -60,9 +58,7 @@ function getWrapWorkspaceListInContext( describe('WorkspaceList', () => { it('should render title and table normally', () => { const { getByText, getByRole, container } = render( - + ); expect(getByText('Workspaces')).toBeInTheDocument(); expect(getByRole('table')).toBeInTheDocument(); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx index a520196fa5bf..606052f5c92c 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -8,21 +8,17 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { WorkspaceMenu } from './workspace_menu'; import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from '../../../../../core/public'; +import { CoreStart, DEFAULT_NAV_GROUPS } from '../../../../../core/public'; import { BehaviorSubject } from 'rxjs'; import { IntlProvider } from 'react-intl'; import { recentWorkspaceManager } from '../../recent_workspace_manager'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; import * as workspaceUtils from '../utils/workspace'; describe('', () => { let coreStartMock: CoreStart; const navigateToApp = jest.fn(); const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.essentials, - WORKSPACE_USE_CASES.search, + { ...DEFAULT_NAV_GROUPS.observability, features: [{ id: 'discover', title: 'Discover' }] }, ]); beforeEach(() => { diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index 3ef7270ddd67..ef15e979a7be 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -132,7 +132,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { const useCase = getUseCase(workspace); const appId = - (useCase?.id !== ALL_USE_CASE_ID && useCase?.features?.[0]) || WORKSPACE_DETAIL_APP_ID; + (useCase?.id !== ALL_USE_CASE_ID && useCase?.features?.[0].id) || WORKSPACE_DETAIL_APP_ID; const useCaseURL = formatUrlWithWorkspaceId( coreStart.application.getUrlForApp(appId, { absolute: false, diff --git a/src/plugins/workspace/public/mocks.ts b/src/plugins/workspace/public/mocks.ts new file mode 100644 index 000000000000..2f74bcf64835 --- /dev/null +++ b/src/plugins/workspace/public/mocks.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { WORKSPACE_USE_CASES } from '../common/constants'; + +export const createMockedRegisteredUseCases = () => + [ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.essentials, + WORKSPACE_USE_CASES.search, + ].map((item) => ({ + ...item, + features: item.features.map((id) => ({ id })), + })); + +export const createMockedRegisteredUseCases$ = () => + new BehaviorSubject(createMockedRegisteredUseCases()); diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index d8372fd68a5c..ab52cbd67040 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -288,7 +288,7 @@ describe('Workspace plugin', () => { { id: 'foo', title: 'Foo', - features: ['system-feature'], + features: [{ id: 'system-feature', title: 'System feature' }], systematic: true, description: '', }, diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index de7efe10eb00..2c8e057fa030 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -129,7 +129,8 @@ export class WorkspacePlugin } if ( registeredUseCases.some( - (useCase) => useCase.systematic && useCase.features.includes(app.id) + (useCase) => + useCase.systematic && useCase.features.some((feature) => feature.id === app.id) ) ) { return; diff --git a/src/plugins/workspace/public/services/use_case_service.test.ts b/src/plugins/workspace/public/services/use_case_service.test.ts index 21049625d85e..3eeaf83cb5a7 100644 --- a/src/plugins/workspace/public/services/use_case_service.test.ts +++ b/src/plugins/workspace/public/services/use_case_service.test.ts @@ -6,7 +6,7 @@ import { BehaviorSubject } from 'rxjs'; import { first } from 'rxjs/operators'; import { chromeServiceMock } from '../../../../core/public/mocks'; -import { NavGroupType } from '../../../../core/public'; +import { DEFAULT_NAV_GROUPS, NavGroupType } from '../../../../core/public'; import { UseCaseService } from './use_case_service'; const mockNavGroupsMap = { @@ -19,20 +19,22 @@ const mockNavGroupsMap = { search: { id: 'search', title: 'Search', - navLinks: [{ id: 'searchRelevance' }], + navLinks: [{ id: 'searchRelevance', title: 'Search Relevance' }], order: 2000, }, observability: { id: 'observability', title: 'Observability', description: 'Observability description', - navLinks: [{ id: 'dashboards' }], + navLinks: [{ id: 'dashboards', title: 'Dashboards' }], order: 1000, }, }; const setupUseCaseStart = (options?: { navGroupEnabled?: boolean }) => { const chrome = chromeServiceMock.createStartContract(); - const workspaceConfigurableApps$ = new BehaviorSubject([{ id: 'searchRelevance' }]); + const workspaceConfigurableApps$ = new BehaviorSubject([ + { id: 'searchRelevance', title: 'Search Relevance' }, + ]); const navGroupsMap$ = new BehaviorSubject(mockNavGroupsMap); const useCase = new UseCaseService(); @@ -59,19 +61,24 @@ describe('UseCaseService', () => { }); const useCases = await useCaseStart.getRegisteredUseCases$().pipe(first()).toPromise(); - expect(useCases).toHaveLength(1); + expect(useCases).toHaveLength(2); expect(useCases).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'search', title: 'Search', - features: expect.arrayContaining(['searchRelevance']), + features: expect.arrayContaining([ + { id: 'searchRelevance', title: 'Search Relevance' }, + ]), + }), + expect.objectContaining({ + ...DEFAULT_NAV_GROUPS.all, }), ]) ); }); - it('should return registered use cases when nav group disabled', async () => { + it('should return registered use cases when nav group enabled', async () => { const { useCaseStart } = setupUseCaseStart(); const useCases = await useCaseStart.getRegisteredUseCases$().pipe(first()).toPromise(); @@ -79,12 +86,12 @@ describe('UseCaseService', () => { expect.objectContaining({ id: 'observability', title: 'Observability', - features: expect.arrayContaining(['dashboards']), + features: expect.arrayContaining([{ id: 'dashboards', title: 'Dashboards' }]), }), expect.objectContaining({ id: 'search', title: 'Search', - features: expect.arrayContaining(['searchRelevance']), + features: expect.arrayContaining([{ id: 'searchRelevance', title: 'Search Relevance' }]), }), expect.objectContaining({ id: 'system', @@ -97,10 +104,10 @@ describe('UseCaseService', () => { it('should not emit after navGroupsMap$ emit same value', async () => { const { useCaseStart, navGroupsMap$ } = setupUseCaseStart(); - const registeredUseCase$ = useCaseStart.getRegisteredUseCases$(); + const registeredUseCases$ = useCaseStart.getRegisteredUseCases$(); const fn = jest.fn(); - registeredUseCase$.subscribe(fn); + registeredUseCases$.subscribe(fn); expect(fn).toHaveBeenCalledTimes(1); diff --git a/src/plugins/workspace/public/services/use_case_service.ts b/src/plugins/workspace/public/services/use_case_service.ts index eed33795a49b..6700ece2c782 100644 --- a/src/plugins/workspace/public/services/use_case_service.ts +++ b/src/plugins/workspace/public/services/use_case_service.ts @@ -6,8 +6,14 @@ import { Observable } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; -import { ChromeStart, PublicAppInfo } from '../../../../core/public'; +import { + ALL_USE_CASE_ID, + ChromeStart, + DEFAULT_NAV_GROUPS, + PublicAppInfo, +} from '../../../../core/public'; import { WORKSPACE_USE_CASES } from '../../common/constants'; +import { WorkspaceUseCase } from '../types'; import { convertNavGroupToWorkspaceUseCase, isEqualWorkspaceUseCase } from '../utils'; export class UseCaseService { @@ -45,10 +51,18 @@ export class UseCaseService { ) .pipe( map((useCases) => - useCases.sort( - (a, b) => + useCases.sort((a, b) => { + // Make sure all use case should be the latest + if (a.id === ALL_USE_CASE_ID) { + return 1; + } + if (b.id === ALL_USE_CASE_ID) { + return -1; + } + return ( (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) - ) + ); + }) ) ); } @@ -62,9 +76,21 @@ export class UseCaseService { WORKSPACE_USE_CASES['security-analytics'], WORKSPACE_USE_CASES.essentials, WORKSPACE_USE_CASES.search, - ].filter((useCase) => { - return useCase.features.some((featureId) => configurableAppsId.includes(featureId)); - }); + ] + .filter((useCase) => { + return useCase.features.some((featureId) => configurableAppsId.includes(featureId)); + }) + .map((item) => ({ + ...item, + features: item.features.map((featureId) => ({ + title: configurableApps.find((app) => app.id === featureId)?.title, + id: featureId, + })), + })) + .concat({ + ...DEFAULT_NAV_GROUPS.all, + features: configurableApps.map((app) => ({ id: app.id, title: app.title })), + }) as WorkspaceUseCase[]; }) ); }, diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 79fed7fa81ac..b906e608f3ba 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -16,7 +16,7 @@ export interface WorkspaceUseCase { id: string; title: string; description: string; - features: string[]; + features: Array<{ id: string; title?: string }>; systematic?: boolean; order?: number; } diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 545b4b3bfa5d..9dd8d728c977 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -19,21 +19,17 @@ import { } from './utils'; import { WorkspaceAvailability } from '../../../core/public'; import { coreMock } from '../../../core/public/mocks'; -import { WORKSPACE_DETAIL_APP_ID, WORKSPACE_USE_CASES } from '../common/constants'; +import { WORKSPACE_DETAIL_APP_ID } from '../common/constants'; import { SigV4ServiceName } from '../../../plugins/data_source/common/data_sources'; +import { createMockedRegisteredUseCases } from './mocks'; const startMock = coreMock.createStart(); -const STATIC_USE_CASES = [ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.search, - WORKSPACE_USE_CASES.essentials, -]; +const STATIC_USE_CASES = createMockedRegisteredUseCases(); const useCaseMock = { id: 'foo', title: 'Foo', description: 'Foo description', - features: ['bar'], + features: [{ id: 'bar' }], systematic: false, order: 1, }; @@ -349,7 +345,7 @@ describe('workspace utils: isFeatureIdInsideUseCase', () => { id: 'foo', title: 'Foo', description: 'Foo description', - features: ['discover'], + features: [{ id: 'discover' }], }, ]) ).toBe(true); @@ -458,13 +454,13 @@ describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { id: 'foo', title: 'Foo', description: 'Foo description', - navLinks: [{ id: 'bar' }], + navLinks: [{ id: 'bar', title: 'Bar' }], }) ).toEqual({ id: 'foo', title: 'Foo', description: 'Foo description', - features: ['bar'], + features: [{ id: 'bar', title: 'Bar' }], systematic: false, }); @@ -473,14 +469,14 @@ describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { id: 'foo', title: 'Foo', description: 'Foo description', - navLinks: [{ id: 'bar' }], + navLinks: [{ id: 'bar', title: 'Bar' }], type: NavGroupType.SYSTEM, }) ).toEqual({ id: 'foo', title: 'Foo', description: 'Foo description', - features: ['bar'], + features: [{ id: 'bar', title: 'Bar' }], systematic: true, }); }); @@ -535,11 +531,19 @@ describe('workspace utils: isEqualWorkspaceUseCase', () => { }) ).toEqual(false); }); - it('should return false when features content not equal', () => { + it('should return false when features id not equal', () => { expect( isEqualWorkspaceUseCase(useCaseMock, { ...useCaseMock, - features: ['baz'], + features: [{ id: 'baz' }], + }) + ).toEqual(false); + }); + it('should return false when features title not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + features: [{ id: 'bar', title: 'Baz' }], }) ).toEqual(false); }); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index dc01e64182eb..30163b979ffe 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -45,7 +45,7 @@ export const isFeatureIdInsideUseCase = ( useCases: WorkspaceUseCase[] ) => { const availableFeatures = useCases.find(({ id }) => id === useCaseId)?.features ?? []; - return availableFeatures.includes(featureId); + return availableFeatures.some((feature) => feature.id === featureId); }; export const isNavGroupInFeatureConfigs = (navGroupId: string, featureConfigs: string[]) => @@ -248,7 +248,7 @@ export const convertNavGroupToWorkspaceUseCase = ({ id, title, description, - features: navLinks.map((item) => item.id), + features: navLinks.map((item) => ({ id: item.id, title: item.title })), systematic: type === NavGroupType.SYSTEM, order, }); @@ -271,7 +271,11 @@ export const isEqualWorkspaceUseCase = (a: WorkspaceUseCase, b: WorkspaceUseCase } if ( a.features.length !== b.features.length || - a.features.some((featureId) => !b.features.includes(featureId)) + a.features.some((aFeature) => + b.features.some( + (bFeature) => aFeature.id !== bFeature.id || aFeature.title !== bFeature.title + ) + ) ) { return false; } From c029539284149ce8001fb41124f07b032ad14924 Mon Sep 17 00:00:00 2001 From: "opensearch-changeset-bot[bot]" <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 03:16:20 +0000 Subject: [PATCH 2/7] Changeset file for PR #7703 created/updated --- changelogs/fragments/7703.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/7703.yml diff --git a/changelogs/fragments/7703.yml b/changelogs/fragments/7703.yml new file mode 100644 index 000000000000..c918313f6b68 --- /dev/null +++ b/changelogs/fragments/7703.yml @@ -0,0 +1,2 @@ +feat: +- [Workspaces]Add features in workspace use case card ([#7703](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7703)) \ No newline at end of file From 84bffe704e680c9417323ff01417ec892898e895 Mon Sep 17 00:00:00 2001 From: "opensearch-changeset-bot[bot]" <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 06:41:00 +0000 Subject: [PATCH 3/7] Changeset file for PR #7703 created/updated --- changelogs/fragments/7703.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/fragments/7703.yml b/changelogs/fragments/7703.yml index c918313f6b68..acce51db4b96 100644 --- a/changelogs/fragments/7703.yml +++ b/changelogs/fragments/7703.yml @@ -1,2 +1,2 @@ feat: -- [Workspaces]Add features in workspace use case card ([#7703](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7703)) \ No newline at end of file +- [Workspaces]Add features in use case card and preselect first one ([#7703](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7703)) \ No newline at end of file From d63b019475a39e3ee025fca012e836afcad07a54 Mon Sep 17 00:00:00 2001 From: "opensearch-changeset-bot[bot]" <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Date: Wed, 14 Aug 2024 06:41:15 +0000 Subject: [PATCH 4/7] Changeset file for PR #7703 created/updated --- changelogs/fragments/7703.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/fragments/7703.yml b/changelogs/fragments/7703.yml index acce51db4b96..721972dab4db 100644 --- a/changelogs/fragments/7703.yml +++ b/changelogs/fragments/7703.yml @@ -1,2 +1,2 @@ feat: -- [Workspaces]Add features in use case card and preselect first one ([#7703](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7703)) \ No newline at end of file +- [Workspaces]Add features in use case card and preselect first use case ([#7703](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7703)) \ No newline at end of file From 6330a33f54c4cd532a46c07022ec01b175bd35f7 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 14 Aug 2024 18:00:45 +0800 Subject: [PATCH 5/7] Add more test cases Signed-off-by: Lin Wang --- .../workspace_form/workspace_form.test.tsx | 25 +++++++++++- .../public/services/use_case_service.test.ts | 40 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx index 1a8585036c29..66279a68a1b6 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx @@ -4,12 +4,12 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import { WorkspaceForm } from './workspace_form'; +import { fireEvent, render } from '@testing-library/react'; import { coreMock } from '../../../../../core/public/mocks'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { createMockedRegisteredUseCases } from '../../mocks'; import { WorkspaceOperationType } from './constants'; +import { WorkspaceForm } from './workspace_form'; const mockCoreStart = coreMock.createStart(); @@ -61,9 +61,30 @@ describe('WorkspaceForm', () => { expect(queryByText('Associate data source')).not.toBeInTheDocument(); }); + it('should not display data source panel when data source is disabled', () => { const { queryByText } = setup(true, undefined); expect(queryByText('Associate data source')).not.toBeInTheDocument(); }); + + it('should automatic update workspace name after use case changed', () => { + const { getByTestId } = setup(false, mockDataSourceManagementSetup); + + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + expect(nameInput).toHaveValue(''); + fireEvent.click(getByTestId('workspaceUseCase-observability')); + expect(nameInput).toHaveValue('Observability'); + }); + + it('should not automatic update workspace name after manual input', () => { + const { getByTestId } = setup(false, mockDataSourceManagementSetup); + + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceUseCase-observability')); + expect(nameInput).toHaveValue('test workspace name'); + }); }); diff --git a/src/plugins/workspace/public/services/use_case_service.test.ts b/src/plugins/workspace/public/services/use_case_service.test.ts index 3eeaf83cb5a7..00938fd7d60d 100644 --- a/src/plugins/workspace/public/services/use_case_service.test.ts +++ b/src/plugins/workspace/public/services/use_case_service.test.ts @@ -6,19 +6,26 @@ import { BehaviorSubject } from 'rxjs'; import { first } from 'rxjs/operators'; import { chromeServiceMock } from '../../../../core/public/mocks'; -import { DEFAULT_NAV_GROUPS, NavGroupType } from '../../../../core/public'; +import { + ALL_USE_CASE_ID, + DEFAULT_NAV_GROUPS, + NavGroupItemInMap, + NavGroupType, +} from '../../../../core/public'; import { UseCaseService } from './use_case_service'; const mockNavGroupsMap = { system: { id: 'system', title: 'System', + description: 'System use case', navLinks: [], type: NavGroupType.SYSTEM, }, search: { id: 'search', title: 'Search', + description: 'Search use case', navLinks: [{ id: 'searchRelevance', title: 'Search Relevance' }], order: 2000, }, @@ -35,7 +42,7 @@ const setupUseCaseStart = (options?: { navGroupEnabled?: boolean }) => { const workspaceConfigurableApps$ = new BehaviorSubject([ { id: 'searchRelevance', title: 'Search Relevance' }, ]); - const navGroupsMap$ = new BehaviorSubject(mockNavGroupsMap); + const navGroupsMap$ = new BehaviorSubject>(mockNavGroupsMap); const useCase = new UseCaseService(); chrome.navGroup.getNavGroupEnabled.mockImplementation(() => options?.navGroupEnabled ?? true); @@ -123,5 +130,34 @@ describe('UseCaseService', () => { }); expect(fn).toHaveBeenCalledTimes(2); }); + it('should move all use case to the last one', async () => { + const { useCaseStart, navGroupsMap$ } = setupUseCaseStart(); + + navGroupsMap$.next({ + ...mockNavGroupsMap, + [ALL_USE_CASE_ID]: { ...DEFAULT_NAV_GROUPS.all, navLinks: [], order: -1 }, + }); + let useCases = await useCaseStart.getRegisteredUseCases$().pipe(first()).toPromise(); + + expect(useCases[useCases.length - 1]).toEqual( + expect.objectContaining({ + id: ALL_USE_CASE_ID, + systematic: true, + }) + ); + + navGroupsMap$.next({ + [ALL_USE_CASE_ID]: { ...DEFAULT_NAV_GROUPS.all, navLinks: [], order: 1500 }, + ...mockNavGroupsMap, + }); + useCases = await useCaseStart.getRegisteredUseCases$().pipe(first()).toPromise(); + + expect(useCases[useCases.length - 1]).toEqual( + expect.objectContaining({ + id: ALL_USE_CASE_ID, + systematic: true, + }) + ); + }); }); }); From 3277af604a42b18e0b31efcad1f48ecba0cf056b Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Tue, 20 Aug 2024 15:42:07 +0800 Subject: [PATCH 6/7] Update snapshort of workspace list Signed-off-by: Lin Wang --- .../workspace_list/__snapshots__/index.test.tsx.snap | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap index c85168003d76..3ead301fd7c2 100644 --- a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap @@ -691,7 +691,9 @@ exports[`WorkspaceList should render title and table normally 1`] = ` > + > + Search + Date: Wed, 21 Aug 2024 08:33:11 +0800 Subject: [PATCH 7/7] Address pr comments Signed-off-by: Lin Wang --- .../workspace_creator/workspace_creator.tsx | 35 ++++++++++--------- .../use_form_available_use_cases.ts | 2 +- 2 files changed, 20 insertions(+), 17 deletions(-) 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 a0bdd80c9ca6..a7a2b247914a 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -110,6 +110,13 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { [notifications?.toasts, http, application, workspaceClient] ); + const isFormReadyToRender = + application && + savedObjects && + // Default values only worked for component mount, should wait for isOnlyAllowEssential and availableUseCases loaded + isOnlyAllowEssential !== undefined && + availableUseCases !== undefined; + return ( { color="subdued" hasShadow={false} > - {application && - savedObjects && - // Default values only worked for component mount, should wait for isOnlyAllowEssential and availableUseCases loaded - isOnlyAllowEssential !== undefined && - availableUseCases !== undefined && ( - - )} + {isFormReadyToRender && ( + + )} diff --git a/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts b/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts index bbbf92c82079..8aea12326173 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_form_available_use_cases.ts @@ -41,7 +41,7 @@ export const useFormAvailableUseCases = ({ const result = await getIsOnlyAllowEssentialUseCase(savedObjects.client); updateEssential(result); } catch (e) { - // Set to false is failed to fetch is only allow essential use case + // Set to false if failed to fetch the "only allow essential use case" setting updateEssential(false); } })();