From ce97558e11e94306b6d900083d009e34bfc4ce3b Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Wed, 11 Mar 2026 09:24:26 -0400 Subject: [PATCH] rename to if and add examples of dynamic cards Signed-off-by: aramissennyeydd --- packages/app/src/examples/pagesPlugin.tsx | 137 ++++++++++++++++-- packages/frontend-app-api/package.json | 2 +- .../src/tree/instantiateAppNodeTree.test.ts | 6 +- .../src/tree/instantiateAppNodeTree.ts | 4 +- .../src/tree/resolveAppNodeSpecs.test.ts | 8 +- .../src/tree/resolveAppNodeSpecs.ts | 20 ++- packages/frontend-defaults/report.api.md | 3 +- .../src/wiring/InternalExtensionDefinition.ts | 2 +- .../src/apis/definitions/AppTreeApi.ts | 2 +- .../src/wiring/createExtension.ts | 8 +- .../src/wiring/createExtensionBlueprint.ts | 10 +- .../src/wiring/resolveExtensionDefinition.ts | 2 +- 12 files changed, 159 insertions(+), 45 deletions(-) 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.

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: {