feat: allow dynamically enabling and disabling extensions
Signed-off-by: aramissennyeydd <aramis.sennyey@doordash.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
00381fa7b2
commit
25b7ddd664
@@ -36,8 +36,10 @@
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@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",
|
||||
|
||||
@@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled predicate', () => {
|
||||
function makeNodeWithEnabled(
|
||||
enabled: AppNodeSpec['enabled'],
|
||||
disabled = false,
|
||||
): AppNode {
|
||||
const ext = resolveExtensionDefinition(
|
||||
createExtension({
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: [testDataRef],
|
||||
factory: () => [testDataRef('value')],
|
||||
}),
|
||||
{ namespace: 'test-ext' },
|
||||
);
|
||||
return {
|
||||
spec: {
|
||||
id: ext.id,
|
||||
attachTo: ext.attachTo,
|
||||
disabled,
|
||||
enabled,
|
||||
extension: ext as Extension<unknown, unknown>,
|
||||
plugin: createFrontendPlugin({ pluginId: 'app' }),
|
||||
},
|
||||
edges: { attachments: new Map() },
|
||||
};
|
||||
}
|
||||
|
||||
it('should skip a node when the predicate is not satisfied', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: [],
|
||||
});
|
||||
expect(tree.root.instance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should instantiate a node when the predicate is satisfied', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: ['the-flag'],
|
||||
});
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
expect(tree.root.instance?.getData(testDataRef)).toBe('value');
|
||||
});
|
||||
|
||||
it('should support $all operator across multiple flags', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'flag-a' } },
|
||||
{ featureFlags: { $contains: 'flag-b' } },
|
||||
],
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
|
||||
// Only one flag active — should not instantiate
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: ['flag-a'],
|
||||
});
|
||||
expect(tree.root.instance).toBeUndefined();
|
||||
|
||||
// Both flags active — should instantiate
|
||||
const tree2 = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree2.root, testApis, collector, undefined, {
|
||||
featureFlags: ['flag-a', 'flag-b'],
|
||||
});
|
||||
expect(tree2.root.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should instantiate nodes without an enabled field regardless of predicateContext', () => {
|
||||
const node = makeNodeWithEnabled(undefined);
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: [],
|
||||
});
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
// No predicateContext passed — predicate evaluation is skipped
|
||||
instantiateAppNodeTree(tree.root, testApis, collector);
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { createExtensionDataContainer } from '@internal/frontend';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
import { evaluateFilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
const INSTANTIATION_FAILED = new Error('Instantiation failed');
|
||||
|
||||
@@ -509,10 +510,25 @@ export function instantiateAppNodeTree(
|
||||
apis: ApiHolder,
|
||||
collector: ErrorCollector,
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
|
||||
options?: {
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
},
|
||||
optionsOrPredicateContext?:
|
||||
| {
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
}
|
||||
| Record<string, unknown>,
|
||||
): boolean {
|
||||
const options: {
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
} =
|
||||
optionsOrPredicateContext &&
|
||||
('stopAtAttachment' in optionsOrPredicateContext ||
|
||||
'predicateContext' in optionsOrPredicateContext)
|
||||
? optionsOrPredicateContext
|
||||
: {
|
||||
predicateContext: optionsOrPredicateContext,
|
||||
};
|
||||
|
||||
function createInstance(node: AppNode): AppNodeInstance | undefined {
|
||||
if (node.instance) {
|
||||
return node.instance;
|
||||
@@ -520,6 +536,13 @@ export function instantiateAppNodeTree(
|
||||
if (node.spec.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
options?.predicateContext !== undefined &&
|
||||
node.spec.enabled !== undefined &&
|
||||
!evaluateFilterPredicate(node.spec.enabled, options.predicateContext)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const instantiatedAttachments = new Map<string, AppNode[]>();
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createExtension,
|
||||
createExtensionDataRef,
|
||||
createFrontendModule,
|
||||
createFrontendPlugin,
|
||||
Extension,
|
||||
@@ -506,4 +508,28 @@ describe('resolveAppNodeSpecs', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry enabled predicate through to AppNodeSpec', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const enabledPredicate = { featureFlags: { $contains: 'my-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
enabled: enabledPredicate,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('value')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const specs = resolveAppNodeSpecs({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0].enabled).toEqual(enabledPredicate);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,10 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
enabled:
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.enabled
|
||||
: undefined,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
@@ -129,6 +133,10 @@ export function resolveAppNodeSpecs(options: {
|
||||
plugin: appPlugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
enabled:
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.enabled
|
||||
: undefined,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
@@ -148,6 +156,10 @@ 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;
|
||||
} else {
|
||||
// Add the extension as a new one when not overriding an existing one
|
||||
configuredExtensions.push({
|
||||
@@ -157,6 +169,10 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: extension.plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
enabled:
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.enabled
|
||||
: undefined,
|
||||
config: undefined,
|
||||
},
|
||||
});
|
||||
@@ -235,6 +251,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
attachTo: param.params.attachTo,
|
||||
extension: param.extension,
|
||||
disabled: param.params.disabled,
|
||||
enabled: param.params.enabled,
|
||||
plugin: param.params.plugin,
|
||||
source: param.params.source,
|
||||
config: param.params.config,
|
||||
|
||||
Reference in New Issue
Block a user