frontend-plugin-api: type safe attachTo references

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-08 19:49:22 +01:00
parent 8b84f39946
commit c6b9f50337
14 changed files with 469 additions and 205 deletions
@@ -391,51 +391,30 @@ const childExtension = createExtension({
});
```
## Direct extension input references
## 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.
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 using blueprints that provide a nested hierarchy of extensions.
Extension inputs references are always relative, this means that they can only be used for referencing extensions within the same 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'),
const parent = createExtension({
inputs: {
children: createExtensionInput([coreExtensionData.reactElement]),
},
// other options...
});
// Create a child extension that attaches to the parent's input
const widget = CardBlueprint.make({
const child = createExtension({
attachTo: page.inputs.children, // Direct reference to the input
params: {
title: 'Example Widget',
content: <div>Widget Content</div>,
},
output: [coreExtensionData.reactElement], // Outputs are verified against the parent input
// other options...
});
```
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: {
/* ... */
},
});
```
These references are a type-safe way to attach child extensions, it both ensures that the parent input is present, as well as the child providing the required data for the parent.
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.
@@ -44,6 +44,7 @@ function makeExt(
version: 'v1',
id,
attachTo: { id: attachId, input: 'default' },
inputs: {},
disabled: status === 'disabled',
toString: expect.any(Function),
} as Extension<unknown>;
+45 -28
View File
@@ -8,6 +8,7 @@ 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';
@@ -384,7 +385,7 @@ export const coreExtensionData: {
>;
routePath: ConfigurableExtensionDataRef<string, 'core.routing.path', {}>;
routeRef: ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams>,
RouteRef<AnyRouteRefParams_2>,
'core.routing.ref',
{}
>;
@@ -406,6 +407,7 @@ export function createExtension<
UFactoryOutput extends ExtensionDataValue<any, any>,
const TKind extends string | undefined = undefined,
const TName extends string | undefined = undefined,
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
>(
options: CreateExtensionOptions<
TKind,
@@ -413,7 +415,8 @@ export function createExtension<
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput
UFactoryOutput,
UParentInputs
>,
): ExtensionDefinition<{
config: string extends keyof TConfigSchema
@@ -453,6 +456,7 @@ export function createExtensionBlueprint<
},
UFactoryOutput extends ExtensionDataValue<any, any>,
TKind extends string,
UParentInputs extends ExtensionDataRef,
TDataRefs extends {
[name in string]: ExtensionDataRef;
} = never,
@@ -464,7 +468,8 @@ export function createExtensionBlueprint<
TInputs,
TConfigSchema,
UFactoryOutput,
TDataRefs
TDataRefs,
UParentInputs
>,
): ExtensionBlueprint<{
kind: TKind;
@@ -507,9 +512,11 @@ export type CreateExtensionBlueprintOptions<
TDataRefs extends {
[name in string]: ExtensionDataRef;
},
UParentInputs extends ExtensionDataRef,
> = {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -588,10 +595,12 @@ export type CreateExtensionOptions<
[key: string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
UParentInputs extends ExtensionDataRef,
> = {
kind?: TKind;
name?: TName;
attachTo: ExtensionDefinitionAttachTo;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -861,9 +870,11 @@ export interface ExtensionBlueprint<
make<
TName extends string | undefined,
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
disabled?: boolean;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
@@ -889,9 +900,16 @@ export interface ExtensionBlueprint<
TExtraInputs extends {
[inputName in string]: ExtensionInput;
},
UParentInputs extends ExtensionDataRef,
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -1076,7 +1094,9 @@ export type ExtensionDefinition<
$$type: '@backstage/ExtensionDefinition';
readonly T: T;
readonly inputs: {
[K in keyof T['inputs']]: ExtensionInputRef;
[K in keyof T['inputs']]: ExtensionInput<
T['inputs'][K] extends ExtensionInput<infer IData> ? IData : never
>;
};
override<
TExtensionConfigSchema extends {
@@ -1088,10 +1108,17 @@ export type ExtensionDefinition<
[inputName in string]: ExtensionInput;
},
TParamsInput extends AnyParamsInput_2<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
>(
args: Expand<
{
attachTo?: ExtensionDefinitionAttachTo;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -1174,7 +1201,9 @@ export type ExtensionDefinition<
};
// @public
export type ExtensionDefinitionAttachTo =
export type ExtensionDefinitionAttachTo<
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
> =
| {
id: string;
input: string;
@@ -1188,7 +1217,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInputRef
| ExtensionInput<UParentInputs>
| Array<
| {
id: string;
@@ -1203,7 +1232,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInputRef
| ExtensionInput<UParentInputs>
>;
// @public (undocumented)
@@ -1253,30 +1282,18 @@ export interface ExtensionInput<
},
> {
// (undocumented)
$$type: '@backstage/ExtensionInput';
readonly $$type: '@backstage/ExtensionInput';
// (undocumented)
config: TConfig;
readonly config: TConfig;
// (undocumented)
extensionData: Array<UExtensionData>;
readonly extensionData: Array<UExtensionData>;
// (undocumented)
replaces?: Array<{
readonly replaces?: Array<{
id: string;
input: string;
}>;
}
// @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,
@@ -15,12 +15,8 @@
*/
import { createApiRef } from '@backstage/core-plugin-api';
import {
FrontendPlugin,
Extension,
ExtensionDataRef,
ExtensionAttachTo,
} from '../../wiring';
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition';
/**
* The specification for this {@link AppNode} in the {@link AppTree}.
@@ -203,10 +203,16 @@ describe('ApiBlueprint', () => {
"optional": false,
"singleton": false,
},
"context": {
"input": "test",
"kind": "api",
"name": "test",
},
"extensionData": [
[Function],
],
"replaces": undefined,
"withContext": [Function],
},
},
"kind": "api",
@@ -288,6 +288,95 @@ describe('createExtension', () => {
);
});
it('should provide type safe attachments by reference', () => {
const parent = createExtension({
attachTo: { id: 'root', input: 'children' },
inputs: {
string: createExtensionInput([stringDataRef]),
stringOpt: createExtensionInput([stringDataRef.optional()]),
number: createExtensionInput([numberDataRef]),
numberOpt: createExtensionInput([numberDataRef.optional()]),
both: createExtensionInput([stringDataRef, numberDataRef]),
bothOptString: createExtensionInput([
stringDataRef.optional(),
numberDataRef,
]),
bothOptNumber: createExtensionInput([
stringDataRef,
numberDataRef.optional(),
]),
bothOpt: createExtensionInput([
stringDataRef.optional(),
numberDataRef.optional(),
]),
},
output: [],
factory: () => [],
});
const strOutExt = createExtension({
attachTo: parent.inputs.string,
output: [stringDataRef],
factory: () => [stringDataRef('str')],
});
strOutExt.override({
attachTo: parent.inputs.string,
});
strOutExt.override({
attachTo: parent.inputs.stringOpt,
});
strOutExt.override({
// @ts-expect-error
attachTo: parent.inputs.number,
});
strOutExt.override({
attachTo: parent.inputs.numberOpt,
});
strOutExt.override({
// @ts-expect-error
attachTo: parent.inputs.both,
});
strOutExt.override({
attachTo: parent.inputs.bothOptNumber,
});
strOutExt.override({
// @ts-expect-error
attachTo: parent.inputs.bothOptString,
});
strOutExt.override({
attachTo: parent.inputs.bothOpt,
});
const numberOutExt = createExtension({
// @ts-expect-error
attachTo: parent.inputs.string,
output: [numberDataRef],
factory: () => [numberDataRef(1)],
});
numberOutExt.override({
// @ts-expect-error
attachTo: parent.inputs.string,
});
numberOutExt.override({
attachTo: parent.inputs.number,
});
const bothOutExt = createExtension({
attachTo: parent.inputs.both,
output: [numberDataRef, stringDataRef],
factory: () => [numberDataRef(1), stringDataRef('str')],
});
// TODO(Rugvip): Potentially encapsulate the parent input type in the extension, until then we can't verify this
bothOutExt.override({
output: [numberDataRef.optional(), stringDataRef],
factory: () => [stringDataRef('str')],
});
bothOutExt.override({
// @ts-expect-error
attachTo: parent.inputs.both,
output: [numberDataRef.optional(), stringDataRef],
factory: () => [stringDataRef('str')],
});
expect('types').not.toBe('broken');
});
it('should create an extension with input', () => {
const extension = createExtension({
attachTo: { id: 'root', input: 'default' },
@@ -93,27 +93,39 @@ type JoinStringUnion<
: JoinStringUnion<IRest, TDiv, `${TResult}${TDiv}${INext}`>
: TResult;
/** @ignore */
export type RequiredExtensionIds<UExtensionData extends ExtensionDataRef> =
UExtensionData extends any
? UExtensionData['config']['optional'] extends true
? never
: UExtensionData['id']
: never;
/** @ignore */
export type VerifyExtensionFactoryOutput<
UDeclaredOutput extends ExtensionDataRef,
UFactoryOutput extends ExtensionDataValue<any, any>,
> = (
UDeclaredOutput extends any
? UDeclaredOutput['config']['optional'] extends true
? never
: UDeclaredOutput['id']
: never
) extends infer IRequiredOutputIds
? [IRequiredOutputIds] extends [UFactoryOutput['id']]
? [UFactoryOutput['id']] extends [UDeclaredOutput['id']]
? {}
: `Error: The extension factory has undeclared output(s): ${JoinStringUnion<
Exclude<UFactoryOutput['id'], UDeclaredOutput['id']>
>}`
: `Error: The extension factory is missing the following output(s): ${JoinStringUnion<
Exclude<IRequiredOutputIds, UFactoryOutput['id']>
> = [RequiredExtensionIds<UDeclaredOutput>] extends [UFactoryOutput['id']]
? [UFactoryOutput['id']] extends [UDeclaredOutput['id']]
? {}
: `Error: The extension factory has undeclared output(s): ${JoinStringUnion<
Exclude<UFactoryOutput['id'], UDeclaredOutput['id']>
>}`
: never;
: `Error: The extension factory is missing the following output(s): ${JoinStringUnion<
Exclude<RequiredExtensionIds<UDeclaredOutput>, UFactoryOutput['id']>
>}`;
/** @ignore */
export type VerifyExtensionAttachTo<
UOutput extends ExtensionDataRef,
UParentInput extends ExtensionDataRef,
> = ExtensionDataRef extends UParentInput
? {}
: [RequiredExtensionIds<UParentInput>] extends [RequiredExtensionIds<UOutput>]
? {}
: `Error: This parent extension input requires the following extension data, but it is not declared as guaranteed output of this extension: ${JoinStringUnion<
Exclude<RequiredExtensionIds<UParentInput>, RequiredExtensionIds<UOutput>>
>}`;
/**
* Specifies where an extension should attach in the extension tree.
@@ -125,7 +137,7 @@ export type VerifyExtensionFactoryOutput<
* 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. Extension input references: using a reference obtained from another extension's `inputs` property.
* 2. Extension input references: using a reference in code to another extension's input in the same plugin. These references are always relative.
* 3. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once.
*
* @example
@@ -149,10 +161,12 @@ export type VerifyExtensionFactoryOutput<
*
* @public
*/
export type ExtensionDefinitionAttachTo =
export type ExtensionDefinitionAttachTo<
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
> =
| { id: string; input: string; relative?: never }
| { relative: { kind?: string; name?: string }; input: string; id?: never }
| ExtensionInput
| ExtensionInput<UParentInputs>
| Array<
| { id: string; input: string; relative?: never }
| {
@@ -160,7 +174,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInput
| ExtensionInput<UParentInputs>
>;
/** @public */
@@ -171,10 +185,12 @@ export type CreateExtensionOptions<
TInputs extends { [inputName in string]: ExtensionInput },
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
UParentInputs extends ExtensionDataRef,
> = {
kind?: TKind;
name?: TName;
attachTo: ExtensionDefinitionAttachTo;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -227,7 +243,9 @@ export type ExtensionDefinition<
* References to the inputs of this extension, which can be used to attach child extensions.
*/
readonly inputs: {
[K in keyof T['inputs']]: ExtensionInput;
[K in keyof T['inputs']]: ExtensionInput<
T['inputs'][K] extends ExtensionInput<infer IData> ? IData : never
>;
};
override<
@@ -238,10 +256,17 @@ export type ExtensionDefinition<
UNewOutput extends ExtensionDataRef,
TExtraInputs extends { [inputName in string]: ExtensionInput },
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
>(
args: Expand<
{
attachTo?: ExtensionDefinitionAttachTo;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -389,6 +414,7 @@ export function createExtension<
UFactoryOutput extends ExtensionDataValue<any, any>,
const TKind extends string | undefined = undefined,
const TName extends string | undefined = undefined,
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
>(
options: CreateExtensionOptions<
TKind,
@@ -396,7 +422,8 @@ export function createExtension<
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput
UFactoryOutput,
UParentInputs
>,
): ExtensionDefinition<{
config: string extends keyof TConfigSchema
@@ -474,7 +501,8 @@ export function createExtension<
}
const attachTo = [options.attachTo]
.flat()
.map(a => {
.map(aAny => {
const a = aAny as ExtensionDefinitionAttachTo;
if (OpaqueExtensionInput.isType(a)) {
const { context } = OpaqueExtensionInput.toInternal(a);
if (!context) {
@@ -533,7 +561,8 @@ export function createExtension<
return createExtension({
kind: options.kind,
name: options.name,
attachTo: overrideOptions.attachTo ?? options.attachTo,
attachTo: (overrideOptions.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: overrideOptions.disabled ?? options.disabled,
inputs: bindInputs(
{
@@ -1463,6 +1463,115 @@ describe('createExtensionBlueprint', () => {
'ExtensionDefinition{kind=test,attachTo=page:<plugin>/index@otherTabs}',
);
});
it('should provide type safe attachments by reference', () => {
const stringDataRef = createExtensionDataRef<string>().with({
id: 'test.string',
});
const numberDataRef = createExtensionDataRef<number>().with({
id: 'test.number',
});
const parent = createExtensionBlueprint({
kind: 'test-parent',
attachTo: { id: 'root', input: 'children' },
inputs: {
string: createExtensionInput([stringDataRef]),
stringOpt: createExtensionInput([stringDataRef.optional()]),
number: createExtensionInput([numberDataRef]),
numberOpt: createExtensionInput([numberDataRef.optional()]),
both: createExtensionInput([stringDataRef, numberDataRef]),
bothOptString: createExtensionInput([
stringDataRef.optional(),
numberDataRef,
]),
bothOptNumber: createExtensionInput([
stringDataRef,
numberDataRef.optional(),
]),
bothOpt: createExtensionInput([
stringDataRef.optional(),
numberDataRef.optional(),
]),
},
output: [],
factory: () => [],
}).make({ params: {} });
const strOutExt = createExtensionBlueprint({
kind: 'test',
attachTo: parent.inputs.string,
output: [stringDataRef],
factory: () => [stringDataRef('str')],
});
strOutExt.make({
attachTo: parent.inputs.string,
params: {},
});
strOutExt.makeWithOverrides({
attachTo: parent.inputs.stringOpt,
factory: orig => orig({}),
});
strOutExt.make({
// @ts-expect-error
attachTo: parent.inputs.number,
params: {},
});
strOutExt.makeWithOverrides({
attachTo: parent.inputs.numberOpt,
factory: orig => orig({}),
});
strOutExt.make({
// @ts-expect-error
attachTo: parent.inputs.both,
params: {},
});
strOutExt.make({
attachTo: parent.inputs.bothOptNumber,
params: {},
});
strOutExt.make({
// @ts-expect-error
attachTo: parent.inputs.bothOptString,
params: {},
});
strOutExt.make({
attachTo: parent.inputs.bothOpt,
params: {},
});
const numberOutExt = createExtensionBlueprint({
kind: 'test',
// @ts-expect-error
attachTo: parent.inputs.string,
output: [numberDataRef],
factory: () => [numberDataRef(1)],
});
numberOutExt.make({
// @ts-expect-error
attachTo: parent.inputs.string,
params: {},
});
numberOutExt.makeWithOverrides({
attachTo: parent.inputs.number,
factory: orig => orig({}),
});
const bothOutExt = createExtensionBlueprint({
kind: 'test',
attachTo: parent.inputs.both,
output: [numberDataRef, stringDataRef],
factory: () => [numberDataRef(1), stringDataRef('str')],
});
bothOutExt.makeWithOverrides({
output: [numberDataRef.optional(), stringDataRef],
factory: orig => orig({}),
});
bothOutExt.makeWithOverrides({
// @ts-expect-error
attachTo: parent.inputs.both,
output: [numberDataRef.optional(), stringDataRef],
factory: orig => orig({}),
});
expect('types').not.toBe('broken');
});
});
describe('with advanced parameter types', () => {
@@ -24,6 +24,7 @@ import {
VerifyExtensionFactoryOutput,
createExtension,
ctxParamsSymbol,
VerifyExtensionAttachTo,
} from './createExtension';
import { z } from 'zod';
import { ExtensionInput } from './createExtensionInput';
@@ -107,9 +108,11 @@ export type CreateExtensionBlueprintOptions<
TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
TDataRefs extends { [name in string]: ExtensionDataRef },
UParentInputs extends ExtensionDataRef,
> = {
kind: TKind;
attachTo: ExtensionDefinitionAttachTo;
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
@@ -212,9 +215,11 @@ export interface ExtensionBlueprint<
make<
TName extends string | undefined,
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
disabled?: boolean;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
@@ -245,9 +250,16 @@ export interface ExtensionBlueprint<
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
TExtraInputs extends { [inputName in string]: ExtensionInput },
UParentInputs extends ExtensionDataRef,
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
@@ -444,6 +456,7 @@ export function createExtensionBlueprint<
TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
TKind extends string,
UParentInputs extends ExtensionDataRef,
TDataRefs extends { [name in string]: ExtensionDataRef } = never,
>(
options: CreateExtensionBlueprintOptions<
@@ -453,7 +466,8 @@ export function createExtensionBlueprint<
TInputs,
TConfigSchema,
UFactoryOutput,
TDataRefs
TDataRefs,
UParentInputs
>,
): ExtensionBlueprint<{
kind: TKind;
@@ -489,7 +503,8 @@ export function createExtensionBlueprint<
return createExtension({
kind: options.kind,
name: args.name,
attachTo: args.attachTo ?? options.attachTo,
attachTo: (args.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: args.disabled ?? options.disabled,
inputs: options.inputs,
output: options.output as ExtensionDataRef[],
@@ -505,7 +520,8 @@ export function createExtensionBlueprint<
return createExtension({
kind: options.kind,
name: args.name,
attachTo: args.attachTo ?? options.attachTo,
attachTo: (args.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: args.disabled ?? options.disabled,
inputs: { ...args.inputs, ...options.inputs },
output: (args.output ?? options.output) as ExtensionDataRef[],
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { OpaqueExtensionInput } from '@internal/frontend';
import { createExtensionDataRef } from './createExtensionDataRef';
import { ExtensionInput, createExtensionInput } from './createExtensionInput';
@@ -29,6 +30,7 @@ describe('createExtensionInput', () => {
$$type: '@backstage/ExtensionInput',
extensionData: [stringDataRef, numberDataRef],
config: { singleton: false, optional: false },
withContext: expect.any(Function),
});
const x1: ExtensionInput<
@@ -54,6 +56,20 @@ describe('createExtensionInput', () => {
unused(x1, x2, x3, x4);
});
it('should attach a context to the input', () => {
const input = createExtensionInput([stringDataRef, numberDataRef]);
const context = { input: 'test1', kind: 'test2', name: 'test3' };
const inputWithContext =
OpaqueExtensionInput.toInternal(input).withContext(context);
expect(inputWithContext).toEqual({
$$type: '@backstage/ExtensionInput',
extensionData: [stringDataRef, numberDataRef],
config: { singleton: false, optional: false },
withContext: expect.any(Function),
context,
});
});
it('should create a singleton input', () => {
const input = createExtensionInput([stringDataRef, numberDataRef], {
singleton: true,
@@ -62,6 +78,7 @@ describe('createExtensionInput', () => {
$$type: '@backstage/ExtensionInput',
extensionData: [stringDataRef, numberDataRef],
config: { singleton: true, optional: false },
withContext: expect.any(Function),
});
const x1: ExtensionInput<
@@ -96,6 +113,7 @@ describe('createExtensionInput', () => {
$$type: '@backstage/ExtensionInput',
extensionData: [stringDataRef, numberDataRef],
config: { singleton: true, optional: true },
withContext: expect.any(Function),
});
const x1: ExtensionInput<
@@ -170,7 +170,6 @@ describe('createFrontendPlugin', () => {
"override": [Function],
"toString": [Function],
"version": "v2",
Symbol(@backstage/ExtensionDefinition/internalInputs): {},
}
`);
// @ts-expect-error
@@ -359,7 +358,6 @@ describe('createFrontendPlugin', () => {
"override": [Function],
"toString": [Function],
"version": "v2",
Symbol(@backstage/ExtensionDefinition/internalInputs): {},
}
`);
});
@@ -18,9 +18,8 @@ export { coreExtensionData } from './coreExtensionData';
export {
createExtension,
type ExtensionDefinition,
type ExtensionDefinitionParameters,
type ExtensionDefinitionAttachTo,
type ExtensionInputRef,
type ExtensionDefinitionParameters,
type CreateExtensionOptions,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
@@ -14,11 +14,21 @@
* limitations under the License.
*/
import {
createExtensionDataRef,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from './createExtension';
import {
ResolveExtensionId,
resolveExtensionDefinition,
} from './resolveExtensionDefinition';
import {
OpaqueExtensionDefinition,
OpaqueExtensionInput,
} from '@internal/frontend';
const testDataRef = createExtensionDataRef<string>().with({ id: 'test' });
describe('resolveExtensionDefinition', () => {
const baseDef = {
@@ -26,8 +36,8 @@ describe('resolveExtensionDefinition', () => {
T: undefined as any,
version: 'v2',
attachTo: { id: '', input: '' },
disabled: false,
inputs: {},
disabled: false,
override: () => ({} as ExtensionDefinition),
};
@@ -62,99 +72,108 @@ describe('resolveExtensionDefinition', () => {
);
});
it('should resolve relative attachment points', () => {
const resolved = resolveExtensionDefinition(
{
...baseDef,
attachTo: [
{ relative: { kind: 'page' }, input: 'tabs' },
{ relative: { kind: 'page', name: 'index' }, input: 'tabs' },
],
} as ExtensionDefinition,
{ namespace: 'test' },
);
expect(resolved.attachTo).toEqual([
{ id: 'page:test', input: 'tabs' },
{ 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' },
const baseInpuf = OpaqueExtensionInput.toInternal(
createExtensionInput([testDataRef]),
);
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',
expect(
resolveExtensionDefinition(
OpaqueExtensionDefinition.toInternal({
...baseDef,
attachTo: baseInpuf.withContext({
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' },
}),
}),
{ namespace: 'test' },
).attachTo,
).toEqual({
id: 'parent:test/example',
input: 'children',
});
expect(
resolveExtensionDefinition(
OpaqueExtensionDefinition.toInternal({
...baseDef,
attachTo: baseInpuf.withContext({
name: 'example',
input: 'children',
}),
}),
{ namespace: 'test' },
).attachTo,
).toEqual({
id: 'test/example',
input: 'children',
});
expect(
resolveExtensionDefinition(
OpaqueExtensionDefinition.toInternal({
...baseDef,
attachTo: baseInpuf.withContext({
kind: 'parent',
input: 'children',
}),
}),
{ namespace: 'test' },
).attachTo,
).toEqual({
id: 'parent:test',
input: 'children',
});
expect(
resolveExtensionDefinition(
OpaqueExtensionDefinition.toInternal({
...baseDef,
attachTo: baseInpuf.withContext({
input: 'children',
}),
}),
{ namespace: 'test' },
).attachTo,
).toEqual({
id: 'test',
input: 'children',
});
expect(
resolveExtensionDefinition(
OpaqueExtensionDefinition.toInternal({
...baseDef,
attachTo: [
baseInpuf.withContext({
kind: 'k1',
input: 'children',
}),
baseInpuf.withContext({
kind: 'k2',
input: 'children',
}),
baseInpuf.withContext({
kind: 'k3',
input: 'children',
}),
],
}),
{ namespace: 'test' },
).attachTo,
).toEqual([
{
id: 'k1:test',
input: 'children',
},
{
id: 'k2:test',
input: 'children',
},
{
id: 'k3:test',
input: 'children',
},
]);
});
});
@@ -165,8 +184,8 @@ describe('old resolveExtensionDefinition', () => {
T: undefined as any,
version: 'v1',
attachTo: { id: '', input: '' },
disabled: false,
inputs: {},
disabled: false,
override: () => ({} as ExtensionDefinition),
};
@@ -199,18 +199,6 @@ export function resolveExtensionDefinition<
): 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,