diff --git a/packages/app/src/examples/pagesPlugin.tsx b/packages/app/src/examples/pagesPlugin.tsx
index 374935e843..d9d8bbb4c3 100644
--- a/packages/app/src/examples/pagesPlugin.tsx
+++ b/packages/app/src/examples/pagesPlugin.tsx
@@ -23,6 +23,9 @@ import {
PageBlueprint,
FrontendPluginInfo,
useAppNode,
+ createExtensionBlueprint,
+ createExtensionInput,
+ coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
@@ -88,8 +91,8 @@ const IndexPage = PageBlueprint.make({
Permission Enablement Examples
The following pages demonstrate conditional extension enablement
- via the enabled predicate using permissions. They
- will only appear when the user has the required permissions.
+ via the if predicate using permissions. They will
+ only appear when the user has the required permissions.
@@ -98,13 +101,20 @@ const IndexPage = PageBlueprint.make({
{' '}
— requires catalog.entity.create
+
+
+ Permission Card Example
+ {' '}
+ — a page that is always visible, but individual cards on it are
+ toggled by permissions
+
Feature Flag Enablement Examples
The following pages demonstrate conditional extension enablement
- via the enabled predicate. They will only appear in
- the router tree when their conditions are satisfied. Toggle the
+ via the if predicate. They will only appear in the
+ router tree when their conditions are satisfied. Toggle the
relevant feature flags in Settings,
then refresh the app to see the pages appear.
@@ -194,7 +204,7 @@ const ExternalPage = PageBlueprint.make({
// Example: Page enabled only when a single feature flag is active.
//
-// The `enabled` predicate is evaluated once at app startup (before the router
+// The `if` predicate is evaluated once at app startup (before the router
// tree is built), so this page simply won't exist in the app until the flag is
// toggled and the page is refreshed.
//
@@ -227,7 +237,7 @@ const FeatureFlagPage = PageBlueprint.make({
return ;
},
},
- enabled: { featureFlags: { $contains: 'experimental-features' } },
+ if: { featureFlags: { $contains: 'experimental-features' } },
});
// Example: Page enabled only when ALL of several feature flags are active.
@@ -263,7 +273,7 @@ const AllFlagsPage = PageBlueprint.make({
return ;
},
},
- enabled: {
+ if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'advanced-features' } },
@@ -304,7 +314,7 @@ const AnyFlagPage = PageBlueprint.make({
return ;
},
},
- enabled: {
+ if: {
$any: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'beta-access' } },
@@ -312,9 +322,113 @@ const AnyFlagPage = PageBlueprint.make({
},
});
+// Blueprint for cards that attach to the PermissionCardPage below.
+//
+// Each card receives a title and description and renders a simple bordered card.
+// Individual card instances can be selectively enabled via the `if`
+// predicate, so only the cards the user is allowed to see will be instantiated.
+const PermissionExampleCardBlueprint = createExtensionBlueprint({
+ kind: 'permission-example-card',
+ attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
+ output: [coreExtensionData.reactElement],
+ *factory(params: { title: string; description: string }) {
+ yield coreExtensionData.reactElement(
+
+
{params.title}
+
{params.description}
+
,
+ );
+ },
+});
+
+// Example: Page with cards that are individually toggled by permissions.
+//
+// The page itself is always present. What changes is which cards are
+// instantiated inside it — each card declares its own `enabled` predicate
+// and is only wired into the page if that predicate is satisfied at startup.
+//
+// To test: make sure you do NOT have the catalog.entity.create permission and
+// refresh the page — the "Restricted Card" below should disappear.
+const PermissionCardPage = PageBlueprint.makeWithOverrides({
+ name: 'permissionCardExample',
+ inputs: {
+ cards: createExtensionInput([coreExtensionData.reactElement]),
+ },
+ factory(originalFactory, { inputs }) {
+ return originalFactory({
+ path: '/permission-card-example',
+ loader: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ const cards = inputs.cards.map(card =>
+ card.get(coreExtensionData.reactElement),
+ );
+ return (
+
+
Permission-Gated Card Example
+
+ This page is always visible. The cards below are individually
+ gated — each one declares its own{' '}
+ {'if: { permissions: { $contains: "..." } }'}{' '}
+ predicate. Cards whose predicate fails are never instantiated,
+ so they simply won't appear here.
+
+
+ {cards.length > 0 ? (
+ cards
+ ) : (
+
+ No cards are visible — you may lack the required
+ permissions.
+
+ )}
+
+ {indexLink &&
Go back}
+
+ );
+ };
+ return ;
+ },
+ });
+ },
+});
+
+// Always-visible card — no predicate, every user sees this.
+const PublicCard = PermissionExampleCardBlueprint.make({
+ name: 'public',
+ params: {
+ title: 'Public Card',
+ description: 'This card is visible to everyone regardless of permissions.',
+ },
+});
+
+// Permission-gated card — only instantiated when the user has
+// the catalog.entity.create permission.
+const RestrictedCard = PermissionExampleCardBlueprint.make({
+ name: 'restricted',
+ params: {
+ title: 'Restricted Card',
+ description:
+ 'This card is only visible to users who have the catalog.entity.create permission.',
+ },
+ if: { permissions: { $contains: 'catalog.entity.create' } },
+});
+
// Example: Page enabled only when the user is allowed to create catalog entities.
//
-// The `enabled` predicate is evaluated once at app startup (after sign-in),
+// The `if` predicate is evaluated once at app startup (after sign-in),
// so this page simply won't exist in the router tree if the user lacks the
// required permission.
const PermissionGatedPage = PageBlueprint.make({
@@ -338,7 +452,7 @@ const PermissionGatedPage = PageBlueprint.make({
return ;
},
},
- enabled: { permissions: { $contains: 'catalog.entity.create' } },
+ if: { permissions: { $contains: 'catalog.entity.create' } },
});
export const pagesPlugin = createFrontendPlugin({
@@ -373,6 +487,9 @@ export const pagesPlugin = createFrontendPlugin({
FeatureFlagPage,
AllFlagsPage,
AnyFlagPage,
+ PermissionCardPage,
+ PublicCard,
+ RestrictedCard,
PermissionGatedPage,
],
});
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index b188f94348..f5dea017a9 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -39,7 +39,6 @@
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
- "@backstage/plugin-permission-common": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"lodash": "^4.17.21",
@@ -49,6 +48,7 @@
"@backstage/cli": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-app": "workspace:^",
+ "@backstage/plugin-permission-common": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
index ac22c34696..f5fc9f2e3a 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts
@@ -1783,9 +1783,9 @@ describe('instantiateAppNodeTree', () => {
});
});
- describe('enabled predicate', () => {
+ describe('if predicate', () => {
function makeNodeWithEnabled(
- enabled: AppNodeSpec['enabled'],
+ enabled: AppNodeSpec['if'],
disabled = false,
): AppNode {
const ext = resolveExtensionDefinition(
@@ -1801,7 +1801,7 @@ describe('instantiateAppNodeTree', () => {
id: ext.id,
attachTo: ext.attachTo,
disabled,
- enabled,
+ if: enabled,
extension: ext as Extension,
plugin: createFrontendPlugin({ pluginId: 'app' }),
},
diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
index d60a910487..5c9062dd06 100644
--- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
+++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts
@@ -538,8 +538,8 @@ export function instantiateAppNodeTree(
}
if (
options?.predicateContext !== undefined &&
- node.spec.enabled !== undefined &&
- !evaluateFilterPredicate(node.spec.enabled, options.predicateContext)
+ node.spec.if !== undefined &&
+ !evaluateFilterPredicate(node.spec.if, options.predicateContext)
) {
return undefined;
}
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
index 279ae13af1..1dedac8e43 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts
@@ -509,15 +509,15 @@ describe('resolveAppNodeSpecs', () => {
]);
});
- it('should carry enabled predicate through to AppNodeSpec', () => {
+ it('should carry if predicate through to AppNodeSpec', () => {
const dataRef = createExtensionDataRef().with({ id: 'test.data' });
- const enabledPredicate = { featureFlags: { $contains: 'my-flag' } };
+ const ifPredicate = { featureFlags: { $contains: 'my-flag' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
extensions: [
createExtension({
attachTo: { id: 'app', input: 'root' },
- enabled: enabledPredicate,
+ if: ifPredicate,
output: [dataRef],
factory: () => [dataRef('value')],
}),
@@ -530,6 +530,6 @@ describe('resolveAppNodeSpecs', () => {
collector,
});
expect(specs).toHaveLength(1);
- expect(specs[0].enabled).toEqual(enabledPredicate);
+ expect(specs[0].if).toEqual(ifPredicate);
});
});
diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
index 005e6a05b9..874df7b8bd 100644
--- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
+++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts
@@ -116,9 +116,9 @@ export function resolveAppNodeSpecs(options: {
source: plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
- enabled:
+ if:
internalExtension.version === 'v2'
- ? internalExtension.enabled
+ ? internalExtension.if
: undefined,
config: undefined as unknown,
},
@@ -133,9 +133,9 @@ export function resolveAppNodeSpecs(options: {
plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
- enabled:
+ if:
internalExtension.version === 'v2'
- ? internalExtension.enabled
+ ? internalExtension.if
: undefined,
config: undefined as unknown,
},
@@ -156,10 +156,8 @@ export function resolveAppNodeSpecs(options: {
configuredExtensions[index].extension = internalExtension;
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
configuredExtensions[index].params.disabled = internalExtension.disabled;
- configuredExtensions[index].params.enabled =
- internalExtension.version === 'v2'
- ? internalExtension.enabled
- : undefined;
+ configuredExtensions[index].params.if =
+ internalExtension.version === 'v2' ? internalExtension.if : undefined;
} else {
// Add the extension as a new one when not overriding an existing one
configuredExtensions.push({
@@ -169,9 +167,9 @@ export function resolveAppNodeSpecs(options: {
source: extension.plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
- enabled:
+ if:
internalExtension.version === 'v2'
- ? internalExtension.enabled
+ ? internalExtension.if
: undefined,
config: undefined,
},
@@ -251,7 +249,7 @@ export function resolveAppNodeSpecs(options: {
attachTo: param.params.attachTo,
extension: param.extension,
disabled: param.params.disabled,
- enabled: param.params.enabled,
+ if: param.params.if,
plugin: param.params.plugin,
source: param.params.source,
config: param.params.config,
diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md
index bc620c95a3..72bd0d785e 100644
--- a/packages/frontend-defaults/report.api.md
+++ b/packages/frontend-defaults/report.api.md
@@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
-import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
+import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
@@ -23,7 +23,6 @@ export function createApp(options?: CreateAppOptions): {
// @public
export interface CreateAppOptions {
advanced?: {
- allowUnknownExtensionConfig?: boolean;
configLoader?: () => Promise<{
config: ConfigApi;
}>;
diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
index a3f1ae8ff3..dcadccb305 100644
--- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
+++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts
@@ -71,7 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly name?: string;
readonly attachTo: ExtensionDefinitionAttachTo;
readonly disabled: boolean;
- readonly enabled?: FilterPredicate;
+ readonly if?: FilterPredicate;
readonly configSchema?: PortableSchema;
readonly inputs: { [inputName in string]: ExtensionInput };
readonly output: Array;
diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
index c6c367e0b5..901216b554 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts
@@ -33,7 +33,7 @@ export interface AppNodeSpec {
readonly attachTo: ExtensionAttachTo;
readonly extension: Extension;
readonly disabled: boolean;
- readonly enabled?: FilterPredicate;
+ readonly if?: FilterPredicate;
readonly config?: unknown;
readonly plugin: FrontendPlugin;
}
diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts
index 10ca58d0da..6f3e87acfd 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtension.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts
@@ -175,7 +175,7 @@ export type CreateExtensionOptions<
attachTo: ExtensionDefinitionAttachTo &
VerifyExtensionAttachTo;
disabled?: boolean;
- enabled?: FilterPredicate;
+ if?: FilterPredicate;
inputs?: TInputs;
output: Array;
config?: {
@@ -257,7 +257,7 @@ export interface OverridableExtensionDefinition<
UParentInputs
>;
disabled?: boolean;
- enabled?: FilterPredicate;
+ if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -477,7 +477,7 @@ export function createExtension<
name: options.name,
attachTo: options.attachTo,
disabled: options.disabled ?? false,
- enabled: options.enabled,
+ if: options.if,
inputs: bindInputs(options.inputs, options.kind, options.name),
output: options.output,
configSchema,
@@ -555,7 +555,7 @@ export function createExtension<
attachTo: (overrideOptions.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: overrideOptions.disabled ?? options.disabled,
- enabled: overrideOptions.enabled ?? options.enabled,
+ if: overrideOptions.if ?? options.if,
inputs: bindInputs(
{
...(options.inputs ?? {}),
diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
index 538a47003b..2d2288b120 100644
--- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
+++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts
@@ -115,7 +115,7 @@ export type CreateExtensionBlueprintOptions<
attachTo: ExtensionDefinitionAttachTo &
VerifyExtensionAttachTo;
disabled?: boolean;
- enabled?: FilterPredicate;
+ if?: FilterPredicate;
inputs?: TInputs;
output: Array;
config?: {
@@ -223,7 +223,7 @@ export interface ExtensionBlueprint<
attachTo?: ExtensionDefinitionAttachTo &
VerifyExtensionAttachTo, UParentInputs>;
disabled?: boolean;
- enabled?: FilterPredicate;
+ if?: FilterPredicate;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
@@ -264,7 +264,7 @@ export interface ExtensionBlueprint<
UParentInputs
>;
disabled?: boolean;
- enabled?: FilterPredicate;
+ if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -514,7 +514,7 @@ export function createExtensionBlueprint<
attachTo: (args.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: args.disabled ?? options.disabled,
- enabled: args.enabled ?? options.enabled,
+ if: args.if ?? options.if,
inputs: options.inputs,
output: options.output as ExtensionDataRef[],
config: options.config,
@@ -532,7 +532,7 @@ export function createExtensionBlueprint<
attachTo: (args.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: args.disabled ?? options.disabled,
- enabled: args.enabled ?? options.enabled,
+ if: args.if ?? options.if,
inputs: { ...args.inputs, ...options.inputs },
output: (args.output ?? options.output) as ExtensionDataRef[],
config:
diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
index 9476c48256..a14c4e7863 100644
--- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
+++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts
@@ -75,7 +75,7 @@ export type InternalExtension = Extension<
}
| {
readonly version: 'v2';
- readonly enabled?: FilterPredicate;
+ readonly if?: FilterPredicate;
readonly inputs: { [inputName in string]: ExtensionInput };
readonly output: Array;
factory(options: {