core-api: add support for external route ref parameters and refactor route ref parameter types
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -50,23 +50,30 @@ describe('generateBoundRoutes', () => {
|
||||
|
||||
describe('Integration Test', () => {
|
||||
const plugin1RouteRef = createRouteRef({ path: '/blah1', title: '' });
|
||||
const plugin2RouteRef = createRouteRef({ path: '/blah2', title: '' });
|
||||
const externalRouteRef = createExternalRouteRef({ id: '3' });
|
||||
const optionalBarExternalRouteRef = createExternalRouteRef({
|
||||
id: 'bar',
|
||||
const plugin2RouteRef = createRouteRef({
|
||||
path: '/blah2',
|
||||
title: '',
|
||||
params: ['x'],
|
||||
});
|
||||
const err = createExternalRouteRef({ id: 'err' });
|
||||
const errParams = createExternalRouteRef({ id: 'errParams', params: ['x'] });
|
||||
const errOptional = createExternalRouteRef({
|
||||
id: 'errOptional',
|
||||
optional: true,
|
||||
});
|
||||
const optionalBazExternalRouteRef = createExternalRouteRef({
|
||||
id: 'baz',
|
||||
const errParamsOptional = createExternalRouteRef({
|
||||
id: 'errParamsOptional',
|
||||
optional: true,
|
||||
params: ['x'],
|
||||
});
|
||||
|
||||
const plugin1 = createPlugin({
|
||||
id: 'blob',
|
||||
externalRoutes: {
|
||||
foo: externalRouteRef,
|
||||
bar: optionalBarExternalRouteRef,
|
||||
baz: optionalBazExternalRouteRef,
|
||||
err,
|
||||
errParams,
|
||||
errOptional,
|
||||
errParamsOptional,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -85,14 +92,19 @@ describe('Integration Test', () => {
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
Promise.resolve((_: PropsWithChildren<{ path?: string }>) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const externalLink = useRouteRef(externalRouteRef);
|
||||
const barLink = useRouteRef(optionalBarExternalRouteRef);
|
||||
const bazLink = useRouteRef(optionalBazExternalRouteRef);
|
||||
const errLink = useRouteRef(err);
|
||||
const errParamsLink = useRouteRef(errParams);
|
||||
const errOptionalLink = useRouteRef(errOptional);
|
||||
const errParamsOptionalLink = useRouteRef(errParamsOptional);
|
||||
return (
|
||||
<div>
|
||||
Our routes are: {externalLink()}, bar: {barLink?.() ?? 'none'},
|
||||
baz: {bazLink?.() ?? 'none'}
|
||||
<span>err: {errLink()}</span>
|
||||
<span>errParams: {errParamsLink({ x: 'a' })}</span>
|
||||
<span>errOptional: {errOptionalLink?.() ?? '<none>'}</span>
|
||||
<span>
|
||||
errParamsOptional:{' '}
|
||||
{errParamsOptionalLink?.({ x: 'b' }) ?? '<none>'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
@@ -100,14 +112,14 @@ describe('Integration Test', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
it('runs happy paths', async () => {
|
||||
const components = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
};
|
||||
const components = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
};
|
||||
|
||||
it('runs happy paths', async () => {
|
||||
const app = new PrivateAppImpl({
|
||||
apis: [],
|
||||
defaultApis: [],
|
||||
@@ -124,8 +136,10 @@ describe('Integration Test', () => {
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
foo: plugin2RouteRef,
|
||||
bar: plugin2RouteRef,
|
||||
err: plugin1RouteRef,
|
||||
errParams: plugin2RouteRef,
|
||||
errOptional: plugin1RouteRef,
|
||||
errParamsOptional: plugin2RouteRef,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -138,25 +152,19 @@ describe('Integration Test', () => {
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo/bar" />
|
||||
<HiddenComponent path="/foo" />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Our routes are: /foo/bar, bar: /foo/bar, baz: none'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('err: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParams: /foo')).toBeInTheDocument();
|
||||
expect(screen.getByText('errOptional: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParamsOptional: /foo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw some error when the route has duplicate params', () => {
|
||||
const components = {
|
||||
NotFoundErrorPage: () => null,
|
||||
BootErrorPage: () => null,
|
||||
Progress: () => null,
|
||||
Router: BrowserRouter,
|
||||
};
|
||||
|
||||
it('runs happy paths without optional routes', async () => {
|
||||
const app = new PrivateAppImpl({
|
||||
apis: [],
|
||||
defaultApis: [],
|
||||
@@ -172,7 +180,53 @@ describe('Integration Test', () => {
|
||||
plugins: [],
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, { foo: plugin2RouteRef });
|
||||
bind(plugin1.externalRoutes, {
|
||||
err: plugin1RouteRef,
|
||||
errParams: plugin2RouteRef,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const Provider = app.getProvider();
|
||||
const Router = app.getRouter();
|
||||
|
||||
await renderWithEffects(
|
||||
<Provider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<ExposedComponent path="/" />
|
||||
<HiddenComponent path="/foo" />
|
||||
</Routes>
|
||||
</Router>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('err: /')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParams: /foo')).toBeInTheDocument();
|
||||
expect(screen.getByText('errOptional: <none>')).toBeInTheDocument();
|
||||
expect(screen.getByText('errParamsOptional: <none>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should throw some error when the route has duplicate params', () => {
|
||||
const app = new PrivateAppImpl({
|
||||
apis: [],
|
||||
defaultApis: [],
|
||||
themes: [
|
||||
{
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
},
|
||||
],
|
||||
icons: defaultSystemIcons,
|
||||
plugins: [],
|
||||
components,
|
||||
bindRoutes: ({ bind }) => {
|
||||
bind(plugin1.externalRoutes, {
|
||||
err: plugin1RouteRef,
|
||||
errParams: plugin2RouteRef,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../extensions/traversal';
|
||||
import { IconComponent, IconComponentMap, IconKey } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { AnyRoutes } from '../plugin/types';
|
||||
import { RouteRef, ExternalRouteRef } from '../routing';
|
||||
import {
|
||||
routeObjectCollector,
|
||||
@@ -77,7 +78,7 @@ export function generateBoundRoutes(
|
||||
const result = new Map<ExternalRouteRef, RouteRef>();
|
||||
|
||||
if (bindRoutes) {
|
||||
const bind: AppRouteBinder = (externalRoutes, targetRoutes) => {
|
||||
const bind: AppRouteBinder = (externalRoutes, targetRoutes: AnyRoutes) => {
|
||||
for (const [key, value] of Object.entries(targetRoutes)) {
|
||||
const externalRoute = externalRoutes[key];
|
||||
if (!externalRoute) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { IconComponent, IconComponentMap, IconKey } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin/types';
|
||||
import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types';
|
||||
import { ExternalRouteRef, RouteRef } from '../routing';
|
||||
import { AnyApiFactory } from '../apis';
|
||||
import { AppTheme, ProfileInfo } from '../apis/definitions';
|
||||
@@ -79,49 +79,39 @@ export type AppComponents = {
|
||||
*/
|
||||
export type AppConfigLoader = () => Promise<AppConfig[]>;
|
||||
|
||||
/**
|
||||
* Extracts the Optional type of a map of ExternalRouteRefs, leaving only the boolean in place as value
|
||||
*/
|
||||
type ExternalRouteRefsToOptionalMap<
|
||||
T extends { [name in string]: ExternalRouteRef<boolean> }
|
||||
> = {
|
||||
[name in keyof T]: T[name] extends ExternalRouteRef<infer U> ? U : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts a union of the keys in a map whose value extends the given type
|
||||
*/
|
||||
type ExtractKeysWithType<Obj extends { [key in string]: any }, Type> = {
|
||||
type KeysWithType<Obj extends { [key in string]: any }, Type> = {
|
||||
[key in keyof Obj]: Obj[key] extends Type ? key : never;
|
||||
}[keyof Obj];
|
||||
|
||||
/**
|
||||
* Given a map of boolean values denoting whether a route is optional, create a
|
||||
* map of needed RouteRefs.
|
||||
*
|
||||
* For example { foo: false, bar: true } gives { foo: RouteRef<any>, bar?: RouteRef<any> }
|
||||
* Takes a map Map required values and makes all keys matching Keys optional
|
||||
*/
|
||||
type CombineOptionalAndRequiredRoutes<
|
||||
OptionalMap extends { [key in string]: boolean }
|
||||
> = {
|
||||
[name in ExtractKeysWithType<OptionalMap, false>]: RouteRef<any>;
|
||||
} &
|
||||
{ [name in keyof OptionalMap]?: RouteRef<any> };
|
||||
type PartialKeys<
|
||||
Map extends { [name in string]: any },
|
||||
Keys extends keyof Map
|
||||
> = Partial<Pick<Map, Keys>> & Required<Omit<Map, Keys>>;
|
||||
|
||||
/**
|
||||
* Creates a map of required target routes based on whether the input external
|
||||
* routes are optional or not. The external routes that are marked as optional
|
||||
* will also be optional in the target routes map.
|
||||
* Creates a map of target routes with matching parameters based on a map of external routes.
|
||||
*/
|
||||
type TargetRoutesMap<
|
||||
T extends { [name in string]: ExternalRouteRef<boolean> }
|
||||
> = CombineOptionalAndRequiredRoutes<ExternalRouteRefsToOptionalMap<T>>;
|
||||
type TargetRouteMap<ExternalRoutes extends AnyExternalRoutes> = {
|
||||
[name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef<
|
||||
infer Params,
|
||||
any
|
||||
>
|
||||
? RouteRef<Params>
|
||||
: never;
|
||||
};
|
||||
|
||||
export type AppRouteBinder = <
|
||||
ExternalRoutes extends { [name in string]: ExternalRouteRef<boolean> }
|
||||
>(
|
||||
export type AppRouteBinder = <ExternalRoutes extends AnyExternalRoutes>(
|
||||
externalRoutes: ExternalRoutes,
|
||||
targetRoutes: TargetRoutesMap<ExternalRoutes>,
|
||||
targetRoutes: PartialKeys<
|
||||
TargetRouteMap<ExternalRoutes>,
|
||||
KeysWithType<ExternalRoutes, ExternalRouteRef<any, true>>
|
||||
>,
|
||||
) => void;
|
||||
|
||||
export type AppOptions = {
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRef, ExternalRouteRef, routeRefType } from './types';
|
||||
import {
|
||||
RouteRef,
|
||||
ExternalRouteRef,
|
||||
routeRefType,
|
||||
AnyParams,
|
||||
ParamKeys,
|
||||
} from './types';
|
||||
import { IconComponent } from '../icons';
|
||||
|
||||
// TODO(Rugvip): Remove this once we get rid of the deprecated fields, it's not exported
|
||||
export type RouteRefConfig<Params extends { [param in string]: string }> = {
|
||||
params?: Array<keyof Params>;
|
||||
export type RouteRefConfig<Params extends AnyParams> = {
|
||||
params?: ParamKeys<Params>;
|
||||
path?: string;
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
@@ -31,15 +37,17 @@ class RouteRefBaseBase {
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteRefImpl<
|
||||
Params extends { [param in string]: string }
|
||||
> extends RouteRefBaseBase {
|
||||
export class RouteRefImpl<Params extends AnyParams> extends RouteRefBaseBase {
|
||||
readonly [routeRefType] = 'absolute';
|
||||
|
||||
constructor(private readonly config: RouteRefConfig<Params>) {
|
||||
super('absolute', config.title);
|
||||
}
|
||||
|
||||
get params(): ParamKeys<Params> {
|
||||
return this.config.params as any;
|
||||
}
|
||||
|
||||
get icon() {
|
||||
return this.config.icon;
|
||||
}
|
||||
@@ -54,6 +62,10 @@ export class RouteRefImpl<
|
||||
}
|
||||
}
|
||||
|
||||
type OptionalParams<
|
||||
Params extends { [param in string]: string }
|
||||
> = Params[keyof Params] extends never ? undefined : Params;
|
||||
|
||||
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}
|
||||
@@ -70,28 +82,43 @@ export function createRouteRef<
|
||||
icon?: IconComponent;
|
||||
/** @deprecated Route refs no longer decide their own title */
|
||||
title: string;
|
||||
}): RouteRef<Params> {
|
||||
return new RouteRefImpl<Params>(config);
|
||||
}): RouteRef<OptionalParams<Params>> {
|
||||
return new RouteRefImpl<OptionalParams<Params>>({
|
||||
...config,
|
||||
params: (config.params ?? []) as ParamKeys<OptionalParams<Params>>,
|
||||
});
|
||||
}
|
||||
|
||||
export class ExternalRouteRefImpl<
|
||||
Params extends AnyParams,
|
||||
Optional extends boolean
|
||||
> extends RouteRefBaseBase {
|
||||
readonly [routeRefType] = 'external';
|
||||
|
||||
constructor(id: string, readonly optional: Optional) {
|
||||
constructor(
|
||||
id: string,
|
||||
readonly params: ParamKeys<Params>,
|
||||
readonly optional: Optional,
|
||||
) {
|
||||
super('external', id);
|
||||
}
|
||||
}
|
||||
|
||||
export function createExternalRouteRef<
|
||||
Optional extends boolean = false
|
||||
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.
|
||||
*
|
||||
@@ -99,9 +126,10 @@ export function createExternalRouteRef<
|
||||
* if they aren't, `useRouteRef` will return `undefined`.
|
||||
*/
|
||||
optional?: Optional;
|
||||
}): ExternalRouteRef<Optional> {
|
||||
return new ExternalRouteRefImpl<Optional>(
|
||||
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
|
||||
return new ExternalRouteRefImpl<OptionalParams<Params>, Optional>(
|
||||
options.id,
|
||||
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
|
||||
Boolean(options.optional) as Optional,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,12 +57,19 @@ const ref1 = createRouteRef(mockConfig({ path: '/wat1' }));
|
||||
const ref2 = createRouteRef(mockConfig({ path: '/wat2' }));
|
||||
const ref3 = createRouteRef(mockConfig({ path: '/wat3' }));
|
||||
const ref4 = createRouteRef(mockConfig({ path: '/wat4' }));
|
||||
const ref5 = createRouteRef(mockConfig({ path: '/wat5' }));
|
||||
const ref5 = createRouteRef({
|
||||
...mockConfig({ path: '/wat5' }),
|
||||
params: ['x'],
|
||||
});
|
||||
const eRefA = createExternalRouteRef({ id: '1' });
|
||||
const eRefB = createExternalRouteRef({ id: '2' });
|
||||
const eRefC = createExternalRouteRef({ id: '3' });
|
||||
const eRefC = createExternalRouteRef({ id: '3', params: ['y'] });
|
||||
const eRefD = createExternalRouteRef({ id: '4', optional: true });
|
||||
const eRefE = createExternalRouteRef({ id: '5', optional: true });
|
||||
const eRefE = createExternalRouteRef({
|
||||
id: '5',
|
||||
optional: true,
|
||||
params: ['z'],
|
||||
});
|
||||
|
||||
const MockRouteSource = <T extends { [name in string]: string }>(props: {
|
||||
path?: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
BackstageRouteObject,
|
||||
RouteRef,
|
||||
ExternalRouteRef,
|
||||
AnyParams,
|
||||
} from './types';
|
||||
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -28,10 +29,8 @@ import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
|
||||
// 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.
|
||||
export type RouteFunc<Params extends { [param in string]: string }> = (
|
||||
...[params]: Params[keyof Params] extends never
|
||||
? readonly []
|
||||
: readonly [Params]
|
||||
export type RouteFunc<Params extends AnyParams> = (
|
||||
...[params]: Params extends undefined ? readonly [] : readonly [Params]
|
||||
) => string;
|
||||
|
||||
class RouteResolver {
|
||||
@@ -42,8 +41,8 @@ class RouteResolver {
|
||||
private readonly routeBindings: Map<RouteRef | ExternalRouteRef, RouteRef>,
|
||||
) {}
|
||||
|
||||
resolve<Params extends { [param in string]: string }>(
|
||||
routeRefOrExternalRouteRef: RouteRef<Params> | ExternalRouteRef,
|
||||
resolve<Params extends AnyParams>(
|
||||
routeRefOrExternalRouteRef: RouteRef<Params> | ExternalRouteRef<Params>,
|
||||
sourceLocation: ReturnType<typeof useLocation>,
|
||||
): RouteFunc<Params> | undefined {
|
||||
const routeRef =
|
||||
@@ -116,14 +115,14 @@ class RouteResolver {
|
||||
|
||||
const RoutingContext = createContext<RouteResolver | undefined>(undefined);
|
||||
|
||||
export function useRouteRef<Optional extends boolean>(
|
||||
routeRef: ExternalRouteRef<Optional>,
|
||||
): Optional extends true ? RouteFunc<{}> | undefined : RouteFunc<{}>;
|
||||
export function useRouteRef<Params extends { [param in string]: string } = {}>(
|
||||
export function useRouteRef<Optional extends boolean, Params extends AnyParams>(
|
||||
routeRef: ExternalRouteRef<Params, Optional>,
|
||||
): Optional extends true ? RouteFunc<Params> | undefined : RouteFunc<Params>;
|
||||
export function useRouteRef<Params extends AnyParams>(
|
||||
routeRef: RouteRef<Params>,
|
||||
): RouteFunc<Params>;
|
||||
export function useRouteRef<Params extends { [param in string]: string } = {}>(
|
||||
routeRef: RouteRef<Params> | ExternalRouteRef,
|
||||
export function useRouteRef<Params extends AnyParams>(
|
||||
routeRef: RouteRef<Params> | ExternalRouteRef<Params, any>,
|
||||
): RouteFunc<Params> | undefined {
|
||||
const sourceLocation = useLocation();
|
||||
const resolver = useContext(RoutingContext);
|
||||
|
||||
@@ -17,15 +17,20 @@
|
||||
import { IconComponent } from '../icons';
|
||||
import { getGlobalSingleton } from '../lib/globalObject';
|
||||
|
||||
export type AnyParams = { [param in string]: string } | undefined;
|
||||
export type ParamKeys<Params extends AnyParams> = keyof Params extends never
|
||||
? []
|
||||
: (keyof Params)[];
|
||||
|
||||
export const routeRefType: unique symbol = getGlobalSingleton<any>(
|
||||
'route-ref-type',
|
||||
() => Symbol('route-ref-type'),
|
||||
);
|
||||
|
||||
export type RouteRef<Params extends { [param in string]: string } = any> = {
|
||||
export type RouteRef<Params extends AnyParams = any> = {
|
||||
[routeRefType]: 'absolute';
|
||||
|
||||
params?: keyof Params;
|
||||
params: ParamKeys<Params>;
|
||||
|
||||
// TODO(Rugvip): Remove all of these once plugins don't rely on the path
|
||||
/** @deprecated paths are no longer accessed directly from RouteRefs, use useRouteRef instead */
|
||||
@@ -36,13 +41,18 @@ export type RouteRef<Params extends { [param in string]: string } = any> = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type ExternalRouteRef<Optional extends boolean = any> = {
|
||||
export type ExternalRouteRef<
|
||||
Params extends AnyParams = any,
|
||||
Optional extends boolean = any
|
||||
> = {
|
||||
[routeRefType]: 'external';
|
||||
|
||||
params: ParamKeys<Params>;
|
||||
|
||||
optional?: Optional;
|
||||
};
|
||||
|
||||
export type AnyRouteRef = RouteRef<any> | ExternalRouteRef<any>;
|
||||
export type AnyRouteRef = RouteRef<any> | ExternalRouteRef<any, any>;
|
||||
|
||||
// TODO(Rugvip): None of these should be found in the wild anymore, remove in next minor release
|
||||
/** @deprecated */
|
||||
|
||||
Reference in New Issue
Block a user