{
+ 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 }) => ),
+ 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 Our Route Is: {routeRefFunction({})}
;
+ }),
+ 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(
+
+
+
+
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+
+
+
+
+
+ ,
+ ),
+ ).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 component',
+ ),
+ ]);
+ });
+});
diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx
index 3ca91966bf..2aaeba9796 100644
--- a/packages/core-api/src/app/App.tsx
+++ b/packages/core-api/src/app/App.tsx
@@ -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 {
+ const result = new Map();
+
+ 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;
icons: SystemIcons;
- plugins: BackstagePlugin[];
+ plugins: BackstagePlugin[];
components: AppComponents;
themes: AppTheme[];
configLoader?: AppConfigLoader;
defaultApis: Iterable;
+ bindRoutes?: AppOptions['bindRoutes'];
};
function useConfigLoader(
@@ -107,11 +145,12 @@ export class PrivateAppImpl implements BackstageApp {
private readonly apis: Iterable;
private readonly icons: SystemIcons;
- private readonly plugins: BackstagePlugin[];
+ private readonly plugins: BackstagePlugin[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
private readonly configLoader?: AppConfigLoader;
private readonly defaultApis: Iterable;
+ 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[] {
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 (
- {children}
+
+
+ {children}
+
+
);
diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts
index 8a1eb92e8e..b6b1002d12 100644
--- a/packages/core-api/src/app/types.ts
+++ b/packages/core-api/src/app/types.ts
@@ -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;
+export type AppRouteBinder = (
+ externalRoutes: T,
+ targetRoutes: { [key in keyof T]: RouteRef },
+) => 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[];
/**
* 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[];
/**
* Get a common icon for this app.
diff --git a/packages/core-api/src/extensions/extensions.test.tsx b/packages/core-api/src/extensions/extensions.test.tsx
index 92fcecebc4..26755b3bcf 100644
--- a/packages/core-api/src/extensions/extensions.test.tsx
+++ b/packages/core-api/src/extensions/extensions.test.tsx
@@ -30,10 +30,12 @@ const plugin = createPlugin({
describe('extensions', () => {
it('should create a react extension with component data', () => {
- const Component = () => null;
+ const Component = () => ;
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 = () => ;
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,
});
diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx
index 4087d5f963..adec690f20 100644
--- a/packages/core-api/src/extensions/extensions.tsx
+++ b/packages/core-api/src/extensions/extensions.tsx
@@ -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(options: {
- component: ComponentType;
+type ComponentLoader =
+ | {
+ lazy: () => Promise;
+ }
+ | {
+ sync: T;
+ };
+
+export function createRoutableExtension<
+ T extends (props: any) => JSX.Element
+>(options: {
+ component: () => Promise;
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>
-> {
+}): Extension {
const { component, mountPoint } = options;
return createReactExtension({
- component,
+ component: {
+ lazy: component,
+ },
data: {
'core.mountPoint': mountPoint,
},
});
}
-export function createComponentExtension(options: {
- component: ComponentType;
-}): Extension> {
+export function createComponentExtension<
+ T extends (props: any) => JSX.Element
+>(options: { component: ComponentLoader }): Extension {
const { component } = options;
return createReactExtension({ component });
}
-export function createReactExtension(options: {
- component: ComponentType;
+export function createReactExtension<
+ T extends (props: any) => JSX.Element
+>(options: {
+ component: ComponentLoader;
data?: Record;
-}): Extension> {
- const { component: Component, data = {} } = options;
+}): Extension {
+ 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 {
- const Result = (props: Props) => ;
+ expose(plugin: BackstagePlugin) {
+ const Result: any = (props: any) => (
+
+
+
+ );
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;
+ Result.displayName = `Extension(${componentName})`;
+ return Result;
},
};
}
diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx
index f477674f4d..dd6d7960ac 100644
--- a/packages/core-api/src/plugin/Plugin.tsx
+++ b/packages/core-api/src/plugin/Plugin.tsx
@@ -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 {
private storedOutput?: PluginOutput[];
- constructor(private readonly config: PluginConfig) {}
+ constructor(private readonly config: PluginConfig) {}
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,
+): BackstagePlugin {
return new PluginImpl(config);
}
diff --git a/packages/core-api/src/plugin/collectors.test.tsx b/packages/core-api/src/plugin/collectors.test.tsx
index f040cfd433..5baf2539ab 100644
--- a/packages/core-api/src/plugin/collectors.test.tsx
+++ b/packages/core-api/src/plugin/collectors.test.tsx
@@ -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', () => {
diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts
index 3eadfc0185..b04c7b41c4 100644
--- a/packages/core-api/src/plugin/collectors.ts
+++ b/packages/core-api/src/plugin/collectors.ts
@@ -34,9 +34,12 @@ import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
export const pluginCollector = createCollector(
- () => new Set(),
+ () => new Set>(),
(acc, node) => {
- const plugin = getComponentData(node, 'core.plugin');
+ const plugin = getComponentData>(
+ node,
+ 'core.plugin',
+ );
if (plugin) {
acc.add(plugin);
}
diff --git a/packages/core-api/src/plugin/index.ts b/packages/core-api/src/plugin/index.ts
index 79b0575755..bbeeca4824 100644
--- a/packages/core-api/src/plugin/index.ts
+++ b/packages/core-api/src/plugin/index.ts
@@ -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';
diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts
index dd19948a0a..94bd4e8172 100644
--- a/packages/core-api/src/plugin/types.ts
+++ b/packages/core-api/src/plugin/types.ts
@@ -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 = {
- expose(plugin: BackstagePlugin): T;
+ expose(plugin: BackstagePlugin): T;
};
-export type BackstagePlugin = {
+export type AnyRoutes = { [name: string]: RouteRef };
+
+export type AnyExternalRoutes = { [name: string]: ExternalRouteRef };
+
+export type BackstagePlugin<
+ Routes extends AnyRoutes = {},
+ ExternalRoutes extends AnyExternalRoutes = {}
+> = {
getId(): string;
output(): PluginOutput[];
getApis(): Iterable;
provide(extension: Extension): T;
+ routes: Routes;
+ externalRoutes: ExternalRoutes;
};
-export type PluginConfig = {
+export type PluginConfig<
+ Routes extends AnyRoutes,
+ ExternalRoutes extends AnyExternalRoutes
+> = {
id: string;
apis?: Iterable;
register?(hooks: PluginHooks): void;
+ routes?: Routes;
+ externalRoutes?: ExternalRoutes;
};
export type PluginHooks = {
diff --git a/packages/core-api/src/public.ts b/packages/core-api/src/public.ts
index 0a30936c52..f91d97c31d 100644
--- a/packages/core-api/src/public.ts
+++ b/packages/core-api/src/public.ts
@@ -16,6 +16,7 @@
export * from './apis';
export * from './app';
+export * from './extensions';
export * from './icons';
export * from './plugin';
export * from './routing';
diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts
index 0fbc57c6f1..f89634e0ec 100644
--- a/packages/core-api/src/routing/RouteRef.ts
+++ b/packages/core-api/src/routing/RouteRef.ts
@@ -53,3 +53,21 @@ export function createRouteRef<
>(config: RouteRefConfig): RouteRef {
return new AbsoluteRouteRef(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]();
+}
diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx
index 2595b8c0a0..200846f175 100644
--- a/packages/core-api/src/routing/collectors.test.tsx
+++ b/packages/core-api/src/routing/collectors.test.tsx
@@ -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`);
});
});
diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx
index ae1053b58d..3dbeaf8e3e 100644
--- a/packages/core-api/src/routing/hooks.test.tsx
+++ b/packages/core-api/src/routing/hooks.test.tsx
@@ -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>) => ({
@@ -43,7 +47,9 @@ const mockConfig = (extra?: Partial>) => ({
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 = (props: {
+ path?: string;
name: string;
- routeRef: RouteRef;
+ routeRef: RouteRef | ExternalRouteRef;
params?: T;
}) => {
try {
@@ -75,22 +85,40 @@ const MockRouteSource = (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}
@@ -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 = (
+
+
+
);
- 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 = (
@@ -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 = (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
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', () => {
diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx
index 563bc84a76..55fde89de2 100644
--- a/packages/core-api/src/routing/hooks.tsx
+++ b/packages/core-api/src/routing/hooks.tsx
@@ -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,
private readonly routeParents: Map,
private readonly routeObjects: BackstageRouteObject[],
+ private readonly routeBindings: Map,
) {}
resolve(
- routeRef: RouteRef,
+ routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef,
sourceLocation: ReturnType,
): RouteFunc {
+ const routeRef =
+ this.routeBindings.get(routeRefOrExternalRouteRef) ??
+ (routeRefOrExternalRouteRef as RouteRef);
+
const match = matchRoutes(this.routeObjects, sourceLocation) ?? [];
const lastPath = this.routePaths.get(routeRef);
@@ -106,7 +112,7 @@ class RouteResolver {
const RoutingContext = createContext(undefined);
export function useRouteRef(
- routeRef: RouteRef,
+ routeRef: RouteRef | ExternalRouteRef,
): RouteFunc {
const sourceLocation = useLocation();
const resolver = useContext(RoutingContext);
@@ -126,6 +132,7 @@ type ProviderProps = {
routePaths: Map;
routeParents: Map;
routeObjects: BackstageRouteObject[];
+ routeBindings: Map;
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 (
{children}
diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts
index d682aa9348..3591f299f1 100644
--- a/packages/core-api/src/routing/index.ts
+++ b/packages/core-api/src/routing/index.ts
@@ -22,3 +22,4 @@ export type {
MutableRouteRef,
} from './types';
export { createRouteRef } from './RouteRef';
+export { useRouteRef } from './hooks';
diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx
index c99ba0ed03..9a8f7a36df 100644
--- a/packages/core/src/api-wrappers/createApp.tsx
+++ b/packages/core/src/api-wrappers/createApp.tsx
@@ -142,6 +142,7 @@ export function createApp(options?: AppOptions) {
themes,
configLoader,
defaultApis,
+ bindRoutes: options?.bindRoutes,
});
app.verify();
diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx
index 22bc8ceee4..1f6c7622b8 100644
--- a/packages/test-utils/src/testUtils/appWrappers.tsx
+++ b/packages/test-utils/src/testUtils/appWrappers.tsx
@@ -80,6 +80,7 @@ export function wrapInTestApp(
},
],
defaultApis: mockApis,
+ bindRoutes: () => {},
});
let Wrapper: ComponentType;
diff --git a/plugins/graphiql/src/index.ts b/plugins/graphiql/src/index.ts
index 50698cdb4c..4b1da14079 100644
--- a/plugins/graphiql/src/index.ts
+++ b/plugins/graphiql/src/index.ts
@@ -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';
diff --git a/plugins/graphiql/src/plugin.ts b/plugins/graphiql/src/plugin.ts
index 28d27802d0..a0bb79ed29 100644
--- a/plugins/graphiql/src/plugin.ts
+++ b/plugins/graphiql/src/plugin.ts
@@ -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,
+ }),
+);