frontend-plugin-api: add support for relative attachments
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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<unknown, unknown>;
|
||||
readonly disabled: boolean;
|
||||
readonly config?: unknown;
|
||||
|
||||
@@ -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=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@tabs}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an extension with input', () => {
|
||||
const extension = createExtension({
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
|
||||
@@ -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<UOutput>;
|
||||
@@ -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 = '<plugin>';
|
||||
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) {
|
||||
|
||||
@@ -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<UOutput>;
|
||||
@@ -214,7 +214,7 @@ export interface ExtensionBlueprint<
|
||||
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
|
||||
>(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 &
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<TConfig, TConfigInput = TConfig> {
|
||||
$$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<ExtensionDefinitionAttachTo, Array<any>>,
|
||||
): { 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,
|
||||
|
||||
Reference in New Issue
Block a user