diff --git a/.changeset/famous-houses-thank.md b/.changeset/famous-houses-thank.md
new file mode 100644
index 0000000000..8fa0eb2d75
--- /dev/null
+++ b/.changeset/famous-houses-thank.md
@@ -0,0 +1,7 @@
+---
+'@backstage/frontend-test-utils': patch
+'@backstage/frontend-app-api': patch
+'@backstage/core-compat-api': patch
+---
+
+Updates to use the new `RouteResolutionsApi`.
diff --git a/.changeset/rare-seals-thank.md b/.changeset/rare-seals-thank.md
new file mode 100644
index 0000000000..345c1c91b4
--- /dev/null
+++ b/.changeset/rare-seals-thank.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Added `RouteResolutionsApi` as a replacement for the routing context.
diff --git a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx
index 8c6ef1f375..288f3f900b 100644
--- a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx
+++ b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx
@@ -18,6 +18,8 @@ import React, { useMemo } from 'react';
import { ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppContextProvider } from '../../../core-app-api/src/app/AppContext';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { RouteResolver } from '../../../core-plugin-api/src/routing/useRouteRef';
import {
createPlugin as createNewPlugin,
BackstagePlugin as NewBackstagePlugin,
@@ -26,13 +28,21 @@ import {
coreComponentRefs,
iconsApiRef,
useApi,
+ routeResolutionApiRef,
} from '@backstage/frontend-plugin-api';
import {
AppComponents,
IconComponent,
BackstagePlugin as LegacyBackstagePlugin,
+ RouteRef,
} from '@backstage/core-plugin-api';
-import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
+import {
+ VersionedValue,
+ createVersionedContext,
+ createVersionedValueMap,
+ getOrCreateGlobalSingleton,
+} from '@backstage/version-bridge';
+import { convertLegacyRouteRef } from '../convertLegacyRouteRef';
// Make sure that we only convert each new plugin instance to its legacy equivalent once
const legacyPluginStore = getOrCreateGlobalSingleton(
@@ -156,6 +166,42 @@ function LegacyAppContextProvider(props: { children: ReactNode }) {
);
}
-export function BackwardsCompatProvider(props: { children: ReactNode }) {
- return {props.children};
+const RoutingContext = createVersionedContext<{ 1: RouteResolver }>(
+ 'routing-context',
+);
+
+function LegacyRoutingProvider(props: { children: ReactNode }) {
+ const routeResolutionApi = useApi(routeResolutionApiRef);
+
+ const value = useMemo>(() => {
+ return createVersionedValueMap({
+ 1: {
+ resolve(anyRouteRef, location) {
+ const sourcePath =
+ typeof location === 'string' ? location : location.pathname ?? '';
+
+ return routeResolutionApi.resolve(
+ // This removes the requirement to use convertLegacyRouteRef inside plugins, but
+ // they still need to converted when passed to the plugin instance
+ convertLegacyRouteRef(anyRouteRef as RouteRef),
+ { sourcePath },
+ );
+ },
+ },
+ });
+ }, [routeResolutionApi]);
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function BackwardsCompatProvider(props: { children: ReactNode }) {
+ return (
+
+ {props.children}
+
+ );
}
diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx
index 2e7dbd9d37..74b9f209eb 100644
--- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx
+++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx
@@ -19,10 +19,18 @@ import {
coreExtensionData,
createExtension,
} from '@backstage/frontend-plugin-api';
-import { createExtensionTester } from '@backstage/frontend-test-utils';
+import {
+ createExtensionTester,
+ renderInTestApp,
+} from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import { compatWrapper } from './compatWrapper';
-import { useApp } from '@backstage/core-plugin-api';
+import {
+ createRouteRef,
+ useApp,
+ useRouteRef,
+} from '@backstage/core-plugin-api';
+import { convertLegacyRouteRef } from '../convertLegacyRouteRef';
describe('BackwardsCompatProvider', () => {
it('should convert the app context', () => {
@@ -64,4 +72,19 @@ describe('BackwardsCompatProvider', () => {
icons: brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, user, warning"
`);
});
+
+ it('should convert the routing context', () => {
+ const routeRef = createRouteRef({ id: 'test' });
+
+ function Component() {
+ const link = useRouteRef(routeRef);
+ return link: {link()}
;
+ }
+
+ renderInTestApp(compatWrapper(), {
+ mountedRoutes: { '/test': convertLegacyRouteRef(routeRef) },
+ });
+
+ expect(screen.getByText('link: /test')).toBeInTheDocument();
+ });
});
diff --git a/packages/frontend-app-api/src/extensions/AppRoot.tsx b/packages/frontend-app-api/src/extensions/AppRoot.tsx
index 2b709f4e42..f3a962dd10 100644
--- a/packages/frontend-app-api/src/extensions/AppRoot.tsx
+++ b/packages/frontend-app-api/src/extensions/AppRoot.tsx
@@ -31,7 +31,6 @@ import {
createSignInPageExtension,
} from '@backstage/frontend-plugin-api';
import {
- ConfigApi,
IdentityApi,
SignInPageProps,
configApiRef,
@@ -42,6 +41,7 @@ import { InternalAppContext } from '../wiring/InternalAppContext';
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BrowserRouter } from 'react-router-dom';
import { RouteTracker } from '../routing/RouteTracker';
+import { getBasePath } from '../routing/getBasePath';
export const AppRoot = createExtension({
namespace: 'app',
@@ -97,20 +97,6 @@ export const AppRoot = createExtension({
},
});
-/**
- * Read the configured base path.
- *
- * The returned path does not have a trailing slash.
- */
-function getBasePath(configApi: ConfigApi) {
- let { pathname } = new URL(
- configApi.getOptionalString('app.baseUrl') ?? '/',
- 'http://sample.dev', // baseUrl can be specified as just a path
- );
- pathname = pathname.replace(/\/*$/, '');
- return pathname;
-}
-
// This wraps the sign-in page and waits for sign-in to be completed before rendering the app
function SignInPageWrapper({
component: Component,
diff --git a/packages/frontend-app-api/src/routing/RouteResolver.test.ts b/packages/frontend-app-api/src/routing/RouteResolver.test.ts
index 81e9d10be4..251241a7f9 100644
--- a/packages/frontend-app-api/src/routing/RouteResolver.test.ts
+++ b/packages/frontend-app-api/src/routing/RouteResolver.test.ts
@@ -45,20 +45,26 @@ const externalRef2 = createExternalRouteRef({ optional: true });
const externalRef3 = createExternalRouteRef({ params: ['x'] });
const externalRef4 = createExternalRouteRef({ optional: true, params: ['x'] });
+function src(sourcePath: string) {
+ return { sourcePath };
+}
+
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);
+ expect(r.resolve(ref1, src('/'))?.()).toBe(undefined);
+ expect(r.resolve(ref2, src('/'))?.({ x: '1x' })).toBe(undefined);
+ expect(r.resolve(subRef1, src('/'))?.()).toBe(undefined);
+ expect(r.resolve(subRef2, src('/'))?.({ a: '2a' })).toBe(undefined);
+ expect(r.resolve(subRef3, src('/'))?.({ x: '3x' })).toBe(undefined);
+ expect(r.resolve(subRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
+ 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);
});
it('should resolve an absolute route', () => {
@@ -70,16 +76,20 @@ describe('RouteResolver', () => {
'',
);
- 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);
+ expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
+ expect(r.resolve(ref2, src('/'))?.({ x: '1x' })).toBe(undefined);
+ expect(r.resolve(subRef1, src('/'))?.()).toBe('/my-route/foo');
+ expect(r.resolve(subRef2, src('/'))?.({ a: '2a' })).toBe(
+ '/my-route/foo/2a',
+ );
+ expect(r.resolve(subRef3, src('/'))?.({ x: '3x' })).toBe(undefined);
+ expect(r.resolve(subRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
+ 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);
});
it('should resolve an absolute route and sub route with an app base path', () => {
@@ -104,37 +114,39 @@ describe('RouteResolver', () => {
'/base',
);
- expect(r.resolve(ref1, '/my-parent/1x')?.()).toBe(
+ expect(r.resolve(ref1, src('/my-parent/1x'))?.()).toBe(
'/base/my-parent/1x/my-route',
);
- expect(r.resolve(ref1, '/base/my-parent/1x')?.()).toBe(
+ expect(r.resolve(ref1, src('/base/my-parent/1x'))?.()).toBe(
'/base/my-parent/1x/my-route',
);
- expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/base/my-parent/1x');
- expect(r.resolve(ref2, '/base')?.({ x: '1x' })).toBe('/base/my-parent/1x');
- expect(r.resolve(ref3, '/')?.({ y: '1y' })).toBe(undefined);
- expect(r.resolve(subRef1, '/my-parent/2x')?.()).toBe(
+ expect(r.resolve(ref2, src('/'))?.({ x: '1x' })).toBe('/base/my-parent/1x');
+ expect(r.resolve(ref2, src('/base'))?.({ x: '1x' })).toBe(
+ '/base/my-parent/1x',
+ );
+ expect(r.resolve(ref3, src('/'))?.({ y: '1y' })).toBe(undefined);
+ expect(r.resolve(subRef1, src('/my-parent/2x'))?.()).toBe(
'/base/my-parent/2x/my-route/foo',
);
- expect(r.resolve(subRef1, '/base/my-parent/2x')?.()).toBe(
+ expect(r.resolve(subRef1, src('/base/my-parent/2x'))?.()).toBe(
'/base/my-parent/2x/my-route/foo',
);
- expect(r.resolve(subRef2, '/my-parent/3x')?.({ a: '2a' })).toBe(
+ expect(r.resolve(subRef2, src('/my-parent/3x'))?.({ a: '2a' })).toBe(
'/base/my-parent/3x/my-route/foo/2a',
);
- expect(r.resolve(subRef2, '/base/my-parent/3x')?.({ a: '2a' })).toBe(
+ expect(r.resolve(subRef2, src('/base/my-parent/3x'))?.({ a: '2a' })).toBe(
'/base/my-parent/3x/my-route/foo/2a',
);
- expect(r.resolve(subRef3, '/')?.({ x: '5x' })).toBe(
+ expect(r.resolve(subRef3, src('/'))?.({ x: '5x' })).toBe(
'/base/my-parent/5x/bar',
);
- expect(r.resolve(subRef4, '/')?.({ x: '6x', a: '4a' })).toBe(
+ expect(r.resolve(subRef4, src('/'))?.({ x: '6x', a: '4a' })).toBe(
'/base/my-parent/6x/bar/4a',
);
- 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);
+ 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);
});
it('should resolve an absolute route with a param and with a parent', () => {
@@ -163,22 +175,26 @@ describe('RouteResolver', () => {
'',
);
- 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(
+ expect(r.resolve(ref1, src('/'))?.()).toBe('/my-route');
+ expect(r.resolve(ref2, src('/'))?.({ x: '1x' })).toBe(
+ '/my-route/my-parent/1x',
+ );
+ expect(r.resolve(subRef1, src('/'))?.()).toBe('/my-route/foo');
+ expect(r.resolve(subRef2, src('/'))?.({ a: '2a' })).toBe(
+ '/my-route/foo/2a',
+ );
+ expect(r.resolve(subRef3, src('/'))?.({ x: '3x' })).toBe(
'/my-route/my-parent/3x/bar',
);
- expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(
+ expect(r.resolve(subRef4, src('/'))?.({ 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(
+ 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, '/')?.({ x: '6x' })).toBe(
+ expect(r.resolve(externalRef4, src('/'))?.({ x: '6x' })).toBe(
'/my-route/my-parent/6x/bar',
);
});
@@ -221,18 +237,20 @@ describe('RouteResolver', () => {
'',
);
- expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x');
- expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y');
+ expect(r.resolve(ref2, src('/'))?.({ x: 'x' })).toBe('/root/x');
+ expect(r.resolve(ref3, src('/root/x'))?.({ y: 'y' })).toBe('/root/x/sub/y');
- expect(() => r.resolve(ref1, '/')?.()).toThrow(
+ expect(() => r.resolve(ref1, src('/'))?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(() => r.resolve(ref1, '/root/x')?.()).toThrow(
+ expect(() => r.resolve(ref1, src('/root/x'))?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(r.resolve(ref1, '/root/x/sub/y')?.()).toBe('/root/x/sub/y/deep');
+ expect(r.resolve(ref1, src('/root/x/sub/y'))?.()).toBe(
+ '/root/x/sub/y/deep',
+ );
// Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here
- expect(r.resolve(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe(
+ expect(r.resolve(ref1, src('/root/x/sub/y/any/nested/path/here'))?.()).toBe(
'/root/x/sub/y/deep',
);
});
@@ -276,62 +294,62 @@ describe('RouteResolver', () => {
);
const l = '/my-grandparent/my-y/my-parent/my-x';
- expect(r.resolve(ref1, l)?.()).toBe(
+ expect(r.resolve(ref1, src(l))?.()).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route',
);
- expect(() => r.resolve(ref1, '/')?.()).toThrow(
+ expect(() => r.resolve(ref1, src('/'))?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe(
+ expect(r.resolve(ref2, src(l))?.({ x: '1x' })).toBe(
'/my-grandparent/my-y/my-parent/1x',
);
- expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe(
+ expect(r.resolve(ref2, src('/my-grandparent/my-y'))?.({ x: '1x' })).toBe(
'/my-grandparent/my-y/my-parent/1x',
);
- expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow(
+ expect(() => r.resolve(ref2, src('/'))?.({ x: '1x' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(r.resolve(subRef1, l)?.()).toBe(
+ expect(r.resolve(subRef1, src(l))?.()).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route/foo',
);
- expect(() => r.resolve(subRef1, '/')?.()).toThrow(
+ expect(() => r.resolve(subRef1, src('/'))?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe(
+ expect(r.resolve(subRef2, src(l))?.({ a: '2a' })).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a',
);
- expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow(
+ expect(() => r.resolve(subRef2, src('/'))?.({ a: '2a' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe(
+ expect(r.resolve(subRef3, src(l))?.({ x: '3x' })).toBe(
'/my-grandparent/my-y/my-parent/3x/bar',
);
- expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe(
+ expect(r.resolve(subRef3, src('/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(
+ expect(r.resolve(subRef4, src(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' }),
+ r.resolve(subRef4, src('/my-grandparent/my-y'))?.({ x: '4x', a: '4a' }),
).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a');
- expect(r.resolve(externalRef1, l)?.()).toBe(
+ expect(r.resolve(externalRef1, src(l))?.()).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route',
);
- expect(() => r.resolve(externalRef1, '/')?.()).toThrow(
+ expect(() => r.resolve(externalRef1, src('/'))?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(r.resolve(externalRef2, l)?.()).toBe(undefined);
- expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe(
+ 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(externalRef3, '/')?.({ x: '5x' })).toThrow(
+ expect(() => r.resolve(externalRef3, src('/'))?.({ x: '5x' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
- expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe(
+ expect(r.resolve(externalRef4, src(l))?.({ x: '6x' })).toBe(
'/my-grandparent/my-y/my-parent/6x/bar',
);
- expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow(
+ expect(() => r.resolve(externalRef4, src('/'))?.({ x: '6x' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
});
@@ -358,7 +376,7 @@ describe('RouteResolver', () => {
'/base',
);
- expect(r.resolve(ref2, '/')?.({ x: 'a/#&?b' })).toBe(
+ expect(r.resolve(ref2, src('/'))?.({ x: 'a/#&?b' })).toBe(
'/base/my-parent/a%2F%23%26%3Fb',
);
});
diff --git a/packages/frontend-app-api/src/routing/RouteResolver.ts b/packages/frontend-app-api/src/routing/RouteResolver.ts
index 77336988e2..5405bb4d7f 100644
--- a/packages/frontend-app-api/src/routing/RouteResolver.ts
+++ b/packages/frontend-app-api/src/routing/RouteResolver.ts
@@ -21,6 +21,8 @@ import {
SubRouteRef,
AnyRouteRefParams,
RouteFunc,
+ RouteResolutionApiResolveOptions,
+ RouteResolutionApi,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
import { AnyRouteRef, BackstageRouteObject } from './types';
@@ -177,7 +179,7 @@ function resolveBasePath(
return `${joinPaths(parentPath, ...diffPaths)}/`;
}
-export class RouteResolver {
+export class RouteResolver implements RouteResolutionApi {
constructor(
private readonly routePaths: Map,
private readonly routeParents: Map,
@@ -189,13 +191,13 @@ export class RouteResolver {
private readonly appBasePath: string, // base path without a trailing slash
) {}
- resolve(
+ resolve(
anyRouteRef:
- | RouteRef
- | SubRouteRef
- | ExternalRouteRef,
- sourceLocation: Parameters[1],
- ): RouteFunc | undefined {
+ | RouteRef
+ | SubRouteRef
+ | ExternalRouteRef,
+ options?: RouteResolutionApiResolveOptions,
+ ): RouteFunc | undefined {
// First figure out what our target absolute ref is, as well as our target path.
const [targetRef, targetPath] = resolveTargetRef(
anyRouteRef,
@@ -208,17 +210,7 @@ export class RouteResolver {
// The location that we get passed in uses the full path, so start by trimming off
// the app base path prefix in case we're running the app on a sub-path.
- let relativeSourceLocation: Parameters[1];
- if (typeof sourceLocation === 'string') {
- relativeSourceLocation = this.trimPath(sourceLocation);
- } else if (sourceLocation.pathname) {
- relativeSourceLocation = {
- ...sourceLocation,
- pathname: this.trimPath(sourceLocation.pathname),
- };
- } else {
- relativeSourceLocation = sourceLocation;
- }
+ const relativeSourceLocation = this.trimPath(options?.sourcePath ?? '');
// 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
@@ -233,7 +225,7 @@ export class RouteResolver {
this.routeObjects,
);
- const routeFunc: RouteFunc = (...[params]) => {
+ const routeFunc: RouteFunc = (...[params]) => {
// We selectively encode some some known-dangerous characters in the
// params. The reason that we don't perform a blanket `encodeURIComponent`
// here is that this encoding was added defensively long after the initial
diff --git a/packages/frontend-app-api/src/routing/RoutingProvider.tsx b/packages/frontend-app-api/src/routing/RoutingProvider.tsx
deleted file mode 100644
index 965faa3655..0000000000
--- a/packages/frontend-app-api/src/routing/RoutingProvider.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright 2020 The Backstage Authors
- *
- * 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 React, { ReactNode } from 'react';
-import {
- ExternalRouteRef,
- RouteRef,
- SubRouteRef,
-} from '@backstage/frontend-plugin-api';
-import {
- createVersionedValueMap,
- createVersionedContext,
-} from '@backstage/version-bridge';
-import { RouteResolver } from './RouteResolver';
-import { BackstageRouteObject } from './types';
-
-const RoutingContext = createVersionedContext<{ 1: RouteResolver }>(
- 'routing-context',
-);
-
-type ProviderProps = {
- routePaths: Map;
- routeParents: Map;
- routeObjects: BackstageRouteObject[];
- routeBindings: Map;
- basePath?: string;
- children: ReactNode;
-};
-
-// TODO(Rugvip): Migrate to a routing API instead
-export const RoutingProvider = ({
- routePaths,
- routeParents,
- routeObjects,
- routeBindings,
- basePath = '',
- children,
-}: ProviderProps) => {
- const resolver = new RouteResolver(
- routePaths,
- routeParents,
- routeObjects,
- routeBindings,
- basePath,
- );
-
- const versionedValue = createVersionedValueMap({ 1: resolver });
- return (
-
- {children}
-
- );
-};
diff --git a/packages/frontend-app-api/src/routing/getBasePath.ts b/packages/frontend-app-api/src/routing/getBasePath.ts
new file mode 100644
index 0000000000..53a2230245
--- /dev/null
+++ b/packages/frontend-app-api/src/routing/getBasePath.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * 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 { ConfigApi } from '@backstage/frontend-plugin-api';
+
+/** @internal */
+export function getBasePath(configApi: ConfigApi) {
+ let { pathname } = new URL(
+ configApi.getOptionalString('app.baseUrl') ?? '/',
+ 'http://sample.dev', // baseUrl can be specified as just a path
+ );
+ pathname = pathname.replace(/\/*$/, '');
+ return pathname;
+}
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index 832aa2663b..6966ef87c3 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -31,6 +31,8 @@ import {
FrontendFeature,
iconsApiRef,
RouteRef,
+ RouteResolutionApi,
+ routeResolutionApiRef,
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { App } from '../extensions/App';
@@ -89,7 +91,7 @@ import {
translationApiRef,
} from '@backstage/core-plugin-api/alpha';
import { CreateAppRouteBinder } from '../routing';
-import { RoutingProvider } from '../routing/RoutingProvider';
+import { RouteResolver } from '../routing/RouteResolver';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
import { createAppTree } from '../tree';
@@ -110,6 +112,7 @@ import { DefaultIconsApi } from '../apis/implementations/IconsApi';
import { stringifyError } from '@backstage/errors';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { icons as defaultIcons } from '../../../app-defaults/src/defaults';
+import { getBasePath } from '../routing/getBasePath';
const DefaultApis = defaultApis.map(factory => createApiExtension({ factory }));
@@ -354,11 +357,25 @@ export function createSpecializedApp(options?: {
config,
});
+ const routeInfo = extractRouteInfoFromAppNode(tree.root);
+ const routeBindings = resolveRouteBindings(
+ options?.bindRoutes,
+ config,
+ collectRouteIds(features),
+ );
+
const appIdentityProxy = new AppIdentityProxy();
const apiHolder = createApiHolder(
tree,
config,
appIdentityProxy,
+ new RouteResolver(
+ routeInfo.routePaths,
+ routeInfo.routeParents,
+ routeInfo.routeObjects,
+ routeBindings,
+ getBasePath(config),
+ ),
options?.icons,
);
@@ -381,24 +398,16 @@ export function createSpecializedApp(options?: {
}
}
- const routeInfo = extractRouteInfoFromAppNode(tree.root);
- const routeBindings = resolveRouteBindings(
- options?.bindRoutes,
- config,
- collectRouteIds(features),
- );
const rootEl = tree.root.instance!.getData(coreExtensionData.reactElement);
const AppComponent = () => (
-
-
- {rootEl}
-
-
+
+ {rootEl}
+
);
@@ -414,6 +423,7 @@ function createApiHolder(
tree: AppTree,
configApi: ConfigApi,
appIdentityProxy: AppIdentityProxy,
+ routeResolutionApi: RouteResolutionApi,
icons?: { [key in string]: IconComponent },
): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
@@ -465,6 +475,12 @@ function createApiHolder(
}),
});
+ factoryRegistry.register('static', {
+ api: routeResolutionApiRef,
+ deps: {},
+ factory: () => routeResolutionApi,
+ });
+
const componentsExtensions =
tree.root.edges.attachments
.get('components')
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 3a7a0f3ee6..4116469c2a 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -1079,6 +1079,26 @@ export interface RouteRef<
readonly T: TParams;
}
+// @public (undocumented)
+export interface RouteResolutionApi {
+ // (undocumented)
+ resolve(
+ anyRouteRef:
+ | RouteRef
+ | SubRouteRef
+ | ExternalRouteRef,
+ options?: RouteResolutionApiResolveOptions,
+ ): RouteFunc | undefined;
+}
+
+// @public
+export const routeResolutionApiRef: ApiRef;
+
+// @public (undocumented)
+export type RouteResolutionApiResolveOptions = {
+ sourcePath?: string;
+};
+
export { SessionApi };
export { SessionState };
diff --git a/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts b/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts
new file mode 100644
index 0000000000..06388cc966
--- /dev/null
+++ b/packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * 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 {
+ AnyRouteRefParams,
+ RouteRef,
+ SubRouteRef,
+ ExternalRouteRef,
+} from '../../routing';
+import { createApiRef } from '@backstage/core-plugin-api';
+
+/**
+ * TS magic for handling route parameters.
+ *
+ * @remarks
+ *
+ * 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.
+ *
+ * @public
+ */
+export type RouteFunc = (
+ ...[params]: TParams extends undefined
+ ? readonly []
+ : readonly [params: TParams]
+) => string;
+
+/**
+ * @public
+ */
+export type RouteResolutionApiResolveOptions = {
+ /**
+ * An absolute path to use as a starting point when resolving the route.
+ * If no path is provided the route will be resolved from the root of the app.
+ */
+ sourcePath?: string;
+};
+
+/**
+ * @public
+ */
+export interface RouteResolutionApi {
+ resolve(
+ anyRouteRef:
+ | RouteRef
+ | SubRouteRef
+ | ExternalRouteRef,
+ options?: RouteResolutionApiResolveOptions,
+ ): RouteFunc | undefined;
+}
+
+/**
+ * The `ApiRef` of {@link RouteResolutionApi}.
+ *
+ * @public
+ */
+export const routeResolutionApiRef = createApiRef({
+ id: 'core.route-resolution',
+});
diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts
index c0a2101db0..d766a8dfdf 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/index.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts
@@ -43,5 +43,6 @@ export * from './FetchApi';
export * from './IconsApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
+export * from './RouteResolutionApi';
export * from './StorageApi';
export * from './AnalyticsApi';
diff --git a/packages/frontend-plugin-api/src/routing/index.ts b/packages/frontend-plugin-api/src/routing/index.ts
index 6c25f725b5..ea733394c6 100644
--- a/packages/frontend-plugin-api/src/routing/index.ts
+++ b/packages/frontend-plugin-api/src/routing/index.ts
@@ -21,5 +21,5 @@ export {
createExternalRouteRef,
type ExternalRouteRef,
} from './ExternalRouteRef';
-export { useRouteRef, type RouteFunc } from './useRouteRef';
+export { useRouteRef } from './useRouteRef';
export { useRouteRefParams } from './useRouteRefParams';
diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx b/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx
index dcd56f81a6..4f43cf8d01 100644
--- a/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx
+++ b/packages/frontend-plugin-api/src/routing/useRouteRef.test.tsx
@@ -21,6 +21,8 @@ import { createVersionedContextForTesting } from '@backstage/version-bridge';
import { useRouteRef } from './useRouteRef';
import { createRouteRef } from './RouteRef';
import { createBrowserHistory } from 'history';
+import { TestApiProvider } from '@backstage/test-utils';
+import { routeResolutionApiRef } from '../apis';
describe('v1 consumer', () => {
const context = createVersionedContextForTesting('routing-context');
@@ -31,13 +33,14 @@ describe('v1 consumer', () => {
it('should resolve routes', () => {
const resolve = jest.fn(() => () => '/hello');
- context.set({ 1: { resolve } });
const routeRef = createRouteRef();
const renderedHook = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
-
+
+
+
),
});
@@ -46,14 +49,13 @@ describe('v1 consumer', () => {
expect(resolve).toHaveBeenCalledWith(
routeRef,
expect.objectContaining({
- pathname: '/my-page',
+ sourcePath: '/my-page',
}),
);
});
it('re-resolves the routeFunc when the search parameters change', () => {
const resolve = jest.fn(() => () => '/hello');
- context.set({ 1: { resolve } });
const routeRef = createRouteRef();
const history = createBrowserHistory();
@@ -61,11 +63,13 @@ describe('v1 consumer', () => {
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
-
+
+
+
),
});
@@ -79,7 +83,7 @@ describe('v1 consumer', () => {
it('does not re-resolve the routeFunc the location pathname does not change', () => {
const resolve = jest.fn(() => () => '/hello');
- context.set({ 1: { resolve } });
+ const api = { resolve };
const routeRef = createRouteRef();
const history = createBrowserHistory();
@@ -87,11 +91,13 @@ describe('v1 consumer', () => {
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
-
+
+
+
),
});
@@ -105,7 +111,7 @@ describe('v1 consumer', () => {
it('does not re-resolve the routeFunc when the search parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
- context.set({ 1: { resolve } });
+ const api = { resolve };
const routeRef = createRouteRef();
const history = createBrowserHistory();
@@ -113,11 +119,13 @@ describe('v1 consumer', () => {
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
-
+
+
+
),
});
@@ -131,7 +139,7 @@ describe('v1 consumer', () => {
it('does not re-resolve the routeFunc when the hash parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
- context.set({ 1: { resolve } });
+ const api = { resolve };
const routeRef = createRouteRef();
const history = createBrowserHistory();
@@ -139,11 +147,13 @@ describe('v1 consumer', () => {
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
-
+
+
+
),
});
diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.tsx b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx
index dfcb110930..1b096a6b5d 100644
--- a/packages/frontend-plugin-api/src/routing/useRouteRef.tsx
+++ b/packages/frontend-plugin-api/src/routing/useRouteRef.tsx
@@ -15,44 +15,12 @@
*/
import { useMemo } from 'react';
-import { matchRoutes, useLocation } from 'react-router-dom';
-import { useVersionedContext } from '@backstage/version-bridge';
+import { useLocation } from 'react-router-dom';
import { AnyRouteRefParams } from './types';
import { RouteRef } from './RouteRef';
import { SubRouteRef } from './SubRouteRef';
import { ExternalRouteRef } from './ExternalRouteRef';
-
-/**
- * TS magic for handling route parameters.
- *
- * @remarks
- *
- * 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.
- *
- * @public
- */
-export type RouteFunc = (
- ...[params]: TParams extends undefined
- ? readonly []
- : readonly [params: TParams]
-) => string;
-
-/**
- * @internal
- */
-export interface RouteResolver {
- resolve(
- anyRouteRef:
- | RouteRef
- | SubRouteRef
- | ExternalRouteRef,
- sourceLocation: Parameters[1],
- ): RouteFunc | undefined;
-}
+import { RouteFunc, routeResolutionApiRef, useApi } from '../apis';
/**
* React hook for constructing URLs to routes.
@@ -105,26 +73,13 @@ export function useRouteRef(
| ExternalRouteRef,
): RouteFunc | undefined {
const { pathname } = useLocation();
- const versionedContext = useVersionedContext<{ 1: RouteResolver }>(
- 'routing-context',
- );
- if (!versionedContext) {
- throw new Error('Routing context is not available');
- }
+ const routeResolutionApi = useApi(routeResolutionApiRef);
- const resolver = versionedContext.atVersion(1);
const routeFunc = useMemo(
- () => resolver && resolver.resolve(routeRef, { pathname }),
- [resolver, routeRef, pathname],
+ () => routeResolutionApi.resolve(routeRef, { sourcePath: pathname }),
+ [routeResolutionApi, routeRef, pathname],
);
- if (!versionedContext) {
- throw new Error('useRouteRef used outside of routing context');
- }
- if (!resolver) {
- throw new Error('RoutingContext v1 not available');
- }
-
const isOptional = 'optional' in routeRef && routeRef.optional;
if (!routeFunc && !isOptional) {
throw new Error(`No path for ${routeRef}`);
diff --git a/packages/frontend-test-utils/api-report.md b/packages/frontend-test-utils/api-report.md
index d67518050d..2605499a96 100644
--- a/packages/frontend-test-utils/api-report.md
+++ b/packages/frontend-test-utils/api-report.md
@@ -19,6 +19,7 @@ import { MockPermissionApi } from '@backstage/test-utils';
import { MockStorageApi } from '@backstage/test-utils';
import { MockStorageBucket } from '@backstage/test-utils';
import { RenderResult } from '@testing-library/react';
+import { RouteRef } from '@backstage/frontend-plugin-api';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { TestApiProvider } from '@backstage/test-utils';
import { TestApiProviderProps } from '@backstage/test-utils';
@@ -73,7 +74,10 @@ export { MockStorageApi };
export { MockStorageBucket };
// @public
-export function renderInTestApp(element: JSX.Element): RenderResult;
+export function renderInTestApp(
+ element: JSX.Element,
+ options?: TestAppOptions,
+): RenderResult;
export { setupRequestMockHandlers };
@@ -83,5 +87,12 @@ export { TestApiProviderProps };
export { TestApiRegistry };
+// @public
+export type TestAppOptions = {
+ mountedRoutes?: {
+ [path: string]: RouteRef;
+ };
+};
+
export { withLogCollector };
```
diff --git a/packages/frontend-test-utils/src/app/index.ts b/packages/frontend-test-utils/src/app/index.ts
index 3f19146c5c..2351561022 100644
--- a/packages/frontend-test-utils/src/app/index.ts
+++ b/packages/frontend-test-utils/src/app/index.ts
@@ -19,4 +19,4 @@ export {
type ExtensionTester,
} from './createExtensionTester';
-export { renderInTestApp } from './renderInTestApp';
+export { renderInTestApp, type TestAppOptions } from './renderInTestApp';
diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx
index 0cd05741b0..e0483f68d8 100644
--- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx
+++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx
@@ -14,17 +14,46 @@
* limitations under the License.
*/
+import React from 'react';
import {
+ RouteRef,
coreExtensionData,
createExtension,
} from '@backstage/frontend-plugin-api';
import { createExtensionTester } from './createExtensionTester';
+/**
+ * Options to customize the behavior of the test app.
+ * @public
+ */
+export type TestAppOptions = {
+ /**
+ * An object of paths to mount route ref on, with the key being the path and the value
+ * being the RouteRef that the path will be bound to. This allows the route refs to be
+ * used by `useRouteRef` in the rendered elements.
+ *
+ * @example
+ * ```ts
+ * renderInTestApp(, {
+ * mountedRoutes: {
+ * '/my-path': myRouteRef,
+ * }
+ * })
+ * // ...
+ * const link = useRouteRef(myRouteRef)
+ * ```
+ */
+ mountedRoutes?: { [path: string]: RouteRef };
+};
+
/**
* @public
* Renders the given element in a test app, for use in unit tests.
*/
-export function renderInTestApp(element: JSX.Element) {
+export function renderInTestApp(
+ element: JSX.Element,
+ options?: TestAppOptions,
+) {
const extension = createExtension({
namespace: 'test',
attachTo: { id: 'app', input: 'root' },
@@ -34,5 +63,26 @@ export function renderInTestApp(element: JSX.Element) {
factory: () => ({ element }),
});
const tester = createExtensionTester(extension);
+
+ if (options?.mountedRoutes) {
+ for (const [path, routeRef] of Object.entries(options.mountedRoutes)) {
+ // TODO(Rugvip): add support for external route refs
+ tester.add(
+ createExtension({
+ kind: 'test-route',
+ name: path,
+ attachTo: { id: 'app/root', input: 'elements' },
+ output: {
+ element: coreExtensionData.reactElement,
+ path: coreExtensionData.routePath,
+ routeRef: coreExtensionData.routeRef,
+ },
+ factory() {
+ return { element: , path, routeRef };
+ },
+ }),
+ );
+ }
+ }
return tester.render();
}