From 7c6a66dd9ff74250b37f9387ed7c34df013b5f5b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 23 Oct 2025 10:35:05 +0200 Subject: [PATCH] frontend-plugin-api: add support for relative attachments Signed-off-by: Patrik Oldsberg --- .changeset/plugin-relative-extensions.md | 17 +++++ .../architecture/20-extensions.md | 38 ++++++++++ .../src/wiring/InternalExtensionDefinition.ts | 6 +- packages/frontend-plugin-api/report.api.md | 48 ++++++++++-- .../src/apis/definitions/AppTreeApi.ts | 4 +- .../src/wiring/createExtension.test.ts | 16 ++++ .../src/wiring/createExtension.ts | 71 +++++++++++++++--- .../src/wiring/createExtensionBlueprint.ts | 8 +- .../frontend-plugin-api/src/wiring/index.ts | 8 +- .../wiring/resolveExtensionDefinition.test.ts | 17 +++++ .../src/wiring/resolveExtensionDefinition.ts | 73 +++++++++++++++---- 11 files changed, 263 insertions(+), 43 deletions(-) create mode 100644 .changeset/plugin-relative-extensions.md diff --git a/.changeset/plugin-relative-extensions.md b/.changeset/plugin-relative-extensions.md new file mode 100644 index 0000000000..dc27a2283f --- /dev/null +++ b/.changeset/plugin-relative-extensions.md @@ -0,0 +1,17 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added support for plugin-relative `attachTo` declarations for extension definitions. This allows for the creation of extension and extension blueprints that attach to other extensions of a particular `kind` in the same plugin, rather than needing to provide the exact extension ID. This is particularly useful when wanting to provide extension blueprints with a built-in hierarchy where the extensions created from one blueprint attach to extensions created from the other blueprint, for example: + +```ts +// kind: 'tabbed-page' +const parentPage = TabbedPageBlueprint.make({ + params: {....} +}) +// attachTo: { kind: 'tabbed-page', input: 'tabs' } +const child1 = TabContentBlueprint.make({ + name: 'tab1', + params: {....} +}) +``` diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 24a5f24e12..21f7a3bb7e 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -352,3 +352,41 @@ const extension = createExtension({ }, }); ``` + +## Relative attachment points + +When creating an extension or an [extension blueprint](./23-extension-blueprints.md) you can specify an attachment point that is relative to the current plugin. This is particularly useful for groups of blueprints that are part of a common hierarchy, with extensions from one blueprint attaching to extensions from the other blueprint. For example, the following pair of extension definitions could be installed multiple times in different plugins, each creating their own hierarchy: + +```tsx +// Parent extension with a fixed attachment point +const parentExtension = createExtension({ + kind: 'section', + attachTo: [{ id: 'app/some-fixed-extension', input: 'children' }], + inputs: { + content: createExtensionInput([coreExtensionData.reactElement], { + singleton: true, + }), + }, + output: [coreExtensionData.reactElement], + factory({ inputs }) { + return [ + coreExtensionData.reactElement( +
+

Section Title

+ {inputs.content.get(coreExtensionData.reactElement)} +
, + ), + ]; + }, +}); + +// Child extension with a relative attachment point +const childExtension = createExtension({ + kind: 'section-content', + attachTo: [{ relative: { kind: 'section' }, input: 'content' }], + output: [coreExtensionData.reactElement], + factory() { + return [coreExtensionData.reactElement(

