core-api: implement SubRouteRef routing

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-03-06 23:53:58 +01:00
parent a96241e788
commit 9bc954b9f8
3 changed files with 137 additions and 53 deletions
+81 -39
View File
@@ -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,
});
},
});
+2 -1
View File
@@ -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;
};
+54 -13
View File
@@ -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;
};