Merge pull request #3753 from backstage/mob/rr

core-api: Introduce new plugin extension API
This commit is contained in:
Patrik Oldsberg
2020-12-18 13:28:16 +01:00
committed by GitHub
23 changed files with 615 additions and 128 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/core': patch
'@backstage/test-utils': patch
'@backstage/plugin-graphiql': patch
---
Update to use new plugin extension API
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-api': patch
---
Introduce new plugin extension API
+2 -2
View File
@@ -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"
+185
View File
@@ -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',
),
]);
});
});
+96 -31
View File
@@ -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>
);
+29 -3
View File
@@ -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,
});
+49 -28
View File
@@ -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;
},
};
}
+21 -3
View File
@@ -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', () => {
+5 -2
View File
@@ -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);
}
+16 -1
View File
@@ -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';
+18 -3
View File
@@ -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 = {
+1
View File
@@ -16,6 +16,7 @@
export * from './apis';
export * from './app';
export * from './extensions';
export * from './icons';
export * from './plugin';
export * from './routing';
+18
View File
@@ -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`);
});
});
+83 -32
View File
@@ -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', () => {
+16 -3
View File
@@ -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}
+1
View File
@@ -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;
+1 -1
View File
@@ -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';
+13 -1
View File
@@ -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,
}),
);