diff --git a/docs/reference/README.md b/docs/reference/README.md index 8438917945..dd926d57dc 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -4,5 +4,6 @@ APIs and Components - [createPlugin](createPlugin.md) - [createPlugin - router](createPlugin-router.md) +- [createPlugin - feature flags](createPlugin-feature-flags.md) [Back to Docs](../README.md) diff --git a/docs/reference/createPlugin-feature-flags.md b/docs/reference/createPlugin-feature-flags.md new file mode 100644 index 0000000000..60fddaa8fd --- /dev/null +++ b/docs/reference/createPlugin-feature-flags.md @@ -0,0 +1,65 @@ +# createPlugin - feature flags + +The `featureFlags` object passed to the `register` function makes it possible for plugins to register Feature Flags in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc. + +```typescript +export type FeatureFlagsHooks = { + register(name: FeatureFlagName): void; +}; +``` + +Here's a code sample: + +```typescript +import { createPlugin } from '@backstage/core'; + +export default createPlugin({ + id: 'welcome', + register({ router, featureFlags }) { + // router.registerRoute('/', Component); + featureFlags.register('enable-example-feature'); + }, +}); +``` + +## Using with useApi + +To use it, you'll first need to register the `FeatureFlags` API via `ApiRegistry` in your `apis.ts` in your App: + +```tsx +import { + ApiHolder, + ApiRegistry, + featureFlagsApiRef, + FeatureFlags, +} from '@backstage/core'; + +const builder = ApiRegistry.builder(); +builder.add(featureFlagsApiRef, FeatureFlags); + +export default builder.build() as ApiHolder; +``` + +Then, later, you can directly use it via `useApi`: + +```tsx +import React, { FC } from 'react'; +import { Button } from '@material-ui/core'; +import { featureFlagsApiRef, useApi } from '@backstage/core'; + +const ExampleButton: FC<{}> = () => { + const flags = useApi(featureFlagsApiRef).getFlags(); + + const handleClick = () => { + flags.set('enable-example-feature', FeatureFlagState.On); + }; + + return ( + + ); +}; +``` + +[Back to References](README.md) diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index 77048199ef..de4636e11d 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -17,13 +17,14 @@ type PluginHooks = { }; ``` -[Read more about the router here](createPlugin-router.md) +- [Read more about the router here](createPlugin-router.md) +- [Read more about feature flags here](createPlugin-feature-flags.md) ## Example Uses ### Creating a basic plugin -Showcasing adding multiple routes and a redirect. +Showcasing adding multiple routes, a feature flag and a redirect. ```jsx import { createPlugin } from '@backstage/core'; @@ -31,7 +32,9 @@ import ExampleComponent from './components/ExampleComponent'; export default createPlugin({ id: 'new-plugin', - register({ router }) { + register({ router, featureFlags }) { + featureFlags.register('enable-example-component'); + router.registerRoute('/new-plugin', ExampleComponent); }, }); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index c3315257d8..494b469312 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -14,13 +14,20 @@ * limitations under the License. */ -import { ApiHolder, ApiRegistry, errorApiRef } from '@backstage/core'; +import { + ApiHolder, + ApiRegistry, + errorApiRef, + featureFlagsApiRef, + FeatureFlags, +} from '@backstage/core'; import { ErrorDisplayForwarder } from './components/ErrorDisplay/ErrorDisplay'; const builder = ApiRegistry.builder(); export const errorDialogForwarder = new ErrorDisplayForwarder(); - builder.add(errorApiRef, errorDialogForwarder); +builder.add(featureFlagsApiRef, new FeatureFlags()); + export default builder.build() as ApiHolder; diff --git a/packages/core/src/api/apis/definitions/featureFlags.ts b/packages/core/src/api/apis/definitions/featureFlags.ts new file mode 100644 index 0000000000..926fe091d5 --- /dev/null +++ b/packages/core/src/api/apis/definitions/featureFlags.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ApiRef from '../ApiRef'; +import { + UserFlags, + FeatureFlagsRegistry, + FeatureFlagsRegistryItem, +} from '../../app/FeatureFlags'; + +/** + * The feature flags API is used to toggle functionality to users across plugins and Backstage. + * + * Plugins can use this API to register feature flags that they have available + * for users to enable/disable, and this API will centralize the current user's + * state of which feature flags they would like to enable. + * + * This is ideal for Backstage plugins, as well as your own App, to trial incomplete + * or unstable upcoming features. Although there will be a common interface for users + * to enable and disable feature flags, this API acts as another way to enable/disable. + */ + +export enum FeatureFlagState { + Off = 0, + On = 1, +} + +export interface FeatureFlagsApi { + /** + * Store a list of registered feature flags. + */ + registeredFeatureFlags: FeatureFlagsRegistryItem[]; + + /** + * Get a list of all feature flags from the current user. + */ + getFlags(): UserFlags; + + /** + * Get a list of all registered flags. + */ + getRegisteredFlags(): FeatureFlagsRegistry; +} + +export const featureFlagsApiRef = new ApiRef({ + id: 'core.featureflags', + description: 'Used to toggle functionality in features across Backstage', +}); diff --git a/packages/core/src/api/apis/definitions/index.ts b/packages/core/src/api/apis/definitions/index.ts index bdf2a0b6a2..55923b50b7 100644 --- a/packages/core/src/api/apis/definitions/index.ts +++ b/packages/core/src/api/apis/definitions/index.ts @@ -21,3 +21,4 @@ // If you think some API definition is missing, please open an Issue or send a PR! export * from './error'; +export * from './featureFlags'; diff --git a/packages/core/src/api/app/AppBuilder.tsx b/packages/core/src/api/app/AppBuilder.tsx index ab6f60a293..23d0e17672 100644 --- a/packages/core/src/api/app/AppBuilder.tsx +++ b/packages/core/src/api/app/AppBuilder.tsx @@ -19,6 +19,8 @@ import { Route, Switch, Redirect } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; import { App } from './types'; import BackstagePlugin from '../plugin/Plugin'; +import { FeatureFlagsRegistryItem } from './FeatureFlags'; +import { featureFlagsApiRef } from '../apis/definitions/featureFlags'; import { IconComponent, SystemIcons, @@ -62,6 +64,7 @@ export default class AppBuilder { const app = new AppImpl(this.systemIcons); const routes = new Array(); + const registeredFeatureFlags = new Array(); for (const plugin of this.plugins.values()) { for (const output of plugin.output()) { @@ -87,12 +90,24 @@ export default class AppBuilder { ); break; } + case 'feature-flag': { + registeredFeatureFlags.push({ + pluginId: plugin.getId(), + name: output.name, + }); + break; + } default: break; } } } + const FeatureFlags = this.apis && this.apis.get(featureFlagsApiRef); + if (FeatureFlags) { + FeatureFlags.registeredFeatureFlags = registeredFeatureFlags; + } + routes.push( , ); diff --git a/packages/core/src/api/app/FeatureFlags.test.tsx b/packages/core/src/api/app/FeatureFlags.test.tsx new file mode 100644 index 0000000000..b5bbb4d68e --- /dev/null +++ b/packages/core/src/api/app/FeatureFlags.test.tsx @@ -0,0 +1,246 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags'; +import { FeatureFlagState } from '../apis/definitions/featureFlags'; + +describe('FeatureFlags', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + describe('#getFlags', () => { + let featureFlags; + + beforeEach(() => { + featureFlags = new FeatureFlagsImpl(); + }); + + it('returns no flags', () => { + expect(featureFlags.getFlags().toObject()).toMatchObject({}); + }); + + it('returns the correct flags', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, + }), + ); + + featureFlags = new FeatureFlagsImpl(); + expect(featureFlags.getFlags().toObject()).toMatchObject({ + 'feature-flag-one': FeatureFlagState.On, + 'feature-flag-two': FeatureFlagState.On, + 'feature-flag-three': FeatureFlagState.Off, + }); + }); + + it('gets the correct values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 0, + }), + ); + + featureFlags = new FeatureFlagsImpl(); + + expect(featureFlags.getFlags().get('feature-flag-one')).toEqual( + FeatureFlagState.On, + ); + expect(featureFlags.getFlags().get('feature-flag-two')).toEqual( + FeatureFlagState.Off, + ); + expect(featureFlags.getFlags().get('feature-flag-three')).toEqual( + FeatureFlagState.Off, + ); + }); + + it('sets the correct values', () => { + const flags = featureFlags.getFlags(); + flags.set('feature-flag-zero', FeatureFlagState.On); + + expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.On); + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-zero":1}', + ); + }); + + it('deletes the correct values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 0, + }), + ); + + featureFlags = new FeatureFlagsImpl(); + const flags = featureFlags.getFlags(); + flags.delete('feature-flag-one'); + + expect(flags.get('feature-flag-one')).toEqual(FeatureFlagState.Off); + expect(window.localStorage.getItem('featureFlags')).toEqual( + '{"feature-flag-two":0}', + ); + }); + + it('clears all values', () => { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify({ + 'feature-flag-one': 1, + 'feature-flag-two': 1, + 'feature-flag-three': 0, + }), + ); + + const flags = featureFlags.getFlags(); + flags.clear(); + + expect(flags.toObject()).toEqual({}); + expect(window.localStorage.getItem('featureFlags')).toEqual('{}'); + }); + }); + + describe('#getRegisteredFlags', () => { + let featureFlags; + + beforeEach(() => { + featureFlags = new FeatureFlagsImpl(); + featureFlags.registeredFeatureFlags = [ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]; + }); + + it('should return an empty list', () => { + featureFlags.registeredFeatureFlags = []; + expect(featureFlags.getRegisteredFlags().toObject()).toEqual([]); + }); + + it('should return an valid list', () => { + expect(featureFlags.getRegisteredFlags().toObject()).toMatchObject([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + ]); + }); + + it('should get the correct values', () => { + const getByName = name => + featureFlags.getRegisteredFlags().find(flag => flag.name === name); + + expect(getByName('registered-flag-0')).toBeUndefined(); + expect(getByName('registered-flag-1')).toEqual({ + name: 'registered-flag-1', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-2')).toEqual({ + name: 'registered-flag-2', + pluginId: 'plugin-one', + }); + expect(getByName('registered-flag-3')).toEqual({ + name: 'registered-flag-3', + pluginId: 'plugin-two', + }); + }); + + it('should append the correct value', () => { + const flags = featureFlags.getRegisteredFlags(); + + flags.push({ + name: 'registered-flag-4', + pluginId: 'plugin-three', + }); + + expect(flags.toObject()).toMatchObject([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + { name: 'registered-flag-4', pluginId: 'plugin-three' }, + ]); + }); + + it('should concat the correct values', () => { + const flags = featureFlags.getRegisteredFlags(); + const concatValues = flags.concat([ + { + name: 'registered-flag-4', + pluginId: 'plugin-three', + }, + { + name: 'registered-flag-5', + pluginId: 'plugin-four', + }, + ]); + + expect(concatValues).toMatchObject([ + { name: 'registered-flag-1', pluginId: 'plugin-one' }, + { name: 'registered-flag-2', pluginId: 'plugin-one' }, + { name: 'registered-flag-3', pluginId: 'plugin-two' }, + { name: 'registered-flag-4', pluginId: 'plugin-three' }, + { name: 'registered-flag-5', pluginId: 'plugin-four' }, + ]); + }); + + it('throws an error if length is less than three characters', () => { + const flags = featureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: 'ab', + pluginId: 'plugin-three', + }), + ).toThrow(/minimum length of three characters/i); + }); + + it('throws an error if length is greater than 150 characters', () => { + const flags = featureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: + 'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat', + pluginId: 'plugin-three', + }), + ).toThrow(/not exceed 150 characters/i); + }); + + it('throws an error if name does not start with a lowercase letter', () => { + const flags = featureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: '123456789', + pluginId: 'plugin-three', + }), + ).toThrow(/start with a lowercase letter/i); + }); + + it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => { + const flags = featureFlags.getRegisteredFlags(); + expect(() => + flags.push({ + name: 'Invalid_Feature_Flag', + pluginId: 'plugin-three', + }), + ).toThrow(/only contain lowercase letters, numbers and hyphens/i); + }); + }); +}); diff --git a/packages/core/src/api/app/FeatureFlags.tsx b/packages/core/src/api/app/FeatureFlags.tsx new file mode 100644 index 0000000000..8e5a06b209 --- /dev/null +++ b/packages/core/src/api/app/FeatureFlags.tsx @@ -0,0 +1,181 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FeatureFlagName } from '../plugin/types'; +import { + FeatureFlagState, + FeatureFlagsApi, +} from '../apis/definitions/featureFlags'; + +/** + * Helper method for validating compatibility and flag name. + */ +export function validateBrowserCompat(): void { + if (!('localStorage' in window)) { + throw new Error( + 'Feature Flags are not supported on browsers without the Local Storage API', + ); + } +} + +export function validateFlagName(name: FeatureFlagName): void { + if (name.length < 3) { + throw new Error( + `The '${name}' feature flag must have a minimum length of three characters.`, + ); + } + + if (name.length > 150) { + throw new Error( + `The '${name}' feature flag must not exceed 150 characters.`, + ); + } + + if (!name.match(/^[a-z]+[a-z0-9-]+$/)) { + throw new Error( + `The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` + + 'Examples: feature-flag-one, alpha, release-2020', + ); + } +} + +/** + * The UserFlags class. + * + * This acts as a data structure for the user's feature flags. You + * can use this to retrieve, add, edit, delete, clear and save the user's + * feature flags to the local browser for persisted storage. + */ +export class UserFlags extends Map { + static load(): UserFlags { + validateBrowserCompat(); + + try { + const jsonString = window.localStorage.getItem('featureFlags') as string; + const json = JSON.parse(jsonString); + return new this(Object.entries(json)); + } catch (err) { + return new this([]); + } + } + + get(name: FeatureFlagName): FeatureFlagState { + return super.get(name) || FeatureFlagState.Off; + } + + set(name: FeatureFlagName, state: FeatureFlagState): this { + validateFlagName(name); + const output = super.set(name, state); + this.save(); + return output; + } + + delete(name: FeatureFlagName): boolean { + const output = super.delete(name); + this.save(); + return output; + } + + clear(): void { + super.clear(); + this.save(); + } + + save(): void { + window.localStorage.setItem( + 'featureFlags', + JSON.stringify(this.toObject()), + ); + } + + toObject() { + return Array.from(this.entries()).reduce( + (obj, [key, value]) => ({ ...obj, [key]: value }), + {}, + ); + } + + toJSON() { + return JSON.stringify(this.toObject()); + } + + toString() { + return this.toJSON(); + } +} + +/** + * The FeatureFlagsRegistry class. + * + * This acts as a holding data structure for feature flags + * that plugins wish to register for use in Backstage. + */ +export interface FeatureFlagsRegistryItem { + pluginId: string; + name: FeatureFlagName; +} + +export class FeatureFlagsRegistry extends Array { + 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/src/api/index.ts b/packages/core/src/api/index.ts index dff75514ae..e60a7efa18 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -16,4 +16,5 @@ export * from './api'; export * from './apis'; +export { FeatureFlags } from './app/FeatureFlags'; export { useApp } from './app/AppContext'; diff --git a/packages/core/src/api/plugin/Plugin.tsx b/packages/core/src/api/plugin/Plugin.tsx index efaabd0ff1..aade5d0092 100644 --- a/packages/core/src/api/plugin/Plugin.tsx +++ b/packages/core/src/api/plugin/Plugin.tsx @@ -15,7 +15,13 @@ */ import { ComponentType } from 'react'; -import { PluginOutput, RoutePath, RouteOptions } from './types'; +import { + PluginOutput, + RoutePath, + RouteOptions, + FeatureFlagName, +} from './types'; +import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags'; import { Widget } from '../widgetView/types'; export type PluginConfig = { @@ -26,6 +32,7 @@ export type PluginConfig = { export type PluginHooks = { router: RouterHooks; widgets: WidgetHooks; + featureFlags: FeatureFlagsHooks; }; export type RouterHooks = { @@ -46,6 +53,10 @@ export type WidgetHooks = { add(widget: Widget): void; }; +export type FeatureFlagsHooks = { + register(name: FeatureFlagName): void; +}; + export const registerSymbol = Symbol('plugin-register'); export const outputSymbol = Symbol('plugin-output'); @@ -54,6 +65,10 @@ export default class Plugin { constructor(private readonly config: PluginConfig) {} + getId(): string { + return this.config.id; + } + output(): PluginOutput[] { if (this.storedOutput) { return this.storedOutput; @@ -78,6 +93,13 @@ export default class Plugin { outputs.push({ type: 'widget', widget }); }, }, + featureFlags: { + register(name) { + validateBrowserCompat(); + validateFlagName(name); + outputs.push({ type: 'feature-flag', name }); + }, + }, }); this.storedOutput = outputs; diff --git a/packages/core/src/api/plugin/types.ts b/packages/core/src/api/plugin/types.ts index 47c121fae8..b006e06d9a 100644 --- a/packages/core/src/api/plugin/types.ts +++ b/packages/core/src/api/plugin/types.ts @@ -43,4 +43,15 @@ export type WidgetOutput = { widget: Widget; }; -export type PluginOutput = RouteOutput | RedirectRouteOutput | WidgetOutput; +export type FeatureFlagName = string; + +export type FeatureFlagOutput = { + type: 'feature-flag'; + name: FeatureFlagName; +}; + +export type PluginOutput = + | RouteOutput + | RedirectRouteOutput + | WidgetOutput + | FeatureFlagOutput; diff --git a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.test.tsx b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.test.tsx new file mode 100644 index 0000000000..13d6741cc7 --- /dev/null +++ b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import ToggleFeatureFlagButton from './ToggleFeatureFlagButton'; +import { + ApiRegistry, + featureFlagsApiRef, + ApiProvider, + FeatureFlags, +} from '@backstage/core'; + +function withApiRegistry(component: ReactNode, featureFlags: FeatureFlags) { + return ( + + {component} + + ); +} + +describe('ToggleFeatureFlagButton', () => { + let featureFlags: FeatureFlags; + + beforeEach(() => { + featureFlags = new FeatureFlags(); + window.localStorage.clear(); + }); + + it('should enable the feature flag', () => { + const rendered = render( + withApiRegistry(, featureFlags), + ); + + const button = rendered.getByTestId('button-switch-feature-flag-state'); + expect(button).toBeInTheDocument(); + + expect(window.localStorage.featureFlags).toBeUndefined(); + fireEvent.click(button); + expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":1}'); + }); + + it('should disable the feature flag', () => { + window.localStorage.setItem('featureFlags', '{"enable-welcome-box":1}'); + + const rendered = render( + withApiRegistry(, featureFlags), + ); + + const button = rendered.getByTestId('button-switch-feature-flag-state'); + expect(button).toBeInTheDocument(); + + expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":1}'); + fireEvent.click(button); + expect(window.localStorage.featureFlags).toBe('{"enable-welcome-box":0}'); + }); +}); diff --git a/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx new file mode 100644 index 0000000000..3ce66ce1c7 --- /dev/null +++ b/plugins/welcome/src/components/WelcomePage/ToggleFeatureFlagButton.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { Button } from '@material-ui/core'; +import { FeatureFlagState, featureFlagsApiRef, useApi } from '@backstage/core'; + +const ToggleFeatureFlagButton: FC<{}> = () => { + const featureFlagsApi = useApi(featureFlagsApiRef); + const flags = featureFlagsApi.getFlags(); + const flagState = flags.get('enable-welcome-box'); + + const handleClick = () => { + const newValue = + flagState === FeatureFlagState.On + ? FeatureFlagState.Off + : FeatureFlagState.On; + flags.set('enable-welcome-box', newValue); + window.location.reload(); + }; + + return ( + + ); +}; + +export default ToggleFeatureFlagButton; diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index 69a892a95e..597ef5044b 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -35,6 +35,7 @@ import { SupportButton, } from '@backstage/core'; import ErrorButton from './ErrorButton'; +import ToggleFeatureFlagButton from './ToggleFeatureFlagButton'; const WelcomePage: FC<{}> = () => { const profile = { givenName: '' }; @@ -121,6 +122,9 @@ const WelcomePage: FC<{}> = () => {
+
+
+ diff --git a/plugins/welcome/src/plugin.ts b/plugins/welcome/src/plugin.ts index 20ec41d4c2..e7755862ff 100644 --- a/plugins/welcome/src/plugin.ts +++ b/plugins/welcome/src/plugin.ts @@ -19,7 +19,9 @@ import WelcomePage from './components/WelcomePage'; export default createPlugin({ id: 'welcome', - register({ router }) { + register({ router, featureFlags }) { router.registerRoute('/', WelcomePage); + + featureFlags.register('enable-welcome-box'); }, });