Merge pull request #28619 from backstage/rugvip/multi
frontend-plugin-api: add support for multiple attachTos
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Add support for defining multiple attachment points for extensions and blueprints.
|
||||
@@ -337,3 +337,21 @@ const routableExtension = createExtension({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Multiple attachment points
|
||||
|
||||
For some cases it can be useful to attach extensions to multiple parents. An example of this are Scaffolder field extensions or TechDocs addons that are consumed by multiple extensions. Specifying multiple attachments is done by providing an array of attachment points to the `attachTo` property of the extension. Keep in mind that this increases the complexity of your extension tree and should only be done when necessary. The following example shows how to attach our example extension to multiple parents:
|
||||
|
||||
```tsx
|
||||
const extension = createExtension({
|
||||
name: 'my-extension',
|
||||
attachTo: [
|
||||
{ id: 'my-first-parent', input: 'content' },
|
||||
{ id: 'my-second-parent', input: 'children' }, // The input names do not need to match
|
||||
],
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory() {
|
||||
return [coreExtensionData.reactElement(<div>Hello World</div>)];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -119,6 +119,56 @@ describe('buildAppTree', () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it('should create a tree with clones', () => {
|
||||
const tree = resolveAppTree('a', [
|
||||
{ ...baseSpec, id: 'a' },
|
||||
{ ...baseSpec, id: 'b', attachTo: { id: 'a', input: 'x' } },
|
||||
{
|
||||
...baseSpec,
|
||||
id: 'c',
|
||||
attachTo: [
|
||||
{ id: 'a', input: 'x' },
|
||||
{ id: 'b', input: 'x' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...baseSpec,
|
||||
id: 'd',
|
||||
attachTo: [
|
||||
{ id: 'b', input: 'x' },
|
||||
{ id: 'c', input: 'x' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b', 'c', 'd']);
|
||||
|
||||
expect(String(tree.root)).toMatchInlineSnapshot(`
|
||||
"<a>
|
||||
x [
|
||||
<b>
|
||||
x [
|
||||
<c>
|
||||
x [
|
||||
<d />
|
||||
]
|
||||
</c>
|
||||
<d />
|
||||
]
|
||||
</b>
|
||||
<c>
|
||||
x [
|
||||
<d />
|
||||
]
|
||||
</c>
|
||||
]
|
||||
</a>"
|
||||
`);
|
||||
|
||||
const orphans = Array.from(tree.orphans).map(String);
|
||||
expect(orphans).toMatchInlineSnapshot(`[]`);
|
||||
});
|
||||
|
||||
it('should create a tree out of order', () => {
|
||||
const tree = resolveAppTree('b', [
|
||||
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
|
||||
@@ -239,30 +289,30 @@ describe('buildAppTree', () => {
|
||||
]);
|
||||
|
||||
expect(tree.root).toMatchInlineSnapshot(`
|
||||
{
|
||||
"attachments": {
|
||||
"test": [
|
||||
{
|
||||
"attachments": undefined,
|
||||
"id": "b",
|
||||
"output": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "a",
|
||||
"output": undefined,
|
||||
}
|
||||
`);
|
||||
{
|
||||
"attachments": {
|
||||
"test": [
|
||||
{
|
||||
"attachments": undefined,
|
||||
"id": "b",
|
||||
"output": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "a",
|
||||
"output": undefined,
|
||||
}
|
||||
`);
|
||||
|
||||
expect(tree.orphans).toMatchInlineSnapshot(`[]`);
|
||||
|
||||
expect(String(tree.root)).toMatchInlineSnapshot(`
|
||||
"<a>
|
||||
test [
|
||||
<b />
|
||||
]
|
||||
</a>"
|
||||
`);
|
||||
"<a>
|
||||
test [
|
||||
<b />
|
||||
]
|
||||
</a>"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not allow redirects for attachment points that already exist', () => {
|
||||
|
||||
@@ -145,6 +145,7 @@ export function resolveAppTree(
|
||||
}
|
||||
|
||||
const orphans = new Array<SerializableAppNode>();
|
||||
const clones = new Map<string, Array<SerializableAppNode>>();
|
||||
|
||||
// A node with the provided rootNodeId must be found in the tree, and it must not be attached to anything
|
||||
let rootNode: AppNode | undefined = undefined;
|
||||
@@ -155,9 +156,42 @@ export function resolveAppTree(
|
||||
// TODO: For now we simply ignore the attachTo spec of the root node, but it'd be cleaner if we could avoid defining it
|
||||
if (spec.id === rootNodeId) {
|
||||
rootNode = node;
|
||||
} else {
|
||||
let attachTo = node.spec.attachTo;
|
||||
} else if (Array.isArray(spec.attachTo)) {
|
||||
let foundFirstParent = false;
|
||||
for (const origAttachTo of spec.attachTo) {
|
||||
let attachTo = origAttachTo;
|
||||
|
||||
if (!isValidAttachmentPoint(attachTo, nodes)) {
|
||||
attachTo =
|
||||
redirectTargetsByKey.get(makeRedirectKey(attachTo)) ?? attachTo;
|
||||
}
|
||||
|
||||
const parent = nodes.get(attachTo.id);
|
||||
if (parent) {
|
||||
const cloneParents = clones.get(attachTo.id) ?? [];
|
||||
|
||||
if (!foundFirstParent) {
|
||||
foundFirstParent = true;
|
||||
node.setParent(parent, attachTo.input);
|
||||
} else {
|
||||
cloneParents.unshift(parent);
|
||||
}
|
||||
|
||||
for (const extraParent of cloneParents) {
|
||||
const clonedNode = new SerializableAppNode(spec);
|
||||
clonedNode.setParent(extraParent, attachTo.input);
|
||||
clones.set(
|
||||
spec.id,
|
||||
clones.get(spec.id)?.concat(clonedNode) ?? [clonedNode],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!foundFirstParent) {
|
||||
orphans.push(node);
|
||||
}
|
||||
} else {
|
||||
let attachTo = spec.attachTo;
|
||||
if (!isValidAttachmentPoint(attachTo, nodes)) {
|
||||
attachTo =
|
||||
redirectTargetsByKey.get(makeRedirectKey(attachTo)) ?? attachTo;
|
||||
|
||||
@@ -350,4 +350,84 @@ describe('createSpecializedApp', () => {
|
||||
|
||||
expect(screen.getByText('link: /test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support multiple attachment points', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
|
||||
createSpecializedApp({
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'root',
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
inputs: {
|
||||
children: createExtensionInput([
|
||||
coreExtensionData.reactElement,
|
||||
]),
|
||||
},
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis }) => {
|
||||
appTreeApi = apis.get(appTreeApiRef);
|
||||
return [coreExtensionData.reactElement(<div />)];
|
||||
},
|
||||
}),
|
||||
createExtension({
|
||||
name: 'a',
|
||||
attachTo: { id: 'test/root', input: 'children' },
|
||||
inputs: {
|
||||
children: createExtensionInput([
|
||||
coreExtensionData.reactElement,
|
||||
]),
|
||||
},
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [coreExtensionData.reactElement(<div />)],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'b',
|
||||
attachTo: { id: 'test/root', input: 'children' },
|
||||
inputs: {
|
||||
children: createExtensionInput([
|
||||
coreExtensionData.reactElement,
|
||||
]),
|
||||
},
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [coreExtensionData.reactElement(<div />)],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'cloned',
|
||||
attachTo: [
|
||||
{ id: 'test/a', input: 'children' },
|
||||
{ id: 'test/b', input: 'children' },
|
||||
],
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [coreExtensionData.reactElement(<div />)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(String(appTreeApi!.getTree().tree.root)).toMatchInlineSnapshot(`
|
||||
"<root out=[core.reactElement]>
|
||||
app [
|
||||
<test/root out=[core.reactElement]>
|
||||
children [
|
||||
<test/a out=[core.reactElement]>
|
||||
children [
|
||||
<test/cloned out=[core.reactElement] />
|
||||
]
|
||||
</test/a>
|
||||
<test/b out=[core.reactElement]>
|
||||
children [
|
||||
<test/cloned out=[core.reactElement] />
|
||||
]
|
||||
</test/b>
|
||||
]
|
||||
</test/root>
|
||||
]
|
||||
</root>"
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
AnyExtensionDataRef,
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
ExtensionAttachToSpec,
|
||||
ExtensionDataValue,
|
||||
ExtensionDefinition,
|
||||
ExtensionDefinitionParameters,
|
||||
@@ -35,7 +36,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
readonly kind?: string;
|
||||
readonly namespace?: string;
|
||||
readonly name?: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly attachTo: ExtensionAttachToSpec;
|
||||
readonly disabled: boolean;
|
||||
readonly configSchema?: PortableSchema<any, any>;
|
||||
readonly inputs: {
|
||||
@@ -66,7 +67,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
readonly kind?: string;
|
||||
readonly namespace?: string;
|
||||
readonly name?: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly attachTo: ExtensionAttachToSpec;
|
||||
readonly disabled: boolean;
|
||||
readonly configSchema?: PortableSchema<any, any>;
|
||||
readonly inputs: {
|
||||
|
||||
@@ -227,10 +227,7 @@ export interface AppNodeInstance {
|
||||
// @public
|
||||
export interface AppNodeSpec {
|
||||
// (undocumented)
|
||||
readonly attachTo: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
readonly attachTo: ExtensionAttachToSpec;
|
||||
// (undocumented)
|
||||
readonly config?: unknown;
|
||||
// (undocumented)
|
||||
@@ -593,10 +590,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
},
|
||||
> = {
|
||||
kind: TKind;
|
||||
attachTo: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
attachTo: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
@@ -680,10 +674,7 @@ export type CreateExtensionOptions<
|
||||
> = {
|
||||
kind?: TKind;
|
||||
name?: TName;
|
||||
attachTo: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
attachTo: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
@@ -813,10 +804,7 @@ export interface Extension<TConfig, TConfigInput = TConfig> {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/Extension';
|
||||
// (undocumented)
|
||||
readonly attachTo: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
readonly attachTo: ExtensionAttachToSpec;
|
||||
// (undocumented)
|
||||
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
|
||||
// (undocumented)
|
||||
@@ -825,6 +813,17 @@ export interface Extension<TConfig, TConfigInput = TConfig> {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionAttachToSpec =
|
||||
| {
|
||||
id: string;
|
||||
input: string;
|
||||
}
|
||||
| Array<{
|
||||
id: string;
|
||||
input: string;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionBlueprint<
|
||||
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
|
||||
@@ -834,10 +833,7 @@ export interface ExtensionBlueprint<
|
||||
// (undocumented)
|
||||
make<TNewName extends string | undefined>(args: {
|
||||
name?: TNewName;
|
||||
attachTo?: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
attachTo?: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
params: T['params'];
|
||||
}): ExtensionDefinition<{
|
||||
@@ -867,10 +863,7 @@ export interface ExtensionBlueprint<
|
||||
},
|
||||
>(args: {
|
||||
name?: TNewName;
|
||||
attachTo?: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
attachTo?: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
@@ -1057,10 +1050,7 @@ export type ExtensionDefinition<
|
||||
>(
|
||||
args: Expand<
|
||||
{
|
||||
attachTo?: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
attachTo?: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
|
||||
import {
|
||||
FrontendPlugin,
|
||||
Extension,
|
||||
ExtensionDataRef,
|
||||
ExtensionAttachToSpec,
|
||||
} from '../../wiring';
|
||||
|
||||
/**
|
||||
* The specification for this {@link AppNode} in the {@link AppTree}.
|
||||
@@ -28,7 +33,7 @@ import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
|
||||
*/
|
||||
export interface AppNodeSpec {
|
||||
readonly id: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly attachTo: ExtensionAttachToSpec;
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
readonly disabled: boolean;
|
||||
readonly config?: unknown;
|
||||
|
||||
@@ -202,6 +202,20 @@ describe('createExtension', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should create an extension with multiple attachment points', () => {
|
||||
const extension = createExtension({
|
||||
attachTo: [
|
||||
{ id: 'root', input: 'default' },
|
||||
{ id: 'other', input: 'default' },
|
||||
],
|
||||
output: [stringDataRef, numberDataRef.optional()],
|
||||
factory: () => [stringDataRef('bar')],
|
||||
});
|
||||
expect(String(extension)).toBe(
|
||||
'ExtensionDefinition{attachTo=root@default+other@default}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an extension with input', () => {
|
||||
const extension = createExtension({
|
||||
attachTo: { id: 'root', input: 'default' },
|
||||
|
||||
@@ -112,6 +112,11 @@ export type VerifyExtensionFactoryOutput<
|
||||
>}`
|
||||
: never;
|
||||
|
||||
/** @public */
|
||||
export type ExtensionAttachToSpec =
|
||||
| { id: string; input: string }
|
||||
| Array<{ id: string; input: string }>;
|
||||
|
||||
/** @public */
|
||||
export type CreateExtensionOptions<
|
||||
TKind extends string | undefined,
|
||||
@@ -128,7 +133,7 @@ export type CreateExtensionOptions<
|
||||
> = {
|
||||
kind?: TKind;
|
||||
name?: TName;
|
||||
attachTo: { id: string; input: string };
|
||||
attachTo: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
@@ -183,7 +188,7 @@ export type ExtensionDefinition<
|
||||
>(
|
||||
args: Expand<
|
||||
{
|
||||
attachTo?: { id: string; input: string };
|
||||
attachTo?: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
@@ -338,7 +343,12 @@ export function createExtension<
|
||||
if (options.name) {
|
||||
parts.push(`name=${options.name}`);
|
||||
}
|
||||
parts.push(`attachTo=${options.attachTo.id}@${options.attachTo.input}`);
|
||||
parts.push(
|
||||
`attachTo=${[options.attachTo]
|
||||
.flat()
|
||||
.map(a => `${a.id}@${a.input}`)
|
||||
.join('+')}`,
|
||||
);
|
||||
return `ExtensionDefinition{${parts.join(',')}}`;
|
||||
},
|
||||
override(overrideOptions) {
|
||||
|
||||
@@ -87,7 +87,11 @@ describe('createExtensionBlueprint', () => {
|
||||
it('should allow creation of extension blueprints with a generator', () => {
|
||||
const TestExtensionBlueprint = createExtensionBlueprint({
|
||||
kind: 'test-extension',
|
||||
attachTo: { id: 'test', input: 'default' },
|
||||
// Try multiple attachment points for this one
|
||||
attachTo: [
|
||||
{ id: 'test-1', input: 'default' },
|
||||
{ id: 'test-2', input: 'default' },
|
||||
],
|
||||
output: [coreExtensionData.reactElement],
|
||||
*factory(params: { text: string }) {
|
||||
yield coreExtensionData.reactElement(<h1>{params.text}</h1>);
|
||||
@@ -103,10 +107,10 @@ describe('createExtensionBlueprint', () => {
|
||||
|
||||
expect(extension).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
attachTo: {
|
||||
id: 'test',
|
||||
input: 'default',
|
||||
},
|
||||
attachTo: [
|
||||
{ id: 'test-1', input: 'default' },
|
||||
{ id: 'test-2', input: 'default' },
|
||||
],
|
||||
configSchema: undefined,
|
||||
disabled: false,
|
||||
inputs: {},
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { ApiHolder, AppNode } from '../apis';
|
||||
import { Expand } from '@backstage/types';
|
||||
import {
|
||||
ExtensionAttachToSpec,
|
||||
ExtensionDefinition,
|
||||
ResolvedExtensionInputs,
|
||||
VerifyExtensionFactoryOutput,
|
||||
@@ -57,7 +58,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
TDataRefs extends { [name in string]: AnyExtensionDataRef },
|
||||
> = {
|
||||
kind: TKind;
|
||||
attachTo: { id: string; input: string };
|
||||
attachTo: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
@@ -107,7 +108,7 @@ export interface ExtensionBlueprint<
|
||||
|
||||
make<TNewName extends string | undefined>(args: {
|
||||
name?: TNewName;
|
||||
attachTo?: { id: string; input: string };
|
||||
attachTo?: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
params: T['params'];
|
||||
}): ExtensionDefinition<{
|
||||
@@ -141,7 +142,7 @@ export interface ExtensionBlueprint<
|
||||
},
|
||||
>(args: {
|
||||
name?: TNewName;
|
||||
attachTo?: { id: string; input: string };
|
||||
attachTo?: ExtensionAttachToSpec;
|
||||
disabled?: boolean;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
createExtension,
|
||||
type ExtensionDefinition,
|
||||
type ExtensionDefinitionParameters,
|
||||
type ExtensionAttachToSpec,
|
||||
type CreateExtensionOptions,
|
||||
type ResolvedExtensionInput,
|
||||
type ResolvedExtensionInputs,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { ApiHolder, AppNode } from '../apis';
|
||||
import {
|
||||
ExtensionAttachToSpec,
|
||||
ExtensionDefinition,
|
||||
ExtensionDefinitionParameters,
|
||||
ResolvedExtensionInputs,
|
||||
@@ -32,7 +33,7 @@ import { OpaqueExtensionDefinition } from '@internal/frontend';
|
||||
export interface Extension<TConfig, TConfigInput = TConfig> {
|
||||
$$type: '@backstage/Extension';
|
||||
readonly id: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly attachTo: ExtensionAttachToSpec;
|
||||
readonly disabled: boolean;
|
||||
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user