frontend-*-api: add and implement AppTreeApi

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-28 12:52:42 +02:00
parent eebab3e819
commit 733bd95746
16 changed files with 236 additions and 38 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Implement new `AppTreeApi`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Add new `AppTreeApi`.
@@ -18,7 +18,7 @@ import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toLegacyPlugin } from '../wiring/createApp';
import { BackstageRouteObject } from './types';
import { AppNode } from '../tree';
import { AppNode } from '@backstage/frontend-plugin-api';
// We always add a child that matches all subroutes but without any route refs. This makes
// sure that we're always able to match each route no matter how deep the navigation goes.
@@ -22,7 +22,7 @@ import {
import { readAppExtensionsConfig } from './readAppExtensionsConfig';
import { resolveAppTree } from './resolveAppTree';
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
import { AppTree } from './types';
import { AppTree } from '@backstage/frontend-plugin-api';
import { Config } from '@backstage/config';
import { instantiateAppNodeTree } from './instantiateAppNodeTree';
@@ -14,10 +14,4 @@
* limitations under the License.
*/
export type {
AppNode,
AppNodeEdges,
AppNodeInstance,
AppNodeSpec,
} from './types';
export { createAppTree } from './createAppTree';
@@ -25,7 +25,7 @@ import {
createAppNodeInstance,
instantiateAppNodeTree,
} from './instantiateAppNodeTree';
import { AppNodeInstance, AppNodeSpec } from './types';
import { AppNodeInstance, AppNodeSpec } from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
const testDataRef = createExtensionDataRef<string>('test');
@@ -20,7 +20,11 @@ import {
ExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance, AppNodeSpec } from './types';
import {
AppNode,
AppNodeInstance,
AppNodeSpec,
} from '@backstage/frontend-plugin-api';
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
@@ -22,7 +22,7 @@ import {
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from './types';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
/** @internal */
export function resolveAppNodeSpecs(options: {
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import { AppTree, AppNode, AppNodeInstance, AppNodeSpec } from './types';
import {
AppTree,
AppNode,
AppNodeInstance,
AppNodeSpec,
} from '@backstage/frontend-plugin-api';
function indent(str: string) {
return str.replace(/^/gm, ' ');
@@ -15,6 +15,8 @@
*/
import {
AppTreeApi,
appTreeApiRef,
createPageExtension,
createPlugin,
createThemeExtension,
@@ -23,6 +25,7 @@ import { screen, waitFor } from '@testing-library/react';
import { createApp } from './createApp';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import React from 'react';
import { useApi } from '@backstage/core-plugin-api';
describe('createApp', () => {
it('should allow themes to be installed', async () => {
@@ -90,4 +93,58 @@ describe('createApp', () => {
expect(screen.getByText('Last Page')).toBeInTheDocument(),
);
});
it('should make the app structure available through the AppTreeApi', async () => {
let appTreeApi: AppTreeApi | undefined = undefined;
const app = createApp({
configLoader: async () => new MockConfigApi({}),
features: [
createPlugin({
id: 'my-plugin',
extensions: [
createPageExtension({
id: 'plugin.my-plugin.page',
defaultPath: '/',
loader: async () => {
const Component = () => {
appTreeApi = useApi(appTreeApiRef);
return <div>My Plugin Page</div>;
};
return <Component />;
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
expect(appTreeApi).toBeDefined();
const { tree } = appTreeApi!.getTree();
expect(String(tree.root)).toMatchInlineSnapshot(`
"<core out=[core.reactElement]>
root [
<core.layout out=[core.reactElement]>
content [
<core.routes out=[core.reactElement]>
routes [
<plugin.my-plugin.page out=[core.routing.path, core.routing.ref, core.reactElement] />
]
</core.routes>
]
nav [
<core.nav out=[core.reactElement] />
]
</core.layout>
]
themes [
<themes.light out=[core.theme] />
<themes.dark out=[core.theme] />
]
</core>"
`);
});
});
@@ -17,6 +17,8 @@
import React, { JSX } from 'react';
import { ConfigReader, Config } from '@backstage/config';
import {
AppTree,
appTreeApiRef,
BackstagePlugin,
coreExtensionData,
ExtensionDataRef,
@@ -87,7 +89,8 @@ import { AppRouteBinder } from '../routing';
import { RoutingProvider } from '../routing/RoutingProvider';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
import { AppNode, createAppTree } from '../tree';
import { createAppTree } from '../tree';
import { AppNode } from '@backstage/frontend-plugin-api';
const builtinExtensions = [
Core,
@@ -262,7 +265,7 @@ export function createApp(options: {
const routeIds = collectRouteIds(allFeatures);
const App = () => (
<ApiProvider apis={createApiHolder(tree.root, config)}>
<ApiProvider apis={createApiHolder(tree, config)}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider
@@ -356,17 +359,17 @@ function createLegacyAppContext(plugins: BackstagePlugin[]): AppContext {
};
}
function createApiHolder(core: AppNode, configApi: ConfigApi): ApiHolder {
function createApiHolder(tree: AppTree, configApi: ConfigApi): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
const pluginApis =
core.edges.attachments
tree.root.edges.attachments
.get('apis')
?.map(e => e.instance?.getData(coreExtensionData.apiFactory))
.filter((x): x is AnyApiFactory => !!x) ?? [];
const themeExtensions =
core.edges.attachments
tree.root.edges.attachments
.get('themes')
?.map(e => e.instance?.getData(coreExtensionData.theme))
.filter((x): x is AppTheme => !!x) ?? [];
@@ -414,6 +417,14 @@ function createApiHolder(core: AppNode, configApi: ConfigApi): ApiHolder {
},
});
factoryRegistry.register('static', {
api: appTreeApiRef,
deps: {},
factory: () => ({
getTree: () => ({ tree }),
}),
});
factoryRegistry.register('static', {
api: appThemeApiRef,
deps: {},
@@ -7,6 +7,7 @@
import { AnyApiFactory } from '@backstage/core-plugin-api';
import { AnyApiRef } from '@backstage/core-plugin-api';
import { ApiRef } from '@backstage/core-plugin-api';
import { AppTheme } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
@@ -55,6 +56,66 @@ export type AnyRoutes = {
[name in string]: RouteRef;
};
// @public
export interface AppNode {
readonly edges: AppNodeEdges;
readonly instance?: AppNodeInstance;
readonly spec: AppNodeSpec;
}
// @public
export interface AppNodeEdges {
// (undocumented)
readonly attachedTo?: {
node: AppNode;
input: string;
};
// (undocumented)
readonly attachments: ReadonlyMap<string, AppNode[]>;
}
// @public
export interface AppNodeInstance {
getData<T>(ref: ExtensionDataRef<T>): T | undefined;
getDataRefs(): Iterable<ExtensionDataRef<unknown>>;
}
// @public
export interface AppNodeSpec {
// (undocumented)
readonly attachTo: {
id: string;
input: string;
};
// (undocumented)
readonly config?: unknown;
// (undocumented)
readonly disabled: boolean;
// (undocumented)
readonly extension: Extension<unknown>;
// (undocumented)
readonly id: string;
// (undocumented)
readonly source?: BackstagePlugin;
}
// @public
export interface AppTree {
readonly nodes: ReadonlyMap<string, AppNode>;
readonly orphans: Iterable<AppNode>;
readonly root: AppNode;
}
// @public
export interface AppTreeApi {
getTree(): {
tree: AppTree;
};
}
// @public
export const appTreeApiRef: ApiRef<AppTreeApi>;
// @public (undocumented)
export interface BackstagePlugin<
Routes extends AnyRoutes = AnyRoutes,
@@ -14,20 +14,13 @@
* limitations under the License.
*/
import {
BackstagePlugin,
Extension,
ExtensionDataRef,
} from '@backstage/frontend-plugin-api';
/*
NOTE: These types are marked as @internal for now, but the intention is for this to be a public API in the future.
*/
import { createApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin, Extension, ExtensionDataRef } from '../../wiring';
/**
* The specification for this node in the app tree.
* The specification for this {@link AppNode} in the {@link AppTree}.
*
* @internal
* @public
* @remarks
*
* The specifications for a collection of app nodes is all the information needed
@@ -43,9 +36,9 @@ export interface AppNodeSpec {
}
/**
* The connections from this node to other nodes.
* The connections from this {@link AppNode} to other nodes.
*
* @internal
* @public
* @remarks
*
* The app node edges are resolved based on the app node specs, regardless of whether
@@ -57,9 +50,9 @@ export interface AppNodeEdges {
}
/**
* The instance of this node in the app tree.
* The instance of this {@link AppNode} in the {@link AppTree}.
*
* @internal
* @public
* @remarks
*
* The app node instance is created when the `factory` function of an extension is called.
@@ -74,8 +67,9 @@ export interface AppNodeInstance {
}
/**
* A node in the {@link AppTree}.
*
* @internal
* @public
*/
export interface AppNode {
/** The specification for how this node should be instantiated */
@@ -87,15 +81,34 @@ export interface AppNode {
}
/**
* The app tree containing all nodes of the app.
* The app tree containing all {@link AppNode}s of the app.
*
* @internal
* @public
*/
export interface AppTree {
/** The root node of the app */
root: AppNode;
readonly root: AppNode;
/** A map of all nodes in the app by ID, including orphaned or disabled nodes */
nodes: ReadonlyMap<string /* id */, AppNode>;
readonly nodes: ReadonlyMap<string /* id */, AppNode>;
/** A sequence of all nodes with a parent that is not reachable from the app root node */
orphans: Iterable<AppNode>;
readonly orphans: Iterable<AppNode>;
}
/**
* The API for interacting with the {@link AppTree}.
*
* @public
*/
export interface AppTreeApi {
/**
* Get the {@link AppTree} for the app.
*/
getTree(): { tree: AppTree };
}
/**
* The `ApiRef` of {@link AppTreeApi}.
*
* @public
*/
export const appTreeApiRef = createApiRef<AppTreeApi>({ id: 'core.app-tree' });
@@ -0,0 +1,25 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
appTreeApiRef,
type AppNode,
type AppNodeEdges,
type AppNodeInstance,
type AppNodeSpec,
type AppTree,
type AppTreeApi,
} from './AppTreeApi';
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './definitions';
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export * from './apis';
export * from './components';
export * from './extensions';
export * from './routing';