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 ( - +
+ + { 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; };