Merge pull request #3753 from backstage/mob/rr
core-api: Introduce new plugin extension API
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
'@backstage/test-utils': patch
|
||||
'@backstage/plugin-graphiql': patch
|
||||
---
|
||||
|
||||
Update to use new plugin extension API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-api': patch
|
||||
---
|
||||
|
||||
Introduce new plugin extension API
|
||||
@@ -29,7 +29,7 @@ import { hot } from 'react-hot-loader/root';
|
||||
import { providers } from './identityProviders';
|
||||
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
|
||||
import { Router as DocsRouter } from '@backstage/plugin-techdocs';
|
||||
import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
|
||||
import { GraphiQLPage } from '@backstage/plugin-graphiql';
|
||||
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
|
||||
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
|
||||
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
|
||||
@@ -81,7 +81,7 @@ const AppRoutes = () => (
|
||||
path="/tech-radar"
|
||||
element={<TechRadarRouter width={1500} height={800} />}
|
||||
/>
|
||||
<Route path="/graphiql" element={<GraphiQLRouter />} />
|
||||
<Route path="/graphiql" element={<GraphiQLPage />} />
|
||||
<Route path="/lighthouse/*" element={<LighthouseRouter />} />
|
||||
<Route
|
||||
path="/register-component"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { renderWithEffects, withLogCollector } from '@backstage/test-utils';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { BrowserRouter, Routes } from 'react-router-dom';
|
||||
import { createRoutableExtension } from '../extensions';
|
||||
import { defaultSystemIcons } from '../icons';
|
||||
import { createPlugin } from '../plugin';
|
||||
import { useRouteRef } from '../routing/hooks';
|
||||
import { createExternalRouteRef, createRouteRef } from '../routing/RouteRef';
|
||||
import { generateBoundRoutes, PrivateAppImpl } from './App';
|
||||
|
||||
describe('generateBoundRoutes', () => {
|
||||
it('runs happy path', () => {
|
||||
const external = { myRoute: createExternalRouteRef() };
|
||||
const ref = createRouteRef({ path: '', title: '' });
|
||||
const result = generateBoundRoutes(({ bind }) => {
|
||||
bind(external, { myRoute: ref });
|
||||
});
|
||||
|
||||
expect(result.get(external.myRoute)).toBe(ref);
|
||||
});
|
||||
|
||||
it('throws on unknown keys', () => {
|
||||
const external = { myRoute: createExternalRouteRef() };
|
||||
const ref = createRouteRef({ path: '', title: '' });
|
||||
expect(() =>
|
||||
generateBoundRoutes(({ bind }) => {
|
||||
bind(external, { someOtherRoute: ref } as any);
|
||||
}),
|
||||
).toThrow('Key someOtherRoute is not an existing external route');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Test', () => {
|
||||
const plugin1RouteRef = createRouteRef({ path: '/blah1', title: '' });
|
||||
const plugin2RouteRef = createRouteRef({ path: '/blah2', title: '' });
|
||||
const externalRouteRef = createExternalRouteRef();
|
||||
|
||||
const plugin1 = createPlugin({
|
||||
id: 'blob',
|
||||
externalRoutes: {
|
||||
foo: externalRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createPlugin({
|
||||
id: 'plugin2',
|
||||
});
|
||||
|
||||
const HiddenComponent = plugin2.provide(
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve((_: { path?: string }) => <div />),
|
||||
mountPoint: plugin2RouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
const ExposedComponent = plugin1.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const routeRefFunction = useRouteRef(externalRouteRef);
|
||||
return <div>Our Route Is: {routeRefFunction({})}</div>;
|
||||
}),
|
||||
mountPoint: plugin1RouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
it('runs happy path', async () => {
|
||||
const components = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
};
|
||||
|
||||
const app = new PrivateAppImpl({
|
||||
apis: [],
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
{
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
},
|
||||
],
|
||||
icons: defaultSystemIcons,
|
||||
plugins: [],
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, { foo: plugin2RouteRef });
|
||||
},
|
||||
});
|
||||
|
||||
const Provider = app.getProvider();
|
||||
const Router = app.getRouter();
|
||||
|
||||
await renderWithEffects(
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo/bar" />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Our Route Is: /foo/bar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw some error when the route has duplicate params', () => {
|
||||
const components = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
};
|
||||
|
||||
const app = new PrivateAppImpl({
|
||||
apis: [],
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
{
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
},
|
||||
],
|
||||
icons: defaultSystemIcons,
|
||||
plugins: [],
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, { foo: plugin2RouteRef });
|
||||
},
|
||||
});
|
||||
|
||||
const Provider = app.getProvider();
|
||||
const Router = app.getRouter();
|
||||
const { error: errorLogs } = withLogCollector(() => {
|
||||
expect(() =>
|
||||
render(
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/test/:thing">
|
||||
<HiddenComponent path="/some/:thing" />
|
||||
</ExposedComponent>
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
),
|
||||
).toThrow(
|
||||
'Parameter :thing is duplicated in path /test/:thing/some/:thing',
|
||||
);
|
||||
});
|
||||
expect(errorLogs).toEqual([
|
||||
expect.stringContaining(
|
||||
'Parameter :thing is duplicated in path /test/:thing/some/:thing',
|
||||
),
|
||||
expect.stringContaining(
|
||||
'The above error occurred in the <Provider> component',
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -13,57 +13,95 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
ComponentType,
|
||||
PropsWithChildren,
|
||||
ReactElement,
|
||||
useMemo,
|
||||
useState,
|
||||
ReactElement,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
import { Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import {
|
||||
BackstageApp,
|
||||
AppComponents,
|
||||
AppConfigLoader,
|
||||
SignInResult,
|
||||
SignInPageProps,
|
||||
} from './types';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import {
|
||||
featureFlagsApiRef,
|
||||
AppThemeApi,
|
||||
ConfigApi,
|
||||
identityApiRef,
|
||||
} from '../apis/definitions';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
|
||||
import { IconComponent, SystemIcons, SystemIconKey } from '../icons';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { useAsync } from 'react-use';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
AppTheme,
|
||||
AppThemeSelector,
|
||||
appThemeApiRef,
|
||||
AppThemeSelector,
|
||||
configApiRef,
|
||||
ConfigReader,
|
||||
useApi,
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
LocalStorageFeatureFlags,
|
||||
useApi,
|
||||
} from '../apis';
|
||||
import { useAsync } from 'react-use';
|
||||
import {
|
||||
AppThemeApi,
|
||||
ConfigApi,
|
||||
featureFlagsApiRef,
|
||||
identityApiRef,
|
||||
} from '../apis/definitions';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
|
||||
import {
|
||||
childDiscoverer,
|
||||
routeElementDiscoverer,
|
||||
traverseElementTree,
|
||||
} from '../extensions/traversal';
|
||||
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { RouteRef } from '../routing';
|
||||
import {
|
||||
routeObjectCollector,
|
||||
routeParentCollector,
|
||||
routePathCollector,
|
||||
} from '../routing/collectors';
|
||||
import { RoutingProvider, validateRoutes } from '../routing/hooks';
|
||||
import { ExternalRouteRef } from '../routing/RouteRef';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { AppIdentity } from './AppIdentity';
|
||||
import { ApiResolver, ApiFactoryRegistry } from '../apis/system';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
import {
|
||||
AppComponents,
|
||||
AppConfigLoader,
|
||||
AppOptions,
|
||||
AppRouteBinder,
|
||||
BackstageApp,
|
||||
SignInPageProps,
|
||||
SignInResult,
|
||||
} from './types';
|
||||
|
||||
export function generateBoundRoutes(
|
||||
bindRoutes: AppOptions['bindRoutes'],
|
||||
): Map<ExternalRouteRef, RouteRef> {
|
||||
const result = new Map<ExternalRouteRef, RouteRef>();
|
||||
|
||||
if (bindRoutes) {
|
||||
const bind: AppRouteBinder = (externalRoutes, targetRoutes) => {
|
||||
for (const [key, value] of Object.entries(targetRoutes)) {
|
||||
const externalRoute = externalRoutes[key];
|
||||
if (!externalRoute) {
|
||||
throw new Error(`Key ${key} is not an existing external route`);
|
||||
}
|
||||
|
||||
result.set(externalRoute, value);
|
||||
}
|
||||
};
|
||||
bindRoutes({ bind });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type FullAppOptions = {
|
||||
apis: Iterable<AnyApiFactory>;
|
||||
icons: SystemIcons;
|
||||
plugins: BackstagePlugin[];
|
||||
plugins: BackstagePlugin<any, any>[];
|
||||
components: AppComponents;
|
||||
themes: AppTheme[];
|
||||
configLoader?: AppConfigLoader;
|
||||
defaultApis: Iterable<AnyApiFactory>;
|
||||
bindRoutes?: AppOptions['bindRoutes'];
|
||||
};
|
||||
|
||||
function useConfigLoader(
|
||||
@@ -107,11 +145,12 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
private readonly apis: Iterable<AnyApiFactory>;
|
||||
private readonly icons: SystemIcons;
|
||||
private readonly plugins: BackstagePlugin[];
|
||||
private readonly plugins: BackstagePlugin<any, any>[];
|
||||
private readonly components: AppComponents;
|
||||
private readonly themes: AppTheme[];
|
||||
private readonly configLoader?: AppConfigLoader;
|
||||
private readonly defaultApis: Iterable<AnyApiFactory>;
|
||||
private readonly bindRoutes: AppOptions['bindRoutes'];
|
||||
|
||||
private readonly identityApi = new AppIdentity();
|
||||
|
||||
@@ -123,9 +162,10 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
this.themes = options.themes;
|
||||
this.configLoader = options.configLoader;
|
||||
this.defaultApis = options.defaultApis;
|
||||
this.bindRoutes = options.bindRoutes;
|
||||
}
|
||||
|
||||
getPlugins(): BackstagePlugin[] {
|
||||
getPlugins(): BackstagePlugin<any, any>[] {
|
||||
return this.plugins;
|
||||
}
|
||||
|
||||
@@ -202,6 +242,22 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
[],
|
||||
);
|
||||
|
||||
const { routePaths, routeParents, routeObjects } = useMemo(() => {
|
||||
const result = traverseElementTree({
|
||||
root: children,
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
collectors: {
|
||||
routePaths: routePathCollector,
|
||||
routeParents: routeParentCollector,
|
||||
routeObjects: routeObjectCollector,
|
||||
},
|
||||
});
|
||||
|
||||
validateRoutes(result.routePaths, result.routeParents);
|
||||
|
||||
return result;
|
||||
}, [children]);
|
||||
|
||||
const loadedConfig = useConfigLoader(
|
||||
this.configLoader,
|
||||
this.components,
|
||||
@@ -218,7 +274,16 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
return (
|
||||
<ApiProvider apis={this.getApiHolder()}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>{children}</AppThemeProvider>
|
||||
<AppThemeProvider>
|
||||
<RoutingProvider
|
||||
routePaths={routePaths}
|
||||
routeParents={routeParents}
|
||||
routeObjects={routeObjects}
|
||||
routeBindings={generateBoundRoutes(this.bindRoutes)}
|
||||
>
|
||||
{children}
|
||||
</RoutingProvider>
|
||||
</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { BackstagePlugin, AnyExternalRoutes } from '../plugin/types';
|
||||
import { RouteRef } from '../routing';
|
||||
import { AnyApiFactory } from '../apis';
|
||||
import { AppTheme, ProfileInfo } from '../apis/definitions';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
@@ -78,6 +79,11 @@ export type AppComponents = {
|
||||
*/
|
||||
export type AppConfigLoader = () => Promise<AppConfig[]>;
|
||||
|
||||
export type AppRouteBinder = <T extends AnyExternalRoutes>(
|
||||
externalRoutes: T,
|
||||
targetRoutes: { [key in keyof T]: RouteRef<any> },
|
||||
) => void;
|
||||
|
||||
export type AppOptions = {
|
||||
/**
|
||||
* A collection of ApiFactories to register in the application to either
|
||||
@@ -93,7 +99,7 @@ export type AppOptions = {
|
||||
/**
|
||||
* A list of all plugins to include in the app.
|
||||
*/
|
||||
plugins?: BackstagePlugin[];
|
||||
plugins?: BackstagePlugin<any, any>[];
|
||||
|
||||
/**
|
||||
* Supply components to the app to override the default ones.
|
||||
@@ -134,13 +140,33 @@ export type AppOptions = {
|
||||
* that was packaged by the backstage-cli and default docker container boot script.
|
||||
*/
|
||||
configLoader?: AppConfigLoader;
|
||||
|
||||
/**
|
||||
* A function that is used to register associations between cross-plugin route
|
||||
* references, enabling plugins to navigate between each other.
|
||||
*
|
||||
* The `bind` function that is passed in should be used to bind all external
|
||||
* routes of all used plugins.
|
||||
*
|
||||
* ```ts
|
||||
* bindRoutes({ bind }) {
|
||||
* bind(docsPlugin.externalRoutes, {
|
||||
* homePage: managePlugin.routes.managePage,
|
||||
* })
|
||||
* bind(homePagePlugin.externalRoutes, {
|
||||
* settingsPage: settingsPlugin.routes.settingsPage,
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
bindRoutes?(context: { bind: AppRouteBinder }): void;
|
||||
};
|
||||
|
||||
export type BackstageApp = {
|
||||
/**
|
||||
* Returns all plugins registered for the app.
|
||||
*/
|
||||
getPlugins(): BackstagePlugin[];
|
||||
getPlugins(): BackstagePlugin<any, any>[];
|
||||
|
||||
/**
|
||||
* Get a common icon for this app.
|
||||
|
||||
@@ -30,10 +30,12 @@ const plugin = createPlugin({
|
||||
|
||||
describe('extensions', () => {
|
||||
it('should create a react extension with component data', () => {
|
||||
const Component = () => null;
|
||||
const Component = () => <div />;
|
||||
|
||||
const extension = createReactExtension({
|
||||
component: Component,
|
||||
component: {
|
||||
sync: Component,
|
||||
},
|
||||
data: {
|
||||
myData: { foo: 'bar' },
|
||||
},
|
||||
@@ -47,15 +49,17 @@ describe('extensions', () => {
|
||||
});
|
||||
|
||||
it('should create react extensions of different types', () => {
|
||||
const Component = () => null;
|
||||
const Component = () => <div />;
|
||||
const routeRef = createRouteRef({ path: '/foo', title: 'Foo' });
|
||||
|
||||
const extension1 = createComponentExtension({
|
||||
component: Component,
|
||||
component: {
|
||||
sync: Component,
|
||||
},
|
||||
});
|
||||
|
||||
const extension2 = createRoutableExtension({
|
||||
component: Component,
|
||||
component: () => Promise.resolve(Component),
|
||||
mountPoint: routeRef,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,59 +14,80 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
NamedExoticComponent,
|
||||
ComponentType,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { RouteRef } from '../routing';
|
||||
import { attachComponentData } from './componentData';
|
||||
import { Extension, BackstagePlugin } from '../plugin/types';
|
||||
|
||||
export function createRoutableExtension<Props extends {}>(options: {
|
||||
component: ComponentType<Props>;
|
||||
type ComponentLoader<T> =
|
||||
| {
|
||||
lazy: () => Promise<T>;
|
||||
}
|
||||
| {
|
||||
sync: T;
|
||||
};
|
||||
|
||||
export function createRoutableExtension<
|
||||
T extends (props: any) => JSX.Element
|
||||
>(options: {
|
||||
component: () => Promise<T>;
|
||||
mountPoint: RouteRef;
|
||||
// TODO(Rugvip): We want to carry forward the exact props type from the inner component, with
|
||||
// or without children. ComponentType stops us from doing that though, as it always
|
||||
// adds children to the props internally. We may want to work around this with custom types.
|
||||
}): Extension<
|
||||
NamedExoticComponent<PropsWithChildren<Props & { path?: string }>>
|
||||
> {
|
||||
}): Extension<T> {
|
||||
const { component, mountPoint } = options;
|
||||
return createReactExtension({
|
||||
component,
|
||||
component: {
|
||||
lazy: component,
|
||||
},
|
||||
data: {
|
||||
'core.mountPoint': mountPoint,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createComponentExtension<Props extends {}>(options: {
|
||||
component: ComponentType<Props>;
|
||||
}): Extension<NamedExoticComponent<Props>> {
|
||||
export function createComponentExtension<
|
||||
T extends (props: any) => JSX.Element
|
||||
>(options: { component: ComponentLoader<T> }): Extension<T> {
|
||||
const { component } = options;
|
||||
return createReactExtension({ component });
|
||||
}
|
||||
|
||||
export function createReactExtension<Props extends {}>(options: {
|
||||
component: ComponentType<Props>;
|
||||
export function createReactExtension<
|
||||
T extends (props: any) => JSX.Element
|
||||
>(options: {
|
||||
component: ComponentLoader<T>;
|
||||
data?: Record<string, unknown>;
|
||||
}): Extension<NamedExoticComponent<Props>> {
|
||||
const { component: Component, data = {} } = options;
|
||||
}): Extension<T> {
|
||||
const { data = {} } = options;
|
||||
|
||||
let Component: T;
|
||||
if ('lazy' in options.component) {
|
||||
const lazyLoader = options.component.lazy;
|
||||
Component = (lazy(() =>
|
||||
lazyLoader().then(component => ({ default: component })),
|
||||
) as unknown) as T;
|
||||
} else {
|
||||
Component = options.component.sync;
|
||||
}
|
||||
const componentName =
|
||||
(Component as { displayName?: string }).displayName ||
|
||||
Component.name ||
|
||||
'Component';
|
||||
|
||||
return {
|
||||
expose(plugin: BackstagePlugin): NamedExoticComponent<Props> {
|
||||
const Result = (props: Props) => <Component {...props} />;
|
||||
expose(plugin: BackstagePlugin<any, any>) {
|
||||
const Result: any = (props: any) => (
|
||||
<Suspense fallback="...">
|
||||
<Component {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
attachComponentData(Result, 'core.plugin', plugin);
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
attachComponentData(Result, key, value);
|
||||
}
|
||||
|
||||
const name = Component.displayName || Component.name || 'Component';
|
||||
if (name) {
|
||||
Result.displayName = `Extension(${name})`;
|
||||
}
|
||||
return Result as NamedExoticComponent<Props>;
|
||||
Result.displayName = `Extension(${componentName})`;
|
||||
return Result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,13 +19,18 @@ import {
|
||||
PluginOutput,
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
AnyRoutes,
|
||||
AnyExternalRoutes,
|
||||
} from './types';
|
||||
import { AnyApiFactory } from '../apis';
|
||||
|
||||
export class PluginImpl implements BackstagePlugin {
|
||||
export class PluginImpl<
|
||||
Routes extends AnyRoutes,
|
||||
ExternalRoutes extends AnyExternalRoutes
|
||||
> implements BackstagePlugin<Routes, ExternalRoutes> {
|
||||
private storedOutput?: PluginOutput[];
|
||||
|
||||
constructor(private readonly config: PluginConfig) {}
|
||||
constructor(private readonly config: PluginConfig<Routes, ExternalRoutes>) {}
|
||||
|
||||
getId(): string {
|
||||
return this.config.id;
|
||||
@@ -35,6 +40,14 @@ export class PluginImpl implements BackstagePlugin {
|
||||
return this.config.apis ?? [];
|
||||
}
|
||||
|
||||
get routes(): Routes {
|
||||
return this.config.routes ?? ({} as Routes);
|
||||
}
|
||||
|
||||
get externalRoutes(): ExternalRoutes {
|
||||
return this.config.externalRoutes ?? ({} as ExternalRoutes);
|
||||
}
|
||||
|
||||
output(): PluginOutput[] {
|
||||
if (this.storedOutput) {
|
||||
return this.storedOutput;
|
||||
@@ -79,6 +92,11 @@ export class PluginImpl implements BackstagePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlugin(config: PluginConfig): BackstagePlugin {
|
||||
export function createPlugin<
|
||||
Routes extends AnyRoutes = {},
|
||||
ExternalRoutes extends AnyExternalRoutes = {}
|
||||
>(
|
||||
config: PluginConfig<Routes, ExternalRoutes>,
|
||||
): BackstagePlugin<Routes, ExternalRoutes> {
|
||||
return new PluginImpl(config);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ import {
|
||||
import { pluginCollector } from './collectors';
|
||||
|
||||
const mockConfig = () => ({ path: '/foo', title: 'Foo' });
|
||||
const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}</>;
|
||||
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
const pluginA = createPlugin({ id: 'my-plugin-a' });
|
||||
const pluginB = createPlugin({ id: 'my-plugin-b' });
|
||||
@@ -40,19 +42,25 @@ const ref1 = createRouteRef(mockConfig());
|
||||
const ref2 = createRouteRef(mockConfig());
|
||||
|
||||
const Extension1 = pluginA.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref1 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref1,
|
||||
}),
|
||||
);
|
||||
const Extension2 = pluginB.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref2 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref2,
|
||||
}),
|
||||
);
|
||||
const Extension3 = pluginA.provide(
|
||||
createComponentExtension({ component: MockComponent }),
|
||||
createComponentExtension({ component: { sync: MockComponent } }),
|
||||
);
|
||||
const Extension4 = pluginB.provide(
|
||||
createComponentExtension({ component: MockComponent }),
|
||||
createComponentExtension({ component: { sync: MockComponent } }),
|
||||
);
|
||||
const Extension5 = pluginC.provide(
|
||||
createComponentExtension({ component: MockComponent }),
|
||||
createComponentExtension({ component: { sync: MockComponent } }),
|
||||
);
|
||||
|
||||
describe('collection', () => {
|
||||
|
||||
@@ -34,9 +34,12 @@ import { getComponentData } from '../extensions';
|
||||
import { createCollector } from '../extensions/traversal';
|
||||
|
||||
export const pluginCollector = createCollector(
|
||||
() => new Set<BackstagePlugin>(),
|
||||
() => new Set<BackstagePlugin<any, any>>(),
|
||||
(acc, node) => {
|
||||
const plugin = getComponentData<BackstagePlugin>(node, 'core.plugin');
|
||||
const plugin = getComponentData<BackstagePlugin<any, any>>(
|
||||
node,
|
||||
'core.plugin',
|
||||
);
|
||||
if (plugin) {
|
||||
acc.add(plugin);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,19 @@
|
||||
*/
|
||||
|
||||
export { createPlugin } from './Plugin';
|
||||
export * from './types';
|
||||
export type {
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
FeatureFlagOutput,
|
||||
FeatureFlagsHooks,
|
||||
LegacyRedirectRouteOutput,
|
||||
LegacyRouteOutput,
|
||||
PluginConfig,
|
||||
PluginHooks,
|
||||
PluginOutput,
|
||||
RedirectRouteOutput,
|
||||
RouteOptions,
|
||||
RouteOutput,
|
||||
RoutePath,
|
||||
RouterHooks,
|
||||
} from './types';
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { RouteRef } from '../routing';
|
||||
import { AnyApiFactory } from '../apis/system';
|
||||
import { ExternalRouteRef } from '../routing/RouteRef';
|
||||
|
||||
export type RouteOptions = {
|
||||
// Whether the route path must match exactly, defaults to true.
|
||||
@@ -67,20 +68,34 @@ export type PluginOutput =
|
||||
| FeatureFlagOutput;
|
||||
|
||||
export type Extension<T> = {
|
||||
expose(plugin: BackstagePlugin): T;
|
||||
expose(plugin: BackstagePlugin<any, any>): T;
|
||||
};
|
||||
|
||||
export type BackstagePlugin = {
|
||||
export type AnyRoutes = { [name: string]: RouteRef<any> };
|
||||
|
||||
export type AnyExternalRoutes = { [name: string]: ExternalRouteRef };
|
||||
|
||||
export type BackstagePlugin<
|
||||
Routes extends AnyRoutes = {},
|
||||
ExternalRoutes extends AnyExternalRoutes = {}
|
||||
> = {
|
||||
getId(): string;
|
||||
output(): PluginOutput[];
|
||||
getApis(): Iterable<AnyApiFactory>;
|
||||
provide<T>(extension: Extension<T>): T;
|
||||
routes: Routes;
|
||||
externalRoutes: ExternalRoutes;
|
||||
};
|
||||
|
||||
export type PluginConfig = {
|
||||
export type PluginConfig<
|
||||
Routes extends AnyRoutes,
|
||||
ExternalRoutes extends AnyExternalRoutes
|
||||
> = {
|
||||
id: string;
|
||||
apis?: Iterable<AnyApiFactory>;
|
||||
register?(hooks: PluginHooks): void;
|
||||
routes?: Routes;
|
||||
externalRoutes?: ExternalRoutes;
|
||||
};
|
||||
|
||||
export type PluginHooks = {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export * from './apis';
|
||||
export * from './app';
|
||||
export * from './extensions';
|
||||
export * from './icons';
|
||||
export * from './plugin';
|
||||
export * from './routing';
|
||||
|
||||
@@ -53,3 +53,21 @@ export function createRouteRef<
|
||||
>(config: RouteRefConfig<Params>): RouteRef<Params> {
|
||||
return new AbsoluteRouteRef<Params>(config);
|
||||
}
|
||||
|
||||
const create = Symbol('create-external-route-ref');
|
||||
|
||||
export class ExternalRouteRef {
|
||||
static [create]() {
|
||||
return new ExternalRouteRef();
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
toString() {
|
||||
return `externalRouteRef{}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createExternalRouteRef(): ExternalRouteRef {
|
||||
return ExternalRouteRef[create]();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ import { createRoutableExtension } from '../extensions';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
|
||||
const mockConfig = () => ({ path: '/foo', title: 'Foo' });
|
||||
const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}</>;
|
||||
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
const plugin = createPlugin({ id: 'my-plugin' });
|
||||
|
||||
@@ -39,19 +41,34 @@ const ref4 = createRouteRef(mockConfig());
|
||||
const ref5 = createRouteRef(mockConfig());
|
||||
|
||||
const Extension1 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref1 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref1,
|
||||
}),
|
||||
);
|
||||
const Extension2 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref2 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref2,
|
||||
}),
|
||||
);
|
||||
const Extension3 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref3 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref3,
|
||||
}),
|
||||
);
|
||||
const Extension4 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref4 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref4,
|
||||
}),
|
||||
);
|
||||
const Extension5 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref5 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref5,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('discovery', () => {
|
||||
@@ -188,6 +205,6 @@ describe('discovery', () => {
|
||||
routeParents: routeParentCollector,
|
||||
},
|
||||
}),
|
||||
).toThrow(`Visited element Extension(MockComponent) twice`);
|
||||
).toThrow(`Visited element Extension(Component) twice`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
validateRoutes,
|
||||
RouteFunc,
|
||||
} from './hooks';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
import {
|
||||
createRouteRef,
|
||||
createExternalRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from './RouteRef';
|
||||
import { RouteRef, RouteRefConfig } from './types';
|
||||
|
||||
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
|
||||
@@ -43,7 +47,9 @@ const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
|
||||
title: 'Unused',
|
||||
...extra,
|
||||
});
|
||||
const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}</>;
|
||||
const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
const plugin = createPlugin({ id: 'my-plugin' });
|
||||
|
||||
@@ -52,10 +58,14 @@ const ref2 = createRouteRef(mockConfig({ path: '/wat2' }));
|
||||
const ref3 = createRouteRef(mockConfig({ path: '/wat3' }));
|
||||
const ref4 = createRouteRef(mockConfig({ path: '/wat4' }));
|
||||
const ref5 = createRouteRef(mockConfig({ path: '/wat5' }));
|
||||
const eRefA = createExternalRouteRef();
|
||||
const eRefB = createExternalRouteRef();
|
||||
const eRefC = createExternalRouteRef();
|
||||
|
||||
const MockRouteSource = <T extends { [name in string]: string }>(props: {
|
||||
path?: string;
|
||||
name: string;
|
||||
routeRef: RouteRef<T>;
|
||||
routeRef: RouteRef<T> | ExternalRouteRef;
|
||||
params?: T;
|
||||
}) => {
|
||||
try {
|
||||
@@ -75,22 +85,40 @@ const MockRouteSource = <T extends { [name in string]: string }>(props: {
|
||||
};
|
||||
|
||||
const Extension1 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref1 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref1,
|
||||
}),
|
||||
);
|
||||
const Extension2 = plugin.provide(
|
||||
createRoutableExtension({ component: MockRouteSource, mountPoint: ref2 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockRouteSource),
|
||||
mountPoint: ref2,
|
||||
}),
|
||||
);
|
||||
const Extension3 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref3 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref3,
|
||||
}),
|
||||
);
|
||||
const Extension4 = plugin.provide(
|
||||
createRoutableExtension({ component: MockRouteSource, mountPoint: ref4 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockRouteSource),
|
||||
mountPoint: ref4,
|
||||
}),
|
||||
);
|
||||
const Extension5 = plugin.provide(
|
||||
createRoutableExtension({ component: MockComponent, mountPoint: ref5 }),
|
||||
createRoutableExtension({
|
||||
component: () => Promise.resolve(MockComponent),
|
||||
mountPoint: ref5,
|
||||
}),
|
||||
);
|
||||
|
||||
function withRoutingProvider(root: ReactElement) {
|
||||
function withRoutingProvider(
|
||||
root: ReactElement,
|
||||
routeBindings: [ExternalRouteRef, RouteRef][] = [],
|
||||
) {
|
||||
const { routePaths, routeParents, routeObjects } = traverseElementTree({
|
||||
root,
|
||||
discoverers: [childDiscoverer, routeElementDiscoverer],
|
||||
@@ -106,6 +134,7 @@ function withRoutingProvider(root: ReactElement) {
|
||||
routePaths={routePaths}
|
||||
routeParents={routeParents}
|
||||
routeObjects={routeObjects}
|
||||
routeBindings={new Map(routeBindings)}
|
||||
>
|
||||
{root}
|
||||
</RoutingProvider>
|
||||
@@ -113,26 +142,46 @@ function withRoutingProvider(root: ReactElement) {
|
||||
}
|
||||
|
||||
describe('discovery', () => {
|
||||
it('should handle simple routeRef path creation for routeRefs used in other parts of the app', () => {
|
||||
it('should handle simple routeRef path creation for routeRefs used in other parts of the app', async () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/foo/bar']}>
|
||||
<Routes>
|
||||
<Extension1 path="/foo">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref2} />
|
||||
<MockRouteSource name="insideExternal" routeRef={eRefA} />
|
||||
</Extension1>
|
||||
<Extension3 path="/baz" />
|
||||
</Routes>
|
||||
<MockRouteSource name="outside" routeRef={ref2} />
|
||||
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
|
||||
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
const rendered = render(
|
||||
withRoutingProvider(root, [
|
||||
[eRefA, ref3],
|
||||
[eRefB, ref1],
|
||||
[eRefC, ref2],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rendered.getByText('Path at inside: /foo/bar')).toBeInTheDocument();
|
||||
await expect(
|
||||
rendered.findByText('Path at inside: /foo/bar'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
rendered.getByText('Path at insideExternal: /baz'),
|
||||
).toBeInTheDocument();
|
||||
expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument();
|
||||
expect(
|
||||
rendered.getByText('Path at outsideExternal1: /foo'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
rendered.getByText('Path at outsideExternal2: /foo/bar'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle routeRefs with parameters', () => {
|
||||
it('should handle routeRefs with parameters', async () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/foo/bar/wat']}>
|
||||
<Routes>
|
||||
@@ -155,37 +204,39 @@ describe('discovery', () => {
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
|
||||
expect(
|
||||
rendered.getByText('Path at inside: /foo/bar/bleb'),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
rendered.findByText('Path at inside: /foo/bar/bleb'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
rendered.getByText('Path at outside: /foo/bar/blob'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle relative routing within parameterized routePaths', () => {
|
||||
it('should handle relative routing within parameterized routePaths', async () => {
|
||||
const root = (
|
||||
<MemoryRouter initialEntries={['/foo/blob/baz']}>
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
<React.Suspense fallback="loller">
|
||||
<Routes>
|
||||
<Extension5 path="/foo/:id">
|
||||
<Extension2 path="/bar" name="inside" routeRef={ref3} />
|
||||
<Extension3 path="/baz" />
|
||||
</Extension5>
|
||||
</Routes>
|
||||
<MockRouteSource name="outsideNoParams" routeRef={ref3} />
|
||||
<MockRouteSource
|
||||
name="outsideWithParams"
|
||||
routeRef={ref3}
|
||||
params={{ id: 'blob' }}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const rendered = render(withRoutingProvider(root));
|
||||
|
||||
expect(
|
||||
rendered.getByText('Path at inside: /foo/blob/baz'),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
rendered.findByText('Path at inside: /foo/blob/baz'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw errors for routing to other routeRefs with unsupported parameters', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
import { AnyRouteRef, BackstageRouteObject, RouteRef } from './types';
|
||||
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
|
||||
import { ExternalRouteRef } from './RouteRef';
|
||||
|
||||
// The extra TS magic here is to require a single params argument if the RouteRef
|
||||
// had at least one param defined, but require 0 arguments if there are no params defined.
|
||||
@@ -34,12 +35,17 @@ class RouteResolver {
|
||||
private readonly routePaths: Map<AnyRouteRef, string>,
|
||||
private readonly routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
|
||||
private readonly routeObjects: BackstageRouteObject[],
|
||||
private readonly routeBindings: Map<ExternalRouteRef, RouteRef>,
|
||||
) {}
|
||||
|
||||
resolve<Params extends { [param in string]: string }>(
|
||||
routeRef: RouteRef<Params>,
|
||||
routeRefOrExternalRouteRef: RouteRef<Params> | ExternalRouteRef,
|
||||
sourceLocation: ReturnType<typeof useLocation>,
|
||||
): RouteFunc<Params> {
|
||||
const routeRef =
|
||||
this.routeBindings.get(routeRefOrExternalRouteRef) ??
|
||||
(routeRefOrExternalRouteRef as RouteRef<Params>);
|
||||
|
||||
const match = matchRoutes(this.routeObjects, sourceLocation) ?? [];
|
||||
|
||||
const lastPath = this.routePaths.get(routeRef);
|
||||
@@ -106,7 +112,7 @@ class RouteResolver {
|
||||
const RoutingContext = createContext<RouteResolver | undefined>(undefined);
|
||||
|
||||
export function useRouteRef<Params extends { [param in string]: string }>(
|
||||
routeRef: RouteRef<Params>,
|
||||
routeRef: RouteRef<Params> | ExternalRouteRef,
|
||||
): RouteFunc<Params> {
|
||||
const sourceLocation = useLocation();
|
||||
const resolver = useContext(RoutingContext);
|
||||
@@ -126,6 +132,7 @@ type ProviderProps = {
|
||||
routePaths: Map<AnyRouteRef, string>;
|
||||
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>;
|
||||
routeObjects: BackstageRouteObject[];
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -133,9 +140,15 @@ export const RoutingProvider = ({
|
||||
routePaths,
|
||||
routeParents,
|
||||
routeObjects,
|
||||
routeBindings,
|
||||
children,
|
||||
}: ProviderProps) => {
|
||||
const resolver = new RouteResolver(routePaths, routeParents, routeObjects);
|
||||
const resolver = new RouteResolver(
|
||||
routePaths,
|
||||
routeParents,
|
||||
routeObjects,
|
||||
routeBindings,
|
||||
);
|
||||
return (
|
||||
<RoutingContext.Provider value={resolver}>
|
||||
{children}
|
||||
|
||||
@@ -22,3 +22,4 @@ export type {
|
||||
MutableRouteRef,
|
||||
} from './types';
|
||||
export { createRouteRef } from './RouteRef';
|
||||
export { useRouteRef } from './hooks';
|
||||
|
||||
@@ -142,6 +142,7 @@ export function createApp(options?: AppOptions) {
|
||||
themes,
|
||||
configLoader,
|
||||
defaultApis,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
});
|
||||
|
||||
app.verify();
|
||||
|
||||
@@ -80,6 +80,7 @@ export function wrapInTestApp(
|
||||
},
|
||||
],
|
||||
defaultApis: mockApis,
|
||||
bindRoutes: () => {},
|
||||
});
|
||||
|
||||
let Wrapper: ComponentType;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export { plugin, GraphiQLPage } from './plugin';
|
||||
export { GraphiQLPage as Router } from './components';
|
||||
export * from './lib/api';
|
||||
export * from './route-refs';
|
||||
|
||||
@@ -14,8 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin, createApiFactory } from '@backstage/core';
|
||||
import {
|
||||
createPlugin,
|
||||
createApiFactory,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core';
|
||||
import { graphQlBrowseApiRef, GraphQLEndpoints } from './lib/api';
|
||||
import { graphiQLRouteRef } from './route-refs';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'graphiql',
|
||||
@@ -33,3 +38,10 @@ export const plugin = createPlugin({
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
export const GraphiQLPage = plugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components').then(m => m.GraphiQLPage),
|
||||
mountPoint: graphiQLRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user