Merge pull request #4857 from backstage/rugvip/subroutes

core: add SubRoutesRefs and refactor routing system
This commit is contained in:
Patrik Oldsberg
2021-03-11 10:20:23 +01:00
committed by GitHub
24 changed files with 1516 additions and 303 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-api': patch
---
Fully deprecate `title` option of `RouteRef`s and introduce `id` instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-api': patch
---
Fixed a bug where FlatRoutes didn't handle React Fragments properly.
+41
View File
@@ -0,0 +1,41 @@
---
'@backstage/core-api': patch
---
Add `SubRouteRef`s, which can be used to create a route ref with a fixed path relative to an absolute `RouteRef`. They are useful if you for example have a page that is mounted at a sub route of a routable extension component, and you want other plugins to be able to route to that page.
For example:
```tsx
// routes.ts
const rootRouteRef = createRouteRef({ id: 'root' });
const detailsRouteRef = createSubRouteRef({
id: 'root-sub',
parent: rootRouteRef,
path: '/details',
});
// plugin.ts
export const myPlugin = createPlugin({
routes: {
root: rootRouteRef,
details: detailsRouteRef,
}
})
export const MyPage = plugin.provide(createRoutableExtension({
component: () => import('./components/MyPage').then(m => m.MyPage),
mountPoint: rootRouteRef,
}))
// components/MyPage.tsx
const MyPage = () => (
<Routes>
{/* myPlugin.routes.root will take the user to this page */}
<Route path='/' element={<IndexPage />}>
{/* myPlugin.routes.details will take the user to this page */}
<Route path='/details' element={<DetailsPage />}>
</Routes>
)
```
+1
View File
@@ -252,6 +252,7 @@ stefanalund
subcomponent
subcomponents
subkey
subroutes
subtree
superfences
superset
+44
View File
@@ -369,6 +369,50 @@ It is currently not possible to have parameterized `ExternalRouteRef`s, or to
bind an external route to a parameterized route, although this may be added in
the future if needed.
### Subroutes
The last kind of route refs that can be created are `SubRouteRef`s, which can be
used to create a route ref with a fixed path relative to an absolute `RouteRef`.
They are useful if you have a page that internally is mounted at a sub route of
a routable extension component, and you want other plugins to be able to route
to that page.
For example:
```tsx
// routes.ts
const rootRouteRef = createRouteRef({ id: 'root' });
const detailsRouteRef = createSubRouteRef({
id: 'root-sub',
parent: rootRouteRef,
path: '/details',
});
// plugin.ts
export const myPlugin = createPlugin({
routes: {
root: rootRouteRef,
details: detailsRouteRef,
}
})
export const MyPage = plugin.provide(createRoutableExtension({
component: () => import('./components/MyPage').then(m => m.MyPage),
mountPoint: rootRouteRef,
}))
// components/MyPage.tsx
const MyPage = () => (
<Routes>
{/* myPlugin.routes.root will take the user to this page */}
<Route path='/' element={<IndexPage />}>
{/* myPlugin.routes.details will take the user to this page */}
<Route path='/details' element={<DetailsPage />}>
</Routes>
)
```
### New Catalog Components
The established pattern for selecting what plugins should be available on each
+83 -45
View File
@@ -23,13 +23,17 @@ 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';
import { generateBoundRoutes, PrivateAppImpl } from './App';
describe('generateBoundRoutes', () => {
it('runs happy path', () => {
const external = { myRoute: createExternalRouteRef({ id: '1' }) };
const ref = createRouteRef({ path: '', title: '' });
const ref = createRouteRef({ id: 'ref-1' });
const result = generateBoundRoutes(({ bind }) => {
bind(external, { myRoute: ref });
});
@@ -39,7 +43,7 @@ describe('generateBoundRoutes', () => {
it('throws on unknown keys', () => {
const external = { myRoute: createExternalRouteRef({ id: '2' }) };
const ref = createRouteRef({ path: '', title: '' });
const ref = createRouteRef({ id: 'ref-2' });
expect(() =>
generateBoundRoutes(({ bind }) => {
bind(external, { someOtherRoute: ref } as any);
@@ -49,20 +53,39 @@ describe('generateBoundRoutes', () => {
});
describe('Integration Test', () => {
const plugin1RouteRef = createRouteRef({ path: '/blah1', title: '' });
const plugin2RouteRef = createRouteRef({
path: '/blah2',
title: '',
const plugin1RouteRef = createRouteRef({ id: 'ref-1' });
const plugin2RouteRef = createRouteRef({ id: 'ref-2', params: ['x'] });
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 err = createExternalRouteRef({ id: 'err' });
const errParams = createExternalRouteRef({ id: 'errParams', params: ['x'] });
const errOptional = createExternalRouteRef({
id: 'errOptional',
const extRouteRef3 = createExternalRouteRef({
id: 'extRouteRef3',
optional: true,
});
const errParamsOptional = createExternalRouteRef({
id: 'errParamsOptional',
const extRouteRef4 = createExternalRouteRef({
id: 'extRouteRef4',
optional: true,
params: ['x'],
});
@@ -70,10 +93,10 @@ describe('Integration Test', () => {
const plugin1 = createPlugin({
id: 'blob',
externalRoutes: {
err,
errParams,
errOptional,
errParamsOptional,
extRouteRef1,
extRouteRef2,
extRouteRef3,
extRouteRef4,
},
});
@@ -92,19 +115,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 +168,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 +184,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 +219,8 @@ describe('Integration Test', () => {
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
err: plugin1RouteRef,
errParams: plugin2RouteRef,
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
});
@@ -201,10 +239,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 +262,8 @@ describe('Integration Test', () => {
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
err: plugin1RouteRef,
errParams: plugin2RouteRef,
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
});
+2 -1
View File
@@ -57,7 +57,8 @@ import {
routeParentCollector,
routePathCollector,
} from '../routing/collectors';
import { RoutingProvider, validateRoutes } from '../routing/hooks';
import { RoutingProvider } from '../routing/hooks';
import { validateRoutes } from '../routing/validation';
import { AppContextProvider } from './AppContext';
import { AppIdentity } from './AppIdentity';
import { AppThemeProvider } from './AppThemeProvider';
+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;
};
@@ -0,0 +1,119 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { AnyParams, ExternalRouteRef } from './types';
import { createExternalRouteRef, isExternalRouteRef } from './ExternalRouteRef';
import { isSubRouteRef } from './SubRouteRef';
import { isRouteRef } from './RouteRef';
describe('ExternalRouteRef', () => {
it('should be created', () => {
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef({
id: 'my-route-ref',
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toBe(false);
expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}');
expect(isRouteRef(routeRef)).toBe(false);
expect(isSubRouteRef(routeRef)).toBe(false);
expect(isExternalRouteRef(routeRef)).toBe(true);
expect(isRouteRef({} as ExternalRouteRef)).toBe(false);
});
it('should be created as optional', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: [],
optional: true,
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toEqual(true);
});
it('should be created with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(false);
});
it('should be created as optional with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
optional: true,
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(true);
});
it('should properly infer and validate parameter types and assignments', () => {
function validateType<T extends AnyParams, O extends boolean>(
_ref: ExternalRouteRef<T, O>,
) {}
const _1 = createExternalRouteRef({ id: '1', params: ['notX'] });
// @ts-expect-error
validateType<{ x: string }, any>(_1);
validateType<{ notX: string }, any>(_1);
const _2 = createExternalRouteRef({
id: '2',
params: ['x'],
optional: true,
});
// @ts-expect-error
validateType<undefined, any>(_2);
validateType<{ x: string }, true>(_2);
const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] });
// @ts-expect-error
validateType<{ x: string }, any>(_3);
// TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead
validateType<{ x: string; y: string; z: string }, any>(_3);
validateType<{ x: string; y: string }, false>(_3);
const _4 = createExternalRouteRef({ id: '4', params: [] });
// @ts-expect-error
validateType<{ x: string }, any>(_4);
validateType<undefined, false>(_4);
const _5 = createExternalRouteRef({ id: '5' });
// @ts-expect-error
validateType<{ x: string }, any>(_5);
validateType<undefined, false>(_5);
const _6 = createExternalRouteRef({ id: '6', optional: true });
// @ts-expect-error
validateType<undefined, false>(_6);
validateType<undefined, true>(_6);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
RouteRef,
SubRouteRef,
ExternalRouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean
> implements ExternalRouteRef<Params, Optional> {
readonly [routeRefType] = 'external';
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
) {}
toString() {
return `routeRef{type=external,id=${this.id}}`;
}
}
export function createExternalRouteRef<
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.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
return new ExternalRouteRefImpl(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
);
}
export function isExternalRouteRef<
Params extends AnyParams,
Optional extends boolean
>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, Optional>,
): routeRef is ExternalRouteRef<Params, Optional> {
return routeRef[routeRefType] === 'external';
}
@@ -0,0 +1,111 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { render, RenderResult } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom';
import { AppContext } from '../app';
import { AppContextProvider } from '../app/AppContext';
import { FlatRoutes } from './FlatRoutes';
function makeRouteRenderer(node: ReactNode) {
let rendered: RenderResult | undefined = undefined;
return (path: string) => {
const content = (
<AppContextProvider
appContext={
({
getComponents: () => ({
NotFoundErrorPage: () => <>Not Found</>,
}),
} as unknown) as AppContext
}
>
<MemoryRouter initialEntries={[path]} children={node} />
</AppContextProvider>
);
if (rendered) {
rendered.unmount();
rendered.rerender(content);
} else {
rendered = render(content);
}
return rendered;
};
}
describe('FlatRoutes', () => {
it('renders some routes', () => {
const renderRoute = makeRouteRenderer(
<FlatRoutes>
<Route path="/a" element={<>a</>} />
<Route path="/b" element={<>b</>} />
</FlatRoutes>,
);
expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
expect(renderRoute('/b').getByText('b')).toBeInTheDocument();
expect(renderRoute('/c').getByText('Not Found')).toBeInTheDocument();
expect(renderRoute('/b').queryByText('Not Found')).not.toBeInTheDocument();
expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
});
it('is not sensitive to ordering and overlapping routes', () => {
// The '/*' suffixes here are intentional and will be ignored by FlatRoutes
const routes = (
<>
<Route path="/a-1/*" element={<>a-1</>} />
<Route path="/a/*" element={<>a</>} />
<Route path="/a-2/*" element={<>a-2</>} />
</>
);
const renderRoute = makeRouteRenderer(<FlatRoutes>{routes}</FlatRoutes>);
expect(renderRoute('/a').getByText('a')).toBeInTheDocument();
expect(renderRoute('/a-1').getByText('a-1')).toBeInTheDocument();
expect(renderRoute('/a-2').getByText('a-2')).toBeInTheDocument();
renderRoute('').unmount();
// This uses regular Routes from react-router, not that a-2 renders a, which is the behavior we're working around
const renderBadRoute = makeRouteRenderer(<Routes>{routes}</Routes>);
expect(renderBadRoute('/a').getByText('a')).toBeInTheDocument();
expect(renderBadRoute('/a-1').getByText('a-1')).toBeInTheDocument();
expect(renderBadRoute('/a-2').getByText('a')).toBeInTheDocument();
});
it('renders children straight as outlets', () => {
const MyPage = () => {
return <>Outlet: {useOutlet()}</>;
};
// The '/*' suffixes here are intentional and will be ignored by FlatRoutes
const routes = (
<>
<Route path="/a" element={<MyPage />}>
a
</Route>
<Route path="/a/b" element={<MyPage />}>
a-b
</Route>
<Route path="/b" element={<MyPage />}>
b
</Route>
</>
);
const renderRoute = makeRouteRenderer(<FlatRoutes>{routes}</FlatRoutes>);
expect(renderRoute('/a').getByText('Outlet: a')).toBeInTheDocument();
expect(renderRoute('/a/b').getByText('Outlet: a-b')).toBeInTheDocument();
expect(renderRoute('/b').getByText('Outlet: b')).toBeInTheDocument();
});
});
+35 -34
View File
@@ -27,44 +27,38 @@ type RouteObject = {
// Similar to the same function from react-router, this collects routes from the
// children, but only the first level of routes
function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] {
return Children.toArray(childrenNode)
.flatMap(child => {
if (!isValidElement(child)) {
return [];
}
return Children.toArray(childrenNode).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
const { children } = child.props;
const { children } = child.props;
if (child.type === Fragment) {
return createRoutesFromChildren(children);
}
if (child.type === Fragment) {
return createRoutesFromChildren(children);
}
let path = child.props.path as string | undefined;
let path = child.props.path as string | undefined;
// TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone
if (path === '') {
return [];
}
path = path?.replace(/\/\*$/, '') ?? '/';
// TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone
if (path === '') {
return [];
}
path = path?.replace(/\/\*$/, '') ?? '/';
return [
{
path,
element: child,
children: children && [
{
path: '/*',
element: children,
},
],
},
];
})
.sort((a, b) => b.path.localeCompare(a.path))
.map(obj => {
obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
return obj;
});
return [
{
path,
element: child,
children: children && [
{
path: '/*',
element: children,
},
],
},
];
});
}
type FlatRoutesProps = {
@@ -74,7 +68,14 @@ type FlatRoutesProps = {
export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
const app = useApp();
const { NotFoundErrorPage } = app.getComponents();
const routes = createRoutesFromChildren(props.children);
const routes = createRoutesFromChildren(props.children)
// Routes are sorted to work around a bug where prefixes are unexpectedly matched
.sort((a, b) => b.path.localeCompare(a.path))
// We make sure all routes have '/*' appended, except '/'
.map(obj => {
obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
return obj;
});
// TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop
routes.push({
@@ -0,0 +1,94 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { AnyParams, RouteRef } from './types';
import { createRouteRef, isRouteRef } from './RouteRef';
import { isSubRouteRef } from './SubRouteRef';
import { isExternalRouteRef } from './ExternalRouteRef';
import MyIcon from '@material-ui/icons/AcUnit';
describe('RouteRef', () => {
it('should be created', () => {
const routeRef: RouteRef<undefined> = createRouteRef({
id: 'my-route-ref',
});
expect(routeRef.params).toEqual([]);
expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}');
expect(isRouteRef(routeRef)).toBe(true);
expect(isSubRouteRef(routeRef)).toBe(false);
expect(isExternalRouteRef(routeRef)).toBe(false);
expect(isRouteRef({} as RouteRef)).toBe(false);
});
it('should be created with params', () => {
const routeRef: RouteRef<{
x: string;
y: string;
}> = createRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
});
expect(routeRef.params).toEqual(['x', 'y']);
});
it('should properly infer and validate parameter types and assignments', () => {
function validateType<T extends AnyParams>(_ref: RouteRef<T>) {}
const _1 = createRouteRef({ id: '1', params: ['x'] });
// @ts-expect-error
validateType<{ y: string }>(_1);
// @ts-expect-error
validateType<undefined>(_1);
validateType<{ x: string }>(_1);
const _2 = createRouteRef({ id: '2', params: ['x', 'y'] });
// @ts-expect-error
validateType<{ x: string }>(_2);
// @ts-expect-error
validateType<undefined>(_2);
// @ts-expect-error
validateType<{ x: string; z: string }>(_2);
// TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead
validateType<{ x: string; y: string; z: string }>(_2);
validateType<{ x: string; y: string }>(_2);
const _3 = createRouteRef({ id: '3', params: [] });
// @ts-expect-error
validateType<{ x: string }>(_3);
validateType<undefined>(_3);
const _4 = createRouteRef({ id: '4' });
// @ts-expect-error
validateType<{ x: string }>(_4);
validateType<undefined>(_4);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
it('should support deprecated access', () => {
const routeRef = createRouteRef({
title: 'My Ref',
path: '/my-path',
icon: MyIcon,
});
expect(routeRef.title).toBe('My Ref');
expect(routeRef.path).toBe('/my-path');
expect(routeRef.icon).toBe(MyIcon);
expect(String(routeRef)).toBe('routeRef{type=absolute,id=My Ref}');
});
});
+37 -70
View File
@@ -16,14 +16,16 @@
import {
RouteRef,
SubRouteRef,
ExternalRouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
import { IconComponent } from '../icons';
// TODO(Rugvip): Remove this once we get rid of the deprecated fields, it's not exported
// TODO(Rugvip): Remove this in the next breaking release, it's exported but unused
export type RouteRefConfig<Params extends AnyParams> = {
params?: ParamKeys<Params>;
path?: string;
@@ -31,24 +33,19 @@ export type RouteRefConfig<Params extends AnyParams> = {
title: string;
};
class RouteRefBase {
constructor(type: string, id: string) {
this.toString = () => `routeRef{type=${type},id=${id}}`;
}
}
export class RouteRefImpl<Params extends AnyParams>
extends RouteRefBase
implements RouteRef<Params> {
readonly [routeRefType] = 'absolute';
constructor(private readonly config: RouteRefConfig<Params>) {
super('absolute', config.title);
}
get params(): ParamKeys<Params> {
return this.config.params as any;
}
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
private readonly config: {
path?: string;
icon?: IconComponent;
title?: string;
},
) {}
get icon() {
return this.config.icon;
@@ -60,14 +57,14 @@ export class RouteRefImpl<Params extends AnyParams>
}
get title() {
return this.config.title;
return this.config.title ?? this.id;
}
toString() {
return `routeRef{type=absolute,id=${this.id}}`;
}
}
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}
@@ -77,63 +74,33 @@ export function createRouteRef<
// Param = {} if the params array is empty.
ParamKey extends string = never
>(config: {
/** The id of the route ref, used to identify it when printed */
id?: string;
/** A list of parameter names that the path that this route ref is bound to must contain */
params?: ParamKey[];
/** @deprecated Route refs no longer decide their own path */
path?: string;
/** @deprecated Route refs no longer decide their own icon */
icon?: IconComponent;
/** @deprecated Route refs no longer decide their own title */
title: string;
title?: string;
}): RouteRef<OptionalParams<Params>> {
return new RouteRefImpl<OptionalParams<Params>>({
...config,
params: (config.params ?? []) as ParamKeys<OptionalParams<Params>>,
});
}
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean
>
extends RouteRefBase
implements ExternalRouteRef<Params, Optional> {
readonly [routeRefType] = 'external';
constructor(
id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
) {
super('external', id);
const id = config.id || config.title;
if (!id) {
throw new Error('RouteRef must be provided a non-empty id');
}
}
export function createExternalRouteRef<
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.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
return new ExternalRouteRefImpl<OptionalParams<Params>, Optional>(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
return new RouteRefImpl(
id,
(config.params ?? []) as ParamKeys<OptionalParams<Params>>,
config,
);
}
export function isRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): routeRef is RouteRef<Params> {
return routeRef[routeRefType] === 'absolute';
}
@@ -0,0 +1,236 @@
/*
* Copyright 2020 Spotify AB
*
* 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 } from './RouteRef';
import { createSubRouteRef } from './SubRouteRef';
import { createExternalRouteRef } from './ExternalRouteRef';
import { RouteResolver } from './RouteResolver';
import { ExternalRouteRef, RouteRef, SubRouteRef } from './types';
const element = () => null;
const rest = { element, caseSensitive: false };
const ref1 = createRouteRef({ id: 'rr1' });
const ref2 = createRouteRef({ id: 'rr2', params: ['x'] });
const ref3 = createRouteRef({ id: 'rr3', params: ['y'] });
const subRef1 = createSubRouteRef({
id: 'srr1',
parent: ref1,
path: '/foo',
});
const subRef2 = createSubRouteRef({
id: 'srr2',
parent: ref1,
path: '/foo/:a',
});
const subRef3 = createSubRouteRef({
id: 'srr3',
parent: ref2,
path: '/bar',
});
const subRef4 = createSubRouteRef({
id: 'srr4',
parent: ref2,
path: '/bar/:a',
});
const externalRef1 = createExternalRouteRef({ id: 'err1' });
const externalRef2 = createExternalRouteRef({
id: 'err2',
optional: true,
});
const externalRef3 = createExternalRouteRef({ id: 'err3', params: ['x'] });
const externalRef4 = createExternalRouteRef({
id: 'err4',
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 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: [
{ 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 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: [
{
routeRefs: new Set([ref2]),
path: '/my-parent/:x',
...rest,
children: [
{ 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$/,
);
});
});
@@ -0,0 +1,223 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
AnyRouteRef,
BackstageRouteObject,
RouteRef,
ExternalRouteRef,
AnyParams,
SubRouteRef,
routeRefType,
RouteFunc,
} from './types';
import { isRouteRef } from './RouteRef';
import { isSubRouteRef } from './SubRouteRef';
import { isExternalRouteRef } from './ExternalRouteRef';
// Joins a list of paths together, avoiding trailing and duplicate slashes
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)) {
targetRef = anyRouteRef.parent;
subRoutePath = anyRouteRef.path;
} else if (isExternalRouteRef(anyRouteRef)) {
const resolvedRoute = routeBindings.get(anyRouteRef);
if (!resolvedRoute) {
return [undefined, ''];
}
if (isRouteRef(resolvedRoute)) {
targetRef = resolvedRoute;
} else if (isSubRouteRef(resolvedRoute)) {
targetRef = resolvedRoute.parent;
subRoutePath = resolvedRoute.path;
} else {
throw new Error(
`ExternalRouteRef was bound to invalid target, ${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}`);
}
// 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) {
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 diffPath = joinPaths(
...refDiffList.slice(0, -1).map(ref => {
const path = routePaths.get(ref);
if (!path) {
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 parentPath + diffPath;
}
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
>,
) {}
resolve<Params extends AnyParams>(
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;
}
// 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 = resolveBasePath(
targetRef,
sourceLocation,
this.routePaths,
this.routeParents,
this.routeObjects,
);
const routeFunc: RouteFunc<Params> = (...[params]) => {
return basePath + generatePath(targetPath, params);
};
return routeFunc;
}
}
@@ -0,0 +1,134 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { AnyParams, SubRouteRef } from './types';
import { createSubRouteRef, isSubRouteRef } from './SubRouteRef';
import { createRouteRef, isRouteRef } from './RouteRef';
import { isExternalRouteRef } from './ExternalRouteRef';
const parent = createRouteRef({ id: 'parent' });
const parentX = createRouteRef({ id: 'parent-x', params: ['x'] });
describe('SubRouteRef', () => {
it('should be created', () => {
const routeRef: SubRouteRef<undefined> = createSubRouteRef({
parent,
id: 'my-route-ref',
path: '/foo',
});
expect(routeRef.path).toBe('/foo');
expect(routeRef.parent).toBe(parent);
expect(routeRef.params).toEqual([]);
expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}');
expect(isRouteRef(routeRef)).toBe(false);
expect(isSubRouteRef(routeRef)).toBe(true);
expect(isExternalRouteRef(routeRef)).toBe(false);
expect(isRouteRef({} as SubRouteRef)).toBe(false);
});
it('should be created with params', () => {
const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({
parent,
id: 'my-other-route-ref',
path: '/foo/:bar',
});
expect(routeRef.path).toBe('/foo/:bar');
expect(routeRef.parent).toBe(parent);
expect(routeRef.params).toEqual(['bar']);
});
it('should be created with merged params', () => {
const routeRef: SubRouteRef<{
x: string;
y: string;
z: string;
}> = createSubRouteRef({
parent: parentX,
id: 'my-other-route-ref',
path: '/foo/:y/:z',
});
expect(routeRef.path).toBe('/foo/:y/:z');
expect(routeRef.parent).toBe(parentX);
expect(routeRef.params).toEqual(['x', 'y', 'z']);
});
it('should be created with params from parent', () => {
const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({
parent: parentX,
id: 'my-other-route-ref',
path: '/foo/bar',
});
expect(routeRef.path).toBe('/foo/bar');
expect(routeRef.parent).toBe(parentX);
expect(routeRef.params).toEqual(['x']);
});
it.each([
['foo', "SubRouteRef path must start with '/', got 'foo'"],
[':foo', "SubRouteRef path must start with '/', got ':foo'"],
['', "SubRouteRef path must start with '/', got ''"],
['/', "SubRouteRef path must not end with '/', got '/'"],
['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"],
['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'],
['/:/foo', "SubRouteRef path has invalid param, got ''"],
['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"],
['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"],
])('should throw if path is invalid, %s', (path, message) => {
expect(() =>
createSubRouteRef({ path, parent: parentX, id: path }),
).toThrow(message);
});
it('should properly infer and parse path parameters', () => {
function validateType<T extends AnyParams>(_ref: SubRouteRef<T>) {}
const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' });
// @ts-expect-error
validateType<{ x: string }>(_1);
validateType<undefined>(_1);
const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' });
// @ts-expect-error
validateType<undefined>(_2);
// @ts-expect-error
validateType<{ x: string; z: string }>(_2);
// @ts-expect-error
validateType<{ y: string }>(_2);
// TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead
validateType<{ x: string; y: string; z: string }>(_2);
validateType<{ x: string; y: string }>(_2);
const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' });
// @ts-expect-error
validateType<undefined>(_3);
// @ts-expect-error
validateType<{ y: string }>(_3);
validateType<{ x: string }>(_3);
const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' });
// @ts-expect-error
validateType<undefined>(_4);
// @ts-expect-error
validateType<{ x: string; z: string }>(_4);
// @ts-expect-error
validateType<{ y: string }>(_4);
validateType<{ x: string; y: string }>(_4);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,128 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
AnyParams,
ExternalRouteRef,
OptionalParams,
ParamKeys,
RouteRef,
routeRefType,
SubRouteRef,
} from './types';
// Should match the pattern in react-router
const PARAM_PATTERN = /^\w+$/;
export class SubRouteRefImpl<Params extends AnyParams>
implements SubRouteRef<Params> {
readonly [routeRefType] = 'sub';
constructor(
private readonly id: string,
readonly path: string,
readonly parent: RouteRef,
readonly params: ParamKeys<Params>,
) {}
toString() {
return `routeRef{type=sub,id=${this.id}}`;
}
}
// These utility types help us infer a Param object type from a string path
// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }`
type ParamPart<S extends string> = S extends `:${infer Param}` ? Param : never;
type ParamNames<S extends string> = S extends `${infer Part}/${infer Rest}`
? ParamPart<Part> | ParamNames<Rest>
: ParamPart<S>;
type PathParams<S extends string> = { [name in ParamNames<S>]: string };
/**
* Merges a param object type with with an optional params type into a params object
*/
type MergeParams<
P1 extends { [param in string]: string },
P2 extends AnyParams
> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2);
/**
* Creates a SubRouteRef type given the desired parameters and parent route parameters.
* The parameters types are merged together while ensuring that there is no overlap between the two.
*/
type MakeSubRouteRef<
Params extends { [param in string]: string },
ParentParams extends AnyParams
> = keyof Params & keyof ParentParams extends never
? SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>
: never;
export function createSubRouteRef<
Path extends string,
ParentParams extends AnyParams = never
>(config: {
id: string;
path: Path;
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
const { id, path, parent } = config;
type Params = PathParams<Path>;
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
const pathParams = path
.split('/')
.filter(p => p.startsWith(':'))
.map(p => p.substring(1));
const params = [...parent.params, ...pathParams];
if (parent.params.some(p => pathParams.includes(p as string))) {
throw new Error(
'SubRouteRef may not have params that overlap with its parent',
);
}
if (!path.startsWith('/')) {
throw new Error(`SubRouteRef path must start with '/', got '${path}'`);
}
if (path.endsWith('/')) {
throw new Error(`SubRouteRef path must not end with '/', got '${path}'`);
}
for (const param of pathParams) {
if (!PARAM_PATTERN.test(param)) {
throw new Error(`SubRouteRef path has invalid param, got '${param}'`);
}
}
// We ensure that the type of the return type is sane here
const subRouteRef = new SubRouteRefImpl(
id,
path,
parent,
params as ParamKeys<MergeParams<Params, ParentParams>>,
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
// But skip type checking of the return value itself, because the conditional
// type checking of the parent parameter overlap is tricky to express.
return subRouteRef as any;
}
export function isSubRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): routeRef is SubRouteRef<Params> {
return routeRef[routeRefType] === 'sub';
}
+5 -12
View File
@@ -29,18 +29,11 @@ import {
routeParentCollector,
routeObjectCollector,
} from './collectors';
import {
useRouteRef,
RoutingProvider,
validateRoutes,
RouteFunc,
} from './hooks';
import {
createRouteRef,
createExternalRouteRef,
RouteRefConfig,
} from './RouteRef';
import { AnyRouteRef, RouteRef, ExternalRouteRef } from './types';
import { validateRoutes } from './validation';
import { useRouteRef, RoutingProvider } from './hooks';
import { createRouteRef, RouteRefConfig } from './RouteRef';
import { createExternalRouteRef } from './ExternalRouteRef';
import { AnyRouteRef, RouteFunc, RouteRef, ExternalRouteRef } from './types';
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
path: '/unused',
+12 -135
View File
@@ -15,103 +15,16 @@
*/
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import {
AnyRouteRef,
BackstageRouteObject,
RouteRef,
ExternalRouteRef,
AnyParams,
SubRouteRef,
RouteFunc,
} from './types';
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
// 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.
// 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 extends AnyParams> = (
...[params]: Params extends undefined ? readonly [] : readonly [Params]
) => string;
class RouteResolver {
constructor(
private readonly routePaths: Map<AnyRouteRef, string>,
private readonly routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
private readonly routeObjects: BackstageRouteObject[],
private readonly routeBindings: Map<RouteRef | ExternalRouteRef, RouteRef>,
) {}
resolve<Params extends AnyParams>(
routeRefOrExternalRouteRef: RouteRef<Params> | ExternalRouteRef<Params>,
sourceLocation: ReturnType<typeof useLocation>,
): RouteFunc<Params> | undefined {
const routeRef =
this.routeBindings.get(routeRefOrExternalRouteRef) ??
(routeRefOrExternalRouteRef as RouteRef<Params>);
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) {
return undefined;
}
const targetRefStack = Array<AnyRouteRef>();
let matchIndex = -1;
for (
let currentRouteRef: AnyRouteRef | undefined = routeRef;
currentRouteRef;
currentRouteRef = this.routeParents.get(currentRouteRef)
) {
matchIndex = match.findIndex(m =>
(m.route as BackstageRouteObject).routeRefs.has(currentRouteRef!),
);
if (matchIndex !== -1) {
break;
}
targetRefStack.unshift(currentRouteRef);
}
// 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 (targetRefStack.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 called would have no way of knowing
// what parameters those are.
const prefixPath = targetRefStack
.slice(0, -1)
.map(ref => {
const path = this.routePaths.get(ref);
if (!path) {
throw new Error(`No path for ${ref}`);
}
if (path.includes(':')) {
throw new Error(
`Cannot route to ${routeRef} with parent ${ref} as it has parameters`,
);
}
return path;
})
.join('/')
.replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s
const routeFunc: RouteFunc<Params> = (...[params]) => {
return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`;
};
return routeFunc;
}
}
import { RouteResolver } from './RouteResolver';
const RoutingContext = createContext<RouteResolver | undefined>(undefined);
@@ -119,10 +32,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);
@@ -144,10 +60,10 @@ export function useRouteRef<Params extends AnyParams>(
}
type ProviderProps = {
routePaths: Map<AnyRouteRef, string>;
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>;
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeBindings: Map<ExternalRouteRef, RouteRef>;
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
children: ReactNode;
};
@@ -170,42 +86,3 @@ export const RoutingProvider = ({
</RoutingContext.Provider>
);
};
export function validateRoutes(
routePaths: Map<AnyRouteRef, string>,
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
) {
const notLeafRoutes = new Set(routeParents.values());
notLeafRoutes.delete(undefined);
for (const route of routeParents.keys()) {
if (notLeafRoutes.has(route)) {
continue;
}
let currentRouteRef: AnyRouteRef | undefined = route;
let fullPath = '';
while (currentRouteRef) {
const path = routePaths.get(currentRouteRef);
if (!path) {
throw new Error(`No path for ${currentRouteRef}`);
}
fullPath = `${path}${fullPath}`;
currentRouteRef = routeParents.get(currentRouteRef);
}
const params = fullPath.match(/:(\w+)/g);
if (params) {
for (let j = 0; j < params.length; j++) {
for (let i = j + 1; i < params.length; i++) {
if (params[i] === params[j]) {
throw new Error(
`Parameter ${params[i]} is duplicated in path ${fullPath}`,
);
}
}
}
}
}
}
+3 -1
View File
@@ -22,6 +22,8 @@ export type {
ExternalRouteRef,
} from './types';
export { FlatRoutes } from './FlatRoutes';
export { createRouteRef, createExternalRouteRef } from './RouteRef';
export { createRouteRef } from './RouteRef';
export { createSubRouteRef } from './SubRouteRef';
export { createExternalRouteRef } from './ExternalRouteRef';
export type { RouteRefConfig } from './RouteRef';
export { useRouteRef } from './hooks';
+27 -2
View File
@@ -21,6 +21,18 @@ export type AnyParams = { [param in string]: string } | undefined;
export type ParamKeys<Params extends AnyParams> = keyof Params extends never
? []
: (keyof Params)[];
export type OptionalParams<
Params extends { [param in string]: string }
> = Params[keyof Params] extends never ? undefined : Params;
// 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.
// 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 extends AnyParams> = (
...[params]: Params extends undefined ? readonly [] : readonly [Params]
) => string;
export const routeRefType: unique symbol = getGlobalSingleton<any>(
'route-ref-type',
@@ -41,6 +53,16 @@ export type RouteRef<Params extends AnyParams = any> = {
title?: string;
};
export type SubRouteRef<Params extends AnyParams = any> = {
readonly [routeRefType]: 'sub';
parent: RouteRef;
path: string;
params: ParamKeys<Params>;
};
export type ExternalRouteRef<
Params extends AnyParams = any,
Optional extends boolean = any
@@ -52,7 +74,10 @@ export type ExternalRouteRef<
optional?: Optional;
};
export type AnyRouteRef = RouteRef<any> | ExternalRouteRef<any, any>;
export type AnyRouteRef =
| RouteRef<any>
| SubRouteRef<any>
| ExternalRouteRef<any, any>;
// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release
/** @deprecated */
@@ -68,5 +93,5 @@ export interface BackstageRouteObject {
children?: BackstageRouteObject[];
element: React.ReactNode;
path: string;
routeRefs: Set<AnyRouteRef>;
routeRefs: Set<RouteRef>;
}
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { AnyRouteRef } from './types';
export function validateRoutes(
routePaths: Map<AnyRouteRef, string>,
routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
) {
const notLeafRoutes = new Set(routeParents.values());
notLeafRoutes.delete(undefined);
for (const route of routeParents.keys()) {
if (notLeafRoutes.has(route)) {
continue;
}
let currentRouteRef: AnyRouteRef | undefined = route;
let fullPath = '';
while (currentRouteRef) {
const path = routePaths.get(currentRouteRef);
if (!path) {
throw new Error(`No path for ${currentRouteRef}`);
}
fullPath = `${path}${fullPath}`;
currentRouteRef = routeParents.get(currentRouteRef);
}
const params = fullPath.match(/:(\w+)/g);
if (params) {
for (let j = 0; j < params.length; j++) {
for (let i = j + 1; i < params.length; i++) {
if (params[i] === params[j]) {
throw new Error(
`Parameter ${params[i]} is duplicated in path ${fullPath}`,
);
}
}
}
}
}
}
@@ -24,6 +24,7 @@ import privateExports, {
RouteRef,
ExternalRouteRef,
attachComponentData,
createRouteRef,
} from '@backstage/core-api';
import { RenderResult } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils-core';
@@ -65,6 +66,13 @@ type TestAppOptions = {
mountedRoutes?: { [path: string]: RouteRef | ExternalRouteRef };
};
function isExternalRouteRef(
routeRef: RouteRef | ExternalRouteRef,
): routeRef is ExternalRouteRef {
// TODO(Rugvip): Least ugly workaround for now, but replace :D
return String(routeRef).includes('{type=external,');
}
/**
* Wraps a component inside a Backstage test app, providing a mocked theme
* and app context, along with mocked APIs.
@@ -77,6 +85,7 @@ export function wrapInTestApp(
options: TestAppOptions = {},
): ReactElement {
const { routeEntries = ['/'] } = options;
const boundRoutes = new Map<ExternalRouteRef, RouteRef>();
const app = new PrivateAppImpl({
apis: [],
@@ -99,7 +108,16 @@ export function wrapInTestApp(
},
],
defaultApis: mockApis,
bindRoutes: () => {},
bindRoutes: ({ bind }) => {
for (const [externalRef, absoluteRef] of boundRoutes) {
bind(
{ ref: externalRef },
{
ref: absoluteRef,
},
);
}
},
});
let Wrapper: ComponentType;
@@ -112,7 +130,16 @@ export function wrapInTestApp(
const routeElements = Object.entries(options.mountedRoutes ?? {}).map(
([path, routeRef]) => {
const Page = () => <div>Mounted at {path}</div>;
attachComponentData(Page, 'core.mountPoint', routeRef);
// Allow external route refs to be bound to paths as well, for convenience.
// We work around it by creating and binding an absolute ref to the external one.
if (isExternalRouteRef(routeRef)) {
const absoluteRef = createRouteRef({ id: 'id' });
boundRoutes.set(routeRef, absoluteRef);
attachComponentData(Page, 'core.mountPoint', absoluteRef);
} else {
attachComponentData(Page, 'core.mountPoint', routeRef);
}
return <Route key={path} path={path} element={<Page />} />;
},
);