diff --git a/.changeset/wicked-pugs-speak.md b/.changeset/wicked-pugs-speak.md new file mode 100644 index 0000000000..620be91773 --- /dev/null +++ b/.changeset/wicked-pugs-speak.md @@ -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). diff --git a/microsite/data/plugins/catalog-graph.yaml b/microsite/data/plugins/catalog-graph.yaml new file mode 100644 index 0000000000..336954b5dc --- /dev/null +++ b/microsite/data/plugins/catalog-graph.yaml @@ -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' diff --git a/microsite/static/img/catalog-graph.svg b/microsite/static/img/catalog-graph.svg new file mode 100644 index 0000000000..6cd13b0e66 --- /dev/null +++ b/microsite/static/img/catalog-graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app/package.json b/packages/app/package.json index b5a5af180d..b997313485 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 81dbdf4cac..87984bece0 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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} } /> + + } + /> }> diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 4a392e0f05..7ccfb059ea 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -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 = ( {entityWarningContent} - + + + + + @@ -454,9 +472,12 @@ const apiPage = ( {entityWarningContent} - + + + + @@ -523,6 +544,9 @@ const systemPage = ( + + + @@ -537,6 +561,25 @@ const systemPage = ( + + + ); @@ -548,6 +591,9 @@ const domainPage = ( + + + diff --git a/plugins/catalog-graph/.eslintrc.js b/plugins/catalog-graph/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-graph/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-graph/README.md b/plugins/catalog-graph/README.md new file mode 100644 index 0000000000..0e06e15f4c --- /dev/null +++ b/plugins/catalog-graph/README.md @@ -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 + + … + } />… + + ``` + + You can configure the page to open with some initial filters: + + ```typescript + + } + /> + ``` + +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 + + + + ``` diff --git a/plugins/catalog-graph/dev/index.tsx b/plugins/catalog-graph/dev/index.tsx new file mode 100644 index 0000000000..ed14e5fce9 --- /dev/null +++ b/plugins/catalog-graph/dev/index.tsx @@ -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 { + return entities[stringifyEntityRef(name)]; + }, + async getEntities(): Promise> { + return { items: Object.values(entities) }; + }, + } as Partial as unknown as CatalogApi; + }, + }) + .addPage({ + title: 'Graph Card', + element: ( + +
+ + + + + + + + + + + ), + }) + .addPage({ + element: , + }) + .render(); diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json new file mode 100644 index 0000000000..2a47979a06 --- /dev/null +++ b/plugins/catalog-graph/package.json @@ -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" + ] +} diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx new file mode 100644 index 0000000000..514f8c2cf5 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -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('', () => { + let entity: Entity; + let wrapper: JSX.Element; + let catalog: jest.Mocked; + 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 = ( + + + + + + ); + }); + + 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( + + + + + , + { + 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', + ); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx new file mode 100644 index 0000000000..78d02d6f3f --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx @@ -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({ + 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 ( + + + + ); +}; diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts b/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts new file mode 100644 index 0000000000..725f90331f --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/index.ts @@ -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'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx new file mode 100644 index 0000000000..8902c49845 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -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('', () => { + let wrapper: JSX.Element; + let catalog: jest.Mocked; + + 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 = ( + + + + ); + }); + + 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}'); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx new file mode 100644 index 0000000000..53b7a80414 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx @@ -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 ( + +
formatEntityRefTitle(e)).join(', ')} + /> + + toggleShowFilters()} + > + Filters + + } + > + + Start tracking your component in by adding it to the software + catalog. + + + + {showFilters && ( + + + + + + + + + )} + + + + Use pinch & zoom to move + around the diagram. Click to change active node, shift click to + navigate to entity. + + 0 + ? selectedKinds + : undefined + } + relations={ + selectedRelations && selectedRelations.length > 0 + ? selectedRelations + : undefined + } + mergeRelations={mergeRelations} + unidirectional={unidirectional} + onNodeClick={onNodeClick} + direction={direction} + relationPairs={relationPairs} + className={classes.graph} + /> + + + + + + ); +}; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx new file mode 100644 index 0000000000..a3ee8d7e51 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx @@ -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('', () => { + test('should display current value', () => { + const { getByText } = render( + {}} />, + ); + + expect(getByText('Left to right')).toBeInTheDocument(); + }); + + test('should select direction', async () => { + const onChange = jest.fn(); + const { getByText, getByTestId } = render( + , + ); + + 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); + }); + }); +}); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx new file mode 100644 index 0000000000..815ed1f292 --- /dev/null +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx @@ -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 ( + +