Merge pull request #3149 from spotify/rugvip/ff
core-api: refactor and slim down the FeatureFlagsApi
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-api': minor
|
||||
---
|
||||
|
||||
Refactored the FeatureFlagsApi to make it easier to re-implement. Existing usage of particularly getUserFlags can be replaced with isActive() or save().
|
||||
@@ -8,12 +8,6 @@ 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
|
||||
@@ -21,8 +15,7 @@ import { createPlugin } from '@backstage/core';
|
||||
|
||||
export default createPlugin({
|
||||
id: 'welcome',
|
||||
register({ router, featureFlags }) {
|
||||
// router.registerRoute('/', Component);
|
||||
register({ featureFlags }) {
|
||||
featureFlags.register('enable-example-feature');
|
||||
},
|
||||
});
|
||||
@@ -30,41 +23,22 @@ export default createPlugin({
|
||||
|
||||
## 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, new FeatureFlags());
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
```
|
||||
|
||||
Then, later, you can directly use it via `useApi`:
|
||||
To inspect the state of a feature flag inside your plugin, you can use the
|
||||
`FeatureFlagsApi`, accessed via the `featureFlagsApiRef`. For example:
|
||||
|
||||
```tsx
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { FeatureFlagState, featureFlagsApiRef, useApi } from '@backstage/core';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core';
|
||||
|
||||
const ExampleButton: FC<{}> = () => {
|
||||
const flags = useApi(featureFlagsApiRef).getFlags();
|
||||
|
||||
const handleClick = () => {
|
||||
flags.set('enable-example-feature', FeatureFlagState.On);
|
||||
};
|
||||
const ExamplePage = () => {
|
||||
const featureFlags = useApi(featureFlagsApiRef);
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" onClick={handleClick}>
|
||||
Enable the 'enable-example-feature' feature flag
|
||||
</Button>
|
||||
<div>
|
||||
<MyPluginWidget>
|
||||
{ featureFlags.isActive('enable-example-feature') && <ExperimentalPluginWidget> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
*/
|
||||
|
||||
import { ApiRef, createApiRef } from '../system';
|
||||
import { UserFlags, FeatureFlagsRegistry } from '../../app/FeatureFlags';
|
||||
import { FeatureFlagName } from '../../plugin';
|
||||
|
||||
/**
|
||||
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
|
||||
@@ -30,31 +28,56 @@ import { FeatureFlagName } from '../../plugin';
|
||||
* to enable and disable feature flags, this API acts as another way to enable/disable.
|
||||
*/
|
||||
|
||||
export type FeatureFlag = {
|
||||
name: string;
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
export enum FeatureFlagState {
|
||||
Off = 0,
|
||||
On = 1,
|
||||
None = 0,
|
||||
Active = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Options to use when saving feature flags.
|
||||
*/
|
||||
export type FeatureFlagsSaveOptions = {
|
||||
/**
|
||||
* The new feature flag states to save.
|
||||
*/
|
||||
states: Record<string, FeatureFlagState>;
|
||||
|
||||
/**
|
||||
* Whether the saves states should be merged into the existing ones, or replace them.
|
||||
*
|
||||
* Defaults to false.
|
||||
*/
|
||||
merge?: boolean;
|
||||
};
|
||||
|
||||
export type UserFlags = {};
|
||||
|
||||
export interface FeatureFlagsApi {
|
||||
/**
|
||||
* Store a list of registered feature flags.
|
||||
* Registers a new feature flag. Once a feature flag has been registered it
|
||||
* can be toggled by users, and read back to enable or disable features.
|
||||
*/
|
||||
registeredFeatureFlags: FeatureFlagsRegistryItem[];
|
||||
|
||||
/**
|
||||
* Get a list of all feature flags from the current user.
|
||||
*/
|
||||
getFlags(): UserFlags;
|
||||
registerFlag(flag: FeatureFlag): void;
|
||||
|
||||
/**
|
||||
* Get a list of all registered flags.
|
||||
*/
|
||||
getRegisteredFlags(): FeatureFlagsRegistry;
|
||||
}
|
||||
getRegisteredFlags(): FeatureFlag[];
|
||||
|
||||
export interface FeatureFlagsRegistryItem {
|
||||
pluginId: string;
|
||||
name: FeatureFlagName;
|
||||
/**
|
||||
* Whether the feature flag with the given name is currently activated for the user.
|
||||
*/
|
||||
isActive(name: string): boolean;
|
||||
|
||||
/**
|
||||
* Save the user's choice of feature flag states.
|
||||
*/
|
||||
save(options: FeatureFlagsSaveOptions): void;
|
||||
}
|
||||
|
||||
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> = createApiRef({
|
||||
|
||||
+77
-101
@@ -14,70 +14,52 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags';
|
||||
import { FeatureFlagState, FeatureFlagsApi } from '../apis/definitions';
|
||||
import { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
|
||||
import { FeatureFlagState, FeatureFlagsApi } from '../../definitions';
|
||||
|
||||
describe('FeatureFlags', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('#getFlags', () => {
|
||||
describe('getFlags', () => {
|
||||
let featureFlags: FeatureFlagsApi;
|
||||
|
||||
beforeEach(() => {
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
featureFlags = new LocalStorageFeatureFlags();
|
||||
});
|
||||
|
||||
it('returns no flags', () => {
|
||||
expect(featureFlags.getFlags().toObject()).toMatchObject({});
|
||||
expect(featureFlags.getRegisteredFlags()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the correct flags', () => {
|
||||
it('loads flags from local storage', () => {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 1,
|
||||
'feature-flag-three': 0,
|
||||
'feature-flag-four': 2,
|
||||
'feature-flag-five': 'not-valid',
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
|
||||
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
|
||||
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
|
||||
expect(featureFlags.isActive('feature-flag-four')).toBe(false);
|
||||
expect(featureFlags.isActive('feature-flag-five')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets the correct values', () => {
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.set('feature-flag-zero', FeatureFlagState.On);
|
||||
featureFlags.save({
|
||||
states: {
|
||||
'feature-flag-zero': FeatureFlagState.Active,
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.On);
|
||||
expect(featureFlags.isActive('feature-flag-zero')).toBe(true);
|
||||
expect(window.localStorage.getItem('featureFlags')).toEqual(
|
||||
'{"feature-flag-zero":1}',
|
||||
);
|
||||
@@ -89,16 +71,20 @@ describe('FeatureFlags', () => {
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 0,
|
||||
'feature-flag-tree': 1,
|
||||
'feature-flag-four': 0,
|
||||
}),
|
||||
);
|
||||
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.delete('feature-flag-one');
|
||||
featureFlags.save({
|
||||
states: {
|
||||
'feature-flag-one': FeatureFlagState.None,
|
||||
'feature-flag-two': FeatureFlagState.Active,
|
||||
},
|
||||
});
|
||||
|
||||
expect(flags.get('feature-flag-one')).toEqual(FeatureFlagState.Off);
|
||||
expect(window.localStorage.getItem('featureFlags')).toEqual(
|
||||
'{"feature-flag-two":0}',
|
||||
'{"feature-flag-two":1}',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -112,33 +98,65 @@ describe('FeatureFlags', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.clear();
|
||||
expect(featureFlags.isActive('feature-flag-one')).toBe(true);
|
||||
expect(featureFlags.isActive('feature-flag-two')).toBe(true);
|
||||
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
|
||||
|
||||
featureFlags.save({ states: {} });
|
||||
|
||||
expect(featureFlags.isActive('feature-flag-one')).toBe(false);
|
||||
expect(featureFlags.isActive('feature-flag-two')).toBe(false);
|
||||
expect(featureFlags.isActive('feature-flag-three')).toBe(false);
|
||||
|
||||
expect(flags.toObject()).toEqual({});
|
||||
expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getRegisteredFlags', () => {
|
||||
describe('getRegisteredFlags', () => {
|
||||
let featureFlags: FeatureFlagsApi;
|
||||
|
||||
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' },
|
||||
];
|
||||
featureFlags = new LocalStorageFeatureFlags();
|
||||
featureFlags.registerFlag({
|
||||
name: 'registered-flag-1',
|
||||
pluginId: 'plugin-one',
|
||||
});
|
||||
featureFlags.registerFlag({
|
||||
name: 'registered-flag-2',
|
||||
pluginId: 'plugin-one',
|
||||
});
|
||||
featureFlags.registerFlag({
|
||||
name: 'registered-flag-3',
|
||||
pluginId: 'plugin-two',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty list', () => {
|
||||
featureFlags.registeredFeatureFlags = [];
|
||||
expect(featureFlags.getRegisteredFlags().toObject()).toEqual([]);
|
||||
featureFlags = new LocalStorageFeatureFlags();
|
||||
expect(featureFlags.getRegisteredFlags()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an valid list', () => {
|
||||
expect(featureFlags.getRegisteredFlags().toObject()).toMatchObject([
|
||||
expect(featureFlags.getRegisteredFlags()).toEqual([
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should provide a copy of the list of flags', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(flags).toEqual([
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
|
||||
]);
|
||||
flags.splice(2, 1);
|
||||
expect(flags).toEqual([
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
]);
|
||||
expect(featureFlags.getRegisteredFlags()).toEqual([
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
|
||||
@@ -164,48 +182,9 @@ describe('FeatureFlags', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
featureFlags.registerFlag({
|
||||
name: 'ab',
|
||||
pluginId: 'plugin-three',
|
||||
}),
|
||||
@@ -213,9 +192,8 @@ describe('FeatureFlags', () => {
|
||||
});
|
||||
|
||||
it('throws an error if length is greater than 150 characters', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(() =>
|
||||
flags.push({
|
||||
featureFlags.registerFlag({
|
||||
name:
|
||||
'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
|
||||
pluginId: 'plugin-three',
|
||||
@@ -224,9 +202,8 @@ describe('FeatureFlags', () => {
|
||||
});
|
||||
|
||||
it('throws an error if name does not start with a lowercase letter', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(() =>
|
||||
flags.push({
|
||||
featureFlags.registerFlag({
|
||||
name: '123456789',
|
||||
pluginId: 'plugin-three',
|
||||
}),
|
||||
@@ -234,9 +211,8 @@ describe('FeatureFlags', () => {
|
||||
});
|
||||
|
||||
it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(() =>
|
||||
flags.push({
|
||||
featureFlags.registerFlag({
|
||||
name: 'Invalid_Feature_Flag',
|
||||
pluginId: 'plugin-three',
|
||||
}),
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 {
|
||||
FeatureFlagState,
|
||||
FeatureFlagsApi,
|
||||
FeatureFlag,
|
||||
FeatureFlagsSaveOptions,
|
||||
} from '../../definitions';
|
||||
|
||||
export function validateFlagName(name: string): 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the FeatureFlags implementation based on the API.
|
||||
*/
|
||||
export class LocalStorageFeatureFlags implements FeatureFlagsApi {
|
||||
private registeredFeatureFlags: FeatureFlag[] = [];
|
||||
private flags?: Map<string, FeatureFlagState>;
|
||||
|
||||
registerFlag(flag: FeatureFlag) {
|
||||
validateFlagName(flag.name);
|
||||
this.registeredFeatureFlags.push(flag);
|
||||
}
|
||||
|
||||
getRegisteredFlags(): FeatureFlag[] {
|
||||
return this.registeredFeatureFlags.slice();
|
||||
}
|
||||
|
||||
isActive(name: string): boolean {
|
||||
if (!this.flags) {
|
||||
this.flags = this.load();
|
||||
}
|
||||
return this.flags.get(name) === FeatureFlagState.Active;
|
||||
}
|
||||
|
||||
save(options: FeatureFlagsSaveOptions): void {
|
||||
if (!this.flags) {
|
||||
this.flags = this.load();
|
||||
}
|
||||
if (!options.merge) {
|
||||
this.flags.clear();
|
||||
}
|
||||
for (const [name, state] of Object.entries(options.states)) {
|
||||
this.flags.set(name, state);
|
||||
}
|
||||
|
||||
const enabled = Array.from(this.flags.entries()).filter(
|
||||
([, state]) => state === FeatureFlagState.Active,
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify(Object.fromEntries(enabled)),
|
||||
);
|
||||
}
|
||||
|
||||
private load(): Map<string, FeatureFlagState> {
|
||||
try {
|
||||
const jsonStr = window.localStorage.getItem('featureFlags');
|
||||
if (!jsonStr) {
|
||||
return new Map();
|
||||
}
|
||||
const json = JSON.parse(jsonStr) as unknown;
|
||||
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const entries = Object.entries(json).filter(([name, value]) => {
|
||||
validateFlagName(name);
|
||||
return value === FeatureFlagState.Active;
|
||||
});
|
||||
|
||||
return new Map(entries);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { LocalStorageFeatureFlags } from './LocalStorageFeatureFlags';
|
||||
@@ -23,7 +23,8 @@ export * from './auth';
|
||||
export * from './AlertApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './ConfigApi';
|
||||
export * from './ErrorApi';
|
||||
export * from './DiscoveryApi';
|
||||
export * from './ErrorApi';
|
||||
export * from './FeatureFlagsApi';
|
||||
export * from './OAuthRequestApi';
|
||||
export * from './StorageApi';
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
AppThemeApi,
|
||||
ConfigApi,
|
||||
identityApiRef,
|
||||
FeatureFlagsRegistryItem,
|
||||
} from '../apis/definitions';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
useApi,
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
LocalStorageFeatureFlags,
|
||||
} from '../apis';
|
||||
import { useAsync } from 'react-use';
|
||||
import { AppIdentity } from './AppIdentity';
|
||||
@@ -135,7 +135,8 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
getRoutes(): JSX.Element[] {
|
||||
const routes = new Array<JSX.Element>();
|
||||
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
|
||||
|
||||
const featureFlagsApi = this.getApiHolder().get(featureFlagsApiRef)!;
|
||||
|
||||
const { NotFoundErrorPage } = this.components;
|
||||
|
||||
@@ -171,9 +172,9 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
break;
|
||||
}
|
||||
case 'feature-flag': {
|
||||
registeredFeatureFlags.push({
|
||||
pluginId: plugin.getId(),
|
||||
featureFlagsApi.registerFlag({
|
||||
name: output.name,
|
||||
pluginId: plugin.getId(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -183,11 +184,6 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
}
|
||||
}
|
||||
|
||||
const featureFlags = this.getApiHolder().get(featureFlagsApiRef);
|
||||
if (featureFlags) {
|
||||
featureFlags.registeredFeatureFlags = registeredFeatureFlags;
|
||||
}
|
||||
|
||||
routes.push(<Route path="/*" element={<NotFoundErrorPage />} />);
|
||||
|
||||
return routes;
|
||||
@@ -319,6 +315,13 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
factory: () => this.identityApi,
|
||||
});
|
||||
|
||||
// It's possible to replace the feature flag API, but since we must have at least
|
||||
// one implementation we add it here directly instead of through the defaultApis.
|
||||
registry.register('default', {
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => new LocalStorageFeatureFlags(),
|
||||
});
|
||||
for (const factory of this.defaultApis) {
|
||||
registry.register('default', factory);
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
* 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,
|
||||
FeatureFlagsRegistryItem,
|
||||
} from '../apis/definitions';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
toggle(name: FeatureFlagName): FeatureFlagState {
|
||||
if (super.get(name) === FeatureFlagState.On) {
|
||||
super.set(name, FeatureFlagState.Off);
|
||||
} else {
|
||||
super.set(name, FeatureFlagState.On);
|
||||
}
|
||||
return super.get(name) || FeatureFlagState.Off;
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { FeatureFlags } from './FeatureFlags';
|
||||
export { useApp } from './AppContext';
|
||||
export * from './types';
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import { PluginConfig, PluginOutput, BackstagePlugin } from './types';
|
||||
import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags';
|
||||
import { AnyApiFactory } from '../apis';
|
||||
|
||||
export class PluginImpl {
|
||||
@@ -57,8 +56,6 @@ export class PluginImpl {
|
||||
},
|
||||
featureFlags: {
|
||||
register(name) {
|
||||
validateBrowserCompat();
|
||||
validateFlagName(name);
|
||||
outputs.push({ type: 'feature-flag', name });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -54,11 +54,9 @@ export type LegacyRedirectRouteOutput = {
|
||||
options?: RouteOptions;
|
||||
};
|
||||
|
||||
export type FeatureFlagName = string;
|
||||
|
||||
export type FeatureFlagOutput = {
|
||||
type: 'feature-flag';
|
||||
name: FeatureFlagName;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PluginOutput =
|
||||
@@ -103,5 +101,5 @@ export type RouterHooks = {
|
||||
};
|
||||
|
||||
export type FeatureFlagsHooks = {
|
||||
register(name: FeatureFlagName): void;
|
||||
register(name: string): void;
|
||||
};
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
AlertApiForwarder,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
featureFlagsApiRef,
|
||||
FeatureFlags,
|
||||
discoveryApiRef,
|
||||
GoogleAuth,
|
||||
GithubAuth,
|
||||
@@ -69,7 +67,6 @@ export const defaultApis = [
|
||||
deps: { errorApi: errorApiRef },
|
||||
factory: ({ errorApi }) => WebStorage.create({ errorApi }),
|
||||
}),
|
||||
createApiFactory(featureFlagsApiRef, new FeatureFlags()),
|
||||
createApiFactory(oauthRequestApiRef, new OAuthRequestManager()),
|
||||
createApiFactory({
|
||||
api: googleAuthApiRef,
|
||||
|
||||
@@ -55,9 +55,7 @@ import { useSubtleTypographyStyles } from '../../utils/styles';
|
||||
|
||||
export const CostInsightsPage = () => {
|
||||
const classes = useSubtleTypographyStyles();
|
||||
const flags = useApi(featureFlagsApiRef).getFlags();
|
||||
// There is not currently a UI to set feature flags
|
||||
// flags.set('cost-insights-currencies', FeatureFlagState.On);
|
||||
const featureFlags = useApi(featureFlagsApiRef);
|
||||
const client = useApi(costInsightsApiRef);
|
||||
const config = useConfig();
|
||||
const groups = useGroups();
|
||||
@@ -211,7 +209,7 @@ export const CostInsightsPage = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box minHeight={40} maxHeight={60} display="flex">
|
||||
{!!flags.get('cost-insights-currencies') && (
|
||||
{featureFlags.isActive('cost-insights-currencies') && (
|
||||
<Box mr={1}>
|
||||
<CurrencySelect
|
||||
currency={currency}
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
FeatureFlagName,
|
||||
featureFlagsApiRef,
|
||||
FeatureFlagsRegistryItem,
|
||||
FeatureFlagState,
|
||||
InfoCard,
|
||||
useApi,
|
||||
@@ -30,29 +28,28 @@ import { FlagItem } from './FeatureFlagsItem';
|
||||
export const FeatureFlags = () => {
|
||||
const featureFlagsApi = useApi(featureFlagsApiRef);
|
||||
const featureFlags = featureFlagsApi.getRegisteredFlags();
|
||||
const initialFlagState = featureFlags.reduce(
|
||||
(result, featureFlag: FeatureFlagsRegistryItem) => {
|
||||
const state = featureFlagsApi.getFlags().get(featureFlag.name);
|
||||
|
||||
result[featureFlag.name] = state;
|
||||
return result;
|
||||
},
|
||||
{} as Record<FeatureFlagName, FeatureFlagState>,
|
||||
const initialFlagState = Object.fromEntries(
|
||||
featureFlags.map(({ name }) => [name, featureFlagsApi.isActive(name)]),
|
||||
);
|
||||
|
||||
const [state, setState] = useState<Record<FeatureFlagName, FeatureFlagState>>(
|
||||
initialFlagState,
|
||||
);
|
||||
const [state, setState] = useState<Record<string, boolean>>(initialFlagState);
|
||||
|
||||
const toggleFlag = useCallback(
|
||||
(flagName: FeatureFlagName) => {
|
||||
const newState = featureFlagsApi.getFlags().toggle(flagName);
|
||||
(flagName: string) => {
|
||||
const newState = featureFlagsApi.isActive(flagName)
|
||||
? FeatureFlagState.None
|
||||
: FeatureFlagState.Active;
|
||||
|
||||
featureFlagsApi.save({
|
||||
states: { [flagName]: newState },
|
||||
merge: true,
|
||||
});
|
||||
|
||||
setState(prevState => ({
|
||||
...prevState,
|
||||
[flagName]: newState,
|
||||
[flagName]: newState === FeatureFlagState.Active,
|
||||
}));
|
||||
featureFlagsApi.getFlags().save();
|
||||
},
|
||||
[featureFlagsApi],
|
||||
);
|
||||
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@material-ui/core';
|
||||
import { FeatureFlagsRegistryItem } from '@backstage/core';
|
||||
import { FeatureFlag } from '@backstage/core';
|
||||
|
||||
type Props = {
|
||||
flag: FeatureFlagsRegistryItem;
|
||||
flag: FeatureFlag;
|
||||
enabled: boolean;
|
||||
toggleHandler: Function;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user