Merge pull request #26152 from backstage/mob/backwerds-support

NFS: Added the ability to `replace` previously declared inputs and redirect them
This commit is contained in:
Ben Lambert
2024-08-26 11:30:58 +02:00
committed by GitHub
11 changed files with 264 additions and 48 deletions
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/frontend-plugin-api': patch
'@backstage/frontend-app-api': patch
---
Added support for defining `replaces` in `createExtensionInput` which will allow extensions to redirect missing `attachTo` points to an input of the created extension.
```ts
export const AppThemeApi = ApiBlueprint.makeWithOverrides({
name: 'app-theme',
inputs: {
themes: createExtensionInput([ThemeBlueprint.dataRefs.theme], {
// attachTo: { id: 'app', input: 'themes'} will be redirected to this input instead
replaces: [{ id: 'app', input: 'themes' }],
}),
},
factory: () {
...
}
});
```
@@ -36,7 +36,9 @@ import { AppThemeSelector } from '@backstage/core-app-api';
export const AppThemeApi = ApiBlueprint.makeWithOverrides({
name: 'app-theme',
inputs: {
themes: createExtensionInput([ThemeBlueprint.dataRefs.theme]),
themes: createExtensionInput([ThemeBlueprint.dataRefs.theme], {
replaces: [{ id: 'app', input: 'themes' }],
}),
},
factory: (originalFactory, { inputs }) => {
return originalFactory({
@@ -29,9 +29,10 @@ import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi';
export const ComponentsApi = ApiBlueprint.makeWithOverrides({
name: 'components',
inputs: {
components: createExtensionInput([
createComponentExtension.componentDataRef,
]),
components: createExtensionInput(
[createComponentExtension.componentDataRef],
{ replaces: [{ id: 'app', input: 'components' }] },
),
},
factory: (originalFactory, { inputs }) => {
return originalFactory({
@@ -31,7 +31,9 @@ import { icons as defaultIcons } from '../../../app-defaults/src/defaults';
export const IconsApi = ApiBlueprint.makeWithOverrides({
name: 'icons',
inputs: {
icons: createExtensionInput([IconBundleBlueprint.dataRefs.icons]),
icons: createExtensionInput([IconBundleBlueprint.dataRefs.icons], {
replaces: [{ id: 'app', input: 'icons' }],
}),
},
factory: (originalFactory, { inputs }) => {
return originalFactory({
@@ -28,7 +28,9 @@ export const Root = createExtension({
app: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
}),
apis: createExtensionInput([ApiBlueprint.dataRefs.factory]),
apis: createExtensionInput([ApiBlueprint.dataRefs.factory], {
replaces: [{ id: 'app', input: 'apis' }],
}),
},
output: [],
factory: () => [],
@@ -33,9 +33,10 @@ import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementa
export const TranslationsApi = ApiBlueprint.makeWithOverrides({
name: 'translations',
inputs: {
translations: createExtensionInput([
TranslationBlueprint.dataRefs.translation,
]),
translations: createExtensionInput(
[TranslationBlueprint.dataRefs.translation],
{ replaces: [{ id: 'app', input: 'translations' }] },
),
},
factory: (originalFactory, { inputs }) => {
return originalFactory({
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import { createExtension, Extension } from '@backstage/frontend-plugin-api';
import {
coreExtensionData,
createExtension,
createExtensionInput,
Extension,
} from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
@@ -165,4 +170,146 @@ describe('buildAppTree', () => {
]),
).toThrow("Unexpected duplicate extension id 'a'");
});
describe('redirects', () => {
it('should throw an error when theres a duplicate redirect target', () => {
const e1 = resolveExtensionDefinition(
createExtension({
name: 'test',
attachTo: { id: 'nonexistent', input: 'nonexistent' },
inputs: {
test: createExtensionInput([coreExtensionData.reactElement], {
replaces: [{ id: 'a', input: 'test' }],
}),
},
output: [],
factory: () => [],
}),
) as Extension<unknown, unknown>;
const e2 = resolveExtensionDefinition(
createExtension({
name: 'test-2',
attachTo: { id: 'nonexistent', input: 'nonexistent' },
inputs: {
test: createExtensionInput([coreExtensionData.reactElement], {
replaces: [{ id: 'a', input: 'test' }],
}),
},
output: [],
factory: () => [],
}),
) as Extension<unknown, unknown>;
expect(() =>
resolveAppTree('a', [
{ ...baseSpec, id: 'a', extension: e1 },
{ ...baseSpec, id: 'b', extension: e2 },
]),
).toThrow("Duplicate redirect target for input 'test' in extension 'b'");
});
it('should set the correct attachment point for a redirect', () => {
const e1 = resolveExtensionDefinition(
createExtension({
name: 'test',
attachTo: { id: 'nonexistent', input: 'nonexistent' },
inputs: {
test: createExtensionInput([coreExtensionData.reactElement], {
replaces: [{ id: 'replace', input: 'me' }],
}),
},
output: [],
factory: () => [],
}),
) as Extension<unknown, unknown>;
const e2 = resolveExtensionDefinition(
createExtension({
name: 'test-2',
attachTo: { id: 'replace', input: 'me' },
output: [],
factory: () => [],
}),
) as Extension<unknown, unknown>;
const tree = resolveAppTree('a', [
{ attachTo: e1.attachTo, id: 'a', extension: e1, disabled: false },
{ attachTo: e2.attachTo, id: 'b', extension: e2, disabled: false },
]);
expect(tree.root).toMatchInlineSnapshot(`
{
"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>"
`);
});
it('should not allow redirects for attachment points that already exist', () => {
const e1 = resolveExtensionDefinition(
createExtension({
name: 'test',
attachTo: { id: 'a', input: 'a' },
inputs: {
test: createExtensionInput([coreExtensionData.reactElement], {
replaces: [{ id: 'test-2', input: 'test' }],
}),
},
output: [],
factory: () => [],
}),
) as Extension<unknown, unknown>;
const e2 = resolveExtensionDefinition(
createExtension({
name: 'test-2',
attachTo: { id: 'b', input: 'b' },
inputs: {
test: createExtensionInput([coreExtensionData.reactElement]),
},
output: [],
factory: () => [],
}),
) as Extension<unknown, unknown>;
const e3 = resolveExtensionDefinition(
createExtension({
name: 'test-3',
attachTo: { id: 'test-2', input: 'test' },
output: [],
factory: () => [],
}),
) as Extension<unknown, unknown>;
const tree = resolveAppTree('test-2', [
{ attachTo: e1.attachTo, id: e1.id, extension: e1, disabled: false },
{ attachTo: e2.attachTo, id: e2.id, extension: e2, disabled: false },
{ attachTo: e3.attachTo, id: e3.id, extension: e3, disabled: false },
]);
expect(tree.nodes.get('test-3')?.edges.attachedTo?.node).toBe(
tree.nodes.get('test-2'),
);
});
});
});
@@ -21,6 +21,9 @@ import {
AppNodeSpec,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
function indent(str: string) {
return str.replace(/^/gm, ' ');
}
@@ -38,9 +41,7 @@ class SerializableAppNode implements AppNode {
this.spec = spec;
}
setParent(parent: SerializableAppNode) {
const input = this.spec.attachTo.input;
setParent(parent: SerializableAppNode, input: string) {
this.edges.attachedTo = { node: parent, input };
const parentInputEdges = parent.edges.attachments.get(input);
@@ -87,6 +88,24 @@ class SerializableAppNode implements AppNode {
}
}
function makeRedirectKey(attachTo: { id: string; input: string }) {
return `${attachTo.id}%${attachTo.input}`;
}
const isValidAttachmentPoint = (
attachTo: { id: string; input: string },
nodes: Map<string, SerializableAppNode>,
) => {
if (!nodes.has(attachTo.id)) {
return false;
}
return (
attachTo.input in
toInternalExtension(nodes.get(attachTo.id)!.spec.extension).inputs
);
};
/**
* Build the app tree by iterating through all node specs and constructing the app
* tree with all attachments in the same order as they appear in the input specs array.
@@ -98,17 +117,7 @@ export function resolveAppTree(
): AppTree {
const nodes = new Map<string, 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;
// While iterating through the inputs specs we keep track of all nodes that were created
// before their parent, and attach them later when the parent is created.
// As we find the parents and attach the children, we remove them from this map. This means
// that after iterating through all input specs, this will be a map for each root node.
const orphansByParent = new Map<
string /* parentId */,
SerializableAppNode[]
>();
const redirectTargetsByKey = new Map<string, { id: string; input: string }>();
for (const spec of specs) {
// The main check with a more helpful error message happens in resolveAppNodeSpecs
@@ -119,28 +128,46 @@ export function resolveAppTree(
const node = new SerializableAppNode(spec);
nodes.set(spec.id, node);
const internal = toInternalExtension(spec.extension);
for (const [inputName, input] of Object.entries(internal.inputs)) {
if (input.replaces) {
for (const replace of input.replaces) {
const key = makeRedirectKey(replace);
if (redirectTargetsByKey.has(key)) {
throw new Error(
`Duplicate redirect target for input '${inputName}' in extension '${spec.id}'`,
);
}
redirectTargetsByKey.set(key, { id: spec.id, input: inputName });
}
}
}
}
const orphans = new 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;
for (const node of nodes.values()) {
const spec = node.spec;
// 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 {
const parent = nodes.get(spec.attachTo.id);
if (parent) {
node.setParent(parent);
} else {
const orphanNodesForParent = orphansByParent.get(spec.attachTo.id);
if (orphanNodesForParent) {
orphanNodesForParent.push(node);
} else {
orphansByParent.set(spec.attachTo.id, [node]);
}
}
}
let attachTo = node.spec.attachTo;
const orphanedChildren = orphansByParent.get(spec.id);
if (orphanedChildren) {
orphansByParent.delete(spec.id);
for (const orphan of orphanedChildren) {
orphan.setParent(node);
if (!isValidAttachmentPoint(attachTo, nodes)) {
attachTo =
redirectTargetsByKey.get(makeRedirectKey(attachTo)) ?? attachTo;
}
const parent = nodes.get(attachTo.id);
if (parent) {
node.setParent(parent, attachTo.input);
} else {
orphans.push(node);
}
}
}
@@ -152,6 +179,6 @@ export function resolveAppTree(
return {
root: rootNode,
nodes,
orphans: Array.from(orphansByParent.values()).flat(),
orphans,
};
}
+13 -3
View File
@@ -687,7 +687,12 @@ export function createExtensionInput<
},
>(
extensionData: Array<UExtensionData>,
config?: TConfig,
config?: TConfig & {
replaces?: Array<{
id: string;
input: string;
}>;
},
): ExtensionInput<
UExtensionData,
{
@@ -1183,7 +1188,7 @@ export interface ExtensionDefinition<
// @public (undocumented)
export interface ExtensionInput<
TExtensionData extends ExtensionDataRef<
UExtensionData extends ExtensionDataRef<
unknown,
string,
{
@@ -1200,7 +1205,12 @@ export interface ExtensionInput<
// (undocumented)
config: TConfig;
// (undocumented)
extensionData: Array<TExtensionData>;
extensionData: Array<UExtensionData>;
// (undocumented)
replaces?: Array<{
id: string;
input: string;
}>;
}
// @public (undocumented)
@@ -116,6 +116,7 @@ describe('ApiBlueprint', () => {
"extensionData": [
[Function],
],
"replaces": undefined,
},
},
"kind": "api",
@@ -18,12 +18,13 @@ import { ExtensionDataRef } from './createExtensionDataRef';
/** @public */
export interface ExtensionInput<
TExtensionData extends ExtensionDataRef<unknown, string, { optional?: true }>,
UExtensionData extends ExtensionDataRef<unknown, string, { optional?: true }>,
TConfig extends { singleton: boolean; optional: boolean },
> {
$$type: '@backstage/ExtensionInput';
extensionData: Array<TExtensionData>;
extensionData: Array<UExtensionData>;
config: TConfig;
replaces?: Array<{ id: string; input: string }>;
}
/** @public */
@@ -32,7 +33,7 @@ export function createExtensionInput<
TConfig extends { singleton?: boolean; optional?: boolean },
>(
extensionData: Array<UExtensionData>,
config?: TConfig,
config?: TConfig & { replaces?: Array<{ id: string; input: string }> },
): ExtensionInput<
UExtensionData,
{
@@ -71,6 +72,7 @@ export function createExtensionInput<
? true
: false,
},
replaces: config?.replaces,
} as ExtensionInput<
UExtensionData,
{