[docs/reference] updated to reflect React Hooks changes

This commit is contained in:
Bilawal Hameed
2020-03-27 13:13:46 +01:00
parent e78680fe03
commit a8f304dbfb
3 changed files with 325 additions and 107 deletions
+22 -22
View File
@@ -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)
+299 -79
View File
@@ -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 />));
});
});
+4 -6
View File
@@ -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',
);
}