diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index bbf7f17c32..6006dc6cb7 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -4,7 +4,7 @@ The `featureFlags` object passed to the `register` function makes it possible fo ```typescript export type FeatureFlagsHooks = { - registerFeatureFlag(name: FeatureFlagName): void; + register(name: FeatureFlagName): void; }; ``` @@ -17,7 +17,7 @@ export default createPlugin({ id: 'welcome', register({ router, featureFlags }) { // router.registerRoute('/', Component); - featureFlags.registerFeatureFlag('enable-example-feature'); + featureFlags.register('enable-example-feature'); }, }); ``` @@ -48,11 +48,10 @@ import { Button } from '@material-ui/core'; import { featureFlagsApiRef, useApi } from '@backstage/core'; const ExampleButton: FC<{}> = () => { - const { useFeatureFlag } = useApi(featureFlagsApiRef); - const [flagState, setFlagState] = useFeatureFlag('enable-example-feature'); + const flags = useApi(featureFlagsApiRef).getFlags(); const handleClick = () => { - setFlagState(FeatureFlagState.Enabled); + flags.set('enable-example-feature', FeatureFlagState.Enabled); }; return ( @@ -63,6 +62,4 @@ const ExampleButton: FC<{}> = () => { }; ``` -Note that you must register it with `registerFeatureFlag` otherwise the `useFeatureFlag` will throw an error. - [Back to References](README.md) diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index b19df50c22..de4636e11d 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -33,7 +33,7 @@ import ExampleComponent from './components/ExampleComponent'; export default createPlugin({ id: 'new-plugin', register({ router, featureFlags }) { - featureFlags.registerFeatureFlag('enable-example-component'); + featureFlags.register('enable-example-component'); router.registerRoute('/new-plugin', ExampleComponent); }, diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 4d339c3734..494b469312 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,8 +26,8 @@ import { ErrorDisplayForwarder } from './components/ErrorDisplay/ErrorDisplay'; const builder = ApiRegistry.builder(); export const errorDialogForwarder = new ErrorDisplayForwarder(); - builder.add(errorApiRef, errorDialogForwarder); -builder.add(featureFlagsApiRef, FeatureFlags); + +builder.add(featureFlagsApiRef, new FeatureFlags()); export default builder.build() as ApiHolder; diff --git a/packages/core/src/api/apis/definitions/featureFlags.ts b/packages/core/src/api/apis/definitions/featureFlags.ts index e3eaeefdfb..95fd02a1cb 100644 --- a/packages/core/src/api/apis/definitions/featureFlags.ts +++ b/packages/core/src/api/apis/definitions/featureFlags.ts @@ -15,7 +15,11 @@ */ import ApiRef from '../ApiRef'; -import { FeatureFlagName } from '../../plugin/types'; +import { + UserFlags, + FeatureFlagsRegistry, + FeatureFlagsRegistryItem, +} from '../../app/FeatureFlags'; /** * The feature flags API is used to toggle functionality to users across plugins and Backstage. @@ -34,30 +38,22 @@ export enum FeatureFlagState { Enabled = 1, } -export type FeatureFlagsApi = { +export interface FeatureFlagsApi { /** - * 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 + * Store a list of registered feature flags. */ - getEnabledFeatureFlags(): Set; + registeredFeatureFlags: FeatureFlagsRegistryItem[]; /** - * 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. + * Get a list of all feature flags from the current user. */ - checkFeatureFlagNameErrors(name: FeatureFlagName): string[]; + getFlags(): UserFlags; /** - * Use Feature Flags as a React Hook. + * Get a list of all registered flags. */ - useFeatureFlag( - name: FeatureFlagName, - ): [FeatureFlagState, (state: FeatureFlagState) => void]; -}; + getRegisteredFlags(): FeatureFlagsRegistry; +} export const featureFlagsApiRef = new ApiRef({ id: 'core.featureflags', diff --git a/packages/core/src/api/app/AppBuilder.tsx b/packages/core/src/api/app/AppBuilder.tsx index 6964c45a26..9aca19b1fe 100644 --- a/packages/core/src/api/app/AppBuilder.tsx +++ b/packages/core/src/api/app/AppBuilder.tsx @@ -19,7 +19,8 @@ import { Route, Switch, Redirect } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; import { App } from './types'; import BackstagePlugin from '../plugin/Plugin'; -import { FeatureFlags, FeatureFlagsEntry } from './FeatureFlags'; +import { FeatureFlagsRegistryItem } from './FeatureFlags'; +import { featureFlagsApiRef } from '../apis/definitions/featureFlags'; import { IconComponent, SystemIcons, @@ -63,7 +64,7 @@ export default class AppBuilder { const app = new AppImpl(this.systemIcons); const routes = new Array(); - const registeredFeatureFlags = new Set(); + const registeredFeatureFlags = new Array(); for (const plugin of this.plugins.values()) { for (const output of plugin.output()) { @@ -90,7 +91,7 @@ export default class AppBuilder { break; } case 'feature-flag': { - registeredFeatureFlags.add({ + registeredFeatureFlags.push({ pluginId: plugin.getId(), name: output.name, }); @@ -102,7 +103,10 @@ export default class AppBuilder { } } - FeatureFlags.registeredFeatureFlags = registeredFeatureFlags; + const FeatureFlags = this.apis && this.apis.get(featureFlagsApiRef); + if (FeatureFlags) { + FeatureFlags!.registeredFeatureFlags = registeredFeatureFlags; + } routes.push( , diff --git a/packages/core/src/api/app/FeatureFlags.test.tsx b/packages/core/src/api/app/FeatureFlags.test.tsx index cb5be32394..1ad247634c 100644 --- a/packages/core/src/api/app/FeatureFlags.test.tsx +++ b/packages/core/src/api/app/FeatureFlags.test.tsx @@ -14,233 +14,231 @@ * limitations under the License. */ -import React, { ReactNode, useContext, useEffect, useRef } from 'react'; -import { render } from '@testing-library/react'; -import { FeatureFlags } from './FeatureFlags'; +import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags'; import { FeatureFlagState } from '../apis/definitions/featureFlags'; -function useRenderCount() { - const renderCount = useRef(-1); - renderCount.current += 1; - return renderCount.current; -} - -function withFeatureFlags( - children: ReactNode, - featureFlags: Set = new Set([ - { name: 'feature-flag-one', pluginId: 'plugin-one' }, - { name: 'feature-flag-two', pluginId: 'plugin-two' }, - { name: 'feature-flag-three', pluginId: 'plugin-two' }, - ]), -) {} - describe('FeatureFlags', () => { beforeEach(() => { window.localStorage.clear(); }); - describe('#getEnabledFeatureFlags', () => { - it('returns an empty set', () => { - expect(FeatureFlags.getEnabledFeatureFlags()).toEqual(new Set()); + describe('#getFlags', () => { + let FeatureFlags; + + beforeEach(() => { + FeatureFlags = new FeatureFlagsImpl(); }); - it('returns enabled feature flags', () => { + it('returns no flags', () => { + expect(FeatureFlags.getFlags().toObject()).toMatchObject({}); + }); + + it('returns the correct flags', () => { window.localStorage.setItem( 'featureFlags', JSON.stringify({ - 'feature-flag-one': true, - 'feature-flag-three': true, + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, }), ); - expect(FeatureFlags.getEnabledFeatureFlags()).toEqual( - new Set(['feature-flag-one', 'feature-flag-three']), + expect(FeatureFlags.getFlags().toObject()).toMatchObject({ + 'feature-flag-one': FeatureFlagState.Enabled, + 'feature-flag-two': FeatureFlagState.Enabled, + 'feature-flag-three': FeatureFlagState.NotEnabled, + }); + }); + + it('gets the correct values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 0, + }), ); + + expect(FeatureFlags.getFlags().get('feature-flag-one')).toEqual( + FeatureFlagState.Enabled, + ); + expect(FeatureFlags.getFlags().get('feature-flag-two')).toEqual( + FeatureFlagState.NotEnabled, + ); + expect(FeatureFlags.getFlags().get('feature-flag-three')).toEqual( + FeatureFlagState.NotEnabled, + ); + }); + + it('sets the correct values', () => { + const flags = FeatureFlags.getFlags(); + flags.set('feature-flag-zero', FeatureFlagState.Enabled); + + expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.Enabled); + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-zero":1}', + ); + }); + + it('deletes the correct values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 0, + }), + ); + + const flags = FeatureFlags.getFlags(); + flags.delete('feature-flag-one'); + + expect(flags.get('feature-flag-one')).toEqual( + FeatureFlagState.NotEnabled, + ); + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-two":0}', + ); + }); + + it('clears all values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, + }), + ); + + const flags = FeatureFlags.getFlags(); + flags.clear(); + + expect(flags.toObject()).toEqual({}); + expect(window.localStorage.getItem('featureFlags')).toEqual('{}'); }); }); - describe('#checkFeatureFlagNameErrors', () => { - it('returns an error if less than three characters', () => { - const errors = FeatureFlags.checkFeatureFlagNameErrors('ab'); - expect(errors[0]).toMatch(/minimum length of three characters/i); + describe('#getRegisteredFlags', () => { + let FeatureFlags; + + beforeEach(() => { + FeatureFlags = new FeatureFlagsImpl(); + FeatureFlags.registeredFeatureFlags = [ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]; }); - it('returns an error if greater than 150 characters', () => { - const errors = FeatureFlags.checkFeatureFlagNameErrors( - 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', - ); - expect(errors[0]).toMatch(/not exceed 150 characters/i); + it('should return an empty list', () => { + FeatureFlags.registeredFeatureFlags = []; + expect(FeatureFlags.getRegisteredFlags().toObject()).toEqual([]); }); - it('returns an error if name does not start with a lowercase letter', () => { - const errors = FeatureFlags.checkFeatureFlagNameErrors('123456789'); - expect(errors[0]).toMatch(/start with a lowercase letter/i); + it('should return an valid list', () => { + expect(FeatureFlags.getRegisteredFlags().toObject()).toMatchObject([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); }); - it('returns an error if name contains characters other than lowercase letters, numbers and hyphens', () => { - const errors = FeatureFlags.checkFeatureFlagNameErrors( - 'Invalid_Feature_Flag', - ); - expect(errors[0]).toMatch( - /only contain lowercase letters, numbers and hyphens/i, - ); + it('should get the correct values', () => { + const getByName = name => + FeatureFlags.getRegisteredFlags().find(flag => flag.name === name); + + expect(getByName('registered-flag-0')).toBeUndefined(); + expect(getByName('registered-flag-1')).toEqual({ + name: 'registered-flag-1', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-2')).toEqual({ + name: 'registered-flag-2', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-3')).toEqual({ + name: 'registered-flag-3', + pluginId: 'plugin-two', + }); }); - it('returns no errors', () => { - const errors = FeatureFlags.checkFeatureFlagNameErrors( - 'valid-feature-flag', - ); - expect(errors.length).toBe(0); - }); - }); + it('should append the correct value', () => { + const flags = FeatureFlags.getRegisteredFlags(); - describe('#useFeatureFlag', () => { - it('throws an error if the feature flag is not registered', () => { - const Component = () => { - expect(() => { - FeatureFlags.useFeatureFlag('feature-flag-four'); - }).toThrow(/'feature-flag-four' feature flag is not registered/i); + flags.push({ + name: 'registered-flag-4', + pluginId: 'plugin-three', + }); - return null; - }; - - render(withFeatureFlags()); + expect(flags.toObject()).toMatchObject([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + { name: 'registered-flag-4', pluginId: 'plugin-three' }, + ]); }); - it('throws an error if changing value is not recognized', () => { - const Component = () => { - const [, setState] = FeatureFlags.useFeatureFlag('feature-flag-one'); - const renderCount = useRenderCount(); - if (renderCount === 1) { - // @ts-ignore - expect(() => setState('not valid')).toThrow( - /requires a recognized value from the FeatureFlagState/i, - ); - } - return null; - }; + it('should concat the correct values', () => { + const flags = FeatureFlags.getRegisteredFlags(); + const concatValues = flags.concat([ + { + name: 'registered-flag-4', + pluginId: 'plugin-three', + }, + { + name: 'registered-flag-5', + pluginId: 'plugin-four', + }, + ]); - render(withFeatureFlags()); + expect(concatValues).toMatchObject([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + { name: 'registered-flag-4', pluginId: 'plugin-three' }, + { name: 'registered-flag-5', pluginId: 'plugin-four' }, + ]); }); - it('defaults to .NotEnabled', () => { - const Component = () => { - const renderCount = useRenderCount(); - const [state] = FeatureFlags.useFeatureFlag('feature-flag-one'); - if (renderCount === 1) { - expect(state).toEqual(FeatureFlagState.NotEnabled); - } - return null; - }; - - render(withFeatureFlags()); + it('throws an error if length is less than three characters', () => { + const flags = FeatureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: 'ab', + pluginId: 'plugin-three', + }), + ).toThrow(/minimum length of three characters/i); }); - it('returns an .Enabled state', () => { - window.localStorage.setItem('featureFlags', '{"feature-flag-one":true}'); - - const Component = () => { - const [state] = FeatureFlags.useFeatureFlag('feature-flag-one'); - const renderCount = useRenderCount(); - if (renderCount === 1) { - expect(state).toEqual(FeatureFlagState.Enabled); - } - return null; - }; - - render(withFeatureFlags()); + it('throws an error if length is greater than 150 characters', () => { + const flags = FeatureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: + 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', + pluginId: 'plugin-three', + }), + ).toThrow(/not exceed 150 characters/i); }); - it('returns an .NotEnabled state', () => { - const Component = () => { - const [state] = FeatureFlags.useFeatureFlag('feature-flag-one'); - const renderCount = useRenderCount(); - if (renderCount === 1) { - expect(state).toEqual(FeatureFlagState.NotEnabled); - } - return null; - }; - - render(withFeatureFlags()); + it('throws an error if name does not start with a lowercase letter', () => { + const flags = FeatureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: '123456789', + pluginId: 'plugin-three', + }), + ).toThrow(/start with a lowercase letter/i); }); - it('changes state to .Enabled', () => { - const Component = () => { - const [state, setState] = FeatureFlags.useFeatureFlag( - 'feature-flag-one', - ); - const renderCount = useRenderCount(); - if (renderCount === 1) setState(FeatureFlagState.Enabled); - if (renderCount === 2) expect(state).toEqual(FeatureFlagState.Enabled); - return null; - }; - - render(withFeatureFlags()); - }); - - it('changes state to .NotEnabled', () => { - window.localStorage.setItem('featureFlags', '{"feature-flag-one":true}'); - - const Component = () => { - const [state, setState] = FeatureFlags.useFeatureFlag( - 'feature-flag-one', - ); - const renderCount = useRenderCount(); - if (renderCount === 1) setState(FeatureFlagState.NotEnabled); - if (renderCount === 2) - expect(state).toEqual(FeatureFlagState.NotEnabled); - return null; - }; - - render(withFeatureFlags()); - }); - - it('changes state to .Enabled then .NotEnabled', () => { - const Component = () => { - const [state, setState] = FeatureFlags.useFeatureFlag( - 'feature-flag-one', - ); - const renderCount = useRenderCount(); - if (renderCount === 1) setState(FeatureFlagState.Enabled); - if (renderCount === 2) setState(FeatureFlagState.NotEnabled); - if (renderCount === 3) - expect(state).toEqual(FeatureFlagState.NotEnabled); - return null; - }; - - render(withFeatureFlags()); - }); - - it('changes multiple feature flag states', () => { - expect.assertions(3); - - const Component = () => { - const renderCount = useRenderCount(); - const [stateA, setStateA] = FeatureFlags.useFeatureFlag( - 'feature-flag-one', - ); - const [stateB, setStateB] = FeatureFlags.useFeatureFlag( - 'feature-flag-two', - ); - - useEffect(() => { - if (renderCount === 1) { - setStateA(FeatureFlagState.Enabled); - setStateB(FeatureFlagState.Enabled); - } - if (renderCount === 2) { - expect(stateA).toEqual(FeatureFlagState.Enabled); - expect(stateB).toEqual(FeatureFlagState.Enabled); - expect(window.localStorage.getItem('featureFlags')).toEqual( - '{"feature-flag-one":true,"feature-flag-two":true}', - ); - } - }, [renderCount]); - - return null; - }; - - render(withFeatureFlags()); + it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => { + const flags = FeatureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: 'Invalid_Feature_Flag', + pluginId: 'plugin-three', + }), + ).toThrow(/only contain lowercase letters, numbers and hyphens/i); }); }); }); diff --git a/packages/core/src/api/app/FeatureFlags.tsx b/packages/core/src/api/app/FeatureFlags.tsx index 172daeb878..3c2983e63f 100644 --- a/packages/core/src/api/app/FeatureFlags.tsx +++ b/packages/core/src/api/app/FeatureFlags.tsx @@ -20,105 +20,158 @@ import { FeatureFlagsApi, } from '../apis/definitions/featureFlags'; -export interface FeatureFlagsEntry { +/** + * Helper method for validating compatibility and flag name. + */ +export function validateBrowserCompat(): void { + if (!('localStorage' in window)) { + throw new Error( + 'Feature Flags are not supported on browsers without the Local Storage API', + ); + } +} + +export function validateFlagName(name: FeatureFlagName): void { + if (name.length < 3) { + throw new Error(`The '${name}' feature flag must have a minimum length of three characters.`); + } + + if (name.length > 150) { + throw new Error(`The '${name}' feature flag must not exceed 150 characters.`); + } + + if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { + throw new Error( + `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` + + 'Examples: feature-flag-one, alpha, release-2020', + ); + } +} + +/** + * The UserFlags class. + * + * This acts as a data structure for the user's feature flags. You + * can use this to retrieve, add, edit, delete, clear and save the user's + * feature flags to the local browser for persisted storage. + */ +export class UserFlags extends Map { + constructor() { + validateBrowserCompat(); + + try { + const jsonString = window.localStorage.getItem('featureFlags') as string; + const json = JSON.parse(jsonString); + super(Object.entries(json)); + } catch (err) { + super([]); + } + } + + get(name: FeatureFlagName): FeatureFlagState { + validateFlagName(name); + return super.get(name) || FeatureFlagState.NotEnabled; + } + + set(name: FeatureFlagName, state: FeatureFlagState): this { + validateFlagName(name); + super.set(name, state); + this.save(); + return this; + } + + delete(name: FeatureFlagName): boolean { + const exists = !!this.get(name); + super.delete(name); + this.save(); + return exists; + } + + clear(): void { + super.clear(); + this.save(); + } + + save(): void { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify(this.toObject()), + ); + } + + toObject() { + return Array.from(this.entries()).reduce( + (obj, [key, value]) => ({ ...obj, [key]: value }), + {}, + ); + } + + toJSON() { + return JSON.stringify(this.toObject()); + } + + toString() { + return this.toJSON(); + } +} + +/** + * The FeatureFlagsRegistry class. + * + * This acts as a holding data structure for feature flags + * that plugins wish to register for use in Backstage. + */ +export interface FeatureFlagsRegistryItem { pluginId: string; name: FeatureFlagName; } +export class FeatureFlagsRegistry extends Array { + static from(entries: FeatureFlagsRegistryItem[]) { + Array.from(entries).forEach(entry => validateFlagName(entry.name)); + return new FeatureFlagsRegistry(...entries); + } + + push(...entries: FeatureFlagsRegistryItem[]): number { + Array.from(entries).forEach(entry => validateFlagName(entry.name)); + return super.push(...entries); + } + + concat( + ...entries: ( + | FeatureFlagsRegistryItem + | ConcatArray + )[] + ): FeatureFlagsRegistryItem[] { + const _concat = super.concat(...entries); + Array.from(_concat).forEach(entry => validateFlagName(entry.name)); + return _concat; + } + + toObject() { + return [...this.values()]; + } + + toJSON() { + return JSON.stringify(this.toObject()); + } + + toString() { + return this.toJSON(); + } +} + /** * Create the FeatureFlags implementation based on the API. */ +export class FeatureFlags implements FeatureFlagsApi { + public registeredFeatureFlags: FeatureFlagsRegistryItem[] = []; -// TODO: figure out where to put implementations of APIs, both inside apps -// but also in core/separate package. -class FeatureFlagsImpl implements FeatureFlagsApi { - private readonly localStorageKey = 'featureFlags'; - - public constructor(public registeredFeatureFlags: FeatureFlagsEntry[] = []) {} - - private getUserEnabledFeatureFlags(): Set { - if (!('localStorage' in window)) { - throw new Error( - 'Feature Flags are not supported on browsers without the Local Storage API', - ); - } - - try { - const featureFlagsJson = window.localStorage.getItem( - this.localStorageKey, - ); - return new Set( - Object.keys(JSON.parse(featureFlagsJson!)), - ); - } catch (err) { - return new Set(); - } + getFlags(): UserFlags { + return new UserFlags(); } - // We don't make this private as we need this to validate - // in the `registerFeatureFlag` method in the Plugin API. - checkFeatureFlagNameErrors(name: FeatureFlagName): string[] { - const errors: string[] = []; - - if (name.length < 3) { - errors.push('The name must have a minimum length of three characters.'); - } - - if (name.length > 150) { - errors.push('The name must not exceed 150 characters.'); - } - - if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { - errors.push( - 'The name must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ' + - 'Examples: feature-flag-one, alpha, release-2020', - ); - } - - return errors; - } - - 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 ', - ); - } - - // 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]); - } - - // Check if the feature flag is registered - const allFlagNames = [...context.featureFlags].map(flag => flag.name); - if (!allFlagNames.includes(name)) { - throw new Error( - `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.`, - ); - } - - // 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]; + getRegisteredFlags(): FeatureFlagsRegistry { + return FeatureFlagsRegistry.from(this.registeredFeatureFlags); } } - -export const FeatureFlags = new FeatureFlagsImpl(); diff --git a/packages/core/src/api/plugin/Plugin.tsx b/packages/core/src/api/plugin/Plugin.tsx index d342cb2429..aade5d0092 100644 --- a/packages/core/src/api/plugin/Plugin.tsx +++ b/packages/core/src/api/plugin/Plugin.tsx @@ -21,7 +21,7 @@ import { RouteOptions, FeatureFlagName, } from './types'; -import { FeatureFlags } from '../app/FeatureFlags'; +import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags'; import { Widget } from '../widgetView/types'; export type PluginConfig = { @@ -54,7 +54,7 @@ export type WidgetHooks = { }; export type FeatureFlagsHooks = { - registerFeatureFlag(name: FeatureFlagName): void; + register(name: FeatureFlagName): void; }; export const registerSymbol = Symbol('plugin-register'); @@ -77,7 +77,6 @@ export default class Plugin { return []; } - const pluginId = this.getId(); const outputs = new Array(); this.config.register({ @@ -95,15 +94,9 @@ export default class Plugin { }, }, featureFlags: { - registerFeatureFlag(name) { - const errors = FeatureFlags.checkFeatureFlagNameErrors(name); - - if (errors.length > 0) { - throw new Error( - `Failed to register '${name}' feature flag in '${pluginId}' plugin: ${errors[0]}`, - ); - } - + register(name) { + validateBrowserCompat(); + validateFlagName(name); outputs.push({ type: 'feature-flag', name }); }, }, diff --git a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.test.tsx b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.test.tsx index f9be280f50..e5c0b05ecf 100644 --- a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.test.tsx +++ b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.test.tsx @@ -22,37 +22,27 @@ import { featureFlagsApiRef, ApiProvider, FeatureFlags, - FeatureFlagsContextProvider, } from '@backstage/core'; -function withFeatureFlags(children: ReactNode) { - const featureFlags = new Set([ - { pluginId: 'welcome', name: 'enable-welcome-box' }, - ]); - +function withApiRegistry(component: ReactNode, featureFlags: FeatureFlags) { return ( - - {children} - - ); -} - -function withApiRegistry(children: ReactNode) { - return ( - - {children} + + {component} ); } describe('ToggleFeatureFlagButton', () => { + let featureFlags: FeatureFlags; + beforeEach(() => { + featureFlags = new FeatureFlags(); window.localStorage.clear(); }); it('should enable the feature flag', () => { const rendered = render( - withFeatureFlags(withApiRegistry()), + withApiRegistry(, featureFlags), ); const button = rendered.getByTestId('button-switch-feature-flag-state'); @@ -60,22 +50,22 @@ describe('ToggleFeatureFlagButton', () => { expect(window.localStorage.featureFlags).toBeUndefined(); fireEvent.click(button); - expect(window.localStorage.featureFlags).toBe( - '{"enable-welcome-box":true}', - ); + expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":1}'); }); it('should disable the feature flag', () => { - const rendered = render( - withFeatureFlags(withApiRegistry()), - ); + const Component = () => + withApiRegistry(, featureFlags); + const rendered = render(); const button = rendered.getByTestId('button-switch-feature-flag-state'); expect(button).toBeInTheDocument(); expect(window.localStorage.featureFlags).toBeUndefined(); fireEvent.click(button); + expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":1}'); + rendered.rerender(); fireEvent.click(button); - expect(window.localStorage.featureFlags).toBe('{}'); + expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":0}'); }); }); diff --git a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx index a940b76263..995339850f 100644 --- a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx +++ b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx @@ -19,15 +19,16 @@ import { Button } from '@material-ui/core'; import { FeatureFlagState, featureFlagsApiRef, useApi } from '@backstage/core'; const ToggleFeatureFlagButton: FC<{}> = () => { - const { useFeatureFlag } = useApi(featureFlagsApiRef); - const [flagState, setFlagState] = useFeatureFlag('enable-welcome-box'); + const featureFlagsApi = useApi(featureFlagsApiRef); + const flags = featureFlagsApi.getFlags(); + const flagState = flags.get('enable-welcome-box'); const handleClick = () => { - if (flagState === FeatureFlagState.Enabled) { - setFlagState(FeatureFlagState.NotEnabled); - } else { - setFlagState(FeatureFlagState.Enabled); - } + const newValue = flagState + ? FeatureFlagState.NotEnabled + : FeatureFlagState.Enabled; + flags.set('enable-welcome-box', newValue); + window.location.reload(); }; return ( @@ -37,10 +38,9 @@ const ToggleFeatureFlagButton: FC<{}> = () => { onClick={handleClick} data-testid="button-switch-feature-flag-state" > - {flagState === FeatureFlagState.NotEnabled && - 'Enable "enable-welcome-box" feature flag'} - {flagState === FeatureFlagState.Enabled && - 'Disable "enable-welcome-box" feature flag'} + {flagState === FeatureFlagState.NotEnabled + ? 'Disable "enable-welcome-box" feature flag' + : 'Enable "enable-welcome-box" feature flag'} ); }; diff --git a/plugins/welcome/src/plugin.ts b/plugins/welcome/src/plugin.ts index a54a19d4af..e7755862ff 100644 --- a/plugins/welcome/src/plugin.ts +++ b/plugins/welcome/src/plugin.ts @@ -22,6 +22,6 @@ export default createPlugin({ register({ router, featureFlags }) { router.registerRoute('/', WelcomePage); - featureFlags.registerFeatureFlag('enable-welcome-box'); + featureFlags.register('enable-welcome-box'); }, });