From 30a847b5baf3d21eeed3e8c42f979341261e2bec Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:08:21 +0800 Subject: [PATCH] [Workspaces]Add features in use case card and preselect first use case (#7703) (#7838) * Display features in workspace use case card * Changeset file for PR #7703 created/updated * Changeset file for PR #7703 created/updated * Changeset file for PR #7703 created/updated * Add more test cases * Update snapshort of workspace list * Address pr comments --------- (cherry picked from commit 446aa08b9730ecd4bfcbbb13e796be932ad29e7a) Signed-off-by: Lin Wang Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7703.yml | 2 + .../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 | 30 +++- .../workspace_detail.test.tsx | 10 +- .../public/components/workspace_form/types.ts | 5 + .../use_form_available_use_cases.test.ts | 140 ++++++++++++++++++ .../use_form_available_use_cases.ts | 71 +++++++++ .../workspace_enter_details_panel.tsx | 9 +- .../workspace_form/workspace_form.test.tsx | 29 +++- .../workspace_form/workspace_form.tsx | 27 +++- .../workspace_use_case.test.tsx | 108 +++++++++++--- .../workspace_form/workspace_use_case.tsx | 120 +++++++++------ .../__snapshots__/index.test.tsx.snap | 4 +- .../components/workspace_list/index.test.tsx | 8 +- .../workspace_menu/workspace_menu.test.tsx | 8 +- 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 | 67 +++++++-- .../public/services/use_case_service.ts | 35 ++++- src/plugins/workspace/public/types.ts | 2 +- src/plugins/workspace/public/utils.test.ts | 34 +++-- src/plugins/workspace/public/utils.ts | 12 +- 25 files changed, 661 insertions(+), 183 deletions(-) create mode 100644 changelogs/fragments/7703.yml 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/changelogs/fragments/7703.yml b/changelogs/fragments/7703.yml new file mode 100644 index 000000000000..721972dab4db --- /dev/null +++ b/changelogs/fragments/7703.yml @@ -0,0 +1,2 @@ +feat: +- [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 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 f42665acd1d1..760c4060de58 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() @@ -96,12 +97,7 @@ const WorkspaceCreator = ({ }, }, }); - 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 ( @@ -139,33 +135,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')); @@ -174,6 +181,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' }, @@ -214,6 +226,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' }, @@ -232,6 +249,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' }, @@ -247,6 +269,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' }, @@ -280,6 +307,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' }, @@ -287,9 +319,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 26bed213d142..a7a2b247914a 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -6,7 +6,6 @@ import React, { useCallback } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, 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'; @@ -19,6 +18,8 @@ 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'; import { NavigationPublicPluginStart } from '../../../../../plugins/navigation/public'; export interface WorkspaceCreatorProps { @@ -26,6 +27,7 @@ export interface WorkspaceCreatorProps { } export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { + const { registeredUseCases$ } = props; const { services: { application, @@ -42,13 +44,24 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { navigationUI: NavigationPublicPluginStart['ui']; }>(); + 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; @@ -97,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 && ( + {isFormReadyToRender && ( { }, }); - 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 ( { + 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..8aea12326173 --- /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 if failed to fetch the "only allow essential use case" setting + 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/workspace_enter_details_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx index fc4a669426b5..0bd172e6c47a 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx @@ -3,14 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - EuiColorPicker, - EuiCompressedFieldText, - EuiCompressedFormRow, - EuiSpacer, - EuiText, - EuiCompressedTextArea, -} from '@elastic/eui'; +import { EuiColorPicker, EuiCompressedFormRow, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; 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..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 { WORKSPACE_USE_CASES } from '../../../common/constants'; +import { createMockedRegisteredUseCases } from '../../mocks'; import { WorkspaceOperationType } from './constants'; +import { WorkspaceForm } from './workspace_form'; 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} /> ); @@ -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/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index 8327bf39b951..f21a800a8357 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, EuiText } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { WorkspaceFormProps } from './types'; @@ -29,7 +29,6 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { permissionEnabled, dataSourceManagement: isDataSourceEnabled, availableUseCases, - operationType, } = props; const { formId, @@ -40,15 +39,33 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { setDescription, handleFormSubmit, handleColorChange, - handleUseCaseChange, + handleUseCaseChange: handleUseCaseChangeInHook, setPermissionSettings, setSelectedDataSources, } = 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( + (newName) => { + setName(newName); + 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 ( @@ -71,7 +88,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { color={formData.color} readOnly={!!defaultValues?.reserved} handleColorChange={handleColorChange} - onNameChange={setName} + onNameChange={handleNameInputChange} onDescriptionChange={setDescription} /> @@ -86,8 +103,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/__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 + - + ); 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/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 f3a5f48f33e8..ab33cd62d075 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -317,7 +317,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 a404132bc990..4731e9d205b7 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -134,7 +134,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..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,34 +6,43 @@ import { BehaviorSubject } from 'rxjs'; import { first } from 'rxjs/operators'; import { chromeServiceMock } from '../../../../core/public/mocks'; -import { 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', - navLinks: [{ id: 'searchRelevance' }], + description: 'Search use case', + 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 navGroupsMap$ = new BehaviorSubject(mockNavGroupsMap); + const workspaceConfigurableApps$ = new BehaviorSubject([ + { id: 'searchRelevance', title: 'Search Relevance' }, + ]); + const navGroupsMap$ = new BehaviorSubject>(mockNavGroupsMap); const useCase = new UseCaseService(); chrome.navGroup.getNavGroupEnabled.mockImplementation(() => options?.navGroupEnabled ?? true); @@ -59,19 +68,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 +93,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 +111,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); @@ -116,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, + }) + ); + }); }); }); diff --git a/src/plugins/workspace/public/services/use_case_service.ts b/src/plugins/workspace/public/services/use_case_service.ts index ae127f08a5f2..681cf141327a 100644 --- a/src/plugins/workspace/public/services/use_case_service.ts +++ b/src/plugins/workspace/public/services/use_case_service.ts @@ -12,6 +12,8 @@ import { DEFAULT_APP_CATEGORIES, PublicAppInfo, WorkspacesSetup, + DEFAULT_NAV_GROUPS, + ALL_USE_CASE_ID, } from '../../../../core/public'; import { WORKSPACE_USE_CASES } from '../../common/constants'; import { @@ -19,6 +21,7 @@ import { getFirstUseCaseOfFeatureConfigs, isEqualWorkspaceUseCase, } from '../utils'; +import { WorkspaceUseCase } from '../types'; export interface UseCaseServiceSetupDeps { chrome: CoreSetup['chrome']; @@ -120,10 +123,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) - ) + ); + }) ) ); } @@ -137,9 +148,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 d5cfc224416f..2fc342c0f8c3 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -18,7 +18,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 4e0146b39ad6..6f599084f9db 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); @@ -469,13 +465,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, }); @@ -484,14 +480,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, }); }); @@ -546,11 +542,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 d57cacde7684..2bc4f7a80155 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -48,7 +48,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[]) => @@ -255,7 +255,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 || id === ALL_USE_CASE_ID, order, }); @@ -278,7 +278,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; } @@ -377,7 +381,7 @@ export const getUseCaseUrl = ( http: HttpSetup ): string => { 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( application.getUrlForApp(appId, { absolute: false,