diff --git a/.changeset/prepare-specialized-app-signin-flow.md b/.changeset/prepare-specialized-app-signin-flow.md index b8a7e65a06..59efcd8c34 100644 --- a/.changeset/prepare-specialized-app-signin-flow.md +++ b/.changeset/prepare-specialized-app-signin-flow.md @@ -1,6 +1,7 @@ --- '@backstage/frontend-app-api': patch '@backstage/frontend-defaults': patch +'@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 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. 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/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 1dedac8e43..8116999cbf 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -532,4 +532,88 @@ describe('resolveAppNodeSpecs', () => { expect(specs).toHaveLength(1); expect(specs[0].if).toEqual(ifPredicate); }); + + it('should apply plugin if predicates to all plugin extensions', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + if: pluginIf, + extensions: [ + createExtension({ + name: 'one', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('one')], + }), + createExtension({ + name: 'two', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('two')], + }), + ], + }); + + const specs = resolveAppNodeSpecs({ + features: [plugin], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(specs).toHaveLength(2); + expect(specs[0].if).toEqual(pluginIf); + expect(specs[1].if).toEqual(pluginIf); + }); + + it('should merge plugin and module if predicates with extension predicates', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + const pluginIf = { featureFlags: { $contains: 'plugin-flag' } }; + const moduleIf = { permissions: { $contains: 'module.permission' } }; + const extensionIf = { featureFlags: { $contains: 'extension-flag' } }; + const moduleExtensionIf = { featureFlags: { $contains: 'module-flag' } }; + const plugin = createFrontendPlugin({ + pluginId: 'test-plugin', + if: pluginIf, + extensions: [ + createExtension({ + name: 'plugin-extension', + attachTo: { id: 'app', input: 'root' }, + if: extensionIf, + output: [dataRef], + factory: () => [dataRef('plugin')], + }), + createExtension({ + name: 'module-extension', + attachTo: { id: 'app', input: 'root' }, + output: [dataRef], + factory: () => [dataRef('base')], + }), + ], + }); + const module = createFrontendModule({ + pluginId: 'test-plugin', + if: moduleIf, + extensions: [ + plugin.getExtension('test-plugin/module-extension').override({ + if: moduleExtensionIf, + factory: () => [dataRef('module')], + }), + ], + }); + + const specs = resolveAppNodeSpecs({ + features: [plugin, module], + builtinExtensions: [], + parameters: [], + collector, + }); + + expect(specs).toHaveLength(2); + expect(specs[0].id).toBe('test-plugin/plugin-extension'); + expect(specs[0].if).toEqual({ $all: [pluginIf, extensionIf] }); + expect(specs[1].id).toBe('test-plugin/module-extension'); + expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] }); + }); }); diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts index 874df7b8bd..318479d6b5 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.ts @@ -20,6 +20,7 @@ import { FrontendFeature, FrontendPlugin, } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { ExtensionParameters } from './readAppExtensionsConfig'; import { AppNodeSpec } from '@backstage/frontend-plugin-api'; import { OpaqueFrontendPlugin } from '@internal/frontend'; @@ -40,6 +41,33 @@ function normalizePlugin(plugin: FrontendPlugin): FrontendPlugin { return plugin; } +function combinePredicates( + left: FilterPredicate | undefined, + right: FilterPredicate | undefined, +) { + if (!left) { + return right; + } + if (!right) { + return left; + } + + return { $all: [left, right] }; +} + +function getExtensionPredicate(options: { + extension: Extension; + internalExtension: ReturnType; +}) { + if (options.extension.version === 'v2') { + return options.extension.if; + } + if (options.internalExtension.version === 'v2') { + return options.internalExtension.if; + } + return undefined; +} + /** @internal */ export function resolveAppNodeSpecs(options: { features?: FrontendFeature[]; @@ -79,26 +107,41 @@ export function resolveAppNodeSpecs(options: { }; const pluginExtensions = plugins.flatMap(plugin => { - return OpaqueFrontendPlugin.toInternal(plugin) - .extensions.map(extension => ({ + const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin); + return internalPlugin.extensions + .map(extension => ({ ...extension, plugin, + if: combinePredicates( + internalPlugin.if, + extension.version === 'v2' ? extension.if : undefined, + ), })) .filter(filterForbidden); }); - const moduleExtensions = modules.flatMap(mod => - toInternalFrontendModule(mod) - .extensions.flatMap(extension => { + const moduleExtensions = modules.flatMap(mod => { + const internalModule = toInternalFrontendModule(mod); + return internalModule.extensions + .flatMap(extension => { // Modules for plugins that are not installed are ignored const plugin = plugins.find(p => p.pluginId === mod.pluginId); if (!plugin) { return []; } - return [{ ...extension, plugin }]; + return [ + { + ...extension, + plugin, + if: combinePredicates( + internalModule.if, + extension.version === 'v2' ? extension.if : undefined, + ), + }, + ]; }) - .filter(filterForbidden), - ); + .filter(filterForbidden); + }); const appPlugin = plugins.find(plugin => plugin.pluginId === 'app') ?? @@ -116,10 +159,7 @@ export function resolveAppNodeSpecs(options: { source: plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: - internalExtension.version === 'v2' - ? internalExtension.if - : undefined, + if: getExtensionPredicate({ extension, internalExtension }), config: undefined as unknown, }, }; @@ -133,10 +173,7 @@ export function resolveAppNodeSpecs(options: { plugin: appPlugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: - internalExtension.version === 'v2' - ? internalExtension.if - : undefined, + if: getExtensionPredicate({ extension, internalExtension }), config: undefined as unknown, }, }; @@ -156,8 +193,10 @@ export function resolveAppNodeSpecs(options: { configuredExtensions[index].extension = internalExtension; configuredExtensions[index].params.attachTo = internalExtension.attachTo; configuredExtensions[index].params.disabled = internalExtension.disabled; - configuredExtensions[index].params.if = - internalExtension.version === 'v2' ? internalExtension.if : undefined; + configuredExtensions[index].params.if = getExtensionPredicate({ + extension, + internalExtension, + }); } else { // Add the extension as a new one when not overriding an existing one configuredExtensions.push({ @@ -167,10 +206,7 @@ export function resolveAppNodeSpecs(options: { source: extension.plugin, attachTo: internalExtension.attachTo, disabled: internalExtension.disabled, - if: - internalExtension.version === 'v2' - ? internalExtension.if - : undefined, + if: getExtensionPredicate({ extension, internalExtension }), config: undefined, }, }); diff --git a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts index 4564be2bb1..8d62e08627 100644 --- a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts +++ b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts @@ -17,6 +17,7 @@ import { Extension, FeatureFlagConfig, + FilterPredicate, IconElement, OverridableFrontendPlugin, } from '@backstage/frontend-plugin-api'; @@ -31,6 +32,7 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{ readonly icon?: IconElement; readonly extensions: Extension[]; readonly featureFlags: FeatureFlagConfig[]; + readonly if?: FilterPredicate; readonly infoOptions?: { packageJson?: () => Promise; manifest?: () => Promise; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 0af67779e7..c265dd41f9 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -255,12 +255,12 @@ export interface AppNodeSpec { // (undocumented) readonly disabled: boolean; // (undocumented) - readonly enabled?: FilterPredicate; - // (undocumented) readonly extension: Extension; // (undocumented) readonly id: string; // (undocumented) + readonly if?: FilterPredicate; + // (undocumented) readonly plugin: FrontendPlugin; } @@ -580,7 +580,7 @@ export type CreateExtensionBlueprintOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -667,7 +667,7 @@ export type CreateExtensionOptions< attachTo: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; inputs?: TInputs; output: Array; config?: { @@ -756,6 +756,8 @@ export interface CreateFrontendModuleOptions< // (undocumented) featureFlags?: FeatureFlagConfig[]; // (undocumented) + if?: FilterPredicate; + // (undocumented) pluginId: TPluginId; } @@ -770,45 +772,13 @@ export function createFrontendPlugin< [name in string]: ExternalRouteRef; } = {}, >( - options: CreateFrontendPluginOptions< - TId, - TRoutes, - TExternalRoutes, - TExtensions - >, + options: PluginOptions, ): OverridableFrontendPlugin< TRoutes, TExternalRoutes, MakeSortedExtensionsMap >; -// @public -export interface CreateFrontendPluginOptions< - TId extends string, - TRoutes extends { - [name in string]: RouteRef | SubRouteRef; - }, - TExternalRoutes extends { - [name in string]: ExternalRouteRef; - }, - TExtensions extends readonly ExtensionDefinition[], -> { - // (undocumented) - extensions?: TExtensions; - // (undocumented) - externalRoutes?: TExternalRoutes; - // (undocumented) - featureFlags?: FeatureFlagConfig[]; - icon?: IconElement; - // (undocumented) - info?: FrontendPluginInfoOptions; - // (undocumented) - pluginId: TId; - // (undocumented) - routes?: TRoutes; - title?: string; -} - // @public export function createRouteRef< TParams extends @@ -1031,7 +1001,7 @@ export interface ExtensionBlueprint< attachTo?: ExtensionDefinitionAttachTo & VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; - enabled?: FilterPredicate; + if?: FilterPredicate; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput : T['params'] extends ExtensionBlueprintDefineParams @@ -1067,7 +1037,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`; @@ -1709,7 +1679,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`; @@ -1815,6 +1785,7 @@ export interface OverridableFrontendPlugin< // (undocumented) withOverrides(options: { extensions?: Array; + if?: FilterPredicate; title?: string; icon?: IconElement; info?: FrontendPluginInfoOptions; @@ -1971,8 +1942,8 @@ export const pluginHeaderActionsApiRef: ApiRef_2< readonly $$type: '@backstage/ApiRef'; }; -// @public @deprecated (undocumented) -export type PluginOptions< +// @public (undocumented) +export interface PluginOptions< TId extends string, TRoutes extends { [name in string]: RouteRef | SubRouteRef; @@ -1981,7 +1952,24 @@ export type PluginOptions< [name in string]: ExternalRouteRef; }, TExtensions extends readonly ExtensionDefinition[], -> = CreateFrontendPluginOptions; +> { + // (undocumented) + extensions?: TExtensions; + // (undocumented) + externalRoutes?: TExternalRoutes; + // (undocumented) + featureFlags?: FeatureFlagConfig[]; + icon?: IconElement; + // (undocumented) + if?: FilterPredicate; + // (undocumented) + info?: FrontendPluginInfoOptions; + // (undocumented) + pluginId: TId; + // (undocumented) + routes?: TRoutes; + title?: string; +} // @public export type PluginWrapperApi = { @@ -2064,6 +2052,19 @@ export const Progress: { // @public (undocumented) export type ProgressProps = {}; +// @public +export type ResolvedExtensionInputs< + TInputs extends { + [name in string]: ExtensionInput; + }, +> = { + [InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton'] + ? Array>> + : false extends TInputs[InputName]['config']['optional'] + ? Expand> + : Expand | undefined>; +}; + // @public export type RouteFunc = ( ...input: TParams extends undefined ? readonly [] : readonly [params: TParams] diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts index d895f6de0f..6f7d8c12c8 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.test.ts @@ -46,6 +46,7 @@ describe('createFrontendModule', () => { "disabled": false, "factory": [Function], "id": "route:test/test", + "if": undefined, "inputs": {}, "output": [], "toString": [Function], @@ -53,6 +54,7 @@ describe('createFrontendModule', () => { }, ], "featureFlags": [], + "if": undefined, "pluginId": "test", "toString": [Function], "version": "v1", diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts index cea0760e68..1d77ce2167 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts @@ -21,6 +21,7 @@ import { resolveExtensionDefinition, } from './resolveExtensionDefinition'; import { FeatureFlagConfig } from './types'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** @public */ export interface CreateFrontendModuleOptions< @@ -30,6 +31,7 @@ export interface CreateFrontendModuleOptions< pluginId: TPluginId; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + if?: FilterPredicate; } /** @public */ @@ -43,6 +45,7 @@ export interface InternalFrontendModule extends FrontendModule { readonly version: 'v1'; readonly extensions: Extension[]; readonly featureFlags: FeatureFlagConfig[]; + readonly if?: FilterPredicate; } /** @@ -126,6 +129,7 @@ export function createFrontendModule< version: 'v1', pluginId, featureFlags: options.featureFlags ?? [], + if: options.if, extensions, toString() { return `Module{pluginId=${pluginId}}`; diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index 1e1f7e1a84..a6034db1e2 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -171,6 +171,7 @@ describe('createFrontendPlugin', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": undefined, "name": "1", @@ -359,6 +360,7 @@ describe('createFrontendPlugin', () => { "configSchema": undefined, "disabled": false, "factory": [Function], + "if": undefined, "inputs": {}, "kind": undefined, "name": "1", diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index f4e9a18990..18e4218dfc 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -32,6 +32,7 @@ import { JsonObject } from '@backstage/types'; import { IconElement } from '../icons/types'; import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; import { ID_PATTERN } from './constants'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * Information about the plugin. @@ -195,6 +196,7 @@ export interface CreateFrontendPluginOptions< externalRoutes?: TExternalRoutes; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + if?: FilterPredicate; info?: FrontendPluginInfoOptions; } @@ -304,6 +306,7 @@ export function createFrontendPlugin< routes: options.routes ?? ({} as TRoutes), externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes), featureFlags: options.featureFlags ?? [], + if: options.if, extensions: extensions, infoOptions: options.info,