Merge pull request #4700 from backstage/rugvip/opternal
core: implement optional external routes and use to toggle "Create Component" button
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Make the external `createComponent` route optional, hiding the "Create Component" button if it isn't bound.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-api': patch
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Added support for optional external route references. By setting `optional: true` when creating an `ExternalRouteRef` it is no longer a requirement to bind the route in the app. If the app isn't bound `useRouteRef` will return `undefined`.
|
||||
@@ -305,6 +305,37 @@ in a different file than the one that creates the plugin instance, for example a
|
||||
top-level `routes.ts`. This is to avoid circular imports when you use the route
|
||||
references from other parts of the same plugin.
|
||||
|
||||
### Optional External Routes
|
||||
|
||||
When creating an `ExternalRouteRef` it is possible to mark it as optional:
|
||||
|
||||
```ts
|
||||
const headerLinkRouteRef = createExternalRouteRef({
|
||||
id: 'header-link',
|
||||
optional: true,
|
||||
});
|
||||
```
|
||||
|
||||
An external route that is marked as optional is not required to be bound in the
|
||||
app, allowing it to be used as a switch for whether a particular link should be
|
||||
displayed or action should be taken.
|
||||
|
||||
When calling `useRouteRef` with an optional external route, its return signature
|
||||
is changed to `RouteFunc | undefined`, allowing for logic like this:
|
||||
|
||||
```tsx
|
||||
const MyComponent = () => {
|
||||
const headerLink = useRouteRef(headerLinkRouteRef);
|
||||
|
||||
return (
|
||||
<header>
|
||||
My Header
|
||||
{headerLink && <a href={headerLink()}>External Link</a>}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Parameterized Routes
|
||||
|
||||
A new addition to `RouteRef`s is the possibility of adding named and typed
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 route 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 = {
|
||||
|
||||
@@ -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 they 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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,14 +167,16 @@ const CatalogPageContents = () => {
|
||||
/>
|
||||
<Content>
|
||||
<ContentHeader title={selectedTab ?? ''}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
to={createComponentLink()}
|
||||
>
|
||||
Create Component
|
||||
</Button>
|
||||
{createComponentLink && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
to={createComponentLink()}
|
||||
>
|
||||
Create Component
|
||||
</Button>
|
||||
)}
|
||||
{showAddExampleEntities && (
|
||||
<Button
|
||||
className={styles.buttonSpacing}
|
||||
|
||||
@@ -18,4 +18,5 @@ import { createExternalRouteRef } from '@backstage/core';
|
||||
|
||||
export const createComponentRouteRef = createExternalRouteRef({
|
||||
id: 'create-component',
|
||||
optional: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user