Merge pull request #19850 from backstage/mob/api-provider
frontend-app-api: implement most bits of AppManager + add sidebar
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Internal refactor
|
||||
@@ -2,6 +2,8 @@ app:
|
||||
experimental:
|
||||
packages: 'all' # ✨
|
||||
|
||||
extensions:
|
||||
- apis.plugin.graphiql.browse.gitlab: true
|
||||
|
||||
# scmAuthExtension: >-
|
||||
# createScmAuthExtension({
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderWithEffects } from '@backstage/test-utils';
|
||||
|
||||
jest.mock('@backstage/plugin-graphiql', () => ({
|
||||
...jest.requireActual('@backstage/plugin-graphiql'),
|
||||
GraphiQLIcon: () => null,
|
||||
}));
|
||||
|
||||
describe('App', () => {
|
||||
it('should render', async () => {
|
||||
process.env = {
|
||||
@@ -41,8 +45,8 @@ describe('App', () => {
|
||||
] as any,
|
||||
};
|
||||
|
||||
const { default: App } = await import('./App');
|
||||
const rendered = await renderWithEffects(<App />);
|
||||
const { default: app } = await import('./App');
|
||||
const rendered = await renderWithEffects(app);
|
||||
expect(rendered.baseElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { graphiqlPlugin as legacyGraphiqlPlugin } from '@backstage/plugin-graphiql';
|
||||
import { createApp as createLegacyApp } from '@backstage/app-defaults';
|
||||
import { createApp } from '@backstage/frontend-app-api';
|
||||
import { pagesPlugin } from './examples/pagesPlugin';
|
||||
import graphiqlPlugin from '@backstage/plugin-graphiql/alpha';
|
||||
@@ -61,9 +59,9 @@ const app = createApp({
|
||||
// },
|
||||
});
|
||||
|
||||
const legacyApp = createLegacyApp({ plugins: [legacyGraphiqlPlugin] });
|
||||
// const legacyApp = createLegacyApp({ plugins: [legacyGraphiqlPlugin] });
|
||||
|
||||
export default legacyApp.createRoot(app.createRoot());
|
||||
export default app.createRoot();
|
||||
|
||||
// const routes = (
|
||||
// <FlatRoutes>
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
*/
|
||||
|
||||
import '@backstage/cli/asset-types';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
import app from './App';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
ReactDOM.render(app, document.getElementById('root'));
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig, Config } from '@backstage/config';
|
||||
import { Config } from '@backstage/config';
|
||||
import React, {
|
||||
ComponentType,
|
||||
PropsWithChildren,
|
||||
@@ -81,6 +81,7 @@ import { InternalAppContext } from './InternalAppContext';
|
||||
import { AppRouter, getBasePath } from './AppRouter';
|
||||
import { AppTranslationProvider } from './AppTranslationProvider';
|
||||
import { AppTranslationApiImpl } from '../apis/implementations/AppTranslationApi';
|
||||
import { overrideBaseUrlConfigs } from './overrideBaseUrlConfigs';
|
||||
|
||||
type CompatiblePlugin =
|
||||
| BackstagePlugin
|
||||
@@ -88,17 +89,6 @@ type CompatiblePlugin =
|
||||
output(): Array<{ type: 'feature-flag'; name: string }>;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a base URL that uses to the current document origin.
|
||||
*/
|
||||
function createLocalBaseUrl(fullUrl: string): string {
|
||||
const url = new URL(fullUrl);
|
||||
url.protocol = document.location.protocol;
|
||||
url.hostname = document.location.hostname;
|
||||
url.port = document.location.port;
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function useConfigLoader(
|
||||
configLoader: AppConfigLoader | undefined,
|
||||
components: AppComponents,
|
||||
@@ -131,52 +121,9 @@ function useConfigLoader(
|
||||
};
|
||||
}
|
||||
|
||||
let configReader;
|
||||
/**
|
||||
* config.value can be undefined or empty. If it's either, don't bother overriding anything.
|
||||
*/
|
||||
if (config.value?.length) {
|
||||
const urlConfigReader = ConfigReader.fromConfigs(config.value);
|
||||
|
||||
/**
|
||||
* Test configs may not define `app.baseUrl` or `backend.baseUrl` and we
|
||||
* don't want to enforce here.
|
||||
*/
|
||||
const appBaseUrl = urlConfigReader.getOptionalString('app.baseUrl');
|
||||
const backendBaseUrl = urlConfigReader.getOptionalString('backend.baseUrl');
|
||||
|
||||
let configs = config.value;
|
||||
const relativeResolverConfig: AppConfig = {
|
||||
data: {},
|
||||
context: 'relative-resolver',
|
||||
};
|
||||
if (appBaseUrl && backendBaseUrl) {
|
||||
const appOrigin = new URL(appBaseUrl).origin;
|
||||
const backendOrigin = new URL(backendBaseUrl).origin;
|
||||
|
||||
if (appOrigin === backendOrigin) {
|
||||
const newBackendBaseUrl = createLocalBaseUrl(backendBaseUrl);
|
||||
if (backendBaseUrl !== newBackendBaseUrl) {
|
||||
relativeResolverConfig.data.backend = { baseUrl: newBackendBaseUrl };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (appBaseUrl) {
|
||||
const newAppBaseUrl = createLocalBaseUrl(appBaseUrl);
|
||||
if (appBaseUrl !== newAppBaseUrl) {
|
||||
relativeResolverConfig.data.app = { baseUrl: newAppBaseUrl };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Only add the relative config if there is actually data to add.
|
||||
*/
|
||||
if (Object.keys(relativeResolverConfig.data).length) {
|
||||
configs = configs.concat([relativeResolverConfig]);
|
||||
}
|
||||
configReader = ConfigReader.fromConfigs(configs);
|
||||
} else {
|
||||
configReader = ConfigReader.fromConfigs([]);
|
||||
}
|
||||
const configReader = ConfigReader.fromConfigs(
|
||||
config.value?.length ? overrideBaseUrlConfigs(config.value) : [],
|
||||
);
|
||||
|
||||
return { api: configReader };
|
||||
}
|
||||
|
||||
@@ -14,40 +14,38 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { defaultConfigLoader } from './defaultConfigLoader';
|
||||
import { defaultConfigLoaderSync } from './defaultConfigLoader';
|
||||
|
||||
(process as any).env = { NODE_ENV: 'test' };
|
||||
const anyEnv = process.env as any;
|
||||
const anyWindow = window as any;
|
||||
|
||||
describe('defaultConfigLoader', () => {
|
||||
describe('defaultConfigLoaderSync', () => {
|
||||
afterEach(() => {
|
||||
delete anyEnv.APP_CONFIG;
|
||||
delete anyWindow.__APP_CONFIG__;
|
||||
});
|
||||
|
||||
it('loads static config', async () => {
|
||||
it('loads static config', () => {
|
||||
anyEnv.APP_CONFIG = [
|
||||
{ data: { my: 'config' }, context: 'a' },
|
||||
{ data: { my: 'override-config' }, context: 'b' },
|
||||
];
|
||||
|
||||
const configs = await defaultConfigLoader();
|
||||
const configs = defaultConfigLoaderSync();
|
||||
expect(configs).toEqual([
|
||||
{ data: { my: 'config' }, context: 'a' },
|
||||
{ data: { my: 'override-config' }, context: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads runtime config', async () => {
|
||||
it('loads runtime config', () => {
|
||||
anyEnv.APP_CONFIG = [
|
||||
{ data: { my: 'override-config' }, context: 'a' },
|
||||
{ data: { my: 'config' }, context: 'b' },
|
||||
];
|
||||
|
||||
const configs = await (defaultConfigLoader as any)(
|
||||
'{"my":"runtime-config"}',
|
||||
);
|
||||
const configs = (defaultConfigLoaderSync as any)('{"my":"runtime-config"}');
|
||||
expect(configs).toEqual([
|
||||
{ data: { my: 'override-config' }, context: 'a' },
|
||||
{ data: { my: 'config' }, context: 'b' },
|
||||
@@ -55,28 +53,28 @@ describe('defaultConfigLoader', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails to load invalid missing config', async () => {
|
||||
await expect(defaultConfigLoader()).rejects.toThrow(
|
||||
it('fails to load invalid missing config', () => {
|
||||
expect(() => defaultConfigLoaderSync()).toThrow(
|
||||
'No static configuration provided',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails to load invalid static config', async () => {
|
||||
it('fails to load invalid static config', () => {
|
||||
anyEnv.APP_CONFIG = { my: 'invalid-config' };
|
||||
await expect(defaultConfigLoader()).rejects.toThrow(
|
||||
expect(() => defaultConfigLoaderSync()).toThrow(
|
||||
'Static configuration has invalid format',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails to load bad runtime config', async () => {
|
||||
it('fails to load bad runtime config', () => {
|
||||
anyEnv.APP_CONFIG = [{ data: { my: 'config' }, context: 'a' }];
|
||||
|
||||
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
|
||||
expect(() => defaultConfigLoaderSync('}')).toThrow(
|
||||
'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads config from window.__APP_CONFIG__', async () => {
|
||||
it('loads config from window.__APP_CONFIG__', () => {
|
||||
anyEnv.APP_CONFIG = [
|
||||
{ data: { my: 'config' }, context: 'a' },
|
||||
{ data: { my: 'override-config' }, context: 'b' },
|
||||
@@ -84,7 +82,7 @@ describe('defaultConfigLoader', () => {
|
||||
const windowConfig = { app: { configKey: 'config-value' } };
|
||||
anyWindow.__APP_CONFIG__ = windowConfig;
|
||||
|
||||
const configs = await defaultConfigLoader();
|
||||
const configs = defaultConfigLoaderSync();
|
||||
|
||||
expect(configs).toEqual([
|
||||
...anyEnv.APP_CONFIG,
|
||||
|
||||
@@ -30,12 +30,16 @@ import { AppConfigLoader } from './types';
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const defaultConfigLoader: AppConfigLoader = async (
|
||||
export const defaultConfigLoader: AppConfigLoader = async () =>
|
||||
defaultConfigLoaderSync();
|
||||
|
||||
/** @internal */
|
||||
export function defaultConfigLoaderSync(
|
||||
// This string may be replaced at runtime to provide additional config.
|
||||
// It should be replaced by a JSON-serialized config object.
|
||||
// It's a param so we can test it, but at runtime this will always fall back to default.
|
||||
runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__',
|
||||
) => {
|
||||
) {
|
||||
const appConfig = process.env.APP_CONFIG;
|
||||
if (!appConfig) {
|
||||
throw new Error('No static configuration provided');
|
||||
@@ -70,4 +74,4 @@ export const defaultConfigLoader: AppConfigLoader = async (
|
||||
});
|
||||
}
|
||||
return configs;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 { AppConfig, ConfigReader } from '@backstage/config';
|
||||
|
||||
/**
|
||||
* Creates a base URL that uses to the current document origin.
|
||||
*/
|
||||
function createLocalBaseUrl(fullUrl: string): string {
|
||||
const url = new URL(fullUrl);
|
||||
url.protocol = document.location.protocol;
|
||||
url.hostname = document.location.hostname;
|
||||
url.port = document.location.port;
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* If we are able to override the app and backend base URLs to values that
|
||||
* match the origin of the current location, then this function returns a
|
||||
* new array of app configs that contain the overrides.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function overrideBaseUrlConfigs(inputConfigs: AppConfig[]): AppConfig[] {
|
||||
const urlConfigReader = ConfigReader.fromConfigs(inputConfigs);
|
||||
|
||||
// In tests we may not have `app.baseUrl` or `backend.baseUrl`, to keep them optional
|
||||
const appBaseUrl = urlConfigReader.getOptionalString('app.baseUrl');
|
||||
const backendBaseUrl = urlConfigReader.getOptionalString('backend.baseUrl');
|
||||
|
||||
let configs = inputConfigs;
|
||||
|
||||
let newBackendBaseUrl: string | undefined = undefined;
|
||||
let newAppBaseUrl: string | undefined = undefined;
|
||||
|
||||
if (appBaseUrl && backendBaseUrl) {
|
||||
const appOrigin = new URL(appBaseUrl).origin;
|
||||
const backendOrigin = new URL(backendBaseUrl).origin;
|
||||
|
||||
if (appOrigin === backendOrigin) {
|
||||
const maybeNewBackendBaseUrl = createLocalBaseUrl(backendBaseUrl);
|
||||
if (backendBaseUrl !== maybeNewBackendBaseUrl) {
|
||||
newBackendBaseUrl = maybeNewBackendBaseUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (appBaseUrl) {
|
||||
const maybeNewAppBaseUrl = createLocalBaseUrl(appBaseUrl);
|
||||
if (appBaseUrl !== maybeNewAppBaseUrl) {
|
||||
newAppBaseUrl = maybeNewAppBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the relative config if there is actually data to add.
|
||||
if (newAppBaseUrl || newBackendBaseUrl) {
|
||||
configs = configs.concat({
|
||||
data: {
|
||||
app: newAppBaseUrl && {
|
||||
baseUrl: newAppBaseUrl,
|
||||
},
|
||||
backend: newBackendBaseUrl && {
|
||||
baseUrl: newBackendBaseUrl,
|
||||
},
|
||||
},
|
||||
context: 'relative-resolver',
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
+5
-9
@@ -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 />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -25,6 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/frontend-app-api": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^12.1.3"
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('createPageExtension', () => {
|
||||
).toEqual({
|
||||
$$type: 'extension',
|
||||
id: 'test',
|
||||
at: 'core.router/routes',
|
||||
at: 'core.routes/routes',
|
||||
configSchema: expect.anything(),
|
||||
disabled: false,
|
||||
inputs: {},
|
||||
@@ -88,7 +88,7 @@ describe('createPageExtension', () => {
|
||||
).toEqual({
|
||||
$$type: 'extension',
|
||||
id: 'test',
|
||||
at: 'core.router/routes',
|
||||
at: 'core.routes/routes',
|
||||
configSchema: expect.anything(),
|
||||
disabled: false,
|
||||
inputs: {},
|
||||
|
||||
@@ -63,7 +63,7 @@ export function createPageExtension<
|
||||
|
||||
return createExtension({
|
||||
id: options.id,
|
||||
at: options.at ?? 'core.router/routes',
|
||||
at: options.at ?? 'core.routes/routes',
|
||||
disabled: options.disabled,
|
||||
output: {
|
||||
component: coreExtensionData.reactComponent,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { JsonObject } from '@backstage/types';
|
||||
import { createExtension } from './createExtension';
|
||||
import { createExtensionDataRef } from './createExtensionDataRef';
|
||||
import { coreExtensionData } from './coreExtensionData';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
|
||||
const nameExtensionDataRef = createExtensionDataRef<string>('name');
|
||||
|
||||
@@ -113,17 +114,10 @@ function createTestAppRoot({
|
||||
plugins: BackstagePlugin[];
|
||||
config: JsonObject;
|
||||
}) {
|
||||
Object.defineProperty(process.env, 'APP_CONFIG', {
|
||||
value: [
|
||||
{
|
||||
data: config,
|
||||
context: 'test',
|
||||
},
|
||||
],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return createApp({ plugins: plugins }).createRoot();
|
||||
return createApp({
|
||||
plugins: plugins,
|
||||
config: new MockConfigApi(config),
|
||||
}).createRoot();
|
||||
}
|
||||
|
||||
describe('createPlugin', () => {
|
||||
@@ -143,7 +137,7 @@ describe('createPlugin', () => {
|
||||
render(
|
||||
createTestAppRoot({
|
||||
plugins: [plugin],
|
||||
config: { app: { extensions: [{ 'core.router': false }] } },
|
||||
config: { app: { extensions: [{ 'core.layout': false }] } },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -169,7 +163,7 @@ describe('createPlugin', () => {
|
||||
config: {
|
||||
app: {
|
||||
extensions: [
|
||||
{ 'core.router': false },
|
||||
{ 'core.layout': false },
|
||||
{
|
||||
'plugin.catalog.page': {
|
||||
config: { name: 'CatalogRenamed' },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PortableSchema } from '@backstage/frontend-plugin-api';
|
||||
export function createEndpointExtension<TConfig extends {}>(options: {
|
||||
id: string;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
disabled?: boolean;
|
||||
factory: (options: { config: TConfig }) => {
|
||||
endpoint: GraphQLEndpoint;
|
||||
};
|
||||
|
||||
@@ -65,13 +65,14 @@ export const graphiqlBrowseApi = createApiExtension({
|
||||
export function createEndpointExtension<TConfig extends {}>(options: {
|
||||
id: string;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
disabled?: boolean;
|
||||
factory: (options: { config: TConfig }) => { endpoint: GraphQLEndpoint };
|
||||
}) {
|
||||
return createExtension({
|
||||
id: `apis.plugin.graphiql.browse.${options.id}`,
|
||||
at: 'apis.plugin.graphiql.browse/endpoints',
|
||||
configSchema: options.configSchema,
|
||||
disabled: true,
|
||||
disabled: options.disabled ?? false,
|
||||
output: {
|
||||
endpoint: endpointDataRef,
|
||||
},
|
||||
@@ -86,6 +87,7 @@ export function createEndpointExtension<TConfig extends {}>(options: {
|
||||
/** @alpha */
|
||||
const gitlabGraphiQLBrowseExtension = createEndpointExtension({
|
||||
id: 'gitlab',
|
||||
disabled: true,
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -4317,10 +4317,13 @@ __metadata:
|
||||
dependencies:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@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
|
||||
"@testing-library/jest-dom": ^5.10.1
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
lodash: ^4.17.21
|
||||
@@ -4337,6 +4340,7 @@ __metadata:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-app-api": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@testing-library/jest-dom": ^5.10.1
|
||||
"@testing-library/react": ^12.1.3
|
||||
|
||||
Reference in New Issue
Block a user