[docs/reference] updated to reflect React Hooks changes
This commit is contained in:
@@ -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 (
|
||||
<Button variant="contained" color="primary" onClick={handleClick}>
|
||||
@@ -45,24 +63,6 @@ const ExampleButton: FC<{}> = () => {
|
||||
};
|
||||
```
|
||||
|
||||
## Using directly with the `FeatureFlags` object
|
||||
|
||||
This is **only** for backwards-compatibility support for Spotify's internal Backstage plugins. This may be deprecated in the distant future so generally we advise against using it.
|
||||
|
||||
```tsx
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { FeatureFlags } from '@backstage/core';
|
||||
|
||||
const ExampleButton: FC<{}> = () => {
|
||||
const handleClick = () => FeatureFlags.set('enable-example-feature', .Enabled);
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" onClick={handleClick}>
|
||||
Enable the 'enable-example-feature' feature flag
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
Note that you must register it with `registerFeatureFlag` otherwise the `useFeatureFlag` will throw an error.
|
||||
|
||||
[Back to References](README.md)
|
||||
|
||||
@@ -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<FeatureFlagsEntry> = new Set([
|
||||
{ name: 'feature-flag-one', pluginId: 'plugin-one' },
|
||||
{ name: 'feature-flag-two', pluginId: 'plugin-two' },
|
||||
{ name: 'feature-flag-three', pluginId: 'plugin-two' },
|
||||
]),
|
||||
) {
|
||||
return (
|
||||
<FeatureFlagsContextProvider featureFlags={featureFlags}>
|
||||
{children}
|
||||
</FeatureFlagsContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
|
||||
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(<Component />));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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(<Component />);
|
||||
});
|
||||
|
||||
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<FeatureFlagsEntry>([
|
||||
{ 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(
|
||||
<FeatureFlagsContextProvider featureFlags={mockFeatureFlags}>
|
||||
<Component />
|
||||
</FeatureFlagsContextProvider>,
|
||||
render(withFeatureFlags(<Component />, 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(<Component />));
|
||||
});
|
||||
|
||||
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 <FirstRender context={context} />;
|
||||
if (renderCount === 1) return <SecondRender context={context} />;
|
||||
if (renderCount === 2) return <ThirdRender context={context} />;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
render(withFeatureFlags(<Component />));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user