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
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Extensions with an `enabled` predicate are now evaluated before app tree instantiation, so pages that do not meet their conditions are excluded from the router tree.
|
||||
|
||||
`prepareSpecializedApp().finalize()` is now async. Extensions whose `enabled` predicate references `permissions` are checked against the current user's allowed permissions (via a single batched call to `permissionApiRef`) before the app tree is instantiated.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-defaults': patch
|
||||
---
|
||||
|
||||
Updated `createApp` to await the now-async `finalize()` call from `prepareSpecializedApp`, enabling permission-gated extensions to be resolved before the app tree is rendered.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added `enabled` option to `createExtension`, `createExtensionBlueprint`, and `AppNodeSpec`, accepting a `FilterPredicate` from `@backstage/filter-predicates` to conditionally enable extensions based on feature flags or other runtime conditions.
|
||||
|
||||
Added `permissions` option to `createFrontendPlugin` and `createFrontendModule`, allowing plugins and modules to declare which permissions they use. These permissions are checked at app startup so that extensions can conditionally enable themselves using a `permissions` predicate, e.g. `enabled: { permissions: { $contains: 'catalog.entity.create' } }`.
|
||||
@@ -65,7 +65,8 @@ const IndexPage = PageBlueprint.make({
|
||||
const page1Link = useRouteRef(page1RouteRef);
|
||||
return (
|
||||
<div>
|
||||
op
|
||||
<h1>Example Pages Plugin</h1>
|
||||
<h2>Navigation</h2>
|
||||
{page1Link && (
|
||||
<div>
|
||||
<Link to={page1Link()}>Page 1</Link>
|
||||
@@ -83,6 +84,47 @@ const IndexPage = PageBlueprint.make({
|
||||
<div>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</div>
|
||||
|
||||
<h2>Permission Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>enabled</code> predicate using permissions. They
|
||||
will only appear when the user has the required permissions.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/permission-gated-example">
|
||||
Permission Gated Example
|
||||
</Link>{' '}
|
||||
— requires <code>catalog.entity.create</code>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Feature Flag Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>enabled</code> predicate. They will only appear in
|
||||
the router tree when their conditions are satisfied. Toggle the
|
||||
relevant feature flags in <Link to="/settings">Settings</Link>,
|
||||
then refresh the app to see the pages appear.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/feature-flag-example">Feature Flag Example</Link> —
|
||||
requires the <code>experimental-features</code> flag
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/all-flags-example">All Flags Example</Link> —
|
||||
requires <em>both</em> <code>experimental-features</code> and{' '}
|
||||
<code>advanced-features</code> (<code>$all</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/any-flag-example">Any Flag Example</Link> — requires{' '}
|
||||
<em>either</em> <code>experimental-features</code> or{' '}
|
||||
<code>beta-access</code> (<code>$any</code>)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<PluginInfo />
|
||||
</div>
|
||||
);
|
||||
@@ -150,6 +192,155 @@ const ExternalPage = PageBlueprint.make({
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page enabled only when a single feature flag is active.
|
||||
//
|
||||
// The `enabled` predicate is evaluated once at app startup (before the router
|
||||
// tree is built), so this page simply won't exist in the app until the flag is
|
||||
// toggled and the page is refreshed.
|
||||
//
|
||||
// To test: enable the 'experimental-features' flag in Settings, then refresh.
|
||||
const FeatureFlagPage = PageBlueprint.make({
|
||||
name: 'featureFlagExample',
|
||||
params: {
|
||||
path: '/feature-flag-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Feature Flag Enabled Page</h1>
|
||||
<p>
|
||||
This page is only present in the app when the{' '}
|
||||
<code>experimental-features</code> feature flag is active.
|
||||
</p>
|
||||
<p>
|
||||
It uses a simple{' '}
|
||||
<code>
|
||||
{'{ featureFlags: { $contains: "experimental-features" } }'}
|
||||
</code>{' '}
|
||||
predicate.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: { featureFlags: { $contains: 'experimental-features' } },
|
||||
});
|
||||
|
||||
// Example: Page enabled only when ALL of several feature flags are active.
|
||||
//
|
||||
// The $all operator requires every nested predicate to be satisfied. This page
|
||||
// won't appear unless both 'experimental-features' and 'advanced-features' are
|
||||
// enabled at the same time.
|
||||
//
|
||||
// To test: enable BOTH flags in Settings, then refresh.
|
||||
const AllFlagsPage = PageBlueprint.make({
|
||||
name: 'allFlagsExample',
|
||||
params: {
|
||||
path: '/all-flags-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>All Flags Required Page</h1>
|
||||
<p>
|
||||
This page requires <em>both</em>{' '}
|
||||
<code>experimental-features</code> and{' '}
|
||||
<code>advanced-features</code> to be active simultaneously.
|
||||
</p>
|
||||
<p>
|
||||
It uses a <code>$all</code> predicate to AND the two conditions
|
||||
together.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page enabled when ANY one of several feature flags is active.
|
||||
//
|
||||
// The $any operator is satisfied as soon as at least one nested predicate
|
||||
// matches. Enabling either 'experimental-features' or 'beta-access' will make
|
||||
// this page appear.
|
||||
//
|
||||
// To test: enable at least one of the two flags in Settings, then refresh.
|
||||
const AnyFlagPage = PageBlueprint.make({
|
||||
name: 'anyFlagExample',
|
||||
params: {
|
||||
path: '/any-flag-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Any Flag Sufficient Page</h1>
|
||||
<p>
|
||||
This page appears when <em>either</em>{' '}
|
||||
<code>experimental-features</code> or <code>beta-access</code> is
|
||||
active.
|
||||
</p>
|
||||
<p>
|
||||
It uses a <code>$any</code> predicate to OR the two conditions
|
||||
together.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page enabled only when the user is allowed to create catalog entities.
|
||||
//
|
||||
// The `enabled` predicate is evaluated once at app startup (after sign-in),
|
||||
// so this page simply won't exist in the router tree if the user lacks the
|
||||
// required permission.
|
||||
const PermissionGatedPage = PageBlueprint.make({
|
||||
name: 'permissionGatedExample',
|
||||
params: {
|
||||
path: '/permission-gated-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Permission Gated Page</h1>
|
||||
<p>
|
||||
This page is only present when the user has the{' '}
|
||||
<code>catalog.entity.create</code> permission.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
enabled: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
|
||||
export const pagesPlugin = createFrontendPlugin({
|
||||
pluginId: 'pages',
|
||||
// routes: {
|
||||
@@ -170,5 +361,18 @@ export const pagesPlugin = createFrontendPlugin({
|
||||
externalRoutes: {
|
||||
pageX: externalPageXRouteRef,
|
||||
},
|
||||
extensions: [IndexPage, Page1, ExternalPage],
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
{ name: 'beta-access' },
|
||||
],
|
||||
extensions: [
|
||||
IndexPage,
|
||||
Page1,
|
||||
ExternalPage,
|
||||
FeatureFlagPage,
|
||||
AllFlagsPage,
|
||||
AnyFlagPage,
|
||||
PermissionGatedPage,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ export function createApp(options?: CreateAppOptions): {
|
||||
// @public
|
||||
export interface CreateAppOptions {
|
||||
advanced?: {
|
||||
allowUnknownExtensionConfig?: boolean;
|
||||
configLoader?: () => Promise<{
|
||||
config: ConfigApi;
|
||||
}>;
|
||||
|
||||
@@ -156,22 +156,33 @@ function PreparedAppRoot(props: {
|
||||
}): JSX.Element {
|
||||
const signIn = props.preparedApp.getSignIn();
|
||||
const [finalizeError, setFinalizeError] = useState<Error>();
|
||||
const [finalizedApp, setFinalizedApp] = useState(() => {
|
||||
if (!signIn) {
|
||||
return props.preparedApp.finalize();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const [finalizedApp, setFinalizedApp] = useState<
|
||||
ReturnType<PreparedSpecializedApp['finalize']> | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const runFinalize = async () => {
|
||||
try {
|
||||
const predicateContext = await props.preparedApp.buildPredicateContext();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFinalizedApp(props.preparedApp.finalize(predicateContext));
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFinalizeError(error as Error);
|
||||
}
|
||||
};
|
||||
if (signIn) {
|
||||
void signIn.complete
|
||||
.then(async () => {
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFinalizedApp(props.preparedApp.finalize());
|
||||
void runFinalize();
|
||||
})
|
||||
.catch(error => {
|
||||
if (cancelled) {
|
||||
@@ -179,8 +190,9 @@ function PreparedAppRoot(props: {
|
||||
}
|
||||
setFinalizeError(error);
|
||||
});
|
||||
} else {
|
||||
void runFinalize();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -191,7 +203,7 @@ function PreparedAppRoot(props: {
|
||||
}
|
||||
|
||||
if (!finalizedApp) {
|
||||
return signIn!.element;
|
||||
return signIn?.element ?? <></>;
|
||||
}
|
||||
|
||||
return renderFinalizedApp(finalizedApp);
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
public: OverridableExtensionDefinition<ExtensionDefinitionParameters>;
|
||||
@@ -70,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
readonly name?: string;
|
||||
readonly attachTo: ExtensionDefinitionAttachTo;
|
||||
readonly disabled: boolean;
|
||||
readonly enabled?: FilterPredicate;
|
||||
readonly configSchema?: PortableSchema<any, any>;
|
||||
readonly inputs: { [inputName in string]: ExtensionInput };
|
||||
readonly output: Array<ExtensionDataRef>;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"zod": "^3.25.76",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-
|
||||
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
@@ -254,6 +255,8 @@ export interface AppNodeSpec {
|
||||
// (undocumented)
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
readonly enabled?: FilterPredicate;
|
||||
// (undocumented)
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
@@ -577,6 +580,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -663,6 +667,7 @@ export type CreateExtensionOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -1026,6 +1031,7 @@ export interface ExtensionBlueprint<
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
@@ -1061,6 +1067,7 @@ export interface ExtensionBlueprint<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -1702,6 +1709,7 @@ export interface OverridableExtensionDefinition<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { createApiRef } from '../system';
|
||||
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
|
||||
import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* The specification for this {@link AppNode} in the {@link AppTree}.
|
||||
@@ -32,6 +33,7 @@ export interface AppNodeSpec {
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
readonly disabled: boolean;
|
||||
readonly enabled?: FilterPredicate;
|
||||
readonly config?: unknown;
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from './createExtensionBlueprint';
|
||||
import { FrontendPlugin } from './createFrontendPlugin';
|
||||
import { FrontendModule } from './createFrontendModule';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* This symbol is used to pass parameter overrides from the extension override to the blueprint factory
|
||||
@@ -174,6 +175,7 @@ export type CreateExtensionOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -255,6 +257,7 @@ export interface OverridableExtensionDefinition<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -474,6 +477,7 @@ export function createExtension<
|
||||
name: options.name,
|
||||
attachTo: options.attachTo,
|
||||
disabled: options.disabled ?? false,
|
||||
enabled: options.enabled,
|
||||
inputs: bindInputs(options.inputs, options.kind, options.name),
|
||||
output: options.output,
|
||||
configSchema,
|
||||
@@ -551,6 +555,7 @@ export function createExtension<
|
||||
attachTo: (overrideOptions.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: overrideOptions.disabled ?? options.disabled,
|
||||
enabled: overrideOptions.enabled ?? options.enabled,
|
||||
inputs: bindInputs(
|
||||
{
|
||||
...(options.inputs ?? {}),
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from './resolveInputOverrides';
|
||||
import { ExtensionDataContainer } from './types';
|
||||
import { PageBlueprint } from '../blueprints/PageBlueprint';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* A function used to define a parameter mapping function in order to facilitate
|
||||
@@ -114,6 +115,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -221,6 +223,7 @@ export interface ExtensionBlueprint<
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
@@ -261,6 +264,7 @@ export interface ExtensionBlueprint<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
enabled?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -510,6 +514,7 @@ export function createExtensionBlueprint<
|
||||
attachTo: (args.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: args.disabled ?? options.disabled,
|
||||
enabled: args.enabled ?? options.enabled,
|
||||
inputs: options.inputs,
|
||||
output: options.output as ExtensionDataRef[],
|
||||
config: options.config,
|
||||
@@ -527,6 +532,7 @@ export function createExtensionBlueprint<
|
||||
attachTo: (args.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: args.disabled ?? options.disabled,
|
||||
enabled: args.enabled ?? options.enabled,
|
||||
inputs: { ...args.inputs, ...options.inputs },
|
||||
output: (args.output ?? options.output) as ExtensionDataRef[],
|
||||
config:
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
OpaqueExtensionDefinition,
|
||||
OpaqueExtensionInput,
|
||||
} from '@internal/frontend';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/** @public */
|
||||
export type ExtensionAttachTo = { id: string; input: string };
|
||||
@@ -74,6 +75,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
|
||||
}
|
||||
| {
|
||||
readonly version: 'v2';
|
||||
readonly enabled?: FilterPredicate;
|
||||
readonly inputs: { [inputName in string]: ExtensionInput };
|
||||
readonly output: Array<ExtensionDataRef>;
|
||||
factory(options: {
|
||||
|
||||
@@ -52,11 +52,18 @@ export class IdentityPermissionApi implements PermissionApi {
|
||||
|
||||
async authorize(
|
||||
request: AuthorizePermissionRequest,
|
||||
): Promise<AuthorizePermissionResponse> {
|
||||
const response = await this.permissionClient.authorize(
|
||||
[request],
|
||||
): Promise<AuthorizePermissionResponse>;
|
||||
async authorize(
|
||||
requests: AuthorizePermissionRequest[],
|
||||
): Promise<AuthorizePermissionResponse[]>;
|
||||
async authorize(
|
||||
request: AuthorizePermissionRequest | AuthorizePermissionRequest[],
|
||||
): Promise<AuthorizePermissionResponse | AuthorizePermissionResponse[]> {
|
||||
const requests = Array.isArray(request) ? request : [request];
|
||||
const responses = await this.permissionClient.authorize(
|
||||
requests,
|
||||
await this.identityApi.getCredentials(),
|
||||
);
|
||||
return response[0];
|
||||
return Array.isArray(request) ? responses : responses[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export type PermissionApi = {
|
||||
authorize(
|
||||
request: EvaluatePermissionRequest,
|
||||
): Promise<EvaluatePermissionResponse>;
|
||||
authorize(
|
||||
requests: EvaluatePermissionRequest[],
|
||||
): Promise<EvaluatePermissionResponse[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -154,7 +154,7 @@ export const createGitlabRepoPushAction: (options: {
|
||||
sourcePath?: string | undefined;
|
||||
targetPath?: string | undefined;
|
||||
token?: string | undefined;
|
||||
commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined;
|
||||
commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined;
|
||||
},
|
||||
{
|
||||
projectid: string;
|
||||
@@ -271,7 +271,7 @@ export const createPublishGitlabMergeRequestAction: (options: {
|
||||
sourcePath?: string | undefined;
|
||||
targetPath?: string | undefined;
|
||||
token?: string | undefined;
|
||||
commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined;
|
||||
commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined;
|
||||
projectid?: string | undefined;
|
||||
removeSourceBranch?: boolean | undefined;
|
||||
assignee?: string | undefined;
|
||||
|
||||
@@ -3628,10 +3628,12 @@ __metadata:
|
||||
"@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/frontend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-app": "workspace:^"
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
@@ -3752,6 +3754,7 @@ __metadata:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/filter-predicates": "workspace:^"
|
||||
"@backstage/frontend-app-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
@@ -9941,6 +9944,7 @@ __metadata:
|
||||
resolution: "@internal/frontend@workspace:packages/frontend-internal"
|
||||
dependencies:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/filter-predicates": "workspace:^"
|
||||
"@backstage/frontend-app-api": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user