Add catalog graph plugin

Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
Oliver Sand
2021-09-15 10:45:30 +02:00
parent 4a1612f998
commit 8c50da6685
53 changed files with 5291 additions and 21 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-catalog-graph': patch
---
Add new plugin `@backstage/plugin-catalog-graph`. The catalog graph visualizes
the relations between entities, like ownership, grouping or API relationships.
For more details on adding the plugin to your Backstage instance, [see the README](https://github.com/backstage/backstage/blob/master/plugins/catalog-graph/README.md).
@@ -0,0 +1,9 @@
---
title: Catalog Graph
author: SDA SE
authorUrl: https://sda.se/
category: Discovery
description: Extend the Backstage Software Catalog with a graph that shows all entities and their relationships providing an easier way to discover the ecosystem.
documentation: https://github.com/backstage/backstage/blob/master/plugins/catalog-graph/README.md
iconUrl: img/catalog-graph.svg
npmPackageName: '@backstage/plugin-catalog-graph'
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M17 16l-4-4V8.82C14.16 8.4 15 7.3 15 6c0-1.66-1.34-3-3-3S9 4.34 9 6c0 1.3.84 2.4 2 2.82V12l-4 4H3v5h5v-3.05l4-4.2 4 4.2V21h5v-5h-4z"/></svg>

After

Width:  |  Height:  |  Size: 305 B

+1
View File
@@ -13,6 +13,7 @@
"@backstage/plugin-api-docs": "^0.6.8",
"@backstage/plugin-badges": "^0.2.9",
"@backstage/plugin-catalog": "^0.6.15",
"@backstage/plugin-catalog-graph": "^0.1.0",
"@backstage/plugin-catalog-import": "^0.5.21",
"@backstage/plugin-catalog-react": "^0.4.6",
"@backstage/plugin-circleci": "^0.2.23",
+46 -7
View File
@@ -14,20 +14,34 @@
* limitations under the License.
*/
import {
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CONSUMES_API,
RELATION_DEPENDENCY_OF,
RELATION_DEPENDS_ON,
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { createApp, FlatRoutes } from '@backstage/core-app-api';
import {
AlertDisplay,
OAuthRequestDialog,
SignInPage,
} from '@backstage/core-components';
import { HomepageCompositionRoot } from '@backstage/plugin-home';
import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs';
import {
CatalogEntityPage,
CatalogIndexPage,
catalogPlugin,
} from '@backstage/plugin-catalog';
import {
CatalogGraphPage,
catalogGraphPlugin,
} from '@backstage/plugin-catalog-graph';
import {
CatalogImportPage,
catalogImportPlugin,
@@ -40,12 +54,13 @@ import {
import { ExplorePage, explorePlugin } from '@backstage/plugin-explore';
import { GcpProjectsPage } from '@backstage/plugin-gcp-projects';
import { GraphiQLPage } from '@backstage/plugin-graphiql';
import { HomepageCompositionRoot } from '@backstage/plugin-home';
import { LighthousePage } from '@backstage/plugin-lighthouse';
import { NewRelicPage } from '@backstage/plugin-newrelic';
import {
ScaffolderFieldExtensions,
ScaffolderPage,
scaffolderPlugin,
ScaffolderFieldExtensions,
} from '@backstage/plugin-scaffolder';
import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
@@ -61,12 +76,11 @@ import React from 'react';
import { hot } from 'react-hot-loader/root';
import { Navigate, Route } from 'react-router';
import { apis } from './apis';
import { Root } from './components/Root';
import { entityPage } from './components/catalog/EntityPage';
import { searchPage } from './components/search/SearchPage';
import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions';
import { HomePage } from './components/home/HomePage';
import { Root } from './components/Root';
import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions';
import { searchPage } from './components/search/SearchPage';
import { providers } from './identityProviders';
import * as plugins from './plugins';
@@ -95,6 +109,9 @@ const app = createApp({
createComponent: scaffolderPlugin.routes.root,
viewTechDoc: techdocsPlugin.routes.docRoot,
});
bind(catalogGraphPlugin.externalRoutes, {
catalogEntity: catalogPlugin.routes.catalogEntity,
});
bind(apiDocsPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
});
@@ -125,6 +142,28 @@ const routes = (
{entityPage}
</Route>
<Route path="/catalog-import" element={<CatalogImportPage />} />
<Route
path="/catalog-graph"
element={
<CatalogGraphPage
initialState={{
selectedKinds: ['component', 'domain', 'system', 'api', 'group'],
selectedRelations: [
RELATION_OWNER_OF,
RELATION_OWNED_BY,
RELATION_CONSUMES_API,
RELATION_API_CONSUMED_BY,
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
RELATION_HAS_PART,
RELATION_PART_OF,
RELATION_DEPENDS_ON,
RELATION_DEPENDENCY_OF,
],
}}
/>
}
/>
<Route path="/docs" element={<TechDocsIndexPage />}>
<DefaultTechDocsHome />
</Route>
@@ -14,15 +14,24 @@
* limitations under the License.
*/
import React, { ReactNode, useMemo, useState } from 'react';
import BadgeIcon from '@material-ui/icons/CallToAction';
import {
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CONSUMES_API,
RELATION_DEPENDENCY_OF,
RELATION_DEPENDS_ON,
RELATION_HAS_PART,
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { EmptyState } from '@backstage/core-components';
import {
EntityApiDefinitionCard,
EntityConsumedApisCard,
EntityConsumingComponentsCard,
EntityHasApisCard,
EntityProvidingComponentsCard,
EntityProvidedApisCard,
EntityConsumedApisCard,
EntityProvidingComponentsCard,
} from '@backstage/plugin-api-docs';
import { EntityBadgesDialog } from '@backstage/plugin-badges';
import {
@@ -30,20 +39,24 @@ import {
EntityDependsOnComponentsCard,
EntityDependsOnResourcesCard,
EntityHasComponentsCard,
EntityHasResourcesCard,
EntityHasSubcomponentsCard,
EntityHasSystemsCard,
EntityLayout,
EntityLinksCard,
EntitySystemDiagramCard,
EntitySwitch,
isComponentType,
isKind,
EntityHasResourcesCard,
EntityOrphanWarning,
EntityProcessingErrorsPanel,
EntitySwitch,
EntitySystemDiagramCard,
hasCatalogProcessingErrors,
isComponentType,
isKind,
isOrphan,
} from '@backstage/plugin-catalog';
import {
Direction,
EntityCatalogGraphCard,
} from '@backstage/plugin-catalog-graph';
import {
EntityCircleCIContent,
isCircleCIAvailable,
@@ -52,6 +65,7 @@ import {
EntityCloudbuildContent,
isCloudbuildAvailable,
} from '@backstage/plugin-cloudbuild';
import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage';
import {
EntityGithubActionsContent,
EntityRecentGithubActionsRunsCard,
@@ -87,6 +101,7 @@ import { EntitySentryContent } from '@backstage/plugin-sentry';
import { EntityTechdocsContent } from '@backstage/plugin-techdocs';
import { EntityTodoContent } from '@backstage/plugin-todo';
import { Button, Grid } from '@material-ui/core';
import BadgeIcon from '@material-ui/icons/CallToAction';
import {
EntityBuildkiteContent,
isBuildkiteAvailable,
@@ -108,8 +123,7 @@ import {
EntityTravisCIOverviewCard,
isTravisciAvailable,
} from '@roadiehq/backstage-plugin-travis-ci';
import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage';
import { EmptyState } from '@backstage/core-components';
import React, { ReactNode, useMemo, useState } from 'react';
const EntityLayoutWrapper = (props: { children?: ReactNode }) => {
const [badgesDialogOpen, setBadgesDialogOpen] = useState(false);
@@ -246,10 +260,14 @@ const errorsContent = (
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={8} xs={12}>
<Grid item md={6} xs={12}>
<EntityAboutCard variant="gridItem" />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" maxHeight={400} />
</Grid>
<EntitySwitch>
<EntitySwitch.Case if={isPagerDutyAvailable}>
<Grid item md={6}>
@@ -454,9 +472,12 @@ const apiPage = (
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12}>
<Grid item md={6} xs={12}>
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" maxHeight={400} />
</Grid>
<Grid item xs={12}>
<Grid container>
<Grid item xs={12} md={6}>
@@ -523,6 +544,9 @@ const systemPage = (
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" maxHeight={400} />
</Grid>
<Grid item md={6}>
<EntityHasComponentsCard variant="gridItem" />
</Grid>
@@ -537,6 +561,25 @@ const systemPage = (
<EntityLayout.Route path="/diagram" title="Diagram">
<EntitySystemDiagramCard />
</EntityLayout.Route>
<EntityLayout.Route path="/relations" title="Diagram (new)">
<EntityCatalogGraphCard
variant="gridItem"
direction={Direction.TOP_BOTTOM}
title="System Diagram"
maxHeight={700}
relations={[
RELATION_PART_OF,
RELATION_HAS_PART,
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CONSUMES_API,
RELATION_PROVIDES_API,
RELATION_DEPENDENCY_OF,
RELATION_DEPENDS_ON,
]}
unidirectional={false}
/>
</EntityLayout.Route>
</EntityLayoutWrapper>
);
@@ -548,6 +591,9 @@ const domainPage = (
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" maxHeight={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
</Grid>
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+83
View File
@@ -0,0 +1,83 @@
# catalog-graph
Welcome to the catalog graph plugin! The catalog graph visualizes the relations
between entities, like ownership, grouping or API relationships.
The plugin comes with these features:
- `EntityCatalogGraphCard`:
A card that displays the directly related entities to the current entity.
This card is for use on the entity page.
The card can be customized, for example filtering for specific relations.
- `CatalogGraphPage`:
A standalone page that can be added to your application providing a viewer for your entities and their relations.
The viewer can be used to navigate through the entities and filter for specific relations.
You can access it from the `EntityCatalogGraphCard`.
- `EntityRelationsGraph`:
A react component that can be used to build own customized entity relation graphs.
## Usage
To use the catalog graph plugin, you have to add some things to your Backstage app:
1. Add a dependency to your `packages/app/package.json`, run:
```sh
yarn add @backstage/plugin-catalog-graph
```
2. Add the `CatalogGraphPage` to your `packages/app/src/App.tsx`:
```typescript
<FlatRoutes>
<Route path="/catalog-graph" element={<CatalogGraphPage />} />…
</FlatRoutes>
```
You can configure the page to open with some initial filters:
```typescript
<Route
path="/catalog-graph"
element={
<CatalogGraphPage
initialState={{
selectedKinds: ['component', 'domain', 'system', 'api', 'group'],
selectedRelations: [
RELATION_OWNER_OF,
RELATION_OWNED_BY,
RELATION_CONSUMES_API,
RELATION_API_CONSUMED_BY,
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
RELATION_HAS_PART,
RELATION_PART_OF,
RELATION_DEPENDS_ON,
RELATION_DEPENDENCY_OF,
],
}}
/>
}
/>
```
3. Bind the external routes of the `catalogGraphPlugin` in your `packages/app/src/App.tsx`:
```typescript
bindRoutes({ bind }) {
bind(catalogGraphPlugin.externalRoutes, {
catalogEntity: catalogPlugin.routes.catalogEntity,
});
}
```
4. Add `EntityCatalogGraphCard` to any entity page that you want in your `packages/app/src/components/catalog/EntityPage.tsx`:
```typescript
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" maxHeight={400} />
</Grid>
```
+167
View File
@@ -0,0 +1,167 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogListResponse } from '@backstage/catalog-client';
import {
Entity,
EntityName,
ENTITY_DEFAULT_NAMESPACE,
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CONSUMES_API,
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
RELATION_PROVIDES_API,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Content, Header, Page } from '@backstage/core-components';
import { createDevApp } from '@backstage/dev-utils';
import {
CatalogApi,
catalogApiRef,
EntityProvider,
} from '@backstage/plugin-catalog-react';
import { Grid } from '@material-ui/core';
import React from 'react';
import {
CatalogGraphPage,
catalogGraphPlugin,
EntityCatalogGraphCard,
} from '../src';
type DataRelation = [string, string, string];
type DataEntity = [string, string, DataRelation[]];
const entities = (
[
[
'Domain',
'wayback',
[
[RELATION_OWNED_BY, 'Group', 'team-a'],
[RELATION_HAS_PART, 'System', 'wayback'],
],
],
[
'System',
'wayback',
[
[RELATION_OWNED_BY, 'Group', 'team-a'],
[RELATION_PART_OF, 'Domain', 'wayback'],
[RELATION_HAS_PART, 'Component', 'wayback-archive'],
[RELATION_HAS_PART, 'Component', 'wayback-search'],
[RELATION_HAS_PART, 'API', 'wayback-api'],
],
],
[
'Component',
'wayback-archive',
[
[RELATION_OWNED_BY, 'Group', 'team-a'],
[RELATION_PART_OF, 'System', 'wayback'],
[RELATION_PROVIDES_API, 'API', 'wayback-api'],
],
],
[
'Component',
'wayback-search',
[
[RELATION_OWNED_BY, 'Group', 'team-a'],
[RELATION_PART_OF, 'System', 'wayback'],
[RELATION_CONSUMES_API, 'API', 'wayback-api'],
],
],
[
'API',
'wayback-api',
[
[RELATION_OWNED_BY, 'Group', 'team-a'],
[RELATION_PART_OF, 'System', 'wayback'],
[RELATION_API_PROVIDED_BY, 'Component', 'wayback-archive'],
[RELATION_API_CONSUMED_BY, 'Component', 'wayback-search'],
],
],
[
'Group',
'team-a',
[
[RELATION_OWNER_OF, 'Component', 'wayback-archive'],
[RELATION_OWNER_OF, 'Component', 'wayback-search'],
[RELATION_OWNER_OF, 'API', 'wayback-api'],
[RELATION_OWNER_OF, 'Domain', 'wayback'],
[RELATION_OWNER_OF, 'System', 'wayback'],
],
],
] as DataEntity[]
).reduce((o, d) => {
const [kind, name, relations] = d;
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind,
metadata: {
name,
},
relations: relations.map(([type, k, n]) => ({
target: { kind: k, name: n, namespace: ENTITY_DEFAULT_NAMESPACE },
type,
})),
};
const entityRef = stringifyEntityRef(entity);
o[entityRef] = entity;
return o;
}, {} as { [entityRef: string]: Entity });
createDevApp()
.registerPlugin(catalogGraphPlugin)
.registerApi({
api: catalogApiRef,
deps: {},
factory() {
return {
async getEntityByName(name: EntityName): Promise<Entity | undefined> {
return entities[stringifyEntityRef(name)];
},
async getEntities(): Promise<CatalogListResponse<Entity>> {
return { items: Object.values(entities) };
},
} as Partial<CatalogApi> as unknown as CatalogApi;
},
})
.addPage({
title: 'Graph Card',
element: (
<Page themeId="home">
<Header title="Graph Card" />
<Content>
<Grid container>
<Grid item xs={12}>
<EntityProvider
entity={entities['component:default/wayback-archive']}
>
<EntityCatalogGraphCard />
</EntityProvider>
</Grid>
</Grid>
</Content>
</Page>
),
})
.addPage({
element: <CatalogGraphPage />,
})
.render();
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@backstage/plugin-catalog-graph",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve --config ../../app-config.yaml",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-client": "^0.3.18",
"@backstage/catalog-model": "^0.9.2",
"@backstage/core-components": "^0.4.1",
"@backstage/core-plugin-api": "^0.1.7",
"@backstage/plugin-catalog-react": "^0.4.5",
"@backstage/theme": "^0.2.10",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^17.2.4",
"classnames": "^2.3.1",
"react-router": "6.0.0-beta.0",
"qs": "^6.9.4",
"lodash": "^4.17.15",
"p-limit": "^3.1.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.11",
"@backstage/dev-utils": "^0.2.9",
"@backstage/test-utils": "^0.1.17",
"@backstage/core-app-api": "^0.1.12",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@testing-library/react-hooks": "^3.4.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,118 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import {
CatalogApi,
catalogApiRef,
EntityProvider,
} from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { catalogEntityRouteRef, catalogGraphRouteRef } from '../../routes';
import { CatalogGraphCard } from './CatalogGraphCard';
describe('<CatalogGraphCard/>', () => {
let entity: Entity;
let wrapper: JSX.Element;
let catalog: jest.Mocked<CatalogApi>;
let apis: ApiRegistry;
beforeAll(() => {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
beforeEach(() => {
entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
};
catalog = {
getEntities: jest.fn(),
getEntityByName: jest.fn(async _ => ({ ...entity, relations: [] })),
removeEntityByUid: jest.fn(),
getLocationById: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
};
apis = ApiRegistry.with(catalogApiRef, catalog);
wrapper = (
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<CatalogGraphCard />
</EntityProvider>
</ApiProvider>
);
});
test('renders without exploding', async () => {
const { findByText, findAllByTestId } = await renderInTestApp(wrapper, {
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef,
'/catalog-graph': catalogGraphRouteRef,
},
});
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(1);
expect(catalog.getEntityByName).toBeCalledTimes(1);
});
test('renders with custom title', async () => {
const { findByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<CatalogGraphCard title="Custom Title" />
</EntityProvider>
</ApiProvider>,
{
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef,
'/catalog-graph': catalogGraphRouteRef,
},
},
);
expect(await findByText('Custom Title')).toBeInTheDocument();
});
test('renders link to standalone viewer', async () => {
const { findByText, getByText } = await renderInTestApp(wrapper, {
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef,
'/catalog-graph': catalogGraphRouteRef,
},
});
expect(await findByText('b:d/c')).toBeInTheDocument();
const button = getByText('View graph');
expect(button).toBeInTheDocument();
expect(button.closest('a')).toHaveAttribute(
'href',
'/catalog-graph?rootEntityRefs%5B%5D=b%3Ad%2Fc',
);
});
});
@@ -0,0 +1,126 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
getEntityName,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
import { useRouteRef } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { makeStyles, Theme } from '@material-ui/core';
import qs from 'qs';
import React, { MouseEvent, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { catalogEntityRouteRef, catalogGraphRouteRef } from '../../routes';
import {
Direction,
EntityNode,
EntityRelationsGraph,
RelationPairs,
RELATION_PAIRS,
} from '../EntityRelationsGraph';
const useStyles = makeStyles<Theme, { maxHeight: number | undefined }>({
card: ({ maxHeight }) => ({
display: 'flex',
flexDirection: 'column',
maxHeight,
minHeight: 0,
}),
graph: {
flex: 1,
minHeight: 0,
},
});
export type Props = {
variant?: InfoCardVariants;
relationPairs?: RelationPairs;
maxDepth?: number;
unidirectional?: boolean;
mergeRelations?: boolean;
kinds?: string[];
relations?: string[];
direction?: Direction;
maxHeight?: number;
title?: string;
};
export const CatalogGraphCard = ({
variant = 'gridItem',
relationPairs = RELATION_PAIRS,
maxDepth = 1,
unidirectional = true,
mergeRelations = true,
kinds,
relations,
direction = Direction.LEFT_RIGHT,
maxHeight,
title = 'Relations',
}: Props) => {
const { entity } = useEntity();
const entityName = getEntityName(entity);
const catalogEntityRoute = useRouteRef(catalogEntityRouteRef);
const catalogGraphRoute = useRouteRef(catalogGraphRouteRef);
const navigate = useNavigate();
const classes = useStyles({ maxHeight });
const onNodeClick = useCallback(
(node: EntityNode, _: MouseEvent) => {
const nodeEntityName = parseEntityRef(node.id);
const path = catalogEntityRoute({
kind: nodeEntityName.kind.toLowerCase(),
namespace: nodeEntityName.namespace.toLowerCase(),
name: nodeEntityName.name,
});
navigate(path);
},
[catalogEntityRoute, navigate],
);
const catalogGraphParams = qs.stringify(
{ rootEntityRefs: [stringifyEntityRef(entity)] },
{ arrayFormat: 'brackets', addQueryPrefix: true },
);
const catalogGraphUrl = `${catalogGraphRoute()}${catalogGraphParams}`;
return (
<InfoCard
title={title}
cardClassName={classes.card}
variant={variant}
noPadding
deepLink={{
title: 'View graph',
link: catalogGraphUrl,
}}
>
<EntityRelationsGraph
rootEntityNames={entityName}
maxDepth={maxDepth}
unidirectional={unidirectional}
mergeRelations={mergeRelations}
kinds={kinds}
relations={relations}
direction={direction}
onNodeClick={onNodeClick}
className={classes.graph}
relationPairs={relationPairs}
/>
</InfoCard>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CatalogGraphCard } from './CatalogGraphCard';
@@ -0,0 +1,168 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RELATION_HAS_PART, RELATION_PART_OF } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { catalogEntityRouteRef } from '../../routes';
import { CatalogGraphPage } from './CatalogGraphPage';
const navigate = jest.fn();
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useNavigate: () => navigate,
}));
describe('<CatalogGraphPage/>', () => {
let wrapper: JSX.Element;
let catalog: jest.Mocked<CatalogApi>;
beforeAll(() => {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
beforeEach(() => {
const entityC = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [
{
type: RELATION_PART_OF,
target: {
kind: 'b',
namespace: 'd',
name: 'e',
},
},
],
};
const entityE = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'e',
namespace: 'd',
},
relations: [
{
type: RELATION_HAS_PART,
target: {
kind: 'b',
namespace: 'd',
name: 'c',
},
},
],
};
catalog = {
getEntities: jest.fn(),
getEntityByName: jest.fn(async n => (n.name === 'e' ? entityE : entityC)),
removeEntityByUid: jest.fn(),
getLocationById: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalog);
wrapper = (
<ApiProvider apis={apis}>
<CatalogGraphPage
initialState={{
showFilters: false,
rootEntityRefs: ['b:d/c'],
selectedKinds: ['b'],
}}
/>
</ApiProvider>
);
});
afterEach(() => jest.resetAllMocks());
test('should render without exploding', async () => {
const { getByText, findByText, findAllByTestId } = await renderInTestApp(
wrapper,
{
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef,
},
},
);
expect(getByText('Catalog Graph')).toBeInTheDocument();
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findByText('b:d/e')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(2);
expect(catalog.getEntityByName).toBeCalledTimes(2);
});
test('should toggle filters', async () => {
const { getByText, queryByText } = await renderInTestApp(wrapper, {
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef,
},
});
expect(queryByText('Max Depth')).toBeNull();
userEvent.click(getByText('Filters'));
expect(getByText('Max Depth')).toBeInTheDocument();
});
test('should select other entity', async () => {
const { getByText, findByText, findAllByTestId } = await renderInTestApp(
wrapper,
{
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef,
},
},
);
expect(await findAllByTestId('node')).toHaveLength(2);
userEvent.click(getByText('b:d/e'));
expect(await findByText('hasPart')).toBeInTheDocument();
});
test('should navigate to entity', async () => {
const { getByText, findAllByTestId } = await renderInTestApp(wrapper, {
mountedRoutes: {
'/entity/{kind}/{namespace}/{name}': catalogEntityRouteRef,
},
});
expect(await findAllByTestId('node')).toHaveLength(2);
userEvent.click(getByText('b:d/e'), { shiftKey: true });
expect(navigate).toBeCalledWith('/entity/{kind}/{namespace}/{name}');
});
});
@@ -0,0 +1,241 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { parseEntityRef } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
Header,
Page,
SupportButton,
} from '@backstage/core-components';
import { useRouteRef } from '@backstage/core-plugin-api';
import { formatEntityRefTitle } from '@backstage/plugin-catalog-react';
import { Grid, makeStyles, Paper, Typography } from '@material-ui/core';
import FilterListIcon from '@material-ui/icons/FilterList';
import ZoomOutMap from '@material-ui/icons/ZoomOutMap';
import { ToggleButton } from '@material-ui/lab';
import React, { MouseEvent, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { catalogEntityRouteRef } from '../../routes';
import {
Direction,
EntityNode,
EntityRelationsGraph,
RelationPairs,
RELATION_PAIRS,
} from '../EntityRelationsGraph';
import { DirectionFilter } from './DirectionFilter';
import { MaxDepthFilter } from './MaxDepthFilter';
import { SelectedKindsFilter } from './SelectedKindsFilter';
import { SelectedRelationsFilter } from './SelectedRelationsFilter';
import { SwitchFilter } from './SwitchFilter';
import { useCatalogGraphPage } from './useCatalogGraphPage';
const useStyles = makeStyles(theme => ({
content: {
minHeight: 0,
},
container: {
height: '100%',
maxHeight: '100%',
minHeight: 0,
},
fullHeight: {
maxHeight: '100%',
display: 'flex',
minHeight: 0,
},
graphWrapper: {
position: 'relative',
flex: 1,
minHeight: 0,
display: 'flex',
},
graph: {
flex: 1,
minHeight: 0,
},
legend: {
position: 'absolute',
bottom: 0,
right: 0,
padding: theme.spacing(1),
'& .icon': {
verticalAlign: 'bottom',
},
},
filters: {
display: 'grid',
gridGap: theme.spacing(1),
gridAutoRows: 'auto',
[theme.breakpoints.up('lg')]: {
display: 'block',
},
[theme.breakpoints.only('md')]: {
gridTemplateColumns: 'repeat(3, 1fr)',
},
[theme.breakpoints.only('sm')]: {
gridTemplateColumns: 'repeat(2, 1fr)',
},
[theme.breakpoints.down('xs')]: {
gridTemplateColumns: 'repeat(1, 1fr)',
},
},
}));
export const CatalogGraphPage = ({
relationPairs = RELATION_PAIRS,
initialState,
}: {
relationPairs?: RelationPairs;
initialState?: {
selectedRelations?: string[];
selectedKinds?: string[];
rootEntityRefs?: string[];
maxDepth?: number;
unidirectional?: boolean;
mergeRelations?: boolean;
direction?: Direction;
showFilters?: boolean;
};
}) => {
const navigate = useNavigate();
const classes = useStyles();
const catalogEntityRoute = useRouteRef(catalogEntityRouteRef);
const {
maxDepth,
setMaxDepth,
selectedKinds,
setSelectedKinds,
selectedRelations,
setSelectedRelations,
unidirectional,
setUnidirectional,
mergeRelations,
setMergeRelations,
direction,
setDirection,
rootEntityNames,
setRootEntityNames,
showFilters,
toggleShowFilters,
} = useCatalogGraphPage({ initialState });
const onNodeClick = useCallback(
(node: EntityNode, event: MouseEvent) => {
const nodeEntityName = parseEntityRef(node.id);
if (event.shiftKey) {
const path = catalogEntityRoute({
kind: nodeEntityName.kind.toLowerCase(),
namespace: nodeEntityName.namespace.toLowerCase(),
name: nodeEntityName.name,
});
navigate(path);
} else {
setRootEntityNames([nodeEntityName]);
}
},
[catalogEntityRoute, navigate, setRootEntityNames],
);
return (
<Page themeId="home">
<Header
title="Catalog Graph"
subtitle={rootEntityNames.map(e => formatEntityRefTitle(e)).join(', ')}
/>
<Content stretch className={classes.content}>
<ContentHeader
titleComponent={
<ToggleButton
value="show filters"
selected={showFilters}
onChange={() => toggleShowFilters()}
>
<FilterListIcon /> Filters
</ToggleButton>
}
>
<SupportButton>
Start tracking your component in by adding it to the software
catalog.
</SupportButton>
</ContentHeader>
<Grid container alignItems="stretch" className={classes.container}>
{showFilters && (
<Grid item xs={12} lg={2} className={classes.filters}>
<MaxDepthFilter value={maxDepth} onChange={setMaxDepth} />
<SelectedKindsFilter
value={selectedKinds}
onChange={setSelectedKinds}
/>
<SelectedRelationsFilter
value={selectedRelations}
onChange={setSelectedRelations}
relationPairs={relationPairs}
/>
<DirectionFilter value={direction} onChange={setDirection} />
<SwitchFilter
value={unidirectional}
onChange={setUnidirectional}
label="Simplified"
/>
<SwitchFilter
value={mergeRelations}
onChange={setMergeRelations}
label="Merge Relations"
/>
</Grid>
)}
<Grid item xs className={classes.fullHeight}>
<Paper className={classes.graphWrapper}>
<Typography
variant="caption"
color="textSecondary"
display="block"
className={classes.legend}
>
<ZoomOutMap className="icon" /> Use pinch &amp; zoom to move
around the diagram. Click to change active node, shift click to
navigate to entity.
</Typography>
<EntityRelationsGraph
rootEntityNames={rootEntityNames}
maxDepth={maxDepth}
kinds={
selectedKinds && selectedKinds.length > 0
? selectedKinds
: undefined
}
relations={
selectedRelations && selectedRelations.length > 0
? selectedRelations
: undefined
}
mergeRelations={mergeRelations}
unidirectional={unidirectional}
onNodeClick={onNodeClick}
direction={direction}
relationPairs={relationPairs}
className={classes.graph}
/>
</Paper>
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,47 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Direction } from '../EntityRelationsGraph';
import { DirectionFilter } from './DirectionFilter';
describe('<DirectionFilter/>', () => {
test('should display current value', () => {
const { getByText } = render(
<DirectionFilter value={Direction.LEFT_RIGHT} onChange={() => {}} />,
);
expect(getByText('Left to right')).toBeInTheDocument();
});
test('should select direction', async () => {
const onChange = jest.fn();
const { getByText, getByTestId } = render(
<DirectionFilter value={Direction.RIGHT_LEFT} onChange={onChange} />,
);
expect(getByText('Right to left')).toBeInTheDocument();
userEvent.click(getByTestId('select'));
userEvent.click(getByText('Top to bottom'));
await waitFor(() => {
expect(getByText('Top to bottom')).toBeInTheDocument();
expect(onChange).toBeCalledWith(Direction.TOP_BOTTOM);
});
});
});
@@ -0,0 +1,49 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Select } from '@backstage/core-components';
import { Box } from '@material-ui/core';
import React, { useCallback } from 'react';
import { Direction } from '../EntityRelationsGraph';
const DIRECTION_DISPLAY_NAMES = {
[Direction.LEFT_RIGHT]: 'Left to right',
[Direction.RIGHT_LEFT]: 'Right to left',
[Direction.TOP_BOTTOM]: 'Top to bottom',
[Direction.BOTTOM_TOP]: 'Bottom to top',
};
export type Props = {
value: Direction;
onChange: (value: Direction) => void;
};
export const DirectionFilter = ({ value, onChange }: Props) => {
const handleChange = useCallback(v => onChange(v as Direction), [onChange]);
return (
<Box pb={1} pt={1}>
<Select
label="Direction"
selected={value}
items={Object.values(Direction).map(v => ({
label: DIRECTION_DISPLAY_NAMES[v],
value: v,
}))}
onChange={handleChange}
/>
</Box>
);
};
@@ -0,0 +1,75 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { MaxDepthFilter } from './MaxDepthFilter';
describe('<MaxDepthFilter/>', () => {
test('should display current value', () => {
const { getByLabelText } = render(
<MaxDepthFilter value={5} onChange={() => {}} />,
);
expect(getByLabelText('maxp')).toBeInTheDocument();
expect(getByLabelText('maxp')).toHaveValue(5);
});
test('should display infinite if non finite', () => {
const { getByPlaceholderText, getByLabelText } = render(
<MaxDepthFilter value={Number.POSITIVE_INFINITY} onChange={() => {}} />,
);
expect(getByPlaceholderText(/Infinite/)).toBeInTheDocument();
expect(getByLabelText('maxp')).toHaveValue(null);
});
test('should clear max depth', () => {
const onChange = jest.fn();
const { getByLabelText } = render(
<MaxDepthFilter value={10} onChange={onChange} />,
);
userEvent.click(getByLabelText('clear max depth'));
expect(onChange).toBeCalledWith(Number.POSITIVE_INFINITY);
});
test('should set max depth to undefined if below one', () => {
const onChange = jest.fn();
const { getByLabelText } = render(
<MaxDepthFilter value={1} onChange={onChange} />,
);
userEvent.clear(getByLabelText('maxp'));
userEvent.type(getByLabelText('maxp'), '0');
expect(onChange).toBeCalledWith(Number.POSITIVE_INFINITY);
});
test('should select direction', async () => {
const onChange = jest.fn();
const { getByLabelText } = render(
<MaxDepthFilter value={5} onChange={onChange} />,
);
expect(getByLabelText('maxp')).toHaveValue(5);
userEvent.clear(getByLabelText('maxp'));
userEvent.type(getByLabelText('maxp'), '10');
expect(onChange).toBeCalledWith(10);
});
});
@@ -0,0 +1,83 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Box,
FormControl,
IconButton,
InputAdornment,
makeStyles,
OutlinedInput,
Typography,
} from '@material-ui/core';
import ClearIcon from '@material-ui/icons/Clear';
import React, { useCallback } from 'react';
export type Props = {
value: number;
onChange: (value: number) => void;
};
const useStyles = makeStyles({
formControl: {
width: '100%',
maxWidth: 300,
},
});
export const MaxDepthFilter = ({ value, onChange }: Props) => {
const classes = useStyles();
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(event.target.value);
onChange(v <= 0 ? Number.POSITIVE_INFINITY : v);
},
[onChange],
);
const reset = useCallback(() => {
onChange(Number.POSITIVE_INFINITY);
}, [onChange]);
return (
<Box pb={1} pt={1}>
<FormControl variant="outlined" className={classes.formControl}>
<Typography variant="button">Max Depth</Typography>
<OutlinedInput
type="number"
placeholder="∞ Infinite"
value={isFinite(value) ? value : ''}
onChange={handleChange}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="clear max depth"
onClick={reset}
edge="end"
>
<ClearIcon />
</IconButton>
</InputAdornment>
}
inputProps={{
'aria-label': 'maxp',
}}
labelWidth={0}
/>
</FormControl>
</Box>
);
};
@@ -0,0 +1,107 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi as useApiMocked } from '@backstage/core-plugin-api';
import { useEntityKinds as useEntityKindsMocked } from '@backstage/plugin-catalog-react';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { SelectedKindsFilter } from './SelectedKindsFilter';
jest.mock('@backstage/core-plugin-api');
jest.mock('@backstage/plugin-catalog-react');
const useApi = useApiMocked as jest.Mock;
const useEntityKinds = useEntityKindsMocked as jest.Mock;
describe('<SelectedKindsFilter/>', () => {
beforeEach(() => {
useApi.mockReturnValue({});
useEntityKinds.mockReturnValue({
kinds: ['API', 'Component', 'System', 'Domain', 'Resource'],
});
});
afterEach(() => jest.resetAllMocks());
test('should not explode while loading', () => {
useEntityKinds.mockReturnValue({});
const { baseElement } = render(
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />,
);
expect(baseElement).toBeInTheDocument();
});
test('should render current value', () => {
const { getByText } = render(
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />,
);
expect(getByText('API')).toBeInTheDocument();
expect(getByText('Component')).toBeInTheDocument();
});
test('should select value', async () => {
const onChange = jest.fn();
const { getByText, getByLabelText } = render(
<SelectedKindsFilter value={['api', 'component']} onChange={onChange} />,
);
userEvent.click(getByLabelText('Open'));
await waitFor(() => expect(getByText('System')).toBeInTheDocument());
userEvent.click(getByText('System'));
await waitFor(() => {
expect(onChange).toBeCalledWith(['api', 'component', 'system']);
});
});
test('should return undefined if all values are selected', async () => {
const onChange = jest.fn();
const { getByText, getByLabelText } = render(
<SelectedKindsFilter
value={['api', 'component', 'system', 'domain']}
onChange={onChange}
/>,
);
userEvent.click(getByLabelText('Open'));
await waitFor(() => expect(getByText('Resource')).toBeInTheDocument());
userEvent.click(getByText('Resource'));
await waitFor(() => {
expect(onChange).toBeCalledWith(undefined);
});
});
test('should return all values when cleared', async () => {
const onChange = jest.fn();
const { getByRole } = render(
<SelectedKindsFilter value={[]} onChange={onChange} />,
);
userEvent.click(getByRole('combobox'));
userEvent.tab();
await waitFor(() => {
expect(onChange).toBeCalledWith(undefined);
});
});
});
@@ -0,0 +1,113 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import { useEntityKinds } from '@backstage/plugin-catalog-react';
import {
Box,
Checkbox,
FormControlLabel,
makeStyles,
TextField,
Typography,
} from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import React, { useCallback, useEffect, useMemo } from 'react';
const useStyles = makeStyles({
formControl: {
maxWidth: 300,
},
});
export type Props = {
value: string[] | undefined;
onChange: (value: string[] | undefined) => void;
};
export const SelectedKindsFilter = ({ value, onChange }: Props) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const { error, kinds } = useEntityKinds();
useEffect(() => {
if (error) {
alertApi.post({
message: `Failed to load entity kinds`,
severity: 'error',
});
}
}, [error, alertApi]);
const normalizedKinds = useMemo(
() => (kinds ? kinds.map(k => k.toLowerCase()) : kinds),
[kinds],
);
const handleChange = useCallback(
(_: unknown, v: string[]) => {
onChange(
normalizedKinds && normalizedKinds.every(r => v.includes(r))
? undefined
: v,
);
},
[normalizedKinds, onChange],
);
const handleEmpty = useCallback(() => {
onChange(value?.length ? value : undefined);
}, [value, onChange]);
if (!kinds?.length || !normalizedKinds?.length || error) {
return <></>;
}
return (
<Box pb={1} pt={1}>
<Typography variant="button">Kinds</Typography>
<Autocomplete<string>
className={classes.formControl}
multiple
limitTags={4}
disableCloseOnSelect
aria-label="Kinds"
options={normalizedKinds}
value={value ?? normalizedKinds}
getOptionLabel={k => kinds[normalizedKinds.indexOf(k)] ?? k}
onChange={handleChange}
onBlur={handleEmpty}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={selected}
/>
}
label={kinds[normalizedKinds.indexOf(option)] ?? option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="selected-kinds-expand" />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</Box>
);
};
@@ -0,0 +1,110 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RELATION_CHILD_OF,
RELATION_HAS_MEMBER,
RELATION_OWNED_BY,
} from '@backstage/catalog-model';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { RELATION_PAIRS } from '../EntityRelationsGraph';
import { SelectedRelationsFilter } from './SelectedRelationsFilter';
describe('<SelectedRelationsFilter/>', () => {
test('should render current value', () => {
const { getByText } = render(
<SelectedRelationsFilter
relationPairs={RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={() => {}}
/>,
);
expect(getByText(RELATION_OWNED_BY)).toBeInTheDocument();
expect(getByText(RELATION_CHILD_OF)).toBeInTheDocument();
});
test('should select value', async () => {
const onChange = jest.fn();
const { getByText, getByLabelText } = render(
<SelectedRelationsFilter
relationPairs={RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={onChange}
/>,
);
userEvent.click(getByLabelText('Open'));
await waitFor(() =>
expect(getByText(RELATION_HAS_MEMBER)).toBeInTheDocument(),
);
userEvent.click(getByText(RELATION_HAS_MEMBER));
await waitFor(() => {
expect(onChange).toBeCalledWith([
RELATION_OWNED_BY,
RELATION_CHILD_OF,
RELATION_HAS_MEMBER,
]);
});
});
test('should return undefined if all values are selected', async () => {
const onChange = jest.fn();
const { getByText, getByLabelText } = render(
<SelectedRelationsFilter
relationPairs={RELATION_PAIRS}
value={RELATION_PAIRS.flatMap(p => p).filter(
r => r !== RELATION_HAS_MEMBER,
)}
onChange={onChange}
/>,
);
userEvent.click(getByLabelText('Open'));
await waitFor(() =>
expect(getByText(RELATION_HAS_MEMBER)).toBeInTheDocument(),
);
userEvent.click(getByText(RELATION_HAS_MEMBER));
await waitFor(() => {
expect(onChange).toBeCalledWith(undefined);
});
});
test('should return all values when cleared', async () => {
const onChange = jest.fn();
const { getByRole } = render(
<SelectedRelationsFilter
relationPairs={RELATION_PAIRS}
value={[]}
onChange={onChange}
/>,
);
userEvent.click(getByRole('combobox'));
userEvent.tab();
await waitFor(() => {
expect(onChange).toBeCalledWith(undefined);
});
});
});
@@ -0,0 +1,96 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Box,
Checkbox,
FormControlLabel,
makeStyles,
TextField,
Typography,
} from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import React, { useCallback, useMemo } from 'react';
import { RelationPairs } from '../EntityRelationsGraph';
const useStyles = makeStyles({
formControl: {
maxWidth: 300,
},
});
export type Props = {
relationPairs: RelationPairs;
value: string[] | undefined;
onChange: (value: string[] | undefined) => void;
};
export const SelectedRelationsFilter = ({
relationPairs,
value,
onChange,
}: Props) => {
const classes = useStyles();
const relations = useMemo(
() => relationPairs.flatMap(r => r),
[relationPairs],
);
const handleChange = useCallback(
(_: unknown, v: string[]) => {
onChange(relations.every(r => v.includes(r)) ? undefined : v);
},
[relations, onChange],
);
const handleEmpty = useCallback(() => {
onChange(value?.length ? value : undefined);
}, [value, onChange]);
return (
<Box pb={1} pt={1}>
<Typography variant="button">Relations</Typography>
<Autocomplete<string>
className={classes.formControl}
multiple
limitTags={4}
disableCloseOnSelect
aria-label="Relations"
options={relations}
value={value ?? relations}
onChange={handleChange}
onBlur={handleEmpty}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={selected}
/>
}
label={option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="selected-relations-expand" />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</Box>
);
};
@@ -0,0 +1,44 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { SwitchFilter } from './SwitchFilter';
describe('<SwitchFilter/>', () => {
test('should render value', () => {
const { getByLabelText } = render(
<SwitchFilter label="My label" value={false} onChange={() => {}} />,
);
expect(getByLabelText('My label')).toBeInTheDocument();
expect(getByLabelText('My label')).not.toBeChecked();
});
test('should toggle value', () => {
const onChange = jest.fn();
const { getByLabelText } = render(
<SwitchFilter label="My label" value onChange={onChange} />,
);
expect(getByLabelText('My label')).toBeInTheDocument();
expect(getByLabelText('My label')).toBeChecked();
userEvent.click(getByLabelText('My label'));
expect(onChange).toBeCalledWith(false);
});
});
@@ -0,0 +1,58 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Box, FormControlLabel, makeStyles, Switch } from '@material-ui/core';
import React, { useCallback } from 'react';
export type Props = {
label: string;
value: boolean;
onChange: (value: boolean) => void;
};
const useStyles = makeStyles({
root: {
width: '100%',
maxWidth: 300,
},
});
export const SwitchFilter = ({ label, value, onChange }: Props) => {
const classes = useStyles();
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked);
},
[onChange],
);
return (
<Box pb={1} pt={1}>
<FormControlLabel
control={
<Switch
checked={value}
onChange={handleChange}
name={label}
color="primary"
/>
}
label={label}
className={classes.root}
/>
</Box>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CatalogGraphPage } from './CatalogGraphPage';
@@ -0,0 +1,133 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RELATION_MEMBER_OF } from '@backstage/catalog-model';
import { act, renderHook } from '@testing-library/react-hooks';
import { useLocation as useLocationMocked } from 'react-router';
import { Direction } from '../EntityRelationsGraph';
import { useCatalogGraphPage } from './useCatalogGraphPage';
jest.mock('react-router', () => ({
useLocation: jest.fn(),
}));
jest.spyOn(window.history, 'replaceState');
jest.spyOn(window.history, 'pushState');
const useLocation = useLocationMocked as jest.Mock;
const windowHistoryReplaceState = window.history.replaceState as jest.Mock;
const windowHistoryPushState = window.history.replaceState as jest.Mock;
describe('useCatalogGraphPage', () => {
beforeEach(() => {
useLocation.mockReturnValue({
search: '?',
});
});
afterEach(() => jest.resetAllMocks());
test('should use initial state', () => {
const { result } = renderHook(() =>
useCatalogGraphPage({
initialState: {
rootEntityRefs: ['b:d/c'],
maxDepth: 2,
direction: Direction.RIGHT_LEFT,
mergeRelations: false,
unidirectional: false,
showFilters: false,
selectedKinds: ['API'],
selectedRelations: [RELATION_MEMBER_OF],
},
}),
);
expect(result.current.rootEntityNames).toEqual([
{ kind: 'b', namespace: 'd', name: 'c' },
]);
expect(result.current.maxDepth).toEqual(2);
expect(result.current.direction).toEqual(Direction.RIGHT_LEFT);
expect(result.current.mergeRelations).toEqual(false);
expect(result.current.unidirectional).toEqual(false);
expect(result.current.showFilters).toEqual(false);
expect(result.current.selectedKinds).toEqual(['api']);
expect(result.current.selectedRelations).toEqual([RELATION_MEMBER_OF]);
});
test('should use state from url', () => {
useLocation.mockReturnValueOnce({
search:
'?rootEntityRefs[]=b:d/c&maxDepth=2&direction=RL&mergeRelations=false&unidirectional=false&showFilters=false&selectedKinds[]=api&selectedRelations[]=memberOf',
});
const { result } = renderHook(() => useCatalogGraphPage({}));
expect(result.current.rootEntityNames).toEqual([
{ kind: 'b', namespace: 'd', name: 'c' },
]);
expect(result.current.maxDepth).toEqual(2);
expect(result.current.direction).toEqual(Direction.RIGHT_LEFT);
expect(result.current.mergeRelations).toEqual(false);
expect(result.current.unidirectional).toEqual(false);
expect(result.current.showFilters).toEqual(false);
expect(result.current.selectedKinds).toEqual(['api']);
expect(result.current.selectedRelations).toEqual([RELATION_MEMBER_OF]);
});
test('should update state in url (replace if setting changes)', () => {
const { result } = renderHook(() => useCatalogGraphPage({}));
act(() => result.current.setMaxDepth(5));
expect(windowHistoryReplaceState).toBeCalledWith(
null,
'',
'/?maxDepth=5&unidirectional=true&mergeRelations=true&direction=LR&showFilters=true',
);
act(() => result.current.setUnidirectional(false));
expect(windowHistoryReplaceState).toBeCalledWith(
null,
'',
'/?maxDepth=5&unidirectional=false&mergeRelations=true&direction=LR&showFilters=true',
);
});
test('should update state in url (only push if different root entity)', () => {
const { result, rerender } = renderHook(() =>
useCatalogGraphPage({
initialState: {
rootEntityRefs: ['component:default/first'],
},
}),
);
act(() =>
result.current.setRootEntityNames([
{ kind: 'Component', namespace: 'default', name: 'my' },
]),
);
rerender();
expect(windowHistoryPushState).toBeCalledWith(
null,
'',
'/?rootEntityRefs%5B%5D=component%3Adefault%2Fmy&maxDepth=%E2%88%9E&unidirectional=true&mergeRelations=true&direction=LR&showFilters=true',
);
});
});
@@ -0,0 +1,258 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
EntityName,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import qs from 'qs';
import {
Dispatch,
DispatchWithoutAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useLocation } from 'react-router';
import { usePrevious } from 'react-use';
import { Direction } from '../EntityRelationsGraph';
export type CatalogGraphPageValue = {
rootEntityNames: EntityName[];
setRootEntityNames: Dispatch<React.SetStateAction<EntityName[]>>;
maxDepth: number;
setMaxDepth: Dispatch<React.SetStateAction<number>>;
selectedRelations: string[] | undefined;
setSelectedRelations: Dispatch<React.SetStateAction<string[] | undefined>>;
selectedKinds: string[] | undefined;
setSelectedKinds: Dispatch<React.SetStateAction<string[] | undefined>>;
unidirectional: boolean;
setUnidirectional: Dispatch<React.SetStateAction<boolean>>;
mergeRelations: boolean;
setMergeRelations: Dispatch<React.SetStateAction<boolean>>;
direction: Direction;
setDirection: Dispatch<React.SetStateAction<Direction>>;
showFilters: boolean;
toggleShowFilters: DispatchWithoutAction;
};
export function useCatalogGraphPage({
initialState = {},
}: {
initialState?: {
selectedRelations?: string[] | undefined;
selectedKinds?: string[] | undefined;
rootEntityRefs?: string[];
maxDepth?: number;
unidirectional?: boolean;
mergeRelations?: boolean;
direction?: Direction;
showFilters?: boolean;
};
}): CatalogGraphPageValue {
const location = useLocation();
const query = useMemo(
() =>
(qs.parse(location.search, { arrayLimit: 0, ignoreQueryPrefix: true }) ||
{}) as {
selectedRelations?: string[] | string;
selectedKinds?: string[] | string;
rootEntityRefs?: string[] | string;
maxDepth?: string[] | string;
unidirectional?: string[] | string;
mergeRelations?: string[] | string;
direction?: string[] | Direction;
showFilters?: string[] | string;
},
[location.search],
);
// Initial state
const [rootEntityNames, setRootEntityNames] = useState<EntityName[]>(() =>
(Array.isArray(query.rootEntityRefs)
? query.rootEntityRefs
: initialState?.rootEntityRefs ?? []
).map(r => parseEntityRef(r)),
);
const [maxDepth, setMaxDepth] = useState<number>(() =>
typeof query.maxDepth === 'string'
? parseMaxDepth(query.maxDepth)
: initialState?.maxDepth ?? Number.POSITIVE_INFINITY,
);
const [selectedRelations, setSelectedRelations] = useState<
string[] | undefined
>(() =>
Array.isArray(query.selectedRelations)
? query.selectedRelations
: initialState?.selectedRelations,
);
const [selectedKinds, setSelectedKinds] = useState<string[] | undefined>(() =>
(Array.isArray(query.selectedKinds)
? query.selectedKinds
: initialState?.selectedKinds
)?.map(k => k.toLowerCase()),
);
const [unidirectional, setUnidirectional] = useState<boolean>(() =>
typeof query.unidirectional === 'string'
? query.unidirectional === 'true'
: initialState?.unidirectional ?? true,
);
const [mergeRelations, setMergeRelations] = useState<boolean>(() =>
typeof query.mergeRelations === 'string'
? query.mergeRelations === 'true'
: initialState?.mergeRelations ?? true,
);
const [direction, setDirection] = useState<Direction>(() =>
typeof query.direction === 'string'
? query.direction
: initialState?.direction ?? Direction.LEFT_RIGHT,
);
const [showFilters, setShowFilters] = useState<boolean>(() =>
typeof query.showFilters === 'string'
? query.showFilters === 'true'
: initialState?.showFilters ?? true,
);
const toggleShowFilters = useCallback(
() => setShowFilters(s => !s),
[setShowFilters],
);
// Update from query parameters
const prevQueryParams = usePrevious(location.search);
useEffect(() => {
// Only respond to changes to url query params
if (location.search === prevQueryParams) {
return;
}
if (Array.isArray(query.rootEntityRefs)) {
setRootEntityNames(query.rootEntityRefs.map(r => parseEntityRef(r)));
}
if (typeof query.maxDepth === 'string') {
setMaxDepth(parseMaxDepth(query.maxDepth));
}
if (Array.isArray(query.selectedKinds)) {
setSelectedKinds(query.selectedKinds);
}
if (Array.isArray(query.selectedRelations)) {
setSelectedRelations(query.selectedRelations);
}
if (typeof query.unidirectional === 'string') {
setUnidirectional(query.unidirectional === 'true');
}
if (typeof query.mergeRelations === 'string') {
setMergeRelations(query.mergeRelations === 'true');
}
if (typeof query.direction === 'string') {
setDirection(query.direction);
}
if (typeof query.showFilters === 'string') {
setShowFilters(query.showFilters === 'true');
}
}, [
prevQueryParams,
location.search,
query,
setRootEntityNames,
setMaxDepth,
setSelectedKinds,
setSelectedRelations,
setUnidirectional,
setMergeRelations,
setDirection,
setShowFilters,
]);
// Update query parameters
const previousRootEntityRefs = usePrevious(
rootEntityNames.map(e => stringifyEntityRef(e)),
);
useEffect(() => {
const rootEntityRefs = rootEntityNames.map(e => stringifyEntityRef(e));
const newParams = qs.stringify(
{
rootEntityRefs,
maxDepth: isFinite(maxDepth) ? maxDepth : '∞',
selectedKinds,
selectedRelations,
unidirectional,
mergeRelations,
direction,
showFilters,
},
{ arrayFormat: 'brackets', addQueryPrefix: true },
);
const newUrl = `${window.location.pathname}${newParams}`;
// We directly manipulate window history here in order to not re-render
// infinitely (state => location => state => etc). The intention of this
// code is just to ensure the right query/filters are loaded when a user
// clicks the "back" button after clicking a result.
// Only push a new history entry if we switched to another entity, but not
// if we just changed a viewer setting.
if (
!previousRootEntityRefs ||
(rootEntityRefs.length === previousRootEntityRefs.length &&
rootEntityRefs.every((v, i) => v === previousRootEntityRefs[i]))
) {
window.history.replaceState(null, document.title, newUrl);
} else {
window.history.pushState(null, document.title, newUrl);
}
}, [
rootEntityNames,
maxDepth,
selectedKinds,
selectedRelations,
unidirectional,
mergeRelations,
direction,
showFilters,
previousRootEntityRefs,
]);
return {
rootEntityNames,
setRootEntityNames,
maxDepth,
setMaxDepth,
selectedRelations,
setSelectedRelations,
selectedKinds,
setSelectedKinds,
unidirectional,
setUnidirectional,
mergeRelations,
setMergeRelations,
direction,
setDirection,
showFilters,
toggleShowFilters,
};
}
function parseMaxDepth(value: string): number {
return value === '∞' ? Number.POSITIVE_INFINITY : Number(value);
}
@@ -0,0 +1,71 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RELATION_CHILD_OF,
RELATION_PARENT_OF,
} from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { CustomLabel } from './CustomLabel';
describe('<CustomLabel />', () => {
test('renders label', () => {
const { getByText } = render(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomLabel
edge={{
label: 'visible',
relations: [RELATION_PARENT_OF],
from: 'from-id',
to: 'to-id',
id: 'id',
x: 111,
y: 222,
width: 100,
height: 25,
points: [],
}}
/>
</svg>,
);
expect(getByText(RELATION_PARENT_OF)).toBeInTheDocument();
});
test('renders label with multiple relations', () => {
const { getByText } = render(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomLabel
edge={{
label: 'visible',
relations: [RELATION_PARENT_OF, RELATION_CHILD_OF],
from: 'from-id',
to: 'to-id',
id: 'id',
x: 111,
y: 222,
width: 100,
height: 25,
points: [],
}}
/>
</svg>,
);
expect(getByText(RELATION_PARENT_OF)).toBeInTheDocument();
expect(getByText(RELATION_CHILD_OF)).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DependencyGraphTypes } from '@backstage/core-components';
import { BackstageTheme } from '@backstage/theme';
import makeStyles from '@material-ui/core/styles/makeStyles';
import React from 'react';
import { GraphEdge } from './types';
import classNames from 'classnames';
const useStyles = makeStyles((theme: BackstageTheme) => ({
text: {
fill: theme.palette.textContrast,
},
secondary: {
fill: theme.palette.textSubtle,
},
}));
export function CustomLabel({
edge: { relations },
}: DependencyGraphTypes.RenderLabelProps<GraphEdge>) {
const classes = useStyles();
return (
<text className={classes.text} textAnchor="middle">
{relations.map((r, i) => (
<tspan key={r} className={classNames(i > 0 && classes.secondary)}>
{i > 0 && <tspan> / </tspan>}
{r}
</tspan>
))}
</text>
);
}
@@ -0,0 +1,122 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { CustomNode } from './CustomNode';
import userEvent from '@testing-library/user-event';
describe('<CustomNode />', () => {
beforeAll(() => {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
test('renders node', async () => {
const { getByText } = await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
node={{
focused: false,
kind: 'kind',
name: 'name',
namespace: 'namespace',
id: 'kind:namespace/name',
x: 111,
y: 222,
width: 100,
height: 25,
color: 'primary',
}}
/>
</svg>,
);
expect(getByText('kind:namespace/name')).toBeInTheDocument();
});
test('renders node, skips default namespace', async () => {
const { getByText } = await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
node={{
focused: false,
kind: 'kind',
name: 'name',
namespace: 'default',
id: 'kind:default/name',
x: 111,
y: 222,
width: 100,
height: 25,
}}
/>
</svg>,
);
expect(getByText('kind:name')).toBeInTheDocument();
});
test('renders node with onClick', async () => {
const onClick = jest.fn();
const { getByText } = await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
node={{
focused: false,
kind: 'kind',
name: 'name',
namespace: 'namespace',
onClick,
id: 'kind:namespace/name',
x: 111,
y: 222,
width: 100,
height: 25,
}}
/>
</svg>,
);
expect(getByText('kind:namespace/name')).toBeInTheDocument();
userEvent.click(getByText('kind:namespace/name'));
expect(onClick).toBeCalledTimes(1);
});
test('renders title if entity has one', async () => {
const { getByText } = await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
node={{
focused: false,
kind: 'kind',
name: 'name',
namespace: 'namespace',
title: 'Custom Title',
id: 'kind:namespace/name',
x: 111,
y: 222,
width: 100,
height: 25,
}}
/>
</svg>,
);
expect(getByText('Custom Title')).toBeInTheDocument();
});
});
@@ -0,0 +1,145 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DependencyGraphTypes } from '@backstage/core-components';
import { formatEntityRefTitle } from '@backstage/plugin-catalog-react';
import { BackstageTheme } from '@backstage/theme';
import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
import React, { useLayoutEffect, useRef, useState } from 'react';
import { EntityKindIcon } from './EntityKindIcon';
import { GraphNode } from './types';
const useStyles = makeStyles((theme: BackstageTheme) => ({
node: {
fill: theme.palette.grey[300],
stroke: theme.palette.grey[300],
'&.primary': {
fill: theme.palette.primary.light,
stroke: theme.palette.primary.light,
},
'&.secondary': {
fill: theme.palette.secondary.light,
stroke: theme.palette.secondary.light,
},
},
text: {
fill: theme.palette.getContrastText(theme.palette.grey[300]),
'&.primary': {
fill: theme.palette.primary.contrastText,
},
'&.secondary': {
fill: theme.palette.secondary.contrastText,
},
'&.focused': {
fontWeight: 'bold',
},
},
clickable: {
cursor: 'pointer',
},
}));
export function CustomNode({
node: {
id,
kind,
namespace,
name,
color = 'default',
focused,
title,
onClick,
},
}: DependencyGraphTypes.RenderNodeProps<GraphNode>) {
const classes = useStyles();
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const idRef = useRef<SVGTextElement | null>(null);
useLayoutEffect(() => {
// set the width to the length of the ID
if (idRef.current) {
let { height: renderedHeight, width: renderedWidth } =
idRef.current.getBBox();
renderedHeight = Math.round(renderedHeight);
renderedWidth = Math.round(renderedWidth);
if (renderedHeight !== height || renderedWidth !== width) {
setWidth(renderedWidth);
setHeight(renderedHeight);
}
}
}, [width, height]);
const padding = 10;
const iconSize = height;
const paddedIconWidth = kind ? iconSize + padding : 0;
const paddedWidth = paddedIconWidth + width + padding * 2;
const paddedHeight = height + padding * 2;
const displayTitle =
title ??
(kind && name && namespace
? formatEntityRefTitle({ kind, name, namespace })
: id);
return (
<g onClick={onClick} className={classNames(onClick && classes.clickable)}>
<rect
className={classNames(
classes.node,
color === 'primary' && 'primary',
color === 'secondary' && 'secondary',
)}
width={paddedWidth}
height={paddedHeight}
rx={10}
/>
{kind && (
<EntityKindIcon
kind={kind}
y={padding}
x={padding}
width={iconSize}
height={iconSize}
className={classNames(
classes.text,
focused && 'focused',
color === 'primary' && 'primary',
color === 'secondary' && 'secondary',
)}
/>
)}
<text
ref={idRef}
className={classNames(
classes.text,
focused && 'focused',
color === 'primary' && 'primary',
color === 'secondary' && 'secondary',
)}
y={paddedHeight / 2}
x={paddedIconWidth + (width + padding * 2) / 2}
textAnchor="middle"
alignmentBaseline="middle"
>
{displayTitle}
</text>
</g>
);
}
@@ -0,0 +1,36 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { EntityKindIcon } from './EntityKindIcon';
describe('<EntityKindIcon />', () => {
it('renders without exploding', async () => {
const { baseElement } = await renderInTestApp(
<EntityKindIcon kind="Component" />,
);
expect(baseElement.querySelector('.MuiSvgIcon-root')).toBeInTheDocument();
});
it('renders without exploding for unknown kind', async () => {
const { baseElement } = await renderInTestApp(
<EntityKindIcon kind="unknown" />,
);
expect(baseElement.querySelector('.MuiSvgIcon-root')).toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApp } from '@backstage/core-plugin-api';
import WorkIcon from '@material-ui/icons/Work';
import React from 'react';
export function EntityKindIcon({
kind,
...props
}: {
kind: string;
x?: number;
y?: number;
width?: number;
height?: number;
className?: string;
}) {
const app = useApp();
const Icon =
app.getSystemIcon(`kind:${kind.toLocaleLowerCase('en-US')}`) ?? WorkIcon;
return <Icon {...props} />;
}
@@ -0,0 +1,410 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Entity,
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import userEvent from '@testing-library/user-event';
import React, { FunctionComponent } from 'react';
import { EntityRelationsGraph } from './EntityRelationsGraph';
describe('<EntityRelationsGraph/>', () => {
let Wrapper: FunctionComponent;
let catalog: jest.Mocked<CatalogApi>;
beforeAll(() => {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
beforeEach(() => {
const entities: { [key: string]: Entity } = {
'b:d/c': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [
{
target: {
kind: 'k',
name: 'a1',
namespace: 'd',
},
type: RELATION_OWNER_OF,
},
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_HAS_PART,
},
],
},
'k:d/a1': {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'a1',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c',
namespace: 'd',
},
type: RELATION_OWNED_BY,
},
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_OWNED_BY,
},
],
},
'b:d/c1': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c1',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c',
namespace: 'd',
},
type: RELATION_PART_OF,
},
{
target: {
kind: 'k',
name: 'a1',
namespace: 'd',
},
type: RELATION_OWNER_OF,
},
{
target: {
kind: 'b',
name: 'c2',
namespace: 'd',
},
type: RELATION_HAS_PART,
},
],
},
'b:d/c2': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c2',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_PART_OF,
},
],
},
};
catalog = {
getEntities: jest.fn(),
getEntityByName: jest.fn(async n => entities[stringifyEntityRef(n)]),
removeEntityByUid: jest.fn(),
getLocationById: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalog);
Wrapper = ({ children }) => (
<ApiProvider apis={apis}>{children}</ApiProvider>
);
});
afterAll(() => {
jest.resetAllMocks();
});
test('renders a single node without exploding', async () => {
catalog.getEntityByName.mockResolvedValue({
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [],
});
const { findByText, findAllByTestId } = await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(1);
expect(catalog.getEntityByName).toBeCalledTimes(1);
});
test('renders a progress indicator while loading', async () => {
catalog.getEntityByName.mockImplementation(() => new Promise(() => {}));
const { findByRole } = await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
/>
</Wrapper>,
);
expect(await findByRole('progressbar')).toBeInTheDocument();
expect(catalog.getEntityByName).toBeCalledTimes(1);
});
test('does not explode if an entity is missing', async () => {
catalog.getEntityByName.mockImplementation(async n => {
if (n.name === 'c') {
return {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [
{
target: {
kind: 'component',
name: 'some-component',
namespace: 'default',
},
type: RELATION_OWNER_OF,
},
],
};
}
return undefined;
});
const { findByText, findAllByTestId } = await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(1);
expect(catalog.getEntityByName).toBeCalledTimes(2);
});
test('renders at max depth of one', async () => {
const { findByText, findAllByTestId, findAllByText } =
await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
maxDepth={1}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findByText('b:d/c1')).toBeInTheDocument();
expect(await findByText('k:d/a1')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(3);
expect(await findAllByText('ownerOf')).toHaveLength(1);
expect(await findAllByText('hasPart')).toHaveLength(1);
expect(await findAllByTestId('label')).toHaveLength(2);
expect(catalog.getEntityByName).toBeCalledTimes(3);
});
test('renders simplied graph at full depth', async () => {
const { findByText, findAllByText, findAllByTestId } =
await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
unidirectional
maxDepth={Number.POSITIVE_INFINITY}
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findByText('b:d/c1')).toBeInTheDocument();
expect(await findByText('k:d/a1')).toBeInTheDocument();
expect(await findByText('b:d/c2')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(4);
expect(await findAllByText('ownerOf')).toHaveLength(1);
expect(await findAllByText('hasPart')).toHaveLength(2);
expect(await findAllByTestId('label')).toHaveLength(3);
expect(catalog.getEntityByName).toBeCalledTimes(4);
});
test('renders full graph at full depth', async () => {
const { findAllByText, findByText, findAllByTestId } =
await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
unidirectional={false}
mergeRelations={false}
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findByText('b:d/c1')).toBeInTheDocument();
expect(await findByText('k:d/a1')).toBeInTheDocument();
expect(await findByText('b:d/c2')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(4);
expect(await findAllByText('ownerOf')).toHaveLength(2);
expect(await findAllByText('ownedBy')).toHaveLength(2);
expect(await findAllByText('hasPart')).toHaveLength(2);
expect(await findAllByText('partOf')).toHaveLength(2);
expect(await findAllByTestId('label')).toHaveLength(8);
expect(catalog.getEntityByName).toBeCalledTimes(4);
});
test('renders full graph at full depth with merged relations', async () => {
const { findAllByText, findByText, findAllByTestId } =
await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
unidirectional={false}
mergeRelations
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findByText('b:d/c1')).toBeInTheDocument();
expect(await findByText('k:d/a1')).toBeInTheDocument();
expect(await findByText('b:d/c2')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(4);
expect(await findAllByText('ownerOf')).toHaveLength(2);
expect(await findAllByText('hasPart')).toHaveLength(2);
expect(await findAllByTestId('label')).toHaveLength(4);
expect(catalog.getEntityByName).toBeCalledTimes(4);
});
test('renders a graph with multiple root nodes', async () => {
const { findAllByText, findByText, findAllByTestId } =
await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
rootEntityNames={[
{ kind: 'b', namespace: 'd', name: 'c' },
{ kind: 'b', namespace: 'd', name: 'c2' },
]}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findByText('b:d/c1')).toBeInTheDocument();
expect(await findByText('k:d/a1')).toBeInTheDocument();
expect(await findByText('b:d/c2')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(4);
expect(await findAllByText('ownerOf')).toHaveLength(1);
expect(await findAllByText('partOf')).toHaveLength(2);
expect(await findAllByTestId('label')).toHaveLength(3);
expect(catalog.getEntityByName).toBeCalledTimes(4);
});
test('renders a graph with filtered kinds and relations', async () => {
const { findAllByText, findByText, findAllByTestId } =
await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
relations={['ownerOf', 'ownedBy']}
kinds={['k']}
/>
</Wrapper>,
);
expect(await findByText('b:d/c')).toBeInTheDocument();
expect(await findByText('k:d/a1')).toBeInTheDocument();
expect(await findAllByTestId('node')).toHaveLength(2);
expect(await findAllByText('ownerOf')).toHaveLength(1);
expect(await findAllByTestId('label')).toHaveLength(1);
expect(catalog.getEntityByName).toBeCalledTimes(2);
});
test('handle clicks on a node', async () => {
const onNodeClick = jest.fn();
const { findByText } = await renderInTestApp(
<Wrapper>
<EntityRelationsGraph
rootEntityNames={{ kind: 'b', namespace: 'd', name: 'c' }}
onNodeClick={onNodeClick}
/>
</Wrapper>,
);
userEvent.click(await findByText('k:d/a1'));
expect(onNodeClick).toBeCalledTimes(1);
});
});
@@ -0,0 +1,132 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityName, stringifyEntityRef } from '@backstage/catalog-model';
import {
DependencyGraph,
DependencyGraphTypes,
} from '@backstage/core-components';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { CircularProgress, makeStyles, useTheme } from '@material-ui/core';
import classNames from 'classnames';
import React, { MouseEvent, useEffect, useMemo } from 'react';
import { CustomLabel } from './CustomLabel';
import { CustomNode } from './CustomNode';
import { RelationPairs, RELATION_PAIRS } from './relations';
import { Direction, EntityNode } from './types';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
const useStyles = makeStyles(theme => ({
progress: {
position: 'absolute',
left: '50%',
top: '50%',
marginLeft: '-20px',
marginTop: '-20px',
},
container: {
position: 'relative',
width: '100%',
display: 'flex',
flexDirection: 'column',
},
graph: {
width: '100%',
flex: 1,
// Right now there is no good way to style edges between nodes, we have to
// fallback to these hacks:
'& path[marker-end]': {
transition: 'filter 0.1s ease-in-out',
},
'& path[marker-end]:hover': {
filter: `drop-shadow(2px 2px 4px ${theme.palette.primary.dark});`,
},
'& g[data-testid=label]': {
transition: 'transform 0s',
},
},
}));
export const EntityRelationsGraph = ({
rootEntityNames,
maxDepth = Number.POSITIVE_INFINITY,
unidirectional = true,
mergeRelations = true,
kinds,
relations,
direction = Direction.LEFT_RIGHT,
onNodeClick,
relationPairs = RELATION_PAIRS,
className,
}: {
rootEntityNames: EntityName | EntityName[];
maxDepth?: number;
unidirectional?: boolean;
mergeRelations?: boolean;
kinds?: string[];
relations?: string[];
direction?: Direction;
onNodeClick?: (value: EntityNode, event: MouseEvent<SVGElement>) => void;
relationPairs?: RelationPairs;
className?: string;
}) => {
const theme = useTheme();
const classes = useStyles();
const rootEntityRefs = useMemo(
() =>
(Array.isArray(rootEntityNames)
? rootEntityNames
: [rootEntityNames]
).map(e => stringifyEntityRef(e)),
[rootEntityNames],
);
const errorApi = useApi(errorApiRef);
const { loading, error, nodes, edges } = useEntityRelationNodesAndEdges({
rootEntityRefs,
maxDepth,
unidirectional,
mergeRelations,
kinds,
relations,
onNodeClick,
relationPairs,
});
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [errorApi, error]);
return (
<div className={classNames(classes.container, className)}>
{loading && <CircularProgress className={classes.progress} />}
{nodes && edges && (
<DependencyGraph
nodes={nodes}
edges={edges}
renderNode={CustomNode}
renderLabel={CustomLabel}
direction={direction}
className={classes.graph}
paddingX={theme.spacing(4)}
paddingY={theme.spacing(4)}
labelPosition={DependencyGraphTypes.LabelPosition.RIGHT}
labelOffset={theme.spacing(1)}
/>
)}
</div>
);
};
@@ -0,0 +1,20 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityRelationsGraph } from './EntityRelationsGraph';
export { RELATION_PAIRS } from './relations';
export type { RelationPairs } from './relations';
export { Direction } from './types';
export type { EntityEdge, EntityNode } from './types';
@@ -0,0 +1,48 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CHILD_OF,
RELATION_CONSUMES_API,
RELATION_DEPENDENCY_OF,
RELATION_DEPENDS_ON,
RELATION_HAS_MEMBER,
RELATION_HAS_PART,
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PARENT_OF,
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
export type RelationPairs = [string, string][];
// TODO: This file only contains the pairs for the build-in relations.
// How to implement this when custom relations are used? Right now you can pass
// the relations everywhere.
// Another option is to move this into @backstage/catalog-model
export const RELATION_PAIRS: RelationPairs = [
[RELATION_OWNER_OF, RELATION_OWNED_BY],
[RELATION_CONSUMES_API, RELATION_API_CONSUMED_BY],
[RELATION_API_PROVIDED_BY, RELATION_PROVIDES_API],
[RELATION_HAS_PART, RELATION_PART_OF],
[RELATION_PARENT_OF, RELATION_CHILD_OF],
[RELATION_HAS_MEMBER, RELATION_MEMBER_OF],
[RELATION_DEPENDS_ON, RELATION_DEPENDENCY_OF],
];
@@ -0,0 +1,44 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DependencyGraphTypes } from '@backstage/core-components';
import { MouseEventHandler } from 'react';
export type EntityEdge = DependencyGraphTypes.DependencyEdge<{
relations: string[];
// Not used, but has to be non empty to draw a label at all!
label: 'visible';
}>;
export type EntityNode = DependencyGraphTypes.DependencyNode<{
name: string;
kind?: string;
title?: string;
namespace: string;
focused?: boolean;
color?: 'primary' | 'secondary' | 'default';
onClick?: MouseEventHandler<SVGGElement>;
}>;
export type GraphEdge = DependencyGraphTypes.GraphEdge<EntityEdge>;
export type GraphNode = DependencyGraphTypes.GraphNode<EntityNode>;
export enum Direction {
TOP_BOTTOM = 'TB',
BOTTOM_TOP = 'BT',
LEFT_RIGHT = 'LR',
RIGHT_LEFT = 'RL',
}
@@ -0,0 +1,365 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
} from '@backstage/catalog-model';
import { renderHook } from '@testing-library/react-hooks';
import { pick } from 'lodash';
import { useEntityRelationGraph } from './useEntityRelationGraph';
import { useEntityStore as useEntityStoreMocked } from './useEntityStore';
jest.mock('./useEntityStore');
const useEntityStore = useEntityStoreMocked as jest.Mock;
describe('useEntityRelationGraph', () => {
const requestEntities = jest.fn();
beforeEach(() => {
const entities = {
'b:d/c': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [
{
target: {
kind: 'k',
name: 'a1',
namespace: 'd',
},
type: RELATION_OWNER_OF,
},
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_HAS_PART,
},
],
},
'k:d/a1': {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'a1',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c',
namespace: 'd',
},
type: RELATION_OWNED_BY,
},
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_OWNED_BY,
},
],
},
'b:d/c1': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c1',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c',
namespace: 'd',
},
type: RELATION_PART_OF,
},
{
target: {
kind: 'k',
name: 'a1',
namespace: 'd',
},
type: RELATION_OWNER_OF,
},
{
target: {
kind: 'b',
name: 'c2',
namespace: 'd',
},
type: RELATION_HAS_PART,
},
],
},
'b:d/c2': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c2',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_PART_OF,
},
],
},
};
let rootEntityRefsFilter: string[] = [];
requestEntities.mockImplementation(r => {
rootEntityRefsFilter = r;
});
useEntityStore.mockImplementation(() => ({
loading: false,
entities: pick(entities, rootEntityRefsFilter),
requestEntities,
}));
});
afterEach(() => jest.resetAllMocks());
test('should return no entities for empty root entity refs', async () => {
useEntityStore.mockReturnValue({
loading: false,
entities: {},
error: undefined,
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
);
const { entities, loading, error } = result.current;
expect(error).toBeUndefined();
expect(loading).toBe(false);
expect(entities).toEqual({});
expect(requestEntities).toHaveBeenNthCalledWith(1, []);
});
test('should pass through loading state', async () => {
useEntityStore.mockReturnValue({
loading: true,
entities: {},
error: undefined,
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
);
const { entities, loading, error } = result.current;
expect(error).toBeUndefined();
expect(loading).toBe(true);
expect(entities).toEqual({});
expect(requestEntities).toHaveBeenNthCalledWith(1, []);
});
test('should pass through error state', async () => {
const err = new Error('Hello World');
useEntityStore.mockReturnValue({
loading: false,
entities: {},
error: err,
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
);
const { entities, loading, error } = result.current;
expect(error).toBe(err);
expect(loading).toBe(false);
expect(entities).toEqual({});
expect(requestEntities).toHaveBeenNthCalledWith(1, []);
});
test('should walk relation tree', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }),
);
// Simulate rerendering as this is triggered automatically due to the mock
for (let i = 0; i < 5; ++i) {
rerender();
}
const { entities, loading, error } = result.current;
expect(error).toBeUndefined();
expect(loading).toBe(false);
expect(entities).toEqual({
'b:d/c': expect.anything(),
'b:d/c1': expect.anything(),
'b:d/c2': expect.anything(),
'k:d/a1': expect.anything(),
});
expect(requestEntities).toHaveBeenNthCalledWith(1, ['b:d/c']);
expect(requestEntities).toHaveBeenNthCalledWith(2, [
'b:d/c',
'k:d/a1',
'b:d/c1',
]);
expect(requestEntities).toHaveBeenNthCalledWith(3, [
'b:d/c',
'k:d/a1',
'b:d/c1',
'b:d/c2',
]);
});
test('should limit max depth', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth: 1 },
}),
);
// Simulate rerendering as this is triggered automatically due to the mock
for (let i = 0; i < 5; ++i) {
rerender();
}
expect(result.current.entities).toEqual({
'b:d/c': expect.anything(),
'b:d/c1': expect.anything(),
'k:d/a1': expect.anything(),
});
});
test('should update on filter change', async () => {
let maxDepth: number = Number.POSITIVE_INFINITY;
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth },
}),
);
// Simulate rerendering as this is triggered automatically due to the mock
for (let i = 0; i < 5; ++i) {
rerender();
}
expect(result.current.entities).toEqual({
'b:d/c': expect.anything(),
'b:d/c1': expect.anything(),
'b:d/c2': expect.anything(),
'k:d/a1': expect.anything(),
});
maxDepth = 1;
// Simulate rerendering as this is triggered automatically due to the mock
for (let i = 0; i < 5; ++i) {
rerender();
}
expect(result.current.entities).toEqual({
'b:d/c': expect.anything(),
'b:d/c1': expect.anything(),
'k:d/a1': expect.anything(),
});
});
test('should filter by relation', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_HAS_PART, RELATION_PART_OF],
},
}),
);
// Simulate rerendering as this is triggered automatically due to the mock
for (let i = 0; i < 5; ++i) {
rerender();
}
expect(result.current.entities).toEqual({
'b:d/c': expect.anything(),
'b:d/c1': expect.anything(),
'b:d/c2': expect.anything(),
});
});
test('should filter by kind', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_OWNED_BY, RELATION_OWNER_OF],
kinds: ['k'],
},
}),
);
// Simulate rerendering as this is triggered automatically due to the mock
for (let i = 0; i < 5; ++i) {
rerender();
}
expect(result.current.entities).toEqual({
'b:d/c': expect.anything(),
'k:d/a1': expect.anything(),
});
});
test('should support multiple roots by kind', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
);
// Simulate rerendering as this is triggered automatically due to the mock
for (let i = 0; i < 5; ++i) {
rerender();
}
expect(result.current.entities).toEqual({
'b:d/c': expect.anything(),
'b:d/c1': expect.anything(),
'b:d/c2': expect.anything(),
'k:d/a1': expect.anything(),
});
});
});
@@ -0,0 +1,90 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useEffect } from 'react';
import { useEntityStore } from './useEntityStore';
/**
* Discover the graph of entities connected by relations, starting from a set of
* root entities. Filters are used to select which relations to includes.
* Returns all discovered entities once they are loaded.
*/
export function useEntityRelationGraph({
rootEntityRefs,
filter: { maxDepth = Number.POSITIVE_INFINITY, relations, kinds } = {},
}: {
rootEntityRefs: string[];
filter?: {
maxDepth?: number;
relations?: string[];
kinds?: string[];
};
}): {
entities?: { [key: string]: Entity };
loading: boolean;
error?: Error;
} {
const { entities, loading, error, requestEntities } = useEntityStore();
useEffect(() => {
const expectedEntities = new Set([...rootEntityRefs]);
const processedEntityRefs = new Set<string>();
let nextDepthRefQueue = [...rootEntityRefs];
let depth = 0;
while (
nextDepthRefQueue.length > 0 &&
(!isFinite(maxDepth) || depth < maxDepth)
) {
const entityRefQueue = nextDepthRefQueue;
nextDepthRefQueue = [];
while (entityRefQueue.length > 0) {
const entityRef = entityRefQueue.shift()!;
const entity = entities[entityRef];
processedEntityRefs.add(entityRef);
if (entity && entity.relations) {
for (const rel of entity.relations) {
if (
(!relations || relations.includes(rel.type)) &&
(!kinds || kinds.includes(rel.target.kind.toLowerCase()))
) {
const relationEntityRef = stringifyEntityRef(rel.target);
if (!processedEntityRefs.has(relationEntityRef)) {
nextDepthRefQueue.push(relationEntityRef);
expectedEntities.add(relationEntityRef);
}
}
}
}
}
++depth;
}
requestEntities([...expectedEntities]);
}, [entities, rootEntityRefs, maxDepth, relations, kinds, requestEntities]);
return {
entities,
loading,
error,
};
}
@@ -0,0 +1,735 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Entity,
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { renderHook } from '@testing-library/react-hooks';
import { filter, keyBy } from 'lodash';
import { useEntityRelationGraph as useEntityRelationGraphMocked } from './useEntityRelationGraph';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
jest.mock('./useEntityRelationGraph');
const useEntityRelationGraph = useEntityRelationGraphMocked as jest.Mock;
describe('useEntityRelationNodesAndEdges', () => {
beforeEach(() => {
const entities: { [key: string]: Entity } = {
'b:d/c': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [
{
target: {
kind: 'k',
name: 'a1',
namespace: 'd',
},
type: RELATION_OWNER_OF,
},
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_HAS_PART,
},
],
},
'k:d/a1': {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'a1',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c',
namespace: 'd',
},
type: RELATION_OWNED_BY,
},
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_OWNED_BY,
},
],
},
'b:d/c1': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c1',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c',
namespace: 'd',
},
type: RELATION_PART_OF,
},
{
target: {
kind: 'k',
name: 'a1',
namespace: 'd',
},
type: RELATION_OWNER_OF,
},
{
target: {
kind: 'b',
name: 'c2',
namespace: 'd',
},
type: RELATION_HAS_PART,
},
],
},
'b:d/c2': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c2',
namespace: 'd',
},
relations: [
{
target: {
kind: 'b',
name: 'c1',
namespace: 'd',
},
type: RELATION_PART_OF,
},
],
},
};
useEntityRelationGraph.mockImplementation(({ filter: { kinds } }) => ({
loading: false,
entities: keyBy(
filter(entities, e => !kinds || kinds.includes(e.kind)),
stringifyEntityRef,
),
}));
});
afterAll(() => {
jest.resetAllMocks();
});
test('should forward loading state', async () => {
useEntityRelationGraph.mockReturnValue({
loading: true,
});
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(true);
expect(error).toBeUndefined();
expect(nodes).toBeUndefined();
expect(edges).toBeUndefined();
});
test('should forward error state', async () => {
const returnError = new Error('Test');
useEntityRelationGraph.mockReturnValue({
loading: false,
error: returnError,
});
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBe(returnError);
expect(nodes).toBeUndefined();
expect(edges).toBeUndefined();
});
test('should generate unidirectional graph with merged relations', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: true,
}),
);
await waitForValueToChange(
() => result.current.nodes && result.current.edges,
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(nodes).toEqual([
{
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
},
]);
expect(edges).toEqual([
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c2',
},
]);
});
test('should generate unidirectional graph', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: false,
}),
);
await waitForValueToChange(
() => result.current.nodes && result.current.edges,
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(nodes).toEqual([
{
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
},
]);
expect(edges).toEqual([
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF],
to: 'k:d/a1',
},
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c2',
},
]);
});
test('should generate bidirectional graph with merged relations', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: false,
mergeRelations: true,
}),
);
await waitForValueToChange(
() => result.current.nodes && result.current.edges,
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(nodes).toEqual([
{
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
},
]);
expect(edges).toEqual([
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c2',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c2',
},
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
]);
});
test('should generate bidirectional graph with all relations', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: false,
mergeRelations: false,
}),
);
await waitForValueToChange(
() => result.current.nodes && result.current.edges,
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(nodes).toEqual([
{
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
},
]);
expect(edges).toEqual([
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF],
to: 'k:d/a1',
},
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_PART_OF],
to: 'b:d/c',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_OWNER_OF],
to: 'k:d/a1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c2',
},
{
from: 'b:d/c2',
label: 'visible',
relations: [RELATION_PART_OF],
to: 'b:d/c1',
},
{
from: 'k:d/a1',
label: 'visible',
relations: [RELATION_OWNED_BY],
to: 'b:d/c',
},
{
from: 'k:d/a1',
label: 'visible',
relations: [RELATION_OWNED_BY],
to: 'b:d/c1',
},
]);
});
test('should generate graph with multiple root nodes', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
);
await waitForValueToChange(
() => result.current.nodes && result.current.edges,
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(nodes).toEqual([
{
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
},
{
color: 'secondary',
focused: true,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
},
]);
expect(edges).toEqual([
{
from: 'b:d/c1',
label: 'visible',
relations: ['hasPart', 'partOf'],
to: 'b:d/c2',
},
{
from: 'b:d/c',
label: 'visible',
relations: ['hasPart', 'partOf'],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: ['ownerOf', 'ownedBy'],
to: 'k:d/a1',
},
]);
});
test('should filter by relation', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
relations: [RELATION_OWNER_OF],
}),
);
await waitForValueToChange(
() => result.current.nodes && result.current.edges,
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(nodes).toEqual([
{
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
},
]);
expect(edges).toEqual([
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
]);
});
test('should filter by kind', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
kinds: ['b'],
}),
);
await waitForValueToChange(
() => result.current.nodes && result.current.edges,
);
const { nodes, edges, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(nodes).toEqual([
{
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
},
]);
expect(edges).toEqual([
{
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c2',
},
]);
});
});
@@ -0,0 +1,170 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ENTITY_DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { MouseEvent, useState } from 'react';
import { useDebounce } from 'react-use';
import { RelationPairs, RELATION_PAIRS } from './relations';
import { EntityEdge, EntityNode } from './types';
import { useEntityRelationGraph } from './useEntityRelationGraph';
/**
* Generate nodes and edges to render the entity graph.
*/
export function useEntityRelationNodesAndEdges({
rootEntityRefs,
maxDepth = Number.POSITIVE_INFINITY,
unidirectional = true,
mergeRelations = true,
kinds,
relations,
onNodeClick,
relationPairs = RELATION_PAIRS,
}: {
rootEntityRefs: string[];
maxDepth?: number;
unidirectional?: boolean;
mergeRelations?: boolean;
kinds?: string[];
relations?: string[];
onNodeClick?: (value: EntityNode, event: MouseEvent<SVGElement>) => void;
relationPairs?: RelationPairs;
}): {
loading: boolean;
nodes?: EntityNode[];
edges?: EntityEdge[];
error?: Error;
} {
const [nodesAndEdges, setNodesAndEdges] = useState<{
nodes?: EntityNode[];
edges?: EntityEdge[];
}>({});
const { entities, loading, error } = useEntityRelationGraph({
rootEntityRefs,
filter: {
maxDepth,
kinds,
relations,
},
});
useDebounce(
() => {
if (!entities || Object.keys(entities).length === 0) {
setNodesAndEdges({});
return;
}
const nodes = Object.entries(entities).map(([entityRef, entity]) => {
const focused = rootEntityRefs.includes(entityRef);
const node: EntityNode = {
id: entityRef,
title: entity.metadata?.title ?? undefined,
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE,
focused,
color: focused ? 'secondary' : 'primary',
};
if (onNodeClick) {
node.onClick = event => onNodeClick(node, event);
}
return node;
});
const edges: EntityEdge[] = [];
const visitedNodes = new Set<string>();
const nodeQueue = [...rootEntityRefs];
while (nodeQueue.length > 0) {
const entityRef = nodeQueue.pop()!;
const entity = entities[entityRef];
visitedNodes.add(entityRef);
if (entity) {
entity?.relations?.forEach(rel => {
const targetRef = stringifyEntityRef(rel.target);
// Check if the related entity should be displayed, if not, ignore
// the relation too
if (!entities[targetRef]) {
return;
}
if (relations && !relations.includes(rel.type)) {
return;
}
if (kinds && !kinds.includes(rel.target.kind.toLowerCase())) {
return;
}
if (!unidirectional || !visitedNodes.has(targetRef)) {
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
) ?? [rel.type];
const [left] = pair;
edges.push({
from: left === rel.type ? entityRef : targetRef,
to: left === rel.type ? targetRef : entityRef,
relations: pair,
label: 'visible',
});
} else {
edges.push({
from: entityRef,
to: targetRef,
relations: [rel.type],
label: 'visible',
});
}
}
if (!visitedNodes.has(targetRef)) {
nodeQueue.push(targetRef);
visitedNodes.add(targetRef);
}
});
}
}
setNodesAndEdges({ nodes, edges });
},
100,
[
entities,
rootEntityRefs,
kinds,
relations,
unidirectional,
mergeRelations,
onNodeClick,
relationPairs,
],
);
return {
loading,
error,
...nodesAndEdges,
};
}
@@ -0,0 +1,234 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { CatalogApi } from '@backstage/plugin-catalog-react';
import { act, renderHook } from '@testing-library/react-hooks';
import { useEntityStore } from './useEntityStore';
jest.mock('@backstage/core-plugin-api');
describe('useEntityStore', () => {
let catalogApi: jest.Mocked<CatalogApi>;
beforeEach(() => {
catalogApi = {
getEntities: jest.fn(),
getEntityByName: jest.fn(),
removeEntityByUid: jest.fn(),
getLocationById: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
};
(useApi as jest.Mock<any>).mockReturnValue(catalogApi);
});
afterEach(() => jest.resetAllMocks());
test('request nothing', () => {
const { result } = renderHook(() => useEntityStore());
const { entities, loading, error } = result.current;
expect(error).toBeUndefined();
expect(loading).toBe(false);
expect(entities).toEqual({});
});
test('request a single entity', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name',
},
};
catalogApi.getEntityByName.mockResolvedValue(entity);
const { result, waitFor } = renderHook(() => useEntityStore());
act(() => {
result.current.requestEntities(['kind:namespace/name']);
});
await waitFor(() => {
const { entities, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name': entity,
});
});
});
test('handles request failures', async () => {
const err = new Error('Hello World');
catalogApi.getEntityByName.mockRejectedValue(err);
const { result, waitFor } = renderHook(() => useEntityStore());
act(() => {
result.current.requestEntities(['kind:namespace/name']);
});
await waitFor(() => {
const { entities, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBe(err);
expect(entities).toEqual({});
});
});
test('handles loading', async () => {
catalogApi.getEntityByName.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useEntityStore());
act(() => {
result.current.requestEntities(['kind:namespace/name']);
});
const { entities, loading, error } = result.current;
expect(loading).toBe(true);
expect(error).toBeUndefined();
expect(entities).toEqual({});
});
test('request multiple entities', async () => {
const entity1: Entity = {
apiVersion: 'v1',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name1',
},
};
const entity2: Entity = {
apiVersion: 'v1',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name2',
},
};
catalogApi.getEntityByName.mockResolvedValue(entity1);
const { result, waitFor } = renderHook(() => useEntityStore());
act(() => {
result.current.requestEntities(['kind:namespace/name1']);
});
await waitFor(() => {
const { entities, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name1': entity1,
});
});
catalogApi.getEntityByName.mockResolvedValue(entity2);
act(() => {
result.current.requestEntities([
'kind:namespace/name1',
'kind:namespace/name2',
]);
});
await waitFor(() => {
const { entities, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name1': entity1,
'kind:namespace/name2': entity2,
});
});
});
test('request cached entity', async () => {
const entity1: Entity = {
apiVersion: 'v1',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name1',
},
};
const entity2: Entity = {
apiVersion: 'v1',
kind: 'kind',
metadata: {
namespace: 'namespace',
name: 'name2',
},
};
catalogApi.getEntityByName.mockResolvedValue(entity1);
const { result, waitFor } = renderHook(() => useEntityStore());
act(() => {
result.current.requestEntities(['kind:namespace/name1']);
});
await waitFor(() => {
const { entities, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name1': entity1,
});
});
catalogApi.getEntityByName.mockResolvedValue(entity2);
act(() => {
result.current.requestEntities(['kind:namespace/name2']);
});
await waitFor(() => {
const { entities, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name2': entity2,
});
});
act(() => {
result.current.requestEntities(['kind:namespace/name1']);
});
await waitFor(() => {
const { entities, loading, error } = result.current;
expect(loading).toBe(false);
expect(error).toBeUndefined();
expect(entities).toEqual({
'kind:namespace/name1': entity1,
});
});
expect(catalogApi.getEntityByName).toBeCalledTimes(2);
});
});
@@ -0,0 +1,120 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, parseEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import limiterFactory from 'p-limit';
import { Dispatch, useCallback, useRef, useState } from 'react';
import { useAsyncFn } from 'react-use';
// TODO: This is a good use case for a graphql API, once it is available in the
// future.
/**
* Ensures that a set of requested entities is loaded.
*/
export function useEntityStore(): {
entities: { [key: string]: Entity };
loading: boolean;
error?: Error;
requestEntities: Dispatch<string[]>;
} {
const catalogClient = useApi(catalogApiRef);
const state = useRef({
requestedEntities: new Set<string>(),
outstandingEntities: new Map<string, Promise<Entity | undefined>>(),
cachedEntities: new Map<string, Entity>(),
});
const [entities, setEntities] = useState<{
[key: string]: Entity;
}>({});
const updateEntities = useCallback(() => {
const { cachedEntities, requestedEntities } = state.current;
const filteredEntities: { [key: string]: Entity } = {};
requestedEntities.forEach(entityRef => {
const entity = cachedEntities.get(entityRef);
if (entity) {
filteredEntities[entityRef] = entity;
}
});
setEntities(filteredEntities);
}, [state, setEntities]);
const [asyncState, fetch] = useAsyncFn(async () => {
const limiter = limiterFactory(10);
const { requestedEntities, outstandingEntities, cachedEntities } =
state.current;
await Promise.all(
Array.from(requestedEntities).map(entityRef =>
limiter(async () => {
if (cachedEntities.has(entityRef)) {
return;
}
if (outstandingEntities.has(entityRef)) {
await outstandingEntities.get(entityRef);
return;
}
const promise = catalogClient.getEntityByName(
parseEntityRef(entityRef),
);
outstandingEntities.set(entityRef, promise);
try {
const entity = await promise;
if (entity) {
cachedEntities.set(entityRef, entity);
updateEntities();
}
} finally {
outstandingEntities.delete(entityRef);
}
}),
),
);
}, [state, updateEntities]);
const { loading, error } = asyncState;
const requestEntities = useCallback(
(entityRefs: string[]) => {
const n = new Set(entityRefs);
const { requestedEntities } = state.current;
if (
n.size !== requestedEntities.size ||
Array.from(n).some(e => !requestedEntities.has(e))
) {
state.current.requestedEntities = n;
fetch();
updateEntities();
}
},
[state, fetch, updateEntities],
);
return {
entities,
loading,
error,
requestEntities,
};
}
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './EntityRelationsGraph';
+38
View File
@@ -0,0 +1,38 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createComponentExtension,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { catalogGraphPlugin } from './plugin';
import { catalogGraphRouteRef } from './routes';
export const EntityCatalogGraphCard = catalogGraphPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/CatalogGraphCard').then(m => m.CatalogGraphCard),
},
}),
);
export const CatalogGraphPage = catalogGraphPlugin.provide(
createRoutableExtension({
component: () =>
import('./components/CatalogGraphPage').then(m => m.CatalogGraphPage),
mountPoint: catalogGraphRouteRef,
}),
);
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './components';
export { CatalogGraphPage, EntityCatalogGraphCard } from './extensions';
export { catalogGraphPlugin } from './plugin';
export { catalogGraphRouteRef } from './routes';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { catalogGraphPlugin } from './plugin';
describe('summary', () => {
it('should export plugin', () => {
expect(catalogGraphPlugin).toBeDefined();
});
});
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin } from '@backstage/core-plugin-api';
import { catalogEntityRouteRef, catalogGraphRouteRef } from './routes';
export const catalogGraphPlugin = createPlugin({
id: '@internal/catalog-graph',
routes: {
catalogGraph: catalogGraphRouteRef,
},
externalRoutes: {
catalogEntity: catalogEntityRouteRef,
},
});
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createExternalRouteRef,
createRouteRef,
} from '@backstage/core-plugin-api';
export const catalogGraphRouteRef = createRouteRef({
path: '/catalog-graph',
title: 'Catalog Graph',
});
export const catalogEntityRouteRef = createExternalRouteRef({
id: 'catalog-entity',
params: ['namespace', 'kind', 'name'],
});
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
+12 -1
View File
@@ -4422,6 +4422,17 @@
prop-types "^15.7.2"
react-is "^16.8.0"
"@material-ui/lab@4.0.0-alpha.47":
version "4.0.0-alpha.47"
resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.47.tgz#757a336e4c2496f700a392ff41e25a7d460a387b"
integrity sha512-+WC3O0M/769D3nO9Rqupusc+lob7tQMe5/DnOjAhZ0bpXlJbhZb7N84WkEk4JgQLj6ydP8e9Jhqd1lG+mGj+xw==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.9.6"
clsx "^1.0.4"
prop-types "^15.7.2"
react-is "^16.8.0"
"@material-ui/lab@4.0.0-alpha.57":
version "4.0.0-alpha.57"
resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a"
@@ -4494,7 +4505,7 @@
resolved "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2"
integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==
"@material-ui/utils@^4.11.2", "@material-ui/utils@^4.7.1":
"@material-ui/utils@^4.11.2", "@material-ui/utils@^4.7.1", "@material-ui/utils@^4.9.6":
version "4.11.2"
resolved "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz#f1aefa7e7dff2ebcb97d31de51aecab1bb57540a"
integrity sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==