Section Content

)]; + }, +}); +``` diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index 08f787a4ae..54953c169a 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -17,7 +17,7 @@ import { ApiHolder, AppNode, - ExtensionAttachToSpec, + ExtensionDefinitionAttachTo, ExtensionDataValue, ExtensionDataRef, ExtensionDefinition, @@ -36,7 +36,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly kind?: string; readonly namespace?: string; readonly name?: string; - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; readonly configSchema?: PortableSchema; readonly inputs: { @@ -67,7 +67,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly kind?: string; readonly namespace?: string; readonly name?: string; - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: ExtensionInput }; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index b7c5b08528..c26e9a99c1 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -259,7 +259,7 @@ export interface AppNodeInstance { // @public export interface AppNodeSpec { // (undocumented) - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionAttachTo; // (undocumented) readonly config?: unknown; // (undocumented) @@ -510,7 +510,7 @@ export type CreateExtensionBlueprintOptions< }, > = { kind: TKind; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -592,7 +592,7 @@ export type CreateExtensionOptions< > = { kind?: TKind; name?: TName; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -839,7 +839,7 @@ export interface Extension { } // @public (undocumented) -export type ExtensionAttachToSpec = +export type ExtensionAttachTo = | { id: string; input: string; @@ -849,6 +849,9 @@ export type ExtensionAttachToSpec = input: string; }>; +// @public @deprecated (undocumented) +export type ExtensionAttachToSpec = ExtensionAttachTo; + // @public (undocumented) export interface ExtensionBlueprint< T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters, @@ -861,7 +864,7 @@ export interface ExtensionBlueprint< TParamsInput extends AnyParamsInput>, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo; disabled?: boolean; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput @@ -889,7 +892,7 @@ export interface ExtensionBlueprint< }, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -1086,7 +1089,7 @@ export type ExtensionDefinition< >( args: Expand< { - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -1168,6 +1171,37 @@ export type ExtensionDefinition< }>; }; +// @public +export type ExtensionDefinitionAttachTo = + | { + id: string; + input: string; + relative?: never; + } + | { + relative: { + kind?: string; + name?: string; + }; + input: string; + id?: never; + } + | Array< + | { + id: string; + input: string; + relative?: never; + } + | { + relative: { + kind?: string; + name?: string; + }; + input: string; + id?: never; + } + >; + // @public (undocumented) export type ExtensionDefinitionParameters = { kind?: string; diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index f4fd2f3a6f..6e219ef741 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -19,7 +19,7 @@ import { FrontendPlugin, Extension, ExtensionDataRef, - ExtensionAttachToSpec, + ExtensionAttachTo, } from '../../wiring'; /** @@ -33,7 +33,7 @@ import { */ export interface AppNodeSpec { readonly id: string; - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionAttachTo; readonly extension: Extension; readonly disabled: boolean; readonly config?: unknown; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 59a81f1b59..852fb09a97 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -216,6 +216,22 @@ describe('createExtension', () => { ); }); + it('should create an extension with a relative attachment point', () => { + const extension = createExtension({ + attachTo: [ + { relative: {}, input: 'tabs' }, + { relative: { kind: 'page' }, input: 'tabs' }, + { relative: { name: 'index' }, input: 'tabs' }, + { relative: { kind: 'page', name: 'index' }, input: 'tabs' }, + ], + output: [stringDataRef], + factory: () => [stringDataRef('bar')], + }); + expect(String(extension)).toBe( + 'ExtensionDefinition{attachTo=@tabs+page:@tabs+/index@tabs+page:/index@tabs}', + ); + }); + 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 8ed4b8b259..fb9aa614b9 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -112,10 +112,46 @@ export type VerifyExtensionFactoryOutput< >}` : never; -/** @public */ -export type ExtensionAttachToSpec = - | { id: string; input: string } - | Array<{ id: string; input: string }>; +/** + * Specifies where an extension should attach in the extension tree. + * + * @remarks + * + * 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: + * + * 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. + * + * @example + * ```ts + * // Attach to a specific extension by full ID + * { id: 'app/routes', input: 'routes' } + * + * // Attach to an extension in the same plugin by kind + * { relative: { kind: 'page' }, input: 'actions' } + * + * // Attach to multiple parents at once + * [ + * { id: 'page/home', input: 'widgets' }, + * { relative: { kind: 'page' }, input: 'widgets' }, + * ] + * ``` + * + * @public + */ +export type ExtensionDefinitionAttachTo = + | { id: string; input: string; relative?: never } + | { relative: { kind?: string; name?: string }; input: string; id?: never } + | Array< + | { id: string; input: string; relative?: never } + | { + relative: { kind?: string; name?: string }; + input: string; + id?: never; + } + >; /** @public */ export type CreateExtensionOptions< @@ -128,7 +164,7 @@ export type CreateExtensionOptions< > = { kind?: TKind; name?: TName; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -188,7 +224,7 @@ export type ExtensionDefinition< >( args: Expand< { - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -395,12 +431,23 @@ export function createExtension< if (options.name) { parts.push(`name=${options.name}`); } - parts.push( - `attachTo=${[options.attachTo] - .flat() - .map(a => `${a.id}@${a.input}`) - .join('+')}`, - ); + const attachTo = [options.attachTo] + .flat() + .map(a => { + if ('relative' in a && a.relative) { + let id = ''; + if (a.relative.kind) { + id = `${a.relative.kind}:${id}`; + } + if (a.relative.name) { + id = `${id}/${a.relative.name}`; + } + return `${id}@${a.input}`; + } + return `${a.id}@${a.input}`; + }) + .join('+'); + parts.push(`attachTo=${attachTo}`); return `ExtensionDefinition{${parts.join(',')}}`; }, override(overrideOptions) { diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index f8dd55ca80..ddd2a3a346 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -18,7 +18,7 @@ import { ApiHolder, AppNode } from '../apis'; import { Expand } from '@backstage/types'; import { OpaqueType } from '@internal/opaque'; import { - ExtensionAttachToSpec, + ExtensionDefinitionAttachTo, ExtensionDefinition, ResolvedExtensionInputs, VerifyExtensionFactoryOutput, @@ -109,7 +109,7 @@ export type CreateExtensionBlueprintOptions< TDataRefs extends { [name in string]: ExtensionDataRef }, > = { kind: TKind; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -214,7 +214,7 @@ export interface ExtensionBlueprint< TParamsInput extends AnyParamsInput>, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo; disabled?: boolean; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput @@ -247,7 +247,7 @@ export interface ExtensionBlueprint< TExtraInputs extends { [inputName in string]: ExtensionInput }, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 45c80d4a90..d9baa201ab 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -19,7 +19,7 @@ export { createExtension, type ExtensionDefinition, type ExtensionDefinitionParameters, - type ExtensionAttachToSpec, + type ExtensionDefinitionAttachTo, type CreateExtensionOptions, type ResolvedExtensionInput, type ResolvedExtensionInputs, @@ -54,7 +54,11 @@ export { type FrontendFeatureLoader, type CreateFrontendFeatureLoaderOptions, } from './createFrontendFeatureLoader'; -export { type Extension } from './resolveExtensionDefinition'; +export { + type Extension, + type ExtensionAttachTo, + type ExtensionAttachToSpec, +} from './resolveExtensionDefinition'; export { type ExtensionDataContainer, type FeatureFlagConfig, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index a74d908024..f63df8159f 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -60,6 +60,23 @@ describe('resolveExtensionDefinition', () => { 'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined', ); }); + + 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' }, + ]); + }); }); describe('old resolveExtensionDefinition', () => { diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index a0a9a52dd3..5bed37588c 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -16,7 +16,7 @@ import { ApiHolder, AppNode } from '../apis'; import { - ExtensionAttachToSpec, + ExtensionDefinitionAttachTo, ExtensionDefinition, ExtensionDefinitionParameters, ResolvedExtensionInputs, @@ -26,6 +26,17 @@ import { ExtensionInput } from './createExtensionInput'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { OpaqueExtensionDefinition } from '@internal/frontend'; +/** @public */ +export type ExtensionAttachTo = + | { id: string; input: string } + | Array<{ id: string; input: string }>; + +/** + * @deprecated Use {@link ExtensionAttachTo} instead. + * @public + */ +export type ExtensionAttachToSpec = ExtensionAttachTo; + /** @public */ export interface Extension { $$type: '@backstage/Extension'; @@ -118,6 +129,49 @@ export type ResolveExtensionId< : never : never; +function resolveExtensionId( + kind?: string, + namespace?: string, + name?: string, +): string { + const namePart = + name && namespace ? `${namespace}/${name}` : namespace || name; + if (!namePart) { + throw new Error( + `Extension must declare an explicit namespace or name as it could not be resolved from context, kind=${kind} namespace=${namespace} name=${name}`, + ); + } + + return kind ? `${kind}:${namePart}` : namePart; +} + +function resolveAttachTo( + attachTo: ExtensionDefinitionAttachTo, + namespace?: string, +): ExtensionAttachToSpec { + const resolveSpec = ( + spec: Exclude>, + ): { id: string; input: string } => { + if ('relative' in spec && spec.relative) { + return { + id: resolveExtensionId( + spec.relative.kind, + namespace, + spec.relative.name, + ), + input: spec.input, + }; + } + return { id: spec.id, input: spec.input }; + }; + + if (Array.isArray(attachTo)) { + return attachTo.map(resolveSpec); + } + + return resolveSpec(attachTo); +} + /** @internal */ export function resolveExtensionDefinition< T extends ExtensionDefinitionParameters, @@ -129,25 +183,18 @@ export function resolveExtensionDefinition< const { name, kind, - namespace: _skip1, + namespace: internalNamespace, override: _skip2, + attachTo, ...rest } = internalDefinition; - const namespace = internalDefinition.namespace ?? context?.namespace; - - const namePart = - name && namespace ? `${namespace}/${name}` : namespace || name; - if (!namePart) { - throw new Error( - `Extension must declare an explicit namespace or name as it could not be resolved from context, kind=${kind} namespace=${namespace} name=${name}`, - ); - } - - const id = kind ? `${kind}:${namePart}` : namePart; + const namespace = internalNamespace ?? context?.namespace; + const id = resolveExtensionId(kind, namespace, name); return { ...rest, + attachTo: resolveAttachTo(attachTo, namespace), $$type: '@backstage/Extension', version: internalDefinition.version, id,