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:
Patrik Oldsberg
2021-03-04 10:11:44 +01:00
committed by GitHub
18 changed files with 327 additions and 168 deletions
+19
View File
@@ -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,
});
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Introduce parameters for namespace, kind, and name to `entityRouteRef`.
+4 -1
View File
@@ -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,
});
},
});
+91 -37
View File
@@ -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,
});
},
});
+3 -3
View File
@@ -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) {
+21 -31
View File
@@ -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 = {
+1 -2
View File
@@ -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.
+65 -28
View File
@@ -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,
);
}
+11 -5
View File
@@ -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;
+17 -14
View File
@@ -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);
+2 -5
View File
@@ -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';
+36 -20
View File
@@ -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(() =>
+4 -1
View File
@@ -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,
},
});
+6 -1
View File
@@ -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'],
});