core-api: implement SubRouteRef routing
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -23,7 +23,11 @@ import { createRoutableExtension } from '../extensions';
|
||||
import { defaultSystemIcons } from '../icons';
|
||||
import { createPlugin } from '../plugin';
|
||||
import { useRouteRef } from '../routing/hooks';
|
||||
import { createExternalRouteRef, createRouteRef } from '../routing/RouteRef';
|
||||
import {
|
||||
createExternalRouteRef,
|
||||
createRouteRef,
|
||||
createSubRouteRef,
|
||||
} from '../routing/RouteRef';
|
||||
import { generateBoundRoutes, PrivateAppImpl } from './App';
|
||||
|
||||
describe('generateBoundRoutes', () => {
|
||||
@@ -55,14 +59,37 @@ describe('Integration Test', () => {
|
||||
title: '',
|
||||
params: ['x'],
|
||||
});
|
||||
const err = createExternalRouteRef({ id: 'err' });
|
||||
const errParams = createExternalRouteRef({ id: 'errParams', params: ['x'] });
|
||||
const errOptional = createExternalRouteRef({
|
||||
id: 'errOptional',
|
||||
const subRouteRef1 = createSubRouteRef({
|
||||
id: 'sub1',
|
||||
path: '/sub1',
|
||||
parent: plugin1RouteRef,
|
||||
});
|
||||
const subRouteRef2 = createSubRouteRef({
|
||||
id: 'sub2',
|
||||
path: '/sub2/:x',
|
||||
parent: plugin1RouteRef,
|
||||
});
|
||||
const subRouteRef3 = createSubRouteRef({
|
||||
id: 'sub3',
|
||||
path: '/sub3',
|
||||
parent: plugin2RouteRef,
|
||||
});
|
||||
const subRouteRef4 = createSubRouteRef({
|
||||
id: 'sub4',
|
||||
path: '/sub4/:y',
|
||||
parent: plugin2RouteRef,
|
||||
});
|
||||
const extRouteRef1 = createExternalRouteRef({ id: 'extRouteRef1' });
|
||||
const extRouteRef2 = createExternalRouteRef({
|
||||
id: 'extRouteRef2',
|
||||
params: ['x'],
|
||||
});
|
||||
const extRouteRef3 = createExternalRouteRef({
|
||||
id: 'extRouteRef3',
|
||||
optional: true,
|
||||
});
|
||||
const errParamsOptional = createExternalRouteRef({
|
||||
id: 'errParamsOptional',
|
||||
const extRouteRef4 = createExternalRouteRef({
|
||||
id: 'extRouteRef4',
|
||||
optional: true,
|
||||
params: ['x'],
|
||||
});
|
||||
@@ -70,10 +97,10 @@ describe('Integration Test', () => {
|
||||
const plugin1 = createPlugin({
|
||||
id: 'blob',
|
||||
externalRoutes: {
|
||||
err,
|
||||
errParams,
|
||||
errOptional,
|
||||
errParamsOptional,
|
||||
extRouteRef1,
|
||||
extRouteRef2,
|
||||
extRouteRef3,
|
||||
extRouteRef4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,19 +119,28 @@ describe('Integration Test', () => {
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
|
||||
const errLink = useRouteRef(err);
|
||||
const errParamsLink = useRouteRef(errParams);
|
||||
const errOptionalLink = useRouteRef(errOptional);
|
||||
const errParamsOptionalLink = useRouteRef(errParamsOptional);
|
||||
const link1 = useRouteRef(plugin1RouteRef);
|
||||
const link2 = useRouteRef(plugin2RouteRef);
|
||||
const subLink1 = useRouteRef(subRouteRef1);
|
||||
const subLink2 = useRouteRef(subRouteRef2);
|
||||
const subLink3 = useRouteRef(subRouteRef3);
|
||||
const subLink4 = useRouteRef(subRouteRef4);
|
||||
const extLink1 = useRouteRef(extRouteRef1);
|
||||
const extLink2 = useRouteRef(extRouteRef2);
|
||||
const extLink3 = useRouteRef(extRouteRef3);
|
||||
const extLink4 = useRouteRef(extRouteRef4);
|
||||
return (
|
||||
<div>
|
||||
<span>err: {errLink()}</span>
|
||||
<span>errParams: {errParamsLink({ x: 'a' })}</span>
|
||||
<span>errOptional: {errOptionalLink?.() ?? '<none>'}</span>
|
||||
<span>
|
||||
errParamsOptional:{' '}
|
||||
{errParamsOptionalLink?.({ x: 'b' }) ?? '<none>'}
|
||||
</span>
|
||||
<span>link1: {link1()}</span>
|
||||
<span>link2: {link2({ x: 'a' })}</span>
|
||||
<span>subLink1: {subLink1()}</span>
|
||||
<span>subLink2: {subLink2({ x: 'a' })}</span>
|
||||
<span>subLink3: {subLink3({ x: 'b' })}</span>
|
||||
<span>subLink4: {subLink4({ x: 'c', y: 'd' })}</span>
|
||||
<span>extLink1: {extLink1()}</span>
|
||||
<span>extLink2: {extLink2({ x: 'a' })}</span>
|
||||
<span>extLink3: {extLink3?.() ?? '<none>'}</span>
|
||||
<span>extLink4: {extLink4?.({ x: 'b' }) ?? '<none>'}</span>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
@@ -136,10 +172,10 @@ describe('Integration Test', () => {
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
err: plugin1RouteRef,
|
||||
errParams: plugin2RouteRef,
|
||||
errOptional: plugin1RouteRef,
|
||||
errParamsOptional: plugin2RouteRef,
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
extRouteRef2: plugin2RouteRef,
|
||||
extRouteRef3: subRouteRef1,
|
||||
extRouteRef4: plugin2RouteRef,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -152,16 +188,22 @@ describe('Integration Test', () => {
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo" />
|
||||
<HiddenComponent path="/foo/:x" />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('err: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParams: /foo')).toBeInTheDocument();
|
||||
expect(screen.getByText('errOptional: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParamsOptional: /foo')).toBeInTheDocument();
|
||||
expect(screen.getByText('link1: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('link2: /foo/a')).toBeInTheDocument();
|
||||
expect(screen.getByText('subLink1: /sub1')).toBeInTheDocument();
|
||||
expect(screen.getByText('subLink2: /sub2/a')).toBeInTheDocument();
|
||||
expect(screen.getByText('subLink3: /foo/b/sub3')).toBeInTheDocument();
|
||||
expect(screen.getByText('subLink4: /foo/c/sub4/d')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink2: /foo/a')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink3: /sub1')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink4: /foo/b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs happy paths without optional routes', async () => {
|
||||
@@ -181,8 +223,8 @@ describe('Integration Test', () => {
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
err: plugin1RouteRef,
|
||||
errParams: plugin2RouteRef,
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
extRouteRef2: plugin2RouteRef,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -201,10 +243,10 @@ describe('Integration Test', () => {
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('err: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParams: /foo')).toBeInTheDocument();
|
||||
expect(screen.getByText('errOptional: <none>')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParamsOptional: <none>')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink2: /foo')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink3: <none>')).toBeInTheDocument();
|
||||
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw some error when the route has duplicate params', () => {
|
||||
@@ -224,8 +266,8 @@ describe('Integration Test', () => {
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
err: plugin1RouteRef,
|
||||
errParams: plugin2RouteRef,
|
||||
extRouteRef1: plugin1RouteRef,
|
||||
extRouteRef2: plugin2RouteRef,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ExternalRouteRef, RouteRef } from '../routing';
|
||||
import { AnyApiFactory } from '../apis';
|
||||
import { AppTheme, ProfileInfo } from '../apis/definitions';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
import { SubRouteRef } from '../routing/types';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config';
|
||||
@@ -102,7 +103,7 @@ type TargetRouteMap<ExternalRoutes extends AnyExternalRoutes> = {
|
||||
infer Params,
|
||||
any
|
||||
>
|
||||
? RouteRef<Params>
|
||||
? RouteRef<Params> | SubRouteRef<Params>
|
||||
: never;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,14 +15,17 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
AnyRouteRef,
|
||||
BackstageRouteObject,
|
||||
RouteRef,
|
||||
ExternalRouteRef,
|
||||
AnyParams,
|
||||
SubRouteRef,
|
||||
routeRefType,
|
||||
} from './types';
|
||||
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
|
||||
import { isRouteRef, isSubRouteRef, isExternalRouteRef } 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.
|
||||
@@ -38,29 +41,64 @@ class RouteResolver {
|
||||
private readonly routePaths: Map<AnyRouteRef, string>,
|
||||
private readonly routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
|
||||
private readonly routeObjects: BackstageRouteObject[],
|
||||
private readonly routeBindings: Map<RouteRef | ExternalRouteRef, RouteRef>,
|
||||
private readonly routeBindings: Map<
|
||||
ExternalRouteRef,
|
||||
RouteRef | SubRouteRef
|
||||
>,
|
||||
) {}
|
||||
|
||||
resolve<Params extends AnyParams>(
|
||||
routeRefOrExternalRouteRef: RouteRef<Params> | ExternalRouteRef<Params>,
|
||||
anyRouteRef:
|
||||
| RouteRef<Params>
|
||||
| SubRouteRef<Params>
|
||||
| ExternalRouteRef<Params, any>,
|
||||
sourceLocation: ReturnType<typeof useLocation>,
|
||||
): RouteFunc<Params> | undefined {
|
||||
const routeRef =
|
||||
this.routeBindings.get(routeRefOrExternalRouteRef) ??
|
||||
(routeRefOrExternalRouteRef as RouteRef<Params>);
|
||||
let resolvedRef: AnyRouteRef;
|
||||
let subRoutePath = '';
|
||||
if (isRouteRef(anyRouteRef)) {
|
||||
resolvedRef = anyRouteRef;
|
||||
} else if (isSubRouteRef(anyRouteRef)) {
|
||||
resolvedRef = anyRouteRef.parent;
|
||||
subRoutePath = anyRouteRef.path;
|
||||
} else if (isExternalRouteRef(anyRouteRef)) {
|
||||
const resolvedRoute = this.routeBindings.get(anyRouteRef);
|
||||
if (!resolvedRoute) {
|
||||
return undefined;
|
||||
}
|
||||
if (isSubRouteRef(resolvedRoute)) {
|
||||
subRoutePath = resolvedRoute.path;
|
||||
resolvedRef = resolvedRoute.parent;
|
||||
} else {
|
||||
resolvedRef = resolvedRoute;
|
||||
}
|
||||
} else if (anyRouteRef[routeRefType]) {
|
||||
throw new Error(
|
||||
`Unknown or invalid route ref type, ${anyRouteRef[routeRefType]}`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown object passed to useRouteRef, got ${anyRouteRef}`,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
const resolvedPath = this.routePaths.get(resolvedRef);
|
||||
if (!resolvedPath) {
|
||||
return undefined;
|
||||
}
|
||||
// SubRouteRefs join the path from the parent route with its own path
|
||||
const lastPath =
|
||||
resolvedPath +
|
||||
(resolvedPath.endsWith('/') ? subRoutePath.slice(1) : subRoutePath);
|
||||
|
||||
const targetRefStack = Array<AnyRouteRef>();
|
||||
let matchIndex = -1;
|
||||
|
||||
for (
|
||||
let currentRouteRef: AnyRouteRef | undefined = routeRef;
|
||||
let currentRouteRef: AnyRouteRef | undefined = resolvedRef;
|
||||
currentRouteRef;
|
||||
currentRouteRef = this.routeParents.get(currentRouteRef)
|
||||
) {
|
||||
@@ -98,7 +136,7 @@ class RouteResolver {
|
||||
}
|
||||
if (path.includes(':')) {
|
||||
throw new Error(
|
||||
`Cannot route to ${routeRef} with parent ${ref} as it has parameters`,
|
||||
`Cannot route to ${resolvedRef} with parent ${ref} as it has parameters`,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
@@ -119,10 +157,13 @@ export function useRouteRef<Optional extends boolean, Params extends AnyParams>(
|
||||
routeRef: ExternalRouteRef<Params, Optional>,
|
||||
): Optional extends true ? RouteFunc<Params> | undefined : RouteFunc<Params>;
|
||||
export function useRouteRef<Params extends AnyParams>(
|
||||
routeRef: RouteRef<Params>,
|
||||
routeRef: RouteRef<Params> | SubRouteRef<Params>,
|
||||
): RouteFunc<Params>;
|
||||
export function useRouteRef<Params extends AnyParams>(
|
||||
routeRef: RouteRef<Params> | ExternalRouteRef<Params, any>,
|
||||
routeRef:
|
||||
| RouteRef<Params>
|
||||
| SubRouteRef<Params>
|
||||
| ExternalRouteRef<Params, any>,
|
||||
): RouteFunc<Params> | undefined {
|
||||
const sourceLocation = useLocation();
|
||||
const resolver = useContext(RoutingContext);
|
||||
@@ -147,7 +188,7 @@ type ProviderProps = {
|
||||
routePaths: Map<AnyRouteRef, string>;
|
||||
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>;
|
||||
routeObjects: BackstageRouteObject[];
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef>;
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user