Merge pull request #391 from spotify/bil/feature-flags
Add Feature Flags API 🎚
This commit is contained in:
@@ -4,5 +4,6 @@ APIs and Components
|
||||
|
||||
- [createPlugin](createPlugin.md)
|
||||
- [createPlugin - router](createPlugin-router.md)
|
||||
- [createPlugin - feature flags](createPlugin-feature-flags.md)
|
||||
|
||||
[Back to Docs](../README.md)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# createPlugin - feature flags
|
||||
|
||||
The `featureFlags` object passed to the `register` function makes it possible for plugins to register Feature Flags in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc.
|
||||
|
||||
```typescript
|
||||
export type FeatureFlagsHooks = {
|
||||
register(name: FeatureFlagName): void;
|
||||
};
|
||||
```
|
||||
|
||||
Here's a code sample:
|
||||
|
||||
```typescript
|
||||
import { createPlugin } from '@backstage/core';
|
||||
|
||||
export default createPlugin({
|
||||
id: 'welcome',
|
||||
register({ router, featureFlags }) {
|
||||
// router.registerRoute('/', Component);
|
||||
featureFlags.register('enable-example-feature');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Using with useApi
|
||||
|
||||
To use it, you'll first need to register the `FeatureFlags` API via `ApiRegistry` in your `apis.ts` in your App:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ApiHolder,
|
||||
ApiRegistry,
|
||||
featureFlagsApiRef,
|
||||
FeatureFlags,
|
||||
} from '@backstage/core';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
builder.add(featureFlagsApiRef, FeatureFlags);
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
```
|
||||
|
||||
Then, later, you can directly use it via `useApi`:
|
||||
|
||||
```tsx
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core';
|
||||
|
||||
const ExampleButton: FC<{}> = () => {
|
||||
const flags = useApi(featureFlagsApiRef).getFlags();
|
||||
|
||||
const handleClick = () => {
|
||||
flags.set('enable-example-feature', FeatureFlagState.On);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" onClick={handleClick}>
|
||||
Enable the 'enable-example-feature' feature flag
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
[Back to References](README.md)
|
||||
@@ -17,13 +17,14 @@ type PluginHooks = {
|
||||
};
|
||||
```
|
||||
|
||||
[Read more about the router here](createPlugin-router.md)
|
||||
- [Read more about the router here](createPlugin-router.md)
|
||||
- [Read more about feature flags here](createPlugin-feature-flags.md)
|
||||
|
||||
## Example Uses
|
||||
|
||||
### Creating a basic plugin
|
||||
|
||||
Showcasing adding multiple routes and a redirect.
|
||||
Showcasing adding multiple routes, a feature flag and a redirect.
|
||||
|
||||
```jsx
|
||||
import { createPlugin } from '@backstage/core';
|
||||
@@ -31,7 +32,9 @@ import ExampleComponent from './components/ExampleComponent';
|
||||
|
||||
export default createPlugin({
|
||||
id: 'new-plugin',
|
||||
register({ router }) {
|
||||
register({ router, featureFlags }) {
|
||||
featureFlags.register('enable-example-component');
|
||||
|
||||
router.registerRoute('/new-plugin', ExampleComponent);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,13 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiHolder, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
import {
|
||||
ApiHolder,
|
||||
ApiRegistry,
|
||||
errorApiRef,
|
||||
featureFlagsApiRef,
|
||||
FeatureFlags,
|
||||
} from '@backstage/core';
|
||||
import { ErrorDisplayForwarder } from './components/ErrorDisplay/ErrorDisplay';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
|
||||
export const errorDialogForwarder = new ErrorDisplayForwarder();
|
||||
|
||||
builder.add(errorApiRef, errorDialogForwarder);
|
||||
|
||||
builder.add(featureFlagsApiRef, new FeatureFlags());
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ApiRef from '../ApiRef';
|
||||
import {
|
||||
UserFlags,
|
||||
FeatureFlagsRegistry,
|
||||
FeatureFlagsRegistryItem,
|
||||
} from '../../app/FeatureFlags';
|
||||
|
||||
/**
|
||||
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
|
||||
*
|
||||
* Plugins can use this API to register feature flags that they have available
|
||||
* for users to enable/disable, and this API will centralize the current user's
|
||||
* state of which feature flags they would like to enable.
|
||||
*
|
||||
* This is ideal for Backstage plugins, as well as your own App, to trial incomplete
|
||||
* or unstable upcoming features. Although there will be a common interface for users
|
||||
* to enable and disable feature flags, this API acts as another way to enable/disable.
|
||||
*/
|
||||
|
||||
export enum FeatureFlagState {
|
||||
Off = 0,
|
||||
On = 1,
|
||||
}
|
||||
|
||||
export interface FeatureFlagsApi {
|
||||
/**
|
||||
* Store a list of registered feature flags.
|
||||
*/
|
||||
registeredFeatureFlags: FeatureFlagsRegistryItem[];
|
||||
|
||||
/**
|
||||
* Get a list of all feature flags from the current user.
|
||||
*/
|
||||
getFlags(): UserFlags;
|
||||
|
||||
/**
|
||||
* Get a list of all registered flags.
|
||||
*/
|
||||
getRegisteredFlags(): FeatureFlagsRegistry;
|
||||
}
|
||||
|
||||
export const featureFlagsApiRef = new ApiRef<FeatureFlagsApi>({
|
||||
id: 'core.featureflags',
|
||||
description: 'Used to toggle functionality in features across Backstage',
|
||||
});
|
||||
@@ -21,3 +21,4 @@
|
||||
// If you think some API definition is missing, please open an Issue or send a PR!
|
||||
|
||||
export * from './error';
|
||||
export * from './featureFlags';
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { App } from './types';
|
||||
import BackstagePlugin from '../plugin/Plugin';
|
||||
import { FeatureFlagsRegistryItem } from './FeatureFlags';
|
||||
import { featureFlagsApiRef } from '../apis/definitions/featureFlags';
|
||||
import {
|
||||
IconComponent,
|
||||
SystemIcons,
|
||||
@@ -62,6 +64,7 @@ export default class AppBuilder {
|
||||
const app = new AppImpl(this.systemIcons);
|
||||
|
||||
const routes = new Array<JSX.Element>();
|
||||
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
|
||||
|
||||
for (const plugin of this.plugins.values()) {
|
||||
for (const output of plugin.output()) {
|
||||
@@ -87,12 +90,24 @@ export default class AppBuilder {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'feature-flag': {
|
||||
registeredFeatureFlags.push({
|
||||
pluginId: plugin.getId(),
|
||||
name: output.name,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FeatureFlags = this.apis && this.apis.get(featureFlagsApiRef);
|
||||
if (FeatureFlags) {
|
||||
FeatureFlags.registeredFeatureFlags = registeredFeatureFlags;
|
||||
}
|
||||
|
||||
routes.push(
|
||||
<Route key="login" path="/login" component={LoginPage} exact />,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags';
|
||||
import { FeatureFlagState } from '../apis/definitions/featureFlags';
|
||||
|
||||
describe('FeatureFlags', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('#getFlags', () => {
|
||||
let featureFlags;
|
||||
|
||||
beforeEach(() => {
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
});
|
||||
|
||||
it('returns no flags', () => {
|
||||
expect(featureFlags.getFlags().toObject()).toMatchObject({});
|
||||
});
|
||||
|
||||
it('returns the correct flags', () => {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 1,
|
||||
'feature-flag-three': 0,
|
||||
}),
|
||||
);
|
||||
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
expect(featureFlags.getFlags().toObject()).toMatchObject({
|
||||
'feature-flag-one': FeatureFlagState.On,
|
||||
'feature-flag-two': FeatureFlagState.On,
|
||||
'feature-flag-three': FeatureFlagState.Off,
|
||||
});
|
||||
});
|
||||
|
||||
it('gets the correct values', () => {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 0,
|
||||
}),
|
||||
);
|
||||
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
|
||||
expect(featureFlags.getFlags().get('feature-flag-one')).toEqual(
|
||||
FeatureFlagState.On,
|
||||
);
|
||||
expect(featureFlags.getFlags().get('feature-flag-two')).toEqual(
|
||||
FeatureFlagState.Off,
|
||||
);
|
||||
expect(featureFlags.getFlags().get('feature-flag-three')).toEqual(
|
||||
FeatureFlagState.Off,
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the correct values', () => {
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.set('feature-flag-zero', FeatureFlagState.On);
|
||||
|
||||
expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.On);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.delete('feature-flag-one');
|
||||
|
||||
expect(flags.get('feature-flag-one')).toEqual(FeatureFlagState.Off);
|
||||
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('#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('should return an empty list', () => {
|
||||
featureFlags.registeredFeatureFlags = [];
|
||||
expect(featureFlags.getRegisteredFlags().toObject()).toEqual([]);
|
||||
});
|
||||
|
||||
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('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('should append the correct value', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
|
||||
flags.push({
|
||||
name: 'registered-flag-4',
|
||||
pluginId: 'plugin-three',
|
||||
});
|
||||
|
||||
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('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',
|
||||
},
|
||||
]);
|
||||
|
||||
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('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('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('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('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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlagName } from '../plugin/types';
|
||||
import {
|
||||
FeatureFlagState,
|
||||
FeatureFlagsApi,
|
||||
} from '../apis/definitions/featureFlags';
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
static load(): UserFlags {
|
||||
validateBrowserCompat();
|
||||
|
||||
try {
|
||||
const jsonString = window.localStorage.getItem('featureFlags') as string;
|
||||
const json = JSON.parse(jsonString);
|
||||
return new this(Object.entries(json));
|
||||
} catch (err) {
|
||||
return new this([]);
|
||||
}
|
||||
}
|
||||
|
||||
get(name: FeatureFlagName): FeatureFlagState {
|
||||
return super.get(name) || FeatureFlagState.Off;
|
||||
}
|
||||
|
||||
set(name: FeatureFlagName, state: FeatureFlagState): this {
|
||||
validateFlagName(name);
|
||||
const output = super.set(name, state);
|
||||
this.save();
|
||||
return output;
|
||||
}
|
||||
|
||||
delete(name: FeatureFlagName): boolean {
|
||||
const output = super.delete(name);
|
||||
this.save();
|
||||
return output;
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
private userFlags: UserFlags | undefined;
|
||||
|
||||
getFlags(): UserFlags {
|
||||
if (!this.userFlags) this.userFlags = UserFlags.load();
|
||||
return this.userFlags;
|
||||
}
|
||||
|
||||
getRegisteredFlags(): FeatureFlagsRegistry {
|
||||
return FeatureFlagsRegistry.from(this.registeredFeatureFlags);
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@
|
||||
|
||||
export * from './api';
|
||||
export * from './apis';
|
||||
export { FeatureFlags } from './app/FeatureFlags';
|
||||
export { useApp } from './app/AppContext';
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { PluginOutput, RoutePath, RouteOptions } from './types';
|
||||
import {
|
||||
PluginOutput,
|
||||
RoutePath,
|
||||
RouteOptions,
|
||||
FeatureFlagName,
|
||||
} from './types';
|
||||
import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags';
|
||||
import { Widget } from '../widgetView/types';
|
||||
|
||||
export type PluginConfig = {
|
||||
@@ -26,6 +32,7 @@ export type PluginConfig = {
|
||||
export type PluginHooks = {
|
||||
router: RouterHooks;
|
||||
widgets: WidgetHooks;
|
||||
featureFlags: FeatureFlagsHooks;
|
||||
};
|
||||
|
||||
export type RouterHooks = {
|
||||
@@ -46,6 +53,10 @@ export type WidgetHooks = {
|
||||
add(widget: Widget): void;
|
||||
};
|
||||
|
||||
export type FeatureFlagsHooks = {
|
||||
register(name: FeatureFlagName): void;
|
||||
};
|
||||
|
||||
export const registerSymbol = Symbol('plugin-register');
|
||||
export const outputSymbol = Symbol('plugin-output');
|
||||
|
||||
@@ -54,6 +65,10 @@ export default class Plugin {
|
||||
|
||||
constructor(private readonly config: PluginConfig) {}
|
||||
|
||||
getId(): string {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
output(): PluginOutput[] {
|
||||
if (this.storedOutput) {
|
||||
return this.storedOutput;
|
||||
@@ -78,6 +93,13 @@ export default class Plugin {
|
||||
outputs.push({ type: 'widget', widget });
|
||||
},
|
||||
},
|
||||
featureFlags: {
|
||||
register(name) {
|
||||
validateBrowserCompat();
|
||||
validateFlagName(name);
|
||||
outputs.push({ type: 'feature-flag', name });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.storedOutput = outputs;
|
||||
|
||||
@@ -43,4 +43,15 @@ export type WidgetOutput = {
|
||||
widget: Widget;
|
||||
};
|
||||
|
||||
export type PluginOutput = RouteOutput | RedirectRouteOutput | WidgetOutput;
|
||||
export type FeatureFlagName = string;
|
||||
|
||||
export type FeatureFlagOutput = {
|
||||
type: 'feature-flag';
|
||||
name: FeatureFlagName;
|
||||
};
|
||||
|
||||
export type PluginOutput =
|
||||
| RouteOutput
|
||||
| RedirectRouteOutput
|
||||
| WidgetOutput
|
||||
| FeatureFlagOutput;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import ToggleFeatureFlagButton from './ToggleFeatureFlagButton';
|
||||
import {
|
||||
ApiRegistry,
|
||||
featureFlagsApiRef,
|
||||
ApiProvider,
|
||||
FeatureFlags,
|
||||
} from '@backstage/core';
|
||||
|
||||
function withApiRegistry(component: ReactNode, featureFlags: FeatureFlags) {
|
||||
return (
|
||||
<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(
|
||||
withApiRegistry(<ToggleFeatureFlagButton />, featureFlags),
|
||||
);
|
||||
|
||||
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}');
|
||||
});
|
||||
|
||||
it('should disable the feature flag', () => {
|
||||
window.localStorage.setItem('featureFlags', '{"enable-welcome-box":1}');
|
||||
|
||||
const rendered = render(
|
||||
withApiRegistry(<ToggleFeatureFlagButton />, featureFlags),
|
||||
);
|
||||
|
||||
const button = rendered.getByTestId('button-switch-feature-flag-state');
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":1}');
|
||||
fireEvent.click(button);
|
||||
expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":0}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { FeatureFlagState, featureFlagsApiRef, useApi } from '@backstage/core';
|
||||
|
||||
const ToggleFeatureFlagButton: FC<{}> = () => {
|
||||
const featureFlagsApi = useApi(featureFlagsApiRef);
|
||||
const flags = featureFlagsApi.getFlags();
|
||||
const flagState = flags.get('enable-welcome-box');
|
||||
|
||||
const handleClick = () => {
|
||||
const newValue =
|
||||
flagState === FeatureFlagState.On
|
||||
? FeatureFlagState.Off
|
||||
: FeatureFlagState.On;
|
||||
flags.set('enable-welcome-box', newValue);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={handleClick}
|
||||
data-testid="button-switch-feature-flag-state"
|
||||
>
|
||||
{flagState === FeatureFlagState.On
|
||||
? 'Disable "enable-welcome-box" feature flag'
|
||||
: 'Enable "enable-welcome-box" feature flag'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleFeatureFlagButton;
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
SupportButton,
|
||||
} from '@backstage/core';
|
||||
import ErrorButton from './ErrorButton';
|
||||
import ToggleFeatureFlagButton from './ToggleFeatureFlagButton';
|
||||
|
||||
const WelcomePage: FC<{}> = () => {
|
||||
const profile = { givenName: '' };
|
||||
@@ -121,6 +122,9 @@ const WelcomePage: FC<{}> = () => {
|
||||
</Typography>
|
||||
<br />
|
||||
<ErrorButton />
|
||||
<br />
|
||||
<br />
|
||||
<ToggleFeatureFlagButton />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -19,7 +19,9 @@ import WelcomePage from './components/WelcomePage';
|
||||
|
||||
export default createPlugin({
|
||||
id: 'welcome',
|
||||
register({ router }) {
|
||||
register({ router, featureFlags }) {
|
||||
router.registerRoute('/', WelcomePage);
|
||||
|
||||
featureFlags.register('enable-welcome-box');
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user