Skip to content

Commit

Permalink
Merge pull request #4792 from backstage/feat/external-route-ref
Browse files Browse the repository at this point in the history
core-api: add parameters to external type refs + reactor types
  • Loading branch information
Rugvip committed Mar 4, 2021
2 parents b9ac206 + 0755fc6 commit 141e9d3
Show file tree
Hide file tree
Showing 18 changed files with 325 additions and 166 deletions.
19 changes: 19 additions & 0 deletions .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,
});
},
});
```
5 changes: 5 additions & 0 deletions .changeset/soft-months-invite.md
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---

Introduce parameters for namespace, kind, and name to `entityRouteRef`.
5 changes: 4 additions & 1 deletion packages/app/src/App.tsx
Expand Up @@ -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';
Expand Down Expand Up @@ -79,6 +79,9 @@ const app = createApp({
bind(apiDocsPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
});
bind(explorePlugin.externalRoutes, {
catalogEntity: catalogPlugin.routes.catalogEntity,
});
},
});

Expand Down
124 changes: 89 additions & 35 deletions packages/core-api/src/app/App.test.tsx
Expand Up @@ -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,
},
});

Expand All @@ -85,29 +92,79 @@ 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>
);
}),
mountPoint: plugin1RouteRef,
}),
);

const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
Progress: () => null,
Router: BrowserRouter,
};

it('runs happy paths', async () => {
const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
Progress: () => null,
Router: BrowserRouter,
};
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,
errOptional: plugin1RouteRef,
errParamsOptional: 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: /')).toBeInTheDocument();
expect(screen.getByText('errParamsOptional: /foo')).toBeInTheDocument();
});

it('runs happy paths without optional routes', async () => {
const app = new PrivateAppImpl({
apis: [],
defaultApis: [],
Expand All @@ -124,8 +181,8 @@ describe('Integration Test', () => {
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
foo: plugin2RouteRef,
bar: plugin2RouteRef,
err: plugin1RouteRef,
errParams: plugin2RouteRef,
});
},
});
Expand All @@ -138,25 +195,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: <none>')).toBeInTheDocument();
expect(screen.getByText('errParamsOptional: <none>')).toBeInTheDocument();
});

it('should throw some error when the route has duplicate params', () => {
const components = {
NotFoundErrorPage: () => null,
BootErrorPage: () => null,
Progress: () => null,
Router: BrowserRouter,
};

const app = new PrivateAppImpl({
apis: [],
defaultApis: [],
Expand All @@ -172,7 +223,10 @@ describe('Integration Test', () => {
plugins: [],
components,
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, { foo: plugin2RouteRef });
bind(plugin1.externalRoutes, {
err: plugin1RouteRef,
errParams: plugin2RouteRef,
});
},
});

Expand Down
6 changes: 3 additions & 3 deletions packages/core-api/src/app/App.tsx
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down
52 changes: 21 additions & 31 deletions packages/core-api/src/app/types.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {
Expand Down
3 changes: 1 addition & 2 deletions packages/core-api/src/plugin/types.ts
Expand Up @@ -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.
Expand Down

0 comments on commit 141e9d3

Please sign in to comment.