diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index d2006a3cd4..a6d00a3131 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -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:
Widget Content
, - }, + 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. diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 59abb6a0eb..3ccdf5258f 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -44,6 +44,7 @@ function makeExt( version: 'v1', id, attachTo: { id: attachId, input: 'default' }, + inputs: {}, disabled: status === 'disabled', toString: expect.any(Function), } as Extension; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 176d2e14bd..c3dd12119f 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -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; routeRef: ConfigurableExtensionDataRef< - RouteRef, + RouteRef, 'core.routing.ref', {} >; @@ -406,6 +407,7 @@ export function createExtension< UFactoryOutput extends ExtensionDataValue, 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, 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 & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -588,10 +595,12 @@ export type CreateExtensionOptions< [key: string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, + UParentInputs extends ExtensionDataRef, > = { kind?: TKind; name?: TName; - attachTo: ExtensionDefinitionAttachTo; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -861,9 +870,11 @@ export interface ExtensionBlueprint< make< TName extends string | undefined, TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, >(args: { name?: TName; - attachTo?: ExtensionDefinitionAttachTo; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo, 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 & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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 ? IData : never + >; }; override< TExtensionConfigSchema extends { @@ -1088,10 +1108,17 @@ export type ExtensionDefinition< [inputName in string]: ExtensionInput; }, TParamsInput extends AnyParamsInput_2>, + UParentInputs extends ExtensionDataRef, >( args: Expand< { - attachTo?: ExtensionDefinitionAttachTo; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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 | Array< | { id: string; @@ -1203,7 +1232,7 @@ export type ExtensionDefinitionAttachTo = input: string; id?: never; } - | ExtensionInputRef + | ExtensionInput >; // @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; + readonly extensionData: Array; // (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, diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index 6e219ef741..6c318d782a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -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}. diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index 471e933b12..988c5a15c0 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -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", diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index f914412cfa..4996fbc535 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -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' }, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 31876c9b6b..0fec40f4c9 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -93,27 +93,39 @@ type JoinStringUnion< : JoinStringUnion : TResult; +/** @ignore */ +export type RequiredExtensionIds = + UExtensionData extends any + ? UExtensionData['config']['optional'] extends true + ? never + : UExtensionData['id'] + : never; + /** @ignore */ export type VerifyExtensionFactoryOutput< UDeclaredOutput extends ExtensionDataRef, UFactoryOutput extends ExtensionDataValue, -> = ( - 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 - >}` - : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< - Exclude +> = [RequiredExtensionIds] extends [UFactoryOutput['id']] + ? [UFactoryOutput['id']] extends [UDeclaredOutput['id']] + ? {} + : `Error: The extension factory has undeclared output(s): ${JoinStringUnion< + Exclude >}` - : never; + : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< + Exclude, UFactoryOutput['id']> + >}`; + +/** @ignore */ +export type VerifyExtensionAttachTo< + UOutput extends ExtensionDataRef, + UParentInput extends ExtensionDataRef, +> = ExtensionDataRef extends UParentInput + ? {} + : [RequiredExtensionIds] extends [RequiredExtensionIds] + ? {} + : `Error: This parent extension input requires the following extension data, but it is not declared as guaranteed output of this extension: ${JoinStringUnion< + Exclude, RequiredExtensionIds> + >}`; /** * 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 | Array< | { id: string; input: string; relative?: never } | { @@ -160,7 +174,7 @@ export type ExtensionDefinitionAttachTo = input: string; id?: never; } - | ExtensionInput + | ExtensionInput >; /** @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, + UParentInputs extends ExtensionDataRef, > = { kind?: TKind; name?: TName; - attachTo: ExtensionDefinitionAttachTo; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -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 ? IData : never + >; }; override< @@ -238,10 +256,17 @@ export type ExtensionDefinition< UNewOutput extends ExtensionDataRef, TExtraInputs extends { [inputName in string]: ExtensionInput }, TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, >( args: Expand< { - attachTo?: ExtensionDefinitionAttachTo; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UParentInputs + >; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -389,6 +414,7 @@ export function createExtension< UFactoryOutput extends ExtensionDataValue, 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( { diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index 05f40dec82..4759441187 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -1463,6 +1463,115 @@ describe('createExtensionBlueprint', () => { 'ExtensionDefinition{kind=test,attachTo=page:/index@otherTabs}', ); }); + + it('should provide type safe attachments by reference', () => { + const stringDataRef = createExtensionDataRef().with({ + id: 'test.string', + }); + const numberDataRef = createExtensionDataRef().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', () => { diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index ddd2a3a346..f9a3731743 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -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, TDataRefs extends { [name in string]: ExtensionDataRef }, + UParentInputs extends ExtensionDataRef, > = { kind: TKind; - attachTo: ExtensionDefinitionAttachTo; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -212,9 +215,11 @@ export interface ExtensionBlueprint< make< TName extends string | undefined, TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, >(args: { name?: TName; - attachTo?: ExtensionDefinitionAttachTo; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput @@ -245,9 +250,16 @@ export interface ExtensionBlueprint< UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, TExtraInputs extends { [inputName in string]: ExtensionInput }, + UParentInputs extends ExtensionDataRef, >(args: { name?: TName; - attachTo?: ExtensionDefinitionAttachTo; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : 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, 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[], diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts b/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts index 084758b793..8511f79b4e 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts @@ -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< diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index f739a1d514..a3226721a1 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -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): {}, } `); }); diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index da3b3bfca1..1bbdf68b13 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -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, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index 4095973752..d8b90d69f3 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -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().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), }; diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index c500954be4..f99d586e87 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -199,18 +199,6 @@ export function resolveExtensionDefinition< ): Extension { 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,