diff --git a/packages/core/src/api/apis/definitions/featureFlags.ts b/packages/core/src/api/apis/definitions/featureFlags.ts index 15c9ecb084..e3eaeefdfb 100644 --- a/packages/core/src/api/apis/definitions/featureFlags.ts +++ b/packages/core/src/api/apis/definitions/featureFlags.ts @@ -36,24 +36,27 @@ export enum FeatureFlagState { export type FeatureFlagsApi = { /** - * Check the feature flag name convention. Used in the - * `registerFeatureFlag` method as well as in the `set` method. + * Get a list of the user's currently enabled feature flags. + * Reads directly from window.localStorage Browser API. + * + * @returns string[] enabledFeatureFlags List of feature flags enabled by the current user + */ + getEnabledFeatureFlags(): Set; + + /** + * Check the feature flag name convention. + * Used in the `registerFeatureFlag` method as well as in the `set` method. * * @returns string[] errors List of errors as string. Empty array if no errors. */ checkFeatureFlagNameErrors(name: FeatureFlagName): string[]; /** - * Get the current user's status of a Feature Flag - * - * @returns bool True if the current user has enabled the feature flag + * Use Feature Flags as a React Hook. */ - get(name: FeatureFlagName): FeatureFlagState; - - /** - * Set the state of a Feature Flag - */ - set(name: FeatureFlagName, state: FeatureFlagState): void; + useFeatureFlag( + name: FeatureFlagName, + ): [FeatureFlagState, (state: FeatureFlagState) => void]; }; export const featureFlagsApiRef = new ApiRef({ diff --git a/packages/core/src/api/app/AppBuilder.tsx b/packages/core/src/api/app/AppBuilder.tsx index 84317a735a..bd40565260 100644 --- a/packages/core/src/api/app/AppBuilder.tsx +++ b/packages/core/src/api/app/AppBuilder.tsx @@ -63,7 +63,7 @@ export default class AppBuilder { const app = new AppImpl(this.systemIcons); const routes = new Array(); - const registeredFeatureFlags = new Array(); + const registeredFeatureFlags = new Set(); for (const plugin of this.plugins.values()) { for (const output of plugin.output()) { @@ -90,7 +90,7 @@ export default class AppBuilder { break; } case 'feature-flag': { - registeredFeatureFlags.push({ + registeredFeatureFlags.add({ pluginId: plugin.getId(), name: output.name, }); diff --git a/packages/core/src/api/app/FeatureFlags.tsx b/packages/core/src/api/app/FeatureFlags.tsx index 9921486999..e5a2063621 100644 --- a/packages/core/src/api/app/FeatureFlags.tsx +++ b/packages/core/src/api/app/FeatureFlags.tsx @@ -19,6 +19,7 @@ import React, { createContext, useContext, useState, + useEffect, FC, } from 'react'; import { FeatureFlagName } from '../plugin/types'; @@ -36,26 +37,50 @@ import { export interface FeatureFlagsEntry { pluginId: string; name: FeatureFlagName; - userEnabled: boolean; } export interface IFeatureFlagsContext { - featureFlags: FeatureFlagsEntry[]; + featureFlags: Set; + enabledFeatureFlags: Set; + refreshEnabledFeatureFlags: () => void; } export const FeatureFlagsContext = createContext({ - featureFlags: [], + featureFlags: new Set(), + enabledFeatureFlags: new Set(), + refreshEnabledFeatureFlags: () => { + throw new Error( + 'The refreshEnabledFeatureFlags method is not implemented as it is not called from within the FeatureFlagsContext context within React. ' + + 'See the Backstage documentation for examples on how to use the Feature Flags API.', + ); + }, }); export const FeatureFlagsContextProvider: FC<{ - featureFlags: FeatureFlagsEntry[]; + featureFlags: Set; children: ReactNode; }> = ({ featureFlags, children }) => { - const [userEnabledFlags, setUserEnabledFlags] = useState([]); + const [enabledFeatureFlags, setEnabledFeatureFlags] = useState< + Set + >(new Set()); + + const refreshEnabledFeatureFlags = () => { + // eslint-disable-next-line no-use-before-define + setEnabledFeatureFlags(FeatureFlags.getEnabledFeatureFlags()); + }; + + // Initially populate our setEnabledFeatureFlags + useEffect(() => { + refreshEnabledFeatureFlags(); + }, []); return ( ); @@ -70,7 +95,46 @@ export const FeatureFlagsContextProvider: FC<{ class FeatureFlagsImpl implements FeatureFlagsApi { private readonly localStorageKey = 'featureFlags'; - private getUserEnabledFeatureFlags(): Set { + private get( + enabledFeatureFlags: Set, + name: FeatureFlagName, + ): FeatureFlagState { + if (enabledFeatureFlags.has(name)) { + return FeatureFlagState.Enabled; + } + + return FeatureFlagState.NotEnabled; + } + + private set(name: FeatureFlagName, state: FeatureFlagState): void { + const errors = this.checkFeatureFlagNameErrors(name); + const flags = this.getEnabledFeatureFlags(); + + if (errors.length > 0) { + throw new Error(errors[0]); + } + + if (state === FeatureFlagState.Enabled) { + flags.add(name); + } else if (state === FeatureFlagState.NotEnabled) { + flags.delete(name); + } else { + throw new Error( + 'The `state` argument requires a recognized value from the FeatureFlagState enum. ' + + 'Please check the Backstage documentation to see all the available options.' + + 'Example values: FeatureFlagState.NotEnabled, FeatureFlagState.Enabled', + ); + } + + window.localStorage.setItem( + this.localStorageKey, + JSON.stringify( + [...flags].reduce((list, flag) => ({ ...list, [flag]: true }), {}), + ), + ); + } + + getEnabledFeatureFlags(): Set { if (!('localStorage' in window)) { throw new Error( 'Feature Flags are not supported on browsers without the Local Storage API', @@ -114,40 +178,46 @@ class FeatureFlagsImpl implements FeatureFlagsApi { return errors; } - get(name: FeatureFlagName): FeatureFlagState { - if (this.getUserEnabledFeatureFlags().has(name)) { - return FeatureFlagState.Enabled; + useFeatureFlag( + name: FeatureFlagName, + ): [FeatureFlagState, (state: FeatureFlagState) => void] { + // Check for context + const context = useContext(FeatureFlagsContext); + if (!context) { + throw new Error( + 'No FeatureFlagsContext found. ' + + 'Please use this React Hook in the context of your ', + ); } - return FeatureFlagState.NotEnabled; - } - - set(name: FeatureFlagName, state: FeatureFlagState): void { - const errors = this.checkFeatureFlagNameErrors(name); - const flags = this.getUserEnabledFeatureFlags(); - + // Check for errors + // eslint-disable-next-line no-use-before-define + const errors = FeatureFlags.checkFeatureFlagNameErrors(name); if (errors.length > 0) { throw new Error(errors[0]); } - if (state === FeatureFlagState.Enabled) { - flags.add(name); - } else if (state === FeatureFlagState.NotEnabled) { - flags.delete(name); - } else { + // Check if the feature flag is registered + const allFlagNames = [...context.featureFlags].map(flag => flag.name); + if (!allFlagNames.includes(name)) { throw new Error( - 'The `state` argument requires a recognized value from the FeatureFlagState enum. ' + - 'Please check the Backstage documentation to see all the available options.' + - 'Example values: FeatureFlagState.NotEnabled, FeatureFlagState.Enabled', + `The '${name}' feature flag is not registered by any plugin. ` + + `See the 'registerFeatureFlag' method in the Plugin API (or in your plugin.ts file) on how to register Feature Flags.`, ); } - window.localStorage.setItem( - this.localStorageKey, - JSON.stringify( - [...flags].reduce((list, flag) => ({ ...list, [flag]: true }), {}), - ), - ); + // eslint-disable-next-line no-use-before-define + const currentState = FeatureFlags.get(context.enabledFeatureFlags, name); + const setState = (state: FeatureFlagState): void => { + // Set the value + // eslint-disable-next-line no-use-before-define + FeatureFlags.set(name, state); + + // Now update the global state + context.refreshEnabledFeatureFlags(); + }; + + return [currentState, setState]; } } diff --git a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx index f9cf68f20c..a940b76263 100644 --- a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx +++ b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx @@ -19,15 +19,15 @@ import { Button } from '@material-ui/core'; import { FeatureFlagState, featureFlagsApiRef, useApi } from '@backstage/core'; const ToggleFeatureFlagButton: FC<{}> = () => { - const featureFlagsApi = useApi(featureFlagsApiRef); + const { useFeatureFlag } = useApi(featureFlagsApiRef); + const [flagState, setFlagState] = useFeatureFlag('enable-welcome-box'); const handleClick = () => { - const isEnabled = featureFlagsApi.get('enable-welcome-box'); - featureFlagsApi.set( - 'enable-welcome-box', - isEnabled ? FeatureFlagState.NotEnabled : FeatureFlagState.Enabled, - ); - window.location.reload(); + if (flagState === FeatureFlagState.Enabled) { + setFlagState(FeatureFlagState.NotEnabled); + } else { + setFlagState(FeatureFlagState.Enabled); + } }; return ( @@ -37,10 +37,9 @@ const ToggleFeatureFlagButton: FC<{}> = () => { onClick={handleClick} data-testid="button-switch-feature-flag-state" > - {featureFlagsApi.get('enable-welcome-box') === - FeatureFlagState.NotEnabled && + {flagState === FeatureFlagState.NotEnabled && 'Enable "enable-welcome-box" feature flag'} - {featureFlagsApi.get('enable-welcome-box') === FeatureFlagState.Enabled && + {flagState === FeatureFlagState.Enabled && 'Disable "enable-welcome-box" feature flag'} );