frontend-plugin-api: make all route refs optional at all times
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/frontend-test-utils': patch
|
||||
'@backstage/frontend-app-api': patch
|
||||
'@backstage/core-compat-api': patch
|
||||
'@backstage/plugin-app-visualizer': patch
|
||||
---
|
||||
|
||||
Updated usage of `useRouteRef`, which can now always return `undefined`.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': minor
|
||||
---
|
||||
|
||||
**BREAKING**: All types of route refs are always considered optional by `useRouteRef`, which means the caller must always handle a potential `undefined` return value. Related to this change, the `optional` option from `createExternalRouteRef` has been removed, since it is no longer necessary.
|
||||
|
||||
This is released as an immediate breaking change as we expect the usage of the new route refs to be extremely low or zero, since plugins that support the new system will still use route refs and `useRouteRef` from `@backstage/core-plugin-api` in combination with `convertLegacyRouteRef` from `@backstage/core-compat-api`.
|
||||
@@ -101,23 +101,26 @@ export const IndexPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Index Page</h1>
|
||||
<a
|
||||
{/* highlight-start */}
|
||||
href={getDetailsPath({
|
||||
kind: 'component',
|
||||
namespace: 'default',
|
||||
name: 'foo',
|
||||
})}
|
||||
{/* highlight-end */}
|
||||
>
|
||||
See "Foo" details
|
||||
</a>
|
||||
{/* highlight-next-line */}
|
||||
{getDetailsPath && (
|
||||
<a
|
||||
{/* highlight-start */}
|
||||
href={getDetailsPath({
|
||||
kind: 'component',
|
||||
namespace: 'default',
|
||||
name: 'foo',
|
||||
})}
|
||||
{/* highlight-end */}
|
||||
>
|
||||
See "Foo" details
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
We use the `useRouteRef` hook to create a link generator function that returns the details page path. We then call the link generator, passing it an object with the kind, namespace, and name. These parameters are used to construct a concrete path to the "Foo" details page.
|
||||
We use the `useRouteRef` hook to create a link generator function that returns the details page path. First we need to check whether the route is available, the link generator function will be `undefined` if it isn't. We then call the link generator, passing it an object with the kind, namespace, and name. These parameters are used to construct a concrete path to the "Foo" details page.
|
||||
|
||||
Let's see how the details page can get the parameters from the URL:
|
||||
|
||||
@@ -176,8 +179,11 @@ export const IndexPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Index Page</h1>
|
||||
{/* highlight-next-line */}
|
||||
<a href={getCreateComponentPath()}>Create Component</a>
|
||||
{/* highlight-start */}
|
||||
{getCreateComponentPath && (
|
||||
<a href={getCreateComponentPath()}>Create Component</a>
|
||||
)}
|
||||
{/* highlight-end */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -289,42 +295,6 @@ export const createComponentExternalRouteRef = createExternalRouteRef({
|
||||
});
|
||||
```
|
||||
|
||||
### Optional External Route References
|
||||
|
||||
It is possible to define an `ExternalRouteRef` as optional, so it is not required to bind it in the app.
|
||||
|
||||
```tsx title="plugins/catalog/src/routes.ts"
|
||||
import { createExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
export const createComponentExternalRouteRef = createExternalRouteRef({
|
||||
// highlight-next-line
|
||||
optional: true,
|
||||
});
|
||||
```
|
||||
|
||||
When calling `useRouteRef` with an optional external route, its return signature is changed to `RouteFunc | undefined`, and the returned value can be used to decide whether a certain link should be displayed or if an action should be taken:
|
||||
|
||||
```tsx title="plugins/catalog/src/components/IndexPage.tsx"
|
||||
import React from 'react';
|
||||
import { useRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { createComponentExternalRouteRef } from '../routes';
|
||||
|
||||
export const IndexPage = () => {
|
||||
const getCreateComponentPath = useRouteRef(createComponentExternalRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Index Page</h1>
|
||||
{/* Rendering the link only if the getCreateComponentPath is defined */}
|
||||
{/* highlight-start */}
|
||||
{getCreateComponentPath && (
|
||||
<a href={getCreateComponentPath()}>Create Component</a>
|
||||
)}
|
||||
{/* highlight-end */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Sub Route References
|
||||
|
||||
The last kind of route ref that can be created is a `SubRouteRef`, 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 page extension component, and you want other plugins to be able to route to that page. And they can be a useful utility to handle routing within a plugin itself as well.
|
||||
@@ -368,17 +338,21 @@ import { DetailsPage } from './DetailsPage';
|
||||
|
||||
export const IndexPage = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// highlight-start
|
||||
const getIndexPath = useRouteRef(indexRouteRef);
|
||||
const getDetailsPath = useRouteRef(detailsSubRouteRef);
|
||||
// highlight-end
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Index Page</h1>
|
||||
{/* Linking to the details sub route */}
|
||||
{pathname === getIndexPath() ? (
|
||||
// highlight-start
|
||||
{/* highlight-start */}
|
||||
{pathname === getIndexPath?.() ? (
|
||||
<a
|
||||
{/* Setting the details sub route params */}
|
||||
href={getDetailsPath({
|
||||
href={getDetailsPath?.({
|
||||
kind: 'component',
|
||||
namespace: 'default',
|
||||
name: 'foo',
|
||||
@@ -386,13 +360,14 @@ export const IndexPage = () => {
|
||||
>
|
||||
Show details
|
||||
</a>
|
||||
// highlight-end
|
||||
{/* highlight-end */}
|
||||
) : (
|
||||
// highlight-next-line
|
||||
<a href={getIndexPath()}>Hide details</a>
|
||||
{/* highlight-next-line */}
|
||||
<a href={getIndexPath?.()}>Hide details</a>
|
||||
)}
|
||||
{/* Registering the details sub route */}
|
||||
<Routes>
|
||||
{/* highlight-next-line */}
|
||||
<Route path={detailsSubRouteRef.path} element={<DetailsPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -47,9 +47,11 @@ const IndexPage = createPageExtension({
|
||||
return (
|
||||
<div>
|
||||
op
|
||||
<div>
|
||||
<Link to={page1Link()}>Page 1</Link>
|
||||
</div>
|
||||
{page1Link && (
|
||||
<div>
|
||||
<Link to={page1Link()}>Page 1</Link>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Link to="/home">Home</Link>
|
||||
</div>
|
||||
@@ -82,10 +84,10 @@ const Page1 = createPageExtension({
|
||||
return (
|
||||
<div>
|
||||
<h1>This is page 1</h1>
|
||||
<Link to={indexLink()}>Go back</Link>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
<Link to="./page2">Page 2</Link>
|
||||
{/* <Link to={page2Link()}>Page 2</Link> */}
|
||||
<Link to={xLink()}>Page X</Link>
|
||||
{xLink && <Link to={xLink()}>Page X</Link>}
|
||||
|
||||
<div>
|
||||
Sub-page content:
|
||||
@@ -115,7 +117,7 @@ const ExternalPage = createPageExtension({
|
||||
return (
|
||||
<div>
|
||||
<h1>This is page X</h1>
|
||||
<Link to={indexLink()}>Go back</Link>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,12 +37,9 @@ export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
): SubRouteRef_2<TParams>;
|
||||
|
||||
// @public
|
||||
export function convertLegacyRouteRef<
|
||||
TParams extends AnyRouteRefParams,
|
||||
TOptional extends boolean,
|
||||
>(
|
||||
ref: ExternalRouteRef<TParams, TOptional>,
|
||||
): ExternalRouteRef_2<TParams, TOptional>;
|
||||
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
ref: ExternalRouteRef<TParams>,
|
||||
): ExternalRouteRef_2<TParams>;
|
||||
|
||||
// @public
|
||||
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
@@ -55,12 +52,9 @@ export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
): SubRouteRef<TParams>;
|
||||
|
||||
// @public
|
||||
export function convertLegacyRouteRef<
|
||||
TParams extends AnyRouteRefParams,
|
||||
TOptional extends boolean,
|
||||
>(
|
||||
ref: ExternalRouteRef_2<TParams, TOptional>,
|
||||
): ExternalRouteRef<TParams, TOptional>;
|
||||
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
ref: ExternalRouteRef_2<TParams>,
|
||||
): ExternalRouteRef<TParams, true>;
|
||||
|
||||
// @public
|
||||
export function convertLegacyRouteRefs<
|
||||
@@ -93,8 +87,8 @@ export type ToNewRouteRef<T extends RouteRef | SubRouteRef | ExternalRouteRef> =
|
||||
? RouteRef_2<IParams>
|
||||
: T extends SubRouteRef<infer IParams>
|
||||
? SubRouteRef_2<IParams>
|
||||
: T extends ExternalRouteRef<infer IParams, infer IOptional>
|
||||
? ExternalRouteRef_2<IParams, IOptional>
|
||||
: T extends ExternalRouteRef<infer IParams>
|
||||
? ExternalRouteRef_2<IParams>
|
||||
: never;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
@@ -111,7 +111,7 @@ class CompatRouteResolutionApi implements RouteResolutionApi {
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: RouteResolutionApiResolveOptions | undefined,
|
||||
): RouteFunc<TParams> | undefined {
|
||||
const legacyRef = convertLegacyRouteRef(anyRouteRef as RouteRef<TParams>);
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('ForwardsCompatProvider', () => {
|
||||
|
||||
function Component() {
|
||||
const link = useNewRouteRef(routeRef);
|
||||
return <div>link: {link()}</div>;
|
||||
return <div>link: {link?.()}</div>;
|
||||
}
|
||||
|
||||
await renderInOldTestApp(compatWrapper(<Component />), {
|
||||
|
||||
@@ -123,13 +123,11 @@ describe('convertLegacyRouteRef', () => {
|
||||
'routeRef{type=external,id=ref3}',
|
||||
);
|
||||
expect(ref3Internal.getParams()).toEqual([]);
|
||||
expect(ref3Internal.optional).toBe(false);
|
||||
expect(ref4Internal.getDefaultTarget()).toBe('ref2');
|
||||
expect(ref4Internal.getDescription()).toBe(
|
||||
'routeRef{type=external,id=ref4}',
|
||||
);
|
||||
expect(ref4Internal.getParams()).toEqual(['p1', 'p2']);
|
||||
expect(ref4Internal.optional).toBe(true);
|
||||
});
|
||||
|
||||
it('converts new to old', () => {
|
||||
@@ -149,7 +147,6 @@ describe('convertLegacyRouteRef', () => {
|
||||
});
|
||||
const ref3 = createNewExternalRouteRef();
|
||||
const ref4 = createNewExternalRouteRef({
|
||||
optional: true,
|
||||
defaultTarget: 'ref2',
|
||||
params: ['p1', 'p2'],
|
||||
});
|
||||
@@ -196,7 +193,7 @@ describe('convertLegacyRouteRef', () => {
|
||||
/^ExternalRouteRef\{created at '.*'\}$/,
|
||||
);
|
||||
expect(ref3Converted.params).toEqual([]);
|
||||
expect(ref3Converted.optional).toBe(false);
|
||||
expect(ref3Converted.optional).toBe(true);
|
||||
expect(String(ref4Converted)).toMatch(
|
||||
/^ExternalRouteRef\{created at '.*'\}$/,
|
||||
);
|
||||
|
||||
@@ -51,8 +51,8 @@ export type ToNewRouteRef<
|
||||
? RouteRef<IParams>
|
||||
: T extends LegacySubRouteRef<infer IParams>
|
||||
? SubRouteRef<IParams>
|
||||
: T extends LegacyExternalRouteRef<infer IParams, infer IOptional>
|
||||
? ExternalRouteRef<IParams, IOptional>
|
||||
: T extends LegacyExternalRouteRef<infer IParams>
|
||||
? ExternalRouteRef<IParams>
|
||||
: never;
|
||||
|
||||
/**
|
||||
@@ -109,12 +109,9 @@ export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
*
|
||||
* In the future the legacy createExternalRouteRef will instead create refs compatible with both systems.
|
||||
*/
|
||||
export function convertLegacyRouteRef<
|
||||
TParams extends AnyRouteRefParams,
|
||||
TOptional extends boolean,
|
||||
>(
|
||||
ref: LegacyExternalRouteRef<TParams, TOptional>,
|
||||
): ExternalRouteRef<TParams, TOptional>;
|
||||
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
ref: LegacyExternalRouteRef<TParams>,
|
||||
): ExternalRouteRef<TParams>;
|
||||
|
||||
/**
|
||||
* A temporary helper to convert a new route ref to the legacy system.
|
||||
@@ -148,12 +145,9 @@ export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
*
|
||||
* In the future the legacy createExternalRouteRef will instead create refs compatible with both systems.
|
||||
*/
|
||||
export function convertLegacyRouteRef<
|
||||
TParams extends AnyRouteRefParams,
|
||||
TOptional extends boolean,
|
||||
>(
|
||||
ref: ExternalRouteRef<TParams, TOptional>,
|
||||
): LegacyExternalRouteRef<TParams, TOptional>;
|
||||
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
|
||||
ref: ExternalRouteRef<TParams>,
|
||||
): LegacyExternalRouteRef<TParams, true>;
|
||||
export function convertLegacyRouteRef(
|
||||
ref:
|
||||
| LegacyRouteRef
|
||||
@@ -209,6 +203,7 @@ function convertNewToOld(
|
||||
const newRef = toInternalExternalRouteRef(ref);
|
||||
return Object.assign(ref, {
|
||||
[routeRefType]: 'external',
|
||||
optional: true,
|
||||
params: newRef.getParams(),
|
||||
defaultTarget: newRef.getDefaultTarget(),
|
||||
} as Omit<LegacyExternalRouteRef, '$$routeRefType' | 'optional'>) as unknown as LegacyExternalRouteRef;
|
||||
@@ -282,7 +277,6 @@ function convertOldToNew(
|
||||
const newRef = toInternalExternalRouteRef(
|
||||
createExternalRouteRef<{ [key in string]: string }>({
|
||||
params: legacyRef.params as string[],
|
||||
optional: legacyRef.optional,
|
||||
defaultTarget:
|
||||
'getDefaultTarget' in legacyRef
|
||||
? (legacyRef.getDefaultTarget as () => string | undefined)()
|
||||
@@ -293,7 +287,6 @@ function convertOldToNew(
|
||||
$$type: '@backstage/ExternalRouteRef' as const,
|
||||
version: 'v1',
|
||||
T: newRef.T,
|
||||
optional: newRef.optional,
|
||||
getParams() {
|
||||
return newRef.getParams();
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ export type CreateAppRouteBinder = <
|
||||
externalRoutes: TExternalRoutes,
|
||||
targetRoutes: PartialKeys<
|
||||
TargetRouteMap<TExternalRoutes>,
|
||||
KeysWithType<TExternalRoutes, ExternalRouteRef<any, true>>
|
||||
KeysWithType<TExternalRoutes, ExternalRouteRef<any>>
|
||||
>,
|
||||
) => void;
|
||||
|
||||
|
||||
@@ -73,9 +73,12 @@ const SidebarNavItem = (
|
||||
props: (typeof createNavItemExtension.targetDataRef)['T'],
|
||||
) => {
|
||||
const { icon: Icon, title, routeRef } = props;
|
||||
const to = useRouteRef(routeRef)();
|
||||
const link = useRouteRef(routeRef);
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
// TODO: Support opening modal, for example, the search one
|
||||
return <SidebarItem to={to} icon={Icon} text={title} />;
|
||||
return <SidebarItem to={link()} icon={Icon} text={title} />;
|
||||
};
|
||||
|
||||
export const AppNav = createExtension({
|
||||
|
||||
@@ -41,9 +41,7 @@ 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'] });
|
||||
const externalRef2 = createExternalRouteRef({ params: ['x'] });
|
||||
|
||||
function src(sourcePath: string) {
|
||||
return { sourcePath };
|
||||
@@ -62,9 +60,7 @@ describe('RouteResolver', () => {
|
||||
undefined,
|
||||
);
|
||||
expect(r.resolve(externalRef1, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef2, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, src('/'))?.({ x: '5x' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef4, src('/'))?.({ x: '6x' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef2, src('/'))?.({ x: '5x' })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route', () => {
|
||||
@@ -87,9 +83,7 @@ describe('RouteResolver', () => {
|
||||
undefined,
|
||||
);
|
||||
expect(r.resolve(externalRef1, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef2, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, src('/'))?.({ x: '5x' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef4, src('/'))?.({ x: '6x' })).toBe(undefined);
|
||||
expect(r.resolve(externalRef2, src('/'))?.({ x: '5x' })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route with a param and with a parent', () => {
|
||||
@@ -112,8 +106,7 @@ describe('RouteResolver', () => {
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
|
||||
[externalRef1, ref1],
|
||||
[externalRef3, ref2],
|
||||
[externalRef4, subRef3],
|
||||
[externalRef2, subRef3],
|
||||
]),
|
||||
'',
|
||||
);
|
||||
@@ -133,11 +126,7 @@ describe('RouteResolver', () => {
|
||||
'/my-route/my-parent/4x/bar/4a',
|
||||
);
|
||||
expect(r.resolve(externalRef1, src('/'))?.()).toBe('/my-route');
|
||||
expect(r.resolve(externalRef2, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, src('/'))?.({ x: '5x' })).toBe(
|
||||
'/my-route/my-parent/5x',
|
||||
);
|
||||
expect(r.resolve(externalRef4, src('/'))?.({ x: '6x' })).toBe(
|
||||
expect(r.resolve(externalRef2, src('/'))?.({ x: '6x' })).toBe(
|
||||
'/my-route/my-parent/6x/bar',
|
||||
);
|
||||
});
|
||||
@@ -230,8 +219,7 @@ describe('RouteResolver', () => {
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
|
||||
[externalRef1, ref1],
|
||||
[externalRef3, ref2],
|
||||
[externalRef4, subRef3],
|
||||
[externalRef2, subRef3],
|
||||
]),
|
||||
'',
|
||||
);
|
||||
@@ -282,17 +270,10 @@ describe('RouteResolver', () => {
|
||||
expect(() => r.resolve(externalRef1, src('/'))?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(externalRef2, src(l))?.()).toBe(undefined);
|
||||
expect(r.resolve(externalRef3, src(l))?.({ x: '5x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/5x',
|
||||
expect(r.resolve(externalRef2, src(l))?.({ x: '5x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/5x/bar',
|
||||
);
|
||||
expect(() => r.resolve(externalRef3, src('/'))?.({ x: '5x' })).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(externalRef4, src(l))?.({ x: '6x' })).toBe(
|
||||
'/my-grandparent/my-y/my-parent/6x/bar',
|
||||
);
|
||||
expect(() => r.resolve(externalRef4, src('/'))?.({ x: '6x' })).toThrow(
|
||||
expect(() => r.resolve(externalRef2, src('/'))?.({ x: '5x' })).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ export class RouteResolver implements RouteResolutionApi {
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: RouteResolutionApiResolveOptions,
|
||||
): RouteFunc<TParams> | undefined {
|
||||
// First figure out what our target absolute ref is, as well as our target path.
|
||||
|
||||
@@ -53,8 +53,7 @@ type TargetRouteMap<
|
||||
ExternalRoutes extends { [name: string]: ExternalRouteRef },
|
||||
> = {
|
||||
[name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef<
|
||||
infer Params,
|
||||
any
|
||||
infer Params
|
||||
>
|
||||
? RouteRef<Params> | SubRouteRef<Params>
|
||||
: never;
|
||||
@@ -72,7 +71,7 @@ export type CreateAppRouteBinder = <
|
||||
externalRoutes: TExternalRoutes,
|
||||
targetRoutes: PartialKeys<
|
||||
TargetRouteMap<TExternalRoutes>,
|
||||
KeysWithType<TExternalRoutes, ExternalRouteRef<any, true>>
|
||||
KeysWithType<TExternalRoutes, ExternalRouteRef<any>>
|
||||
>,
|
||||
) => void;
|
||||
|
||||
@@ -95,11 +94,6 @@ export function resolveRouteBindings(
|
||||
if (!externalRoute) {
|
||||
throw new Error(`Key ${key} is not an existing external route`);
|
||||
}
|
||||
if (!value && !externalRoute.optional) {
|
||||
throw new Error(
|
||||
`External route ${key} is required but was undefined`,
|
||||
);
|
||||
}
|
||||
if (value) {
|
||||
result.set(externalRoute, value);
|
||||
}
|
||||
|
||||
@@ -799,13 +799,11 @@ export function createExternalRouteRef<
|
||||
[param in TParamKeys]: string;
|
||||
}
|
||||
| undefined = undefined,
|
||||
TOptional extends boolean = false,
|
||||
TParamKeys extends string = string,
|
||||
>(options?: {
|
||||
readonly params?: string extends TParamKeys
|
||||
? (keyof TParams)[]
|
||||
: TParamKeys[];
|
||||
optional?: TOptional;
|
||||
defaultTarget?: string;
|
||||
}): ExternalRouteRef<
|
||||
keyof TParams extends never
|
||||
@@ -814,8 +812,7 @@ export function createExternalRouteRef<
|
||||
? TParams
|
||||
: {
|
||||
[param in TParamKeys]: string;
|
||||
},
|
||||
TOptional
|
||||
}
|
||||
>;
|
||||
|
||||
// @public
|
||||
@@ -1300,13 +1297,10 @@ export interface ExtensionOverridesOptions {
|
||||
// @public
|
||||
export interface ExternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
TOptional extends boolean = boolean,
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/ExternalRouteRef';
|
||||
// (undocumented)
|
||||
readonly optional: TOptional;
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
@@ -1570,7 +1564,7 @@ export interface RouteResolutionApi {
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: RouteResolutionApiResolveOptions,
|
||||
): RouteFunc<TParams> | undefined;
|
||||
}
|
||||
@@ -1631,18 +1625,13 @@ export function useComponentRef<T extends {}>(
|
||||
ref: ComponentRef<T>,
|
||||
): ComponentType<T>;
|
||||
|
||||
// @public
|
||||
export function useRouteRef<
|
||||
TOptional extends boolean,
|
||||
TParams extends AnyRouteRefParams,
|
||||
>(
|
||||
routeRef: ExternalRouteRef<TParams, TOptional>,
|
||||
): TOptional extends true ? RouteFunc<TParams> | undefined : RouteFunc<TParams>;
|
||||
|
||||
// @public
|
||||
export function useRouteRef<TParams extends AnyRouteRefParams>(
|
||||
routeRef: RouteRef<TParams> | SubRouteRef<TParams>,
|
||||
): RouteFunc<TParams>;
|
||||
routeRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams>,
|
||||
): RouteFunc<TParams> | undefined;
|
||||
|
||||
// @public
|
||||
export function useRouteRefParams<Params extends AnyRouteRefParams>(
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface RouteResolutionApi {
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: RouteResolutionApiResolveOptions,
|
||||
): RouteFunc<TParams> | undefined;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('ExternalRouteRef', () => {
|
||||
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef();
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
expect(internal.optional).toBe(false);
|
||||
|
||||
expect(String(internal)).toMatch(
|
||||
/^ExternalRouteRef\{created at '.*ExternalRouteRef\.test\.ts.*'\}$/,
|
||||
@@ -35,16 +34,6 @@ describe('ExternalRouteRef', () => {
|
||||
expect(String(internal)).toBe('ExternalRouteRef{some-id}');
|
||||
});
|
||||
|
||||
it('should be created as optional', () => {
|
||||
const routeRef: ExternalRouteRef<undefined, true> = createExternalRouteRef({
|
||||
params: [],
|
||||
optional: true,
|
||||
});
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
expect(internal.optional).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be created with params', () => {
|
||||
const routeRef: ExternalRouteRef<{
|
||||
x: string;
|
||||
@@ -52,63 +41,39 @@ describe('ExternalRouteRef', () => {
|
||||
}> = createExternalRouteRef({ params: ['x', 'y'] });
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual(['x', 'y']);
|
||||
expect(internal.optional).toEqual(false);
|
||||
});
|
||||
|
||||
it('should be created as optional with params', () => {
|
||||
const routeRef: ExternalRouteRef<{
|
||||
x: string;
|
||||
y: string;
|
||||
}> = createExternalRouteRef({ params: ['x', 'y'], optional: true });
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual(['x', 'y']);
|
||||
expect(internal.optional).toEqual(true);
|
||||
});
|
||||
|
||||
it('should properly infer and validate parameter types and assignments', () => {
|
||||
function checkRouteRef<
|
||||
T extends AnyRouteRefParams,
|
||||
TOptional extends boolean,
|
||||
TCheck extends TOptional,
|
||||
>(
|
||||
_ref: ExternalRouteRef<T, TOptional>,
|
||||
function checkRouteRef<T extends AnyRouteRefParams>(
|
||||
_ref: ExternalRouteRef<T>,
|
||||
_params: T extends undefined ? undefined : T,
|
||||
_optional: TCheck,
|
||||
) {}
|
||||
|
||||
const _1 = createExternalRouteRef({ params: ['notX'] });
|
||||
checkRouteRef(_1, { notX: '' }, false);
|
||||
const _1 = createExternalRouteRef();
|
||||
checkRouteRef(_1, undefined);
|
||||
// @ts-expect-error
|
||||
checkRouteRef(_1, { x: '' }, false);
|
||||
checkRouteRef(_1, { x: '' });
|
||||
|
||||
const _2 = createExternalRouteRef({ params: ['x'], optional: true });
|
||||
checkRouteRef(_2, { x: '' }, true);
|
||||
const _2 = createExternalRouteRef({ params: ['x'] });
|
||||
checkRouteRef(_2, { x: '' });
|
||||
// @ts-expect-error
|
||||
checkRouteRef(_2, undefined, false);
|
||||
checkRouteRef(_2, { notX: '' });
|
||||
// @ts-expect-error
|
||||
checkRouteRef(_2, undefined);
|
||||
|
||||
const _3 = createExternalRouteRef({ params: ['x', 'y'] });
|
||||
checkRouteRef(_3, { x: '', y: '' }, false);
|
||||
checkRouteRef(_3, { x: '', y: '' });
|
||||
// @ts-expect-error
|
||||
checkRouteRef(_3, { x: '' }, false);
|
||||
checkRouteRef(_3, { x: '' });
|
||||
// @ts-expect-error
|
||||
checkRouteRef(_3, { x: '', y: '', z: '' }, false);
|
||||
checkRouteRef(_3, { x: '', y: '', z: '' });
|
||||
|
||||
const _4 = createExternalRouteRef({ params: [] });
|
||||
checkRouteRef(_4, undefined, false);
|
||||
checkRouteRef(_4, undefined);
|
||||
// @ts-expect-error
|
||||
checkRouteRef<any>(_4, { x: '' });
|
||||
|
||||
const _5 = createExternalRouteRef();
|
||||
checkRouteRef(_5, undefined, false);
|
||||
// @ts-expect-error
|
||||
checkRouteRef<any>(_5, { x: '' });
|
||||
|
||||
const _6 = createExternalRouteRef({ optional: true });
|
||||
checkRouteRef(_6, undefined, true);
|
||||
// @ts-expect-error
|
||||
checkRouteRef(_6, undefined, false);
|
||||
checkRouteRef(_4, { x: '' });
|
||||
|
||||
// To avoid complains about missing expectations and unused vars
|
||||
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
|
||||
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,18 +29,15 @@ import { AnyRouteRefParams } from './types';
|
||||
*/
|
||||
export interface ExternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
TOptional extends boolean = boolean,
|
||||
> {
|
||||
readonly $$type: '@backstage/ExternalRouteRef';
|
||||
readonly T: TParams;
|
||||
readonly optional: TOptional;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalExternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
TOptional extends boolean = boolean,
|
||||
> extends ExternalRouteRef<TParams, TOptional> {
|
||||
> extends ExternalRouteRef<TParams> {
|
||||
readonly version: 'v1';
|
||||
getParams(): string[];
|
||||
getDescription(): string;
|
||||
@@ -52,11 +49,8 @@ export interface InternalExternalRouteRef<
|
||||
/** @internal */
|
||||
export function toInternalExternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
TOptional extends boolean = boolean,
|
||||
>(
|
||||
resource: ExternalRouteRef<TParams, TOptional>,
|
||||
): InternalExternalRouteRef<TParams, TOptional> {
|
||||
const r = resource as InternalExternalRouteRef<TParams, TOptional>;
|
||||
>(resource: ExternalRouteRef<TParams>): InternalExternalRouteRef<TParams> {
|
||||
const r = resource as InternalExternalRouteRef<TParams>;
|
||||
if (r.$$type !== '@backstage/ExternalRouteRef') {
|
||||
throw new Error(`Invalid ExternalRouteRef, bad type '${r.$$type}'`);
|
||||
}
|
||||
@@ -79,7 +73,6 @@ class ExternalRouteRefImpl
|
||||
readonly $$type = '@backstage/ExternalRouteRef' as any;
|
||||
|
||||
constructor(
|
||||
readonly optional: boolean,
|
||||
readonly params: string[] = [],
|
||||
readonly defaultTarget: string | undefined,
|
||||
creationSite: string,
|
||||
@@ -104,7 +97,6 @@ class ExternalRouteRefImpl
|
||||
*/
|
||||
export function createExternalRouteRef<
|
||||
TParams extends { [param in TParamKeys]: string } | undefined = undefined,
|
||||
TOptional extends boolean = false,
|
||||
TParamKeys extends string = string,
|
||||
>(options?: {
|
||||
/**
|
||||
@@ -114,14 +106,6 @@ export function createExternalRouteRef<
|
||||
? (keyof TParams)[]
|
||||
: TParamKeys[];
|
||||
|
||||
/**
|
||||
* 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, `useExternalRouteRef` will return `undefined`.
|
||||
*/
|
||||
optional?: TOptional;
|
||||
|
||||
/**
|
||||
* The route (typically in another plugin) that this should map to by default.
|
||||
*
|
||||
@@ -134,13 +118,11 @@ export function createExternalRouteRef<
|
||||
? undefined
|
||||
: string extends TParamKeys
|
||||
? TParams
|
||||
: { [param in TParamKeys]: string },
|
||||
TOptional
|
||||
: { [param in TParamKeys]: string }
|
||||
> {
|
||||
return new ExternalRouteRefImpl(
|
||||
Boolean(options?.optional),
|
||||
options?.params as string[] | undefined,
|
||||
options?.defaultTarget,
|
||||
describeParentCallSite(),
|
||||
) as ExternalRouteRef<any, any>;
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('v1 consumer', () => {
|
||||
});
|
||||
|
||||
const routeFunc = renderedHook.result.current;
|
||||
expect(routeFunc()).toBe('/hello');
|
||||
expect(routeFunc?.()).toBe('/hello');
|
||||
expect(resolve).toHaveBeenCalledWith(
|
||||
routeRef,
|
||||
expect.objectContaining({
|
||||
@@ -54,6 +54,23 @@ describe('v1 consumer', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore missing routes', () => {
|
||||
const routeRef = createRouteRef();
|
||||
|
||||
const renderedHook = renderHook(() => useRouteRef(routeRef), {
|
||||
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[routeResolutionApiRef, { resolve: () => undefined }]]}
|
||||
>
|
||||
<MemoryRouter initialEntries={['/my-page']} children={children} />
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const routeFunc = renderedHook.result.current;
|
||||
expect(routeFunc).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-resolves the routeFunc when the search parameters change', () => {
|
||||
const resolve = jest.fn(() => () => '/hello');
|
||||
|
||||
|
||||
@@ -30,47 +30,14 @@ import { RouteFunc, routeResolutionApiRef, useApi } from '../apis';
|
||||
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
|
||||
*
|
||||
* @param routeRef - The ref to route that should be converted to URL.
|
||||
* @returns A function that will in turn return the concrete URL of the `routeRef`.
|
||||
* @public
|
||||
*/
|
||||
export function useRouteRef<
|
||||
TOptional extends boolean,
|
||||
TParams extends AnyRouteRefParams,
|
||||
>(
|
||||
routeRef: ExternalRouteRef<TParams, TOptional>,
|
||||
): TOptional extends true ? RouteFunc<TParams> | undefined : RouteFunc<TParams>;
|
||||
|
||||
/**
|
||||
* React hook for constructing URLs to routes.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
|
||||
*
|
||||
* @param routeRef - The ref to route that should be converted to URL.
|
||||
* @returns A function that will in turn return the concrete URL of the `routeRef`.
|
||||
* @public
|
||||
*/
|
||||
export function useRouteRef<TParams extends AnyRouteRefParams>(
|
||||
routeRef: RouteRef<TParams> | SubRouteRef<TParams>,
|
||||
): RouteFunc<TParams>;
|
||||
|
||||
/**
|
||||
* React hook for constructing URLs to routes.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
|
||||
*
|
||||
* @param routeRef - The ref to route that should be converted to URL.
|
||||
* @returns A function that will in turn return the concrete URL of the `routeRef`.
|
||||
* @returns A function that will in turn return the concrete URL of the `routeRef`, or `undefined` if the route is not available.
|
||||
* @public
|
||||
*/
|
||||
export function useRouteRef<TParams extends AnyRouteRefParams>(
|
||||
routeRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
| ExternalRouteRef<TParams>,
|
||||
): RouteFunc<TParams> | undefined {
|
||||
const { pathname } = useLocation();
|
||||
const routeResolutionApi = useApi(routeResolutionApiRef);
|
||||
@@ -80,10 +47,5 @@ export function useRouteRef<TParams extends AnyRouteRefParams>(
|
||||
[routeResolutionApi, routeRef, pathname],
|
||||
);
|
||||
|
||||
const isOptional = 'optional' in routeRef && routeRef.optional;
|
||||
if (!routeFunc && !isOptional) {
|
||||
throw new Error(`No path for ${routeRef}`);
|
||||
}
|
||||
|
||||
return routeFunc;
|
||||
}
|
||||
|
||||
@@ -56,10 +56,13 @@ const NavItem = (props: {
|
||||
icon: IconComponent;
|
||||
}) => {
|
||||
const { routeRef, title, icon: Icon } = props;
|
||||
const to = useRouteRef(routeRef)();
|
||||
const link = useRouteRef(routeRef);
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<li>
|
||||
<Link to={to}>
|
||||
<Link to={link()}>
|
||||
<Icon /> {title}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -13,7 +13,7 @@ const _default: BackstagePlugin<
|
||||
root: RouteRef<undefined>;
|
||||
},
|
||||
{
|
||||
registerApi: ExternalRouteRef<undefined, true>;
|
||||
registerApi: ExternalRouteRef<undefined>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
@@ -189,18 +189,12 @@ function OutputLink(props: {
|
||||
}) {
|
||||
const routeRef = props.node?.instance?.getData(coreExtensionData.routeRef);
|
||||
|
||||
let link: string | undefined = undefined;
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
link = useRouteRef(routeRef as RouteRef<undefined>)();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const link = useRouteRef(routeRef as RouteRef<undefined>);
|
||||
|
||||
return (
|
||||
<Tooltip title={<Typography>{props.dataRef.id}</Typography>}>
|
||||
<Box className={props.className}>
|
||||
{link ? <Link to={link}>link</Link> : null}
|
||||
{link ? <Link to={link()}>link</Link> : null}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -13,14 +13,11 @@ const _default: BackstagePlugin<
|
||||
catalogGraph: RouteRef<undefined>;
|
||||
},
|
||||
{
|
||||
catalogEntity: ExternalRouteRef<
|
||||
{
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
},
|
||||
true
|
||||
>;
|
||||
catalogEntity: ExternalRouteRef<{
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
@@ -121,23 +121,17 @@ const _default: BackstagePlugin<
|
||||
}>;
|
||||
},
|
||||
{
|
||||
viewTechDoc: ExternalRouteRef<
|
||||
{
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
},
|
||||
true
|
||||
>;
|
||||
createComponent: ExternalRouteRef<undefined, true>;
|
||||
createFromTemplate: ExternalRouteRef<
|
||||
{
|
||||
namespace: string;
|
||||
templateName: string;
|
||||
},
|
||||
true
|
||||
>;
|
||||
unregisterRedirect: ExternalRouteRef<undefined, true>;
|
||||
viewTechDoc: ExternalRouteRef<{
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
}>;
|
||||
createComponent: ExternalRouteRef<undefined>;
|
||||
createFromTemplate: ExternalRouteRef<{
|
||||
namespace: string;
|
||||
templateName: string;
|
||||
}>;
|
||||
unregisterRedirect: ExternalRouteRef<undefined>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
const _default: BackstagePlugin<
|
||||
{},
|
||||
{
|
||||
catalogIndex: ExternalRouteRef<undefined, true>;
|
||||
catalogIndex: ExternalRouteRef<undefined>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
@@ -30,15 +30,12 @@ const _default: BackstagePlugin<
|
||||
edit: SubRouteRef<undefined>;
|
||||
},
|
||||
{
|
||||
registerComponent: ExternalRouteRef<undefined, true>;
|
||||
viewTechDoc: ExternalRouteRef<
|
||||
{
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
},
|
||||
true
|
||||
>;
|
||||
registerComponent: ExternalRouteRef<undefined>;
|
||||
viewTechDoc: ExternalRouteRef<{
|
||||
name: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
Reference in New Issue
Block a user