frontend-*: add and use RouteResolutionsApi

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-01-23 10:19:06 +01:00
parent 086294bda1
commit bc621aaa3d
19 changed files with 448 additions and 272 deletions
@@ -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}`);