frontend-plugin-api: runtime attachTo by reference
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -390,3 +390,52 @@ const childExtension = createExtension({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Direct extension input references
|
||||
|
||||
Building on the relative attachment point concept, you can also reference extension inputs directly via the `inputs` property of an extension definition. This provides a more convenient and type-safe way to attach child extensions, especially when working with blueprints within a plugin.
|
||||
|
||||
Each extension definition exposes an `inputs` property that contains references to all of its defined inputs. These references can be passed directly to the `attachTo` option when creating child extensions:
|
||||
|
||||
```tsx
|
||||
// Create a parent extension using a blueprint
|
||||
const page = PageBlueprint.make({
|
||||
params: {
|
||||
path: '/example',
|
||||
loader: () => import('./ExamplePage'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create a child extension that attaches to the parent's input
|
||||
const widget = CardBlueprint.make({
|
||||
attachTo: page.inputs.children, // Direct reference to the input
|
||||
params: {
|
||||
title: 'Example Widget',
|
||||
content: <div>Widget Content</div>,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This approach is functionally equivalent to using relative attachment points, but offers several advantages:
|
||||
|
||||
- **Type safety**: The TypeScript compiler will verify that the referenced input exists and is spelled correctly
|
||||
- **IDE support**: Your editor can provide autocomplete suggestions for available inputs
|
||||
- **Clearer intent**: The code explicitly shows the relationship between parent and child extensions
|
||||
|
||||
The direct input reference syntax works with both single attachment points and arrays of attachment points, and can be mixed with other attachment point formats:
|
||||
|
||||
```tsx
|
||||
// Attach to multiple parents using a mix of styles
|
||||
const multiAttachChild = ExampleBlueprint.make({
|
||||
attachTo: [
|
||||
page.inputs.children, // Direct reference
|
||||
{ relative: { kind: 'sidebar' }, input: 'items' }, // Relative
|
||||
{ id: 'app/nav', input: 'items' }, // Absolute ID
|
||||
],
|
||||
params: {
|
||||
/* ... */
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Under the hood, input references are resolved in the same way as relative attachment points, using the extension's kind, namespace, and name to construct the final attachment target.
|
||||
|
||||
@@ -61,6 +61,7 @@ function makeExtDef(
|
||||
name,
|
||||
attachTo: { id: attachId, input: 'default' },
|
||||
disabled: status === 'disabled',
|
||||
inputs: {},
|
||||
override: () => ({} as ExtensionDefinition),
|
||||
} as ExtensionDefinition;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ExtensionInput } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
export type ExtensionInputContext = {
|
||||
input: string;
|
||||
kind?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const OpaqueExtensionInput = OpaqueType.create<{
|
||||
public: ExtensionInput;
|
||||
versions: {
|
||||
readonly version: undefined;
|
||||
readonly context?: ExtensionInputContext;
|
||||
withContext(context: ExtensionInputContext): ExtensionInput;
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/ExtensionInput',
|
||||
versions: [undefined],
|
||||
});
|
||||
@@ -15,6 +15,10 @@
|
||||
*/
|
||||
|
||||
export { createExtensionDataContainer } from './createExtensionDataContainer';
|
||||
export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef';
|
||||
export { OpaqueExtensionDefinition } from './InternalExtensionDefinition';
|
||||
export {
|
||||
OpaqueExtensionInput,
|
||||
type ExtensionInputContext,
|
||||
} from './InternalExtensionInput';
|
||||
export { OpaqueFrontendPlugin } from './InternalFrontendPlugin';
|
||||
export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef';
|
||||
|
||||
@@ -8,7 +8,6 @@ import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { AlertMessage } from '@backstage/core-plugin-api';
|
||||
import { AnyApiFactory } from '@backstage/core-plugin-api';
|
||||
import { AnyApiRef } from '@backstage/core-plugin-api';
|
||||
import { AnyRouteRefParams as AnyRouteRefParams_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ApiFactory } from '@backstage/core-plugin-api';
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
@@ -385,7 +384,7 @@ export const coreExtensionData: {
|
||||
>;
|
||||
routePath: ConfigurableExtensionDataRef<string, 'core.routing.path', {}>;
|
||||
routeRef: ConfigurableExtensionDataRef<
|
||||
RouteRef<AnyRouteRefParams_2>,
|
||||
RouteRef<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{}
|
||||
>;
|
||||
@@ -1076,6 +1075,9 @@ export type ExtensionDefinition<
|
||||
> = {
|
||||
$$type: '@backstage/ExtensionDefinition';
|
||||
readonly T: T;
|
||||
readonly inputs: {
|
||||
[K in keyof T['inputs']]: ExtensionInputRef;
|
||||
};
|
||||
override<
|
||||
TExtensionConfigSchema extends {
|
||||
[key in string]: (zImpl: typeof z) => z.ZodType;
|
||||
@@ -1186,6 +1188,7 @@ export type ExtensionDefinitionAttachTo =
|
||||
input: string;
|
||||
id?: never;
|
||||
}
|
||||
| ExtensionInputRef
|
||||
| Array<
|
||||
| {
|
||||
id: string;
|
||||
@@ -1200,6 +1203,7 @@ export type ExtensionDefinitionAttachTo =
|
||||
input: string;
|
||||
id?: never;
|
||||
}
|
||||
| ExtensionInputRef
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -1261,6 +1265,18 @@ export interface ExtensionInput<
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ExtensionInputRef {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/ExtensionInputRef';
|
||||
// (undocumented)
|
||||
input: string;
|
||||
// (undocumented)
|
||||
kind?: string;
|
||||
// (undocumented)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ExternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
|
||||
@@ -216,7 +216,7 @@ describe('createExtension', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an extension with a relative attachment point', () => {
|
||||
it('should create an extension with relative attachment points', () => {
|
||||
const extension = createExtension({
|
||||
attachTo: [
|
||||
{ relative: {}, input: 'tabs' },
|
||||
@@ -232,6 +232,62 @@ describe('createExtension', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an extension with relative attachment points by reference', () => {
|
||||
const baseOpts = {
|
||||
attachTo: { id: 'root', input: 'children' },
|
||||
inputs: {
|
||||
tabs: createExtensionInput([stringDataRef]),
|
||||
},
|
||||
output: [],
|
||||
factory: () => [],
|
||||
};
|
||||
const parent1 = createExtension({
|
||||
...baseOpts,
|
||||
});
|
||||
const parent2 = createExtension({
|
||||
...baseOpts,
|
||||
kind: 'page',
|
||||
});
|
||||
const parent3 = createExtension({
|
||||
...baseOpts,
|
||||
name: 'index',
|
||||
});
|
||||
const parent4 = createExtension({
|
||||
...baseOpts,
|
||||
inputs: {},
|
||||
kind: 'page',
|
||||
name: 'index',
|
||||
}).override({
|
||||
inputs: {
|
||||
otherTabs: createExtensionInput([stringDataRef]),
|
||||
},
|
||||
factory: () => [],
|
||||
});
|
||||
const extension = createExtension({
|
||||
attachTo: [
|
||||
parent1.inputs.tabs,
|
||||
parent2.inputs.tabs,
|
||||
parent3.inputs.tabs,
|
||||
parent4.inputs.otherTabs,
|
||||
],
|
||||
output: [stringDataRef],
|
||||
factory: () => [stringDataRef('bar')],
|
||||
});
|
||||
expect(String(extension)).toBe(
|
||||
'ExtensionDefinition{attachTo=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
|
||||
);
|
||||
const overrdeExtension = extension.override({
|
||||
attachTo: [
|
||||
parent2.inputs.tabs,
|
||||
parent3.inputs.tabs,
|
||||
parent4.inputs.otherTabs,
|
||||
],
|
||||
});
|
||||
expect(String(overrdeExtension)).toBe(
|
||||
'ExtensionDefinition{attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an extension with input', () => {
|
||||
const extension = createExtension({
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
ResolvedInputValueOverrides,
|
||||
resolveInputOverrides,
|
||||
} from './resolveInputOverrides';
|
||||
import { createExtensionDataContainer } from '@internal/frontend';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueExtensionInput,
|
||||
} from '@internal/frontend';
|
||||
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
|
||||
import { ExtensionInput } from './createExtensionInput';
|
||||
import { z } from 'zod';
|
||||
@@ -119,10 +122,11 @@ export type VerifyExtensionFactoryOutput<
|
||||
*
|
||||
* A standard attachment point declaration will specify the ID of the parent extension, as well as the name of the input to attach to.
|
||||
*
|
||||
* There are two more advanced forms that are available for more complex use-cases:
|
||||
* There are three more advanced forms that are available for more complex use-cases:
|
||||
*
|
||||
* 1. Relative attachment points: using the `relative` property instead of `id`, the attachment point is resolved relative to the current plugin.
|
||||
* 2. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once.
|
||||
* 2. Extension input references: using a reference obtained from another extension's `inputs` property.
|
||||
* 3. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
@@ -132,6 +136,10 @@ export type VerifyExtensionFactoryOutput<
|
||||
* // Attach to an extension in the same plugin by kind
|
||||
* { relative: { kind: 'page' }, input: 'actions' }
|
||||
*
|
||||
* // Attach to a specific input of another extension
|
||||
* const page = ParentBlueprint.make({ ... });
|
||||
* const child = ChildBlueprint.make({ attachTo: page.inputs.children });
|
||||
*
|
||||
* // Attach to multiple parents at once
|
||||
* [
|
||||
* { id: 'page/home', input: 'widgets' },
|
||||
@@ -144,6 +152,7 @@ export type VerifyExtensionFactoryOutput<
|
||||
export type ExtensionDefinitionAttachTo =
|
||||
| { id: string; input: string; relative?: never }
|
||||
| { relative: { kind?: string; name?: string }; input: string; id?: never }
|
||||
| ExtensionInput
|
||||
| Array<
|
||||
| { id: string; input: string; relative?: never }
|
||||
| {
|
||||
@@ -151,6 +160,7 @@ export type ExtensionDefinitionAttachTo =
|
||||
input: string;
|
||||
id?: never;
|
||||
}
|
||||
| ExtensionInput
|
||||
>;
|
||||
|
||||
/** @public */
|
||||
@@ -213,6 +223,13 @@ export type ExtensionDefinition<
|
||||
$$type: '@backstage/ExtensionDefinition';
|
||||
readonly T: T;
|
||||
|
||||
/**
|
||||
* References to the inputs of this extension, which can be used to attach child extensions.
|
||||
*/
|
||||
readonly inputs: {
|
||||
[K in keyof T['inputs']]: ExtensionInput;
|
||||
};
|
||||
|
||||
override<
|
||||
TExtensionConfigSchema extends {
|
||||
[key in string]: (zImpl: typeof z) => z.ZodType;
|
||||
@@ -306,6 +323,30 @@ export type ExtensionDefinition<
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
function bindInputs(
|
||||
inputs: { [inputName in string]: ExtensionInput } | undefined,
|
||||
kind?: string,
|
||||
name?: string,
|
||||
) {
|
||||
if (!inputs) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(inputs).map(([inputName, input]) => [
|
||||
inputName,
|
||||
OpaqueExtensionInput.toInternal(input).withContext({
|
||||
kind,
|
||||
name,
|
||||
input: inputName,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new extension definition for installation in a Backstage app.
|
||||
*
|
||||
@@ -419,7 +460,7 @@ export function createExtension<
|
||||
name: options.name,
|
||||
attachTo: options.attachTo,
|
||||
disabled: options.disabled ?? false,
|
||||
inputs: options.inputs ?? {},
|
||||
inputs: bindInputs(options.inputs, options.kind, options.name),
|
||||
output: options.output,
|
||||
configSchema,
|
||||
factory: options.factory,
|
||||
@@ -434,6 +475,20 @@ export function createExtension<
|
||||
const attachTo = [options.attachTo]
|
||||
.flat()
|
||||
.map(a => {
|
||||
if (OpaqueExtensionInput.isType(a)) {
|
||||
const { context } = OpaqueExtensionInput.toInternal(a);
|
||||
if (!context) {
|
||||
return '<detached-input>';
|
||||
}
|
||||
let id = '<plugin>';
|
||||
if (context?.kind) {
|
||||
id = `${context?.kind}:${id}`;
|
||||
}
|
||||
if (context?.name) {
|
||||
id = `${id}/${context?.name}`;
|
||||
}
|
||||
return `${id}@${context.input}`;
|
||||
}
|
||||
if ('relative' in a && a.relative) {
|
||||
let id = '<plugin>';
|
||||
if (a.relative.kind) {
|
||||
@@ -444,7 +499,10 @@ export function createExtension<
|
||||
}
|
||||
return `${id}@${a.input}`;
|
||||
}
|
||||
return `${a.id}@${a.input}`;
|
||||
if ('id' in a) {
|
||||
return `${a.id}@${a.input}`;
|
||||
}
|
||||
throw new Error('Invalid attachment point specification');
|
||||
})
|
||||
.join('+');
|
||||
parts.push(`attachTo=${attachTo}`);
|
||||
@@ -477,7 +535,14 @@ export function createExtension<
|
||||
name: options.name,
|
||||
attachTo: overrideOptions.attachTo ?? options.attachTo,
|
||||
disabled: overrideOptions.disabled ?? options.disabled,
|
||||
inputs: { ...overrideOptions.inputs, ...options.inputs },
|
||||
inputs: bindInputs(
|
||||
{
|
||||
...(options.inputs ?? {}),
|
||||
...(overrideOptions.inputs ?? {}),
|
||||
},
|
||||
options.kind,
|
||||
options.name,
|
||||
),
|
||||
output: (overrideOptions.output ??
|
||||
options.output) as ExtensionDataRef[],
|
||||
config:
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from './createExtensionDataRef';
|
||||
import { createExtensionInput } from './createExtensionInput';
|
||||
import { RouteRef } from '../routing';
|
||||
import { ExtensionDefinition } from './createExtension';
|
||||
import { createExtension, ExtensionDefinition } from './createExtension';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueExtensionDefinition,
|
||||
@@ -1345,6 +1345,126 @@ describe('createExtensionBlueprint', () => {
|
||||
).toThrow('Refused to override params and factory at the same time');
|
||||
});
|
||||
|
||||
describe('with relative attachment points', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
|
||||
it('should create an extension with relative attachment points', () => {
|
||||
const blueprint = createExtensionBlueprint({
|
||||
kind: 'test',
|
||||
attachTo: [
|
||||
{ relative: {}, input: 'tabs' },
|
||||
{ relative: { kind: 'page' }, input: 'tabs' },
|
||||
{ relative: { name: 'index' }, input: 'tabs' },
|
||||
{ relative: { kind: 'page', name: 'index' }, input: 'tabs' },
|
||||
],
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('bar')],
|
||||
});
|
||||
|
||||
expect(String(blueprint.make({ params: {} }))).toBe(
|
||||
'ExtensionDefinition{kind=test,attachTo=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@tabs}',
|
||||
);
|
||||
expect(
|
||||
String(
|
||||
blueprint.make({
|
||||
attachTo: [
|
||||
{ relative: { kind: 'page' }, input: 'tabs' },
|
||||
{ relative: { name: 'index' }, input: 'tabs' },
|
||||
{ relative: { kind: 'page', name: 'index' }, input: 'tabs' },
|
||||
],
|
||||
params: {},
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
'ExtensionDefinition{kind=test,attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@tabs}',
|
||||
);
|
||||
expect(
|
||||
String(
|
||||
blueprint.makeWithOverrides({
|
||||
attachTo: {
|
||||
relative: { kind: 'page', name: 'index' },
|
||||
input: 'tabs',
|
||||
},
|
||||
factory: orig => orig({}),
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
'ExtensionDefinition{kind=test,attachTo=page:<plugin>/index@tabs}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an extension with relative attachment points by reference', () => {
|
||||
const baseOpts = {
|
||||
attachTo: { id: 'root', input: 'children' },
|
||||
inputs: {
|
||||
tabs: createExtensionInput([dataRef]),
|
||||
},
|
||||
output: [],
|
||||
factory: () => [],
|
||||
};
|
||||
const parent1 = createExtension({
|
||||
...baseOpts,
|
||||
});
|
||||
const parent2 = createExtension({
|
||||
...baseOpts,
|
||||
kind: 'page',
|
||||
});
|
||||
const parent3 = createExtension({
|
||||
...baseOpts,
|
||||
name: 'index',
|
||||
});
|
||||
const parent4 = createExtension({
|
||||
...baseOpts,
|
||||
inputs: {},
|
||||
kind: 'page',
|
||||
name: 'index',
|
||||
}).override({
|
||||
inputs: {
|
||||
otherTabs: createExtensionInput([dataRef]),
|
||||
},
|
||||
factory: () => [],
|
||||
});
|
||||
const blueprint = createExtensionBlueprint({
|
||||
kind: 'test',
|
||||
attachTo: [
|
||||
parent1.inputs.tabs,
|
||||
parent2.inputs.tabs,
|
||||
parent3.inputs.tabs,
|
||||
parent4.inputs.otherTabs,
|
||||
],
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('bar')],
|
||||
});
|
||||
expect(String(blueprint.make({ params: {} }))).toBe(
|
||||
'ExtensionDefinition{kind=test,attachTo=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
|
||||
);
|
||||
expect(
|
||||
String(
|
||||
blueprint.make({
|
||||
attachTo: [
|
||||
parent2.inputs.tabs,
|
||||
parent3.inputs.tabs,
|
||||
parent4.inputs.otherTabs,
|
||||
],
|
||||
params: {},
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
'ExtensionDefinition{kind=test,attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
|
||||
);
|
||||
expect(
|
||||
String(
|
||||
blueprint.makeWithOverrides({
|
||||
attachTo: parent4.inputs.otherTabs,
|
||||
factory: orig => orig({}),
|
||||
}),
|
||||
),
|
||||
).toBe(
|
||||
'ExtensionDefinition{kind=test,attachTo=page:<plugin>/index@otherTabs}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with advanced parameter types', () => {
|
||||
const testDataRef = createExtensionDataRef<string>().with({ id: 'test' });
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ExtensionInputContext,
|
||||
OpaqueExtensionInput,
|
||||
} from '@internal/frontend';
|
||||
import { ExtensionDataRef } from './createExtensionDataRef';
|
||||
|
||||
/** @public */
|
||||
@@ -28,10 +32,10 @@ export interface ExtensionInput<
|
||||
optional: boolean;
|
||||
},
|
||||
> {
|
||||
$$type: '@backstage/ExtensionInput';
|
||||
extensionData: Array<UExtensionData>;
|
||||
config: TConfig;
|
||||
replaces?: Array<{ id: string; input: string }>;
|
||||
readonly $$type: '@backstage/ExtensionInput';
|
||||
readonly extensionData: Array<UExtensionData>;
|
||||
readonly config: TConfig;
|
||||
readonly replaces?: Array<{ id: string; input: string }>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -68,8 +72,7 @@ export function createExtensionInput<
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
$$type: '@backstage/ExtensionInput',
|
||||
const baseOptions = {
|
||||
extensionData,
|
||||
config: {
|
||||
singleton: Boolean(config?.singleton) as TConfig['singleton'] extends true
|
||||
@@ -80,11 +83,21 @@ export function createExtensionInput<
|
||||
: false,
|
||||
},
|
||||
replaces: config?.replaces,
|
||||
} as ExtensionInput<
|
||||
};
|
||||
|
||||
function createInstance(parent?: ExtensionInputContext): ExtensionInput<
|
||||
UExtensionData,
|
||||
{
|
||||
singleton: TConfig['singleton'] extends true ? true : false;
|
||||
optional: TConfig['optional'] extends true ? true : false;
|
||||
}
|
||||
>;
|
||||
> {
|
||||
return OpaqueExtensionInput.createInstance(undefined, {
|
||||
...baseOptions,
|
||||
context: parent,
|
||||
withContext: createInstance,
|
||||
});
|
||||
}
|
||||
|
||||
return createInstance();
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('createFrontendPlugin', () => {
|
||||
"override": [Function],
|
||||
"toString": [Function],
|
||||
"version": "v2",
|
||||
Symbol(@backstage/ExtensionDefinition/internalInputs): {},
|
||||
}
|
||||
`);
|
||||
// @ts-expect-error
|
||||
@@ -358,6 +359,7 @@ describe('createFrontendPlugin', () => {
|
||||
"override": [Function],
|
||||
"toString": [Function],
|
||||
"version": "v2",
|
||||
Symbol(@backstage/ExtensionDefinition/internalInputs): {},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
type ExtensionDefinition,
|
||||
type ExtensionDefinitionParameters,
|
||||
type ExtensionDefinitionAttachTo,
|
||||
type ExtensionInputRef,
|
||||
type CreateExtensionOptions,
|
||||
type ResolvedExtensionInput,
|
||||
type ResolvedExtensionInputs,
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('resolveExtensionDefinition', () => {
|
||||
version: 'v2',
|
||||
attachTo: { id: '', input: '' },
|
||||
disabled: false,
|
||||
inputs: {},
|
||||
override: () => ({} as ExtensionDefinition),
|
||||
};
|
||||
|
||||
@@ -77,6 +78,85 @@ describe('resolveExtensionDefinition', () => {
|
||||
{ id: 'page:test/index', input: 'tabs' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve extension input references', () => {
|
||||
const resolved = resolveExtensionDefinition(
|
||||
{
|
||||
...baseDef,
|
||||
attachTo: {
|
||||
$$type: '@backstage/ExtensionInputRef',
|
||||
kind: 'parent',
|
||||
name: 'example',
|
||||
input: 'children',
|
||||
},
|
||||
} as ExtensionDefinition,
|
||||
{ namespace: 'test' },
|
||||
);
|
||||
expect(resolved.attachTo).toEqual({
|
||||
id: 'parent:test/example',
|
||||
input: 'children',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve extension input references without name', () => {
|
||||
const resolved = resolveExtensionDefinition(
|
||||
{
|
||||
...baseDef,
|
||||
attachTo: {
|
||||
$$type: '@backstage/ExtensionInputRef',
|
||||
kind: 'parent',
|
||||
input: 'children',
|
||||
},
|
||||
} as ExtensionDefinition,
|
||||
{ namespace: 'test' },
|
||||
);
|
||||
expect(resolved.attachTo).toEqual({
|
||||
id: 'parent:test',
|
||||
input: 'children',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve extension input references without kind', () => {
|
||||
const resolved = resolveExtensionDefinition(
|
||||
{
|
||||
...baseDef,
|
||||
attachTo: {
|
||||
$$type: '@backstage/ExtensionInputRef',
|
||||
name: 'example',
|
||||
input: 'children',
|
||||
},
|
||||
} as ExtensionDefinition,
|
||||
{ namespace: 'test' },
|
||||
);
|
||||
expect(resolved.attachTo).toEqual({
|
||||
id: 'test/example',
|
||||
input: 'children',
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve array with mixed attachment types including input references', () => {
|
||||
const resolved = resolveExtensionDefinition(
|
||||
{
|
||||
...baseDef,
|
||||
attachTo: [
|
||||
{ id: 'page:home', input: 'widgets' },
|
||||
{ relative: { kind: 'page' }, input: 'actions' },
|
||||
{
|
||||
$$type: '@backstage/ExtensionInputRef',
|
||||
kind: 'parent',
|
||||
name: 'example',
|
||||
input: 'children',
|
||||
},
|
||||
],
|
||||
} as ExtensionDefinition,
|
||||
{ namespace: 'test' },
|
||||
);
|
||||
expect(resolved.attachTo).toEqual([
|
||||
{ id: 'page:home', input: 'widgets' },
|
||||
{ id: 'page:test', input: 'actions' },
|
||||
{ id: 'parent:test/example', input: 'children' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('old resolveExtensionDefinition', () => {
|
||||
@@ -86,6 +166,7 @@ describe('old resolveExtensionDefinition', () => {
|
||||
version: 'v1',
|
||||
attachTo: { id: '', input: '' },
|
||||
disabled: false,
|
||||
inputs: {},
|
||||
override: () => ({} as ExtensionDefinition),
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
import { PortableSchema } from '../schema';
|
||||
import { ExtensionInput } from './createExtensionInput';
|
||||
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
|
||||
import { OpaqueExtensionDefinition } from '@internal/frontend';
|
||||
import {
|
||||
OpaqueExtensionDefinition,
|
||||
OpaqueExtensionInput,
|
||||
} from '@internal/frontend';
|
||||
|
||||
/** @public */
|
||||
export type ExtensionAttachTo =
|
||||
@@ -152,6 +155,18 @@ function resolveAttachTo(
|
||||
const resolveSpec = (
|
||||
spec: Exclude<ExtensionDefinitionAttachTo, Array<any>>,
|
||||
): { id: string; input: string } => {
|
||||
if (OpaqueExtensionInput.isType(spec)) {
|
||||
const { context } = OpaqueExtensionInput.toInternal(spec);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Invalid input object without a parent extension used as attachment point',
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: resolveExtensionId(context.kind, namespace, context.name),
|
||||
input: context.input,
|
||||
};
|
||||
}
|
||||
if ('relative' in spec && spec.relative) {
|
||||
return {
|
||||
id: resolveExtensionId(
|
||||
@@ -162,7 +177,10 @@ function resolveAttachTo(
|
||||
input: spec.input,
|
||||
};
|
||||
}
|
||||
return { id: spec.id, input: spec.input };
|
||||
if ('id' in spec) {
|
||||
return { id: spec.id, input: spec.input };
|
||||
}
|
||||
throw new Error('Invalid attachment point specification');
|
||||
};
|
||||
|
||||
if (Array.isArray(attachTo)) {
|
||||
@@ -180,6 +198,19 @@ export function resolveExtensionDefinition<
|
||||
context?: { namespace?: string },
|
||||
): Extension<T['config'], T['configInput']> {
|
||||
const internalDefinition = OpaqueExtensionDefinition.toInternal(definition);
|
||||
|
||||
// Restore internal inputs if they were saved under a symbol
|
||||
// This is needed because we override the inputs property with ExtensionInputRef objects
|
||||
const internalInputsSymbol = Symbol.for(
|
||||
'@backstage/ExtensionDefinition/internalInputs',
|
||||
);
|
||||
if ((internalDefinition as any)[internalInputsSymbol]) {
|
||||
// Restore the internal inputs from the symbol
|
||||
(internalDefinition as any).inputs = (internalDefinition as any)[
|
||||
internalInputsSymbol
|
||||
];
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
kind,
|
||||
|
||||
Reference in New Issue
Block a user