frontend-plugin-api: type safe attachTo references
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user