+ >((previousValue, categoryLabel) => {
+ const features = categoryLabel2Features[categoryLabel];
+ if (categoryLabel === UNDEFINED) {
+ return [...previousValue, ...features];
+ }
+ return [
+ ...previousValue,
+ {
+ name: categoryLabel,
+ features,
+ },
+ ];
+ }, []);
+};
diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx
new file mode 100644
index 000000000000..7e528d2214ee
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx
@@ -0,0 +1,110 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { i18n } from '@osd/i18n';
+import React, { useState } from 'react';
+import { ApplicationStart } from 'opensearch-dashboards/public';
+import { WorkspaceOperationType } from '../workspace_form';
+import { WorkspaceCancelModal } from './workspace_cancel_modal';
+
+interface WorkspaceBottomBarProps {
+ formId: string;
+ operationType?: WorkspaceOperationType;
+ numberOfErrors: number;
+ application: ApplicationStart;
+ numberOfUnSavedChanges?: number;
+}
+
+export const WorkspaceBottomBar = ({
+ formId,
+ operationType,
+ numberOfErrors,
+ numberOfUnSavedChanges,
+ application,
+}: WorkspaceBottomBarProps) => {
+ const [isCancelModalVisible, setIsCancelModalVisible] = useState(false);
+ const closeCancelModal = () => setIsCancelModalVisible(false);
+ const showCancelModal = () => setIsCancelModalVisible(true);
+
+ return (
+
+
+
+
+
+
+
+ {operationType === WorkspaceOperationType.Update ? (
+
+ {i18n.translate('workspace.form.bottomBar.unsavedChanges', {
+ defaultMessage: '{numberOfUnSavedChanges} Unsaved change(s)',
+ values: {
+ numberOfUnSavedChanges,
+ },
+ })}
+
+ ) : (
+
+ {i18n.translate('workspace.form.bottomBar.errors', {
+ defaultMessage: '{numberOfErrors} Error(s)',
+ values: {
+ numberOfErrors,
+ },
+ })}
+
+ )}
+
+
+
+
+
+ {i18n.translate('workspace.form.bottomBar.cancel', {
+ defaultMessage: 'Cancel',
+ })}
+
+
+ {operationType === WorkspaceOperationType.Create && (
+
+ {i18n.translate('workspace.form.bottomBar.createWorkspace', {
+ defaultMessage: 'Create workspace',
+ })}
+
+ )}
+ {operationType === WorkspaceOperationType.Update && (
+
+ {i18n.translate('workspace.form.bottomBar.saveChanges', {
+ defaultMessage: 'Save changes',
+ })}
+
+ )}
+
+
+
+
+ {isCancelModalVisible && (
+
+ )}
+
+ );
+};
diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx
new file mode 100644
index 000000000000..11e835087cd6
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { i18n } from '@osd/i18n';
+import { EuiConfirmModal } from '@elastic/eui';
+import { ApplicationStart } from 'opensearch-dashboards/public';
+import { WORKSPACE_LIST_APP_ID } from '../../../common/constants';
+
+interface WorkspaceCancelModalProps {
+ application: ApplicationStart;
+ closeCancelModal: () => void;
+}
+
+export const WorkspaceCancelModal = ({
+ application,
+ closeCancelModal,
+}: WorkspaceCancelModalProps) => {
+ return (
+ application?.navigateToApp(WORKSPACE_LIST_APP_ID)}
+ cancelButtonText={i18n.translate('workspace.form.cancelButtonText', {
+ defaultMessage: 'Continue editing',
+ })}
+ confirmButtonText={i18n.translate('workspace.form.confirmButtonText', {
+ defaultMessage: 'Discard changes',
+ })}
+ buttonColor="danger"
+ defaultFocusedButton="confirm"
+ >
+ {i18n.translate('workspace.form.cancelModal.body', {
+ defaultMessage: 'This will discard all changes. Are you sure?',
+ })}
+
+ );
+};
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
new file mode 100644
index 000000000000..0875b0d1ff10
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx
@@ -0,0 +1,92 @@
+/*
+ * 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 } 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,
+ },
+ {
+ id: 'app-2',
+ title: 'App 2',
+ category: { id: 'category-1', label: 'Category 1' },
+ navLinkStatus: AppNavLinkStatus.visible,
+ },
+ {
+ id: 'app-3',
+ title: 'App 3',
+ category: { id: 'category-2', label: 'Category 2' },
+ navLinkStatus: AppNavLinkStatus.visible,
+ },
+ {
+ id: 'app-4',
+ title: 'App 4',
+ navLinkStatus: AppNavLinkStatus.visible,
+ },
+ ];
+ 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
new file mode 100644
index 000000000000..da9deb174f52
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx
@@ -0,0 +1,143 @@
+/*
+ * 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 {
+ applications: Array<
+ Pick
+ >;
+ selectedFeatures: string[];
+ onChange: (newFeatures: string[]) => void;
+}
+
+export const WorkspaceFeatureSelector = ({
+ applications,
+ selectedFeatures,
+ onChange,
+}: WorkspaceFeatureSelectorProps) => {
+ const featuresOrGroups = useMemo(() => convertApplicationsToFeaturesOrGroups(applications), [
+ applications,
+ ]);
+
+ 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
new file mode 100644
index 000000000000..69793c75395d
--- /dev/null
+++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx
@@ -0,0 +1,151 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import {
+ EuiPanel,
+ EuiSpacer,
+ EuiTitle,
+ EuiForm,
+ EuiFormRow,
+ EuiFieldText,
+ EuiText,
+ EuiColorPicker,
+ EuiHorizontalRule,
+ EuiTab,
+ EuiTabs,
+} 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';
+
+export const WorkspaceForm = (props: WorkspaceFormProps) => {
+ const { application, defaultValues, operationType } = props;
+ const {
+ formId,
+ formData,
+ formErrors,
+ selectedTab,
+ applications,
+ numberOfErrors,
+ handleFormSubmit,
+ handleColorChange,
+ handleFeaturesChange,
+ handleNameInputChange,
+ handleTabFeatureClick,
+ handleDescriptionInputChange,
+ } = useWorkspaceForm(props);
+ const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', {
+ defaultMessage: 'Workspace Details',
+ });
+ const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', {
+ defaultMessage: 'Feature Visibility',
+ });
+
+ return (
+
+
+
+ {workspaceDetailsTitle}
+
+
+
+
+
+
+
+ Description - optional
+ >
+ }
+ helpText={i18n.translate('workspace.form.workspaceDetails.description.helpText', {
+ defaultMessage:
+ 'Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).',
+ })}
+ isInvalid={!!formErrors.description}
+ error={formErrors.description}
+ >
+
+
+
+
+
+ {i18n.translate('workspace.form.workspaceDetails.color.helpText', {
+ defaultMessage: 'Accent color for your workspace',
+ })}
+
+
+
+
+
+
+
+
+
+
+ {featureVisibilityTitle}
+
+
+ {selectedTab === WorkspaceFormTabs.FeatureVisibility && (
+
+
+ {featureVisibilityTitle}
+
+
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts
new file mode 100644
index 000000000000..875e9b494f23
--- /dev/null
+++ b/src/plugins/workspace/public/hooks.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public';
+import { useObservable } from 'react-use';
+import { useMemo } from 'react';
+
+export function useApplications(applicationInstance: ApplicationStart) {
+ const applications = useObservable(applicationInstance.applications$);
+ return useMemo(() => {
+ const apps: PublicAppInfo[] = [];
+ applications?.forEach((app) => {
+ apps.push(app);
+ });
+ return apps;
+ }, [applications]);
+}
diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts
index 2306c460b88d..0ec007079c24 100644
--- a/src/plugins/workspace/public/plugin.test.ts
+++ b/src/plugins/workspace/public/plugin.test.ts
@@ -23,6 +23,7 @@ describe('Workspace plugin', () => {
const setupMock = getSetupMock();
const workspacePlugin = new WorkspacePlugin();
await workspacePlugin.setup(setupMock);
+ expect(setupMock.application.register).toBeCalledTimes(2);
expect(WorkspaceClientMock).toBeCalledTimes(1);
});
@@ -34,7 +35,7 @@ describe('Workspace plugin', () => {
workspacePlugin.start(coreStart);
coreStart.workspaces.currentWorkspaceId$.next('foo');
expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo');
- expect(setupMock.application.register).toBeCalledTimes(1);
+ expect(setupMock.application.register).toBeCalledTimes(2);
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0);
});
@@ -69,7 +70,7 @@ describe('Workspace plugin', () => {
const workspacePlugin = new WorkspacePlugin();
await workspacePlugin.setup(setupMock);
- expect(setupMock.application.register).toBeCalledTimes(1);
+ expect(setupMock.application.register).toBeCalledTimes(2);
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId');
expect(setupMock.getStartServices).toBeCalledTimes(1);
diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts
index f0c82bda90b7..eeaab74e8e44 100644
--- a/src/plugins/workspace/public/plugin.ts
+++ b/src/plugins/workspace/public/plugin.ts
@@ -5,6 +5,7 @@
import type { Subscription } from 'rxjs';
import React from 'react';
+import { i18n } from '@osd/i18n';
import {
Plugin,
CoreStart,
@@ -12,7 +13,11 @@ import {
AppMountParameters,
AppNavLinkStatus,
} from '../../../core/public';
-import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants';
+import {
+ WORKSPACE_FATAL_ERROR_APP_ID,
+ WORKSPACE_OVERVIEW_APP_ID,
+ WORKSPACE_CREATE_APP_ID,
+} from '../common/constants';
import { getWorkspaceIdFromUrl } from '../../../core/public/utils';
import { Services } from './types';
import { WorkspaceClient } from './workspace_client';
@@ -89,6 +94,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> {
return renderApp(params, services);
};
+ // create
+ core.application.register({
+ id: WORKSPACE_CREATE_APP_ID,
+ title: i18n.translate('workspace.settings.workspaceCreate', {
+ defaultMessage: 'Create Workspace',
+ }),
+ navLinkStatus: AppNavLinkStatus.hidden,
+ async mount(params: AppMountParameters) {
+ const { renderCreatorApp } = await import('./application');
+ return mountWorkspaceApp(params, renderCreatorApp);
+ },
+ });
+
// workspace fatal error
core.application.register({
id: WORKSPACE_FATAL_ERROR_APP_ID,
diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts
new file mode 100644
index 000000000000..510a775cd745
--- /dev/null
+++ b/src/plugins/workspace/public/utils.test.ts
@@ -0,0 +1,93 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { featureMatchesConfig } from './utils';
+
+describe('workspace utils: featureMatchesConfig', () => {
+ it('feature configured with `*` should match any features', () => {
+ const match = featureMatchesConfig(['*']);
+ expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe(
+ true
+ );
+ expect(
+ match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } })
+ ).toBe(true);
+ });
+
+ it('should NOT match the config if feature id not matches', () => {
+ const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']);
+ 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']);
+ 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']);
+ 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']);
+ expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe(
+ true
+ );
+ expect(
+ match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } })
+ ).toBe(false);
+ });
+
+ it('should match any features but not the excluded feature category', () => {
+ const match = featureMatchesConfig(['*', '!@management']);
+ expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe(
+ false
+ );
+ expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe(
+ false
+ );
+ expect(
+ match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } })
+ ).toBe(true);
+ });
+
+ it('should NOT match the excluded feature category', () => {
+ const match = featureMatchesConfig(['!@management']);
+ expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe(
+ false
+ );
+ expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe(
+ false
+ );
+ });
+
+ it('should match features of a category but NOT the excluded feature', () => {
+ const match = featureMatchesConfig(['@management', '!dev_tools']);
+ expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe(
+ false
+ );
+ expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe(
+ true
+ );
+ });
+
+ 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']);
+ expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe(
+ true
+ );
+ expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe(
+ true
+ );
+ });
+});
diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts
new file mode 100644
index 000000000000..444b3aadadf3
--- /dev/null
+++ b/src/plugins/workspace/public/utils.ts
@@ -0,0 +1,57 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { AppCategory } from '../../../core/public';
+
+/**
+ * Checks if a given feature matches the provided feature configuration.
+ *
+ * Rules:
+ * 1. `*` matches any feature.
+ * 2. Config starts with `@` matches category, for example, @management matches any feature of `management` category,
+ * 3. To match a specific feature, use the feature id, such as `discover`,
+ * 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'.
+ */
+export const featureMatchesConfig = (featureConfigs: string[]) => ({
+ id,
+ category,
+}: {
+ id: string;
+ category?: AppCategory;
+}) => {
+ let matched = false;
+
+ for (const featureConfig of featureConfigs) {
+ // '*' matches any feature
+ if (featureConfig === '*') {
+ matched = true;
+ }
+
+ // The config starts with `@` matches a category
+ if (category && featureConfig === `@${category.id}`) {
+ matched = true;
+ }
+
+ // The config matches a feature id
+ if (featureConfig === id) {
+ matched = true;
+ }
+
+ // If a config starts with `!`, such feature or category will be excluded
+ if (featureConfig.startsWith('!')) {
+ if (category && featureConfig === `!@${category.id}`) {
+ matched = false;
+ }
+
+ if (featureConfig === `!${id}`) {
+ matched = false;
+ }
+ }
+ }
+
+ return matched;
+};