diff --git a/.changeset/swift-emus-mate.md b/.changeset/swift-emus-mate.md
new file mode 100644
index 0000000000..1903742115
--- /dev/null
+++ b/.changeset/swift-emus-mate.md
@@ -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().
diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md
index ebb8a6b503..bcea80e26b 100644
--- a/docs/reference/createPlugin-feature-flags.md
+++ b/docs/reference/createPlugin-feature-flags.md
@@ -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 (
-
- Enable the 'enable-example-feature' feature flag
-
+
+
+ { featureFlags.isActive('enable-example-feature') && }
+
);
};
```
diff --git a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
index 6e8c8cd8fc..243af562b1 100644
--- a/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
+++ b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts
@@ -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;
+
+ /**
+ * 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 = createApiRef({
diff --git a/packages/core-api/src/app/FeatureFlags.test.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
similarity index 59%
rename from packages/core-api/src/app/FeatureFlags.test.tsx
rename to packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
index 99974f9b1d..2902e399c9 100644
--- a/packages/core-api/src/app/FeatureFlags.test.tsx
+++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.test.tsx
@@ -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',
}),
diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx
new file mode 100644
index 0000000000..00b3eff906
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags.tsx
@@ -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;
+
+ 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 {
+ 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();
+ }
+ }
+}
diff --git a/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts
new file mode 100644
index 0000000000..33990584f3
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/FeatureFlagsApi/index.ts
@@ -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';
diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts
index 30aeb81d44..d0df2760ab 100644
--- a/packages/core-api/src/apis/implementations/index.ts
+++ b/packages/core-api/src/apis/implementations/index.ts
@@ -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';
diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx
index 0588cd819c..9c38c20b44 100644
--- a/packages/core-api/src/app/App.tsx
+++ b/packages/core-api/src/app/App.tsx
@@ -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();
- const registeredFeatureFlags = new Array();
+
+ 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( } />);
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);
}
diff --git a/packages/core-api/src/app/FeatureFlags.tsx b/packages/core-api/src/app/FeatureFlags.tsx
deleted file mode 100644
index 7a69fa5456..0000000000
--- a/packages/core-api/src/app/FeatureFlags.tsx
+++ /dev/null
@@ -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 {
- 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 {
- 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[] {
- 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);
- }
-}
diff --git a/packages/core-api/src/app/index.ts b/packages/core-api/src/app/index.ts
index 003b71e353..17610ea3ee 100644
--- a/packages/core-api/src/app/index.ts
+++ b/packages/core-api/src/app/index.ts
@@ -14,6 +14,5 @@
* limitations under the License.
*/
-export { FeatureFlags } from './FeatureFlags';
export { useApp } from './AppContext';
export * from './types';
diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx
index 866d430070..d69bb7e721 100644
--- a/packages/core-api/src/plugin/Plugin.tsx
+++ b/packages/core-api/src/plugin/Plugin.tsx
@@ -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 });
},
},
diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts
index 76c05f7b04..427d3d539a 100644
--- a/packages/core-api/src/plugin/types.ts
+++ b/packages/core-api/src/plugin/types.ts
@@ -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;
};
diff --git a/packages/core/src/api-wrappers/defaultApis.ts b/packages/core/src/api-wrappers/defaultApis.ts
index e735e4d371..d0a777dc94 100644
--- a/packages/core/src/api-wrappers/defaultApis.ts
+++ b/packages/core/src/api-wrappers/defaultApis.ts
@@ -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,
diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx
index 638f6fef00..52f70d2756 100644
--- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx
+++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx
@@ -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 = () => {
- {!!flags.get('cost-insights-currencies') && (
+ {featureFlags.isActive('cost-insights-currencies') && (
{
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,
+ const initialFlagState = Object.fromEntries(
+ featureFlags.map(({ name }) => [name, featureFlagsApi.isActive(name)]),
);
- const [state, setState] = useState>(
- initialFlagState,
- );
+ const [state, setState] = useState>(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],
);
diff --git a/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx
index 460a15f56b..603f63ef73 100644
--- a/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx
+++ b/plugins/user-settings/src/components/FeatureFlags/FeatureFlagsItem.tsx
@@ -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;
};