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:
Patrik Oldsberg
2026-03-14 12:00:55 +01:00
parent 457904d372
commit f30eeba995
9 changed files with 201 additions and 66 deletions
@@ -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,
},
});