Merge pull request #31699 from backstage/rugvip/attach-ts

frontend-plugin-api: add support for attachment point references
This commit is contained in:
Patrik Oldsberg
2025-11-11 16:13:53 -05:00
committed by GitHub
16 changed files with 818 additions and 79 deletions
@@ -390,3 +390,31 @@ const childExtension = createExtension({
},
});
```
## 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 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
const parent = createExtension({
inputs: {
children: createExtensionInput([coreExtensionData.reactElement]),
},
// other options...
});
// Create a child extension that attaches to the parent's input
const child = createExtension({
attachTo: page.inputs.children, // Direct reference to the input
output: [coreExtensionData.reactElement], // Outputs are verified against the parent input
// other options...
});
```
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>;
@@ -61,6 +62,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';
+45 -12
View File
@@ -407,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,
@@ -414,7 +415,8 @@ export function createExtension<
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput
UFactoryOutput,
UParentInputs
>,
): ExtensionDefinition<{
config: string extends keyof TConfigSchema
@@ -454,6 +456,7 @@ export function createExtensionBlueprint<
},
UFactoryOutput extends ExtensionDataValue<any, any>,
TKind extends string,
UParentInputs extends ExtensionDataRef,
TDataRefs extends {
[name in string]: ExtensionDataRef;
} = never,
@@ -465,7 +468,8 @@ export function createExtensionBlueprint<
TInputs,
TConfigSchema,
UFactoryOutput,
TDataRefs
TDataRefs,
UParentInputs
>,
): ExtensionBlueprint<{
kind: TKind;
@@ -508,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>;
@@ -589,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>;
@@ -862,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
@@ -890,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,6 +1093,11 @@ export type ExtensionDefinition<
> = {
$$type: '@backstage/ExtensionDefinition';
readonly T: T;
readonly inputs: {
[K in keyof T['inputs']]: ExtensionInput<
T['inputs'][K] extends ExtensionInput<infer IData> ? IData : never
>;
};
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
@@ -1086,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 &
@@ -1172,7 +1201,9 @@ export type ExtensionDefinition<
};
// @public
export type ExtensionDefinitionAttachTo =
export type ExtensionDefinitionAttachTo<
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
> =
| {
id: string;
input: string;
@@ -1186,6 +1217,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInput<UParentInputs>
| Array<
| {
id: string;
@@ -1200,6 +1232,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInput<UParentInputs>
>;
// @public (undocumented)
@@ -1249,13 +1282,13 @@ 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;
}>;
@@ -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",
@@ -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,151 @@ 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 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' },
@@ -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';
@@ -90,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.
@@ -119,10 +134,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 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
* ```ts
@@ -132,6 +148,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' },
@@ -141,9 +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<UParentInputs>
| Array<
| { id: string; input: string; relative?: never }
| {
@@ -151,6 +174,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInput<UParentInputs>
>;
/** @public */
@@ -161,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>;
@@ -213,6 +239,15 @@ 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<
T['inputs'][K] extends ExtensionInput<infer IData> ? IData : never
>;
};
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
@@ -221,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 &
@@ -306,6 +348,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.
*
@@ -348,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,
@@ -355,7 +422,8 @@ export function createExtension<
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput
UFactoryOutput,
UParentInputs
>,
): ExtensionDefinition<{
config: string extends keyof TConfigSchema
@@ -419,7 +487,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,
@@ -433,7 +501,22 @@ 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) {
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 +527,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}`);
@@ -475,9 +561,17 @@ 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: { ...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,235 @@ 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}',
);
});
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', () => {
const testDataRef = createExtensionDataRef<string>().with({ id: 'test' });
@@ -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<
@@ -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();
}
@@ -18,8 +18,8 @@ export { coreExtensionData } from './coreExtensionData';
export {
createExtension,
type ExtensionDefinition,
type ExtensionDefinitionParameters,
type ExtensionDefinitionAttachTo,
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,6 +36,7 @@ describe('resolveExtensionDefinition', () => {
T: undefined as any,
version: 'v2',
attachTo: { id: '', input: '' },
inputs: {},
disabled: false,
override: () => ({} as ExtensionDefinition),
};
@@ -61,20 +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' },
it('should resolve extension input references', () => {
const baseInpuf = OpaqueExtensionInput.toInternal(
createExtensionInput([testDataRef]),
);
expect(resolved.attachTo).toEqual([
{ id: 'page:test', input: 'tabs' },
{ id: 'page:test/index', input: 'tabs' },
expect(
resolveExtensionDefinition(
OpaqueExtensionDefinition.toInternal({
...baseDef,
attachTo: baseInpuf.withContext({
kind: 'parent',
name: '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',
},
]);
});
});
@@ -85,6 +184,7 @@ describe('old resolveExtensionDefinition', () => {
T: undefined as any,
version: 'v1',
attachTo: { id: '', input: '' },
inputs: {},
disabled: false,
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,7 @@ export function resolveExtensionDefinition<
context?: { namespace?: string },
): Extension<T['config'], T['configInput']> {
const internalDefinition = OpaqueExtensionDefinition.toInternal(definition);
const {
name,
kind,