frontend-plugin-api: add support for multiple attachment points
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
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.
|
||||
@@ -119,6 +119,84 @@ 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(JSON.parse(JSON.stringify(tree.root))).toMatchInlineSnapshot(`
|
||||
{
|
||||
"attachments": {
|
||||
"x": [
|
||||
{
|
||||
"attachments": {
|
||||
"x": [
|
||||
{
|
||||
"id": "c",
|
||||
},
|
||||
{
|
||||
"id": "d",
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "b",
|
||||
},
|
||||
{
|
||||
"attachments": {
|
||||
"x": [
|
||||
{
|
||||
"id": "d",
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "c",
|
||||
},
|
||||
],
|
||||
},
|
||||
"id": "a",
|
||||
}
|
||||
`);
|
||||
expect(String(tree.root)).toMatchInlineSnapshot(`
|
||||
"<a>
|
||||
x [
|
||||
<b>
|
||||
x [
|
||||
<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 +317,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', () => {
|
||||
|
||||
@@ -155,9 +155,33 @@ 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) {
|
||||
if (!foundFirstParent) {
|
||||
foundFirstParent = true;
|
||||
node.setParent(parent, attachTo.input);
|
||||
} else {
|
||||
// TODO(Rugvip): Perhaps makes sense to keep track of these with a `clones` map, similar to `orphans`?
|
||||
const clonedNode = new SerializableAppNode(spec);
|
||||
clonedNode.setParent(parent, attachTo.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
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