Add catalog graph plugin
Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
@@ -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'
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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();
|
||||
@@ -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 & 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>
|
||||
);
|
||||
};
|
||||
+110
@@ -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} />;
|
||||
}
|
||||
+410
@@ -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',
|
||||
}
|
||||
+365
@@ -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,
|
||||
};
|
||||
}
|
||||
+735
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+170
@@ -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';
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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'],
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user