diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md index 8226efc129..bbf7f17c32 100644 --- a/docs/reference/createPlugin-feature-flags.md +++ b/docs/reference/createPlugin-feature-flags.md @@ -8,9 +8,23 @@ export type FeatureFlagsHooks = { }; ``` +Here's a code sample: + +```typescript +import { createPlugin } from '@backstage/core'; + +export default createPlugin({ + id: 'welcome', + register({ router, featureFlags }) { + // router.registerRoute('/', Component); + featureFlags.registerFeatureFlag('enable-example-feature'); + }, +}); +``` + ## Using with useApi -This is the **recommended** way of using the API. It's officially supported by Backstage. To use it, you'll first need to register the `FeatureFlags` API via `ApiRegistry` in your `apis.ts` in your App: +To use it, you'll first need to register the `FeatureFlags` API via `ApiRegistry` in your `apis.ts` in your App: ```tsx import { @@ -34,8 +48,12 @@ import { Button } from '@material-ui/core'; import { featureFlagsApiRef, useApi } from '@backstage/core'; const ExampleButton: FC<{}> = () => { - const featureFlagsApi = useApi(featureFlagsApiRef); - const handleClick = () => featureFlagsApi.set('enable-example-feature', .Enabled); + const { useFeatureFlag } = useApi(featureFlagsApiRef); + const [flagState, setFlagState] = useFeatureFlag('enable-example-feature'); + + const handleClick = () => { + setFlagState(FeatureFlagState.Enabled); + }; return ( - ); -}; -``` +Note that you must register it with `registerFeatureFlag` otherwise the `useFeatureFlag` will throw an error. [Back to References](README.md) diff --git a/packages/core/src/api/app/FeatureFlags.test.tsx b/packages/core/src/api/app/FeatureFlags.test.tsx index 3e3bb3e50d..b803b27b3a 100644 --- a/packages/core/src/api/app/FeatureFlags.test.tsx +++ b/packages/core/src/api/app/FeatureFlags.test.tsx @@ -14,142 +14,362 @@ * limitations under the License. */ -import React, { useContext } from 'react'; +import React, { ReactNode, useContext, useEffect, useRef } from 'react'; import { render } from '@testing-library/react'; import { FeatureFlags, + FeatureFlagsEntry, FeatureFlagsContext, FeatureFlagsContextProvider, } 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' }, + ]), +) { + return ( + + {children} + + ); +} + describe('FeatureFlags', () => { beforeEach(() => { window.localStorage.clear(); }); - describe('#get', () => { - it('defaults to .NotEnabled', () => { - expect(FeatureFlags.get('enable-feature-flag')).toBe( - FeatureFlagState.NotEnabled, - ); + describe('#getEnabledFeatureFlags', () => { + it('returns an empty set', () => { + expect(FeatureFlags.getEnabledFeatureFlags()).toEqual(new Set()); }); - it('returns a .Enabled state', () => { - FeatureFlags.set('enable-feature-flag', FeatureFlagState.Enabled); - expect(FeatureFlags.get('enable-feature-flag')).toBe( - FeatureFlagState.Enabled, + it('returns enabled feature flags', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': true, + 'feature-flag-three': true, + }), ); - }); - it('returns a .NotEnabled state', () => { - FeatureFlags.set('enable-feature-flag', FeatureFlagState.NotEnabled); - expect(FeatureFlags.get('enable-feature-flag')).toBe( - FeatureFlagState.NotEnabled, + expect(FeatureFlags.getEnabledFeatureFlags()).toEqual( + new Set(['feature-flag-one', 'feature-flag-three']), ); }); }); - describe('#set', () => { - it('fails if name is less than three characters', () => { - expect(() => { - FeatureFlags.set('ab', FeatureFlagState.NotEnabled); - }).toThrow(/minimum length of three characters/i); + 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); }); - it('fails if name is greater than 150 characters', () => { - expect(() => { - FeatureFlags.set( - 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', - FeatureFlagState.NotEnabled, + it('returns an error if greater than 150 characters', () => { + const errors = FeatureFlags.checkFeatureFlagNameErrors( + 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', + ); + expect(errors[0]).toMatch(/not exceed 150 characters/i); + }); + + 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('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('returns no errors', () => { + const errors = FeatureFlags.checkFeatureFlagNameErrors( + 'valid-feature-flag', + ); + expect(errors.length).toBe(0); + }); + }); + + 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); + + return null; + }; + + render(withFeatureFlags()); + }); + + 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; + }; + + render(withFeatureFlags()); + }); + + 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('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('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('changes state to .Enabled', () => { + const Component = () => { + const [state, setState] = FeatureFlags.useFeatureFlag( + 'feature-flag-one', ); - }).toThrow(/not exceed 150 characters/i); + const renderCount = useRenderCount(); + if (renderCount === 1) setState(FeatureFlagState.Enabled); + if (renderCount === 2) expect(state).toEqual(FeatureFlagState.Enabled); + return null; + }; + + render(withFeatureFlags()); }); - it('fails if name does not start with a lowercase letter', () => { - expect(() => { - FeatureFlags.set('123456789', FeatureFlagState.NotEnabled); - }).toThrow(/start with a lowercase letter/i); + 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('fails if name contains characters other than lowercase letters, numbers and hyphens', () => { - expect(() => { - FeatureFlags.set('Invalid_Feature_Flag', FeatureFlagState.NotEnabled); - }).toThrow(/only contain lowercase letters, numbers and hyphens/i); + 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('fails if state is not recognized from FeatureFlagState', () => { - expect(() => { - // @ts-ignore - FeatureFlags.set('valid-feature-flag', 'invalid state'); - }).toThrow(/requires a recognized value from the FeatureFlagState/i); - }); + it('changes multiple feature flag states', () => { + expect.assertions(3); - it('sets a feature flag to enabled', () => { - expect(window.localStorage.featureFlags).toBeUndefined(); - FeatureFlags.set('valid-feature-flag', FeatureFlagState.Enabled); - expect(window.localStorage.featureFlags).toBe( - '{"valid-feature-flag":true}', - ); - }); + const Component = () => { + const renderCount = useRenderCount(); + const [stateA, setStateA] = FeatureFlags.useFeatureFlag( + 'feature-flag-one', + ); + const [stateB, setStateB] = FeatureFlags.useFeatureFlag( + 'feature-flag-two', + ); - it('sets a feature flag to disabled', () => { - expect(window.localStorage.featureFlags).toBeUndefined(); - FeatureFlags.set('valid-feature-flag', FeatureFlagState.NotEnabled); - expect(window.localStorage.featureFlags).toBe('{}'); - }); + 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]); - it('sets a feature flag to disabled then enabled', () => { - expect(window.localStorage.featureFlags).toBeUndefined(); - FeatureFlags.set('valid-feature-flag', FeatureFlagState.NotEnabled); - FeatureFlags.set('valid-feature-flag', FeatureFlagState.Enabled); - expect(window.localStorage.featureFlags).toBe( - '{"valid-feature-flag":true}', - ); - }); + return null; + }; - it('sets multiple feature flags', () => { - expect(window.localStorage.featureFlags).toBeUndefined(); - FeatureFlags.set('valid-feature-flag', FeatureFlagState.Enabled); - FeatureFlags.set('another-valid-feature-flag', FeatureFlagState.Enabled); - expect(window.localStorage.featureFlags).toBe( - '{"valid-feature-flag":true,"another-valid-feature-flag":true}', - ); + render(withFeatureFlags()); }); }); }); describe('FeatureFlagsContext', () => { - it('returns an empty list without the context', () => { - expect.assertions(1); + beforeEach(() => { + window.localStorage.clear(); + }); + it('returns an empty set without the context', () => { const Component = () => { const { featureFlags } = useContext(FeatureFlagsContext); - expect(featureFlags).toEqual([]); + expect(featureFlags).toEqual(new Set()); return null; }; render(); }); - it('returns a list of registered feature flags', () => { - expect.assertions(1); + it('returns a set of registered feature flags', () => { + expect.assertions(2); - const mockFeatureFlags = [ + const mockFeatureFlags = new Set([ { name: 'feature-flag-one', pluginId: 'plugin-one' }, { name: 'feature-flag-two', pluginId: 'plugin-two' }, - ]; + ]); const Component = () => { const { featureFlags } = useContext(FeatureFlagsContext); - expect(featureFlags).toBe(mockFeatureFlags); + expect(featureFlags).toEqual(mockFeatureFlags); return null; }; - render( - - - , + render(withFeatureFlags(, mockFeatureFlags)); + }); + + it('returns a set of user enabled feature flags', () => { + expect.assertions(1); + + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': true, + 'feature-flag-three': true, + }), ); + + const Component = () => { + const { enabledFeatureFlags } = useContext(FeatureFlagsContext); + + if (enabledFeatureFlags.size > 0) { + expect(enabledFeatureFlags).toEqual( + new Set(['feature-flag-one', 'feature-flag-three']), + ); + } + + return null; + }; + + render(withFeatureFlags()); + }); + + it('correctly re-renders when calling refreshEnabledFeatureFlags', () => { + // First is the initial context + // Second is the context with the state from FeatureFlagsContextProvider + // Third is from calling refreshEnabledFeatureFlags + expect.assertions(3); + + const FirstRender = ({ context: { enabledFeatureFlags } }) => { + useEffect(() => { + expect(enabledFeatureFlags).toEqual(new Set()); + }, []); + return null; + }; + + const SecondRender = ({ + context: { enabledFeatureFlags, refreshEnabledFeatureFlags }, + }) => { + useEffect(() => { + expect(enabledFeatureFlags).toEqual(new Set()); + + // Change localStorage + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': true, + 'feature-flag-three': true, + }), + ); + + // Refresh + refreshEnabledFeatureFlags(); + }, []); + + return null; + }; + + const ThirdRender = ({ context: { enabledFeatureFlags } }) => { + useEffect(() => { + expect(enabledFeatureFlags).toEqual( + new Set(['feature-flag-one', 'feature-flag-three']), + ); + }, []); + + return null; + }; + + const Component = () => { + const context = useContext(FeatureFlagsContext); + const renderCount = useRenderCount(); + + if (renderCount === 0) return ; + if (renderCount === 1) return ; + if (renderCount === 2) return ; + + return null; + }; + + render(withFeatureFlags()); }); }); diff --git a/packages/core/src/api/app/FeatureFlags.tsx b/packages/core/src/api/app/FeatureFlags.tsx index e5a2063621..4f2b7da7d0 100644 --- a/packages/core/src/api/app/FeatureFlags.tsx +++ b/packages/core/src/api/app/FeatureFlags.tsx @@ -156,21 +156,19 @@ class FeatureFlagsImpl implements FeatureFlagsApi { // 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 = []; + const errors: string[] = []; if (name.length < 3) { - errors.push( - 'The `name` argument must have a minimum length of three characters.', - ); + errors.push('The name must have a minimum length of three characters.'); } if (name.length > 150) { - errors.push('The `name` argument must not exceed 150 characters.'); + errors.push('The name must not exceed 150 characters.'); } if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { errors.push( - 'The `name` argument must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ' + + 'The name must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ' + 'Examples: feature-flag-one, alpha, release-2020', ); }