frontend-app-api: lift over and migrate RouteResolver
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
createRouteRef,
|
||||
createSubRouteRef,
|
||||
createExternalRouteRef,
|
||||
ExternalRouteRef,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { RouteResolver } from './RouteResolver';
|
||||
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromInstanceTree';
|
||||
|
||||
const element = () => null;
|
||||
const rest = {
|
||||
element,
|
||||
caseSensitive: false,
|
||||
children: [MATCH_ALL_ROUTE],
|
||||
plugins: new Set<BackstagePlugin>(),
|
||||
};
|
||||
|
||||
const ref1 = createRouteRef();
|
||||
const ref2 = createRouteRef({ params: ['x'] });
|
||||
const ref3 = createRouteRef({ params: ['y'] });
|
||||
const subRef1 = createSubRouteRef({ parent: ref1, path: '/foo' });
|
||||
const subRef2 = createSubRouteRef({ parent: ref1, path: '/foo/:a' });
|
||||
const subRef3 = createSubRouteRef({ parent: ref2, path: '/bar' });
|
||||
const subRef4 = createSubRouteRef({ parent: ref2, path: '/bar/:a' });
|
||||
const externalRef1 = createExternalRouteRef();
|
||||
const externalRef2 = createExternalRouteRef({ optional: true });
|
||||
const externalRef3 = createExternalRouteRef({ params: ['x'] });
|
||||
const externalRef4 = createExternalRouteRef({ optional: true, params: ['x'] });
|
||||
|
||||
describe('RouteResolver', () => {
|
||||
it('should not resolve anything with an empty resolver', () => {
|
||||
const r = new RouteResolver(new Map(), new Map(), [], new Map(), '');
|
||||
|
||||
expect(r.resolve(ref1, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined);
|
||||
expect(r.resolve(subRef1, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(undefined);
|
||||
expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined);
|
||||
expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef1, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map([[ref1, 'my-route']]),
|
||||
new Map(),
|
||||
[{ routeRefs: new Set([ref1]), path: 'my-route', ...rest }],
|
||||
new Map(),
|
||||
'',
|
||||
);
|
||||
|
||||
expect(r.resolve(ref1, '/')?.()).toBe('/my-route');
|
||||
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined);
|
||||
expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo');
|
||||
expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a');
|
||||
expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined);
|
||||
expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef1, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route and sub route with an app base path', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[ref2, 'my-parent/:x'],
|
||||
[ref1, 'my-route'],
|
||||
]),
|
||||
new Map<RouteRef, RouteRef>([[ref1, ref2]]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([ref2]),
|
||||
path: 'my-parent/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map(),
|
||||
'/base',
|
||||
);
|
||||
|
||||
expect(r.resolve(ref1, '/my-parent/1x')?.()).toBe(
|
||||
'/base/my-parent/1x/my-route',
|
||||
);
|
||||
expect(r.resolve(ref1, '/base/my-parent/1x')?.()).toBe(
|
||||
'/base/my-parent/1x/my-route',
|
||||
);
|
||||
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/base/my-parent/1x');
|
||||
expect(r.resolve(ref2, '/base')?.({ x: '1x' })).toBe('/base/my-parent/1x');
|
||||
expect(r.resolve(ref3, '/')?.({ y: '1y' })).toBe(undefined);
|
||||
expect(r.resolve(subRef1, '/my-parent/2x')?.()).toBe(
|
||||
'/base/my-parent/2x/my-route/foo',
|
||||
);
|
||||
expect(r.resolve(subRef1, '/base/my-parent/2x')?.()).toBe(
|
||||
'/base/my-parent/2x/my-route/foo',
|
||||
);
|
||||
expect(r.resolve(subRef2, '/my-parent/3x')?.({ a: '2a' })).toBe(
|
||||
'/base/my-parent/3x/my-route/foo/2a',
|
||||
);
|
||||
expect(r.resolve(subRef2, '/base/my-parent/3x')?.({ a: '2a' })).toBe(
|
||||
'/base/my-parent/3x/my-route/foo/2a',
|
||||
);
|
||||
expect(r.resolve(subRef3, '/')?.({ x: '5x' })).toBe(
|
||||
'/base/my-parent/5x/bar',
|
||||
);
|
||||
expect(r.resolve(subRef4, '/')?.({ x: '6x', a: '4a' })).toBe(
|
||||
'/base/my-parent/6x/bar/4a',
|
||||
);
|
||||
expect(r.resolve(externalRef1, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route with a param and with a parent', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[ref1, 'my-route'],
|
||||
[ref2, 'my-parent/:x'],
|
||||
]),
|
||||
new Map([[ref2, ref1]]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([ref2]),
|
||||
path: 'my-parent/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
|
||||
[externalRef1, ref1],
|
||||
[externalRef3, ref2],
|
||||
[externalRef4, subRef3],
|
||||
]),
|
||||
'',
|
||||
);
|
||||
|
||||
expect(r.resolve(ref1, '/')?.()).toBe('/my-route');
|
||||
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/my-route/my-parent/1x');
|
||||
expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo');
|
||||
expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a');
|
||||
expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(
|
||||
'/my-route/my-parent/3x/bar',
|
||||
);
|
||||
expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(
|
||||
'/my-route/my-parent/4x/bar/4a',
|
||||
);
|
||||
expect(r.resolve(externalRef1, '/')?.()).toBe('/my-route');
|
||||
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(
|
||||
'/my-route/my-parent/5x',
|
||||
);
|
||||
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(
|
||||
'/my-route/my-parent/6x/bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve the most specific match', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[ref1, 'deep'],
|
||||
[ref2, 'root/:x'],
|
||||
[ref3, 'sub/:y'],
|
||||
]),
|
||||
new Map<RouteRef, RouteRef>([
|
||||
[ref3, ref2],
|
||||
[ref1, ref3],
|
||||
]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([ref2]),
|
||||
path: 'root/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([ref3]),
|
||||
path: 'sub/:y',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([ref1]),
|
||||
path: 'deep',
|
||||
...rest,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
|
||||
'',
|
||||
);
|
||||
|
||||
expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x');
|
||||
expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y');
|
||||
|
||||
expect(() => r.resolve(ref1, '/')?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(() => r.resolve(ref1, '/root/x')?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(ref1, '/root/x/sub/y')?.()).toBe('/root/x/sub/y/deep');
|
||||
// Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here
|
||||
expect(r.resolve(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe(
|
||||
'/root/x/sub/y/deep',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route with multiple parents', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[ref1, 'my-route'],
|
||||
[ref2, 'my-parent/:x'],
|
||||
[ref3, 'my-grandparent/:y'],
|
||||
]),
|
||||
new Map<RouteRef, RouteRef>([
|
||||
[ref1, ref2],
|
||||
[ref2, ref3],
|
||||
]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([ref3]),
|
||||
path: 'my-grandparent/:y',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([ref2]),
|
||||
path: 'my-parent/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
|
||||
[externalRef1, ref1],
|
||||
[externalRef3, ref2],
|
||||
[externalRef4, subRef3],
|
||||
]),
|
||||
'',
|
||||
);
|
||||
|
||||
const l = '/my-grandparent/my-y/my-parent/my-x';
|
||||
expect(r.resolve(ref1, l)?.()).toBe(
|
||||
'/my-grandparent/my-y/my-parent/my-x/my-route',
|
||||
);
|
||||
expect(() => r.resolve(ref1, '/')?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/1x',
|
||||
);
|
||||
expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/1x',
|
||||
);
|
||||
expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(subRef1, l)?.()).toBe(
|
||||
'/my-grandparent/my-y/my-parent/my-x/my-route/foo',
|
||||
);
|
||||
expect(() => r.resolve(subRef1, '/')?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a',
|
||||
);
|
||||
expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/3x/bar',
|
||||
);
|
||||
expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/3x/bar',
|
||||
);
|
||||
expect(r.resolve(subRef4, l)?.({ x: '4x', a: '4a' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/4x/bar/4a',
|
||||
);
|
||||
expect(
|
||||
r.resolve(subRef4, '/my-grandparent/my-y')?.({ x: '4x', a: '4a' }),
|
||||
).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a');
|
||||
expect(r.resolve(externalRef1, l)?.()).toBe(
|
||||
'/my-grandparent/my-y/my-parent/my-x/my-route',
|
||||
);
|
||||
expect(() => r.resolve(externalRef1, '/')?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(externalRef2, l)?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/5x',
|
||||
);
|
||||
expect(() => r.resolve(externalRef3, '/')?.({ x: '5x' })).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/6x/bar',
|
||||
);
|
||||
expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should encode some characters in params', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[ref2, 'my-parent/:x'],
|
||||
[ref1, 'my-route'],
|
||||
]),
|
||||
new Map<RouteRef, RouteRef>([[ref1, ref2]]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([ref2]),
|
||||
path: 'my-parent/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map(),
|
||||
'/base',
|
||||
);
|
||||
|
||||
expect(r.resolve(ref2, '/')?.({ x: 'a/#&?b' })).toBe(
|
||||
'/base/my-parent/a%2F%23%26%3Fb',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { generatePath, matchRoutes } from 'react-router-dom';
|
||||
import {
|
||||
RouteRef,
|
||||
ExternalRouteRef,
|
||||
SubRouteRef,
|
||||
AnyRouteParams,
|
||||
RouteFunc,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
import { AnyRouteRef, BackstageRouteObject } from './types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { isRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
isSubRouteRef,
|
||||
toInternalSubRouteRef,
|
||||
} from '../../../frontend-plugin-api/src/routing/SubRouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { isExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
|
||||
|
||||
// Joins a list of paths together, avoiding trailing and duplicate slashes
|
||||
export function joinPaths(...paths: string[]): string {
|
||||
const normalized = paths.join('/').replace(/\/\/+/g, '/');
|
||||
if (normalized !== '/' && normalized.endsWith('/')) {
|
||||
return normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the absolute route ref that our target route ref is pointing pointing to, as well
|
||||
* as the relative target path.
|
||||
*
|
||||
* Returns an undefined target ref if one could not be fully resolved.
|
||||
*/
|
||||
function resolveTargetRef(
|
||||
anyRouteRef: AnyRouteRef,
|
||||
routePaths: Map<RouteRef, string>,
|
||||
routeBindings: Map<AnyRouteRef, AnyRouteRef | undefined>,
|
||||
): readonly [RouteRef | undefined, string] {
|
||||
// First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append.
|
||||
// For sub routes it will be the parent path, while for external routes it will be the bound route.
|
||||
let targetRef: RouteRef;
|
||||
let subRoutePath = '';
|
||||
if (isRouteRef(anyRouteRef)) {
|
||||
targetRef = anyRouteRef;
|
||||
} else if (isSubRouteRef(anyRouteRef)) {
|
||||
const internal = toInternalSubRouteRef(anyRouteRef);
|
||||
targetRef = internal.getParent();
|
||||
subRoutePath = internal.path;
|
||||
} else if (isExternalRouteRef(anyRouteRef)) {
|
||||
const resolvedRoute = routeBindings.get(anyRouteRef);
|
||||
if (!resolvedRoute) {
|
||||
return [undefined, ''];
|
||||
}
|
||||
if (isRouteRef(resolvedRoute)) {
|
||||
targetRef = resolvedRoute;
|
||||
} else if (isSubRouteRef(resolvedRoute)) {
|
||||
const internal = toInternalSubRouteRef(resolvedRoute);
|
||||
targetRef = internal.getParent();
|
||||
subRoutePath = resolvedRoute.path;
|
||||
} else {
|
||||
throw new Error(
|
||||
`ExternalRouteRef was bound to invalid target, ${resolvedRoute}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`);
|
||||
}
|
||||
|
||||
// Bail if no absolute path could be resolved
|
||||
if (!targetRef) {
|
||||
return [undefined, ''];
|
||||
}
|
||||
|
||||
// Find the path that our target route is bound to
|
||||
const resolvedPath = routePaths.get(targetRef);
|
||||
if (resolvedPath === undefined) {
|
||||
return [undefined, ''];
|
||||
}
|
||||
|
||||
// SubRouteRefs join the path from the parent route with its own path
|
||||
const targetPath = joinPaths(resolvedPath, subRoutePath);
|
||||
return [targetRef, targetPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the complete base path for navigating to the target RouteRef.
|
||||
*/
|
||||
function resolveBasePath(
|
||||
targetRef: RouteRef,
|
||||
sourceLocation: Parameters<typeof matchRoutes>[1],
|
||||
routePaths: Map<RouteRef, string>,
|
||||
routeParents: Map<RouteRef, RouteRef | undefined>,
|
||||
routeObjects: BackstageRouteObject[],
|
||||
) {
|
||||
// While traversing the app element tree we build up the routeObjects structure
|
||||
// used here. It is the same kind of structure that react-router creates, with the
|
||||
// addition that associated route refs are stored throughout the tree. This lets
|
||||
// us look up all route refs that can be reached from our source location.
|
||||
// Because of the similar route object structure, we can use `matchRoutes` from
|
||||
// react-router to do the lookup of our current location.
|
||||
const match = matchRoutes(routeObjects, sourceLocation) ?? [];
|
||||
|
||||
// While we search for a common routing root between our current location and
|
||||
// the target route, we build a list of all route refs we find that we need
|
||||
// to traverse to reach the target.
|
||||
const refDiffList = Array<RouteRef>();
|
||||
|
||||
let matchIndex = -1;
|
||||
for (
|
||||
let targetSearchRef: RouteRef | undefined = targetRef;
|
||||
targetSearchRef;
|
||||
targetSearchRef = routeParents.get(targetSearchRef)
|
||||
) {
|
||||
// The match contains a list of all ancestral route refs present at our current location
|
||||
// Starting at the desired target ref and traversing back through its parents, we search
|
||||
// for a target ref that is present in the match for our current location. When a match
|
||||
// is found it means we have found a common base to resolve the route from.
|
||||
matchIndex = match.findIndex(m =>
|
||||
(m.route as BackstageRouteObject).routeRefs.has(targetSearchRef!),
|
||||
);
|
||||
if (matchIndex !== -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Every time we move a step up in the ancestry of the target ref, we add the current ref
|
||||
// to the diff list, which ends up being the list of route refs to traverse form the common base
|
||||
// in order to reach our target.
|
||||
refDiffList.unshift(targetSearchRef);
|
||||
}
|
||||
|
||||
// If our target route is present in the initial match we need to construct the final path
|
||||
// from the parent of the matched route segment. That's to allow the caller of the route
|
||||
// function to supply their own params.
|
||||
if (refDiffList.length === 0) {
|
||||
matchIndex -= 1;
|
||||
}
|
||||
|
||||
// This is the part of the route tree that the target and source locations have in common.
|
||||
// We re-use the existing pathname directly along with all params.
|
||||
const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname;
|
||||
|
||||
// This constructs the mid section of the path using paths resolved from all route refs
|
||||
// we need to traverse to reach our target except for the very last one. None of these
|
||||
// paths are allowed to require any parameters, as the caller would have no way of knowing
|
||||
// what parameters those are.
|
||||
const diffPaths = refDiffList.slice(0, -1).map(ref => {
|
||||
const path = routePaths.get(ref);
|
||||
if (path === undefined) {
|
||||
throw new Error(`No path for ${ref}`);
|
||||
}
|
||||
if (path.includes(':')) {
|
||||
throw new Error(
|
||||
`Cannot route to ${targetRef} with parent ${ref} as it has parameters`,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
});
|
||||
|
||||
return `${joinPaths(parentPath, ...diffPaths)}/`;
|
||||
}
|
||||
|
||||
export class RouteResolver {
|
||||
constructor(
|
||||
private readonly routePaths: Map<RouteRef, string>,
|
||||
private readonly routeParents: Map<RouteRef, RouteRef | undefined>,
|
||||
private readonly routeObjects: BackstageRouteObject[],
|
||||
private readonly routeBindings: Map<
|
||||
ExternalRouteRef,
|
||||
RouteRef | SubRouteRef
|
||||
>,
|
||||
private readonly appBasePath: string, // base path without a trailing slash
|
||||
) {}
|
||||
|
||||
resolve<Params extends AnyRouteParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<Params>
|
||||
| SubRouteRef<Params>
|
||||
| ExternalRouteRef<Params, any>,
|
||||
sourceLocation: Parameters<typeof matchRoutes>[1],
|
||||
): RouteFunc<Params> | undefined {
|
||||
// First figure out what our target absolute ref is, as well as our target path.
|
||||
const [targetRef, targetPath] = resolveTargetRef(
|
||||
anyRouteRef,
|
||||
this.routePaths,
|
||||
this.routeBindings,
|
||||
);
|
||||
if (!targetRef) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The location that we get passed in uses the full path, so start by trimming off
|
||||
// the app base path prefix in case we're running the app on a sub-path.
|
||||
let relativeSourceLocation: Parameters<typeof matchRoutes>[1];
|
||||
if (typeof sourceLocation === 'string') {
|
||||
relativeSourceLocation = this.trimPath(sourceLocation);
|
||||
} else if (sourceLocation.pathname) {
|
||||
relativeSourceLocation = {
|
||||
...sourceLocation,
|
||||
pathname: this.trimPath(sourceLocation.pathname),
|
||||
};
|
||||
} else {
|
||||
relativeSourceLocation = sourceLocation;
|
||||
}
|
||||
|
||||
// Next we figure out the base path, which is the combination of the common parent path
|
||||
// between our current location and our target location, as well as the additional path
|
||||
// that is the difference between the parent path and the base of our target location.
|
||||
const basePath =
|
||||
this.appBasePath +
|
||||
resolveBasePath(
|
||||
targetRef,
|
||||
relativeSourceLocation,
|
||||
this.routePaths,
|
||||
this.routeParents,
|
||||
this.routeObjects,
|
||||
);
|
||||
|
||||
const routeFunc: RouteFunc<Params> = (...[params]) => {
|
||||
// We selectively encode some some known-dangerous characters in the
|
||||
// params. The reason that we don't perform a blanket `encodeURIComponent`
|
||||
// here is that this encoding was added defensively long after the initial
|
||||
// release of this code. There's likely to be many users of this code that
|
||||
// already encode their parameters knowing that this code didn't do this
|
||||
// for them in the past. Therefore, we are extra careful NOT to include
|
||||
// the percent character in this set, even though that might seem like a
|
||||
// bad idea.
|
||||
const encodedParams =
|
||||
params &&
|
||||
mapValues(params, value => {
|
||||
if (typeof value === 'string') {
|
||||
return value.replaceAll(/[&?#;\/]/g, c => encodeURIComponent(c));
|
||||
}
|
||||
return value;
|
||||
});
|
||||
return joinPaths(basePath, generatePath(targetPath, encodedParams));
|
||||
};
|
||||
return routeFunc;
|
||||
}
|
||||
|
||||
private trimPath(targetPath: string) {
|
||||
if (!targetPath) {
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
if (targetPath.startsWith(this.appBasePath)) {
|
||||
return targetPath.slice(this.appBasePath.length);
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
}
|
||||
@@ -18,20 +18,7 @@ import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionInstance } from '../wiring/createExtensionInstance';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toLegacyPlugin } from '../wiring/createApp';
|
||||
import { BackstagePlugin as LegacyBackstagePlugin } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* A duplicate of the react-router RouteObject, but with routeRef added
|
||||
* @internal
|
||||
*/
|
||||
export interface BackstageRouteObject {
|
||||
caseSensitive: boolean;
|
||||
children?: BackstageRouteObject[];
|
||||
element: React.ReactNode;
|
||||
path: string;
|
||||
routeRefs: Set<RouteRef>;
|
||||
plugins: Set<LegacyBackstagePlugin>;
|
||||
}
|
||||
import { BackstageRouteObject } from './types';
|
||||
|
||||
// We always add a child that matches all subroutes but without any route refs. This makes
|
||||
// sure that we're always able to match each route no matter how deep the navigation goes.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ExternalRouteRef,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin as LegacyBackstagePlugin } from '@backstage/core-plugin-api';
|
||||
|
||||
/** @internal */
|
||||
export type AnyRouteRef = RouteRef | SubRouteRef | ExternalRouteRef;
|
||||
|
||||
/**
|
||||
* A duplicate of the react-router RouteObject, but with routeRef added
|
||||
* @internal
|
||||
*/
|
||||
export interface BackstageRouteObject {
|
||||
caseSensitive: boolean;
|
||||
children?: BackstageRouteObject[];
|
||||
element: React.ReactNode;
|
||||
path: string;
|
||||
routeRefs: Set<RouteRef>;
|
||||
plugins: Set<LegacyBackstagePlugin>;
|
||||
}
|
||||
Reference in New Issue
Block a user