frontend-plugin-api: support feature-level predicates
This lets plugin and module instances apply a shared condition to all of their extensions, while preserving extension-level conditions by combining them with logical AND during app spec resolution. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string>().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<string>().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] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<any, any>;
|
||||
internalExtension: ReturnType<typeof toInternalExtension>;
|
||||
}) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<unknown>[];
|
||||
readonly featureFlags: FeatureFlagConfig[];
|
||||
readonly if?: FilterPredicate;
|
||||
readonly infoOptions?: {
|
||||
packageJson?: () => Promise<JsonObject>;
|
||||
manifest?: () => Promise<JsonObject>;
|
||||
|
||||
@@ -255,12 +255,12 @@ export interface AppNodeSpec {
|
||||
// (undocumented)
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
readonly enabled?: FilterPredicate;
|
||||
// (undocumented)
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
// (undocumented)
|
||||
readonly if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -667,7 +667,7 @@ export type CreateExtensionOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
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<TId, TRoutes, TExternalRoutes, TExtensions>,
|
||||
): OverridableFrontendPlugin<
|
||||
TRoutes,
|
||||
TExternalRoutes,
|
||||
MakeSortedExtensionsMap<TExtensions[number], TId>
|
||||
>;
|
||||
|
||||
// @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<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, 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<ExtensionDefinition>;
|
||||
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<TId, TRoutes, TExternalRoutes, TExtensions>;
|
||||
> {
|
||||
// (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<Expand<ResolvedExtensionInput<TInputs[InputName]>>>
|
||||
: false extends TInputs[InputName]['config']['optional']
|
||||
? Expand<ResolvedExtensionInput<TInputs[InputName]>>
|
||||
: Expand<ResolvedExtensionInput<TInputs[InputName]> | undefined>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type RouteFunc<TParams extends AnyRouteRefParams> = (
|
||||
...input: TParams extends undefined ? readonly [] : readonly [params: TParams]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<unknown>[];
|
||||
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}}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user