frontend-plugin-api: refactor SubRouteRef to modern structure
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -14,24 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AnyParams, SubRouteRef } from './types';
|
||||
import { createSubRouteRef } from './SubRouteRef';
|
||||
import { AnyRouteParams } from './types';
|
||||
import {
|
||||
SubRouteRef,
|
||||
createSubRouteRef,
|
||||
toInternalSubRouteRef,
|
||||
} from './SubRouteRef';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
|
||||
const parent = createRouteRef({ id: 'parent' });
|
||||
const parentX = createRouteRef({ id: 'parent-x', params: ['x'] });
|
||||
const parent = createRouteRef();
|
||||
const parentX = createRouteRef({ params: ['x'] });
|
||||
|
||||
describe('SubRouteRef', () => {
|
||||
it('should be created', () => {
|
||||
const routeRef: SubRouteRef<undefined> = createSubRouteRef({
|
||||
const routeRef: SubRouteRef = 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}');
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
expect(internal.path).toBe('/foo');
|
||||
expect(internal.getParent()).toBe(parent);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
expect(String(internal)).toBe('SubRouteRef{}');
|
||||
});
|
||||
|
||||
it('should be created with params', () => {
|
||||
@@ -40,9 +45,10 @@ describe('SubRouteRef', () => {
|
||||
id: 'my-other-route-ref',
|
||||
path: '/foo/:bar',
|
||||
});
|
||||
expect(routeRef.path).toBe('/foo/:bar');
|
||||
expect(routeRef.parent).toBe(parent);
|
||||
expect(routeRef.params).toEqual(['bar']);
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
expect(internal.path).toBe('/foo/:bar');
|
||||
expect(internal.getParent()).toBe(parent);
|
||||
expect(internal.getParams()).toEqual(['bar']);
|
||||
});
|
||||
|
||||
it('should be created with merged params', () => {
|
||||
@@ -55,9 +61,10 @@ describe('SubRouteRef', () => {
|
||||
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']);
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
expect(internal.path).toBe('/foo/:y/:z');
|
||||
expect(internal.getParent()).toBe(parentX);
|
||||
expect(internal.getParams()).toEqual(['x', 'y', 'z']);
|
||||
});
|
||||
|
||||
it('should be created with params from parent', () => {
|
||||
@@ -66,9 +73,10 @@ describe('SubRouteRef', () => {
|
||||
id: 'my-other-route-ref',
|
||||
path: '/foo/bar',
|
||||
});
|
||||
expect(routeRef.path).toBe('/foo/bar');
|
||||
expect(routeRef.parent).toBe(parentX);
|
||||
expect(routeRef.params).toEqual(['x']);
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
expect(internal.path).toBe('/foo/bar');
|
||||
expect(internal.getParent()).toBe(parentX);
|
||||
expect(internal.getParams()).toEqual(['x']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -88,39 +96,42 @@ describe('SubRouteRef', () => {
|
||||
});
|
||||
|
||||
it('should properly infer and parse path parameters', () => {
|
||||
function validateType<T extends AnyParams>(_ref: SubRouteRef<T>) {}
|
||||
function checkSubRouteRef<T extends AnyRouteParams>(
|
||||
_ref: SubRouteRef<T>,
|
||||
_params: T extends undefined ? undefined : T,
|
||||
) {}
|
||||
|
||||
const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' });
|
||||
// @ts-expect-error
|
||||
validateType<{ x: string }>(_1);
|
||||
validateType<undefined>(_1);
|
||||
checkSubRouteRef(_1, { x: '' });
|
||||
checkSubRouteRef(_1, undefined);
|
||||
|
||||
const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' });
|
||||
// @ts-expect-error
|
||||
validateType<undefined>(_2);
|
||||
checkSubRouteRef(_2, undefined);
|
||||
// @ts-expect-error
|
||||
validateType<{ x: string; z: string }>(_2);
|
||||
checkSubRouteRef(_2, { x: '', z: '' });
|
||||
// @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);
|
||||
checkSubRouteRef(_2, { y: '' });
|
||||
// @ts-expect-error
|
||||
checkSubRouteRef(_2, { x: '', y: '', z: '' });
|
||||
checkSubRouteRef(_2, { x: '', y: '' });
|
||||
|
||||
const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' });
|
||||
// @ts-expect-error
|
||||
validateType<undefined>(_3);
|
||||
checkSubRouteRef(_3, undefined);
|
||||
// @ts-expect-error
|
||||
validateType<{ y: string }>(_3);
|
||||
validateType<{ x: string }>(_3);
|
||||
checkSubRouteRef(_3, { y: '' });
|
||||
checkSubRouteRef(_3, { x: '' });
|
||||
|
||||
const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' });
|
||||
// @ts-expect-error
|
||||
validateType<undefined>(_4);
|
||||
checkSubRouteRef(_4, undefined);
|
||||
// @ts-expect-error
|
||||
validateType<{ x: string; z: string }>(_4);
|
||||
checkSubRouteRef(_4, { x: '', z: '' });
|
||||
// @ts-expect-error
|
||||
validateType<{ y: string }>(_4);
|
||||
validateType<{ x: string; y: string }>(_4);
|
||||
checkSubRouteRef(_4, { y: '' });
|
||||
checkSubRouteRef(_4, { x: '', y: '' });
|
||||
|
||||
// To avoid complains about missing expectations and unused vars
|
||||
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
|
||||
|
||||
@@ -14,43 +14,93 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AnyParams,
|
||||
OptionalParams,
|
||||
ParamKeys,
|
||||
RouteRef,
|
||||
routeRefType,
|
||||
SubRouteRef,
|
||||
} from './types';
|
||||
import { RouteRef, toInternalRouteRef } from './RouteRef';
|
||||
import { AnyRouteParams } from './types';
|
||||
|
||||
// Should match the pattern in react-router
|
||||
const PARAM_PATTERN = /^\w+$/;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Descriptor of a route relative to an absolute {@link RouteRef}.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
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';
|
||||
export interface SubRouteRef<TParams extends AnyRouteParams = AnyRouteParams> {
|
||||
readonly $$type: '@backstage/SubRouteRef';
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
readonly path: string,
|
||||
readonly parent: RouteRef,
|
||||
readonly params: ParamKeys<Params>,
|
||||
) {}
|
||||
readonly T: TParams;
|
||||
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalSubRouteRef<
|
||||
TParams extends AnyRouteParams = AnyRouteParams,
|
||||
> extends SubRouteRef<TParams> {
|
||||
readonly version: 'v1';
|
||||
|
||||
getParams(): string[];
|
||||
getParent(): RouteRef;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function toInternalSubRouteRef<
|
||||
TParams extends AnyRouteParams = AnyRouteParams,
|
||||
>(resource: SubRouteRef<TParams>): InternalSubRouteRef<TParams> {
|
||||
const r = resource as InternalSubRouteRef<TParams>;
|
||||
if (r.$$type !== '@backstage/SubRouteRef') {
|
||||
throw new Error(`Invalid SubRouteRef, bad type '${r.$$type}'`);
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function isSubRouteRef(opaque: {
|
||||
$$type: string;
|
||||
}): opaque is SubRouteRef {
|
||||
return opaque.$$type === '@backstage/SubRouteRef';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class SubRouteRefImpl<TParams extends AnyRouteParams>
|
||||
implements SubRouteRef<TParams>
|
||||
{
|
||||
readonly $$type = '@backstage/SubRouteRef';
|
||||
readonly version = 'v1';
|
||||
|
||||
#params: string[];
|
||||
#parent: RouteRef;
|
||||
|
||||
constructor(readonly path: string, params: string[], parent: RouteRef) {
|
||||
this.#params = params;
|
||||
this.#parent = parent;
|
||||
}
|
||||
|
||||
get T(): never {
|
||||
throw new Error(`tried to read RouteRef.T of ${this}`);
|
||||
}
|
||||
|
||||
getParams(): string[] {
|
||||
return this.#params;
|
||||
}
|
||||
|
||||
getParent(): RouteRef {
|
||||
return this.#parent;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `routeRef{type=sub,id=${this.id}}`;
|
||||
return `SubRouteRef{}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in {@link PathParams} type declaration.
|
||||
* @public
|
||||
* @ignore
|
||||
*/
|
||||
export type ParamPart<S extends string> = S extends `:${infer Param}`
|
||||
? Param
|
||||
@@ -58,7 +108,7 @@ export type ParamPart<S extends string> = S extends `:${infer Param}`
|
||||
|
||||
/**
|
||||
* Used in {@link PathParams} type declaration.
|
||||
* @public
|
||||
* @ignore
|
||||
*/
|
||||
export type ParamNames<S extends string> =
|
||||
S extends `${infer Part}/${infer Rest}`
|
||||
@@ -67,30 +117,37 @@ export type ParamNames<S extends string> =
|
||||
/**
|
||||
* 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
|
||||
* @ignore
|
||||
*/
|
||||
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
|
||||
* @ignore
|
||||
*/
|
||||
export type MergeParams<
|
||||
P1 extends { [param in string]: string },
|
||||
P2 extends AnyParams,
|
||||
P2 extends AnyRouteParams,
|
||||
> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2);
|
||||
|
||||
/**
|
||||
* Convert empty params to undefined.
|
||||
* @ignore
|
||||
*/
|
||||
export type TrimEmptyParams<Params extends { [param in string]: string }> =
|
||||
keyof Params extends never ? undefined : Params;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @ignore
|
||||
*/
|
||||
export type MakeSubRouteRef<
|
||||
Params extends { [param in string]: string },
|
||||
ParentParams extends AnyParams,
|
||||
ParentParams extends AnyRouteParams,
|
||||
> = keyof Params & keyof ParentParams extends never
|
||||
? SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>
|
||||
? SubRouteRef<TrimEmptyParams<MergeParams<Params, ParentParams>>>
|
||||
: never;
|
||||
|
||||
/**
|
||||
@@ -101,23 +158,26 @@ export type MakeSubRouteRef<
|
||||
*/
|
||||
export function createSubRouteRef<
|
||||
Path extends string,
|
||||
ParentParams extends AnyParams = never,
|
||||
ParentParams extends AnyRouteParams = never,
|
||||
>(config: {
|
||||
id: string;
|
||||
path: Path;
|
||||
parent: RouteRef<ParentParams>;
|
||||
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
|
||||
const { id, path, parent } = config;
|
||||
const { path, parent } = config;
|
||||
type Params = PathParams<Path>;
|
||||
|
||||
const internalParent = toInternalRouteRef(parent);
|
||||
const parentParams = internalParent.getParams();
|
||||
|
||||
// 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];
|
||||
const params = [...parentParams, ...pathParams];
|
||||
|
||||
if (parent.params.some(p => pathParams.includes(p as string))) {
|
||||
if (parentParams.some(p => pathParams.includes(p as string))) {
|
||||
throw new Error(
|
||||
'SubRouteRef may not have params that overlap with its parent',
|
||||
);
|
||||
@@ -136,11 +196,10 @@ export function createSubRouteRef<
|
||||
|
||||
// We ensure that the type of the return type is sane here
|
||||
const subRouteRef = new SubRouteRefImpl(
|
||||
id,
|
||||
path,
|
||||
params as string[],
|
||||
parent,
|
||||
params as ParamKeys<MergeParams<Params, ParentParams>>,
|
||||
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
|
||||
) as SubRouteRef<TrimEmptyParams<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.
|
||||
|
||||
@@ -70,25 +70,6 @@ export type OptionalParams<Params extends { [param in string]: string }> =
|
||||
export type ParamKeys<TParams extends AnyRouteParams> =
|
||||
keyof TParams extends never ? [] : (keyof TParams)[];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user