From d65d4812f64f07eab054b718ee73989cc924926d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Mar 2026 12:33:19 +0100 Subject: [PATCH] frontend-plugin-api: support predicate overrides Allow plugin and extension overrides to replace or remove existing if predicates. This makes conditional plugin and extension behavior overrideable through both plugin overrides and module-installed extension overrides. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .../prepare-specialized-app-signin-flow.md | 2 +- .../architecture/25-extension-overrides.md | 2 + .../src/tree/resolveAppNodeSpecs.test.ts | 92 +++++++++++++++++++ .../src/wiring/createExtension.ts | 7 +- .../src/wiring/createFrontendPlugin.ts | 10 ++ 5 files changed, 111 insertions(+), 2 deletions(-) diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index 59efcd8c34..422c35ae8b 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -4,4 +4,4 @@ '@backstage/frontend-plugin-api': patch --- -Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The `if` option is also now supported on `createFrontendPlugin` and `createFrontendModule`, and is applied to each extension from that feature together with any extension-level predicate. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. +Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a bootstrap tree before full app finalization. The bootstrap phase is exposed as a partial app tree through `getBootstrapApp()`, while `onFinalized()` provides a one-way handoff to the finalized app once bootstrap completes. The opaque reusable `sessionState` is returned from the finalized app and can be passed into a future `prepareSpecializedApp` call to skip sign-in and reuse the prepared session. Conditional `app/root.elements` and predicate-gated APIs are now deferred until finalization, while unsupported bootstrap-visible predicates are downgraded to warnings and bootstrap extensions now surface warnings if they accessed APIs that only became available after finalization. The `if` option is also now supported on `createFrontendPlugin` and `createFrontendModule`, and is applied to each extension from that feature together with any extension-level predicate. Plugin and extension overrides can now also replace or remove existing `if` predicates. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow. diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md index b3a7759867..7afbd2bacb 100644 --- a/docs/frontend-system/architecture/25-extension-overrides.md +++ b/docs/frontend-system/architecture/25-extension-overrides.md @@ -11,6 +11,8 @@ An important customization point in the frontend system is the ability to overri InĀ general, most features should have a good level of customization built into them, so that users do not have to leverage extension overrides to achieve common goals. A well written feature often has [configuration](../../conf/) settings, or uses extension inputs for extensibility where applicable. An example of this is the search plugin, which allows you to provide result renderers as inputs rather than replacing the result page wholesale just to tweak how results are shown. Adopters should take advantage of those when possible in order to reduce the need and size of extension overrides. +Extension overrides can also replace or remove existing `if` predicates. This applies both to direct extension overrides through `.override(...)` and to plugin-level overrides through `plugin.withOverrides(...)`. Frontend modules can use the same extension override mechanism to adjust or clear the condition for an overridden extension. + ## Overriding an extension Every extension created with `createExtension` comes with an `override` method, including those created from an [extension blueprint](./23-extension-blueprints.md). The `override` method **creates a new extension**, it does not mutate the existing extension. This new extension in created in such a way that if it is installed adjacent to the existing extension, it will take precedence and override the existing extension. While the `override` method does create new extension instances, it is not intended to be used as a way to create multiple new extensions from a base template, for that use-case you will want to use an [extension blueprint](./23-extension-blueprints.md) instead. diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 8116999cbf..ec56394b39 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -567,6 +567,42 @@ describe('resolveAppNodeSpecs', () => { expect(specs[1].if).toEqual(pluginIf); }); + it('should allow plugin overrides to replace or remove plugin if predicates', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; + const overrideIf = { permissions: { $contains: 'override.permission' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + if: pluginIf, + extensions: [ + createExtension({ + name: 'one', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('one')], + }), + ], + }); + + const overriddenSpecs = resolveAppNodeSpecs({ + features: [plugin.withOverrides({ if: overrideIf })], + builtinExtensions: [], + parameters: [], + collector, + }); + const clearedSpecs = resolveAppNodeSpecs({ + features: [plugin.withOverrides({ if: undefined })], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(overriddenSpecs).toHaveLength(1); + expect(overriddenSpecs[0].if).toEqual(overrideIf); + expect(clearedSpecs).toHaveLength(1); + expect(clearedSpecs[0].if).toBeUndefined(); + }); + it('should merge plugin and module if predicates with extension predicates', () => { const dataRef = createExtensionDataRef().with({ id: 'test.data' }); const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; @@ -616,4 +652,60 @@ describe('resolveAppNodeSpecs', () => { expect(specs[1].id).toBe('test-plugin/module-extension'); expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] }); }); + + it('should allow module extension overrides to replace or remove extension if predicates', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const extensionIf = { featureFlags: { $contains: 'extension-flag' } }; + const overrideIf = { permissions: { $contains: 'override.permission' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + extensions: [ + createExtension({ + name: 'extension', + attachTo: { id: 'app', input: 'root' }, + if: extensionIf, + output: [dataRef], + factory: () => [dataRef('base')], + }), + ], + }); + + const overriddenSpecs = resolveAppNodeSpecs({ + features: [ + plugin, + createFrontendModule({ + pluginId: 'test-plugin', + extensions: [ + plugin.getExtension('test-plugin/extension').override({ + if: overrideIf, + }), + ], + }), + ], + builtinExtensions: [], + parameters: [], + collector, + }); + const clearedSpecs = resolveAppNodeSpecs({ + features: [ + plugin, + createFrontendModule({ + pluginId: 'test-plugin', + extensions: [ + plugin.getExtension('test-plugin/extension').override({ + if: undefined, + }), + ], + }), + ], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(overriddenSpecs).toHaveLength(1); + expect(overriddenSpecs[0].if).toEqual(overrideIf); + expect(clearedSpecs).toHaveLength(1); + expect(clearedSpecs[0].if).toBeUndefined(); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 6f3e87acfd..8632b3caeb 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -549,13 +549,18 @@ export function createExtension< ); } + let ifPredicate = options.if; + if ('if' in overrideOptions) { + ifPredicate = overrideOptions.if; + } + return createExtension({ kind: options.kind, name: options.name, attachTo: (overrideOptions.attachTo ?? options.attachTo) as ExtensionDefinitionAttachTo, disabled: overrideOptions.disabled ?? options.disabled, - if: overrideOptions.if ?? options.if, + if: ifPredicate, inputs: bindInputs( { ...(options.inputs ?? {}), diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 18e4218dfc..9e9c58c4b6 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -116,6 +116,11 @@ export interface OverridableFrontendPlugin< withOverrides(options: { extensions?: Array; + /** + * Overrides the shared condition that applies to all extensions in the plugin. + */ + if?: FilterPredicate; + /** * Overrides the display title of the plugin. */ @@ -329,6 +334,10 @@ export function createFrontendPlugin< return `Plugin{id=${pluginId}}`; }, withOverrides(overrides) { + let ifPredicate = options.if; + if ('if' in overrides) { + ifPredicate = overrides.if; + } const overrideExtensions = overrides.extensions ?? []; const overriddenExtensionIds = new Set( overrideExtensions.map( @@ -344,6 +353,7 @@ export function createFrontendPlugin< return createFrontendPlugin({ ...options, pluginId, + if: ifPredicate, title: overrides.title ?? options.title, icon: overrides.icon ?? options.icon, extensions: [...nonOverriddenExtensions, ...overrideExtensions],