diff --git a/.changeset/quiet-pants-happen.md b/.changeset/quiet-pants-happen.md new file mode 100644 index 0000000000..165e3ae12c --- /dev/null +++ b/.changeset/quiet-pants-happen.md @@ -0,0 +1,7 @@ +--- +'@backstage/core': patch +'@backstage/test-utils': patch +'@backstage/plugin-graphiql': patch +--- + +Update to use new plugin extension API diff --git a/.changeset/short-dancers-explode.md b/.changeset/short-dancers-explode.md new file mode 100644 index 0000000000..56fee63fe7 --- /dev/null +++ b/.changeset/short-dancers-explode.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-api': patch +--- + +Introduce new plugin extension API diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 627b5d19aa..a58bf45f9c 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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={} /> - } /> + } /> } /> { + 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, + }), +);