diff --git a/.changeset/gold-humans-tease.md b/.changeset/gold-humans-tease.md
new file mode 100644
index 0000000000..b3b40d0a7f
--- /dev/null
+++ b/.changeset/gold-humans-tease.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Add feature flags to plugins and extension overrides.
diff --git a/.changeset/green-seals-play.md b/.changeset/green-seals-play.md
new file mode 100644
index 0000000000..5c5a31afcd
--- /dev/null
+++ b/.changeset/green-seals-play.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Collect and register feature flags from plugins and extension overrides.
diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
index 85fed83e8c..d09911b39d 100644
--- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
+++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx
@@ -22,6 +22,8 @@ import React from 'react';
import { Route } from 'react-router-dom';
import { collectLegacyRoutes } from './collectLegacyRoutes';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { toInternalBackstagePlugin } from '../../frontend-plugin-api/src/wiring/createPlugin';
describe('collectLegacyRoutes', () => {
it('should collect legacy routes', () => {
@@ -37,7 +39,7 @@ describe('collectLegacyRoutes', () => {
expect(
collected.map(p => ({
id: p.id,
- extensions: p.extensions.map(e => ({
+ extensions: toInternalBackstagePlugin(p).extensions.map(e => ({
id: e.id,
attachTo: e.attachTo,
disabled: e.disabled,
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
index 0a024c0a3f..34ec29b8f7 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
@@ -23,6 +23,8 @@ import {
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin';
/** @internal */
export function resolveAppNodeSpecs(options: {
@@ -42,7 +44,10 @@ export function resolveAppNodeSpecs(options: {
);
const pluginExtensions = plugins.flatMap(source => {
- return source.extensions.map(extension => ({ ...extension, source }));
+ return toInternalBackstagePlugin(source).extensions.map(extension => ({
+ ...extension,
+ source,
+ }));
});
const overrideExtensions = overrides.flatMap(
override => toInternalExtensionOverrides(override).extensions,
diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index 52186a5029..41d722a68e 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -17,6 +17,9 @@
import {
AppTreeApi,
appTreeApiRef,
+ coreExtensionData,
+ createExtension,
+ createExtensionOverrides,
createPageExtension,
createPlugin,
createThemeExtension,
@@ -25,7 +28,7 @@ import { screen, waitFor } from '@testing-library/react';
import { createApp } from './createApp';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import React from 'react';
-import { useApi } from '@backstage/core-plugin-api';
+import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
describe('createApp', () => {
it('should allow themes to be installed', async () => {
@@ -94,6 +97,58 @@ describe('createApp', () => {
);
});
+ it('should register feature flags', async () => {
+ const app = createApp({
+ configLoader: async () => new MockConfigApi({}),
+ features: [
+ createPlugin({
+ id: 'test',
+ featureFlags: [{ name: 'test-1' }],
+ extensions: [
+ createExtension({
+ id: 'test.page.first',
+ attachTo: { id: 'core', input: 'root' },
+ output: { element: coreExtensionData.reactElement },
+ factory() {
+ const Component = () => {
+ const flagsApi = useApi(featureFlagsApiRef);
+ return (
+
+ Flags:{' '}
+ {flagsApi
+ .getRegisteredFlags()
+ .map(flag => `${flag.name} from '${flag.pluginId}'`)
+ .join(', ')}
+
+ );
+ };
+ return { element: };
+ },
+ }),
+ ],
+ }),
+ createExtensionOverrides({
+ featureFlags: [{ name: 'test-2' }],
+ extensions: [
+ createExtension({
+ id: 'core.router',
+ attachTo: { id: 'core', input: 'root' },
+ disabled: true,
+ output: {},
+ factory: () => ({}),
+ }),
+ ],
+ }),
+ ],
+ });
+
+ await renderWithEffects(app.createRoot());
+
+ await expect(
+ screen.findByText("Flags: test-1 from 'test', test-2 from ''"),
+ ).resolves.toBeInTheDocument();
+ });
+
it('should make the app structure available through the AppTreeApi', async () => {
let appTreeApi: AppTreeApi | undefined = undefined;
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index 1858bd4884..a9400d3ba4 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -93,6 +93,10 @@ import { AppNode } from '@backstage/frontend-plugin-api';
import { toLegacyPlugin } from '../routing/toLegacyPlugin';
import { InternalAppContext } from './InternalAppContext';
import { CoreRouter } from '../extensions/CoreRouter';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
const builtinExtensions = [
Core,
@@ -304,6 +308,26 @@ export function createSpecializedApp(options?: {
const appIdentityProxy = new AppIdentityProxy();
const apiHolder = createApiHolder(tree, config, appIdentityProxy);
+
+ const featureFlagApi = apiHolder.get(featureFlagsApiRef);
+ if (featureFlagApi) {
+ for (const feature of features) {
+ if (feature.$$type === '@backstage/BackstagePlugin') {
+ toInternalBackstagePlugin(feature).featureFlags.forEach(flag =>
+ featureFlagApi.registerFlag({
+ name: flag.name,
+ pluginId: feature.id,
+ }),
+ );
+ }
+ if (feature.$$type === '@backstage/ExtensionOverrides') {
+ toInternalExtensionOverrides(feature).featureFlags.forEach(flag =>
+ featureFlagApi.registerFlag({ name: flag.name, pluginId: '' }),
+ );
+ }
+ }
+ }
+
const routeInfo = extractRouteInfoFromAppNode(tree.root);
const routeBindings = resolveRouteBindings(
options?.bindRoutes,
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index bcc6115ff3..c5005d34ac 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -218,15 +218,13 @@ export interface BackstagePlugin<
ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
> {
// (undocumented)
- $$type: '@backstage/BackstagePlugin';
+ readonly $$type: '@backstage/BackstagePlugin';
// (undocumented)
- extensions: Extension[];
+ readonly externalRoutes: ExternalRoutes;
// (undocumented)
- externalRoutes: ExternalRoutes;
+ readonly id: string;
// (undocumented)
- id: string;
- // (undocumented)
- routes: Routes;
+ readonly routes: Routes;
}
export { BackstageUserIdentity };
@@ -606,13 +604,15 @@ export type ExtensionInputValues<
// @public (undocumented)
export interface ExtensionOverrides {
// (undocumented)
- $$type: '@backstage/ExtensionOverrides';
+ readonly $$type: '@backstage/ExtensionOverrides';
}
// @public (undocumented)
export interface ExtensionOverridesOptions {
// (undocumented)
extensions: Extension[];
+ // (undocumented)
+ featureFlags?: FeatureFlagConfig[];
}
// @public
@@ -630,6 +630,11 @@ export interface ExternalRouteRef<
export { FeatureFlag };
+// @public
+export type FeatureFlagConfig = {
+ name: string;
+};
+
export { FeatureFlagsApi };
export { featureFlagsApiRef };
@@ -707,6 +712,8 @@ export interface PluginOptions<
// (undocumented)
externalRoutes?: ExternalRoutes;
// (undocumented)
+ featureFlags?: FeatureFlagConfig[];
+ // (undocumented)
id: string;
// (undocumented)
routes?: Routes;
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts
index eb69f954e4..90f878ceaa 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts
@@ -26,6 +26,7 @@ describe('createExtensionOverrides', () => {
{
"$$type": "@backstage/ExtensionOverrides",
"extensions": [],
+ "featureFlags": [],
"version": "v1",
}
`);
@@ -60,6 +61,7 @@ describe('createExtensionOverrides', () => {
"output": {},
},
],
+ "featureFlags": [],
"version": "v1",
}
`);
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts
index 2c64229eb9..3902654db7 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts
@@ -15,21 +15,24 @@
*/
import { Extension } from './createExtension';
+import { FeatureFlagConfig } from './types';
/** @public */
export interface ExtensionOverridesOptions {
extensions: Extension[];
+ featureFlags?: FeatureFlagConfig[];
}
/** @public */
export interface ExtensionOverrides {
- $$type: '@backstage/ExtensionOverrides';
+ readonly $$type: '@backstage/ExtensionOverrides';
}
/** @internal */
export interface InternalExtensionOverrides extends ExtensionOverrides {
- version: string;
- extensions: Extension[];
+ readonly version: 'v1';
+ readonly extensions: Extension[];
+ readonly featureFlags: FeatureFlagConfig[];
}
/** @public */
@@ -40,6 +43,7 @@ export function createExtensionOverrides(
$$type: '@backstage/ExtensionOverrides',
version: 'v1',
extensions: options.extensions,
+ featureFlags: options.featureFlags ?? [],
} as InternalExtensionOverrides;
}
@@ -50,12 +54,12 @@ export function toInternalExtensionOverrides(
const internal = overrides as InternalExtensionOverrides;
if (internal.$$type !== '@backstage/ExtensionOverrides') {
throw new Error(
- `Invalid translation resource, bad type '${internal.$$type}'`,
+ `Invalid extension overrides instance, bad type '${internal.$$type}'`,
);
}
if (internal.version !== 'v1') {
throw new Error(
- `Invalid translation resource, bad version '${internal.version}'`,
+ `Invalid extension overrides instance, bad version '${internal.version}'`,
);
}
return internal;
diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts
index 84edd954ef..5e2a55ab88 100644
--- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts
+++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts
@@ -16,6 +16,7 @@
import { Extension } from './createExtension';
import { ExternalRouteRef, RouteRef } from '../routing';
+import { FeatureFlagConfig } from './types';
/** @public */
export type AnyRoutes = { [name in string]: RouteRef };
@@ -32,6 +33,7 @@ export interface PluginOptions<
routes?: Routes;
externalRoutes?: ExternalRoutes;
extensions?: Extension[];
+ featureFlags?: FeatureFlagConfig[];
}
/** @public */
@@ -39,11 +41,20 @@ export interface BackstagePlugin<
Routes extends AnyRoutes = AnyRoutes,
ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
> {
- $$type: '@backstage/BackstagePlugin';
- id: string;
- extensions: Extension[];
- routes: Routes;
- externalRoutes: ExternalRoutes;
+ readonly $$type: '@backstage/BackstagePlugin';
+ readonly id: string;
+ readonly routes: Routes;
+ readonly externalRoutes: ExternalRoutes;
+}
+
+/** @public */
+export interface InternalBackstagePlugin<
+ Routes extends AnyRoutes = AnyRoutes,
+ ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
+> extends BackstagePlugin {
+ readonly version: 'v1';
+ readonly extensions: Extension[];
+ readonly featureFlags: FeatureFlagConfig[];
}
/** @public */
@@ -54,10 +65,28 @@ export function createPlugin<
options: PluginOptions,
): BackstagePlugin {
return {
- ...options,
+ $$type: '@backstage/BackstagePlugin',
+ version: 'v1',
+ id: options.id,
routes: options.routes ?? ({} as Routes),
externalRoutes: options.externalRoutes ?? ({} as ExternalRoutes),
extensions: options.extensions ?? [],
- $$type: '@backstage/BackstagePlugin',
- };
+ featureFlags: options.featureFlags ?? [],
+ } as InternalBackstagePlugin;
+}
+
+/** @internal */
+export function toInternalBackstagePlugin(
+ plugin: BackstagePlugin,
+): InternalBackstagePlugin {
+ const internal = plugin as InternalBackstagePlugin;
+ if (internal.$$type !== '@backstage/BackstagePlugin') {
+ throw new Error(`Invalid plugin instance, bad type '${internal.$$type}'`);
+ }
+ if (internal.version !== 'v1') {
+ throw new Error(
+ `Invalid plugin instance, bad version '${internal.version}'`,
+ );
+ }
+ return internal;
}
diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts
index 19e11185e9..191307ce1a 100644
--- a/packages/frontend-plugin-api/src/wiring/index.ts
+++ b/packages/frontend-plugin-api/src/wiring/index.ts
@@ -49,3 +49,4 @@ export {
type ExtensionOverrides,
type ExtensionOverridesOptions,
} from './createExtensionOverrides';
+export type { FeatureFlagConfig } from './types';
diff --git a/packages/frontend-plugin-api/src/wiring/types.ts b/packages/frontend-plugin-api/src/wiring/types.ts
new file mode 100644
index 0000000000..ab9f07b35e
--- /dev/null
+++ b/packages/frontend-plugin-api/src/wiring/types.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * 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.
+ */
+
+/**
+ * Feature flag configuration.
+ *
+ * @public
+ */
+export type FeatureFlagConfig = {
+ /** Feature flag name */
+ name: string;
+};