[core/apis] adapted to build on existing data structures

This commit is contained in:
Bilawal Hameed
2020-03-27 19:45:59 +01:00
parent 6fa6373536
commit 64a14b2191
11 changed files with 387 additions and 356 deletions
+4 -7
View File
@@ -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)
+1 -1
View File
@@ -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);
},
+2 -2
View File
@@ -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;
@@ -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<FeatureFlagName>;
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<FeatureFlagsApi>({
id: 'core.featureflags',
+8 -4
View File
@@ -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<JSX.Element>();
const registeredFeatureFlags = new Set<FeatureFlagsEntry>();
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
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(
<Route key="login" path="/login" component={LoginPage} exact />,
+185 -187
View File
@@ -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<FeatureFlagsEntry> = 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(<Component />));
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(<Component />));
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(<Component />));
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(<Component />));
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(<Component />));
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(<Component />));
});
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('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('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(<Component />));
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);
});
});
});
+143 -90
View File
@@ -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<FeatureFlagName, FeatureFlagState> {
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<FeatureFlagsRegistryItem> {
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>
)[]
): 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<FeatureFlagName> {
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<FeatureFlagName>(
Object.keys(JSON.parse(featureFlagsJson!)),
);
} catch (err) {
return new Set<FeatureFlagName>();
}
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 <App />',
);
}
// 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();
+5 -12
View File
@@ -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<PluginOutput>();
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 });
},
},
@@ -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 (
<FeatureFlagsContextProvider featureFlags={featureFlags}>
{children}
</FeatureFlagsContextProvider>
);
}
function withApiRegistry(children: ReactNode) {
return (
<ApiProvider apis={ApiRegistry.from([[featureFlagsApiRef, FeatureFlags]])}>
{children}
<ApiProvider apis={ApiRegistry.from([[featureFlagsApiRef, featureFlags]])}>
{component}
</ApiProvider>
);
}
describe('ToggleFeatureFlagButton', () => {
let featureFlags: FeatureFlags;
beforeEach(() => {
featureFlags = new FeatureFlags();
window.localStorage.clear();
});
it('should enable the feature flag', () => {
const rendered = render(
withFeatureFlags(withApiRegistry(<ToggleFeatureFlagButton />)),
withApiRegistry(<ToggleFeatureFlagButton />, 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(<ToggleFeatureFlagButton />)),
);
const Component = () =>
withApiRegistry(<ToggleFeatureFlagButton />, featureFlags);
const rendered = render(<Component />);
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(<Component />);
fireEvent.click(button);
expect(window.localStorage.featureFlags).toBe('{}');
expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":0}');
});
});
@@ -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'}
</Button>
);
};
+1 -1
View File
@@ -22,6 +22,6 @@ export default createPlugin({
register({ router, featureFlags }) {
router.registerRoute('/', WelcomePage);
featureFlags.registerFeatureFlag('enable-welcome-box');
featureFlags.register('enable-welcome-box');
},
});