diff --git a/changelogs/fragments/7318.yml b/changelogs/fragments/7318.yml new file mode 100644 index 000000000000..4f7d9abe3553 --- /dev/null +++ b/changelogs/fragments/7318.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Add "All use case" option to workspace form ([#7318](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7318)) \ No newline at end of file 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 2d5a5e00ce6f..ae9ba5d6a4fa 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 @@ -127,4 +127,17 @@ describe('useWorkspaceForm', () => { }) ); }); + it('should update selected use case', () => { + const { renderResult } = setup({ + id: 'foo', + name: 'test-workspace-name', + features: ['use-case-observability'], + }); + + expect(renderResult.result.current.formData.useCase).toBe('observability'); + act(() => { + renderResult.result.current.handleUseCaseChange('search'); + }); + expect(renderResult.result.current.formData.useCase).toBe('search'); + }); }); 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 b15a6e6a670d..e0b01454fa0f 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 @@ -13,8 +13,8 @@ import { import { useApplications } from '../../hooks'; import { + getFirstUseCaseOfFeatureConfigs, getUseCaseFeatureConfig, - getUseCaseFromFeatureConfig, isUseCaseFeatureConfig, } from '../../utils'; import { DataSource } from '../../../common/types'; @@ -30,8 +30,6 @@ import { WorkspacePermissionItemType } from './constants'; const workspaceHtmlIdGenerator = htmlIdGenerator(); -const isNotNull = (value: T | null): value is T => !!value; - export const useWorkspaceForm = ({ application, defaultValues, @@ -51,10 +49,9 @@ export const useWorkspaceForm = ({ const [featureConfigs, setFeatureConfigs] = useState( appendDefaultFeatureIds(defaultValues?.features ?? []) ); - const selectedUseCases = useMemo( - () => featureConfigs.map(getUseCaseFromFeatureConfig).filter(isNotNull), - [featureConfigs] - ); + const selectedUseCase = useMemo(() => getFirstUseCaseOfFeatureConfigs(featureConfigs), [ + featureConfigs, + ]); const [permissionSettings, setPermissionSettings] = useState< Array & Partial> >(initialPermissionSettingsRef.current); @@ -72,7 +69,7 @@ export const useWorkspaceForm = ({ name, description, features: featureConfigs, - useCases: selectedUseCases, + useCase: selectedUseCase, color, permissionSettings, selectedDataSources, @@ -92,14 +89,14 @@ export const useWorkspaceForm = ({ formIdRef.current = workspaceHtmlIdGenerator(); } - const handleUseCasesChange = useCallback( - (newUseCases: string[]) => { + const handleUseCaseChange = useCallback( + (newUseCase: string) => { setFeatureConfigs((previousFeatureConfigs) => { return [ ...previousFeatureConfigs.filter( (featureConfig) => !isUseCaseFeatureConfig(featureConfig) ), - ...newUseCases.map((useCaseItem) => getUseCaseFeatureConfig(useCaseItem)), + getUseCaseFeatureConfig(newUseCase), ]; }); }, @@ -157,7 +154,7 @@ export const useWorkspaceForm = ({ numberOfChanges, handleFormSubmit, handleColorChange, - handleUseCasesChange, + handleUseCaseChange, handleNameInputChange, setPermissionSettings, setSelectedDataSources, 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 2e8b7cb32415..3834e7a23628 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 @@ -59,7 +59,7 @@ export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { numberOfChanges, handleFormSubmit, handleColorChange, - handleUseCasesChange, + handleUseCaseChange, setPermissionSettings, handleNameInputChange, setSelectedDataSources, @@ -109,8 +109,8 @@ export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { 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 07c86ef15ab0..c7e319b48435 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -41,7 +41,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { numberOfChanges, handleFormSubmit, handleColorChange, - handleUseCasesChange, + handleUseCaseChange, handleNameInputChange, setPermissionSettings, setSelectedDataSources, @@ -85,8 +85,8 @@ 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 7aa04a547c2c..9825cf07c209 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 @@ -23,11 +23,10 @@ const setup = (options?: Partial) => { id: 'system-use-case', title: 'System use case', description: 'System use case description', - features: [], systematic: true, }, ]} - value={[]} + value="" onChange={onChangeMock} formErrors={formErrors} {...options} @@ -49,19 +48,19 @@ describe('WorkspaceUseCase', () => { expect(renderResult.getByText('Search')).toBeInTheDocument(); }); - it('should call onChange with new added use case', () => { + it('should call onChange with new checked use case', () => { const { renderResult, onChangeMock } = setup(); expect(onChangeMock).not.toHaveBeenCalled(); fireEvent.click(renderResult.getByText('Observability')); - expect(onChangeMock).toHaveBeenLastCalledWith(['observability']); + expect(onChangeMock).toHaveBeenLastCalledWith('observability'); }); - it('should call onChange without removed use case', () => { - const { renderResult, onChangeMock } = setup({ value: ['observability'] }); + it('should not call onChange after checked use case clicked', () => { + const { renderResult, onChangeMock } = setup({ value: 'observability' }); expect(onChangeMock).not.toHaveBeenCalled(); fireEvent.click(renderResult.getByText('Observability')); - expect(onChangeMock).toHaveBeenLastCalledWith([]); + expect(onChangeMock).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx index 06a00cefa39b..e77f5b03c709 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx @@ -6,6 +6,8 @@ import React, { useCallback } from 'react'; import { i18n } from '@osd/i18n'; import { EuiCheckableCard, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiText } from '@elastic/eui'; + +import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; import { WorkspaceUseCase as WorkspaceUseCaseObject } from '../../types'; import { WorkspaceFormErrors } from './types'; import './workspace_use_case.scss'; @@ -31,7 +33,7 @@ const WorkspaceUseCaseCard = ({ return ( void; + value: string | undefined; + onChange: (newValue: string) => void; formErrors: WorkspaceFormErrors; - availableUseCases: WorkspaceUseCaseObject[]; + availableUseCases: Array< + Pick + >; } export const WorkspaceUseCase = ({ @@ -59,17 +63,6 @@ export const WorkspaceUseCase = ({ formErrors, availableUseCases, }: WorkspaceUseCaseProps) => { - const handleCardChange = useCallback( - (id: string) => { - if (!value.includes(id)) { - onChange([...value, id]); - return; - } - onChange(value.filter((item) => item !== id)); - }, - [value, onChange] - ); - return ( {availableUseCases .filter((item) => !item.systematic) + .concat(DEFAULT_NAV_GROUPS.all) .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 5e55205c196e..d939b8f84ea4 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -25,7 +25,7 @@ jest.mock('../delete_workspace_modal', () => ({ function getWrapWorkspaceListInContext( workspaceList = [ - { id: 'id1', name: 'name1', features: [] }, + { id: 'id1', name: 'name1', features: ['use-case-all'] }, { id: 'id2', name: 'name2' }, { id: 'id3', name: 'name3', features: ['use-case-observability'] }, ] @@ -69,6 +69,7 @@ describe('WorkspaceList', () => { expect(getByText('name2')).toBeInTheDocument(); // should display use case + expect(getByText('All use case')).toBeInTheDocument(); expect(getByText('Observability')).toBeInTheDocument(); }); it('should be able to apply debounce search after input', async () => { diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 0d2e3c79082d..3a9342cf68f6 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -17,7 +17,7 @@ import { import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject, of } from 'rxjs'; import { i18n } from '@osd/i18n'; -import { debounce } from '../../../../../core/public'; +import { debounce, DEFAULT_NAV_GROUPS } from '../../../../../core/public'; import { WorkspaceAttribute } from '../../../../../core/public'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { navigateToWorkspaceDetail } from '../utils/workspace'; @@ -26,7 +26,7 @@ import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; import { cleanWorkspaceId } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; -import { getUseCaseFromFeatureConfig } from '../../utils'; +import { getFirstUseCaseOfFeatureConfigs, getUseCaseFromFeatureConfig } from '../../utils'; import { WorkspaceUseCase } from '../../types'; const WORKSPACE_LIST_PAGE_DESCRIPTION = i18n.translate('workspace.list.description', { @@ -108,17 +108,14 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { if (!features || features.length === 0) { return ''; } - const results: string[] = []; - features.forEach((featureConfig) => { - const useCaseId = getUseCaseFromFeatureConfig(featureConfig); - if (useCaseId) { - const useCase = registeredUseCases?.find(({ id }) => id === useCaseId); - if (useCase) { - results.push(useCase.title); - } - } - }); - return results.join(', '); + const useCaseId = getFirstUseCaseOfFeatureConfigs(features); + const useCase = + useCaseId === DEFAULT_NAV_GROUPS.all.id + ? DEFAULT_NAV_GROUPS.all + : registeredUseCases?.find(({ id }) => id === useCaseId); + if (useCase) { + return useCase.title; + } }, }, { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index db496633128c..a2c84554205a 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -21,6 +21,7 @@ import { NavGroupStatus, DEFAULT_NAV_GROUPS, NavGroupType, + ALL_USE_CASE_ID, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, @@ -38,6 +39,7 @@ import { getWorkspaceColumn } from './components/workspace_column'; import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_management/public'; import { filterWorkspaceConfigurableApps, + getFirstUseCaseOfFeatureConfigs, isAppAccessibleInWorkspace, isNavGroupInFeatureConfigs, } from './utils'; @@ -119,9 +121,20 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { if (currentWorkspace) { this.navGroupUpdater$.next((navGroup) => { + /** + * The following logic determines whether a navigation group should be hidden or not based on the workspace's feature configurations. + * It checks the following conditions: + * 1. The navigation group is not a system-level group (system groups are always visible). + * 2. The current workspace has feature configurations set up. + * 3. The current workspace's use case it not "All use case". + * 4. The current navigation group is not included in the feature configurations of the workspace. + * + * If all these conditions are true, it means that the navigation group should be hidden. + */ if ( navGroup.type !== NavGroupType.SYSTEM && currentWorkspace.features && + getFirstUseCaseOfFeatureConfigs(currentWorkspace.features) !== ALL_USE_CASE_ID && !isNavGroupInFeatureConfigs(navGroup.id, currentWorkspace.features) ) { return { diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 852324025100..4763a4455746 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -125,12 +125,18 @@ describe('workspace utils: featureMatchesConfig', () => { it('should match features include by any use cases', () => { const match = featureMatchesConfig( - ['use-case-observability', 'use-case-analytics'], + ['use-case-observability', 'use-case-search'], STATIC_USE_CASES ); expect(match({ id: 'dashboards' })).toBe(true); expect(match({ id: 'observability-traces' })).toBe(true); - expect(match({ id: 'alerting' })).toBe(true); + + /** + * The searchRelevance is a feature under search use case. Since each workspace only can be a specific use case, + * the feature matches will use first use case to check if features exists. The observability doesn't have + * searchRelevance feature, it will return false. + */ + expect(match({ id: 'searchRelevance' })).toBe(false); expect(match({ id: 'not-in-any-use-case' })).toBe(false); }); }); @@ -240,6 +246,15 @@ describe('workspace utils: isAppAccessibleInWorkspace', () => { ) ).toBe(true); }); + it('any app is accessible when workspace is all use case', () => { + expect( + isAppAccessibleInWorkspace( + { id: 'any_app', title: 'Any app', mount: jest.fn() }, + { id: 'workspace_id', name: 'workspace name', features: ['use-case-all'] }, + STATIC_USE_CASES + ) + ).toBe(true); + }); }); describe('workspace utils: filterWorkspaceConfigurableApps', () => { @@ -309,11 +324,11 @@ 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', 'invalid', [])).toBe(false); }); it('should return false if feature not in use case', () => { expect( - isFeatureIdInsideUseCase('discover', 'use-case-foo', [ + isFeatureIdInsideUseCase('discover', 'foo', [ { id: 'foo', title: 'Foo', @@ -325,7 +340,7 @@ describe('workspace utils: isFeatureIdInsideUseCase', () => { }); it('should return true if feature id exists in use case', () => { expect( - isFeatureIdInsideUseCase('discover', 'use-case-foo', [ + isFeatureIdInsideUseCase('discover', 'foo', [ { id: 'foo', title: 'Foo', diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index aeb46993b6c6..589f1d8159d2 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -3,7 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { NavGroupType, SavedObjectsStart, NavGroupItemInMap } from '../../../core/public'; +import { + NavGroupType, + SavedObjectsStart, + NavGroupItemInMap, + ALL_USE_CASE_ID, +} from '../../../core/public'; import { App, AppCategory, @@ -32,14 +37,11 @@ export const getUseCaseFromFeatureConfig = (featureConfig: string) => { export const isFeatureIdInsideUseCase = ( featureId: string, - featureConfig: string, + useCaseId: string, useCases: WorkspaceUseCase[] ) => { - const useCase = useCases.find(({ id }) => id === getUseCaseFromFeatureConfig(featureConfig)); - if (useCase) { - return useCase.features.includes(featureId); - } - return false; + const availableFeatures = useCases.find(({ id }) => id === useCaseId)?.features ?? []; + return availableFeatures.includes(featureId); }; export const isNavGroupInFeatureConfigs = (navGroupId: string, featureConfigs: string[]) => @@ -66,6 +68,7 @@ export const featureMatchesConfig = (featureConfigs: string[], useCases: Workspa category?: AppCategory; }) => { let matched = false; + let firstUseCaseId: string | undefined; /** * Iterate through each feature configuration to determine if the given feature matches any of them. @@ -79,8 +82,14 @@ export const featureMatchesConfig = (featureConfigs: string[], useCases: Workspa } // matches any feature inside use cases - if (isFeatureIdInsideUseCase(id, featureConfig, useCases)) { - matched = true; + if (!firstUseCaseId) { + const useCaseId = getUseCaseFromFeatureConfig(featureConfig); + if (useCaseId) { + firstUseCaseId = useCaseId; + if (isFeatureIdInsideUseCase(id, firstUseCaseId, useCases)) { + matched = true; + } + } } // The config starts with `@` matches a category @@ -130,6 +139,13 @@ export function isAppAccessibleInWorkspace( return true; } + /** + * When workspace is all use case, all apps are accessible + */ + if (getFirstUseCaseOfFeatureConfigs(workspace.features) === ALL_USE_CASE_ID) { + return true; + } + /** * The app is configured into a workspace, it is accessible after entering the workspace */ @@ -244,3 +260,8 @@ export const isEqualWorkspaceUseCase = (a: WorkspaceUseCase, b: WorkspaceUseCase } return true; }; + +const isNotNull = (value: T | null): value is T => !!value; + +export const getFirstUseCaseOfFeatureConfigs = (featureConfigs: string[]): string | undefined => + featureConfigs.map(getUseCaseFromFeatureConfig).filter(isNotNull)[0];