frontend-*: add and use RouteResolutionsApi
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
---
|
||||
|
||||
Added `RouteResolutionsApi` as a replacement for the routing context.
|
||||
@@ -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 <LegacyAppContextProvider>{props.children}</LegacyAppContextProvider>;
|
||||
const RoutingContext = createVersionedContext<{ 1: RouteResolver }>(
|
||||
'routing-context',
|
||||
);
|
||||
|
||||
function LegacyRoutingProvider(props: { children: ReactNode }) {
|
||||
const routeResolutionApi = useApi(routeResolutionApiRef);
|
||||
|
||||
const value = useMemo<VersionedValue<{ 1: RouteResolver }>>(() => {
|
||||
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 (
|
||||
<RoutingContext.Provider value={value}>
|
||||
{props.children}
|
||||
</RoutingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackwardsCompatProvider(props: { children: ReactNode }) {
|
||||
return (
|
||||
<LegacyRoutingProvider>
|
||||
<LegacyAppContextProvider>{props.children}</LegacyAppContextProvider>
|
||||
</LegacyRoutingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <div>link: {link()}</div>;
|
||||
}
|
||||
|
||||
renderInTestApp(compatWrapper(<Component />), {
|
||||
mountedRoutes: { '/test': convertLegacyRouteRef(routeRef) },
|
||||
});
|
||||
|
||||
expect(screen.getByText('link: /test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<RouteRef, string>,
|
||||
private readonly routeParents: Map<RouteRef, RouteRef | undefined>,
|
||||
@@ -189,13 +191,13 @@ export class RouteResolver {
|
||||
private readonly appBasePath: string, // base path without a trailing slash
|
||||
) {}
|
||||
|
||||
resolve<Params extends AnyRouteRefParams>(
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<Params>
|
||||
| SubRouteRef<Params>
|
||||
| ExternalRouteRef<Params, any>,
|
||||
sourceLocation: Parameters<typeof matchRoutes>[1],
|
||||
): RouteFunc<Params> | undefined {
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
options?: RouteResolutionApiResolveOptions,
|
||||
): RouteFunc<TParams> | 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<typeof matchRoutes>[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> = (...[params]) => {
|
||||
const routeFunc: RouteFunc<TParams> = (...[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
|
||||
|
||||
@@ -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<RouteRef, string>;
|
||||
routeParents: Map<RouteRef, RouteRef | undefined>;
|
||||
routeObjects: BackstageRouteObject[];
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
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 (
|
||||
<RoutingContext.Provider value={versionedValue}>
|
||||
{children}
|
||||
</RoutingContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<ApiProvider apis={apiHolder}>
|
||||
<AppThemeProvider>
|
||||
<RoutingProvider {...routeInfo} routeBindings={routeBindings}>
|
||||
<InternalAppContext.Provider
|
||||
value={{ appIdentityProxy, routeObjects: routeInfo.routeObjects }}
|
||||
>
|
||||
{rootEl}
|
||||
</InternalAppContext.Provider>
|
||||
</RoutingProvider>
|
||||
<InternalAppContext.Provider
|
||||
value={{ appIdentityProxy, routeObjects: routeInfo.routeObjects }}
|
||||
>
|
||||
{rootEl}
|
||||
</InternalAppContext.Provider>
|
||||
</AppThemeProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
@@ -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')
|
||||
|
||||
@@ -1079,6 +1079,26 @@ export interface RouteRef<
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RouteResolutionApi {
|
||||
// (undocumented)
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
options?: RouteResolutionApiResolveOptions,
|
||||
): RouteFunc<TParams> | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const routeResolutionApiRef: ApiRef<RouteResolutionApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type RouteResolutionApiResolveOptions = {
|
||||
sourcePath?: string;
|
||||
};
|
||||
|
||||
export { SessionApi };
|
||||
|
||||
export { SessionState };
|
||||
|
||||
@@ -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<TParams extends AnyRouteRefParams> = (
|
||||
...[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<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
options?: RouteResolutionApiResolveOptions,
|
||||
): RouteFunc<TParams> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ApiRef` of {@link RouteResolutionApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const routeResolutionApiRef = createApiRef<RouteResolutionApi>({
|
||||
id: 'core.route-resolution',
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -21,5 +21,5 @@ export {
|
||||
createExternalRouteRef,
|
||||
type ExternalRouteRef,
|
||||
} from './ExternalRouteRef';
|
||||
export { useRouteRef, type RouteFunc } from './useRouteRef';
|
||||
export { useRouteRef } from './useRouteRef';
|
||||
export { useRouteRefParams } from './useRouteRefParams';
|
||||
|
||||
@@ -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<{}>) => (
|
||||
<MemoryRouter initialEntries={['/my-page']} children={children} />
|
||||
<TestApiProvider apis={[[routeResolutionApiRef, { resolve }]]}>
|
||||
<MemoryRouter initialEntries={['/my-page']} children={children} />
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<{}>) => (
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
<TestApiProvider apis={[[routeResolutionApiRef, { resolve }]]}>
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<{}>) => (
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
<TestApiProvider apis={[[routeResolutionApiRef, api]]}>
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<{}>) => (
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
<TestApiProvider apis={[[routeResolutionApiRef, api]]}>
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<{}>) => (
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
<TestApiProvider apis={[[routeResolutionApiRef, api]]}>
|
||||
<Router
|
||||
location={history.location}
|
||||
navigator={history}
|
||||
children={children}
|
||||
/>
|
||||
</TestApiProvider>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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<TParams extends AnyRouteRefParams> = (
|
||||
...[params]: TParams extends undefined
|
||||
? readonly []
|
||||
: readonly [params: TParams]
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface RouteResolver {
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
sourceLocation: Parameters<typeof matchRoutes>[1],
|
||||
): RouteFunc<TParams> | undefined;
|
||||
}
|
||||
import { RouteFunc, routeResolutionApiRef, useApi } from '../apis';
|
||||
|
||||
/**
|
||||
* React hook for constructing URLs to routes.
|
||||
@@ -105,26 +73,13 @@ export function useRouteRef<TParams extends AnyRouteRefParams>(
|
||||
| ExternalRouteRef<TParams, any>,
|
||||
): RouteFunc<TParams> | 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}`);
|
||||
|
||||
@@ -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 };
|
||||
```
|
||||
|
||||
@@ -19,4 +19,4 @@ export {
|
||||
type ExtensionTester,
|
||||
} from './createExtensionTester';
|
||||
|
||||
export { renderInTestApp } from './renderInTestApp';
|
||||
export { renderInTestApp, type TestAppOptions } from './renderInTestApp';
|
||||
|
||||
@@ -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(<MyComponent />, {
|
||||
* 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: <React.Fragment />, path, routeRef };
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return tester.render();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user