frontend-plugin-api: refactor ExternalRouteRef to modern structure
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -14,97 +14,94 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AnyParams, ExternalRouteRef } from './types';
|
||||
import { createExternalRouteRef } from './ExternalRouteRef';
|
||||
import {
|
||||
ExternalRouteRef,
|
||||
createExternalRouteRef,
|
||||
toInternalExternalRouteRef,
|
||||
} from './ExternalRouteRef';
|
||||
import { AnyRouteParams } from './types';
|
||||
|
||||
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}');
|
||||
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef();
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
expect(internal.optional).toBe(false);
|
||||
expect(String(internal)).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',
|
||||
const routeRef: ExternalRouteRef<undefined, true> = createExternalRouteRef({
|
||||
params: [],
|
||||
optional: true,
|
||||
});
|
||||
expect(routeRef.params).toEqual([]);
|
||||
expect(routeRef.optional).toEqual(true);
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
expect(internal.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);
|
||||
}> = createExternalRouteRef({ params: ['x', 'y'] });
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual(['x', 'y']);
|
||||
expect(internal.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);
|
||||
}> = createExternalRouteRef({ params: ['x', 'y'], optional: true });
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
expect(internal.getParams()).toEqual(['x', 'y']);
|
||||
expect(internal.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>,
|
||||
function checkRouteRef<
|
||||
T extends AnyRouteParams,
|
||||
TOptional extends boolean,
|
||||
TCheck extends TOptional,
|
||||
>(
|
||||
_ref: ExternalRouteRef<T, TOptional>,
|
||||
_params: T extends undefined ? undefined : T,
|
||||
_optional: TCheck,
|
||||
) {}
|
||||
|
||||
const _1 = createExternalRouteRef({ id: '1', params: ['notX'] });
|
||||
const _1 = createExternalRouteRef({ params: ['notX'] });
|
||||
checkRouteRef(_1, { notX: '' }, false);
|
||||
// @ts-expect-error
|
||||
validateType<{ x: string }, any>(_1);
|
||||
validateType<{ notX: string }, any>(_1);
|
||||
checkRouteRef(_1, { x: '' }, false);
|
||||
|
||||
const _2 = createExternalRouteRef({
|
||||
id: '2',
|
||||
params: ['x'],
|
||||
optional: true,
|
||||
});
|
||||
const _2 = createExternalRouteRef({ params: ['x'], optional: true });
|
||||
checkRouteRef(_2, { x: '' }, true);
|
||||
// @ts-expect-error
|
||||
validateType<undefined, any>(_2);
|
||||
validateType<{ x: string }, true>(_2);
|
||||
checkRouteRef(_2, undefined, false);
|
||||
|
||||
const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] });
|
||||
const _3 = createExternalRouteRef({ params: ['x', 'y'] });
|
||||
checkRouteRef(_3, { x: '', y: '' }, false);
|
||||
// @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);
|
||||
checkRouteRef(_3, { x: '' }, false);
|
||||
// @ts-expect-error
|
||||
checkRouteRef(_3, { x: '', y: '', z: '' }, false);
|
||||
|
||||
const _4 = createExternalRouteRef({ id: '4', params: [] });
|
||||
const _4 = createExternalRouteRef({ params: [] });
|
||||
checkRouteRef(_4, undefined, false);
|
||||
// @ts-expect-error
|
||||
validateType<{ x: string }, any>(_4);
|
||||
validateType<undefined, false>(_4);
|
||||
checkRouteRef<any>(_4, { x: '' });
|
||||
|
||||
const _5 = createExternalRouteRef({ id: '5' });
|
||||
const _5 = createExternalRouteRef();
|
||||
checkRouteRef(_5, undefined, false);
|
||||
// @ts-expect-error
|
||||
validateType<{ x: string }, any>(_5);
|
||||
validateType<undefined, false>(_5);
|
||||
checkRouteRef<any>(_5, { x: '' });
|
||||
|
||||
const _6 = createExternalRouteRef({ id: '6', optional: true });
|
||||
const _6 = createExternalRouteRef({ optional: true });
|
||||
checkRouteRef(_6, undefined, true);
|
||||
// @ts-expect-error
|
||||
validateType<undefined, false>(_6);
|
||||
validateType<undefined, true>(_6);
|
||||
checkRouteRef(_6, undefined, false);
|
||||
|
||||
// To avoid complains about missing expectations and unused vars
|
||||
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
|
||||
|
||||
@@ -14,34 +14,78 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ExternalRouteRef,
|
||||
routeRefType,
|
||||
AnyParams,
|
||||
ParamKeys,
|
||||
OptionalParams,
|
||||
} from './types';
|
||||
import { AnyRouteParams } from './types';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* 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 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';
|
||||
export interface ExternalRouteRef<
|
||||
TParams extends AnyRouteParams = AnyRouteParams,
|
||||
TOptional extends boolean = boolean,
|
||||
> {
|
||||
readonly $$type: '@backstage/ExternalRouteRef';
|
||||
readonly T: TParams;
|
||||
readonly optional: TOptional;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
readonly params: ParamKeys<Params>,
|
||||
readonly optional: Optional,
|
||||
) {}
|
||||
/** @internal */
|
||||
export interface InternalExternalRouteRef<
|
||||
TParams extends AnyRouteParams = AnyRouteParams,
|
||||
TOptional extends boolean = boolean,
|
||||
> extends ExternalRouteRef<TParams, TOptional> {
|
||||
readonly version: 'v1';
|
||||
getParams(): string[];
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `routeRef{type=external,id=${this.id}}`;
|
||||
/** @internal */
|
||||
export function toInternalExternalRouteRef<
|
||||
TParams extends AnyRouteParams = AnyRouteParams,
|
||||
TOptional extends boolean = boolean,
|
||||
>(
|
||||
resource: ExternalRouteRef<TParams, TOptional>,
|
||||
): InternalExternalRouteRef<TParams, TOptional> {
|
||||
const r = resource as InternalExternalRouteRef<TParams, TOptional>;
|
||||
if (r.$$type !== '@backstage/ExternalRouteRef') {
|
||||
throw new Error(`Invalid ExternalRouteRef, bad type '${r.$$type}'`);
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function isExternalRouteRef(opaque: {
|
||||
$$type: string;
|
||||
}): opaque is ExternalRouteRef {
|
||||
return opaque.$$type === '@backstage/ExternalRouteRef';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class ExternalRouteRefImpl implements InternalExternalRouteRef {
|
||||
readonly $$type = '@backstage/ExternalRouteRef';
|
||||
readonly version = 'v1';
|
||||
|
||||
#params: string[];
|
||||
|
||||
constructor(readonly optional: boolean, readonly params: string[] = []) {
|
||||
this.#params = params;
|
||||
}
|
||||
|
||||
get T(): never {
|
||||
throw new Error(`tried to read ExternalRouteRef.T of ${this}`);
|
||||
}
|
||||
|
||||
getParams(): string[] {
|
||||
return this.#params;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `ExternalRouteRef{}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,31 +100,34 @@ export class ExternalRouteRefImpl<
|
||||
* @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;
|
||||
|
||||
TParams extends { [param in TParamKeys]: string } | undefined = undefined,
|
||||
TOptional extends boolean = false,
|
||||
TParamKeys extends string = string,
|
||||
>(options?: {
|
||||
/**
|
||||
* The parameters that will be provided to the external route reference.
|
||||
*/
|
||||
params?: ParamKey[];
|
||||
readonly params?: string extends TParamKeys
|
||||
? (keyof TParams)[]
|
||||
: TParamKeys[];
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* if they aren't, `useExternalRouteRef` will return `undefined`.
|
||||
*/
|
||||
optional?: Optional;
|
||||
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
|
||||
optional?: TOptional;
|
||||
}): ExternalRouteRef<
|
||||
keyof TParams extends never
|
||||
? undefined
|
||||
: string extends TParamKeys
|
||||
? TParams
|
||||
: { [param in TParamKeys]: string },
|
||||
TOptional
|
||||
> {
|
||||
return new ExternalRouteRefImpl(
|
||||
options.id,
|
||||
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
|
||||
Boolean(options.optional) as Optional,
|
||||
);
|
||||
Boolean(options?.optional),
|
||||
options?.params as string[] | undefined,
|
||||
) as ExternalRouteRef<any, any>;
|
||||
}
|
||||
|
||||
@@ -70,26 +70,6 @@ export type OptionalParams<Params extends { [param in string]: string }> =
|
||||
export type ParamKeys<TParams extends AnyRouteParams> =
|
||||
keyof TParams extends never ? [] : (keyof TParams)[];
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* A duplicate of the react-router RouteObject, but with routeRef added
|
||||
* @internal
|
||||
|
||||
Reference in New Issue
Block a user