From fceba91ba2c55c03efd48c82c795f9c557d8af84 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 19 Jul 2024 14:31:49 +0800 Subject: [PATCH] [Workspace] use registered nav groups in workspace form (#7221) * Add registered use cases Signed-off-by: Lin Wang * Separate use case service and fix UTs Signed-off-by: Lin Wang * Add test case for workspace plugin Signed-off-by: Lin Wang * Fix workspace unit tests Signed-off-by: Lin Wang * Changeset file for PR #7221 created/updated * Remove workspaceConfigurableApp$ in component Signed-off-by: Lin Wang * Remove no need workspaceConfigurableApps$ and add navGroupUpdater ut Signed-off-by: Lin Wang * Fix type error Signed-off-by: Lin Wang * Remove centered horizontal position Signed-off-by: Lin Wang * Fix isDashboardAdmin in workspace creator unit tests Signed-off-by: Lin Wang * Fix dynamic nav groups missing in workspace list Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7221.yml | 2 + src/plugins/workspace/public/application.tsx | 10 +- .../workspace_creator.test.tsx | 74 +++---- .../workspace_creator/workspace_creator.tsx | 14 +- .../workspace_detail.test.tsx | 14 +- .../workspace_detail/workspace_detail.tsx | 9 +- .../workspace_updater.test.tsx | 68 +++--- .../workspace_detail/workspace_updater.tsx | 11 +- .../public/components/workspace_form/types.ts | 9 +- .../workspace_form/workspace_detail_form.tsx | 4 +- .../workspace_form/workspace_form.test.tsx | 4 + .../workspace_form/workspace_form.tsx | 4 +- .../workspace_use_case.test.tsx | 13 +- .../workspace_form/workspace_use_case.tsx | 54 ++--- .../components/workspace_list/index.test.tsx | 24 ++- .../components/workspace_list/index.tsx | 23 +- .../public/components/workspace_list_app.tsx | 8 +- src/plugins/workspace/public/plugin.test.ts | 111 +++++++++- src/plugins/workspace/public/plugin.ts | 70 +++++-- .../public/services/use_case_service.test.ts | 120 +++++++++++ .../public/services/use_case_service.ts | 73 +++++++ src/plugins/workspace/public/types.ts | 9 + src/plugins/workspace/public/utils.test.ts | 196 ++++++++++++++++-- src/plugins/workspace/public/utils.ts | 82 ++++++-- 24 files changed, 759 insertions(+), 247 deletions(-) create mode 100644 changelogs/fragments/7221.yml create mode 100644 src/plugins/workspace/public/services/use_case_service.test.ts create mode 100644 src/plugins/workspace/public/services/use_case_service.ts diff --git a/changelogs/fragments/7221.yml b/changelogs/fragments/7221.yml new file mode 100644 index 000000000000..f173960109d7 --- /dev/null +++ b/changelogs/fragments/7221.yml @@ -0,0 +1,2 @@ +feat: +- Use registered nav group as workspace use case ([#7221](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7221)) \ No newline at end of file diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index 717c0aadd623..3e9cb3a506eb 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -9,7 +9,7 @@ import { AppMountParameters, ScopedHistory } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceFatalError } from './components/workspace_fatal_error'; import { WorkspaceCreatorApp } from './components/workspace_creator_app'; -import { WorkspaceListApp } from './components/workspace_list_app'; +import { WorkspaceListApp, WorkspaceListAppProps } from './components/workspace_list_app'; import { Services } from './types'; import { WorkspaceCreatorProps } from './components/workspace_creator/workspace_creator'; import { WorkspaceDetailApp } from './components/workspace_detail_app'; @@ -46,10 +46,14 @@ export const renderFatalErrorApp = (params: AppMountParameters, services: Servic ReactDOM.unmountComponentAtNode(element); }; }; -export const renderListApp = ({ element }: AppMountParameters, services: Services) => { +export const renderListApp = ( + { element }: AppMountParameters, + services: Services, + props: WorkspaceListAppProps +) => { ReactDOM.render( - + , element ); 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 ad3b79da4788..8550e0c4fa91 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 @@ -7,9 +7,13 @@ import React from 'react'; import { PublicAppInfo } from 'opensearch-dashboards/public'; import { fireEvent, render, waitFor, act } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; -import { WorkspaceCreator as WorkspaceCreatorComponent } from './workspace_creator'; +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() @@ -43,7 +47,10 @@ const dataSourcesList = [ const mockCoreStart = coreMock.createStart(); -const WorkspaceCreator = (props: any, isDashboardAdmin = false) => { +const WorkspaceCreator = ({ + isDashboardAdmin = false, + ...props +}: Partial) => { const { Provider } = createOpenSearchDashboardsReactContext({ ...mockCoreStart, ...{ @@ -86,10 +93,16 @@ const WorkspaceCreator = (props: any, isDashboardAdmin = false) => { dataSourceManagement: {}, }, }); + const registeredUseCases$ = new BehaviorSubject([ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, + ]); return ( - + ); }; @@ -122,21 +135,13 @@ describe('WorkspaceCreator', () => { }); it('should not create workspace when name is empty', async () => { - const { getByTestId } = render( - - ); + const { getByTestId } = render(); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).not.toHaveBeenCalled(); }); it('should not create workspace with invalid name', async () => { - const { getByTestId } = render( - - ); + const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: '~' }, @@ -146,11 +151,7 @@ describe('WorkspaceCreator', () => { it('should not create workspace without use cases', async () => { setHrefSpy.mockReset(); - const { getByTestId } = render( - - ); + const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -161,11 +162,7 @@ describe('WorkspaceCreator', () => { }); it('cancel create workspace', async () => { - const { findByText, getByTestId } = render( - - ); + const { findByText, getByTestId } = render(); fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); await findByText('Discard changes?'); fireEvent.click(getByTestId('confirmModalConfirmButton')); @@ -173,11 +170,7 @@ describe('WorkspaceCreator', () => { }); it('create workspace with detailed information', async () => { - const { getByTestId } = render( - - ); + const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -217,11 +210,7 @@ describe('WorkspaceCreator', () => { it('should show danger toasts after create workspace failed', async () => { workspaceClientCreate.mockReturnValueOnce({ result: { id: 'failResult' }, success: false }); - const { getByTestId } = render( - - ); + const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -239,11 +228,7 @@ describe('WorkspaceCreator', () => { workspaceClientCreate.mockImplementationOnce(async () => { throw new Error(); }); - const { getByTestId } = render( - - ); + const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -257,12 +242,8 @@ describe('WorkspaceCreator', () => { expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); }); - it('create workspace with current user', async () => { - const { getByTestId } = render( - - ); + it('create workspace with customized permissions', async () => { + const { getByTestId } = render(); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -294,10 +275,7 @@ describe('WorkspaceCreator', () => { it('create workspace with customized selected dataSources', async () => { const { getByTestId, getByTitle, getByText } = render( - + ); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { 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 9d4ccaaef1cb..88c46e973c00 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -7,9 +7,8 @@ import React, { useCallback } from 'react'; import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { useObservable } from 'react-use'; -import { BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; -import { PublicAppInfo } from 'opensearch-dashboards/public'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; @@ -18,9 +17,10 @@ import { WorkspaceClient } from '../../workspace_client'; import { convertPermissionSettingsToPermissions } from '../workspace_form'; import { DataSource } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; +import { WorkspaceUseCase } from '../../types'; export interface WorkspaceCreatorProps { - workspaceConfigurableApps$?: BehaviorSubject; + registeredUseCases$: BehaviorSubject; } export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { @@ -37,10 +37,8 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; }>(); - const workspaceConfigurableApps = useObservable( - props.workspaceConfigurableApps$ ?? of(undefined) - ); const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + const availableUseCases = useObservable(props.registeredUseCases$, []); const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { @@ -96,7 +94,6 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { { savedObjects={savedObjects} onSubmit={handleWorkspaceFormSubmit} operationType={WorkspaceOperationType.Create} - workspaceConfigurableApps={workspaceConfigurableApps} permissionEnabled={isPermissionEnabled} - permissionLastAdminItemDeletable dataSourceManagement={dataSourceManagement} + availableUseCases={availableUseCases} /> )} 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 8b2937abd6c2..030cc03fee99 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 @@ -5,10 +5,11 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { coreMock } from '../../../../../core/public/mocks'; -import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; 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 { WorkspaceDetail } from './workspace_detail'; // all applications @@ -71,9 +72,16 @@ const WorkspaceDetailPage = (props: any) => { }, }); + const registeredUseCases$ = new BehaviorSubject([ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, + ]); + return ( - + ); }; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx index efa5e5a68194..8e1df0bd6372 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx @@ -16,15 +16,16 @@ import { import { useObservable } from 'react-use'; import { i18n } from '@osd/i18n'; -import { CoreStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { CoreStart } from 'opensearch-dashboards/public'; import { BehaviorSubject } from 'rxjs'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceUseCase } from '../../types'; import { WorkspaceDetailContent } from './workspace_detail_content'; import { WorkspaceUpdater } from './workspace_updater'; import { DetailTab } from '../workspace_form/constants'; export interface WorkspaceDetailProps { - workspaceConfigurableApps$?: BehaviorSubject; + registeredUseCases$: BehaviorSubject; } export const WorkspaceDetail = (props: WorkspaceDetailProps) => { @@ -60,8 +61,8 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { }), content: ( ), }, @@ -74,8 +75,8 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { }), content: ( ), }, 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 707f72e17ce6..8f28fdd816dc 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 @@ -7,10 +7,15 @@ import React from 'react'; import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; import { fireEvent, render, waitFor, screen, act } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; -import { WorkspaceUpdater as WorkspaceUpdaterComponent } from './workspace_updater'; + 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 { + WorkspaceUpdater as WorkspaceUpdaterComponent, + WorkspaceUpdaterProps, +} from './workspace_updater'; const workspaceClientUpdate = jest.fn().mockReturnValue({ result: true, success: true }); @@ -70,7 +75,11 @@ const mockCoreStart = coreMock.createStart(); const renderCompleted = () => expect(screen.queryByText('Enter details')).not.toBeNull(); -const WorkspaceUpdater = (props: any) => { +const WorkspaceUpdater = ( + props: Partial & { + workspacesService?: ReturnType; + } +) => { const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); const { Provider } = createOpenSearchDashboardsReactContext({ ...mockCoreStart, @@ -115,10 +124,16 @@ const WorkspaceUpdater = (props: any) => { dataSourceManagement: {}, }, }); + const registeredUseCases$ = new BehaviorSubject([ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, + ]); return ( - + ); }; @@ -154,7 +169,6 @@ describe('WorkspaceUpdater', () => { const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); const { container } = render( @@ -163,12 +177,7 @@ describe('WorkspaceUpdater', () => { }); it('cannot update workspace with invalid name', async () => { - const { getByTestId } = render( - - ); + const { getByTestId } = render(); await waitFor(renderCompleted); @@ -180,12 +189,7 @@ describe('WorkspaceUpdater', () => { }); it('cancel update workspace', async () => { - const { findByText, getByTestId } = render( - - ); + const { findByText, getByTestId } = render(); await waitFor(renderCompleted); fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); @@ -194,12 +198,9 @@ describe('WorkspaceUpdater', () => { expect(navigateToApp).toHaveBeenCalled(); }); - it('update workspace successfully without permission', async () => { + it('update workspace successfully', async () => { const { getByTestId, getAllByLabelText } = render( - + ); await waitFor(renderCompleted); @@ -258,10 +259,7 @@ describe('WorkspaceUpdater', () => { it('update workspace permission successfully', async () => { const { getByTestId, getAllByTestId } = render( - + ); await waitFor(() => expect(screen.queryByText('Manage access and permissions')).not.toBeNull()); @@ -304,12 +302,7 @@ describe('WorkspaceUpdater', () => { it('should show danger toasts after update workspace failed', async () => { workspaceClientUpdate.mockReturnValue({ result: false, success: false }); - const { getByTestId } = render( - - ); + const { getByTestId } = render(); await waitFor(renderCompleted); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); @@ -328,12 +321,7 @@ describe('WorkspaceUpdater', () => { workspaceClientUpdate.mockImplementation(() => { throw new Error('update workspace failed'); }); - const { getByTestId } = render( - - ); + const { getByTestId } = render(); await waitFor(renderCompleted); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); @@ -351,11 +339,7 @@ describe('WorkspaceUpdater', () => { it('should show danger toasts when currentWorkspace is missing after click update button', async () => { const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); const { getByTestId } = render( - + ); await waitFor(renderCompleted); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx index f0c4869f441f..7ba838303a89 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx @@ -6,7 +6,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import { PublicAppInfo } from 'opensearch-dashboards/public'; import { useObservable } from 'react-use'; import { BehaviorSubject, of } from 'rxjs'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; @@ -25,9 +24,10 @@ import { getDataSourcesList } from '../../utils'; import { DataSource } from '../../../common/types'; import { DetailTab } from '../workspace_form/constants'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; +import { WorkspaceUseCase } from '../../types'; export interface WorkspaceUpdaterProps { - workspaceConfigurableApps$?: BehaviorSubject; + registeredUseCases$: BehaviorSubject; detailTab?: DetailTab; } @@ -66,9 +66,7 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { }>(); const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); - const workspaceConfigurableApps = useObservable( - props.workspaceConfigurableApps$ ?? of(undefined) - ); + const availableUseCases = useObservable(props.registeredUseCases$, []); const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); const handleWorkspaceFormSubmit = useCallback( @@ -150,7 +148,6 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { { defaultValues={currentWorkspaceFormData} onSubmit={handleWorkspaceFormSubmit} operationType={WorkspaceOperationType.Update} - workspaceConfigurableApps={workspaceConfigurableApps} savedObjects={savedObjects} detailTab={props.detailTab} dataSourceManagement={dataSourceManagement} + availableUseCases={availableUseCases} /> )} diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 64010f90ef7b..0e2ab1631fc9 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -3,15 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - ApplicationStart, - PublicAppInfo, - SavedObjectsStart, -} from '../../../../../core/public'; +import type { ApplicationStart, SavedObjectsStart } from '../../../../../core/public'; import type { WorkspacePermissionMode } from '../../../common/constants'; import type { DetailTab, WorkspaceOperationType, WorkspacePermissionItemType } from './constants'; import { DataSource } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; +import { WorkspaceUseCase } from '../../types'; export interface WorkspaceUserPermissionSetting { id: number; @@ -84,8 +81,8 @@ export interface WorkspaceFormProps { onSubmit?: (formData: WorkspaceFormSubmitData) => void; defaultValues?: WorkspaceFormData; operationType: WorkspaceOperationType; - workspaceConfigurableApps?: PublicAppInfo[]; permissionEnabled?: boolean; detailTab?: DetailTab; dataSourceManagement?: DataSourceManagementPluginSetup; + availableUseCases: WorkspaceUseCase[]; } diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx index 1369abca517e..2e8b7cb32415 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx @@ -48,7 +48,7 @@ export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { savedObjects, defaultValues, operationType, - workspaceConfigurableApps, + availableUseCases, dataSourceManagement: isDataSourceEnabled, } = props; const { @@ -109,10 +109,10 @@ export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { 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 ad79181af285..f9b6fea2e66e 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,6 +8,8 @@ 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 { WorkspaceOperationType } from './constants'; const mockCoreStart = coreMock.createStart(); @@ -38,6 +40,8 @@ const setup = ( ); 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 25381fd94e6c..07c86ef15ab0 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -30,8 +30,8 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { defaultValues, operationType, permissionEnabled, - workspaceConfigurableApps, dataSourceManagement: isDataSourceEnabled, + availableUseCases, } = props; const { formId, @@ -85,10 +85,10 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { 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 d758c3ee0d60..7aa04a547c2c 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,6 +5,7 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; +import { WORKSPACE_USE_CASES } from '../../../common/constants'; import { WorkspaceUseCase, WorkspaceUseCaseProps } from './workspace_use_case'; import { WorkspaceFormErrors } from './types'; @@ -13,9 +14,17 @@ const setup = (options?: Partial) => { const formErrors: WorkspaceFormErrors = {}; const renderResult = render( void; formErrors: WorkspaceFormErrors; + availableUseCases: WorkspaceUseCaseObject[]; } export const WorkspaceUseCase = ({ - configurableApps, value, onChange, formErrors, + availableUseCases, }: WorkspaceUseCaseProps) => { - const availableUseCases = useMemo(() => { - if (!configurableApps) { - return []; - } - const configurableAppsId = configurableApps.map((app) => app.id); - return ALL_USE_CASES.filter((useCase) => { - return useCase.features.some((featureId) => configurableAppsId.includes(featureId)); - }); - }, [configurableApps]); - const handleCardChange = useCallback( (id: string) => { if (!value.includes(id)) { @@ -98,17 +80,19 @@ export const WorkspaceUseCase = ({ fullWidth > - {availableUseCases.map(({ id, title, description }) => ( - - - - ))} + {availableUseCases + .filter((item) => !item.systematic) + .map(({ id, title, description }) => ( + + + + ))} ); 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 c716c745736a..5e55205c196e 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -4,15 +4,14 @@ */ import React from 'react'; -import { WorkspaceList } from './index'; -import { coreMock } from '../../../../../core/public/mocks'; +import { BehaviorSubject, 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 { of } from 'rxjs'; - import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import { WorkspaceList } from './index'; jest.mock('../utils/workspace'); @@ -43,7 +42,9 @@ function getWrapWorkspaceListInContext( return ( - + ); @@ -51,15 +52,24 @@ function getWrapWorkspaceListInContext( describe('WorkspaceList', () => { it('should render title and table normally', () => { - const { getByText, getByRole, container } = render(); + const { getByText, getByRole, container } = render( + + ); expect(getByText('Workspaces')).toBeInTheDocument(); expect(getByRole('table')).toBeInTheDocument(); expect(container).toMatchSnapshot(); }); it('should render data in table based on workspace list data', async () => { const { getByText } = render(getWrapWorkspaceListInContext()); + + // should display workspace names expect(getByText('name1')).toBeInTheDocument(); expect(getByText('name2')).toBeInTheDocument(); + + // should display use case + expect(getByText('Observability')).toBeInTheDocument(); }); it('should be able to apply debounce search after input', async () => { const list = [ diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 563a90f04970..0d2e3c79082d 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -15,28 +15,34 @@ import { EuiSearchBarProps, } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { i18n } from '@osd/i18n'; import { debounce } from '../../../../../core/public'; import { WorkspaceAttribute } from '../../../../../core/public'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { navigateToWorkspaceDetail } from '../utils/workspace'; -import { WORKSPACE_CREATE_APP_ID, WORKSPACE_USE_CASES } from '../../../common/constants'; +import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; import { cleanWorkspaceId } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; -import { getUseCaseFromFeatureConfig, isUseCaseFeatureConfig } from '../../utils'; +import { getUseCaseFromFeatureConfig } from '../../utils'; +import { WorkspaceUseCase } from '../../types'; -const WORKSPACE_LIST_PAGE_DESCRIPTIOIN = i18n.translate('workspace.list.description', { +const WORKSPACE_LIST_PAGE_DESCRIPTION = i18n.translate('workspace.list.description', { defaultMessage: 'Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace.', }); -export const WorkspaceList = () => { +export interface WorkspaceListProps { + registeredUseCases$: BehaviorSubject; +} + +export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { const { services: { workspaces, application, http }, } = useOpenSearchDashboards(); + const registeredUseCases = useObservable(registeredUseCases$); const initialSortField = 'name'; const initialSortDirection = 'asc'; @@ -106,7 +112,10 @@ export const WorkspaceList = () => { features.forEach((featureConfig) => { const useCaseId = getUseCaseFromFeatureConfig(featureConfig); if (useCaseId) { - results.push(WORKSPACE_USE_CASES[useCaseId].title); + const useCase = registeredUseCases?.find(({ id }) => id === useCaseId); + if (useCase) { + results.push(useCase.title); + } } }); return results.join(', '); @@ -182,7 +191,7 @@ export const WorkspaceList = () => { { +export type WorkspaceListAppProps = WorkspaceListProps; + +export const WorkspaceListApp = (props: WorkspaceListAppProps) => { const { services: { chrome }, } = useOpenSearchDashboards(); @@ -29,7 +31,7 @@ export const WorkspaceListApp = () => { return ( - + ); }; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index ff1c05e1c237..d3fff9a3f577 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -5,14 +5,21 @@ import { BehaviorSubject, Observable, Subscriber } from 'rxjs'; import { waitFor } from '@testing-library/dom'; -import { ChromeBreadcrumb } from 'opensearch-dashboards/public'; -import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; +import { first } from 'rxjs/operators'; + import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; -import { DEFAULT_NAV_GROUPS, AppNavLinkStatus } from '../../../core/public'; -import { WorkspacePlugin } from './plugin'; +import { + ChromeBreadcrumb, + NavGroupStatus, + DEFAULT_NAV_GROUPS, + AppNavLinkStatus, +} from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_DETAIL_APP_ID } from '../common/constants'; import { savedObjectsManagementPluginMock } from '../../saved_objects_management/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; +import { UseCaseService } from './services/use_case_service'; +import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; +import { WorkspacePlugin } from './plugin'; describe('Workspace plugin', () => { const getSetupMock = () => ({ @@ -234,6 +241,62 @@ describe('Workspace plugin', () => { }); }); + it('#start should not update systematic use case features after currentWorkspace set', async () => { + const registeredUseCases$ = new BehaviorSubject([ + { + id: 'foo', + title: 'Foo', + features: ['system-feature'], + systematic: true, + }, + ]); + jest.spyOn(UseCaseService.prototype, 'start').mockImplementationOnce(() => ({ + getRegisteredUseCases$: jest.fn(() => registeredUseCases$), + })); + const workspacePlugin = new WorkspacePlugin(); + const setupMock = getSetupMock(); + const coreStart = coreMock.createStart(); + await workspacePlugin.setup(setupMock, {}); + const workspaceObject = { + id: 'foo', + name: 'bar', + features: ['baz'], + }; + coreStart.workspaces.currentWorkspace$.next(workspaceObject); + + const appUpdater$ = setupMock.application.registerAppUpdater.mock.calls[0][0]; + + workspacePlugin.start(coreStart); + + const appUpdater = await appUpdater$.pipe(first()).toPromise(); + + expect(appUpdater({ id: 'system-feature' })).toBeUndefined(); + }); + + it('#start should update nav group status after currentWorkspace set', async () => { + const workspacePlugin = new WorkspacePlugin(); + const setupMock = getSetupMock(); + const coreStart = coreMock.createStart(); + await workspacePlugin.setup(setupMock, {}); + const workspaceObject = { + id: 'foo', + name: 'bar', + features: ['use-case-foo'], + }; + coreStart.workspaces.currentWorkspace$.next(workspaceObject); + + const navGroupUpdater$ = setupMock.chrome.navGroup.registerNavGroupUpdater.mock.calls[0][0]; + + workspacePlugin.start(coreStart); + + const navGroupUpdater = await navGroupUpdater$.pipe(first()).toPromise(); + + expect(navGroupUpdater({ id: 'foo' })).toBeUndefined(); + expect(navGroupUpdater({ id: 'bar' })).toEqual({ + status: NavGroupStatus.Hidden, + }); + }); + it('#stop should call unregisterNavGroupUpdater', async () => { const workspacePlugin = new WorkspacePlugin(); const setupMock = getSetupMock(); @@ -247,4 +310,44 @@ describe('Workspace plugin', () => { expect(unregisterNavGroupUpdater).toHaveBeenCalled(); }); + + it('#stop should not call appUpdater$.next anymore', async () => { + const registeredUseCases$ = new BehaviorSubject([ + { + id: 'foo', + title: 'Foo', + features: ['system-feature'], + systematic: true, + }, + ]); + jest.spyOn(UseCaseService.prototype, 'start').mockImplementationOnce(() => ({ + getRegisteredUseCases$: jest.fn(() => registeredUseCases$), + })); + const workspacePlugin = new WorkspacePlugin(); + const setupMock = getSetupMock(); + const coreStart = coreMock.createStart(); + await workspacePlugin.setup(setupMock, {}); + const workspaceObject = { + id: 'foo', + name: 'bar', + features: ['baz'], + }; + coreStart.workspaces.currentWorkspace$.next(workspaceObject); + + const appUpdater$ = setupMock.application.registerAppUpdater.mock.calls[0][0]; + const appUpdaterChangeMock = jest.fn(); + appUpdater$.subscribe(appUpdaterChangeMock); + + workspacePlugin.start(coreStart); + + // Wait for filterNav been executed + await new Promise(setImmediate); + + expect(appUpdaterChangeMock).toHaveBeenCalledTimes(2); + + workspacePlugin.stop(); + + registeredUseCases$.next([]); + expect(appUpdaterChangeMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 4bfdb0dde002..db496633128c 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; import React from 'react'; import { i18n } from '@osd/i18n'; -import { first } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { Plugin, CoreStart, @@ -15,12 +15,12 @@ import { AppNavLinkStatus, AppUpdater, AppStatus, - PublicAppInfo, ChromeBreadcrumb, WorkspaceAvailability, ChromeNavGroupUpdater, NavGroupStatus, DEFAULT_NAV_GROUPS, + NavGroupType, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, @@ -29,7 +29,7 @@ import { WORKSPACE_LIST_APP_ID, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; -import { Services } from './types'; +import { Services, WorkspaceUseCase } from './types'; import { WorkspaceClient } from './workspace_client'; import { SavedObjectsManagementPluginSetup } from '../../../plugins/saved_objects_management/public'; import { ManagementSetup } from '../../../plugins/management/public'; @@ -41,11 +41,12 @@ import { isAppAccessibleInWorkspace, isNavGroupInFeatureConfigs, } from './utils'; +import { UseCaseService } from './services/use_case_service'; type WorkspaceAppType = ( params: AppMountParameters, services: Services, - props: Record + props: Record & { registeredUseCases$: BehaviorSubject } ) => () => void; interface WorkspacePluginSetupDeps { @@ -62,8 +63,11 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> private managementCurrentWorkspaceIdSubscription?: Subscription; private appUpdater$ = new BehaviorSubject(() => undefined); private navGroupUpdater$ = new BehaviorSubject(() => undefined); - private workspaceConfigurableApps$ = new BehaviorSubject([]); private unregisterNavGroupUpdater?: () => void; + private registeredUseCases$ = new BehaviorSubject([]); + private registeredUseCasesUpdaterSubscription?: Subscription; + private workspaceAndUseCasesCombineSubscription?: Subscription; + private useCase = new UseCaseService(); private _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { @@ -81,28 +85,42 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> */ private filterNavLinks = (core: CoreStart) => { const currentWorkspace$ = core.workspaces.currentWorkspace$; - this.currentWorkspaceSubscription?.unsubscribe(); - this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { + this.workspaceAndUseCasesCombineSubscription?.unsubscribe(); + this.workspaceAndUseCasesCombineSubscription = combineLatest([ + currentWorkspace$, + this.registeredUseCases$, + ]).subscribe(([currentWorkspace, registeredUseCases]) => { if (currentWorkspace) { this.appUpdater$.next((app) => { - if (isAppAccessibleInWorkspace(app, currentWorkspace)) { + if (isAppAccessibleInWorkspace(app, currentWorkspace, registeredUseCases)) { return; } - if (app.status === AppStatus.inaccessible) { return; } - + if ( + registeredUseCases.some( + (useCase) => useCase.systematic && useCase.features.includes(app.id) + ) + ) { + return; + } /** * Change the app to `inaccessible` if it is not configured in the workspace * If trying to access such app, an "Application Not Found" page will be displayed */ return { status: AppStatus.inaccessible }; }); + } + }); + this.currentWorkspaceSubscription?.unsubscribe(); + this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { + if (currentWorkspace) { this.navGroupUpdater$.next((navGroup) => { if ( + navGroup.type !== NavGroupType.SYSTEM && currentWorkspace.features && !isNavGroupInFeatureConfigs(navGroup.id, currentWorkspace.features) ) { @@ -116,16 +134,12 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }; /** - * Initiate an observable with the value of all applications which can be configured by workspace + * Return an observable with the value of all applications which can be configured by workspace */ - private setWorkspaceConfigurableApps = async (core: CoreStart) => { - const allApps = await new Promise((resolve) => { - core.application.applications$.pipe(first()).subscribe((apps) => { - resolve([...apps.values()]); - }); - }); - const availableApps = filterWorkspaceConfigurableApps(allApps); - this.workspaceConfigurableApps$.next(availableApps); + private getWorkspaceConfigurableApps$ = (core: CoreStart) => { + return core.application.applications$.pipe( + map((apps) => filterWorkspaceConfigurableApps([...apps.values()])) + ); }; /** @@ -248,7 +262,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }; return renderApp(params, services, { - workspaceConfigurableApps$: this.workspaceConfigurableApps$, + registeredUseCases$: this.registeredUseCases$, }); }; @@ -356,11 +370,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); - this.setWorkspaceConfigurableApps(core).then(() => { - // filter the nav links based on the current workspace - this.filterNavLinks(core); + const useCaseStart = this.useCase.start({ + chrome: core.chrome, + workspaceConfigurableApps$: this.getWorkspaceConfigurableApps$(core), }); + this.registeredUseCasesUpdaterSubscription = useCaseStart + .getRegisteredUseCases$() + .subscribe((registeredUseCases) => { + this.registeredUseCases$.next(registeredUseCases); + }); + + this.filterNavLinks(core); + if (!core.chrome.navGroup.getNavGroupEnabled()) { this.addWorkspaceToBreadcrumbs(core); } @@ -374,5 +396,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> this.managementCurrentWorkspaceIdSubscription?.unsubscribe(); this.breadcrumbsSubscription?.unsubscribe(); this.unregisterNavGroupUpdater?.(); + this.registeredUseCasesUpdaterSubscription?.unsubscribe(); + this.workspaceAndUseCasesCombineSubscription?.unsubscribe(); } } diff --git a/src/plugins/workspace/public/services/use_case_service.test.ts b/src/plugins/workspace/public/services/use_case_service.test.ts new file mode 100644 index 000000000000..21049625d85e --- /dev/null +++ b/src/plugins/workspace/public/services/use_case_service.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { chromeServiceMock } from '../../../../core/public/mocks'; +import { NavGroupType } from '../../../../core/public'; +import { UseCaseService } from './use_case_service'; + +const mockNavGroupsMap = { + system: { + id: 'system', + title: 'System', + navLinks: [], + type: NavGroupType.SYSTEM, + }, + search: { + id: 'search', + title: 'Search', + navLinks: [{ id: 'searchRelevance' }], + order: 2000, + }, + observability: { + id: 'observability', + title: 'Observability', + description: 'Observability description', + navLinks: [{ id: 'dashboards' }], + order: 1000, + }, +}; +const setupUseCaseStart = (options?: { navGroupEnabled?: boolean }) => { + const chrome = chromeServiceMock.createStartContract(); + const workspaceConfigurableApps$ = new BehaviorSubject([{ id: 'searchRelevance' }]); + const navGroupsMap$ = new BehaviorSubject(mockNavGroupsMap); + const useCase = new UseCaseService(); + + chrome.navGroup.getNavGroupEnabled.mockImplementation(() => options?.navGroupEnabled ?? true); + chrome.navGroup.getNavGroupsMap$.mockImplementation(() => navGroupsMap$); + + return { + chrome, + navGroupsMap$, + workspaceConfigurableApps$, + useCaseStart: useCase.start({ + chrome, + workspaceConfigurableApps$, + ...options, + }), + }; +}; + +describe('UseCaseService', () => { + describe('#start', () => { + it('should return built in use cases when nav group disabled', async () => { + const { useCaseStart } = setupUseCaseStart({ + navGroupEnabled: false, + }); + const useCases = await useCaseStart.getRegisteredUseCases$().pipe(first()).toPromise(); + + expect(useCases).toHaveLength(1); + expect(useCases).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'search', + title: 'Search', + features: expect.arrayContaining(['searchRelevance']), + }), + ]) + ); + }); + + it('should return registered use cases when nav group disabled', async () => { + const { useCaseStart } = setupUseCaseStart(); + const useCases = await useCaseStart.getRegisteredUseCases$().pipe(first()).toPromise(); + + expect(useCases).toEqual([ + expect.objectContaining({ + id: 'observability', + title: 'Observability', + features: expect.arrayContaining(['dashboards']), + }), + expect.objectContaining({ + id: 'search', + title: 'Search', + features: expect.arrayContaining(['searchRelevance']), + }), + expect.objectContaining({ + id: 'system', + title: 'System', + features: [], + systematic: true, + }), + ]); + }); + + it('should not emit after navGroupsMap$ emit same value', async () => { + const { useCaseStart, navGroupsMap$ } = setupUseCaseStart(); + const registeredUseCase$ = useCaseStart.getRegisteredUseCases$(); + const fn = jest.fn(); + + registeredUseCase$.subscribe(fn); + + expect(fn).toHaveBeenCalledTimes(1); + + navGroupsMap$.next({ ...mockNavGroupsMap }); + expect(fn).toHaveBeenCalledTimes(1); + + navGroupsMap$.next({ + ...mockNavGroupsMap, + observability: { + ...mockNavGroupsMap.observability, + navLinks: [{ id: 'bar' }], + }, + }); + expect(fn).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/plugins/workspace/public/services/use_case_service.ts b/src/plugins/workspace/public/services/use_case_service.ts new file mode 100644 index 000000000000..8c1adbf7d49c --- /dev/null +++ b/src/plugins/workspace/public/services/use_case_service.ts @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +import { ChromeStart, PublicAppInfo } from '../../../../core/public'; +import { WORKSPACE_USE_CASES } from '../../common/constants'; +import { convertNavGroupToWorkspaceUseCase, isEqualWorkspaceUseCase } from '../utils'; + +export class UseCaseService { + constructor() {} + + start({ + chrome, + workspaceConfigurableApps$, + }: { + chrome: ChromeStart; + workspaceConfigurableApps$: Observable; + }) { + return { + getRegisteredUseCases$: () => { + if (chrome.navGroup.getNavGroupEnabled()) { + return chrome.navGroup + .getNavGroupsMap$() + .pipe( + map((navGroupsMap) => + Object.values(navGroupsMap).map(convertNavGroupToWorkspaceUseCase) + ) + ) + .pipe( + distinctUntilChanged((useCases, anotherUseCases) => { + return ( + useCases.length === anotherUseCases.length && + useCases.every( + (useCase) => + !!anotherUseCases.find((anotherUseCase) => + isEqualWorkspaceUseCase(useCase, anotherUseCase) + ) + ) + ); + }) + ) + .pipe( + map((useCases) => + useCases.sort( + (a, b) => + (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) + ) + ) + ); + } + + return workspaceConfigurableApps$.pipe( + map((configurableApps) => { + const configurableAppsId = configurableApps.map((app) => app.id); + + return [ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, + ].filter((useCase) => { + return useCase.features.some((featureId) => configurableAppsId.includes(featureId)); + }); + }) + ); + }, + }; + } +} diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 9435685b9039..79fed7fa81ac 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -11,3 +11,12 @@ export type Services = CoreStart & { workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; }; + +export interface WorkspaceUseCase { + id: string; + title: string; + description: string; + features: string[]; + systematic?: boolean; + order?: number; +} diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index e3211990acf2..852324025100 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AppNavLinkStatus, PublicAppInfo } from '../../../core/public'; +import { AppNavLinkStatus, NavGroupType, PublicAppInfo } from '../../../core/public'; import { featureMatchesConfig, filterWorkspaceConfigurableApps, @@ -11,15 +11,32 @@ import { isFeatureIdInsideUseCase, isNavGroupInFeatureConfigs, getDataSourcesList, + convertNavGroupToWorkspaceUseCase, + isEqualWorkspaceUseCase, } from './utils'; import { WorkspaceAvailability } from '../../../core/public'; import { coreMock } from '../../../core/public/mocks'; +import { WORKSPACE_USE_CASES } from '../common/constants'; const startMock = coreMock.createStart(); +const STATIC_USE_CASES = [ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.search, + WORKSPACE_USE_CASES.analytics, +]; +const useCaseMock = { + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: ['bar'], + systematic: false, + order: 1, +}; describe('workspace utils: featureMatchesConfig', () => { it('feature configured with `*` should match any features', () => { - const match = featureMatchesConfig(['*']); + const match = featureMatchesConfig(['*'], STATIC_USE_CASES); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( true ); @@ -29,28 +46,31 @@ describe('workspace utils: featureMatchesConfig', () => { }); it('should NOT match the config if feature id not matches', () => { - const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize'], STATIC_USE_CASES); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( false ); }); it('should match the config if feature id matches', () => { - const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize'], STATIC_USE_CASES); expect( match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) ).toBe(true); }); it('should match the config if feature category matches', () => { - const match = featureMatchesConfig(['discover', 'dashboards', '@management', 'visualize']); + const match = featureMatchesConfig( + ['discover', 'dashboards', '@management', 'visualize'], + STATIC_USE_CASES + ); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( true ); }); it('should match any features but not the excluded feature id', () => { - const match = featureMatchesConfig(['*', '!discover']); + const match = featureMatchesConfig(['*', '!discover'], STATIC_USE_CASES); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( true ); @@ -60,7 +80,7 @@ describe('workspace utils: featureMatchesConfig', () => { }); it('should match any features but not the excluded feature category', () => { - const match = featureMatchesConfig(['*', '!@management']); + const match = featureMatchesConfig(['*', '!@management'], STATIC_USE_CASES); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( false ); @@ -73,7 +93,7 @@ describe('workspace utils: featureMatchesConfig', () => { }); it('should NOT match the excluded feature category', () => { - const match = featureMatchesConfig(['!@management']); + const match = featureMatchesConfig(['!@management'], STATIC_USE_CASES); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( false ); @@ -83,7 +103,7 @@ describe('workspace utils: featureMatchesConfig', () => { }); it('should match features of a category but NOT the excluded feature', () => { - const match = featureMatchesConfig(['@management', '!dev_tools']); + const match = featureMatchesConfig(['@management', '!dev_tools'], STATIC_USE_CASES); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( false ); @@ -94,7 +114,7 @@ describe('workspace utils: featureMatchesConfig', () => { it('a config presents later in the config array should override the previous config', () => { // though `dev_tools` is excluded, but this config will override by '@management' as dev_tools has category 'management' - const match = featureMatchesConfig(['!dev_tools', '@management']); + const match = featureMatchesConfig(['!dev_tools', '@management'], STATIC_USE_CASES); expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( true ); @@ -104,7 +124,10 @@ describe('workspace utils: featureMatchesConfig', () => { }); it('should match features include by any use cases', () => { - const match = featureMatchesConfig(['use-case-observability', 'use-case-analytics']); + const match = featureMatchesConfig( + ['use-case-observability', 'use-case-analytics'], + STATIC_USE_CASES + ); expect(match({ id: 'dashboards' })).toBe(true); expect(match({ id: 'observability-traces' })).toBe(true); expect(match({ id: 'alerting' })).toBe(true); @@ -117,7 +140,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { expect( isAppAccessibleInWorkspace( { id: 'any_app', title: 'Any app', mount: jest.fn() }, - { id: 'workspace_id', name: 'workspace name' } + { id: 'workspace_id', name: 'workspace name' }, + STATIC_USE_CASES ) ).toBe(true); }); @@ -126,7 +150,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { expect( isAppAccessibleInWorkspace( { id: 'dev_tools', title: 'Any app', mount: jest.fn() }, - { id: 'workspace_id', name: 'workspace name', features: ['dev_tools'] } + { id: 'workspace_id', name: 'workspace name', features: ['dev_tools'] }, + STATIC_USE_CASES ) ).toBe(true); }); @@ -135,7 +160,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { expect( isAppAccessibleInWorkspace( { id: 'dev_tools', title: 'Any app', mount: jest.fn() }, - { id: 'workspace_id', name: 'workspace name', features: [] } + { id: 'workspace_id', name: 'workspace name', features: [] }, + STATIC_USE_CASES ) ).toBe(false); }); @@ -149,7 +175,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { mount: jest.fn(), navLinkStatus: AppNavLinkStatus.hidden, }, - { id: 'workspace_id', name: 'workspace name', features: [] } + { id: 'workspace_id', name: 'workspace name', features: [] }, + STATIC_USE_CASES ) ).toBe(true); }); @@ -163,7 +190,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { mount: jest.fn(), chromeless: true, }, - { id: 'workspace_id', name: 'workspace name', features: [] } + { id: 'workspace_id', name: 'workspace name', features: [] }, + STATIC_USE_CASES ) ).toBe(true); }); @@ -177,7 +205,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { mount: jest.fn(), workspaceAvailability: WorkspaceAvailability.outsideWorkspace, }, - { id: 'workspace_id', name: 'workspace name', features: [] } + { id: 'workspace_id', name: 'workspace name', features: [] }, + STATIC_USE_CASES ) ).toBe(false); }); @@ -190,7 +219,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { mount: jest.fn(), workspaceAvailability: WorkspaceAvailability.insideWorkspace, }, - { id: 'workspace_id', name: 'workspace name', features: ['home'] } + { id: 'workspace_id', name: 'workspace name', features: ['home'] }, + STATIC_USE_CASES ) ).toBe(true); }); @@ -205,7 +235,8 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { // eslint-disable-next-line no-bitwise WorkspaceAvailability.insideWorkspace | WorkspaceAvailability.outsideWorkspace, }, - { id: 'workspace_id', name: 'workspace name', features: ['home'] } + { id: 'workspace_id', name: 'workspace name', features: ['home'] }, + STATIC_USE_CASES ) ).toBe(true); }); @@ -278,7 +309,31 @@ describe('workspace utils: filterWorkspaceConfigurableApps', () => { describe('workspace utils: isFeatureIdInsideUseCase', () => { it('should return false for invalid use case', () => { - expect(isFeatureIdInsideUseCase('discover', 'use-case-invalid')).toBe(false); + expect(isFeatureIdInsideUseCase('discover', 'use-case-invalid', [])).toBe(false); + }); + it('should return false if feature not in use case', () => { + expect( + isFeatureIdInsideUseCase('discover', 'use-case-foo', [ + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: [], + }, + ]) + ).toBe(false); + }); + it('should return true if feature id exists in use case', () => { + expect( + isFeatureIdInsideUseCase('discover', 'use-case-foo', [ + { + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: ['discover'], + }, + ]) + ).toBe(true); }); }); @@ -325,3 +380,104 @@ describe('workspace utils: getDataSourcesList', () => { expect(await getDataSourcesList(mockedSavedObjectClient, [])).toStrictEqual([]); }); }); + +describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { + it('should convert nav group to consistent workspace use case', () => { + expect( + convertNavGroupToWorkspaceUseCase({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'bar' }], + }) + ).toEqual({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: ['bar'], + systematic: false, + }); + + expect( + convertNavGroupToWorkspaceUseCase({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + navLinks: [{ id: 'bar' }], + type: NavGroupType.SYSTEM, + }) + ).toEqual({ + id: 'foo', + title: 'Foo', + description: 'Foo description', + features: ['bar'], + systematic: true, + }); + }); +}); + +describe('workspace utils: isEqualWorkspaceUseCase', () => { + it('should return false when id not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + id: 'foo1', + }) + ).toEqual(false); + }); + it('should return false when title not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + title: 'Foo1', + }) + ).toEqual(false); + }); + it('should return false when description not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + description: 'Foo description 1', + }) + ).toEqual(false); + }); + it('should return false when systematic not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + systematic: true, + }) + ).toEqual(false); + }); + it('should return false when order not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + order: 2, + }) + ).toEqual(false); + }); + it('should return false when features length not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + features: [], + }) + ).toEqual(false); + }); + it('should return false when features content not equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + features: ['baz'], + }) + ).toEqual(false); + }); + it('should return true when all properties equal', () => { + expect( + isEqualWorkspaceUseCase(useCaseMock, { + ...useCaseMock, + }) + ).toEqual(true); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index da9987b2aa1a..aeb46993b6c6 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SavedObjectsStart } from '../../../core/public'; +import { NavGroupType, SavedObjectsStart, NavGroupItemInMap } from '../../../core/public'; import { App, AppCategory, @@ -13,7 +13,8 @@ import { WorkspaceObject, WorkspaceAvailability, } from '../../../core/public'; -import { DEFAULT_SELECTED_FEATURES_IDS, WORKSPACE_USE_CASES } from '../common/constants'; +import { DEFAULT_SELECTED_FEATURES_IDS } from '../common/constants'; +import { WorkspaceUseCase } from './types'; const USE_CASE_PREFIX = 'use-case-'; @@ -22,22 +23,21 @@ export const getUseCaseFeatureConfig = (useCaseId: string) => `${USE_CASE_PREFIX export const isUseCaseFeatureConfig = (featureConfig: string) => featureConfig.startsWith(USE_CASE_PREFIX); -type WorkspaceUseCaseId = keyof typeof WORKSPACE_USE_CASES; - export const getUseCaseFromFeatureConfig = (featureConfig: string) => { if (isUseCaseFeatureConfig(featureConfig)) { - const useCaseId = featureConfig.substring(USE_CASE_PREFIX.length); - if (Object.keys(WORKSPACE_USE_CASES).includes(useCaseId)) { - return useCaseId as WorkspaceUseCaseId; - } + return featureConfig.substring(USE_CASE_PREFIX.length); } return null; }; -export const isFeatureIdInsideUseCase = (featureId: string, featureConfig: string) => { - const useCase = getUseCaseFromFeatureConfig(featureConfig); - if (useCase && useCase in WORKSPACE_USE_CASES) { - return WORKSPACE_USE_CASES[useCase].features.includes(featureId); +export const isFeatureIdInsideUseCase = ( + featureId: string, + featureConfig: string, + useCases: WorkspaceUseCase[] +) => { + const useCase = useCases.find(({ id }) => id === getUseCaseFromFeatureConfig(featureConfig)); + if (useCase) { + return useCase.features.includes(featureId); } return false; }; @@ -58,7 +58,7 @@ export const isNavGroupInFeatureConfigs = (navGroupId: string, featureConfigs: s * 6. For feature id start with use case prefix, it will read use case's features and match every passed apps. * For example, ['user-case-observability'] matches all features under observability use case. */ -export const featureMatchesConfig = (featureConfigs: string[]) => ({ +export const featureMatchesConfig = (featureConfigs: string[], useCases: WorkspaceUseCase[]) => ({ id, category, }: { @@ -79,11 +79,8 @@ export const featureMatchesConfig = (featureConfigs: string[]) => ({ } // matches any feature inside use cases - if (getUseCaseFromFeatureConfig(featureConfig)) { - const isInsideUseCase = isFeatureIdInsideUseCase(id, featureConfig); - if (isInsideUseCase) { - matched = true; - } + if (isFeatureIdInsideUseCase(id, featureConfig, useCases)) { + matched = true; } // The config starts with `@` matches a category @@ -114,7 +111,11 @@ export const featureMatchesConfig = (featureConfigs: string[]) => ({ /** * Check if an app is accessible in a workspace based on the workspace configured features */ -export function isAppAccessibleInWorkspace(app: App, workspace: WorkspaceObject) { +export function isAppAccessibleInWorkspace( + app: App, + workspace: WorkspaceObject, + availableUseCases: WorkspaceUseCase[] +) { /** * App is not accessible within workspace if it explicitly declare itself as WorkspaceAvailability.outsideWorkspace */ @@ -132,7 +133,7 @@ export function isAppAccessibleInWorkspace(app: App, workspace: WorkspaceObject) /** * The app is configured into a workspace, it is accessible after entering the workspace */ - const featureMatcher = featureMatchesConfig(workspace.features); + const featureMatcher = featureMatchesConfig(workspace.features, availableUseCases); if (featureMatcher({ id: app.id, category: app.category })) { return true; } @@ -202,3 +203,44 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac } }); }; + +export const convertNavGroupToWorkspaceUseCase = ({ + id, + title, + description, + navLinks, + type, + order, +}: NavGroupItemInMap): WorkspaceUseCase => ({ + id, + title, + description, + features: navLinks.map((item) => item.id), + systematic: type === NavGroupType.SYSTEM, + order, +}); + +export const isEqualWorkspaceUseCase = (a: WorkspaceUseCase, b: WorkspaceUseCase) => { + if (a.id !== b.id) { + return false; + } + if (a.title !== b.title) { + return false; + } + if (a.description !== b.description) { + return false; + } + if (a.systematic !== b.systematic) { + return false; + } + if (a.order !== b.order) { + return false; + } + if ( + a.features.length !== b.features.length || + a.features.some((featureId) => !b.features.includes(featureId)) + ) { + return false; + } + return true; +};