core-api: add support for optional external route refs

This commit is contained in:
Patrik Oldsberg
2021-02-25 17:32:39 +01:00
parent 674202d2d2
commit a3a2e23bc4
7 changed files with 137 additions and 30 deletions
+27 -5
View File
@@ -52,11 +52,21 @@ describe('Integration Test', () => {
const plugin1RouteRef = createRouteRef({ path: '/blah1', title: '' });
const plugin2RouteRef = createRouteRef({ path: '/blah2', title: '' });
const externalRouteRef = createExternalRouteRef({ id: '3' });
const optionalBarExternalRouteRef = createExternalRouteRef({
id: 'bar',
optional: true,
});
const optionalBazExternalRouteRef = createExternalRouteRef({
id: 'baz',
optional: true,
});
const plugin1 = createPlugin({
id: 'blob',
externalRoutes: {
foo: externalRouteRef,
bar: optionalBarExternalRouteRef,
baz: optionalBazExternalRouteRef,
},
});
@@ -76,14 +86,21 @@ describe('Integration Test', () => {
component: () =>
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const routeRefFunction = useRouteRef(externalRouteRef);
return <div>Our Route Is: {routeRefFunction()}</div>;
const externalLink = useRouteRef(externalRouteRef);
const barLink = useRouteRef(optionalBarExternalRouteRef);
const bazLink = useRouteRef(optionalBazExternalRouteRef);
return (
<div>
Our routes are: {externalLink()}, bar: {barLink?.() ?? 'none'},
baz: {bazLink?.() ?? 'none'}
</div>
);
}),
mountPoint: plugin1RouteRef,
}),
);
it('runs happy path', async () => {
it('runs happy paths', async () => {
const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
@@ -106,7 +123,10 @@ describe('Integration Test', () => {
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, { foo: plugin2RouteRef });
bind(plugin1.externalRoutes, {
foo: plugin2RouteRef,
bar: plugin2RouteRef,
});
},
});
@@ -124,7 +144,9 @@ describe('Integration Test', () => {
</Provider>,
);
expect(screen.getByText('Our Route Is: /foo/bar')).toBeInTheDocument();
expect(
screen.getByText('Our routes are: /foo/bar, bar: /foo/bar, baz: none'),
).toBeInTheDocument();
});
it('should throw some error when the route has duplicate params', () => {
+8 -2
View File
@@ -84,8 +84,14 @@ export function generateBoundRoutes(
if (!externalRoute) {
throw new Error(`Key ${key} is not an existing external route`);
}
result.set(externalRoute, value);
if (!value && !externalRoute.optional) {
throw new Error(
`External route ${key} is required but was undefined`,
);
}
if (value) {
result.set(externalRoute, value);
}
}
};
bindRoutes({ bind });
+45 -5
View File
@@ -16,8 +16,8 @@
import { ComponentType } from 'react';
import { IconComponent, IconComponentMap, IconKey } from '../icons';
import { BackstagePlugin, AnyExternalRoutes } from '../plugin/types';
import { RouteRef } from '../routing';
import { BackstagePlugin } from '../plugin/types';
import { ExternalRouteRef, RouteRef } from '../routing';
import { AnyApiFactory } from '../apis';
import { AppTheme, ProfileInfo } from '../apis/definitions';
import { AppConfig } from '@backstage/config';
@@ -79,9 +79,49 @@ export type AppComponents = {
*/
export type AppConfigLoader = () => Promise<AppConfig[]>;
export type AppRouteBinder = <T extends AnyExternalRoutes>(
externalRoutes: T,
targetRoutes: { [key in keyof T]: RouteRef<any> },
/**
* Extracts the Optional type of a map of ExternalRouteRefs, leaving only the boolean in place as value
*/
type ExternalRouteRefsToOptionalMap<
T extends { [name in string]: ExternalRouteRef<boolean> }
> = {
[name in keyof T]: T[name] extends ExternalRouteRef<infer U> ? U : never;
};
/**
* Extracts a union of the keys in a map whose value extends the given type
*/
type ExtractKeysWithType<Obj extends { [key in string]: any }, Type> = {
[key in keyof Obj]: Obj[key] extends Type ? key : never;
}[keyof Obj];
/**
* Given a map of boolean values denoting whether a rout is optional, create a
* map of needed RouteRefs.
*
* For example { foo: false, bar: true } gives { foo: RouteRef<any>, bar?: RouteRef<any> }
*/
type CombineOptionalAndRequiredRoutes<
OptionalMap extends { [key in string]: boolean }
> = {
[name in ExtractKeysWithType<OptionalMap, false>]: RouteRef<any>;
} &
{ [name in keyof OptionalMap]?: RouteRef<any> };
/**
* Creates a map of required target routes based on whether the input external
* routes are optional or not. The external routes that are marked as optional
* will also be optional in the target routes map.
*/
type TargetRoutesMap<
T extends { [name in string]: ExternalRouteRef<boolean> }
> = CombineOptionalAndRequiredRoutes<ExternalRouteRefsToOptionalMap<T>>;
export type AppRouteBinder = <
ExternalRoutes extends { [name in string]: ExternalRouteRef<boolean> }
>(
externalRoutes: ExternalRoutes,
targetRoutes: TargetRoutesMap<ExternalRoutes>,
) => void;
export type AppOptions = {
+21 -8
View File
@@ -64,23 +64,36 @@ export function createRouteRef<
return new AbsoluteRouteRef<Params>(config);
}
export class ExternalRouteRef {
private constructor(id: string) {
export class ExternalRouteRef<Optional extends boolean = true> {
readonly optional: boolean;
private constructor({ id, optional }: ExternalRouteRefOptions<Optional>) {
this.toString = () => `externalRouteRef{${id}}`;
this.optional = Boolean(optional);
}
}
export type ExternalRouteRefOptions = {
export type ExternalRouteRefOptions<Optional extends boolean = false> = {
/**
* An identifier for this route, used to identify it in error messages
*/
id: string;
/**
* Whether or not this route is optional, defaults to false.
*
* Optional external routes are not required to be bound in the app, and
* if aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
};
export function createExternalRouteRef(
options: ExternalRouteRefOptions,
): ExternalRouteRef {
export function createExternalRouteRef<Optional extends boolean = false>(
options: ExternalRouteRefOptions<Optional>,
): ExternalRouteRef<Optional> {
return new ((ExternalRouteRef as unknown) as {
new (id: string): ExternalRouteRef;
})(options.id);
new (options: ExternalRouteRefOptions<Optional>): ExternalRouteRef<
Optional
>;
})(options);
}
+17 -4
View File
@@ -41,7 +41,7 @@ import {
ExternalRouteRef,
RouteRefConfig,
} from './RouteRef';
import { RouteRef } from './types';
import { AnyRouteRef, RouteRef } from './types';
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
path: '/unused',
@@ -62,18 +62,22 @@ const ref5 = createRouteRef(mockConfig({ path: '/wat5' }));
const eRefA = createExternalRouteRef({ id: '1' });
const eRefB = createExternalRouteRef({ id: '2' });
const eRefC = createExternalRouteRef({ id: '3' });
const eRefD = createExternalRouteRef({ id: '4', optional: true });
const eRefE = createExternalRouteRef({ id: '5', optional: true });
const MockRouteSource = <T extends { [name in string]: string }>(props: {
path?: string;
name: string;
routeRef: RouteRef<T> | ExternalRouteRef;
routeRef: AnyRouteRef;
params?: T;
}) => {
try {
const routeFunc = useRouteRef(props.routeRef) as RouteFunc<any>;
const routeFunc = useRouteRef(props.routeRef as any) as
| RouteFunc<any>
| undefined;
return (
<div>
Path at {props.name}: {routeFunc(props.params)}
Path at {props.name}: {routeFunc?.(props.params) ?? '<none>'}
</div>
);
} catch (ex) {
@@ -156,6 +160,8 @@ describe('discovery', () => {
<MockRouteSource name="outside" routeRef={ref2} />
<MockRouteSource name="outsideExternal1" routeRef={eRefB} />
<MockRouteSource name="outsideExternal2" routeRef={eRefC} />
<MockRouteSource name="outsideExternal3" routeRef={eRefD} />
<MockRouteSource name="outsideExternal4" routeRef={eRefE} />
</MemoryRouter>
);
@@ -164,6 +170,7 @@ describe('discovery', () => {
[eRefA, ref3],
[eRefB, ref1],
[eRefC, ref2],
[eRefD, ref1],
]),
);
@@ -180,6 +187,12 @@ describe('discovery', () => {
expect(
rendered.getByText('Path at outsideExternal2: /foo/bar'),
).toBeInTheDocument();
expect(
rendered.getByText('Path at outsideExternal3: /foo'),
).toBeInTheDocument();
expect(
rendered.getByText('Path at outsideExternal4: <none>'),
).toBeInTheDocument();
});
it('should handle routeRefs with parameters', async () => {
+17 -5
View File
@@ -35,22 +35,23 @@ class RouteResolver {
private readonly routePaths: Map<AnyRouteRef, string>,
private readonly routeParents: Map<AnyRouteRef, AnyRouteRef | undefined>,
private readonly routeObjects: BackstageRouteObject[],
private readonly routeBindings: Map<ExternalRouteRef, RouteRef>,
private readonly routeBindings: Map<RouteRef | ExternalRouteRef, RouteRef>,
) {}
resolve<Params extends { [param in string]: string }>(
routeRefOrExternalRouteRef: RouteRef<Params> | ExternalRouteRef,
sourceLocation: ReturnType<typeof useLocation>,
): RouteFunc<Params> {
): 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) {
throw new Error(`No path for ${routeRef}`);
return undefined;
}
const targetRefStack = Array<AnyRouteRef>();
let matchIndex = -1;
@@ -111,9 +112,15 @@ class RouteResolver {
const RoutingContext = createContext<RouteResolver | undefined>(undefined);
export function useRouteRef<Optional extends boolean>(
routeRef: ExternalRouteRef<Optional>,
): Optional extends true ? RouteFunc<{}> | undefined : RouteFunc<{}>;
export function useRouteRef<Params extends { [param in string]: string } = {}>(
routeRef: RouteRef<Params>,
): RouteFunc<Params>;
export function useRouteRef<Params extends { [param in string]: string } = {}>(
routeRef: RouteRef<Params> | ExternalRouteRef,
): RouteFunc<Params> {
): RouteFunc<Params> | undefined {
const sourceLocation = useLocation();
const resolver = useContext(RoutingContext);
const routeFunc = useMemo(
@@ -121,10 +128,15 @@ export function useRouteRef<Params extends { [param in string]: string } = {}>(
[resolver, routeRef, sourceLocation],
);
if (!routeFunc) {
if (!routeFunc && !resolver) {
throw new Error('No route resolver found in context');
}
const isOptional = 'optional' in routeRef && routeRef.optional;
if (!routeFunc && !isOptional) {
throw new Error(`No path for ${routeRef}`);
}
return routeFunc;
}
+2 -1
View File
@@ -15,6 +15,7 @@
*/
import { IconComponent } from '../icons';
import { ExternalRouteRef } from './RouteRef';
// @ts-ignore, we're just embedding the Params type for usage in other places
export type RouteRef<Params extends { [param in string]: string } = {}> = {
@@ -25,7 +26,7 @@ export type RouteRef<Params extends { [param in string]: string } = {}> = {
title: string;
};
export type AnyRouteRef = RouteRef<any>;
export type AnyRouteRef = RouteRef<any> | ExternalRouteRef<any>;
/**
* This type should not be used