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 */