diff --git a/.changeset/large-eggs-help.md b/.changeset/large-eggs-help.md new file mode 100644 index 0000000000..e310ad7645 --- /dev/null +++ b/.changeset/large-eggs-help.md @@ -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, + }); + }, +}); +``` diff --git a/.changeset/soft-months-invite.md b/.changeset/soft-months-invite.md new file mode 100644 index 0000000000..a6a20973e9 --- /dev/null +++ b/.changeset/soft-months-invite.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Introduce parameters for namespace, kind, and name to `entityRouteRef`. diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 5b6f7b95fe..7acde03953 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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, + }); }, }); diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx index 5ceb123b06..b9fdf05444 100644 --- a/packages/core-api/src/app/App.test.tsx +++ b/packages/core-api/src/app/App.test.tsx @@ -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 (
- Our routes are: {externalLink()}, bar: {barLink?.() ?? 'none'}, - baz: {bazLink?.() ?? 'none'} + err: {errLink()} + errParams: {errParamsLink({ x: 'a' })} + errOptional: {errOptionalLink?.() ?? ''} + + errParamsOptional:{' '} + {errParamsOptionalLink?.({ x: 'b' }) ?? ''} +
); }), @@ -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', () => { - + , ); - 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( + + + + + + + + , + ); + + expect(screen.getByText('err: /')).toBeInTheDocument(); + expect(screen.getByText('errParams: /foo')).toBeInTheDocument(); + expect(screen.getByText('errOptional: ')).toBeInTheDocument(); + expect(screen.getByText('errParamsOptional: ')).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, + }); }, }); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 9ab19b5588..3c58930709 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -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(); 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) { diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 701862601d..df4180c6e4 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -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; -/** - * 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 } -> = { - [name in keyof T]: T[name] extends ExternalRouteRef ? U : never; -}; - /** * Extracts a union of the keys in a map whose value extends the given type */ -type ExtractKeysWithType = { +type KeysWithType = { [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, bar?: RouteRef } + * Takes a map Map required values and makes all keys matching Keys optional */ -type CombineOptionalAndRequiredRoutes< - OptionalMap extends { [key in string]: boolean } -> = { - [name in ExtractKeysWithType]: RouteRef; -} & - { [name in keyof OptionalMap]?: RouteRef }; +type PartialKeys< + Map extends { [name in string]: any }, + Keys extends keyof Map +> = Partial> & Required>; /** - * 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 } -> = CombineOptionalAndRequiredRoutes>; +type TargetRouteMap = { + [name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef< + infer Params, + any + > + ? RouteRef + : never; +}; -export type AppRouteBinder = < - ExternalRoutes extends { [name in string]: ExternalRouteRef } ->( +export type AppRouteBinder = ( externalRoutes: ExternalRoutes, - targetRoutes: TargetRoutesMap, + targetRoutes: PartialKeys< + TargetRouteMap, + KeysWithType> + >, ) => void; export type AppOptions = { diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 711246f1f4..c3b64d8cb6 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -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. diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 67ff9bc1fb..eda16ccc91 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -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?: Array; - /** @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?: ParamKeys; path?: string; icon?: IconComponent; title: string; }; -export class AbsoluteRouteRef { - constructor(private readonly config: RouteRefConfig) {} +class RouteRefBase { + constructor(type: string, id: string) { + this.toString = () => `routeRef{type=${type},id=${id}}`; + } +} + +export class RouteRefImpl extends RouteRefBase + implements RouteRef { + readonly [routeRefType] = 'absolute'; + + constructor(private readonly config: RouteRefConfig) { + super('absolute', config.title); + } + + get params(): ParamKeys { + return this.config.params as any; + } get icon() { return this.config.icon; @@ -40,12 +61,12 @@ export class AbsoluteRouteRef { 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 { - return new AbsoluteRouteRef(config); +}): RouteRef> { + return new RouteRefImpl>({ + ...config, + params: (config.params ?? []) as ParamKeys>, + }); } -export class ExternalRouteRef { - readonly optional: boolean; +export class ExternalRouteRefImpl< + Params extends AnyParams, + Optional extends boolean +> extends RouteRefBase implements ExternalRouteRef { + readonly [routeRefType] = 'external'; - private constructor({ id, optional }: ExternalRouteRefOptions) { - this.toString = () => `externalRouteRef{${id}}`; - this.optional = Boolean(optional); + constructor( + id: string, + readonly params: ParamKeys, + readonly optional: Optional, + ) { + super('external', id); } } -export type ExternalRouteRefOptions = { +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 = { * if they aren't, `useRouteRef` will return `undefined`. */ optional?: Optional; -}; - -export function createExternalRouteRef( - options: ExternalRouteRefOptions, -): ExternalRouteRef { - return new ((ExternalRouteRef as unknown) as { - new (options: ExternalRouteRefOptions): ExternalRouteRef< - Optional - >; - })(options); +}): ExternalRouteRef, Optional> { + return new ExternalRouteRefImpl, Optional>( + options.id, + (options.params ?? []) as ParamKeys>, + Boolean(options.optional) as Optional, + ); } diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 94cd3a704d..0ada37b18d 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -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>) => ({ 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 = (props: { path?: string; diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 54490fb4f2..4d419485f6 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -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]: Params[keyof Params] extends never - ? readonly [] - : readonly [Params] +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; class RouteResolver { @@ -38,8 +41,8 @@ class RouteResolver { private readonly routeBindings: Map, ) {} - resolve( - routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef, + resolve( + routeRefOrExternalRouteRef: RouteRef | ExternalRouteRef, sourceLocation: ReturnType, ): RouteFunc | undefined { const routeRef = @@ -112,14 +115,14 @@ class RouteResolver { const RoutingContext = createContext(undefined); -export function useRouteRef( - routeRef: ExternalRouteRef, -): Optional extends true ? RouteFunc<{}> | undefined : RouteFunc<{}>; -export function useRouteRef( +export function useRouteRef( + routeRef: ExternalRouteRef, +): Optional extends true ? RouteFunc | undefined : RouteFunc; +export function useRouteRef( routeRef: RouteRef, ): RouteFunc; -export function useRouteRef( - routeRef: RouteRef | ExternalRouteRef, +export function useRouteRef( + routeRef: RouteRef | ExternalRouteRef, ): RouteFunc | undefined { const sourceLocation = useLocation(); const resolver = useContext(RoutingContext); diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 100158c3c5..2a0685f583 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -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'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index b21fa38052..69de5345a5 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -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 = { - // TODO(Rugvip): Remove path, look up via registry instead +export type AnyParams = { [param in string]: string } | undefined; +export type ParamKeys = keyof Params extends never + ? [] + : (keyof Params)[]; + +export const routeRefType: unique symbol = getGlobalSingleton( + 'route-ref-type', + () => Symbol('route-ref-type'), +); + +export type RouteRef = { + readonly [routeRefType]: 'absolute'; + + params: ParamKeys; + + // 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 | ExternalRouteRef; +export type ExternalRouteRef< + Params extends AnyParams = any, + Optional extends boolean = any +> = { + readonly [routeRefType]: 'external'; -/** - * This type should not be used - * @deprecated - */ + params: ParamKeys; + + optional?: Optional; +}; + +export type AnyRouteRef = RouteRef | ExternalRouteRef; + +// 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 diff --git a/plugins/explore/src/components/DomainCard/DomainCard.test.tsx b/plugins/explore/src/components/DomainCard/DomainCard.test.tsx index 9d409051f6..56a02a3642 100644 --- a/plugins/explore/src/components/DomainCard/DomainCard.test.tsx +++ b/plugins/explore/src/components/DomainCard/DomainCard.test.tsx @@ -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('', () => { - 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('', () => { owner: 'guest', }, }; - const { getByText } = render(, { - wrapper: MemoryRouter, - }); + const { getByText } = await renderInTestApp( + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, + }, + }, + ); expect(getByText('artists')).toBeInTheDocument(); expect(getByText('Everything about artists')).toBeInTheDocument(); diff --git a/plugins/explore/src/components/DomainCard/DomainCard.tsx b/plugins/explore/src/components/DomainCard/DomainCard.tsx index e5efc543b2..67bee13696 100644 --- a/plugins/explore/src/components/DomainCard/DomainCard.tsx +++ b/plugins/explore/src/components/DomainCard/DomainCard.tsx @@ -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 ( { /> } label="Explore" - // TODO: Use useRouteRef here to generate the path - href={generatePath( - `/catalog/${entityRoute.path}`, - entityRouteParams(entity), - )} + href={catalogEntityRoute(entityRouteParams(entity))} /> ); }; diff --git a/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx b/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx index bad3ca581b..ee06a50427 100644 --- a/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx +++ b/plugins/explore/src/components/DomainCard/DomainCardGrid.test.tsx @@ -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('', () => { - 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('', () => { }, }, ]; - const { getByText } = render(, { - wrapper: MemoryRouter, - }); + const { getByText } = await renderInTestApp( + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, + }, + }, + ); expect(getByText('artists')).toBeInTheDocument(); expect(getByText('playback')).toBeInTheDocument(); diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index f29ad2cd6a..cd3f2847c6 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -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('', () => { @@ -71,6 +72,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, + }, + }, ); await waitFor(() => { @@ -86,6 +92,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, + }, + }, ); await waitFor(() => @@ -101,6 +112,11 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, + }, + }, ); await waitFor(() => diff --git a/plugins/explore/src/plugin.ts b/plugins/explore/src/plugin.ts index 27573eaeb9..c5dd6644e7 100644 --- a/plugins/explore/src/plugin.ts +++ b/plugins/explore/src/plugin.ts @@ -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, + }, }); diff --git a/plugins/explore/src/routes.ts b/plugins/explore/src/routes.ts index c41a53128f..fc7868c4c9 100644 --- a/plugins/explore/src/routes.ts +++ b/plugins/explore/src/routes.ts @@ -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'], +});