frontend-plugin-api: forklift routing API from core-plugin-api

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-11 20:04:21 +02:00
parent 640fe3dbef
commit d41c97e5db
14 changed files with 1164 additions and 3 deletions
+3 -1
View File
@@ -27,7 +27,8 @@
"@backstage/frontend-app-api": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^12.1.3"
"@testing-library/react-hooks": "^8.0.1",
"history": "^5.3.0"
},
"files": [
"dist"
@@ -39,6 +40,7 @@
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@types/react": "^16.13.1 || ^17.0.0",
"lodash": "^4.17.21",
"zod": "^3.21.4",
@@ -0,0 +1,112 @@
/*
* 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 { AnyParams, ExternalRouteRef } from './types';
import { createExternalRouteRef } from './ExternalRouteRef';
describe('ExternalRouteRef', () => {
it('should be created', () => {
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef({
id: 'my-route-ref',
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toBe(false);
expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}');
});
it('should be created as optional', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: [],
optional: true,
});
expect(routeRef.params).toEqual([]);
expect(routeRef.optional).toEqual(true);
});
it('should be created with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(false);
});
it('should be created as optional with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
optional: true,
});
expect(routeRef.params).toEqual(['x', 'y']);
expect(routeRef.optional).toEqual(true);
});
it('should properly infer and validate parameter types and assignments', () => {
function validateType<T extends AnyParams, O extends boolean>(
_ref: ExternalRouteRef<T, O>,
) {}
const _1 = createExternalRouteRef({ id: '1', params: ['notX'] });
// @ts-expect-error
validateType<{ x: string }, any>(_1);
validateType<{ notX: string }, any>(_1);
const _2 = createExternalRouteRef({
id: '2',
params: ['x'],
optional: true,
});
// @ts-expect-error
validateType<undefined, any>(_2);
validateType<{ x: string }, true>(_2);
const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] });
// @ts-expect-error
validateType<{ x: string }, any>(_3);
// extra z, we validate this at runtime instead
validateType<{ x: string; y: string; z: string }, any>(_3);
validateType<{ x: string; y: string }, false>(_3);
const _4 = createExternalRouteRef({ id: '4', params: [] });
// @ts-expect-error
validateType<{ x: string }, any>(_4);
validateType<undefined, false>(_4);
const _5 = createExternalRouteRef({ id: '5' });
// @ts-expect-error
validateType<{ x: string }, any>(_5);
validateType<undefined, false>(_5);
const _6 = createExternalRouteRef({ id: '6', optional: true });
// @ts-expect-error
validateType<undefined, false>(_6);
validateType<undefined, true>(_6);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,86 @@
/*
* 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 {
ExternalRouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
/**
* @internal
*/
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean,
> implements ExternalRouteRef<Params, Optional>
{
// The marker is used for type checking while the symbol is used at runtime.
declare $$routeRefType: 'external';
readonly [routeRefType] = 'external';
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
) {}
toString() {
return `routeRef{type=external,id=${this.id}}`;
}
}
/**
* Creates a route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @param options - Description of the route reference to be created.
* @public
*/
export function createExternalRouteRef<
Params extends { [param in ParamKey]: string },
Optional extends boolean = false,
ParamKey extends string = never,
>(options: {
/**
* An identifier for this route, used to identify it in error messages
*/
id: string;
/**
* The parameters that will be provided to the external route reference.
*/
params?: ParamKey[];
/**
* Whether or not this route is optional, defaults to false.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
return new ExternalRouteRefImpl(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
);
}
@@ -0,0 +1,74 @@
/*
* 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 { AnyParams, RouteRef } from './types';
import { createRouteRef } from './RouteRef';
describe('RouteRef', () => {
it('should be created', () => {
const routeRef: RouteRef<undefined> = createRouteRef({
id: 'my-route-ref',
});
expect(routeRef.params).toEqual([]);
expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}');
});
it('should be created with params', () => {
const routeRef: RouteRef<{
x: string;
y: string;
}> = createRouteRef({
id: 'my-other-route-ref',
params: ['x', 'y'],
});
expect(routeRef.params).toEqual(['x', 'y']);
});
it('should properly infer and validate parameter types and assignments', () => {
function validateType<T extends AnyParams>(_ref: RouteRef<T>) {}
const _1 = createRouteRef({ id: '1', params: ['x'] });
// @ts-expect-error
validateType<{ y: string }>(_1);
// @ts-expect-error
validateType<undefined>(_1);
validateType<{ x: string }>(_1);
const _2 = createRouteRef({ id: '2', params: ['x', 'y'] });
// @ts-expect-error
validateType<{ x: string }>(_2);
// @ts-expect-error
validateType<undefined>(_2);
// @ts-expect-error
validateType<{ x: string; z: string }>(_2);
// extra z, we validate this at runtime instead
validateType<{ x: string; y: string; z: string }>(_2);
validateType<{ x: string; y: string }>(_2);
const _3 = createRouteRef({ id: '3', params: [] });
// @ts-expect-error
validateType<{ x: string }>(_3);
validateType<undefined>(_3);
const _4 = createRouteRef({ id: '4' });
// @ts-expect-error
validateType<{ x: string }>(_4);
validateType<undefined>(_4);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,73 @@
/*
* 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 {
RouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
/**
* @internal
*/
export class RouteRefImpl<Params extends AnyParams>
implements RouteRef<Params>
{
// The marker is used for type checking while the symbol is used at runtime.
declare $$routeRefType: 'absolute';
readonly [routeRefType] = 'absolute';
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
) {}
get title() {
return this.id;
}
toString() {
return `routeRef{type=absolute,id=${this.id}}`;
}
}
/**
* Create a {@link RouteRef} from a route descriptor.
*
* @param config - Description of the route reference to be created.
* @public
*/
export function createRouteRef<
// Params is the type that we care about and the one to be embedded in the route ref.
// For example, given the params ['name', 'kind'], Params will be {name: string, kind: string}
Params extends { [param in ParamKey]: string },
// ParamKey is here to make sure the Params type properly has its keys narrowed down
// to only the elements of params. Defaulting to never makes sure we end up with
// Param = {} if the params array is empty.
ParamKey extends string = never,
>(config: {
/** The id of the route ref, used to identify it when printed */
id: string;
/** A list of parameter names that the path that this route ref is bound to must contain */
params?: ParamKey[];
}): RouteRef<OptionalParams<Params>> {
return new RouteRefImpl(
config.id,
(config.params ?? []) as ParamKeys<OptionalParams<Params>>,
);
}
@@ -0,0 +1,128 @@
/*
* 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 { AnyParams, SubRouteRef } from './types';
import { createSubRouteRef } from './SubRouteRef';
import { createRouteRef } from './RouteRef';
const parent = createRouteRef({ id: 'parent' });
const parentX = createRouteRef({ id: 'parent-x', params: ['x'] });
describe('SubRouteRef', () => {
it('should be created', () => {
const routeRef: SubRouteRef<undefined> = createSubRouteRef({
parent,
id: 'my-route-ref',
path: '/foo',
});
expect(routeRef.path).toBe('/foo');
expect(routeRef.parent).toBe(parent);
expect(routeRef.params).toEqual([]);
expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}');
});
it('should be created with params', () => {
const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({
parent,
id: 'my-other-route-ref',
path: '/foo/:bar',
});
expect(routeRef.path).toBe('/foo/:bar');
expect(routeRef.parent).toBe(parent);
expect(routeRef.params).toEqual(['bar']);
});
it('should be created with merged params', () => {
const routeRef: SubRouteRef<{
x: string;
y: string;
z: string;
}> = createSubRouteRef({
parent: parentX,
id: 'my-other-route-ref',
path: '/foo/:y/:z',
});
expect(routeRef.path).toBe('/foo/:y/:z');
expect(routeRef.parent).toBe(parentX);
expect(routeRef.params).toEqual(['x', 'y', 'z']);
});
it('should be created with params from parent', () => {
const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({
parent: parentX,
id: 'my-other-route-ref',
path: '/foo/bar',
});
expect(routeRef.path).toBe('/foo/bar');
expect(routeRef.parent).toBe(parentX);
expect(routeRef.params).toEqual(['x']);
});
it.each([
['foo', "SubRouteRef path must start with '/', got 'foo'"],
[':foo', "SubRouteRef path must start with '/', got ':foo'"],
['', "SubRouteRef path must start with '/', got ''"],
['/', "SubRouteRef path must not end with '/', got '/'"],
['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"],
['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'],
['/:/foo', "SubRouteRef path has invalid param, got ''"],
['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"],
['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"],
])('should throw if path is invalid, %s', (path, message) => {
expect(() =>
createSubRouteRef({ path, parent: parentX, id: path }),
).toThrow(message);
});
it('should properly infer and parse path parameters', () => {
function validateType<T extends AnyParams>(_ref: SubRouteRef<T>) {}
const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' });
// @ts-expect-error
validateType<{ x: string }>(_1);
validateType<undefined>(_1);
const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' });
// @ts-expect-error
validateType<undefined>(_2);
// @ts-expect-error
validateType<{ x: string; z: string }>(_2);
// @ts-expect-error
validateType<{ y: string }>(_2);
// extra z, we validate this at runtime instead
validateType<{ x: string; y: string; z: string }>(_2);
validateType<{ x: string; y: string }>(_2);
const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' });
// @ts-expect-error
validateType<undefined>(_3);
// @ts-expect-error
validateType<{ y: string }>(_3);
validateType<{ x: string }>(_3);
const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' });
// @ts-expect-error
validateType<undefined>(_4);
// @ts-expect-error
validateType<{ x: string; z: string }>(_4);
// @ts-expect-error
validateType<{ y: string }>(_4);
validateType<{ x: string; y: string }>(_4);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,148 @@
/*
* 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 {
AnyParams,
OptionalParams,
ParamKeys,
RouteRef,
routeRefType,
SubRouteRef,
} from './types';
// Should match the pattern in react-router
const PARAM_PATTERN = /^\w+$/;
/**
* @internal
*/
export class SubRouteRefImpl<Params extends AnyParams>
implements SubRouteRef<Params>
{
// The marker is used for type checking while the symbol is used at runtime.
declare $$routeRefType: 'sub';
readonly [routeRefType] = 'sub';
constructor(
private readonly id: string,
readonly path: string,
readonly parent: RouteRef,
readonly params: ParamKeys<Params>,
) {}
toString() {
return `routeRef{type=sub,id=${this.id}}`;
}
}
/**
* Used in {@link PathParams} type declaration.
* @public
*/
export type ParamPart<S extends string> = S extends `:${infer Param}`
? Param
: never;
/**
* Used in {@link PathParams} type declaration.
* @public
*/
export type ParamNames<S extends string> =
S extends `${infer Part}/${infer Rest}`
? ParamPart<Part> | ParamNames<Rest>
: ParamPart<S>;
/**
* This utility type helps us infer a Param object type from a string path
* For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }`
* @public
*/
export type PathParams<S extends string> = { [name in ParamNames<S>]: string };
/**
* Merges a param object type with an optional params type into a params object.
* @public
*/
export type MergeParams<
P1 extends { [param in string]: string },
P2 extends AnyParams,
> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2);
/**
* Creates a SubRouteRef type given the desired parameters and parent route parameters.
* The parameters types are merged together while ensuring that there is no overlap between the two.
*
* @public
*/
export type MakeSubRouteRef<
Params extends { [param in string]: string },
ParentParams extends AnyParams,
> = keyof Params & keyof ParentParams extends never
? SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>
: never;
/**
* Create a {@link SubRouteRef} from a route descriptor.
*
* @param config - Description of the route reference to be created.
* @public
*/
export function createSubRouteRef<
Path extends string,
ParentParams extends AnyParams = never,
>(config: {
id: string;
path: Path;
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
const { id, path, parent } = config;
type Params = PathParams<Path>;
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
const pathParams = path
.split('/')
.filter(p => p.startsWith(':'))
.map(p => p.substring(1));
const params = [...parent.params, ...pathParams];
if (parent.params.some(p => pathParams.includes(p as string))) {
throw new Error(
'SubRouteRef may not have params that overlap with its parent',
);
}
if (!path.startsWith('/')) {
throw new Error(`SubRouteRef path must start with '/', got '${path}'`);
}
if (path.endsWith('/')) {
throw new Error(`SubRouteRef path must not end with '/', got '${path}'`);
}
for (const param of pathParams) {
if (!PARAM_PATTERN.test(param)) {
throw new Error(`SubRouteRef path has invalid param, got '${param}'`);
}
}
// We ensure that the type of the return type is sane here
const subRouteRef = new SubRouteRefImpl(
id,
path,
parent,
params as ParamKeys<MergeParams<Params, ParentParams>>,
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
// But skip type checking of the return value itself, because the conditional
// type checking of the parent parameter overlap is tricky to express.
return subRouteRef as any;
}
@@ -0,0 +1,37 @@
/*
* 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.
*/
export type {
AnyParams,
RouteRef,
SubRouteRef,
ExternalRouteRef,
OptionalParams,
ParamKeys,
RouteFunc,
} from './types';
export { createRouteRef } from './RouteRef';
export { createSubRouteRef } from './SubRouteRef';
export type {
MakeSubRouteRef,
MergeParams,
ParamNames,
ParamPart,
PathParams,
} from './SubRouteRef';
export { createExternalRouteRef } from './ExternalRouteRef';
export { useRouteRef } from './useRouteRef';
export { useRouteRefParams } from './useRouteRefParams';
@@ -0,0 +1,146 @@
/*
* 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 { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
/**
* Catch-all type for route params.
*
* @public
*/
export type AnyParams = { [param in string]: string } | undefined;
/**
* Type describing the key type of a route parameter mapping.
*
* @public
*/
export type ParamKeys<Params extends AnyParams> = keyof Params extends never
? []
: (keyof Params)[];
/**
* Optional route params.
*
* @public
*/
export type OptionalParams<Params extends { [param in string]: string }> =
Params[keyof Params] extends never ? undefined : Params;
/**
* 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 extends AnyParams> = (
...[params]: Params extends undefined ? readonly [] : readonly [Params]
) => string;
/**
* This symbol is what we use at runtime to determine whether a given object
* is a type of RouteRef or not. It doesn't work well in TypeScript though since
* the `unique symbol` will refer to different values between package versions.
* For that reason we use the marker $$routeRefType to represent the symbol at
* compile-time instead of using the symbol directly.
*
* @internal
*/
export const routeRefType: unique symbol = getOrCreateGlobalSingleton<any>(
'route-ref-type',
() => Symbol('route-ref-type'),
);
/**
* Absolute route reference.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export type RouteRef<Params extends AnyParams = any> = {
$$routeRefType: 'absolute'; // See routeRefType above
params: ParamKeys<Params>;
};
/**
* Descriptor of a route relative to an absolute {@link RouteRef}.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export type SubRouteRef<Params extends AnyParams = any> = {
$$routeRefType: 'sub'; // See routeRefType above
parent: RouteRef;
path: string;
params: ParamKeys<Params>;
};
/**
* Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export type ExternalRouteRef<
Params extends AnyParams = any,
Optional extends boolean = any,
> = {
$$routeRefType: 'external'; // See routeRefType above
params: ParamKeys<Params>;
optional?: Optional;
};
/**
* @internal
*/
export type AnyRouteRef =
| RouteRef<any>
| SubRouteRef<any>
| ExternalRouteRef<any, any>;
/**
* A duplicate of the react-router RouteObject, but with routeRef added
* @internal
*/
export interface BackstageRouteObject {
caseSensitive: boolean;
children?: BackstageRouteObject[];
element: React.ReactNode;
path: string;
routeRefs: Set<RouteRef>;
}
@@ -0,0 +1,157 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { MemoryRouter, Router } from 'react-router-dom';
import { createVersionedContextForTesting } from '@backstage/version-bridge';
import { useRouteRef } from './useRouteRef';
import { createRouteRef } from './RouteRef';
import { createBrowserHistory } from 'history';
describe('v1 consumer', () => {
const context = createVersionedContextForTesting('routing-context');
afterEach(() => {
context.reset();
});
it('should resolve routes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef({ id: 'ref1' });
const renderedHook = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<MemoryRouter initialEntries={['/my-page']} children={children} />
),
});
const routeFunc = renderedHook.result.current;
expect(routeFunc()).toBe('/hello');
expect(resolve).toHaveBeenCalledWith(
routeRef,
expect.objectContaining({
pathname: '/my-page',
}),
);
});
it('re-resolves the routeFunc when the search parameters change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-new-page');
rerender();
expect(resolve).toHaveBeenCalledTimes(2);
});
it('does not re-resolve the routeFunc the location pathname does not change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-page');
rerender();
expect(resolve).toHaveBeenCalledTimes(1);
});
it('does not re-resolve the routeFunc when the search parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-page?foo=bar');
rerender();
expect(resolve).toHaveBeenCalledTimes(1);
});
it('does not re-resolve the routeFunc when the hash parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef({ id: 'ref1' });
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-page#foo');
rerender();
expect(resolve).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,115 @@
/*
* 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 { useMemo } from 'react';
import { matchRoutes, useLocation } from 'react-router-dom';
import { useVersionedContext } from '@backstage/version-bridge';
import {
AnyParams,
ExternalRouteRef,
RouteFunc,
RouteRef,
SubRouteRef,
} from './types';
/**
* @internal
*/
export interface RouteResolver {
resolve<Params extends AnyParams>(
anyRouteRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
sourceLocation: Parameters<typeof matchRoutes>[1],
): RouteFunc<Params> | undefined;
}
/**
* React hook for constructing URLs to routes.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
*
* @param routeRef - The ref to route that should be converted to URL.
* @returns A function that will in turn return the concrete URL of the `routeRef`.
* @public
*/
export function useRouteRef<Optional extends boolean, Params extends AnyParams>(
routeRef: ExternalRouteRef<Params, Optional>,
): Optional extends true ? RouteFunc<Params> | undefined : RouteFunc<Params>;
/**
* React hook for constructing URLs to routes.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
*
* @param routeRef - The ref to route that should be converted to URL.
* @returns A function that will in turn return the concrete URL of the `routeRef`.
* @public
*/
export function useRouteRef<Params extends AnyParams>(
routeRef: RouteRef<Params> | SubRouteRef<Params>,
): RouteFunc<Params>;
/**
* React hook for constructing URLs to routes.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
*
* @param routeRef - The ref to route that should be converted to URL.
* @returns A function that will in turn return the concrete URL of the `routeRef`.
* @public
*/
export function useRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): RouteFunc<Params> | undefined {
const { pathname } = useLocation();
const versionedContext = useVersionedContext<{ 1: RouteResolver }>(
'routing-context',
);
if (!versionedContext) {
throw new Error('Routing context is not available');
}
const resolver = versionedContext.atVersion(1);
const routeFunc = useMemo(
() => resolver && resolver.resolve(routeRef, { pathname }),
[resolver, 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}`);
}
return routeFunc;
}
@@ -0,0 +1,52 @@
/*
* Copyright 2021 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 { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { useRouteRefParams } from './useRouteRefParams';
import { createRouteRef } from './RouteRef';
describe('useRouteRefParams', () => {
it('should provide types params', () => {
const routeRef = createRouteRef({
id: 'ref1',
params: ['a', 'b'],
});
const Page = () => {
const params: { a: string; b: string } = useRouteRefParams(routeRef);
return (
<div>
<span>{params.a}</span>
<span>{params.b}</span>
</div>
);
};
const { getByText } = render(
<MemoryRouter initialEntries={['/foo/bar']}>
<Routes>
<Route path="/:a/:b" element={<Page />} />
</Routes>
</MemoryRouter>,
);
expect(getByText('foo')).toBeInTheDocument();
expect(getByText('bar')).toBeInTheDocument();
});
});
@@ -0,0 +1,29 @@
/*
* Copyright 2021 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 { useParams } from 'react-router-dom';
import { RouteRef, AnyParams, SubRouteRef } from './types';
/**
* React hook for retrieving dynamic params from the current URL.
* @param _routeRef - Ref of the current route.
* @public
*/
export function useRouteRefParams<Params extends AnyParams>(
_routeRef: RouteRef<Params> | SubRouteRef<Params>,
): Params {
return useParams() as Params;
}
+4 -2
View File
@@ -4234,9 +4234,11 @@ __metadata:
"@backstage/frontend-app-api": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^12.1.3
"@testing-library/react-hooks": ^8.0.1
"@types/react": ^16.13.1 || ^17.0.0
history: ^5.3.0
lodash: ^4.17.21
zod: ^3.21.4
zod-to-json-schema: ^3.21.4
@@ -27641,7 +27643,7 @@ __metadata:
languageName: node
linkType: hard
"history@npm:^5.0.0":
"history@npm:^5.0.0, history@npm:^5.3.0":
version: 5.3.0
resolution: "history@npm:5.3.0"
dependencies: