Skip to content

Commit

Permalink
feat: Interface for feature flags (#14231)
Browse files Browse the repository at this point in the history
  • Loading branch information
TomasEng authored Dec 9, 2024
1 parent 61f7546 commit 2ab780b
Show file tree
Hide file tree
Showing 32 changed files with 259 additions and 101 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState }
import classes from './SettingsModal.module.css';
import { CogIcon } from '@studio/icons';
import {
StudioModal,
StudioContentMenu,
type StudioContentMenuButtonTabProps,
StudioModal,
} from '@studio/components';
import type { SettingsModalTabId } from '../../../../../types/SettingsModalTabId';
import { useTranslation } from 'react-i18next';
Expand All @@ -16,7 +16,7 @@ import { SetupTab } from './components/Tabs/SetupTab';
import { type SettingsModalHandle } from '../../../../../types/SettingsModalHandle';
import { useSettingsModalMenuTabConfigs } from './hooks/useSettingsModalMenuTabConfigs';
import { Maskinporten } from './components/Tabs/Maskinporten';
import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils';
import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils';

export const SettingsModal = forwardRef<SettingsModalHandle, {}>(({}, ref): ReactElement => {
const { t } = useTranslation();
Expand Down Expand Up @@ -54,7 +54,7 @@ export const SettingsModal = forwardRef<SettingsModalHandle, {}>(({}, ref): Reac
return <AccessControlTab />;
}
case 'maskinporten': {
return shouldDisplayFeature('maskinporten') ? <Maskinporten /> : null;
return shouldDisplayFeature(FeatureFlag.Maskinporten) ? <Maskinporten /> : null;
}
}
};
Expand Down Expand Up @@ -94,7 +94,7 @@ SettingsModal.displayName = 'SettingsModal';
function filterFeatureFlag(
menuTabConfigs: Array<StudioContentMenuButtonTabProps<SettingsModalTabId>>,
) {
return shouldDisplayFeature('maskinporten')
return shouldDisplayFeature(FeatureFlag.Maskinporten)
? menuTabConfigs
: menuTabConfigs.filter((tab) => tab.tabId !== 'maskinporten');
}
4 changes: 2 additions & 2 deletions frontend/app-development/types/HeaderMenu/HeaderMenuItem.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { type HeaderMenuGroupKey } from 'app-development/enums/HeaderMenuGroupKey';
import { type HeaderMenuItemKey } from 'app-development/enums/HeaderMenuItemKey';
import { type RepositoryType } from 'app-shared/types/global';
import { type SupportedFeatureFlags } from 'app-shared/utils/featureToggleUtils';
import { type FeatureFlag } from 'app-shared/utils/featureToggleUtils';

