Merge pull request #4792 from backstage/feat/external-route-ref
core-api: add parameters to external type refs + reactor types
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
---
|
||||
'@backstage/plugin-explore': minor
|
||||
---
|
||||
|
||||
Introduce external route for linking to the entity page from the explore plugin.
|
||||
|
||||
To use the explore plugin you have to bind the external route in your app:
|
||||
|
||||
```typescript
|
||||
const app = createApp({
|
||||
...
|
||||
bindRoutes({ bind }) {
|
||||
...
|
||||
bind(explorePlugin.externalRoutes, {
|
||||
catalogEntity: catalogPlugin.routes.catalogEntity,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Introduce parameters for namespace, kind, and name to `entityRouteRef`.
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
CostInsightsPage,
|
||||
CostInsightsProjectGrowthInstructionsPage,
|
||||
} from '@backstage/plugin-cost-insights';
|
||||
import { ExplorePage } from '@backstage/plugin-explore';
|
||||
import { ExplorePage, explorePlugin } from '@backstage/plugin-explore';
|
||||
import { GcpProjectsPage } from '@backstage/plugin-gcp-projects';
|
||||
import { GraphiQLPage } from '@backstage/plugin-graphiql';
|
||||
import { LighthousePage } from '@backstage/plugin-lighthouse';
|
||||
@@ -79,6 +79,9 @@ const app = createApp({
|
||||
bind(apiDocsPlugin.externalRoutes, {
|
||||
createComponent: scaffolderPlugin.routes.root,
|
||||
});
|
||||
bind(explorePlugin.externalRoutes, {
|
||||
catalogEntity: catalogPlugin.routes.catalogEntity,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,14 +50,14 @@ import {
|
||||
} from '../extensions/traversal';
|
||||
import { IconComponent, IconComponentMap, IconKey } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { RouteRef } from '../routing';
|
||||
import { AnyRoutes } from '../plugin/types';
|
||||
import { RouteRef, ExternalRouteRef } from '../routing';
|
||||
import {
|
||||
routeObjectCollector,
|
||||
routeParentCollector,
|
||||
routePathCollector,
|
||||
} from '../routing/collectors';
|
||||
import { RoutingProvider, validateRoutes } from '../routing/hooks';
|
||||
import { ExternalRouteRef } from '../routing/RouteRef';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { AppIdentity } from './AppIdentity';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
@@ -78,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 = {
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { RouteRef } from '../routing';
|
||||
import { RouteRef, ExternalRouteRef } from '../routing';
|
||||
import { AnyApiFactory } from '../apis/system';
|
||||
import { ExternalRouteRef } from '../routing/RouteRef';
|
||||
|
||||
export type RouteOptions = {
|
||||
// Whether the route path must match exactly, defaults to true.
|
||||
|
||||
@@ -14,19 +14,40 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRef } from './types';
|
||||
import {
|
||||
RouteRef,
|
||||
ExternalRouteRef,
|
||||
routeRefType,
|
||||
AnyParams,
|
||||
ParamKeys,
|
||||
} from './types';
|
||||
import { IconComponent } from '../icons';
|
||||
|
||||
export type RouteRefConfig<Params extends { [param in string]: string }> = {
|
||||
params?: Array<keyof Params>;
|
||||
/** @deprecated Route refs no longer decide their own path */
|
||||
// TODO(Rugvip): Remove this once we get rid of the deprecated fields, it's not exported
|
||||
export type RouteRefConfig<Params extends AnyParams> = {
|
||||
params?: ParamKeys<Params>;
|
||||
path?: string;
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export class AbsoluteRouteRef<Params extends { [param in string]: string }> {
|
||||
constructor(private readonly config: RouteRefConfig<Params>) {}
|
||||
class RouteRefBase {
|
||||
constructor(type: string, id: string) {
|
||||
this.toString = () => `routeRef{type=${type},id=${id}}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteRefImpl<Params extends AnyParams> extends RouteRefBase
|
||||
implements RouteRef<Params> {
|
||||
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;
|
||||
@@ -40,12 +61,12 @@ export class AbsoluteRouteRef<Params extends { [param in string]: string }> {
|
||||
get title() {
|
||||
return this.config.title;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `routeRef{title=${this.title}}`;
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
@@ -58,27 +79,47 @@ export function createRouteRef<
|
||||
params?: ParamKey[];
|
||||
/** @deprecated Route refs no longer decide their own path */
|
||||
path?: string;
|
||||
/** @deprecated Route refs no longer decide their own icon */
|
||||
icon?: IconComponent;
|
||||
/** @deprecated Route refs no longer decide their own title */
|
||||
title: string;
|
||||
}): RouteRef<Params> {
|
||||
return new AbsoluteRouteRef<Params>(config);
|
||||
}): RouteRef<OptionalParams<Params>> {
|
||||
return new RouteRefImpl<OptionalParams<Params>>({
|
||||
...config,
|
||||
params: (config.params ?? []) as ParamKeys<OptionalParams<Params>>,
|
||||
});
|
||||
}
|
||||
|
||||
export class ExternalRouteRef<Optional extends boolean = true> {
|
||||
readonly optional: boolean;
|
||||
export class ExternalRouteRefImpl<
|
||||
Params extends AnyParams,
|
||||
Optional extends boolean
|
||||
> extends RouteRefBase implements ExternalRouteRef<Params, Optional> {
|
||||
readonly [routeRefType] = 'external';
|
||||
|
||||
private constructor({ id, optional }: ExternalRouteRefOptions<Optional>) {
|
||||
this.toString = () => `externalRouteRef{${id}}`;
|
||||
this.optional = Boolean(optional);
|
||||
constructor(
|
||||
id: string,
|
||||
readonly params: ParamKeys<Params>,
|
||||
readonly optional: Optional,
|
||||
) {
|
||||
super('external', id);
|
||||
}
|
||||
}
|
||||
|
||||
export type ExternalRouteRefOptions<Optional extends boolean = false> = {
|
||||
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.
|
||||
*
|
||||
@@ -86,14 +127,10 @@ export type ExternalRouteRefOptions<Optional extends boolean = false> = {
|
||||
* if they aren't, `useRouteRef` will return `undefined`.
|
||||
*/
|
||||
optional?: Optional;
|
||||
};
|
||||
|
||||
export function createExternalRouteRef<Optional extends boolean = false>(
|
||||
options: ExternalRouteRefOptions<Optional>,
|
||||
): ExternalRouteRef<Optional> {
|
||||
return new ((ExternalRouteRef as unknown) as {
|
||||
new (options: ExternalRouteRefOptions<Optional>): ExternalRouteRef<
|
||||
Optional
|
||||
>;
|
||||
})(options);
|
||||
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
|
||||
return new ExternalRouteRefImpl<OptionalParams<Params>, Optional>(
|
||||
options.id,
|
||||
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
|
||||
Boolean(options.optional) as Optional,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,9 @@ import {
|
||||
import {
|
||||
createRouteRef,
|
||||
createExternalRouteRef,
|
||||
ExternalRouteRef,
|
||||
RouteRefConfig,
|
||||
} from './RouteRef';
|
||||
import { AnyRouteRef, RouteRef } from './types';
|
||||
import { AnyRouteRef, RouteRef, ExternalRouteRef } from './types';
|
||||
|
||||
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
|
||||
path: '/unused',
|
||||
@@ -58,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;
|
||||
|
||||
@@ -15,19 +15,22 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
import { AnyRouteRef, BackstageRouteObject, RouteRef } from './types';
|
||||
import {
|
||||
AnyRouteRef,
|
||||
BackstageRouteObject,
|
||||
RouteRef,
|
||||
ExternalRouteRef,
|
||||
AnyParams,
|
||||
} from './types';
|
||||
import { generatePath, matchRoutes, useLocation } from 'react-router-dom';
|
||||
import { ExternalRouteRef } from './RouteRef';
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
@@ -38,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 =
|
||||
@@ -112,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);
|
||||
|
||||
@@ -19,12 +19,9 @@ export type {
|
||||
AbsoluteRouteRef,
|
||||
ConcreteRoute,
|
||||
MutableRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from './types';
|
||||
export { FlatRoutes } from './FlatRoutes';
|
||||
export {
|
||||
createRouteRef,
|
||||
createExternalRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from './RouteRef';
|
||||
export { createRouteRef, createExternalRouteRef } from './RouteRef';
|
||||
export type { RouteRefConfig } from './RouteRef';
|
||||
export { useRouteRef } from './hooks';
|
||||
|
||||
@@ -15,35 +15,51 @@
|
||||
*/
|
||||
|
||||
import { IconComponent } from '../icons';
|
||||
import { ExternalRouteRef } from './RouteRef';
|
||||
import { getGlobalSingleton } from '../lib/globalObject';
|
||||
|
||||
// @ts-ignore, we're just embedding the Params type for usage in other places
|
||||
export type RouteRef<Params extends { [param in string]: string } = {}> = {
|
||||
// TODO(Rugvip): Remove path, look up via registry instead
|
||||
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 AnyParams = any> = {
|
||||
readonly [routeRefType]: 'absolute';
|
||||
|
||||
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 */
|
||||
path: string;
|
||||
/** @deprecated icons are no longer accessed via RouteRefs */
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
/** @deprecated titles are no longer accessed via RouteRefs */
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type AnyRouteRef = RouteRef<any> | ExternalRouteRef<any>;
|
||||
export type ExternalRouteRef<
|
||||
Params extends AnyParams = any,
|
||||
Optional extends boolean = any
|
||||
> = {
|
||||
readonly [routeRefType]: 'external';
|
||||
|
||||
/**
|
||||
* This type should not be used
|
||||
* @deprecated
|
||||
*/
|
||||
params: ParamKeys<Params>;
|
||||
|
||||
optional?: Optional;
|
||||
};
|
||||
|
||||
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 */
|
||||
export type ConcreteRoute = {};
|
||||
|
||||
/**
|
||||
* This type should not be used, use RouteRef instead
|
||||
* @deprecated
|
||||
*/
|
||||
/** @deprecated */
|
||||
export type AbsoluteRouteRef = RouteRef<{}>;
|
||||
|
||||
/**
|
||||
* This type should not be used, use RouteRef instead
|
||||
* @deprecated
|
||||
*/
|
||||
/** @deprecated */
|
||||
export type MutableRouteRef = RouteRef<{}>;
|
||||
|
||||
// A duplicate of the react-router RouteObject, but with routeRef added
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
*/
|
||||
|
||||
import { DomainEntity } from '@backstage/catalog-model';
|
||||
import { render } from '@testing-library/react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { catalogEntityRouteRef } from '../../routes';
|
||||
import { DomainCard } from './DomainCard';
|
||||
|
||||
describe('<DomainCard />', () => {
|
||||
it('renders a domain card', () => {
|
||||
it('renders a domain card', async () => {
|
||||
const entity: DomainEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Domain',
|
||||
@@ -34,9 +34,14 @@ describe('<DomainCard />', () => {
|
||||
owner: 'guest',
|
||||
},
|
||||
};
|
||||
const { getByText } = render(<DomainCard entity={entity} />, {
|
||||
wrapper: MemoryRouter,
|
||||
});
|
||||
const { getByText } = await renderInTestApp(
|
||||
<DomainCard entity={entity} />,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText('artists')).toBeInTheDocument();
|
||||
expect(getByText('Everything about artists')).toBeInTheDocument();
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { DomainEntity, RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { ItemCard } from '@backstage/core';
|
||||
import { ItemCard, useRouteRef } from '@backstage/core';
|
||||
import {
|
||||
EntityRefLinks,
|
||||
entityRoute,
|
||||
entityRouteParams,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { catalogEntityRouteRef } from '../../routes';
|
||||
|
||||
type DomainCardProps = {
|
||||
entity: DomainEntity;
|
||||
@@ -30,6 +29,7 @@ type DomainCardProps = {
|
||||
|
||||
export const DomainCard = ({ entity }: DomainCardProps) => {
|
||||
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
|
||||
const catalogEntityRoute = useRouteRef(catalogEntityRouteRef);
|
||||
|
||||
return (
|
||||
<ItemCard
|
||||
@@ -44,11 +44,7 @@ export const DomainCard = ({ entity }: DomainCardProps) => {
|
||||
/>
|
||||
}
|
||||
label="Explore"
|
||||
// TODO: Use useRouteRef here to generate the path
|
||||
href={generatePath(
|
||||
`/catalog/${entityRoute.path}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
href={catalogEntityRoute(entityRouteParams(entity))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
*/
|
||||
|
||||
import { DomainEntity } from '@backstage/catalog-model';
|
||||
import { render } from '@testing-library/react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { catalogEntityRouteRef } from '../../routes';
|
||||
import { DomainCardGrid } from './DomainCardGrid';
|
||||
|
||||
describe('<DomainCardGrid />', () => {
|
||||
it('renders a grid of domain cards', () => {
|
||||
it('renders a grid of domain cards', async () => {
|
||||
const entities: DomainEntity[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -44,9 +44,14 @@ describe('<DomainCardGrid />', () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
const { getByText } = render(<DomainCardGrid entities={entities} />, {
|
||||
wrapper: MemoryRouter,
|
||||
});
|
||||
const { getByText } = await renderInTestApp(
|
||||
<DomainCardGrid entities={entities} />,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText('artists')).toBeInTheDocument();
|
||||
expect(getByText('playback')).toBeInTheDocument();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { catalogEntityRouteRef } from '../../routes';
|
||||
import { DomainExplorerContent } from './DomainExplorerContent';
|
||||
|
||||
describe('<DomainExplorerContent />', () => {
|
||||
@@ -71,6 +72,11 @@ describe('<DomainExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<DomainExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -86,6 +92,11 @@ describe('<DomainExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<DomainExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -101,6 +112,11 @@ describe('<DomainExplorerContent />', () => {
|
||||
<Wrapper>
|
||||
<DomainExplorerContent />
|
||||
</Wrapper>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { createApiFactory, createPlugin } from '@backstage/core';
|
||||
import { exploreToolsConfigRef } from '@backstage/plugin-explore-react';
|
||||
import { exploreRouteRef } from './routes';
|
||||
import { catalogEntityRouteRef, exploreRouteRef } from './routes';
|
||||
import { exampleTools } from './util/examples';
|
||||
|
||||
export const explorePlugin = createPlugin({
|
||||
@@ -37,4 +37,7 @@ export const explorePlugin = createPlugin({
|
||||
routes: {
|
||||
explore: exploreRouteRef,
|
||||
},
|
||||
externalRoutes: {
|
||||
catalogEntity: catalogEntityRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createRouteRef } from '@backstage/core';
|
||||
import { createExternalRouteRef, createRouteRef } from '@backstage/core';
|
||||
|
||||
const NoIcon = () => null;
|
||||
|
||||
@@ -22,3 +22,8 @@ export const exploreRouteRef = createRouteRef({
|
||||
icon: NoIcon,
|
||||
title: 'Explore',
|
||||
});
|
||||
|
||||
export const catalogEntityRouteRef = createExternalRouteRef({
|
||||
id: 'catalog-entity',
|
||||
params: ['namespace', 'kind', 'name'],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user