From bc621aaa3d5bbc3409a5af8b97af264ebdc08c41 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 23 Jan 2024 10:19:06 +0100 Subject: [PATCH] frontend-*: add and use RouteResolutionsApi Signed-off-by: Patrik Oldsberg --- .changeset/famous-houses-thank.md | 7 + .changeset/rare-seals-thank.md | 5 + .../compatWrapper/BackwardsCompatProvider.tsx | 52 +++++- .../src/compatWrapper/compatWrapper.test.tsx | 27 ++- .../src/extensions/AppRoot.tsx | 16 +- .../src/routing/RouteResolver.test.ts | 162 ++++++++++-------- .../src/routing/RouteResolver.ts | 30 ++-- .../src/routing/RoutingProvider.tsx | 66 ------- .../src/routing/getBasePath.ts | 27 +++ .../frontend-app-api/src/wiring/createApp.tsx | 44 +++-- packages/frontend-plugin-api/api-report.md | 20 +++ .../apis/definitions/RouteResolutionApi.ts | 75 ++++++++ .../src/apis/definitions/index.ts | 1 + .../frontend-plugin-api/src/routing/index.ts | 2 +- .../src/routing/useRouteRef.test.tsx | 64 ++++--- .../src/routing/useRouteRef.tsx | 55 +----- packages/frontend-test-utils/api-report.md | 13 +- packages/frontend-test-utils/src/app/index.ts | 2 +- .../src/app/renderInTestApp.tsx | 52 +++++- 19 files changed, 448 insertions(+), 272 deletions(-) create mode 100644 .changeset/famous-houses-thank.md create mode 100644 .changeset/rare-seals-thank.md delete mode 100644 packages/frontend-app-api/src/routing/RoutingProvider.tsx create mode 100644 packages/frontend-app-api/src/routing/getBasePath.ts create mode 100644 packages/frontend-plugin-api/src/apis/definitions/RouteResolutionApi.ts 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(); }