frontend-plugin-api: forklift routing API from core-plugin-api
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user