export interface HeaderMenuItem {
key: HeaderMenuItemKey;
link: string;
icon?: React.FC<React.SVGProps<SVGSVGElement>>;
repositoryTypes: RepositoryType[];
featureFlagName?: SupportedFeatureFlags;
featureFlagName?: FeatureFlag;
isBeta?: boolean;
group: HeaderMenuGroupKey;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { RoutePaths } from 'app-development/enums/RoutePaths';
import { DatabaseIcon } from '@studio/icons';
import { HeaderMenuGroupKey } from 'app-development/enums/HeaderMenuGroupKey';
import { typedLocalStorage } from '@studio/pure-functions';
import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils';
import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils';

jest.mock('app-shared/utils/featureToggleUtils');

Expand Down Expand Up @@ -137,7 +137,7 @@ describe('headerMenuUtils', () => {
icon: DatabaseIcon,
repositoryTypes: [RepositoryType.App, RepositoryType.DataModels],
group: HeaderMenuGroupKey.Tools,
featureFlagName: 'shouldOverrideAppLibCheck',
featureFlagName: FeatureFlag.ShouldOverrideAppLibCheck,
};

expect(filterRoutesByFeatureFlag(menuItem)).toBe(true);
Expand All @@ -152,7 +152,7 @@ describe('headerMenuUtils', () => {
icon: DatabaseIcon,
repositoryTypes: [RepositoryType.App, RepositoryType.DataModels],
group: HeaderMenuGroupKey.Tools,
featureFlagName: 'shouldOverrideAppLibCheck',
featureFlagName: FeatureFlag.ShouldOverrideAppLibCheck,
};

expect(filterRoutesByFeatureFlag(menuItem)).toBe(false);
Expand Down
1 change: 1 addition & 0 deletions frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@
"expression.valueType.null": "Ikke satt",
"expression.valueType.number": "Tall",
"expression.valueType.string": "Tekst",
"feature_flags.heading": "Funksjonsflagg",
"form_filler.file_uploader_validation_error_upload": "Noe gikk galt da filen skulle lastes opp, prøv igjen senere.",
"general.action": "Handling",
"general.actions": "Handlinger",
Expand Down
7 changes: 4 additions & 3 deletions frontend/packages/process-editor/src/contexts/BpmnContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useRef, useState, type MutableRefObject } from 'react';
import React, { createContext, type MutableRefObject, useContext, useRef, useState } from 'react';
import { supportsProcessEditor } from '../utils/processEditorUtils';
import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils';
import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils';
import type Modeler from 'bpmn-js/lib/Modeler';
import type { BpmnDetails } from '../types/BpmnDetails';

Expand Down Expand Up @@ -29,7 +29,8 @@ export const BpmnContextProvider = ({
const [bpmnDetails, setBpmnDetails] = useState<BpmnDetails>(null);

const isEditAllowed =
supportsProcessEditor(appLibVersion) || shouldDisplayFeature('shouldOverrideAppLibCheck');
supportsProcessEditor(appLibVersion) ||
shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck);

const modelerRef = useRef<Modeler | null>(null);

Expand Down
31 changes: 16 additions & 15 deletions frontend/packages/shared/src/utils/featureToggleUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,29 @@ import {
addFeatureFlagToLocalStorage,
removeFeatureFlagFromLocalStorage,
shouldDisplayFeature,
FeatureFlag,
} from './featureToggleUtils';

describe('featureToggle localStorage', () => {
beforeEach(() => typedLocalStorage.removeItem('featureFlags'));

it('should return true if feature is enabled in the localStorage', () => {
typedLocalStorage.setItem<string[]>('featureFlags', ['shouldOverrideAppLibCheck']);
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy();
});

it('should return true if featureFlag includes in feature params', () => {
typedLocalStorage.setItem<string[]>('featureFlags', ['demo', 'shouldOverrideAppLibCheck']);
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy();
});

it('should return false if feature is not enabled in the localStorage', () => {
typedLocalStorage.setItem<string[]>('featureFlags', ['demo']);
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy();
});

it('should return false if feature is not enabled in the localStorage', () => {
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy();
});
});

Expand All @@ -35,22 +36,22 @@ describe('featureToggle url', () => {
});
it('should return true if feature is enabled in the url', () => {
window.history.pushState({}, 'PageUrl', '/?featureFlags=shouldOverrideAppLibCheck');
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy();
});

it('should return true if featureFlag includes in feature params', () => {
window.history.pushState({}, 'PageUrl', '/?featureFlags=demo,shouldOverrideAppLibCheck');
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy();
});

it('should return false if feature is not included in the url', () => {
window.history.pushState({}, 'PageUrl', '/?featureFlags=demo');
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy();
});

it('should return false if feature is not included in the url', () => {
window.history.pushState({}, 'PageUrl', '/');
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy();
});

it('should persist features in sessionStorage when persistFeatureFlag is set in url', () => {
Expand All @@ -59,9 +60,9 @@ describe('featureToggle url', () => {
'PageUrl',
'/?featureFlags=resourceMigration,shouldOverrideAppLibCheck&persistFeatureFlag=true',
);
expect(shouldDisplayFeature('componentConfigBeta')).toBeFalsy();
expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy();
expect(shouldDisplayFeature('resourceMigration')).toBeTruthy();
expect(shouldDisplayFeature(FeatureFlag.ComponentConfigBeta)).toBeFalsy();
expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy();
expect(shouldDisplayFeature(FeatureFlag.ResourceMigration)).toBeTruthy();
expect(typedSessionStorage.getItem<string[]>('featureFlags')).toEqual([
'shouldOverrideAppLibCheck',
'resourceMigration',
Expand All @@ -75,14 +76,14 @@ describe('addFeatureToLocalStorage', () => {
typedLocalStorage.removeItem('featureFlags');
});
it('should add feature to local storage', () => {
addFeatureFlagToLocalStorage('shouldOverrideAppLibCheck');
addFeatureFlagToLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck);
expect(typedLocalStorage.getItem<string[]>('featureFlags')).toEqual([
'shouldOverrideAppLibCheck',
]);
});
it('should append provided feature to existing features in local storage', () => {
typedLocalStorage.setItem<string[]>('featureFlags', ['demo']);
addFeatureFlagToLocalStorage('shouldOverrideAppLibCheck');
addFeatureFlagToLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck);
expect(typedLocalStorage.getItem<string[]>('featureFlags')).toEqual([
'demo',
'shouldOverrideAppLibCheck',
Expand All @@ -96,12 +97,12 @@ describe('removeFeatureFromLocalStorage', () => {
});
it('should remove feature from local storage', () => {
typedLocalStorage.setItem<string[]>('featureFlags', ['shouldOverrideAppLibCheck']);
removeFeatureFlagFromLocalStorage('shouldOverrideAppLibCheck');
removeFeatureFlagFromLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck);
expect(typedLocalStorage.getItem<string[]>('featureFlags')).toEqual([]);
});
it('should only remove specified feature from local storage', () => {
typedLocalStorage.setItem<string[]>('featureFlags', ['shouldOverrideAppLibCheck', 'demo']);
removeFeatureFlagFromLocalStorage('shouldOverrideAppLibCheck');
removeFeatureFlagFromLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck);
expect(typedLocalStorage.getItem<string[]>('featureFlags')).toEqual(['demo']);
});
});
43 changes: 21 additions & 22 deletions frontend/packages/shared/src/utils/featureToggleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,25 @@ import { typedLocalStorage, typedSessionStorage } from '@studio/pure-functions';
const featureFlagKey = 'featureFlags';
const persistFeatureKey = 'persistFeatureFlag';

// All the features that you want to be toggle on/off should be added here. To ensure that we type check the feature name.
export type SupportedFeatureFlags =
| 'componentConfigBeta'
| 'shouldOverrideAppLibCheck'
| 'resourceMigration'
| 'multipleDataModelsPerTask'
| 'exportForm'
| 'addComponentModal'
| 'subform'
| 'summary2'
| 'codeListEditor'
| 'optionListEditor'
| 'maskinporten';
export enum FeatureFlag {
AddComponentModal = 'addComponentModal',
ComponentConfigBeta = 'componentConfigBeta',
ExportForm = 'exportForm',
Maskinporten = 'maskinporten',
MultipleDataModelsPerTask = 'multipleDataModelsPerTask',
OptionListEditor = 'optionListEditor',
ResourceMigration = 'resourceMigration',
ShouldOverrideAppLibCheck = 'shouldOverrideAppLibCheck',
Subform = 'subform',
Summary2 = 'summary2',
}

/*
* Please add all the features that you want to be toggle on by default here.
* Remember that all the features that are listed here will be available to the users in production,
* since this is the default active features.
*/
const defaultActiveFeatures: SupportedFeatureFlags[] = [];
const defaultActiveFeatures: FeatureFlag[] = [];

/**
* @param featureFlag
Expand All @@ -31,7 +30,7 @@ const defaultActiveFeatures: SupportedFeatureFlags[] = [];
* @example shouldDisplayFeature('myFeatureName') && <MyFeatureComponent />
* @example The feature can be toggled and persisted by the url query, (url)?featureFlags=[featureName]&persistFeatureFlag=true
*/
export const shouldDisplayFeature = (featureFlag: SupportedFeatureFlags): boolean => {
export const shouldDisplayFeature = (featureFlag: FeatureFlag): boolean => {
// Check if feature should be persisted in session storage, (url)?persistFeatureFlag=true
if (shouldPersistInSession() && isFeatureActivatedByUrl(featureFlag)) {
addFeatureFlagToSessionStorage(featureFlag);
Expand All @@ -46,12 +45,12 @@ export const shouldDisplayFeature = (featureFlag: SupportedFeatureFlags): boolea
};

// Check if the feature is one of the default active features
const isDefaultActivatedFeature = (featureFlag: SupportedFeatureFlags): boolean => {
const isDefaultActivatedFeature = (featureFlag: FeatureFlag): boolean => {
return defaultActiveFeatures.includes(featureFlag);
};

// Check if feature includes in the url query, (url)?featureFlags=[featureName]
const isFeatureActivatedByUrl = (featureFlag: SupportedFeatureFlags): boolean => {
const isFeatureActivatedByUrl = (featureFlag: FeatureFlag): boolean => {
const urlParams = new URLSearchParams(window.location.search);
const featureParam = urlParams.get(featureFlagKey);

Expand All @@ -64,7 +63,7 @@ const isFeatureActivatedByUrl = (featureFlag: SupportedFeatureFlags): boolean =>
};

// Check if feature includes in local storage, featureFlags: ["featureName"]
const isFeatureActivatedByLocalStorage = (featureFlag: SupportedFeatureFlags): boolean => {
export const isFeatureActivatedByLocalStorage = (featureFlag: FeatureFlag): boolean => {
const featureFlagsFromStorage = typedLocalStorage.getItem<string[]>(featureFlagKey) || [];
return featureFlagsFromStorage.includes(featureFlag);
};
Expand All @@ -74,7 +73,7 @@ const isFeatureActivatedByLocalStorage = (featureFlag: SupportedFeatureFlags): b
* @description This function will add the feature flag to local storage
* @example addFeatureToLocalStorage('myFeatureName')
*/
export const addFeatureFlagToLocalStorage = (featureFlag: SupportedFeatureFlags): void => {
export const addFeatureFlagToLocalStorage = (featureFlag: FeatureFlag): void => {
const featureFlagsFromStorage = typedLocalStorage.getItem<string[]>(featureFlagKey) || [];
featureFlagsFromStorage.push(featureFlag);
typedLocalStorage.setItem(featureFlagKey, featureFlagsFromStorage);
Expand All @@ -85,14 +84,14 @@ export const addFeatureFlagToLocalStorage = (featureFlag: SupportedFeatureFlags)
* @description This function will remove the feature flag from local storage
* @example removeFeatureFromLocalStorage('myFeatureName')
*/
export const removeFeatureFlagFromLocalStorage = (featureFlag: SupportedFeatureFlags): void => {
export const removeFeatureFlagFromLocalStorage = (featureFlag: FeatureFlag): void => {
const featureFlagsFromStorage = typedLocalStorage.getItem<string[]>(featureFlagKey) || [];
const filteredFeatureFlags = featureFlagsFromStorage.filter((feature) => feature !== featureFlag);
typedLocalStorage.setItem(featureFlagKey, filteredFeatureFlags);
};

// Check if feature includes in session storage, featureFlags: ["featureName"]
const isFeatureActivatedBySessionStorage = (featureFlag: SupportedFeatureFlags): boolean => {
const isFeatureActivatedBySessionStorage = (featureFlag: FeatureFlag): boolean => {
const featureFlagsFromStorage = typedSessionStorage.getItem<string[]>(featureFlagKey) || [];
return featureFlagsFromStorage.includes(featureFlag);
};
Expand All @@ -105,7 +104,7 @@ const shouldPersistInSession = (): boolean => {
};

// Add feature to session storage to persist the feature in the current session
const addFeatureFlagToSessionStorage = (featureFlag: SupportedFeatureFlags): void => {
const addFeatureFlagToSessionStorage = (featureFlag: FeatureFlag): void => {
const featureFlagsFromStorage = typedSessionStorage.getItem<string[]>(featureFlagKey) || [];

const featureFlagAlreadyExist = featureFlagsFromStorage.includes(featureFlag);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { textMock } from '@studio/testing/mocks/i18nMock';
import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery';
import { DEFAULT_LANGUAGE } from 'app-shared/constants';
import { typedLocalStorage } from '@studio/pure-functions';
import { addFeatureFlagToLocalStorage } from 'app-shared/utils/featureToggleUtils';
import { addFeatureFlagToLocalStorage, FeatureFlag } from 'app-shared/utils/featureToggleUtils';
import { app, org } from '@studio/testing/testids';

const user = userEvent.setup();
Expand Down Expand Up @@ -226,7 +226,7 @@ describe('TextResource', () => {
});

it('Renders delete button as enabled when handleRemoveTextResource is given and componentConfigBeta feature flag is enabled', async () => {
addFeatureFlagToLocalStorage('componentConfigBeta');
addFeatureFlagToLocalStorage(FeatureFlag.ComponentConfigBeta);
await render({ textResourceId: 'test', handleRemoveTextResource: jest.fn() });
expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeEnabled();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { ITextResource } from 'app-shared/types/global';
import { FormField } from './FormField';
import { AltinnConfirmDialog } from 'app-shared/components/AltinnConfirmDialog';
import { useTranslation } from 'react-i18next';
import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils';
import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils';
import { StudioButton, StudioNativeSelect } from '@studio/components';

export interface TextResourceProps {
Expand Down Expand Up @@ -181,7 +181,7 @@ export const TextResource = ({
color='second'
disabled={
!handleRemoveTextResource ||
!(!!textResourceId || shouldDisplayFeature('componentConfigBeta'))
!(!!textResourceId || shouldDisplayFeature(FeatureFlag.ComponentConfigBeta))
}
icon={<TrashIcon />}
onClick={() => setIsConfirmDeleteDialogOpen(true)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import type { DataModelMetadataResponse } from 'app-shared/types/api';
import { dataModelNameMock, layoutSet1NameMock } from '@altinn/ux-editor-v3/testing/layoutSetsMock';
import { app, org } from '@studio/testing/testids';
import { textMock } from '@studio/testing/mocks/i18nMock';
import { removeFeatureFlagFromLocalStorage } from 'app-shared/utils/featureToggleUtils';
import {
removeFeatureFlagFromLocalStorage,
FeatureFlag,
} from 'app-shared/utils/featureToggleUtils';

// Test data:
const srcValueLabel = 'Source';
Expand Down Expand Up @@ -68,7 +71,7 @@ const getDataModelMetadata = () =>

describe('EditFormComponent', () => {
beforeEach(() => {
removeFeatureFlagFromLocalStorage('componentConfigBeta');
removeFeatureFlagFromLocalStorage(FeatureFlag.ComponentConfigBeta);
jest.clearAllMocks();
});

Expand Down
Loading

0 comments on commit 2ab780b

Please sign in to comment.