diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx index 6a1e80334b..5ceb123b06 100644 --- a/packages/core-api/src/app/App.test.tsx +++ b/packages/core-api/src/app/App.test.tsx @@ -52,11 +52,21 @@ describe('Integration Test', () => { const plugin1RouteRef = createRouteRef({ path: '/blah1', title: '' }); const plugin2RouteRef = createRouteRef({ path: '/blah2', title: '' }); const externalRouteRef = createExternalRouteRef({ id: '3' }); + const optionalBarExternalRouteRef = createExternalRouteRef({ + id: 'bar', + optional: true, + }); + const optionalBazExternalRouteRef = createExternalRouteRef({ + id: 'baz', + optional: true, + }); const plugin1 = createPlugin({ id: 'blob', externalRoutes: { foo: externalRouteRef, + bar: optionalBarExternalRouteRef, + baz: optionalBazExternalRouteRef, }, }); @@ -76,14 +86,21 @@ describe('Integration Test', () => { component: () => Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { // eslint-disable-next-line react-hooks/rules-of-hooks - const routeRefFunction = useRouteRef(externalRouteRef); - return
Our Route Is: {routeRefFunction()}
; + const externalLink = useRouteRef(externalRouteRef); + const barLink = useRouteRef(optionalBarExternalRouteRef); + const bazLink = useRouteRef(optionalBazExternalRouteRef); + return ( +
+ Our routes are: {externalLink()}, bar: {barLink?.() ?? 'none'}, + baz: {bazLink?.() ?? 'none'} +
+ ); }), mountPoint: plugin1RouteRef, }), ); - it('runs happy path', async () => { + it('runs happy paths', async () => { const components = { NotFoundErrorPage: () => null, BootErrorPage: () => null, @@ -106,7 +123,10 @@ describe('Integration Test', () => { plugins: [], components, bindRoutes: ({ bind }) => { - bind(plugin1.externalRoutes, { foo: plugin2RouteRef }); + bind(plugin1.externalRoutes, { + foo: plugin2RouteRef, + bar: plugin2RouteRef, + }); }, }); @@ -124,7 +144,9 @@ describe('Integration Test', () => { , ); - expect(screen.getByText('Our Route Is: /foo/bar')).toBeInTheDocument(); + expect( + screen.getByText('Our routes are: /foo/bar, bar: /foo/bar, baz: none'), + ).toBeInTheDocument(); }); it('should throw some error when the route has duplicate params', () => { diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 1cf2d892d0..9ab19b5588 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -84,8 +84,14 @@ export function generateBoundRoutes( if (!externalRoute) { throw new Error(`Key ${key} is not an existing external route`); } - - result.set(externalRoute, value); + if (!value && !externalRoute.optional) { + throw new Error( + `External route ${key} is required but was undefined`, + ); + } + if (value) { + result.set(externalRoute, value); + } } }; bindRoutes({ bind }); diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index ec7b70689d..a54234b046 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -16,8 +16,8 @@ import { ComponentType } from 'react'; import { IconComponent, IconComponentMap, IconKey } from '../icons'; -import { BackstagePlugin, AnyExternalRoutes } from '../plugin/types'; -import { RouteRef } from '../routing'; +import { BackstagePlugin } from '../plugin/types'; +import { ExternalRouteRef, RouteRef } from '../routing'; import { AnyApiFactory } from '../apis'; import { AppTheme, ProfileInfo } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; @@ -79,9 +79,49 @@ export type AppComponents = { */ export type AppConfigLoader = () => Promise; -export type AppRouteBinder = ( - externalRoutes: T, - targetRoutes: { [key in keyof T]: RouteRef }, +/** + * Extracts the Optional type of a map of ExternalRouteRefs, leaving only the boolean in place as value + */ +type ExternalRouteRefsToOptionalMap< + T extends { [name in string]: ExternalRouteRef } +> = { + [name in keyof T]: T[name] extends ExternalRouteRef ? U : never; +}; + +/** + * Extracts a union of the keys in a map whose value extends the given type + */ +type ExtractKeysWithType = { + [key in keyof Obj]: Obj[key] extends Type ? key : never; +}[keyof Obj]; + +/** + * Given a map of boolean values denoting whether a rout is optional, create a + * map of needed RouteRefs. + * + * For example { foo: false, bar: true } gives { foo: RouteRef, bar?: RouteRef } + */ +type CombineOptionalAndRequiredRoutes< + OptionalMap extends { [key in string]: boolean } +> = { + [name in ExtractKeysWithType]: RouteRef; +} & + { [name in keyof OptionalMap]?: RouteRef }; + +/** + * Creates a map of required target routes based on whether the input external + * routes are optional or not. The external routes that are marked as optional + * will also be optional in the target routes map. + */ +type TargetRoutesMap< + T extends { [name in string]: ExternalRouteRef } +> = CombineOptionalAndRequiredRoutes>; + +export type AppRouteBinder = < + ExternalRoutes extends { [name in string]: ExternalRouteRef } +>( + externalRoutes: ExternalRoutes, + targetRoutes: TargetRoutesMap, ) => void; export type AppOptions = { diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 5ae85ab553..dc97afbe96 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -64,23 +64,36 @@ export function createRouteRef< return new AbsoluteRouteRef(config); } -export class ExternalRouteRef { - private constructor(id: string) { +export class ExternalRouteRef { + readonly optional: boolean; + + private constructor({ id, optional }: ExternalRouteRefOptions) { this.toString = () => `externalRouteRef{${id}}`; + this.optional = Boolean(optional); } } -export type ExternalRouteRefOptions = { +export type ExternalRouteRefOptions = { /** * An identifier for this route, used to identify it in error messages */ id: string; + + /** + * Whether or not this route is optional, defaults to false. + * + * Optional external routes are not required to be bound in the app, and + * if aren't, `useRouteRef` will return `undefined`. + */ + optional?: Optional; }; -export function createExternalRouteRef( - options: ExternalRouteRefOptions, -): ExternalRouteRef { +export function createExternalRouteRef( + options: ExternalRouteRefOptions, +): ExternalRouteRef { return new ((ExternalRouteRef as unknown) as { - new (id: string): ExternalRouteRef; - })(options.id); + new (options: ExternalRouteRefOptions): ExternalRouteRef< + Optional + >; + })(options); } diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 462b44c3bd..94cd3a704d 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -41,7 +41,7 @@ import { ExternalRouteRef, RouteRefConfig, } from './RouteRef'; -import { RouteRef } from './types'; +import { AnyRouteRef, RouteRef } from './types'; const mockConfig = (extra?: Partial>) => ({ path: '/unused', @@ -62,18 +62,22 @@ const ref5 = createRouteRef(mockConfig({ path: '/wat5' })); const eRefA = createExternalRouteRef({ id: '1' }); const eRefB = createExternalRouteRef({ id: '2' }); const eRefC = createExternalRouteRef({ id: '3' }); +const eRefD = createExternalRouteRef({ id: '4', optional: true }); +const eRefE = createExternalRouteRef({ id: '5', optional: true }); const MockRouteSource = (props: { path?: string; name: string; - routeRef: RouteRef | ExternalRouteRef; + routeRef: AnyRouteRef; params?: T; }) => { try { - const routeFunc = useRouteRef(props.routeRef) as RouteFunc; + const routeFunc = useRouteRef(props.routeRef as any) as + | RouteFunc + | undefined; return (
- Path at {props.name}: {routeFunc(props.params)} + Path at {props.name}: {routeFunc?.(props.params) ?? ''}
); } catch (ex) { @@ -156,6 +160,8 @@ describe('discovery', () => { + + ); @@ -164,6 +170,7 @@ describe('discovery', () => { [eRefA, ref3], [eRefB, ref1], [eRefC, ref2], + [eRefD, ref1], ]), ); @@ -180,6 +187,12 @@ describe('discovery', () => { expect( rendered.getByText('Path at outsideExternal2: /foo/bar'), ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal3: /foo'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outsideExternal4: '), + ).toBeInTheDocument(); }); it('should handle routeRefs with parameters', async () => { diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index c478027ae2..54490fb4f2 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -35,22 +35,23 @@ class RouteResolver { private readonly routePaths: Map, private readonly routeParents: Map, private readonly routeObjects: BackstageRouteObject[], - private readonly routeBindings: Map, + private readonly routeBindings: Map, ) {} resolve( routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef, sourceLocation: ReturnType, - ): RouteFunc { + ): RouteFunc | undefined { const routeRef = this.routeBindings.get(routeRefOrExternalRouteRef) ?? (routeRefOrExternalRouteRef as RouteRef); const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; + // If our route isn't bound to a path we fail the resolution and let the caller decide the failure mode const lastPath = this.routePaths.get(routeRef); if (!lastPath) { - throw new Error(`No path for ${routeRef}`); + return undefined; } const targetRefStack = Array(); let matchIndex = -1; @@ -111,9 +112,15 @@ class RouteResolver { const RoutingContext = createContext(undefined); +export function useRouteRef( + routeRef: ExternalRouteRef, +): Optional extends true ? RouteFunc<{}> | undefined : RouteFunc<{}>; +export function useRouteRef( + routeRef: RouteRef, +): RouteFunc; export function useRouteRef( routeRef: RouteRef | ExternalRouteRef, -): RouteFunc { +): RouteFunc | undefined { const sourceLocation = useLocation(); const resolver = useContext(RoutingContext); const routeFunc = useMemo( @@ -121,10 +128,15 @@ export function useRouteRef( [resolver, routeRef, sourceLocation], ); - if (!routeFunc) { + if (!routeFunc && !resolver) { throw new Error('No route resolver found in context'); } + const isOptional = 'optional' in routeRef && routeRef.optional; + if (!routeFunc && !isOptional) { + throw new Error(`No path for ${routeRef}`); + } + return routeFunc; } diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 5f9376c1d2..b21fa38052 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -15,6 +15,7 @@ */ import { IconComponent } from '../icons'; +import { ExternalRouteRef } from './RouteRef'; // @ts-ignore, we're just embedding the Params type for usage in other places export type RouteRef = { @@ -25,7 +26,7 @@ export type RouteRef = { title: string; }; -export type AnyRouteRef = RouteRef; +export type AnyRouteRef = RouteRef | ExternalRouteRef; /** * This type should not be used