From d8b595d885a610d7fc41698a93c8b6103db05609 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Fri, 7 Jun 2024 09:47:31 -0700 Subject: [PATCH] [Workspace]Feat add use cases to workspace form (#6887) (#6967) * Add workspace use case to workspace form * Remove feature selector in workspace form * Show use cases in workspace list page * Change direction for workspace use case selector * Modify test cases for match use case * Make use cases as a required field * Update ui according feedbacks * Add management feature to dashboards and visualize use cases * Update latest feature relationships * Changeset file for PR #6887 created/updated * Changeset file for PR #6887 created/updated * Update test case for workspace creator and updater * Address unit test * Add discover feature to all use case * Add missing features to security analytics * Address PR comments * Add comment for workspace use cases map * Update use case UI * Remove the permissions tab * Update breadcrum to Create a workspace * Address ut failed --------- (cherry picked from commit 7be3e3090d7ae702dbfa61ef140f1d6a6b1c9f0f) 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> Co-authored-by: Yulong Ruan --- changelogs/fragments/6887.yml | 2 + src/plugins/workspace/common/constants.ts | 103 +++++++++++++ .../workspace_creator.test.tsx | 66 ++++---- .../workspace_creator/workspace_creator.tsx | 2 +- .../components/workspace_creator_app.tsx | 2 +- .../components/workspace_form/constants.ts | 6 - .../public/components/workspace_form/types.ts | 10 -- .../workspace_form/use_workspace_form.test.ts | 26 +++- .../workspace_form/use_workspace_form.ts | 91 +++++------ .../components/workspace_form/utils.test.ts | 96 +----------- .../public/components/workspace_form/utils.ts | 73 ++------- .../workspace_feature_selector.test.tsx | 100 ------------ .../workspace_feature_selector.tsx | 142 ------------------ .../workspace_form/workspace_form.tsx | 81 ++++------ .../workspace_form/workspace_use_case.scss | 8 + .../workspace_use_case.test.tsx | 55 +++++++ .../workspace_form/workspace_use_case.tsx | 99 ++++++++++++ .../__snapshots__/index.test.tsx.snap | 4 +- .../components/workspace_list/index.test.tsx | 3 +- .../components/workspace_list/index.tsx | 18 ++- .../workspace_overview.test.tsx | 2 +- .../workspace_updater.test.tsx | 16 +- src/plugins/workspace/public/plugin.ts | 2 +- src/plugins/workspace/public/utils.test.ts | 15 ++ src/plugins/workspace/public/utils.ts | 39 ++++- 25 files changed, 484 insertions(+), 577 deletions(-) create mode 100644 changelogs/fragments/6887.yml delete mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_use_case.scss create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx diff --git a/changelogs/fragments/6887.yml b/changelogs/fragments/6887.yml new file mode 100644 index 000000000000..b98591b0aa4c --- /dev/null +++ b/changelogs/fragments/6887.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Add use cases to workspace form ([#6887](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6887)) \ No newline at end of file diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 17a8291b4f72..4380ce40a10b 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -79,3 +79,106 @@ export const WORKSPACE_APP_CATEGORIES: Record = Object.free order: 14000, }, }); +/** + * + * This is a temp solution to store relationships between use cases and features. + * The relationship should be provided by plugin itself. The workspace plugin should + * provide some method to register single feature to the use case map instead of + * store a static map in workspace. + * + */ +export const WORKSPACE_USE_CASES = Object.freeze({ + observability: { + id: 'observability', + title: i18n.translate('workspace.usecase.observability.title', { + defaultMessage: 'Observability', + }), + description: i18n.translate('workspace.usecase.observability.description', { + defaultMessage: + 'Gain visibility into system health, performance, and reliability through monitoring and analysis of logs, metrics, and traces.', + }), + features: [ + 'discover', + 'dashboards', + 'visualize', + 'maps-dashboards', + 'observability-notebooks', + 'reports-dashboards', + 'integrations', + 'alerting', + 'anomaly-detection-dashboards', + 'observability-metrics', + 'observability-traces', + 'observability-applications', + // Add management avoid index patterns application not found for dashboards or visualize + 'management', + ] as string[], + }, + 'security-analytics': { + id: 'security-analytics', + title: i18n.translate('workspace.usecase.security.analytics.title', { + defaultMessage: 'Security Analytics', + }), + description: i18n.translate('workspace.usecase.analytics.description', { + defaultMessage: + 'Detect and investigate potential security threats and vulnerabilities across your systems and data.', + }), + features: [ + 'discover', + 'dashboards', + 'visualize', + 'maps-dashboards', + 'observability-notebooks', + 'reports-dashboards', + 'integrations', + 'alerting', + 'anomaly-detection-dashboards', + 'opensearch_security_analytics_dashboards', + // Add management avoid index patterns application not found for dashboards or visualize + 'management', + ] as string[], + }, + analytics: { + id: 'analytics', + title: i18n.translate('workspace.usecase.analytics.title', { + defaultMessage: 'Analytics', + }), + description: i18n.translate('workspace.usecase.analytics.description', { + defaultMessage: + 'Analyze data to derive insights, identify patterns and trends, and make data-driven decisions.', + }), + features: [ + 'discover', + 'dashboards', + 'visualize', + 'maps-dashboards', + 'observability-notebooks', + 'reports-dashboards', + 'integrations', + 'alerting', + 'anomaly-detection-dashboards', + // Add management avoid index patterns application not found for dashboards or visualize + 'management', + ] as string[], + }, + search: { + id: 'search', + title: i18n.translate('workspace.usecase.search.title', { + defaultMessage: 'Search', + }), + description: i18n.translate('workspace.usecase.search.description', { + defaultMessage: + "Quickly find and explore relevant information across your organization's data sources.", + }), + features: [ + 'discover', + 'dashboards', + 'visualize', + 'maps-dashboards', + 'reports-dashboards', + 'searchRelevance', + // Add management avoid index patterns application not found for dashboards or visualize + 'management', + ] as string[], + }, +}); 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 06d766a831e1..1c1632f7cb4d 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 @@ -19,11 +19,8 @@ const navigateToApp = jest.fn(); const notificationToastsAddSuccess = jest.fn(); const notificationToastsAddDanger = jest.fn(); const PublicAPPInfoMap = new Map([ - ['app1', { id: 'app1', title: 'app1' }], - ['app2', { id: 'app2', title: 'app2', category: { id: 'category1', label: 'category1' } }], - ['app3', { id: 'app3', category: { id: 'category1', label: 'category1' } }], - ['app4', { id: 'app4', category: { id: 'category2', label: 'category2' } }], - ['app5', { id: 'app5', category: { id: 'category2', label: 'category2' } }], + ['data-explorer', { id: 'data-explorer', title: 'Data Explorer' }], + ['dashboards', { id: 'dashboards', title: 'Dashboards' }], ]); const mockCoreStart = coreMock.createStart(); @@ -116,6 +113,22 @@ describe('WorkspaceCreator', () => { expect(workspaceClientCreate).not.toHaveBeenCalled(); }); + it('should not create workspace without use cases', async () => { + setHrefSpy.mockReset(); + const { getByTestId } = render( + + ); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + expect(setHrefSpy).not.toHaveBeenCalled(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + it('cancel create workspace', async () => { const { findByText, getByTestId } = render( { fireEvent.input(colorSelector, { target: { value: '#000000' }, }); + fireEvent.click(getByTestId('workspaceUseCase-observability')); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).toHaveBeenCalledWith( expect.objectContaining({ name: 'test workspace name', color: '#000000', description: 'test workspace description', + features: expect.arrayContaining(['use-case-observability']), }), undefined ); @@ -163,37 +178,6 @@ describe('WorkspaceCreator', () => { expect(notificationToastsAddDanger).not.toHaveBeenCalled(); }); - it('create workspace with customized features', async () => { - setHrefSpy.mockReset(); - const { getByTestId } = render( - - ); - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); - fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); - expect(setHrefSpy).not.toHaveBeenCalled(); - fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); - expect(workspaceClientCreate).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'test workspace name', - features: expect.arrayContaining(['app1', 'app2', 'app3']), - }), - undefined - ); - await waitFor(() => { - expect(notificationToastsAddSuccess).toHaveBeenCalled(); - }); - expect(notificationToastsAddDanger).not.toHaveBeenCalled(); - await waitFor(() => { - expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_overview$/)); - }); - }); - it('should show danger toasts after create workspace failed', async () => { workspaceClientCreate.mockReturnValueOnce({ result: { id: 'failResult' }, success: false }); const { getByTestId } = render( @@ -205,6 +189,7 @@ describe('WorkspaceCreator', () => { fireEvent.input(nameInput, { target: { value: 'test workspace name' }, }); + fireEvent.click(getByTestId('workspaceUseCase-observability')); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).toHaveBeenCalled(); await waitFor(() => { @@ -226,6 +211,7 @@ describe('WorkspaceCreator', () => { fireEvent.input(nameInput, { target: { value: 'test workspace name' }, }); + fireEvent.click(getByTestId('workspaceUseCase-observability')); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).toHaveBeenCalled(); await waitFor(() => { @@ -235,12 +221,16 @@ describe('WorkspaceCreator', () => { }); it('create workspace with customized permissions', async () => { - const { getByTestId, getByText, getAllByText } = render(); + const { getByTestId, getAllByText } = render( + + ); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, }); - fireEvent.click(getByText('Users & Permissions')); + fireEvent.click(getByTestId('workspaceUseCase-observability')); fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); const userIdInput = getAllByText('Select')[0]; fireEvent.click(userIdInput); 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 11d411f6e0d2..61905572f628 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -78,7 +78,7 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { return ( - + { chrome?.setBreadcrumbs([ { text: i18n.translate('workspace.workspaceCreateTitle', { - defaultMessage: 'Create workspace', + defaultMessage: 'Create a workspace', }), }, ]); diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index 693f0cdce141..073477b2ad89 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -11,12 +11,6 @@ export enum WorkspaceOperationType { Update = 'update', } -export enum WorkspaceFormTabs { - NotSelected, - FeatureVisibility, - UsersAndPermissions, -} - export enum WorkspacePermissionItemType { User = 'user', Group = 'group', diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 33521cc8dcb9..d8679629c48b 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -34,16 +34,6 @@ export interface WorkspaceFormData extends WorkspaceFormSubmitData { reserved?: boolean; } -export interface WorkspaceFeature { - id: string; - name: string; -} - -export interface WorkspaceFeatureGroup { - name: string; - features: WorkspaceFeature[]; -} - export type WorkspaceFormErrors = { [key in keyof Omit]?: string; } & { diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts index 5b756348e795..9e67d49bd07d 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts @@ -35,15 +35,35 @@ describe('useWorkspaceForm', () => { act(() => { renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); }); - expect(renderResult.result.current.formErrors).toEqual({ - name: 'Invalid workspace name', + expect(renderResult.result.current.formErrors).toEqual( + expect.objectContaining({ + name: 'Invalid workspace name', + }) + ); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + it('should return "Use case is required. Select a use case." and not call onSubmit', async () => { + const { renderResult, onSubmitMock } = setup({ + id: 'foo', + name: 'test-workspace-name', }); + expect(renderResult.result.current.formErrors).toEqual({}); + + act(() => { + renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); + }); + expect(renderResult.result.current.formErrors).toEqual( + expect.objectContaining({ + features: 'Use case is required. Select a use case.', + }) + ); expect(onSubmitMock).not.toHaveBeenCalled(); }); it('should call onSubmit with workspace name and features', async () => { const { renderResult, onSubmitMock } = setup({ id: 'foo', name: 'test-workspace-name', + features: ['use-case-observability'], }); expect(renderResult.result.current.formErrors).toEqual({}); @@ -53,7 +73,7 @@ describe('useWorkspaceForm', () => { expect(onSubmitMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'test-workspace-name', - features: ['workspace_update', 'workspace_overview'], + features: ['use-case-observability', 'workspace_update', 'workspace_overview'], }) ); }); diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts index 07de89ffe18a..4536e56d21bd 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -3,42 +3,40 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useState, FormEventHandler, useRef, useMemo, useEffect } from 'react'; +import { useCallback, useState, FormEventHandler, useRef, useMemo } from 'react'; import { htmlIdGenerator, EuiFieldTextProps, EuiColorPickerProps, EuiTextAreaProps, } from '@elastic/eui'; + import { useApplications } from '../../hooks'; -import { featureMatchesConfig } from '../../utils'; +import { + getUseCaseFeatureConfig, + getUseCaseFromFeatureConfig, + isUseCaseFeatureConfig, +} from '../../utils'; -import { WorkspaceFormTabs } from './constants'; import { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; import { appendDefaultFeatureIds, getNumberOfErrors, validateWorkspaceForm } from './utils'; const workspaceHtmlIdGenerator = htmlIdGenerator(); +const isNotNull = (value: T | null): value is T => !!value; + export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: WorkspaceFormProps) => { const applications = useApplications(application); const [name, setName] = useState(defaultValues?.name); const [description, setDescription] = useState(defaultValues?.description); const [color, setColor] = useState(defaultValues?.color); - const [selectedTab, setSelectedTab] = useState(WorkspaceFormTabs.FeatureVisibility); - // The matched feature id list based on original feature config, - // the feature category will be expanded to list of feature ids - const defaultFeatures = useMemo(() => { - // The original feature list, may contain feature id and category wildcard like @management, etc. - const defaultOriginalFeatures = defaultValues?.features ?? []; - return applications.filter(featureMatchesConfig(defaultOriginalFeatures)).map((app) => app.id); - }, [defaultValues?.features, applications]); - - const defaultFeaturesRef = useRef(defaultFeatures); - defaultFeaturesRef.current = defaultFeatures; - - const [selectedFeatureIds, setSelectedFeatureIds] = useState( - appendDefaultFeatureIds(defaultFeatures) + const [featureConfigs, setFeatureConfigs] = useState( + appendDefaultFeatureIds(defaultValues?.features ?? []) + ); + const selectedUseCases = useMemo( + () => featureConfigs.map(getUseCaseFromFeatureConfig).filter(isNotNull), + [featureConfigs] ); const [permissionSettings, setPermissionSettings] = useState< Array & Partial> @@ -54,7 +52,8 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works const getFormData = () => ({ name, description, - features: selectedFeatureIds, + features: featureConfigs, + useCases: selectedUseCases, color, permissionSettings, }); @@ -65,6 +64,20 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works formIdRef.current = workspaceHtmlIdGenerator(); } + const handleUseCasesChange = useCallback( + (newUseCases: string[]) => { + setFeatureConfigs((previousFeatureConfigs) => { + return [ + ...previousFeatureConfigs.filter( + (featureConfig) => !isUseCaseFeatureConfig(featureConfig) + ), + ...newUseCases.map((useCaseItem) => getUseCaseFeatureConfig(useCaseItem)), + ]; + }); + }, + [setFeatureConfigs] + ); + const handleFormSubmit = useCallback( (e) => { e.preventDefault(); @@ -75,27 +88,15 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works return; } - const featureConfigChanged = - formData.features.length !== defaultFeatures.length || - formData.features.some((feat) => !defaultFeatures.includes(feat)); - - if (!featureConfigChanged) { - // If feature config not changed, set workspace feature config to the original value. - // The reason why we do this is when a workspace feature is configured by wildcard, - // such as `['@management']` or `['*']`. The form value `formData.features` will be - // expanded to array of individual feature id, if the feature hasn't changed, we will - // set the feature config back to the original value so that category wildcard won't - // expanded to feature ids - formData.features = defaultValues?.features ?? []; - } - onSubmit?.({ - ...formData, name: formData.name!, + description: formData.description, + features: formData.features, + color: formData.color, permissionSettings: formData.permissionSettings as WorkspacePermissionSetting[], }); }, - [defaultFeatures, onSubmit, defaultValues?.features] + [onSubmit] ); const handleNameInputChange = useCallback['onChange']>((e) => { @@ -110,37 +111,17 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works setColor(text); }, []); - const handleTabFeatureClick = useCallback(() => { - setSelectedTab(WorkspaceFormTabs.FeatureVisibility); - }, []); - - const handleTabPermissionClick = useCallback(() => { - setSelectedTab(WorkspaceFormTabs.UsersAndPermissions); - }, []); - - const handleFeaturesChange = useCallback((featureIds: string[]) => { - setSelectedFeatureIds(featureIds); - }, []); - - useEffect(() => { - // When applications changed, reset form feature selection to original value - setSelectedFeatureIds(appendDefaultFeatureIds(defaultFeaturesRef.current)); - }, [applications]); - return { formId: formIdRef.current, formData: getFormData(), formErrors, - selectedTab, applications, numberOfErrors, handleFormSubmit, handleColorChange, - handleFeaturesChange, + handleUseCasesChange, handleNameInputChange, - handleTabFeatureClick, setPermissionSettings, - handleTabPermissionClick, handleDescriptionChange, }; }; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts index ec078c8a0456..6935f84eda35 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -3,106 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES } from '../../../../../core/public'; import { validateWorkspaceForm, - convertApplicationsToFeaturesOrGroups, convertPermissionSettingsToPermissions, convertPermissionsToPermissionSettings, } from './utils'; import { WorkspacePermissionMode } from '../../../common/constants'; import { WorkspacePermissionItemType } from './constants'; -describe('convertApplicationsToFeaturesOrGroups', () => { - it('should group same category applications in same feature group', () => { - expect( - convertApplicationsToFeaturesOrGroups([ - { - id: 'foo', - title: 'Foo', - navLinkStatus: AppNavLinkStatus.visible, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - }, - { - id: 'bar', - title: 'Bar', - navLinkStatus: AppNavLinkStatus.visible, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - }, - { - id: 'baz', - title: 'Baz', - navLinkStatus: AppNavLinkStatus.visible, - category: DEFAULT_APP_CATEGORIES.observability, - }, - ]) - ).toEqual([ - { - name: 'OpenSearch Dashboards', - features: [ - { - id: 'foo', - name: 'Foo', - }, - { - id: 'bar', - name: 'Bar', - }, - ], - }, - { - name: 'Observability', - features: [ - { - id: 'baz', - name: 'Baz', - }, - ], - }, - ]); - }); - it('should return features if application without category', () => { - expect( - convertApplicationsToFeaturesOrGroups([ - { - id: 'foo', - title: 'Foo', - navLinkStatus: AppNavLinkStatus.visible, - }, - { - id: 'baz', - title: 'Baz', - navLinkStatus: AppNavLinkStatus.visible, - category: DEFAULT_APP_CATEGORIES.observability, - }, - { - id: 'bar', - title: 'Bar', - navLinkStatus: AppNavLinkStatus.visible, - }, - ]) - ).toEqual([ - { - id: 'foo', - name: 'Foo', - }, - { - id: 'bar', - name: 'Bar', - }, - { - name: 'Observability', - features: [ - { - id: 'baz', - name: 'Baz', - }, - ], - }, - ]); - }); -}); - describe('convertPermissionSettingsToPermissions', () => { it('should return undefined if permission items not provided', () => { expect(convertPermissionSettingsToPermissions(undefined)).toBeUndefined(); @@ -250,6 +158,9 @@ describe('validateWorkspaceForm', () => { it('should return error if name is invalid', () => { expect(validateWorkspaceForm({ name: '~' }).name).toEqual('Invalid workspace name'); }); + it('should return error if use case is empty', () => { + expect(validateWorkspaceForm({}).features).toEqual('Use case is required. Select a use case.'); + }); it('should return error if permission setting type is invalid', () => { expect( validateWorkspaceForm({ @@ -350,6 +261,7 @@ describe('validateWorkspaceForm', () => { group: 'foo', }, ], + features: ['use-case-observability'], }) ).toEqual({}); }); diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index f5a7bd149218..352cbe1aca1a 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -5,26 +5,16 @@ import { i18n } from '@osd/i18n'; -import { PublicAppInfo } from '../../../../../core/public'; import type { SavedObjectPermissions } from '../../../../../core/types'; import { DEFAULT_SELECTED_FEATURES_IDS, WorkspacePermissionMode } from '../../../common/constants'; +import { isUseCaseFeatureConfig } from '../../utils'; import { optionIdToWorkspacePermissionModesMap, PermissionModeId, WorkspacePermissionItemType, } from './constants'; -import { - WorkspaceFeature, - WorkspaceFeatureGroup, - WorkspaceFormErrors, - WorkspaceFormSubmitData, - WorkspacePermissionSetting, -} from './types'; - -export const isWorkspaceFeatureGroup = ( - featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup -): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; +import { WorkspaceFormErrors, WorkspaceFormSubmitData, WorkspacePermissionSetting } from './types'; export const appendDefaultFeatureIds = (ids: string[]) => { // concat default checked ids and unique the result @@ -51,58 +41,12 @@ export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { if (formErrors.permissionSettings) { numberOfErrors += Object.keys(formErrors.permissionSettings).length; } + if (formErrors.features) { + numberOfErrors += 1; + } return numberOfErrors; }; -export const convertApplicationsToFeaturesOrGroups = ( - applications: Array< - Pick - > -) => { - const UNDEFINED = 'undefined'; - - /** - * - * Convert applications to features map, the map use category label as - * map key and group all same category applications in one array after - * transfer application to feature. - * - **/ - const categoryLabel2Features = applications.reduce<{ - [key: string]: WorkspaceFeature[]; - }>((previousValue, application) => { - const label = application.category?.label || UNDEFINED; - - return { - ...previousValue, - [label]: [...(previousValue[label] || []), { id: application.id, name: application.title }], - }; - }, {}); - - /** - * - * Iterate all keys of categoryLabel2Features map, convert map to features or groups array. - * Features with category label will be converted to feature groups. Features without "undefined" - * category label will be converted to single features. Then append them to the result array. - * - **/ - return Object.keys(categoryLabel2Features).reduce< - Array - >((previousValue, categoryLabel) => { - const features = categoryLabel2Features[categoryLabel]; - if (categoryLabel === UNDEFINED) { - return [...previousValue, ...features]; - } - return [ - ...previousValue, - { - name: categoryLabel, - features, - }, - ]; - }, []); -}; - export const isUserOrGroupPermissionSettingDuplicated = ( permissionSettings: Array>, permissionSettingToCheck: WorkspacePermissionSetting @@ -243,7 +187,7 @@ export const validateWorkspaceForm = ( } ) => { const formErrors: WorkspaceFormErrors = {}; - const { name, description, permissionSettings } = formData; + const { name, permissionSettings, features } = formData; if (name) { if (!isValidFormTextInput(name)) { formErrors.name = i18n.translate('workspace.form.detail.name.invalid', { @@ -255,6 +199,11 @@ export const validateWorkspaceForm = ( defaultMessage: "Name can't be empty.", }); } + if (!features || !features.some((featureConfig) => isUseCaseFeatureConfig(featureConfig))) { + formErrors.features = i18n.translate('workspace.form.features.empty', { + defaultMessage: 'Use case is required. Select a use case.', + }); + } if (permissionSettings) { const permissionSettingsErrors: { [key: number]: string } = {}; for (let i = 0; i < permissionSettings.length; i++) { diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx deleted file mode 100644 index 313d459b6018..000000000000 --- a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import { - WorkspaceFeatureSelector, - WorkspaceFeatureSelectorProps, -} from './workspace_feature_selector'; -import { AppNavLinkStatus, AppStatus } from '../../../../../core/public'; - -const setup = (options?: Partial) => { - const onChangeMock = jest.fn(); - const applications = [ - { - id: 'app-1', - title: 'App 1', - category: { id: 'category-1', label: 'Category 1' }, - navLinkStatus: AppNavLinkStatus.visible, - status: AppStatus.accessible, - appRoute: '/app-1', - }, - { - id: 'app-2', - title: 'App 2', - category: { id: 'category-1', label: 'Category 1' }, - navLinkStatus: AppNavLinkStatus.visible, - status: AppStatus.accessible, - appRoute: '/app-2', - }, - { - id: 'app-3', - title: 'App 3', - category: { id: 'category-2', label: 'Category 2' }, - navLinkStatus: AppNavLinkStatus.visible, - status: AppStatus.accessible, - appRoute: '/app-3', - }, - { - id: 'app-4', - title: 'App 4', - navLinkStatus: AppNavLinkStatus.visible, - status: AppStatus.accessible, - appRoute: '/app-4', - }, - ]; - const renderResult = render( - - ); - return { - renderResult, - onChangeMock, - }; -}; - -describe('WorkspaceFeatureSelector', () => { - it('should call onChange with clicked feature', () => { - const { renderResult, onChangeMock } = setup(); - - expect(onChangeMock).not.toHaveBeenCalled(); - fireEvent.click(renderResult.getByText('App 1')); - expect(onChangeMock).toHaveBeenCalledWith(['app-1']); - }); - it('should call onChange with empty array after selected feature clicked', () => { - const { renderResult, onChangeMock } = setup({ - selectedFeatures: ['app-2'], - }); - - expect(onChangeMock).not.toHaveBeenCalled(); - fireEvent.click(renderResult.getByText('App 2')); - expect(onChangeMock).toHaveBeenCalledWith([]); - }); - it('should call onChange with features under clicked group', () => { - const { renderResult, onChangeMock } = setup(); - - expect(onChangeMock).not.toHaveBeenCalled(); - fireEvent.click( - renderResult.getByTestId('workspaceForm-workspaceFeatureVisibility-Category 1') - ); - expect(onChangeMock).toHaveBeenCalledWith(['app-1', 'app-2']); - }); - it('should call onChange without features under clicked group when group already selected', () => { - const { renderResult, onChangeMock } = setup({ - selectedFeatures: ['app-1', 'app-2', 'app-3'], - }); - - expect(onChangeMock).not.toHaveBeenCalled(); - fireEvent.click( - renderResult.getByTestId('workspaceForm-workspaceFeatureVisibility-Category 1') - ); - expect(onChangeMock).toHaveBeenCalledWith(['app-3']); - }); -}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx deleted file mode 100644 index 8c99e5fa6642..000000000000 --- a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useCallback, useMemo } from 'react'; -import { - EuiText, - EuiFlexItem, - EuiCheckbox, - EuiCheckboxGroup, - EuiFlexGroup, - EuiCheckboxGroupProps, - EuiCheckboxProps, -} from '@elastic/eui'; - -import { PublicAppInfo } from '../../../../../core/public'; - -import { isWorkspaceFeatureGroup, convertApplicationsToFeaturesOrGroups } from './utils'; - -export interface WorkspaceFeatureSelectorProps { - selectedFeatures: string[]; - onChange: (newFeatures: string[]) => void; - workspaceConfigurableApps?: PublicAppInfo[]; -} - -export const WorkspaceFeatureSelector = ({ - selectedFeatures, - onChange, - workspaceConfigurableApps, -}: WorkspaceFeatureSelectorProps) => { - const featuresOrGroups = useMemo( - () => convertApplicationsToFeaturesOrGroups(workspaceConfigurableApps ?? []), - [workspaceConfigurableApps] - ); - - const handleFeatureChange = useCallback( - (featureId) => { - if (!selectedFeatures.includes(featureId)) { - onChange([...selectedFeatures, featureId]); - return; - } - onChange(selectedFeatures.filter((selectedId) => selectedId !== featureId)); - }, - [selectedFeatures, onChange] - ); - - const handleFeatureCheckboxChange = useCallback( - (e) => { - handleFeatureChange(e.target.id); - }, - [handleFeatureChange] - ); - - const handleFeatureGroupChange = useCallback( - (e) => { - const featureOrGroup = featuresOrGroups.find( - (item) => isWorkspaceFeatureGroup(item) && item.name === e.target.id - ); - if (!featureOrGroup || !isWorkspaceFeatureGroup(featureOrGroup)) { - return; - } - const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); - const notExistsIds = groupFeatureIds.filter((id) => !selectedFeatures.includes(id)); - // Check all not selected features if not been selected in current group. - if (notExistsIds.length > 0) { - onChange([...selectedFeatures, ...notExistsIds]); - return; - } - // Need to un-check these features, if all features in group has been selected - onChange(selectedFeatures.filter((featureId) => !groupFeatureIds.includes(featureId))); - }, - [featuresOrGroups, selectedFeatures, onChange] - ); - - return ( - <> - {featuresOrGroups.map((featureOrGroup) => { - const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; - const selectedIds = selectedFeatures.filter((id) => - (isWorkspaceFeatureGroup(featureOrGroup) - ? featureOrGroup.features - : [featureOrGroup] - ).find((item) => item.id === id) - ); - const featureOrGroupId = isWorkspaceFeatureGroup(featureOrGroup) - ? featureOrGroup.name - : featureOrGroup.id; - - return ( - - -
- - {featureOrGroup.name} - -
-
- - 0 ? ` (${selectedIds.length}/${features.length})` : '' - }`} - checked={selectedIds.length > 0} - indeterminate={ - isWorkspaceFeatureGroup(featureOrGroup) && - selectedIds.length > 0 && - selectedIds.length < features.length - } - data-test-subj={`workspaceForm-workspaceFeatureVisibility-${featureOrGroupId}`} - /> - {isWorkspaceFeatureGroup(featureOrGroup) && ( - ({ - id: item.id, - label: item.name, - }))} - idToSelectedMap={selectedIds.reduce( - (previousValue, currentValue) => ({ - ...previousValue, - [currentValue]: true, - }), - {} - )} - onChange={handleFeatureChange} - style={{ marginLeft: 40 }} - data-test-subj={`workspaceForm-workspaceFeatureVisibility-featureWithCategory-${featureOrGroupId}`} - /> - )} - -
- ); - })} - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index 794f983bd989..645e1843c9ab 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -13,19 +13,15 @@ import { EuiFieldText, EuiText, EuiColorPicker, - EuiHorizontalRule, - EuiTab, - EuiTabs, EuiTextArea, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { WorkspaceBottomBar } from './workspace_bottom_bar'; import { WorkspaceFormProps } from './types'; -import { WorkspaceFormTabs } from './constants'; import { useWorkspaceForm } from './use_workspace_form'; -import { WorkspaceFeatureSelector } from './workspace_feature_selector'; import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; +import { WorkspaceUseCase } from './workspace_use_case'; export const WorkspaceForm = (props: WorkspaceFormProps) => { const { @@ -33,31 +29,23 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { defaultValues, operationType, permissionEnabled, + workspaceConfigurableApps, permissionLastAdminItemDeletable, } = props; const { formId, formData, formErrors, - selectedTab, numberOfErrors, handleFormSubmit, handleColorChange, - handleFeaturesChange, + handleUseCasesChange, handleNameInputChange, - handleTabFeatureClick, setPermissionSettings, - handleTabPermissionClick, handleDescriptionChange, } = useWorkspaceForm(props); const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { - defaultMessage: 'Workspace Details', - }); - const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { - defaultMessage: 'Feature Visibility', - }); - const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { - defaultMessage: 'Users & Permissions', + defaultMessage: 'Enter Details', }); return ( @@ -66,7 +54,6 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => {

{workspaceDetailsTitle}

- { onChange={handleNameInputChange} readOnly={!!defaultValues?.reserved} data-test-subj="workspaceForm-workspaceDetails-nameInputText" + placeholder={i18n.translate('workspace.form.workspaceDetails.name.placeholder', { + defaultMessage: 'Enter a name', + })} /> { - - - + +

+ {i18n.translate('workspace.form.workspaceUseCase.title', { + defaultMessage: 'Choose one or more focus areas', + })} +

+
+ + - {featureVisibilityTitle} -
- {permissionEnabled && ( - - {usersAndPermissionsTitle} - - )} -
- {selectedTab === WorkspaceFormTabs.FeatureVisibility && ( - - -

{featureVisibilityTitle}

-
- - - -
- )} - {selectedTab === WorkspaceFormTabs.UsersAndPermissions && ( + + + + {permissionEnabled && (

{i18n.translate('workspace.form.usersAndPermissions.title', { - defaultMessage: 'Users & Permissions', + defaultMessage: 'Manage access and permissions', })}

- ) => { + const onChangeMock = jest.fn(); + const renderResult = render( + + ); + return { + renderResult, + onChangeMock, + }; +}; + +describe('WorkspaceUseCase', () => { + it('should render four use cases', () => { + const { renderResult } = setup(); + + expect(renderResult.getByText('Observability')).toBeInTheDocument(); + expect(renderResult.getByText('Analytics')).toBeInTheDocument(); + expect(renderResult.getByText('Security Analytics')).toBeInTheDocument(); + expect(renderResult.getByText('Search')).toBeInTheDocument(); + }); + + it('should call onChange with new added use case', () => { + const { renderResult, onChangeMock } = setup(); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('Observability')); + expect(onChangeMock).toHaveBeenLastCalledWith(['observability']); + }); + + it('should call onChange without removed use case', () => { + const { renderResult, onChangeMock } = setup({ value: ['observability'] }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('Observability')); + expect(onChangeMock).toHaveBeenLastCalledWith([]); + }); +}); 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 new file mode 100644 index 000000000000..8e47d9eafc77 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx @@ -0,0 +1,99 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useCallback } from 'react'; +import { PublicAppInfo } from 'opensearch-dashboards/public'; +import { EuiCheckableCard, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import './workspace_use_case.scss'; + +const ALL_USE_CASES = [ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, +]; + +interface WorkspaceUseCaseCardProps { + id: string; + title: string; + checked: boolean; + description: string; + onChange: (id: string) => void; +} + +const WorkspaceUseCaseCard = ({ + id, + title, + description, + checked, + onChange, +}: WorkspaceUseCaseCardProps) => { + const handleChange = useCallback(() => { + onChange(id); + }, [id, onChange]); + return ( + + + {description} + + + ); +}; + +export interface WorkspaceUseCaseProps { + configurableApps?: PublicAppInfo[]; + value: string[]; + onChange: (newValue: string[]) => void; +} + +export const WorkspaceUseCase = ({ configurableApps, value, onChange }: 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)) { + onChange([...value, id]); + return; + } + onChange(value.filter((item) => item !== id)); + }, + [value, onChange] + ); + + return ( + + {availableUseCases.map(({ id, title, description }) => ( + + + + ))} + + ); +}; 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 f90101772950..375e320e6305 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 @@ -246,9 +246,9 @@ exports[`WorkspaceList should render title and table normally 1`] = ` > - Features + Use case 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 d75ddf0d513f..6cbf36e0c31e 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -26,8 +26,9 @@ jest.mock('../delete_workspace_modal', () => ({ function getWrapWorkspaceListInContext( workspaceList = [ - { id: 'id1', name: 'name1' }, + { id: 'id1', name: 'name1', features: [] }, { id: 'id2', name: 'name2' }, + { id: 'id3', name: 'name3', features: ['use-case-observability'] }, ] ) { const coreStartMock = coreMock.createStart(); diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index b22a0fdb99fd..34027b87de5b 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -22,10 +22,11 @@ import { WorkspaceAttribute } from '../../../../../core/public'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { switchWorkspace, navigateToWorkspaceUpdatePage } from '../utils/workspace'; -import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; +import { WORKSPACE_CREATE_APP_ID, WORKSPACE_USE_CASES } from '../../../common/constants'; import { cleanWorkspaceId } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; +import { getUseCaseFromFeatureConfig, isUseCaseFeatureConfig } from '../../utils'; const WORKSPACE_LIST_PAGE_DESCRIPTIOIN = i18n.translate('workspace.list.description', { defaultMessage: @@ -103,9 +104,22 @@ export const WorkspaceList = () => { }, { field: 'features', - name: 'Features', + name: 'Use case', isExpander: true, hasActions: true, + render: (features: string[]) => { + if (!features || features.length === 0) { + return ''; + } + const results: string[] = []; + features.forEach((featureConfig) => { + const useCaseId = getUseCaseFromFeatureConfig(featureConfig); + if (useCaseId) { + results.push(WORKSPACE_USE_CASES[useCaseId].title); + } + }); + return results.join(', '); + }, }, { name: 'Actions', diff --git a/src/plugins/workspace/public/components/workspace_overview/workspace_overview.test.tsx b/src/plugins/workspace/public/components/workspace_overview/workspace_overview.test.tsx index 64ff49fcbdd6..8a87510f0d9e 100644 --- a/src/plugins/workspace/public/components/workspace_overview/workspace_overview.test.tsx +++ b/src/plugins/workspace/public/components/workspace_overview/workspace_overview.test.tsx @@ -207,7 +207,7 @@ describe('WorkspaceOverview', () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); const { getByText } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); fireEvent.click(getByText('Settings')); - expect(screen.queryByText('Workspace Details')).not.toBeNull(); + expect(screen.queryByText('Enter Details')).not.toBeNull(); // title is hidden expect(screen.queryByText('Update Workspace')).toBeNull(); }); diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx index 989e556afe4f..463db3591f9c 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx @@ -17,11 +17,8 @@ const navigateToApp = jest.fn(); const notificationToastsAddSuccess = jest.fn(); const notificationToastsAddDanger = jest.fn(); const PublicAPPInfoMap = new Map([ - ['app1', { id: 'app1', title: 'app1' }], - ['app2', { id: 'app2', title: 'app2', category: { id: 'category1', label: 'category1' } }], - ['app3', { id: 'app3', category: { id: 'category1', label: 'category1' } }], - ['app4', { id: 'app4', category: { id: 'category2', label: 'category2' } }], - ['app5', { id: 'app5', category: { id: 'category2', label: 'category2' } }], + ['data-explorer', { id: 'data-explorer', title: 'Data Explorer' }], + ['dashboards', { id: 'dashboards', title: 'Dashboards' }], ]); const createWorkspacesSetupContractMockWithValue = () => { const currentWorkspaceId$ = new BehaviorSubject('abljlsds'); @@ -29,7 +26,7 @@ const createWorkspacesSetupContractMockWithValue = () => { id: 'abljlsds', name: 'test1', description: 'test1', - features: [], + features: ['use-case-observability'], color: '', icon: '', reserved: false, @@ -173,10 +170,9 @@ describe('WorkspaceUpdater', () => { target: { value: '#000000' }, }); - fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); - fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); + fireEvent.click(getByTestId('workspaceUseCase-observability')); + fireEvent.click(getByTestId('workspaceUseCase-analytics')); - fireEvent.click(getByText('Users & Permissions')); fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); const userIdInput = getAllByText('Select')[0]; fireEvent.click(userIdInput); @@ -192,7 +188,7 @@ describe('WorkspaceUpdater', () => { name: 'test workspace name', color: '#000000', description: 'test workspace description', - features: expect.arrayContaining(['app1', 'app2', 'app3']), + features: expect.arrayContaining(['use-case-analytics']), }), { read: { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8c6bd2ddc0e6..969459e188ee 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -230,7 +230,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> core.application.register({ id: WORKSPACE_CREATE_APP_ID, title: i18n.translate('workspace.settings.workspaceCreate', { - defaultMessage: 'Create Workspace', + defaultMessage: 'Create a workspace', }), navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index c68dc844da2e..70eb91cfbdda 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -8,6 +8,7 @@ import { featureMatchesConfig, filterWorkspaceConfigurableApps, isAppAccessibleInWorkspace, + isFeatureIdInsideUseCase, } from './utils'; import { WorkspaceAvailability } from '../../../core/public'; @@ -96,6 +97,14 @@ describe('workspace utils: featureMatchesConfig', () => { true ); }); + + it('should match features include by any use cases', () => { + const match = featureMatchesConfig(['use-case-observability', 'use-case-analytics']); + expect(match({ id: 'dashboards' })).toBe(true); + expect(match({ id: 'observability-traces' })).toBe(true); + expect(match({ id: 'alerting' })).toBe(true); + expect(match({ id: 'not-in-any-use-case' })).toBe(false); + }); }); describe('workspace utils: isAppAccessibleInWorkspace', () => { @@ -261,3 +270,9 @@ describe('workspace utils: filterWorkspaceConfigurableApps', () => { expect(filteredApps[1].id).toEqual('management'); }); }); + +describe('workspace utils: isFeatureIdInsideUseCase', () => { + it('should return false for invalid use case', () => { + expect(isFeatureIdInsideUseCase('discover', 'use-case-invalid')).toBe(false); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index ba9ab5399e21..eaf96c80a4df 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -12,7 +12,34 @@ import { WorkspaceObject, WorkspaceAvailability, } from '../../../core/public'; -import { DEFAULT_SELECTED_FEATURES_IDS } from '../common/constants'; +import { DEFAULT_SELECTED_FEATURES_IDS, WORKSPACE_USE_CASES } from '../common/constants'; + +const USE_CASE_PREFIX = 'use-case-'; + +export const getUseCaseFeatureConfig = (useCaseId: string) => `${USE_CASE_PREFIX}${useCaseId}`; + +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 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); + } + return false; +}; /** * Checks if a given feature matches the provided feature configuration. @@ -24,6 +51,8 @@ import { DEFAULT_SELECTED_FEATURES_IDS } from '../common/constants'; * 4. To exclude a feature or category, prepend with `!`, e.g., `!discover` or `!@management`. * 5. The order of featureConfig array matters. From left to right, later configs override the previous ones. * For example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management'. + * 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[]) => ({ id, @@ -45,6 +74,14 @@ export const featureMatchesConfig = (featureConfigs: string[]) => ({ matched = true; } + // matches any feature inside use cases + if (getUseCaseFromFeatureConfig(featureConfig)) { + const isInsideUseCase = isFeatureIdInsideUseCase(id, featureConfig); + if (isInsideUseCase) { + matched = true; + } + } + // The config starts with `@` matches a category if (category && featureConfig === `@${category.id}`) { matched = true;