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');
},
});