diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx index 5ceb123b06..b9fdf05444 100644 --- a/packages/core-api/src/app/App.test.tsx +++ b/packages/core-api/src/app/App.test.tsx @@ -50,23 +50,30 @@ describe('generateBoundRoutes', () => { 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', + const plugin2RouteRef = createRouteRef({ + path: '/blah2', + title: '', + params: ['x'], + }); + const err = createExternalRouteRef({ id: 'err' }); + const errParams = createExternalRouteRef({ id: 'errParams', params: ['x'] }); + const errOptional = createExternalRouteRef({ + id: 'errOptional', optional: true, }); - const optionalBazExternalRouteRef = createExternalRouteRef({ - id: 'baz', + const errParamsOptional = createExternalRouteRef({ + id: 'errParamsOptional', optional: true, + params: ['x'], }); const plugin1 = createPlugin({ id: 'blob', externalRoutes: { - foo: externalRouteRef, - bar: optionalBarExternalRouteRef, - baz: optionalBazExternalRouteRef, + err, + errParams, + errOptional, + errParamsOptional, }, }); @@ -85,14 +92,19 @@ describe('Integration Test', () => { createRoutableExtension({ component: () => Promise.resolve((_: PropsWithChildren<{ path?: string }>) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const externalLink = useRouteRef(externalRouteRef); - const barLink = useRouteRef(optionalBarExternalRouteRef); - const bazLink = useRouteRef(optionalBazExternalRouteRef); + const errLink = useRouteRef(err); + const errParamsLink = useRouteRef(errParams); + const errOptionalLink = useRouteRef(errOptional); + const errParamsOptionalLink = useRouteRef(errParamsOptional); return (
- Our routes are: {externalLink()}, bar: {barLink?.() ?? 'none'}, - baz: {bazLink?.() ?? 'none'} + err: {errLink()} + errParams: {errParamsLink({ x: 'a' })} + errOptional: {errOptionalLink?.() ?? ''} + + errParamsOptional:{' '} + {errParamsOptionalLink?.({ x: 'b' }) ?? ''} +
); }), @@ -100,14 +112,14 @@ describe('Integration Test', () => { }), ); - it('runs happy paths', async () => { - const components = { - NotFoundErrorPage: () => null, - BootErrorPage: () => null, - Progress: () => null, - Router: BrowserRouter, - }; + const components = { + NotFoundErrorPage: () => null, + BootErrorPage: () => null, + Progress: () => null, + Router: BrowserRouter, + }; + it('runs happy paths', async () => { const app = new PrivateAppImpl({ apis: [], defaultApis: [], @@ -124,8 +136,10 @@ describe('Integration Test', () => { components, bindRoutes: ({ bind }) => { bind(plugin1.externalRoutes, { - foo: plugin2RouteRef, - bar: plugin2RouteRef, + err: plugin1RouteRef, + errParams: plugin2RouteRef, + errOptional: plugin1RouteRef, + errParamsOptional: plugin2RouteRef, }); }, }); @@ -138,25 +152,19 @@ describe('Integration Test', () => { - + , ); - expect( - screen.getByText('Our routes are: /foo/bar, bar: /foo/bar, baz: none'), - ).toBeInTheDocument(); + expect(screen.getByText('err: /')).toBeInTheDocument(); + expect(screen.getByText('errParams: /foo')).toBeInTheDocument(); + expect(screen.getByText('errOptional: /')).toBeInTheDocument(); + expect(screen.getByText('errParamsOptional: /foo')).toBeInTheDocument(); }); - it('should throw some error when the route has duplicate params', () => { - const components = { - NotFoundErrorPage: () => null, - BootErrorPage: () => null, - Progress: () => null, - Router: BrowserRouter, - }; - + it('runs happy paths without optional routes', async () => { const app = new PrivateAppImpl({ apis: [], defaultApis: [], @@ -172,7 +180,53 @@ describe('Integration Test', () => { plugins: [], components, bindRoutes: ({ bind }) => { - bind(plugin1.externalRoutes, { foo: plugin2RouteRef }); + bind(plugin1.externalRoutes, { + err: plugin1RouteRef, + errParams: plugin2RouteRef, + }); + }, + }); + + const Provider = app.getProvider(); + const Router = app.getRouter(); + + await renderWithEffects( + + + + + + + + , + ); + + expect(screen.getByText('err: /')).toBeInTheDocument(); + expect(screen.getByText('errParams: /foo')).toBeInTheDocument(); + expect(screen.getByText('errOptional: ')).toBeInTheDocument(); + expect(screen.getByText('errParamsOptional: ')).toBeInTheDocument(); + }); + + it('should throw some error when the route has duplicate params', () => { + 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, { + err: plugin1RouteRef, + errParams: plugin2RouteRef, + }); }, }); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 11635368fb..3c58930709 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -50,6 +50,7 @@ import { } from '../extensions/traversal'; import { IconComponent, IconComponentMap, IconKey } from '../icons'; import { BackstagePlugin } from '../plugin'; +import { AnyRoutes } from '../plugin/types'; import { RouteRef, ExternalRouteRef } from '../routing'; import { routeObjectCollector, @@ -77,7 +78,7 @@ export function generateBoundRoutes( const result = new Map(); if (bindRoutes) { - const bind: AppRouteBinder = (externalRoutes, targetRoutes) => { + const bind: AppRouteBinder = (externalRoutes, targetRoutes: AnyRoutes) => { for (const [key, value] of Object.entries(targetRoutes)) { const externalRoute = externalRoutes[key]; if (!externalRoute) { diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 701862601d..df4180c6e4 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -16,7 +16,7 @@ import { ComponentType } from 'react'; import { IconComponent, IconComponentMap, IconKey } from '../icons'; -import { BackstagePlugin } from '../plugin/types'; +import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types'; import { ExternalRouteRef, RouteRef } from '../routing'; import { AnyApiFactory } from '../apis'; import { AppTheme, ProfileInfo } from '../apis/definitions'; @@ -79,49 +79,39 @@ export type AppComponents = { */ export type AppConfigLoader = () => Promise; -/** - * 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 = { +type KeysWithType = { [key in keyof Obj]: Obj[key] extends Type ? key : never; }[keyof Obj]; /** - * Given a map of boolean values denoting whether a route is optional, create a - * map of needed RouteRefs. - * - * For example { foo: false, bar: true } gives { foo: RouteRef, bar?: RouteRef } + * Takes a map Map required values and makes all keys matching Keys optional */ -type CombineOptionalAndRequiredRoutes< - OptionalMap extends { [key in string]: boolean } -> = { - [name in ExtractKeysWithType]: RouteRef; -} & - { [name in keyof OptionalMap]?: RouteRef }; +type PartialKeys< + Map extends { [name in string]: any }, + Keys extends keyof Map +> = Partial> & Required>; /** - * 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. + * Creates a map of target routes with matching parameters based on a map of external routes. */ -type TargetRoutesMap< - T extends { [name in string]: ExternalRouteRef } -> = CombineOptionalAndRequiredRoutes>; +type TargetRouteMap = { + [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< + infer Params, + any + > + ? RouteRef + : never; +}; -export type AppRouteBinder = < - ExternalRoutes extends { [name in string]: ExternalRouteRef } ->( +export type AppRouteBinder = ( externalRoutes: ExternalRoutes, - targetRoutes: TargetRoutesMap, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, ) => void; export type AppOptions = { diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 879a9c99f8..d218d72fbb 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,12 +14,18 @@ * limitations under the License. */ -import { RouteRef, ExternalRouteRef, routeRefType } from './types'; +import { + RouteRef, + ExternalRouteRef, + routeRefType, + AnyParams, + ParamKeys, +} from './types'; import { IconComponent } from '../icons'; // TODO(Rugvip): Remove this once we get rid of the deprecated fields, it's not exported -export type RouteRefConfig = { - params?: Array; +export type RouteRefConfig = { + params?: ParamKeys; path?: string; icon?: IconComponent; title: string; @@ -31,15 +37,17 @@ class RouteRefBaseBase { } } -export class RouteRefImpl< - Params extends { [param in string]: string } -> extends RouteRefBaseBase { +export class RouteRefImpl extends RouteRefBaseBase { readonly [routeRefType] = 'absolute'; constructor(private readonly config: RouteRefConfig) { super('absolute', config.title); } + get params(): ParamKeys { + return this.config.params as any; + } + get icon() { return this.config.icon; } @@ -54,6 +62,10 @@ export class RouteRefImpl< } } +type OptionalParams< + Params extends { [param in string]: string } +> = Params[keyof Params] extends never ? undefined : Params; + export function createRouteRef< // Params is the type that we care about and the one to be embedded in the route ref. // For example, given the params ['name', 'kind'], Params will be {name: string, kind: string} @@ -70,28 +82,43 @@ export function createRouteRef< icon?: IconComponent; /** @deprecated Route refs no longer decide their own title */ title: string; -}): RouteRef { - return new RouteRefImpl(config); +}): RouteRef> { + return new RouteRefImpl>({ + ...config, + params: (config.params ?? []) as ParamKeys>, + }); } export class ExternalRouteRefImpl< + Params extends AnyParams, Optional extends boolean > extends RouteRefBaseBase { readonly [routeRefType] = 'external'; - constructor(id: string, readonly optional: Optional) { + constructor( + id: string, + readonly params: ParamKeys, + readonly optional: Optional, + ) { super('external', id); } } export function createExternalRouteRef< - Optional extends boolean = false + Params extends { [param in ParamKey]: string }, + Optional extends boolean = false, + ParamKey extends string = never >(options: { /** * An identifier for this route, used to identify it in error messages */ id: string; + /** + * The parameters that will be provided to the external route reference. + */ + params?: ParamKey[]; + /** * Whether or not this route is optional, defaults to false. * @@ -99,9 +126,10 @@ export function createExternalRouteRef< * if they aren't, `useRouteRef` will return `undefined`. */ optional?: Optional; -}): ExternalRouteRef { - return new ExternalRouteRefImpl( +}): ExternalRouteRef, Optional> { + return new ExternalRouteRefImpl, Optional>( options.id, + (options.params ?? []) as ParamKeys>, Boolean(options.optional) as Optional, ); } diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index cfb7a5c68a..0ada37b18d 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -57,12 +57,19 @@ const ref1 = createRouteRef(mockConfig({ path: '/wat1' })); 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 ref5 = createRouteRef({ + ...mockConfig({ path: '/wat5' }), + params: ['x'], +}); const eRefA = createExternalRouteRef({ id: '1' }); const eRefB = createExternalRouteRef({ id: '2' }); -const eRefC = createExternalRouteRef({ id: '3' }); +const eRefC = createExternalRouteRef({ id: '3', params: ['y'] }); const eRefD = createExternalRouteRef({ id: '4', optional: true }); -const eRefE = createExternalRouteRef({ id: '5', optional: true }); +const eRefE = createExternalRouteRef({ + id: '5', + optional: true, + params: ['z'], +}); const MockRouteSource = (props: { path?: string; diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index d9bbef438e..4d419485f6 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -20,6 +20,7 @@ import { BackstageRouteObject, RouteRef, ExternalRouteRef, + AnyParams, } from './types'; import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; @@ -28,10 +29,8 @@ import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; // Without this we'd have to pass in empty object to all parameter-less RouteRefs // just to make TypeScript happy, or we would have to make the argument optional in // which case you might forget to pass it in when it is actually required. -export type RouteFunc = ( - ...[params]: Params[keyof Params] extends never - ? readonly [] - : readonly [Params] +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; class RouteResolver { @@ -42,8 +41,8 @@ class RouteResolver { private readonly routeBindings: Map, ) {} - resolve( - routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef, + resolve( + routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef, sourceLocation: ReturnType, ): RouteFunc | undefined { const routeRef = @@ -116,14 +115,14 @@ class RouteResolver { const RoutingContext = createContext(undefined); -export function useRouteRef( - routeRef: ExternalRouteRef, -): Optional extends true ? RouteFunc<{}> | undefined : RouteFunc<{}>; -export function useRouteRef( +export function useRouteRef( + routeRef: ExternalRouteRef, +): Optional extends true ? RouteFunc | undefined : RouteFunc; +export function useRouteRef( routeRef: RouteRef, ): RouteFunc; -export function useRouteRef( - routeRef: RouteRef | ExternalRouteRef, +export function useRouteRef( + routeRef: RouteRef | ExternalRouteRef, ): RouteFunc | undefined { const sourceLocation = useLocation(); const resolver = useContext(RoutingContext); diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 1b2cea3465..de6f991ec9 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -17,15 +17,20 @@ import { IconComponent } from '../icons'; import { getGlobalSingleton } from '../lib/globalObject'; +export type AnyParams = { [param in string]: string } | undefined; +export type ParamKeys = keyof Params extends never + ? [] + : (keyof Params)[]; + export const routeRefType: unique symbol = getGlobalSingleton( 'route-ref-type', () => Symbol('route-ref-type'), ); -export type RouteRef = { +export type RouteRef = { [routeRefType]: 'absolute'; - params?: keyof Params; + params: ParamKeys; // TODO(Rugvip): Remove all of these once plugins don't rely on the path /** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */ @@ -36,13 +41,18 @@ export type RouteRef = { title?: string; }; -export type ExternalRouteRef = { +export type ExternalRouteRef< + Params extends AnyParams = any, + Optional extends boolean = any +> = { [routeRefType]: 'external'; + params: ParamKeys; + optional?: Optional; }; -export type AnyRouteRef = RouteRef | ExternalRouteRef; +export type AnyRouteRef = RouteRef | ExternalRouteRef; // TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release /** @deprecated */