Merge pull request #19850 from backstage/mob/api-provider

frontend-app-api: implement most bits of AppManager + add sidebar
This commit is contained in:
Patrik Oldsberg
2023-09-11 13:55:09 +02:00
committed by GitHub
23 changed files with 529 additions and 131 deletions
+5 -1
View File
@@ -6,9 +6,13 @@
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
// @public (undocumented)
export function createApp(options: { plugins: BackstagePlugin[] }): {
export function createApp(options: {
plugins: BackstagePlugin[];
config?: ConfigApi;
}): {
createRoot(): JSX.Element;
};
```
+3
View File
@@ -34,10 +34,13 @@
],
"dependencies": {
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-graphiql": "workspace:^",
"@backstage/types": "workspace:^",
"@material-ui/core": "^4.12.4",
"lodash": "^4.17.21"
},
"peerDependencies": {
+183 -17
View File
@@ -20,7 +20,10 @@ import {
BackstagePlugin,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { CoreRouter } from './extensions/CoreRouter';
import { Core } from './extensions/Core';
import { CoreRoutes } from './extensions/CoreRoutes';
import { CoreLayout } from './extensions/CoreLayout';
import { CoreNav } from './extensions/CoreNav';
import {
createExtensionInstance,
ExtensionInstance,
@@ -31,22 +34,66 @@ import {
readAppExtensionParameters,
} from './wiring/parameters';
import { RoutingProvider } from './routing/RoutingContext';
import { RouteRef } from '@backstage/core-plugin-api';
import {
AnyApiFactory,
ApiHolder,
AppComponents,
AppContext,
appThemeApiRef,
ConfigApi,
configApiRef,
IconComponent,
RouteRef,
BackstagePlugin as LegacyBackstagePlugin,
featureFlagsApiRef,
} from '@backstage/core-plugin-api';
import { getAvailablePlugins } from './wiring/discovery';
import {
ApiFactoryRegistry,
ApiProvider,
ApiResolver,
AppThemeSelector,
} from '@backstage/core-app-api';
// TODO: Get rid of all of these
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppThemeProvider } from '../../core-app-api/src/app/AppThemeProvider';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppContextProvider } from '../../core-app-api/src/app/AppContext';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { LocalStorageFeatureFlags } from '../../core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseUrlConfigs';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
apis as defaultApis,
components as defaultComponents,
icons as defaultIcons,
themes as defaultThemes,
} from '../../app-defaults/src/defaults';
import { BrowserRouter } from 'react-router-dom';
/** @public */
export function createApp(options: { plugins: BackstagePlugin[] }): {
export function createApp(options: {
plugins: BackstagePlugin[];
config?: ConfigApi;
}): {
createRoot(): JSX.Element;
} {
const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any);
const appConfig =
options?.config ??
ConfigReader.fromConfigs(overrideBaseUrlConfigs(defaultConfigLoaderSync()));
const builtinExtensions = [CoreRouter];
const builtinExtensions = [Core, CoreRoutes, CoreNav, CoreLayout];
const discoveredPlugins = getAvailablePlugins();
const allPlugins = [...discoveredPlugins, ...options.plugins];
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
const extensionParams = mergeExtensionParameters({
sources: [...options.plugins, ...discoveredPlugins],
sources: allPlugins,
builtinExtensions,
parameters: readAppExtensionParameters(appConfig),
});
@@ -115,25 +162,144 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
const routePaths = extractRouteInfoFromInstanceTree(rootInstances);
const coreInstance = rootInstances.find(({ id }) => id === 'core');
if (!coreInstance) {
throw Error('Unable to find core extension instance');
}
const apiHolder = createApiHolder(coreInstance, appConfig);
const appContext = createLegacyAppContext(allPlugins);
return {
createRoot() {
const rootComponents = rootInstances.map(
e =>
e.data.get(
coreExtensionData.reactComponent.id,
) as typeof coreExtensionData.reactComponent.T,
);
const rootComponents = rootInstances
.map(
e =>
e.data.get(
coreExtensionData.reactComponent.id,
) as typeof coreExtensionData.reactComponent.T,
)
.filter(Boolean);
return (
<RoutingProvider routePaths={routePaths}>
{rootComponents.map((Component, i) => (
<Component key={i} />
))}
</RoutingProvider>
<ApiProvider apis={apiHolder}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider routePaths={routePaths}>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>
{rootComponents.map((Component, i) => (
<Component key={i} />
))}
</BrowserRouter>
</RoutingProvider>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
},
};
}
function toLegacyPlugin(plugin: BackstagePlugin): LegacyBackstagePlugin {
const errorMsg = 'Not implemented in legacy plugin compatibility layer';
const notImplemented = () => {
throw new Error(errorMsg);
};
return {
getId(): string {
return plugin.id;
},
get routes(): never {
throw new Error(errorMsg);
},
get externalRoutes(): never {
throw new Error(errorMsg);
},
getApis: notImplemented,
getFeatureFlags: notImplemented,
provide: notImplemented,
__experimentalReconfigure: notImplemented,
};
}
function createLegacyAppContext(plugins: BackstagePlugin[]): AppContext {
return {
getPlugins(): LegacyBackstagePlugin[] {
return plugins.map(toLegacyPlugin);
},
getSystemIcon(key: string): IconComponent | undefined {
return key in defaultIcons
? defaultIcons[key as keyof typeof defaultIcons]
: undefined;
},
getSystemIcons(): Record<string, IconComponent> {
return defaultIcons;
},
getComponents(): AppComponents {
return defaultComponents;
},
};
}
function createApiHolder(
coreExtension: ExtensionInstance,
configApi: ConfigApi,
): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
const apiFactories =
coreExtension.attachments
.get('apis')
?.map(
e =>
e.data.get(
coreExtensionData.apiFactory.id,
) as typeof coreExtensionData.apiFactory.T,
)
.filter(Boolean) ?? [];
for (const factory of apiFactories) {
factoryRegistry.register('default', factory);
}
// TODO: properly discovery feature flags, maybe rework the whole thing
factoryRegistry.register('default', {
api: featureFlagsApiRef,
deps: {},
factory: () => new LocalStorageFeatureFlags(),
});
factoryRegistry.register('static', {
api: appThemeApiRef,
deps: {},
// TODO: add extension for registering themes
factory: () => AppThemeSelector.createWithStorage(defaultThemes),
});
factoryRegistry.register('static', {
api: configApiRef,
deps: {},
factory: () => configApi,
});
// TODO: ship these as default extensions instead
for (const factory of defaultApis as AnyApiFactory[]) {
if (!factoryRegistry.register('app', factory)) {
throw new Error(
`Duplicate or forbidden API factory for ${factory.api} in app`,
);
}
}
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
return new ApiResolver(factoryRegistry);
}
/** @internal */
export function extractRouteInfoFromInstanceTree(
roots: ExtensionInstance[],
@@ -0,0 +1,34 @@
/*
* 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.
*/
import {
coreExtensionData,
createExtension,
} from '@backstage/frontend-plugin-api';
export const Core = createExtension({
id: 'core',
at: 'root',
inputs: {
apis: {
extensionData: {
api: coreExtensionData.apiFactory,
},
},
},
output: {},
factory() {},
});
@@ -0,0 +1,68 @@
/*
* 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.
*/
import React from 'react';
import {
createExtension,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { SidebarPage } from '@backstage/core-components';
export const CoreLayout = createExtension({
id: 'core.layout',
at: 'root',
inputs: {
nav: {
extensionData: {
component: coreExtensionData.reactComponent,
},
},
content: {
extensionData: {
component: coreExtensionData.reactComponent,
},
},
},
output: {
component: coreExtensionData.reactComponent,
},
factory({ bind, inputs }) {
// TODO: Support this as part of the core system
if (inputs.nav.length !== 1) {
throw Error(
`Extension 'core.layout' did not receive exactly one 'nav' input, got ${inputs.nav.length}`,
);
}
const Nav = inputs.nav[0].component;
if (inputs.content.length !== 1) {
throw Error(
`Extension 'core.layout' did not receive exactly one 'content' input, got ${inputs.content.length}`,
);
}
const Content = inputs.content[0].component;
bind({
// TODO: set base path using the logic from AppRouter
component: () => (
<SidebarPage>
<Nav />
<Content />
</SidebarPage>
),
});
},
});
@@ -0,0 +1,84 @@
/*
* 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.
*/
import React from 'react';
import {
createExtension,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { makeStyles } from '@material-ui/core';
import {
Sidebar,
useSidebarOpenState,
Link,
sidebarConfig,
SidebarDivider,
SidebarItem,
} from '@backstage/core-components';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import LogoIcon from '../../../app/src/components/Root/LogoIcon';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import LogoFull from '../../../app/src/components/Root/LogoFull';
const useSidebarLogoStyles = makeStyles({
root: {
width: sidebarConfig.drawerWidthClosed,
height: 3 * sidebarConfig.logoHeight,
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
marginBottom: -14,
},
link: {
width: sidebarConfig.drawerWidthClosed,
marginLeft: 24,
},
});
const SidebarLogo = () => {
const classes = useSidebarLogoStyles();
const { isOpen } = useSidebarOpenState();
return (
<div className={classes.root}>
<Link to="/" underline="none" className={classes.link} aria-label="Home">
{isOpen ? <LogoFull /> : <LogoIcon />}
</Link>
</div>
);
};
export const CoreNav = createExtension({
id: 'core.nav',
at: 'core.layout/nav',
inputs: {},
output: {
component: coreExtensionData.reactComponent,
},
factory({ bind }) {
bind({
// TODO: set base path using the logic from AppRouter
component: () => (
<Sidebar>
<SidebarLogo />
<SidebarDivider />
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
</Sidebar>
),
});
},
});
@@ -19,11 +19,11 @@ import {
createExtension,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { BrowserRouter, useRoutes } from 'react-router-dom';
import { useRoutes } from 'react-router-dom';
export const CoreRouter = createExtension({
id: 'core.router',
at: 'root',
export const CoreRoutes = createExtension({
id: 'core.routes',
at: 'core.layout/content',
inputs: {
routes: {
extensionData: {
@@ -48,11 +48,7 @@ export const CoreRouter = createExtension({
return element;
};
bind({
component: () => (
<BrowserRouter>
<Routes />
</BrowserRouter>
),
component: () => <Routes />,
});
},
});