Skip to content

Commit

Permalink
feat: filter out ADMIN application and add feature dependency logic (o…
Browse files Browse the repository at this point in the history
…pensearch-project#49)

* feat: filter out ADMIN application and add feature dependency logic

Signed-off-by: Lin Wang <wonglam@amazon.com>

* feat: separate feature utils function

Signed-off-by: Lin Wang <wonglam@amazon.com>

* feat: rename isFeatureDependBySelectedFeatures, separate generateFeatureDependencyMap and add annotation

Signed-off-by: Lin Wang <wonglam@amazon.com>

---------

Signed-off-by: Lin Wang <wonglam@amazon.com>
  • Loading branch information
wanglam authored Jul 24, 2023
1 parent 210d3c5 commit 36e8e22
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 24 deletions.
15 changes: 15 additions & 0 deletions src/core/public/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,21 @@ export interface App<HistoryLocationState = unknown> {
* ```
*/
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';
};
};
}

/**
Expand Down
53 changes: 53 additions & 0 deletions src/plugins/workspace/public/components/utils/feature.test.ts
Original file line number Diff line number Diff line change
@@ -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'],
});
});
});
60 changes: 60 additions & 0 deletions src/plugins/workspace/public/components/utils/feature.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<App, 'id' | 'dependencies'>>
) =>
allFeatures.reduce<{ [key: string]: string[] }>(
(pValue, { id, dependencies }) =>
dependencies
? {
...pValue,
[id]: [
...(pValue[id] || []),
...Object.keys(dependencies).filter((key) => dependencies[key].type === 'required'),
],
}
: pValue,
{}
);
Original file line number Diff line number Diff line change
Expand Up @@ -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<App, 'dependencies'> {
id: string;
name: string;
templates: WorkspaceTemplate[];
Expand Down Expand Up @@ -74,6 +80,7 @@ interface WorkspaceFormProps {
defaultValues?: WorkspaceFormData;
opType?: string;
}

export const WorkspaceForm = ({
application,
onSubmit,
Expand Down Expand Up @@ -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];
Expand All @@ -141,6 +151,22 @@ export const WorkspaceForm = ({
[defaultVISTheme]
);

const allFeatures = useMemo(
() =>
featureOrGroups.reduce<WorkspaceFeature[]>(
(previousData, currentData) => [
...previousData,
...(isWorkspaceFeatureGroup(currentData) ? currentData.features : [currentData]),
],
[]
),
[featureOrGroups]
);

const featureDependencies = useMemo(() => generateFeatureDependencyMap(allFeatures), [
allFeatures,
]);

if (!formIdRef.current) {
formIdRef.current = workspaceHtmlIdGenerator();
}
Expand All @@ -150,27 +176,33 @@ export const WorkspaceForm = ({
const templateId = e.target.value;
setSelectedTemplateId(templateId);
setSelectedFeatureIds(
featureOrGroups.reduce<string[]>(
(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<EuiCheckboxGroupProps['onChange']>((featureId) => {
setSelectedFeatureIds((previousData) =>
previousData.includes(featureId)
? previousData.filter((id) => id !== featureId)
: [...previousData, featureId]
);
}, []);
const handleFeatureChange = useCallback<EuiCheckboxGroupProps['onChange']>(
(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<EuiCheckboxProps['onChange']>(
(e) => {
Expand All @@ -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<FormEventHandler>(
Expand Down

0 comments on commit 36e8e22

Please sign in to comment.