From 36e8e22ad5a2d7eb06fea77a3c60f539a10ccf6d Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 24 Jul 2023 17:38:38 +0800 Subject: [PATCH] feat: filter out ADMIN application and add feature dependency logic (#49) * feat: filter out ADMIN application and add feature dependency logic Signed-off-by: Lin Wang * feat: separate feature utils function Signed-off-by: Lin Wang * feat: rename isFeatureDependBySelectedFeatures, separate generateFeatureDependencyMap and add annotation Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/core/public/application/types.ts | 15 +++ .../public/components/utils/feature.test.ts | 53 +++++++++ .../public/components/utils/feature.ts | 60 ++++++++++ .../workspace_creator/workspace_form.tsx | 103 ++++++++++++++---- 4 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 src/plugins/workspace/public/components/utils/feature.test.ts create mode 100644 src/plugins/workspace/public/components/utils/feature.ts diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 54ed7840596f..e284e80297f0 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -251,6 +251,21 @@ export interface App { * ``` */ exactRoute?: boolean; + + /** + * The feature group of workspace, won't be displayed as feature if feature set is ADMIN. + */ + featureGroup?: Array<'WORKSPACE' | 'ADMIN'>; + + /** + * The dependencies of one application, required feature will be automatic select and can't + * be unselect in the workspace configuration. + */ + dependencies?: { + [key: string]: { + type: 'required' | 'optional'; + }; + }; } /** diff --git a/src/plugins/workspace/public/components/utils/feature.test.ts b/src/plugins/workspace/public/components/utils/feature.test.ts new file mode 100644 index 000000000000..87554ef54ecb --- /dev/null +++ b/src/plugins/workspace/public/components/utils/feature.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isFeatureDependBySelectedFeatures, + getFinalFeatureIdsByDependency, + generateFeatureDependencyMap, +} from './feature'; + +describe('feature utils', () => { + describe('isFeatureDependBySelectedFeatures', () => { + it('should return true', () => { + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['a'] })).toBe(true); + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['a', 'c'] })).toBe(true); + }); + it('should return false', () => { + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['c'] })).toBe(false); + expect(isFeatureDependBySelectedFeatures('a', ['b'], {})).toBe(false); + }); + }); + + describe('getFinalFeatureIdsByDependency', () => { + it('should return consistent feature ids', () => { + expect(getFinalFeatureIdsByDependency(['a'], { a: ['b'] }, ['c', 'd'])).toStrictEqual([ + 'c', + 'd', + 'a', + 'b', + ]); + expect(getFinalFeatureIdsByDependency(['a'], { a: ['b', 'e'] }, ['c', 'd'])).toStrictEqual([ + 'c', + 'd', + 'a', + 'b', + 'e', + ]); + }); + }); + + it('should generate consistent features dependency map', () => { + expect( + generateFeatureDependencyMap([ + { id: 'a', dependencies: { b: { type: 'required' }, c: { type: 'optional' } } }, + { id: 'b', dependencies: { c: { type: 'required' } } }, + ]) + ).toEqual({ + a: ['b'], + b: ['c'], + }); + }); +}); diff --git a/src/plugins/workspace/public/components/utils/feature.ts b/src/plugins/workspace/public/components/utils/feature.ts new file mode 100644 index 000000000000..3da6027e83d3 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/feature.ts @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { App } from '../../../../../core/public'; + +export const isFeatureDependBySelectedFeatures = ( + featureId: string, + selectedFeatureIds: string[], + featureDependencies: { [key: string]: string[] } +) => + selectedFeatureIds.some((selectedFeatureId) => + (featureDependencies[selectedFeatureId] || []).some((dependencies) => + dependencies.includes(featureId) + ) + ); + +/** + * + * Generate new feature id list based the old feature id list + * and feature dependencies map. The feature dependency map may + * has duplicate ids with old feature id list. Use set here to + * get the unique feature ids. + * + * @param featureIds a feature id list need to add based old feature id list + * @param featureDependencies a feature dependencies map to get depended feature ids + * @param oldFeatureIds a feature id list that represent current feature id selection states + */ +export const getFinalFeatureIdsByDependency = ( + featureIds: string[], + featureDependencies: { [key: string]: string[] }, + oldFeatureIds: string[] = [] +) => + Array.from( + new Set([ + ...oldFeatureIds, + ...featureIds.reduce( + (pValue, featureId) => [...pValue, ...(featureDependencies[featureId] || [])], + featureIds + ), + ]) + ); + +export const generateFeatureDependencyMap = ( + allFeatures: Array> +) => + allFeatures.reduce<{ [key: string]: string[] }>( + (pValue, { id, dependencies }) => + dependencies + ? { + ...pValue, + [id]: [ + ...(pValue[id] || []), + ...Object.keys(dependencies).filter((key) => dependencies[key].type === 'required'), + ], + } + : pValue, + {} + ); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index 8e83b7057245..29e4d66edd2a 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -33,12 +33,18 @@ import { } from '@elastic/eui'; import { WorkspaceTemplate } from '../../../../../core/types'; -import { AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; +import { App, AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; import { useApplications, useWorkspaceTemplate } from '../../hooks'; import { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; +import { + isFeatureDependBySelectedFeatures, + getFinalFeatureIdsByDependency, + generateFeatureDependencyMap, +} from '../utils/feature'; + import { WorkspaceIconSelector } from './workspace_icon_selector'; -interface WorkspaceFeature { +interface WorkspaceFeature extends Pick { id: string; name: string; templates: WorkspaceTemplate[]; @@ -74,6 +80,7 @@ interface WorkspaceFormProps { defaultValues?: WorkspaceFormData; opType?: string; } + export const WorkspaceForm = ({ application, onSubmit, @@ -115,13 +122,16 @@ export const WorkspaceForm = ({ const apps = category2Applications[currentKey]; const features = apps .filter( - ({ navLinkStatus, chromeless }) => - navLinkStatus !== AppNavLinkStatus.hidden && !chromeless + ({ navLinkStatus, chromeless, featureGroup }) => + navLinkStatus !== AppNavLinkStatus.hidden && + !chromeless && + featureGroup?.includes('WORKSPACE') ) - .map(({ id, title, workspaceTemplate }) => ({ + .map(({ id, title, workspaceTemplate, dependencies }) => ({ id, name: title, templates: workspaceTemplate || [], + dependencies, })); if (features.length === 1 || currentKey === 'undefined') { return [...previousValue, ...features]; @@ -141,6 +151,22 @@ export const WorkspaceForm = ({ [defaultVISTheme] ); + const allFeatures = useMemo( + () => + featureOrGroups.reduce( + (previousData, currentData) => [ + ...previousData, + ...(isWorkspaceFeatureGroup(currentData) ? currentData.features : [currentData]), + ], + [] + ), + [featureOrGroups] + ); + + const featureDependencies = useMemo(() => generateFeatureDependencyMap(allFeatures), [ + allFeatures, + ]); + if (!formIdRef.current) { formIdRef.current = workspaceHtmlIdGenerator(); } @@ -150,27 +176,33 @@ export const WorkspaceForm = ({ const templateId = e.target.value; setSelectedTemplateId(templateId); setSelectedFeatureIds( - featureOrGroups.reduce( - (previousData, currentData) => [ - ...previousData, - ...(isWorkspaceFeatureGroup(currentData) ? currentData.features : [currentData]) - .filter(({ templates }) => !!templates.find((template) => template.id === templateId)) - .map((feature) => feature.id), - ], - [] + getFinalFeatureIdsByDependency( + allFeatures + .filter(({ templates }) => !!templates.find((template) => template.id === templateId)) + .map((feature) => feature.id), + featureDependencies ) ); }, - [featureOrGroups] + [allFeatures, featureDependencies] ); - const handleFeatureChange = useCallback((featureId) => { - setSelectedFeatureIds((previousData) => - previousData.includes(featureId) - ? previousData.filter((id) => id !== featureId) - : [...previousData, featureId] - ); - }, []); + const handleFeatureChange = useCallback( + (featureId) => { + setSelectedFeatureIds((previousData) => { + if (!previousData.includes(featureId)) { + return getFinalFeatureIdsByDependency([featureId], featureDependencies, previousData); + } + + if (isFeatureDependBySelectedFeatures(featureId, previousData, featureDependencies)) { + return previousData; + } + + return previousData.filter((selectedId) => selectedId !== featureId); + }); + }, + [featureDependencies] + ); const handleFeatureCheckboxChange = useCallback( (e) => { @@ -187,14 +219,37 @@ export const WorkspaceForm = ({ setSelectedFeatureIds((previousData) => { const notExistsIds = groupFeatureIds.filter((id) => !previousData.includes(id)); if (notExistsIds.length > 0) { - return [...previousData, ...notExistsIds]; + return getFinalFeatureIdsByDependency( + notExistsIds, + featureDependencies, + previousData + ); } - return previousData.filter((id) => !groupFeatureIds.includes(id)); + let groupRemainFeatureIds = groupFeatureIds; + const outGroupFeatureIds = previousData.filter( + (featureId) => !groupFeatureIds.includes(featureId) + ); + + while (true) { + const lastRemainFeatures = groupRemainFeatureIds.length; + groupRemainFeatureIds = groupRemainFeatureIds.filter((featureId) => + isFeatureDependBySelectedFeatures( + featureId, + [...outGroupFeatureIds, ...groupRemainFeatureIds], + featureDependencies + ) + ); + if (lastRemainFeatures === groupRemainFeatureIds.length) { + break; + } + } + + return [...outGroupFeatureIds, ...groupRemainFeatureIds]; }); } } }, - [featureOrGroups] + [featureOrGroups, featureDependencies] ); const handleFormSubmit = useCallback(