diff --git a/.changeset/empty-windows-nail.md b/.changeset/empty-windows-nail.md
new file mode 100644
index 0000000000..0b2eeb08cc
--- /dev/null
+++ b/.changeset/empty-windows-nail.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Make the external `createComponent` route optional, hiding the "Create Component" button if it isn't bound.
diff --git a/.changeset/popular-sheep-act.md b/.changeset/popular-sheep-act.md
new file mode 100644
index 0000000000..885d9f0e6b
--- /dev/null
+++ b/.changeset/popular-sheep-act.md
@@ -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`.
diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md
index 6106fa9141..75993dbc46 100644
--- a/docs/plugins/composability.md
+++ b/docs/plugins/composability.md
@@ -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 (
+
+ );
+};
+```
+
### Parameterized Routes
A new addition to `RouteRef`s is the possibility of adding named and typed
diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx
index 6a1e80334b..5ceb123b06 100644
--- a/packages/core-api/src/app/App.test.tsx
+++ b/packages/core-api/src/app/App.test.tsx
@@ -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
Our Route Is: {routeRefFunction()}
;
+ const externalLink = useRouteRef(externalRouteRef);
+ const barLink = useRouteRef(optionalBarExternalRouteRef);
+ const bazLink = useRouteRef(optionalBazExternalRouteRef);
+ return (
+
+ Our routes are: {externalLink()}, bar: {barLink?.() ?? 'none'},
+ baz: {bazLink?.() ?? 'none'}
+
+ );
}),
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', () => {
,
);
- 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', () => {
diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx
index 1cf2d892d0..9ab19b5588 100644
--- a/packages/core-api/src/app/App.tsx
+++ b/packages/core-api/src/app/App.tsx
@@ -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 });
diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts
index ec7b70689d..701862601d 100644
--- a/packages/core-api/src/app/types.ts
+++ b/packages/core-api/src/app/types.ts
@@ -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;
-export type AppRouteBinder = (
- externalRoutes: T,
- targetRoutes: { [key in keyof T]: RouteRef },
+/**
+ * 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 }
+> = {
+ [name in keyof T]: T[name] extends ExternalRouteRef ? U : never;
+};
+
+/**
+ * Extracts a union of the keys in a map whose value extends the given type
+ */
+type ExtractKeysWithType = {
+ [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, bar?: RouteRef }
+ */
+type CombineOptionalAndRequiredRoutes<
+ OptionalMap extends { [key in string]: boolean }
+> = {
+ [name in ExtractKeysWithType]: RouteRef;
+} &
+ { [name in keyof OptionalMap]?: RouteRef };
+
+/**
+ * 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 }
+> = CombineOptionalAndRequiredRoutes>;
+
+export type AppRouteBinder = <
+ ExternalRoutes extends { [name in string]: ExternalRouteRef }
+>(
+ externalRoutes: ExternalRoutes,
+ targetRoutes: TargetRoutesMap,
) => void;
export type AppOptions = {
diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts
index 5ae85ab553..67ff9bc1fb 100644
--- a/packages/core-api/src/routing/RouteRef.ts
+++ b/packages/core-api/src/routing/RouteRef.ts
@@ -64,23 +64,36 @@ export function createRouteRef<
return new AbsoluteRouteRef(config);
}
-export class ExternalRouteRef {
- private constructor(id: string) {
+export class ExternalRouteRef {
+ readonly optional: boolean;
+
+ private constructor({ id, optional }: ExternalRouteRefOptions) {
this.toString = () => `externalRouteRef{${id}}`;
+ this.optional = Boolean(optional);
}
}
-export type ExternalRouteRefOptions = {
+export type ExternalRouteRefOptions = {
/**
* 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(
+ options: ExternalRouteRefOptions,
+): ExternalRouteRef {
return new ((ExternalRouteRef as unknown) as {
- new (id: string): ExternalRouteRef;
- })(options.id);
+ new (options: ExternalRouteRefOptions): ExternalRouteRef<
+ Optional
+ >;
+ })(options);
}
diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx
index 462b44c3bd..94cd3a704d 100644
--- a/packages/core-api/src/routing/hooks.test.tsx
+++ b/packages/core-api/src/routing/hooks.test.tsx
@@ -41,7 +41,7 @@ import {
ExternalRouteRef,
RouteRefConfig,
} from './RouteRef';
-import { RouteRef } from './types';
+import { AnyRouteRef, RouteRef } from './types';
const mockConfig = (extra?: Partial>) => ({
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 = (props: {
path?: string;
name: string;
- routeRef: RouteRef | ExternalRouteRef;
+ routeRef: AnyRouteRef;
params?: T;
}) => {
try {
- const routeFunc = useRouteRef(props.routeRef) as RouteFunc;
+ const routeFunc = useRouteRef(props.routeRef as any) as
+ | RouteFunc
+ | undefined;
return (
- Path at {props.name}: {routeFunc(props.params)}
+ Path at {props.name}: {routeFunc?.(props.params) ?? ''}
);
} catch (ex) {
@@ -156,6 +160,8 @@ describe('discovery', () => {
+
+
);
@@ -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: '),
+ ).toBeInTheDocument();
});
it('should handle routeRefs with parameters', async () => {
diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx
index c478027ae2..54490fb4f2 100644
--- a/packages/core-api/src/routing/hooks.tsx
+++ b/packages/core-api/src/routing/hooks.tsx
@@ -35,22 +35,23 @@ class RouteResolver {
private readonly routePaths: Map,
private readonly routeParents: Map,
private readonly routeObjects: BackstageRouteObject[],
- private readonly routeBindings: Map,
+ private readonly routeBindings: Map,
) {}
resolve(
routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef,
sourceLocation: ReturnType,
- ): RouteFunc {
+ ): RouteFunc | undefined {
const routeRef =
this.routeBindings.get(routeRefOrExternalRouteRef) ??
(routeRefOrExternalRouteRef as RouteRef);
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();
let matchIndex = -1;
@@ -111,9 +112,15 @@ class RouteResolver {
const RoutingContext = createContext(undefined);
+export function useRouteRef(
+ routeRef: ExternalRouteRef,
+): Optional extends true ? RouteFunc<{}> | undefined : RouteFunc<{}>;
+export function useRouteRef(
+ routeRef: RouteRef,
+): RouteFunc;
export function useRouteRef(
routeRef: RouteRef | ExternalRouteRef,
-): RouteFunc {
+): RouteFunc | undefined {
const sourceLocation = useLocation();
const resolver = useContext(RoutingContext);
const routeFunc = useMemo(
@@ -121,10 +128,15 @@ export function useRouteRef(
[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;
}
diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts
index 5f9376c1d2..b21fa38052 100644
--- a/packages/core-api/src/routing/types.ts
+++ b/packages/core-api/src/routing/types.ts
@@ -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 = {
@@ -25,7 +26,7 @@ export type RouteRef = {
title: string;
};
-export type AnyRouteRef = RouteRef;
+export type AnyRouteRef = RouteRef | ExternalRouteRef;
/**
* This type should not be used
diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
index 327bc2e472..9190ae703e 100644
--- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
+++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
@@ -167,14 +167,16 @@ const CatalogPageContents = () => {
/>
-
- Create Component
-
+ {createComponentLink && (
+
+ Create Component
+
+ )}
{showAddExampleEntities && (