From ae6b606b72e7d591286ebe7a0369a46cdd63c3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Wed, 13 Aug 2025 19:42:00 +0100 Subject: [PATCH 1/8] Add support for customizing the catalog graph relations using an API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .changeset/dull-eagles-sin.md | 5 + packages/app/src/App.tsx | 24 ---- plugins/catalog-graph/README.md | 52 +++++-- plugins/catalog-graph/report.api.md | 50 +++++++ .../catalog-graph/src/api/CatalogGraphApi.ts | 58 ++++++++ .../src/api/DefaultCatalogGraphApi.test.ts | 61 +++++++++ .../src/api/DefaultCatalogGraphApi.ts | 74 ++++++++++ plugins/catalog-graph/src/api/index.ts | 18 +++ .../CatalogGraphCard.test.tsx | 2 + .../CatalogGraphCard/CatalogGraphCard.tsx | 3 +- .../CatalogGraphPage/CatalogGraphPage.tsx | 8 +- .../SelectedRelationsFilter.test.tsx | 80 +++++++---- .../SelectedRelationsFilter.tsx | 28 ++-- .../EntityRelationsGraph.tsx | 4 +- .../components/EntityRelationsGraph/index.ts | 2 - ...est.ts => useEntityRelationGraph.test.tsx} | 121 +++++++++++------ .../useEntityRelationGraph.ts | 6 +- ...> useEntityRelationNodesAndEdges.test.tsx} | 127 +++++++++++------- .../useEntityRelationNodesAndEdges.ts | 14 +- plugins/catalog-graph/src/hooks/index.ts | 18 +++ .../catalog-graph/src/hooks/useRelations.ts | 71 ++++++++++ plugins/catalog-graph/src/index.ts | 3 + plugins/catalog-graph/src/plugin.ts | 10 +- plugins/catalog-graph/src/types/index.ts | 18 +++ plugins/catalog-graph/src/types/relations.ts | 77 +++++++++++ 25 files changed, 750 insertions(+), 184 deletions(-) create mode 100644 .changeset/dull-eagles-sin.md create mode 100644 plugins/catalog-graph/src/api/CatalogGraphApi.ts create mode 100644 plugins/catalog-graph/src/api/DefaultCatalogGraphApi.test.ts create mode 100644 plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts create mode 100644 plugins/catalog-graph/src/api/index.ts rename plugins/catalog-graph/src/components/EntityRelationsGraph/{useEntityRelationGraph.test.ts => useEntityRelationGraph.test.tsx} (78%) rename plugins/catalog-graph/src/components/EntityRelationsGraph/{useEntityRelationNodesAndEdges.test.ts => useEntityRelationNodesAndEdges.test.tsx} (87%) create mode 100644 plugins/catalog-graph/src/hooks/index.ts create mode 100644 plugins/catalog-graph/src/hooks/useRelations.ts create mode 100644 plugins/catalog-graph/src/types/index.ts create mode 100644 plugins/catalog-graph/src/types/relations.ts diff --git a/.changeset/dull-eagles-sin.md b/.changeset/dull-eagles-sin.md new file mode 100644 index 0000000000..a527a9757d --- /dev/null +++ b/.changeset/dull-eagles-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +Support custom relations by using an API diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 5ac9052d2a..ad525420a7 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,18 +14,6 @@ * 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 } from '@backstage/app-defaults'; import { AppRouter, FeatureFlagged, FlatRoutes } from '@backstage/core-app-api'; import { @@ -158,18 +146,6 @@ const routes = ( } diff --git a/plugins/catalog-graph/README.md b/plugins/catalog-graph/README.md index b9400e8e8b..b411d4d868 100644 --- a/plugins/catalog-graph/README.md +++ b/plugins/catalog-graph/README.md @@ -50,18 +50,6 @@ To use the catalog graph plugin, you have to add some things to your Backstage a } @@ -88,7 +76,7 @@ To use the catalog graph plugin, you have to add some things to your Backstage a ``` -### Customization +### Customizing the UI Copy the default implementation `DefaultRenderNode.tsx` and add more classes to the styles: @@ -151,6 +139,44 @@ Once you have your custom implementation, you can follow these steps to modify t ``` +### Custom relations + +Implementers with added custom relations can add them to the catalog graph plugin by overriding the default API. This also allows some relations to not be selected by default. + +In `packages/app/src/apis.ts`, import the api ref and create the API as: + +```ts +import { + ALL_RELATIONS, + ALL_RELATION_PAIRS, + catalogGraphApiRef, + DefaultCatalogGraphApi, +} from '@backstage/plugin-catalog-graph'; + +// ... + + createApiFactory({ + api: catalogGraphApiRef, + deps: {}, + factory: () => + new DefaultCatalogGraphApi({ + // The relations to support + knownRelations: [...ALL_RELATIONS, 'myRelationOf', 'myRelationFor'], + // The relation pairs to support + knownRelationPairs: [ + ...ALL_RELATION_PAIRS, + ['myRelationOf', 'myRelationFor'], + ], + // Select what relations to be shown by default, either by including them, + // or excluding some from all known relations: + defaultRelationTypes: { + // Don't show/select these by default + exclude: ['myRelationOf', 'myRelationFor'], + }, + }), + }), +``` + ## Development Run `yarn` in the root of this plugin to install all dependencies and then `yarn start` to run a [development version](./dev/index.tsx) of this plugin. diff --git a/plugins/catalog-graph/report.api.md b/plugins/catalog-graph/report.api.md index 25479bd9b7..fd1e403473 100644 --- a/plugins/catalog-graph/report.api.md +++ b/plugins/catalog-graph/report.api.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { DependencyGraphTypes } from '@backstage/core-components'; @@ -19,6 +20,19 @@ import { RouteRef } from '@backstage/core-plugin-api'; // @public export const ALL_RELATION_PAIRS: RelationPairs; +// @public +export const ALL_RELATIONS: string[]; + +// @public +export interface CatalogGraphApi { + readonly defaultRelations: string[]; + readonly knownRelationPairs: [string, string][]; + readonly knownRelations: string[]; +} + +// @public +export const catalogGraphApiRef: ApiRef; + // @public export const CatalogGraphPage: ( props: { @@ -62,6 +76,42 @@ export type CustomLabelClassKey = 'text' | 'secondary'; // @public (undocumented) export type CustomNodeClassKey = 'node' | 'text' | 'clickable'; +// @public +export class DefaultCatalogGraphApi implements CatalogGraphApi { + constructor(options?: DefaultCatalogGraphApiOptions); + // (undocumented) + readonly defaultRelations: string[]; + // (undocumented) + readonly knownRelationPairs: [string, string][]; + // (undocumented) + readonly knownRelations: string[]; +} + +// @public +export interface DefaultCatalogGraphApiOptions { + // (undocumented) + readonly defaultRelationTypes?: DefaultRelations; + // (undocumented) + readonly knownRelationPairs?: RelationPairs; + // (undocumented) + readonly knownRelations?: string[]; +} + +// @public +export type DefaultRelations = + | DefaultRelationsInclude + | DefaultRelationsExclude; + +// @public (undocumented) +export type DefaultRelationsExclude = { + exclude: string[]; +}; + +// @public (undocumented) +export type DefaultRelationsInclude = { + include: string[]; +}; + // @public export enum Direction { BOTTOM_TOP = 'BT', diff --git a/plugins/catalog-graph/src/api/CatalogGraphApi.ts b/plugins/catalog-graph/src/api/CatalogGraphApi.ts new file mode 100644 index 0000000000..33d994e0c7 --- /dev/null +++ b/plugins/catalog-graph/src/api/CatalogGraphApi.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2025 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 { createApiRef } from '@backstage/core-plugin-api'; + +/** + * Utility API reference for the {@link CatalogGraphApi}. + * + * @public + */ +export const catalogGraphApiRef = createApiRef({ + id: 'plugin.catalog-graph.service', +}); + +/** @public */ +export type DefaultRelationsInclude = { include: string[] }; + +/** @public */ +export type DefaultRelationsExclude = { exclude: string[] }; + +/** + * Default relations. Can either be a list of relations to use by default, or + * a list of relations to _not_ use, i.e. include all other relations. + * + * @public + */ +export type DefaultRelations = + | DefaultRelationsInclude + | DefaultRelationsExclude; + +/** + * API for driving catalog imports. + * + * @public + */ +export interface CatalogGraphApi { + /** All known relations */ + readonly knownRelations: string[]; + + /** All known relation pairs */ + readonly knownRelationPairs: [string, string][]; + + /** The default relations to show in the graph */ + readonly defaultRelations: string[]; +} diff --git a/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.test.ts b/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.test.ts new file mode 100644 index 0000000000..63186868fd --- /dev/null +++ b/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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 { DefaultCatalogGraphApi } from './DefaultCatalogGraphApi'; + +describe('DefaultCatalogGraphApi', () => { + it('default config', async () => { + const { defaultRelations } = new DefaultCatalogGraphApi(); + + expect(defaultRelations.includes('')).toBe(false); + expect(defaultRelations.includes('fooRelation')).toBe(false); + expect(defaultRelations.includes('ownedBy')).toBe(true); + expect(defaultRelations.includes('hasPart')).toBe(true); + }); + + it('empty include config', async () => { + const { defaultRelations } = new DefaultCatalogGraphApi({ + defaultRelationTypes: { include: [] }, + }); + + expect(defaultRelations.includes('')).toBe(false); + expect(defaultRelations.includes('fooRelation')).toBe(false); + expect(defaultRelations.includes('ownedBy')).toBe(false); + expect(defaultRelations.includes('hasPart')).toBe(false); + }); + + it('include config', async () => { + const { defaultRelations } = new DefaultCatalogGraphApi({ + defaultRelationTypes: { include: ['ownedBy'] }, + }); + + expect(defaultRelations.includes('')).toBe(false); + expect(defaultRelations.includes('fooRelation')).toBe(false); + expect(defaultRelations.includes('ownedBy')).toBe(true); + expect(defaultRelations.includes('hasPart')).toBe(false); + }); + + it('exclude config', async () => { + const { defaultRelations } = new DefaultCatalogGraphApi({ + defaultRelationTypes: { exclude: ['ownedBy', 'ownerOf'] }, + }); + + expect(defaultRelations.includes('')).toBe(false); + expect(defaultRelations.includes('fooRelation')).toBe(false); + expect(defaultRelations.includes('ownedBy')).toBe(false); + expect(defaultRelations.includes('hasPart')).toBe(true); + }); +}); diff --git a/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts b/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts new file mode 100644 index 0000000000..f64dffb09b --- /dev/null +++ b/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2025 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 { ALL_RELATION_PAIRS, ALL_RELATIONS, RelationPairs } from '../types'; +import { + CatalogGraphApi, + DefaultRelations, + DefaultRelationsExclude, + DefaultRelationsInclude, +} from './CatalogGraphApi'; + +/** + * Options for the {@link DefaultCatalogGraphApi}. + * + * @public + */ +export interface DefaultCatalogGraphApiOptions { + readonly knownRelations?: string[]; + readonly knownRelationPairs?: RelationPairs; + readonly defaultRelationTypes?: DefaultRelations; +} + +/** + * The default implementation of the {@link CatalogGraphApi}. + * + * @public + */ +export class DefaultCatalogGraphApi implements CatalogGraphApi { + readonly knownRelations: string[]; + readonly knownRelationPairs: [string, string][]; + readonly defaultRelations: string[]; + + constructor( + options: DefaultCatalogGraphApiOptions = { + knownRelations: ALL_RELATIONS, + knownRelationPairs: ALL_RELATION_PAIRS, + defaultRelationTypes: { exclude: [] }, + }, + ) { + this.knownRelations = options.knownRelations ?? ALL_RELATIONS; + this.knownRelationPairs = options.knownRelationPairs ?? ALL_RELATION_PAIRS; + + const defaultRelations = options.defaultRelationTypes; + + if (Array.isArray((defaultRelations as DefaultRelationsInclude).include)) { + const defaultRelationsInclude = + defaultRelations as DefaultRelationsInclude; + + this.defaultRelations = this.knownRelations.filter(rel => + defaultRelationsInclude.include.includes(rel), + ); + } else { + const defaultRelationsExclude = + defaultRelations as DefaultRelationsExclude; + + this.defaultRelations = this.knownRelations.filter( + rel => !defaultRelationsExclude.exclude.includes(rel), + ); + } + } +} diff --git a/plugins/catalog-graph/src/api/index.ts b/plugins/catalog-graph/src/api/index.ts new file mode 100644 index 0000000000..5bca33ce1a --- /dev/null +++ b/plugins/catalog-graph/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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 './CatalogGraphApi'; +export * from './DefaultCatalogGraphApi'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index ed8069a8f4..3ae8310280 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -35,6 +35,7 @@ import { catalogGraphRouteRef } from '../../routes'; import { CatalogGraphCard } from './CatalogGraphCard'; import Button from '@material-ui/core/Button'; import { translationApiRef } from '@backstage/core-plugin-api/alpha'; +import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api'; describe('', () => { let entity: Entity; @@ -56,6 +57,7 @@ describe('', () => { apis = TestApiRegistry.from( [catalogApiRef, catalog], [translationApiRef, mockApis.translation()], + [catalogGraphApiRef, new DefaultCatalogGraphApi()], ); wrapper = ( diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx index 733f209990..d0524668aa 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx @@ -32,7 +32,6 @@ import { MouseEvent, ReactNode, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { catalogGraphRouteRef } from '../../routes'; import { - ALL_RELATION_PAIRS, Direction, EntityNode, EntityRelationsGraph, @@ -71,7 +70,7 @@ export const CatalogGraphCard = ( const { t } = useTranslationRef(catalogGraphTranslationRef); const { variant = 'gridItem', - relationPairs = ALL_RELATION_PAIRS, + relationPairs, maxDepth = 1, unidirectional = true, mergeRelations = true, diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx index 69cb04bf3a..27d619c9c5 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx @@ -37,7 +37,6 @@ import ToggleButton from '@material-ui/lab/ToggleButton'; import { MouseEvent, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { - ALL_RELATION_PAIRS, Direction, EntityNode, EntityRelationsGraph, @@ -133,11 +132,7 @@ export const CatalogGraphPage = ( }; } & Partial, ) => { - const { - relationPairs = ALL_RELATION_PAIRS, - initialState, - entityFilter, - } = props; + const { relationPairs, initialState, entityFilter } = props; const { t } = useTranslationRef(catalogGraphTranslationRef); const navigate = useNavigate(); const classes = useStyles(); @@ -224,7 +219,6 @@ export const CatalogGraphPage = ( diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx index 057842bcdc..e97b817915 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx @@ -14,6 +14,8 @@ * limitations under the License. */ +import { PropsWithChildren } from 'react'; +import { ApiProvider } from '@backstage/core-app-api'; import { RELATION_CHILD_OF, RELATION_HAS_MEMBER, @@ -21,18 +23,34 @@ import { } from '@backstage/catalog-model'; import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { ALL_RELATION_PAIRS } from '../EntityRelationsGraph'; +import { ALL_RELATIONS } from '../../types'; import { SelectedRelationsFilter } from './SelectedRelationsFilter'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api'; + +function GraphContext(props: PropsWithChildren<{}>) { + return ( + + {props.children} + + ); +} describe('', () => { test('should render current value', async () => { await renderInTestApp( - {}} - />, + + {}} + /> + , ); expect(screen.getByText(RELATION_OWNED_BY)).toBeInTheDocument(); @@ -42,11 +60,13 @@ describe('', () => { test('should select value', async () => { const onChange = jest.fn(); await renderInTestApp( - , + + + , ); await userEvent.click(screen.getByLabelText('Open')); @@ -66,16 +86,16 @@ describe('', () => { }); }); - test('should return undefined if all values are selected', async () => { + test('should return all known relations if all values are selected', async () => { const onChange = jest.fn(); await renderInTestApp( - p).filter( - r => r !== RELATION_HAS_MEMBER, - )} - onChange={onChange} - />, + + r !== RELATION_HAS_MEMBER)} + onChange={onChange} + /> + , ); await userEvent.click(screen.getByLabelText('Open')); @@ -87,18 +107,26 @@ describe('', () => { await userEvent.click(screen.getByText(RELATION_HAS_MEMBER)); await waitFor(() => { - expect(onChange).toHaveBeenCalledWith(undefined); + // Same as ALL_RELATIONS but with RELATION_HAS_MEMBER at the end + const allRelationsOrdered = [ + ...ALL_RELATIONS.filter(rel => rel !== RELATION_HAS_MEMBER), + RELATION_HAS_MEMBER, + ]; + + expect(onChange).toHaveBeenCalledWith(allRelationsOrdered); }); }); test('should return all values when cleared', async () => { const onChange = jest.fn(); await renderInTestApp( - , + + + , ); await userEvent.click(screen.getByRole('combobox')); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.tsx index 9ded3def03..aa5fa026d4 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.tsx @@ -24,9 +24,9 @@ import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import Autocomplete from '@material-ui/lab/Autocomplete'; import { useCallback, useMemo } from 'react'; -import { RelationPairs } from '../EntityRelationsGraph'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { catalogGraphTranslationRef } from '../../translation'; +import { useRelations } from '../../hooks'; /** @public */ export type SelectedRelationsFilterClassKey = 'formControl'; @@ -41,25 +41,31 @@ const useStyles = makeStyles( ); export type Props = { - relationPairs: RelationPairs; + relations?: string[]; value: string[] | undefined; onChange: (value: string[] | undefined) => void; }; -export const SelectedRelationsFilter = ({ - relationPairs, - value, - onChange, -}: Props) => { +export const SelectedRelationsFilter = (props: Props) => { + const { relations: incomingRelations, value, onChange } = props; + const classes = useStyles(); - const relations = useMemo(() => relationPairs.flat(), [relationPairs]); + + const { relations, includeRelation } = useRelations({ + relations: incomingRelations, + }); + + const defaultValue = useMemo( + () => relations.filter(rel => includeRelation(rel)), + [relations, includeRelation], + ); const { t } = useTranslationRef(catalogGraphTranslationRef); const handleChange = useCallback( (_: unknown, v: string[]) => { - onChange(relations.every(r => v.includes(r)) ? undefined : v); + onChange(v); }, - [relations, onChange], + [onChange], ); const handleEmpty = useCallback(() => { @@ -78,7 +84,7 @@ export const SelectedRelationsFilter = ({ disableCloseOnSelect aria-label={t('catalogGraphPage.selectedRelationsFilter.title')} options={relations} - value={value ?? relations} + value={value ?? defaultValue} onChange={handleChange} onBlur={handleEmpty} renderOption={(option, { selected }) => ( diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx index 933aa230be..13bebbabb7 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx @@ -30,7 +30,7 @@ import classNames from 'classnames'; import { MouseEvent, useEffect, useMemo } from 'react'; import { DefaultRenderLabel } from './DefaultRenderLabel'; import { DefaultRenderNode } from './DefaultRenderNode'; -import { ALL_RELATION_PAIRS, RelationPairs } from './relations'; +import { RelationPairs } from '../../types'; import { Direction, EntityEdge, EntityNode } from './types'; import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges'; @@ -109,7 +109,7 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => { entityFilter, direction = Direction.LEFT_RIGHT, onNodeClick, - relationPairs = ALL_RELATION_PAIRS, + relationPairs, className, zoom = 'enabled', renderNode, diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts index 7477d22f87..1c61e4abc9 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts @@ -18,8 +18,6 @@ export type { EntityRelationsGraphProps, EntityRelationsGraphClassKey, } from './EntityRelationsGraph'; -export { ALL_RELATION_PAIRS } from './relations'; -export type { RelationPairs } from './relations'; export { Direction } from './types'; export type { EntityEdgeData, diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.tsx similarity index 78% rename from plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.ts rename to plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.tsx index b2d9083180..26feb6f597 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.test.tsx @@ -13,16 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { PropsWithChildren } from 'react'; +import { ApiProvider } from '@backstage/core-app-api'; import { RELATION_HAS_PART, RELATION_OWNED_BY, RELATION_OWNER_OF, RELATION_PART_OF, } from '@backstage/catalog-model'; +import { TestApiRegistry } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react'; import { pick } from 'lodash'; import { useEntityRelationGraph } from './useEntityRelationGraph'; import { useEntityStore as useEntityStoreMocked } from './useEntityStore'; +import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api'; jest.mock('./useEntityStore'); @@ -30,6 +34,19 @@ const useEntityStore = useEntityStoreMocked as jest.Mock< ReturnType >; +function GraphContext(props: PropsWithChildren<{}>) { + return ( + + {props.children} + + ); +} + describe('useEntityRelationGraph', () => { const requestEntities = jest.fn(); @@ -171,8 +188,9 @@ describe('useEntityRelationGraph', () => { requestEntities, }); - const { result } = renderHook(() => - useEntityRelationGraph({ rootEntityRefs: [] }), + const { result } = renderHook( + () => useEntityRelationGraph({ rootEntityRefs: [] }), + { wrapper: GraphContext }, ); const { entities, loading, error } = result.current; @@ -190,8 +208,9 @@ describe('useEntityRelationGraph', () => { requestEntities, }); - const { result } = renderHook(() => - useEntityRelationGraph({ rootEntityRefs: [] }), + const { result } = renderHook( + () => useEntityRelationGraph({ rootEntityRefs: [] }), + { wrapper: GraphContext }, ); const { entities, loading, error } = result.current; @@ -210,8 +229,9 @@ describe('useEntityRelationGraph', () => { requestEntities, }); - const { result } = renderHook(() => - useEntityRelationGraph({ rootEntityRefs: [] }), + const { result } = renderHook( + () => useEntityRelationGraph({ rootEntityRefs: [] }), + { wrapper: GraphContext }, ); const { entities, loading, error } = result.current; @@ -222,8 +242,9 @@ describe('useEntityRelationGraph', () => { }); test('should walk relation tree', async () => { - const { result, rerender } = renderHook(() => - useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }), + const { result, rerender } = renderHook( + () => useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }), + { wrapper: GraphContext }, ); // Simulate rerendering as this is triggered automatically due to the mock @@ -256,11 +277,13 @@ describe('useEntityRelationGraph', () => { }); test('should limit max depth', async () => { - const { result, rerender } = renderHook(() => - useEntityRelationGraph({ - rootEntityRefs: ['b:d/c'], - filter: { maxDepth: 1 }, - }), + const { result, rerender } = renderHook( + () => + useEntityRelationGraph({ + rootEntityRefs: ['b:d/c'], + filter: { maxDepth: 1 }, + }), + { wrapper: GraphContext }, ); // Simulate rerendering as this is triggered automatically due to the mock @@ -277,11 +300,13 @@ describe('useEntityRelationGraph', () => { test('should update on filter change', async () => { let maxDepth: number = Number.POSITIVE_INFINITY; - const { result, rerender } = renderHook(() => - useEntityRelationGraph({ - rootEntityRefs: ['b:d/c'], - filter: { maxDepth }, - }), + const { result, rerender } = renderHook( + () => + useEntityRelationGraph({ + rootEntityRefs: ['b:d/c'], + filter: { maxDepth }, + }), + { wrapper: GraphContext }, ); // Simulate rerendering as this is triggered automatically due to the mock @@ -310,13 +335,15 @@ describe('useEntityRelationGraph', () => { }); test('should filter by relation', async () => { - const { result, rerender } = renderHook(() => - useEntityRelationGraph({ - rootEntityRefs: ['b:d/c'], - filter: { - relations: [RELATION_HAS_PART, RELATION_PART_OF], - }, - }), + const { result, rerender } = renderHook( + () => + useEntityRelationGraph({ + rootEntityRefs: ['b:d/c'], + filter: { + relations: [RELATION_HAS_PART, RELATION_PART_OF], + }, + }), + { wrapper: GraphContext }, ); // Simulate rerendering as this is triggered automatically due to the mock @@ -332,14 +359,16 @@ describe('useEntityRelationGraph', () => { }); 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'], - }, - }), + const { result, rerender } = renderHook( + () => + useEntityRelationGraph({ + rootEntityRefs: ['b:d/c'], + filter: { + relations: [RELATION_OWNED_BY, RELATION_OWNER_OF], + kinds: ['k'], + }, + }), + { wrapper: GraphContext }, ); // Simulate rerendering as this is triggered automatically due to the mock @@ -354,13 +383,15 @@ describe('useEntityRelationGraph', () => { }); test('should filter by func', async () => { - const { result, rerender } = renderHook(() => - useEntityRelationGraph({ - rootEntityRefs: ['b:d/c'], - filter: { - entityFilter: e => e.metadata.name !== 'c2', - }, - }), + const { result, rerender } = renderHook( + () => + useEntityRelationGraph({ + rootEntityRefs: ['b:d/c'], + filter: { + entityFilter: e => e.metadata.name !== 'c2', + }, + }), + { wrapper: GraphContext }, ); // Simulate rerendering as this is triggered automatically due to the mock @@ -376,10 +407,12 @@ describe('useEntityRelationGraph', () => { }); test('should support multiple roots by kind', async () => { - const { result, rerender } = renderHook(() => - useEntityRelationGraph({ - rootEntityRefs: ['b:d/c', 'b:d/c2'], - }), + const { result, rerender } = renderHook( + () => + useEntityRelationGraph({ + rootEntityRefs: ['b:d/c', 'b:d/c2'], + }), + { wrapper: GraphContext }, ); // Simulate rerendering as this is triggered automatically due to the mock diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts index 07e3085f1f..f52ac03f7e 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts @@ -17,6 +17,7 @@ import { Entity } from '@backstage/catalog-model'; import { useEffect, useMemo } from 'react'; import { useEntityStore } from './useEntityStore'; import { pickBy } from 'lodash'; +import { useRelations } from '../../hooks/useRelations'; /** * Discover the graph of entities connected by relations, starting from a set of @@ -45,6 +46,7 @@ export function useEntityRelationGraph({ error?: Error; } { const { entities, loading, error, requestEntities } = useEntityStore(); + const { includeRelation } = useRelations({ relations }); useEffect(() => { const expectedEntities = new Set([...rootEntityRefs]); @@ -74,7 +76,7 @@ export function useEntityRelationGraph({ } for (const rel of entity.relations) { if ( - (!relations || relations.includes(rel.type)) && + includeRelation(rel.type) && (!kinds || kinds.some(kind => rel.targetRef.startsWith( @@ -98,7 +100,7 @@ export function useEntityRelationGraph({ entities, rootEntityRefs, maxDepth, - relations, + includeRelation, kinds, entityFilter, requestEntities, diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx similarity index 87% rename from plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts rename to plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx index 08b718d789..4dfb612646 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { PropsWithChildren } from 'react'; import { DEFAULT_NAMESPACE, Entity, @@ -22,11 +23,14 @@ import { RELATION_PART_OF, stringifyEntityRef, } from '@backstage/catalog-model'; +import { ApiProvider } from '@backstage/core-app-api'; +import { TestApiRegistry } from '@backstage/test-utils'; import { renderHook, waitFor } from '@testing-library/react'; import { filter, keyBy } from 'lodash'; import { useEntityRelationGraph as useEntityRelationGraphMocked } from './useEntityRelationGraph'; import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges'; import { EntityNode } from './types'; +import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api'; jest.mock('./useEntityRelationGraph'); @@ -129,6 +133,19 @@ function deprecatedProperties(entity: Entity): Partial { }; } +function GraphContext(props: PropsWithChildren<{}>) { + return ( + + {props.children} + + ); +} + describe('useEntityRelationNodesAndEdges', () => { beforeEach(() => { useEntityRelationGraph.mockImplementation(({ filter: { kinds } }) => ({ @@ -149,10 +166,12 @@ describe('useEntityRelationNodesAndEdges', () => { loading: true, }); - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + }), + { wrapper: GraphContext }, ); const { nodes, edges, loading, error } = result.current; @@ -170,10 +189,12 @@ describe('useEntityRelationNodesAndEdges', () => { error: returnError, }); - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + }), + { wrapper: GraphContext }, ); const { nodes, edges, loading, error } = result.current; @@ -185,12 +206,14 @@ describe('useEntityRelationNodesAndEdges', () => { }); test('should generate unidirectional graph with merged relations', async () => { - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - unidirectional: true, - mergeRelations: true, - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + unidirectional: true, + mergeRelations: true, + }), + { wrapper: GraphContext }, ); await waitFor(() => { @@ -260,12 +283,14 @@ describe('useEntityRelationNodesAndEdges', () => { }); test('should generate unidirectional graph', async () => { - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - unidirectional: true, - mergeRelations: false, - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + unidirectional: true, + mergeRelations: false, + }), + { wrapper: GraphContext }, ); await waitFor(() => { @@ -359,12 +384,14 @@ describe('useEntityRelationNodesAndEdges', () => { }); test('should generate bidirectional graph with merged relations', async () => { - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - unidirectional: false, - mergeRelations: true, - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + unidirectional: false, + mergeRelations: true, + }), + { wrapper: GraphContext }, ); await waitFor(() => { @@ -434,12 +461,14 @@ describe('useEntityRelationNodesAndEdges', () => { }); test('should generate bidirectional graph with all relations', async () => { - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - unidirectional: false, - mergeRelations: false, - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + unidirectional: false, + mergeRelations: false, + }), + { wrapper: GraphContext }, ); await waitFor(() => { @@ -533,10 +562,12 @@ describe('useEntityRelationNodesAndEdges', () => { }); test('should generate graph with multiple root nodes', async () => { - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c', 'b:d/c2'], - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c', 'b:d/c2'], + }), + { wrapper: GraphContext }, ); await waitFor(() => { @@ -606,11 +637,13 @@ describe('useEntityRelationNodesAndEdges', () => { }); test('should filter by relation', async () => { - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - relations: [RELATION_OWNER_OF], - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + relations: [RELATION_OWNER_OF], + }), + { wrapper: GraphContext }, ); await waitFor(() => { @@ -662,11 +695,13 @@ describe('useEntityRelationNodesAndEdges', () => { }); test('should filter by kind', async () => { - const { result } = renderHook(() => - useEntityRelationNodesAndEdges({ - rootEntityRefs: ['b:d/c'], - kinds: ['b'], - }), + const { result } = renderHook( + () => + useEntityRelationNodesAndEdges({ + rootEntityRefs: ['b:d/c'], + kinds: ['b'], + }), + { wrapper: GraphContext }, ); await waitFor(() => { diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts index 19a6dbe566..20b33ba14e 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts @@ -15,10 +15,11 @@ */ import { MouseEvent, useState } from 'react'; import useDebounce from 'react-use/esm/useDebounce'; -import { RelationPairs, ALL_RELATION_PAIRS } from './relations'; +import { RelationPairs } from '../../types'; import { EntityEdge, EntityNode } from './types'; import { useEntityRelationGraph } from './useEntityRelationGraph'; import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { useRelations } from '../../hooks/useRelations'; /** * Generate nodes and edges to render the entity graph. @@ -32,7 +33,7 @@ export function useEntityRelationNodesAndEdges({ relations, entityFilter, onNodeClick, - relationPairs = ALL_RELATION_PAIRS, + relationPairs: incomingRelationPairs, }: { rootEntityRefs: string[]; maxDepth?: number; @@ -63,6 +64,11 @@ export function useEntityRelationNodesAndEdges({ }, }); + const { relationPairs, includeRelation } = useRelations({ + relations, + relationPairs: incomingRelationPairs, + }); + useDebounce( () => { if (!entities || Object.keys(entities).length === 0) { @@ -124,7 +130,7 @@ export function useEntityRelationNodesAndEdges({ return; } - if (relations && !relations.includes(rel.type)) { + if (!includeRelation(rel.type)) { return; } @@ -230,7 +236,7 @@ export function useEntityRelationNodesAndEdges({ entities, rootEntityRefs, kinds, - relations, + includeRelation, unidirectional, mergeRelations, onNodeClick, diff --git a/plugins/catalog-graph/src/hooks/index.ts b/plugins/catalog-graph/src/hooks/index.ts new file mode 100644 index 0000000000..6cc875631d --- /dev/null +++ b/plugins/catalog-graph/src/hooks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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 type { UseRelationsOptions, UseRelationsResult } from './useRelations'; +export { useRelations } from './useRelations'; diff --git a/plugins/catalog-graph/src/hooks/useRelations.ts b/plugins/catalog-graph/src/hooks/useRelations.ts new file mode 100644 index 0000000000..976f17735b --- /dev/null +++ b/plugins/catalog-graph/src/hooks/useRelations.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2025 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 { useCallback, useMemo } from 'react'; + +import { useApi } from '@backstage/core-plugin-api'; + +import { catalogGraphApiRef } from '../api'; +import { RelationPairs } from '../types'; + +export interface UseRelationsOptions { + relations?: string[]; + relationPairs?: RelationPairs; +} + +export interface UseRelationsResult { + relations: string[]; + relationPairs: RelationPairs; + defaultRelations: string[]; + includeRelation: (type: string) => boolean; +} + +/** + * Given an optional list of specific relations and/or relation pairs, + * this hook returns the relations, relation pairs and default relations (to + * show/select), falling back to the configured setting. + * It also returns a function `includeRelation` to filter whether a relation + * should be included or not. + */ +export function useRelations( + opts: UseRelationsOptions = {}, +): UseRelationsResult { + const { knownRelations, knownRelationPairs, defaultRelations } = + useApi(catalogGraphApiRef); + + const relations = opts.relations ?? knownRelations; + const relationPairs = opts.relationPairs ?? knownRelationPairs; + + const includeRelation = useCallback( + (type: string) => { + if (opts.relations) { + return opts.relations.includes(type); + } + return defaultRelations.includes(type); + }, + [opts.relations, defaultRelations], + ); + + return useMemo( + () => ({ + relations, + relationPairs, + defaultRelations, + includeRelation, + }), + [relations, relationPairs, defaultRelations, includeRelation], + ); +} diff --git a/plugins/catalog-graph/src/index.ts b/plugins/catalog-graph/src/index.ts index af81b076f2..fb12cda14e 100644 --- a/plugins/catalog-graph/src/index.ts +++ b/plugins/catalog-graph/src/index.ts @@ -23,5 +23,8 @@ export * from './components'; export { CatalogGraphPage, EntityCatalogGraphCard } from './extensions'; +export * from './api'; export { catalogGraphPlugin } from './plugin'; export { catalogGraphRouteRef } from './routes'; +export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './types'; +export type { RelationPairs } from './types'; diff --git a/plugins/catalog-graph/src/plugin.ts b/plugins/catalog-graph/src/plugin.ts index 7629caabee..570283c952 100644 --- a/plugins/catalog-graph/src/plugin.ts +++ b/plugins/catalog-graph/src/plugin.ts @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin } from '@backstage/core-plugin-api'; +import { createApiFactory, createPlugin } from '@backstage/core-plugin-api'; import { catalogEntityRouteRef, catalogGraphRouteRef } from './routes'; +import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api'; /** * Catalog Graph Plugin instance. @@ -28,4 +29,11 @@ export const catalogGraphPlugin = createPlugin({ externalRoutes: { catalogEntity: catalogEntityRouteRef, }, + apis: [ + createApiFactory({ + api: catalogGraphApiRef, + deps: {}, + factory: () => new DefaultCatalogGraphApi(), + }), + ], }); diff --git a/plugins/catalog-graph/src/types/index.ts b/plugins/catalog-graph/src/types/index.ts new file mode 100644 index 0000000000..63dfb5e3f2 --- /dev/null +++ b/plugins/catalog-graph/src/types/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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 { ALL_RELATIONS, ALL_RELATION_PAIRS } from './relations'; +export type { RelationPairs } from './relations'; diff --git a/plugins/catalog-graph/src/types/relations.ts b/plugins/catalog-graph/src/types/relations.ts new file mode 100644 index 0000000000..d6a683cf4f --- /dev/null +++ b/plugins/catalog-graph/src/types/relations.ts @@ -0,0 +1,77 @@ +/* + * 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'; + +/** + * A pair of two relations that describe the opposite of each other. The first + * relation is considered as the primary relation. + * + * @public + */ +export type RelationPairs = [string, string][]; + +/** + * A list of built-in relations + * + * @public + */ +export const ALL_RELATIONS: string[] = [ + 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, +]; + +/** + * A list of pairs of entity relations, used to define which relations are + * merged together and which the primary relation is. + * + * @public + */ +export const ALL_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], +]; From 4d0de036ed708231b1043a91d87c09ccbc5ead4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Wed, 27 Aug 2025 08:38:55 +0200 Subject: [PATCH 2/8] Minor change, not patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .changeset/dull-eagles-sin.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/dull-eagles-sin.md b/.changeset/dull-eagles-sin.md index a527a9757d..864f38f582 100644 --- a/.changeset/dull-eagles-sin.md +++ b/.changeset/dull-eagles-sin.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-catalog-graph': patch +'@backstage/plugin-catalog-graph': minor --- -Support custom relations by using an API +Support custom relations by using an API to define known relations and which to show by default From 3eeb8d395c6cd3e8deb59c0ff4d6be30f6a7ade2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 4 Sep 2025 15:40:10 +0200 Subject: [PATCH 3/8] fix api report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/scaffolder-backend-module-rails/report.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-rails/report.api.md b/plugins/scaffolder-backend-module-rails/report.api.md index a8abcfee1e..c7bbd19a72 100644 --- a/plugins/scaffolder-backend-module-rails/report.api.md +++ b/plugins/scaffolder-backend-module-rails/report.api.md @@ -48,6 +48,7 @@ export function createFetchRailsAction(options: { railsArguments?: | { template?: string | undefined; + api?: boolean | undefined; database?: | 'sqlite3' | 'mysql' @@ -59,7 +60,6 @@ export function createFetchRailsAction(options: { | 'jdbcpostgresql' | 'jdbc' | undefined; - api?: boolean | undefined; force?: boolean | undefined; minimal?: boolean | undefined; railsVersion?: 'edge' | 'master' | 'dev' | 'fromImage' | undefined; From b9a87f45933adeef2f43e9e272c71d9a75704915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Fri, 5 Sep 2025 09:19:02 +0200 Subject: [PATCH 4/8] Fixed "simplified" in the catalog-graph, solving #30121 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .changeset/dull-eagles-sin.md | 1 + .changeset/nine-socks-share.md | 5 + packages/core-components/report.api.md | 1 + .../src/components/DependencyGraph/types.ts | 4 + plugins/catalog-graph/report-alpha.api.md | 4 +- .../src/api/DefaultCatalogGraphApi.ts | 2 +- .../CatalogGraphCard/CatalogGraphCard.tsx | 3 +- .../CatalogGraphPage/CatalogGraphPage.tsx | 3 +- .../CatalogGraphPage/DirectionFilter.test.tsx | 2 +- .../CatalogGraphPage/DirectionFilter.tsx | 2 +- .../SelectedRelationsFilter.test.tsx | 2 +- .../useCatalogGraphPage.test.tsx | 2 +- .../CatalogGraphPage/useCatalogGraphPage.ts | 2 +- .../DefaultRenderLabel.tsx | 2 +- .../DefaultRenderNode.tsx | 2 +- .../EntityRelationsGraph.tsx | 4 +- .../components/EntityRelationsGraph/index.ts | 7 - .../useEntityRelationGraph.ts | 16 +- .../useEntityRelationNodesAndEdges.test.tsx | 74 +++----- .../useEntityRelationNodesAndEdges.ts | 178 +++++------------- .../EntityRelationsGraph/useEntityStore.ts | 4 +- .../catalog-graph/src/hooks/useRelations.ts | 20 +- plugins/catalog-graph/src/index.ts | 11 +- .../src/lib/graph-transformations/index.ts | 33 ++++ .../graph-transformations/merge-relations.ts | 50 +++++ .../graph-transformations/order-forward.ts | 33 ++++ .../lib/graph-transformations/reduce-edges.ts | 46 +++++ .../remove-backward-edges.ts | 53 ++++++ .../lib/graph-transformations/set-distance.ts | 98 ++++++++++ .../strip-distant-edges.ts | 78 ++++++++ .../src/lib/graph-transformations/types.ts | 36 ++++ .../src/lib/graph/build-graph.ts | 151 +++++++++++++++ plugins/catalog-graph/src/lib/graph/index.ts | 18 ++ .../types.ts => lib/types/graph.ts} | 0 .../src/{ => lib}/types/index.ts | 10 +- .../src/{ => lib}/types/relations.ts | 0 36 files changed, 738 insertions(+), 219 deletions(-) create mode 100644 .changeset/nine-socks-share.md create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/index.ts create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/reduce-edges.ts create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/strip-distant-edges.ts create mode 100644 plugins/catalog-graph/src/lib/graph-transformations/types.ts create mode 100644 plugins/catalog-graph/src/lib/graph/build-graph.ts create mode 100644 plugins/catalog-graph/src/lib/graph/index.ts rename plugins/catalog-graph/src/{components/EntityRelationsGraph/types.ts => lib/types/graph.ts} (100%) rename plugins/catalog-graph/src/{ => lib}/types/index.ts (84%) rename plugins/catalog-graph/src/{ => lib}/types/relations.ts (100%) diff --git a/.changeset/dull-eagles-sin.md b/.changeset/dull-eagles-sin.md index 864f38f582..8689ea3fc2 100644 --- a/.changeset/dull-eagles-sin.md +++ b/.changeset/dull-eagles-sin.md @@ -3,3 +3,4 @@ --- Support custom relations by using an API to define known relations and which to show by default +Fixes "simplified" bug (#30121) which caused graphs not to be simplified diff --git a/.changeset/nine-socks-share.md b/.changeset/nine-socks-share.md new file mode 100644 index 0000000000..82c96e554e --- /dev/null +++ b/.changeset/nine-socks-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': minor +--- + +Add optional `distance` property to `DependencyEdge` to reflect the distance to a root. diff --git a/packages/core-components/report.api.md b/packages/core-components/report.api.md index fff1d0365a..b16aecb3b1 100644 --- a/packages/core-components/report.api.md +++ b/packages/core-components/report.api.md @@ -291,6 +291,7 @@ export namespace DependencyGraphTypes { from: string; to: string; label?: string; + distance?: number; }; export type DependencyNode = T & { id: string; diff --git a/packages/core-components/src/components/DependencyGraph/types.ts b/packages/core-components/src/components/DependencyGraph/types.ts index e8fc9f6da3..a449637e49 100644 --- a/packages/core-components/src/components/DependencyGraph/types.ts +++ b/packages/core-components/src/components/DependencyGraph/types.ts @@ -46,6 +46,10 @@ export namespace DependencyGraphTypes { * Label assigned and rendered with the Edge */ label?: string; + /** + * Distance to a root entity + */ + distance?: number; }; /** diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index 729be9006b..17ef05722a 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -84,9 +84,9 @@ const _default: FrontendPlugin< relations?: string[] | undefined; maxDepth?: number | undefined; kinds?: string[] | undefined; - unidirectional?: boolean | undefined; mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; + unidirectional?: boolean | undefined; } & { filter?: EntityPredicate | undefined; type?: 'content' | 'summary' | 'info' | undefined; @@ -157,9 +157,9 @@ const _default: FrontendPlugin< rootEntityRefs?: string[] | undefined; maxDepth?: number | undefined; kinds?: string[] | undefined; - unidirectional?: boolean | undefined; mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; + unidirectional?: boolean | undefined; selectedRelations?: string[] | undefined; selectedKinds?: string[] | undefined; showFilters?: boolean | undefined; diff --git a/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts b/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts index f64dffb09b..edb7b8f509 100644 --- a/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts +++ b/plugins/catalog-graph/src/api/DefaultCatalogGraphApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ALL_RELATION_PAIRS, ALL_RELATIONS, RelationPairs } from '../types'; +import { ALL_RELATION_PAIRS, ALL_RELATIONS, RelationPairs } from '../lib/types'; import { CatalogGraphApi, DefaultRelations, diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx index d0524668aa..2f07b89d3f 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx @@ -32,13 +32,12 @@ import { MouseEvent, ReactNode, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { catalogGraphRouteRef } from '../../routes'; import { - Direction, - EntityNode, EntityRelationsGraph, EntityRelationsGraphProps, } from '../EntityRelationsGraph'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { catalogGraphTranslationRef } from '../../translation'; +import { Direction, EntityNode } from '../../lib/types'; /** @public */ export type CatalogGraphCardClassKey = 'card' | 'graph'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx index 27d619c9c5..7e6420da1f 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx @@ -37,8 +37,6 @@ import ToggleButton from '@material-ui/lab/ToggleButton'; import { MouseEvent, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Direction, - EntityNode, EntityRelationsGraph, EntityRelationsGraphProps, } from '../EntityRelationsGraph'; @@ -51,6 +49,7 @@ import { SwitchFilter } from './SwitchFilter'; import { useCatalogGraphPage } from './useCatalogGraphPage'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { catalogGraphTranslationRef } from '../../translation'; +import { Direction, EntityNode } from '../../lib/types'; /** @public */ export type CatalogGraphPageClassKey = diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx index 1262797a43..d5cee68971 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx @@ -16,7 +16,7 @@ import { waitFor, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Direction } from '../EntityRelationsGraph'; +import { Direction } from '../../lib/types'; import { DirectionFilter } from './DirectionFilter'; import { renderInTestApp } from '@backstage/test-utils'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx index 5d21f61c6c..060a1d32c6 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx @@ -16,7 +16,7 @@ import { Select, SelectedItems } from '@backstage/core-components'; import Box from '@material-ui/core/Box'; import { useCallback } from 'react'; -import { Direction } from '../EntityRelationsGraph'; +import { Direction } from '../../lib/types'; import { useTranslationRef } from '@backstage/frontend-plugin-api'; import { catalogGraphTranslationRef } from '../../translation'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx index e97b817915..09e09ee822 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx @@ -23,7 +23,7 @@ import { } from '@backstage/catalog-model'; import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { ALL_RELATIONS } from '../../types'; +import { ALL_RELATIONS } from '../../lib/types'; import { SelectedRelationsFilter } from './SelectedRelationsFilter'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.tsx index c255281ff0..fa3d67520d 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.tsx @@ -18,7 +18,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { ReactNode } from 'react'; import { act } from 'react-dom/test-utils'; import { BrowserRouter } from 'react-router-dom'; -import { Direction } from '../EntityRelationsGraph'; +import { Direction } from '../../lib/types'; import { useCatalogGraphPage } from './useCatalogGraphPage'; const wrapper = ({ children }: { children?: ReactNode }) => { diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts index 1f6fb74997..efc2170153 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts @@ -28,7 +28,7 @@ import { useState, } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { Direction } from '../EntityRelationsGraph'; +import { Direction } from '../../lib/types'; export type CatalogGraphPageValue = { rootEntityNames: CompoundEntityRef[]; diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx index a38f11fca3..c9ddddb4b3 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx @@ -15,7 +15,7 @@ */ import { DependencyGraphTypes } from '@backstage/core-components'; import makeStyles from '@material-ui/core/styles/makeStyles'; -import { EntityEdgeData } from './types'; +import { EntityEdgeData } from '../../lib/types'; import classNames from 'classnames'; /** @public */ diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx index 3541e4511e..97c17af3ec 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx @@ -20,7 +20,7 @@ import { makeStyles } from '@material-ui/core/styles'; import classNames from 'classnames'; import { useLayoutEffect, useRef, useState } from 'react'; import { EntityIcon } from './EntityIcon'; -import { EntityNodeData } from './types'; +import { EntityNodeData } from '../../lib/types'; import { DEFAULT_NAMESPACE } from '@backstage/catalog-model'; /** @public */ diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx index 13bebbabb7..f77ca4b37d 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx @@ -30,8 +30,8 @@ import classNames from 'classnames'; import { MouseEvent, useEffect, useMemo } from 'react'; import { DefaultRenderLabel } from './DefaultRenderLabel'; import { DefaultRenderNode } from './DefaultRenderNode'; -import { RelationPairs } from '../../types'; -import { Direction, EntityEdge, EntityNode } from './types'; +import { RelationPairs } from '../../lib/types'; +import { Direction, EntityEdge, EntityNode } from '../../lib/types'; import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges'; /** @public */ diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts index 1c61e4abc9..4a325591ca 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts @@ -18,12 +18,5 @@ export type { EntityRelationsGraphProps, EntityRelationsGraphClassKey, } from './EntityRelationsGraph'; -export { Direction } from './types'; -export type { - EntityEdgeData, - EntityEdge, - EntityNodeData, - EntityNode, -} from './types'; export type { CustomLabelClassKey } from './DefaultRenderLabel'; export type { CustomNodeClassKey } from './DefaultRenderNode'; diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts index f52ac03f7e..1b99cfc074 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.ts @@ -106,13 +106,15 @@ export function useEntityRelationGraph({ requestEntities, ]); - const filteredEntities = useMemo( - () => - entityFilter - ? pickBy(entities, (value, _key) => entityFilter(value)) - : entities, - [entities, entityFilter], - ); + const filteredEntities = useMemo(() => { + if (loading) { + return {}; + } + + return entityFilter + ? pickBy(entities, (value, _key) => entityFilter(value)) + : entities; + }, [loading, entities, entityFilter]); return { entities: filteredEntities, diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx index 4dfb612646..b896fe1ed3 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx @@ -29,7 +29,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { filter, keyBy } from 'lodash'; import { useEntityRelationGraph as useEntityRelationGraphMocked } from './useEntityRelationGraph'; import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges'; -import { EntityNode } from './types'; +import { EntityNode } from '../../lib/types'; import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api'; jest.mock('./useEntityRelationGraph'); @@ -256,24 +256,21 @@ describe('useEntityRelationNodesAndEdges', () => { ]); expect(edges).toEqual([ { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_OWNER_OF, RELATION_OWNED_BY], to: 'k:d/a1', }, { + distance: 1, 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', - }, - { + distance: 2, from: 'b:d/c1', label: 'visible', relations: [RELATION_HAS_PART, RELATION_PART_OF], @@ -333,53 +330,26 @@ describe('useEntityRelationNodesAndEdges', () => { ]); expect(edges).toEqual([ { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_OWNER_OF], to: 'k:d/a1', }, { + distance: 1, 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', - }, - { + distance: 2, 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', - }, ]); }); @@ -434,24 +404,28 @@ describe('useEntityRelationNodesAndEdges', () => { ]); expect(edges).toEqual([ { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_OWNER_OF, RELATION_OWNED_BY], to: 'k:d/a1', }, { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_HAS_PART, RELATION_PART_OF], to: 'b:d/c1', }, { + distance: 2, from: 'b:d/c1', label: 'visible', relations: [RELATION_OWNER_OF, RELATION_OWNED_BY], to: 'k:d/a1', }, { + distance: 2, from: 'b:d/c1', label: 'visible', relations: [RELATION_HAS_PART, RELATION_PART_OF], @@ -511,48 +485,56 @@ describe('useEntityRelationNodesAndEdges', () => { ]); expect(edges).toEqual([ { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_OWNER_OF], to: 'k:d/a1', }, { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_HAS_PART], to: 'b:d/c1', }, { + distance: 1, from: 'b:d/c1', label: 'visible', relations: [RELATION_PART_OF], to: 'b:d/c', }, { + distance: 2, from: 'b:d/c1', label: 'visible', relations: [RELATION_OWNER_OF], to: 'k:d/a1', }, { + distance: 2, from: 'b:d/c1', label: 'visible', relations: [RELATION_HAS_PART], to: 'b:d/c2', }, { + distance: 2, from: 'b:d/c2', label: 'visible', relations: [RELATION_PART_OF], to: 'b:d/c1', }, { + distance: 1, from: 'k:d/a1', label: 'visible', relations: [RELATION_OWNED_BY], to: 'b:d/c', }, { + distance: 2, from: 'k:d/a1', label: 'visible', relations: [RELATION_OWNED_BY], @@ -610,24 +592,21 @@ describe('useEntityRelationNodesAndEdges', () => { ]); expect(edges).toEqual([ { - from: 'b:d/c1', + distance: 1, + from: 'b:d/c2', label: 'visible', - relations: ['hasPart', 'partOf'], - to: 'b:d/c2', + relations: ['partOf', 'hasPart'], + to: 'b:d/c1', }, { + distance: 1, 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', - }, - { + distance: 1, from: 'b:d/c', label: 'visible', relations: ['ownerOf', 'ownedBy'], @@ -686,6 +665,7 @@ describe('useEntityRelationNodesAndEdges', () => { ]); expect(edges).toEqual([ { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_OWNER_OF, RELATION_OWNED_BY], @@ -738,12 +718,14 @@ describe('useEntityRelationNodesAndEdges', () => { ]); expect(edges).toEqual([ { + distance: 1, from: 'b:d/c', label: 'visible', relations: [RELATION_HAS_PART, RELATION_PART_OF], to: 'b:d/c1', }, { + distance: 2, from: 'b:d/c1', label: 'visible', relations: [RELATION_HAS_PART, RELATION_PART_OF], diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts index 20b33ba14e..d65100567d 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts @@ -13,13 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { MouseEvent, useState } from 'react'; +import { MouseEvent, useMemo, useState } from 'react'; import useDebounce from 'react-use/esm/useDebounce'; -import { RelationPairs } from '../../types'; -import { EntityEdge, EntityNode } from './types'; +import { RelationPairs } from '../../lib/types'; +import { EntityEdge, EntityNode } from '../../lib/types'; import { useEntityRelationGraph } from './useEntityRelationGraph'; import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { useRelations } from '../../hooks/useRelations'; +import { buildGraph } from '../../lib/graph'; +import { + builtInTransformers, + TransformerContext, +} from '../../lib/graph-transformations'; /** * Generate nodes and edges to render the entity graph. @@ -69,6 +74,11 @@ export function useEntityRelationNodesAndEdges({ relationPairs: incomingRelationPairs, }); + const forwardRelations = useMemo( + () => relationPairs.map(pair => pair[0]), + [relationPairs], + ); + useDebounce( () => { if (!entities || Object.keys(entities).length === 0) { @@ -98,141 +108,46 @@ export function useEntityRelationNodesAndEdges({ return node; }); - const edges: EntityEdge[] = []; - const visitedNodes = new Set(); - const nodeQueue = [...rootEntityRefs]; + const edges = buildGraph({ + rootEntityRefs, + entities, + includeRelation, + kinds, + mergeRelations, + relationPairs, + unidirectional, + }); - const hasEdge = ( - fromRef: string, - toRef: string, - relation: [string, string?], - ): boolean => { - return edges.some( - edge => - fromRef === edge.from && - toRef === edge.to && - edge.relations.some( - rel => rel[0] === relation[0] && rel[1] === relation[1], - ), - ); + const transformerContext: TransformerContext = { + nodeDistances: new Map(), + edges, + + rootEntityRefs, + unidirectional, + maxDepth, + forwardRelations, }; - - while (nodeQueue.length > 0) { - const entityRef = nodeQueue.pop()!; - const entity = entities[entityRef]; - visitedNodes.add(entityRef); - - if (entity) { - entity?.relations?.forEach(rel => { - // Check if the related entity should be displayed, if not, ignore - // the relation too - if (!entities[rel.targetRef]) { - return; - } - - if (!includeRelation(rel.type)) { - return; - } - - if ( - kinds && - !kinds.some(kind => - rel.targetRef.startsWith(`${kind.toLocaleLowerCase('en-US')}:`), - ) - ) { - return; - } - - if (mergeRelations) { - const pair = relationPairs.find( - ([l, r]) => l === rel.type || r === rel.type, - ) ?? [rel.type]; - const [left] = pair; - const from = left === rel.type ? entityRef : rel.targetRef; - const to = left === rel.type ? rel.targetRef : entityRef; - if (!unidirectional || !hasEdge(from, to, pair)) { - edges.push({ - from, - to, - relations: pair, - label: 'visible', - }); - } - } else { - if ( - !unidirectional || - !hasEdge(entityRef, rel.targetRef, [rel.type]) - ) { - edges.push({ - from: entityRef, - to: rel.targetRef, - relations: [rel.type], - label: 'visible', - }); - } - } - - if (!visitedNodes.has(rel.targetRef)) { - nodeQueue.push(rel.targetRef); - visitedNodes.add(rel.targetRef); - } - - // if unidirectional add missing relations as entities are only visited once - if (unidirectional) { - const findIndex = edges.findIndex( - edge => - entityRef === edge.from && - rel.targetRef === edge.to && - !edge.relations.includes(rel.type), - ); - if (findIndex >= 0) { - if (mergeRelations) { - const pair = relationPairs.find( - ([l, r]) => l === rel.type || r === rel.type, - ) ?? [rel.type]; - edges[findIndex].relations = [ - ...edges[findIndex].relations, - ...pair, - ]; - } else { - edges[findIndex].relations = [ - ...edges[findIndex].relations, - rel.type, - ]; - } - } - } - }); - } + builtInTransformers['reduce-edges'](transformerContext); + builtInTransformers['set-distances'](transformerContext); + if (unidirectional) { + builtInTransformers['order-forward'](transformerContext); + builtInTransformers['strip-distant-edges'](transformerContext); + } + if (mergeRelations || unidirectional) { + // Merge relations even if only unidirectional, the next transformer + // 'remove-backward-edges' needs to know about all relations before it + // strips away the backward ones + builtInTransformers['merge-relations'](transformerContext); + } + if (unidirectional && !mergeRelations) { + builtInTransformers['remove-backward-edges'](transformerContext); } - // Reduce edges as the dependency graph anyway ignores duplicated edges regarding from / to - // Additionally, this will improve rendering speed for the dependency graph - const finalEdges = edges.reduce((previousEdges, currentEdge) => { - const indexFound = previousEdges.findIndex( - previousEdge => - previousEdge.from === currentEdge.from && - previousEdge.to === currentEdge.to, - ); - if (indexFound >= 0) { - previousEdges[indexFound] = { - ...previousEdges[indexFound], - relations: Array.from( - new Set([ - ...previousEdges[indexFound].relations, - ...currentEdge.relations, - ]), - ), - }; - return previousEdges; - } - return [...previousEdges, currentEdge]; - }, [] as EntityEdge[]); - - setNodesAndEdges({ nodes, edges: finalEdges }); + setNodesAndEdges({ nodes, edges: transformerContext.edges }); }, 100, [ + maxDepth, entities, rootEntityRefs, kinds, @@ -241,6 +156,7 @@ export function useEntityRelationNodesAndEdges({ mergeRelations, onNodeClick, relationPairs, + forwardRelations, ], ); diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts index c55becdd49..5880468317 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts @@ -16,7 +16,7 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { Dispatch, useCallback, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import useAsyncFn from 'react-use/esm/useAsyncFn'; // TODO: This is a good use case for a graphql API, once it is available in the @@ -29,7 +29,7 @@ export function useEntityStore(): { entities: { [ref: string]: Entity }; loading: boolean; error?: Error; - requestEntities: Dispatch; + requestEntities: (entityRefs: string[]) => void; } { const catalogClient = useApi(catalogApiRef); const state = useRef({ diff --git a/plugins/catalog-graph/src/hooks/useRelations.ts b/plugins/catalog-graph/src/hooks/useRelations.ts index 976f17735b..2c734a2cb1 100644 --- a/plugins/catalog-graph/src/hooks/useRelations.ts +++ b/plugins/catalog-graph/src/hooks/useRelations.ts @@ -19,7 +19,7 @@ import { useCallback, useMemo } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { catalogGraphApiRef } from '../api'; -import { RelationPairs } from '../types'; +import { RelationPairs } from '../lib/types'; export interface UseRelationsOptions { relations?: string[]; @@ -30,6 +30,7 @@ export interface UseRelationsResult { relations: string[]; relationPairs: RelationPairs; defaultRelations: string[]; + relationsToInclude: string[]; includeRelation: (type: string) => boolean; } @@ -48,15 +49,13 @@ export function useRelations( const relations = opts.relations ?? knownRelations; const relationPairs = opts.relationPairs ?? knownRelationPairs; + const relationsToInclude = opts.relations ?? defaultRelations; const includeRelation = useCallback( (type: string) => { - if (opts.relations) { - return opts.relations.includes(type); - } - return defaultRelations.includes(type); + return relationsToInclude.includes(type); }, - [opts.relations, defaultRelations], + [relationsToInclude], ); return useMemo( @@ -64,8 +63,15 @@ export function useRelations( relations, relationPairs, defaultRelations, + relationsToInclude, includeRelation, }), - [relations, relationPairs, defaultRelations, includeRelation], + [ + relations, + relationPairs, + defaultRelations, + relationsToInclude, + includeRelation, + ], ); } diff --git a/plugins/catalog-graph/src/index.ts b/plugins/catalog-graph/src/index.ts index fb12cda14e..68b84c2325 100644 --- a/plugins/catalog-graph/src/index.ts +++ b/plugins/catalog-graph/src/index.ts @@ -26,5 +26,12 @@ export { CatalogGraphPage, EntityCatalogGraphCard } from './extensions'; export * from './api'; export { catalogGraphPlugin } from './plugin'; export { catalogGraphRouteRef } from './routes'; -export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './types'; -export type { RelationPairs } from './types'; +export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './lib/types'; +export type { + RelationPairs, + EntityEdgeData, + EntityEdge, + EntityNodeData, + EntityNode, +} from './lib/types'; +export { Direction } from './lib/types'; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/index.ts b/plugins/catalog-graph/src/lib/graph-transformations/index.ts new file mode 100644 index 0000000000..948596ab3d --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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 type { GraphTransformer, TransformerContext } from './types'; + +import { reduceEdges } from './reduce-edges'; +import { setDistances } from './set-distance'; +import { orderForward } from './order-forward'; +import { stripDistantEdges } from './strip-distant-edges'; +import { mergeRelations } from './merge-relations'; +import { removeBackwardEdges } from './remove-backward-edges'; + +export const builtInTransformers = { + 'reduce-edges': reduceEdges, + 'set-distances': setDistances, + 'order-forward': orderForward, + 'strip-distant-edges': stripDistantEdges, + 'merge-relations': mergeRelations, + 'remove-backward-edges': removeBackwardEdges, +}; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts new file mode 100644 index 0000000000..aed9d0dd85 --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2025 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 { EntityEdge } from '../types'; +import { GraphTransformer } from './types'; + +function concatRelations(a: readonly string[], b: readonly string[]): string[] { + return Array.from(new Set([...a, ...b])); +} + +/** Merge relations in multiple edges into one edge */ +export const mergeRelations: GraphTransformer = options => { + // The key is on the form `from-to` + const mergedEdges = new Map(); + options.edges.forEach(edge => { + const keyForward = `${edge.from}-${edge.to}`; + const keyBackward = `${edge.to}-${edge.from}`; + + const edgeForward = mergedEdges.get(keyForward); + const edgeBackward = mergedEdges.get(keyBackward); + + if (edgeForward) { + edgeForward.relations = concatRelations( + edgeForward.relations, + edge.relations, + ); + } else if (edgeBackward) { + edgeBackward.relations = concatRelations( + edge.relations, + edgeBackward.relations, + ); + } else { + mergedEdges.set(keyForward, edge); + } + }); + options.edges = Array.from(mergedEdges.values()); +}; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts new file mode 100644 index 0000000000..d832a1411d --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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 { GraphTransformer } from './types'; + +/** Orders the edges direction so that the graph goes strictly forward */ +export const orderForward: GraphTransformer = ({ nodeDistances, edges }) => { + edges.forEach(edge => { + const fromDistance = nodeDistances.get(edge.from) ?? 0; + const toDistance = nodeDistances.get(edge.to) ?? 0; + + if (toDistance < fromDistance) { + // Reverse order + const { from, to } = edge; + edge.from = to; + edge.to = from; + edge.relations.reverse(); + } + }); +}; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/reduce-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/reduce-edges.ts new file mode 100644 index 0000000000..115aec438c --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/reduce-edges.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 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 { EntityEdge } from '../types'; +import { GraphTransformer } from './types'; + +/** + * Reduce edges as the dependency graph anyway ignores duplicated edges + * regarding from / to + * Additionally, this will improve rendering speed for the dependency graph + */ +export const reduceEdges: GraphTransformer = ctx => { + ctx.edges = ctx.edges.reduce((previousEdges, currentEdge) => { + const indexFound = previousEdges.findIndex( + previousEdge => + previousEdge.from === currentEdge.from && + previousEdge.to === currentEdge.to, + ); + if (indexFound >= 0) { + previousEdges[indexFound] = { + ...previousEdges[indexFound], + relations: Array.from( + new Set([ + ...previousEdges[indexFound].relations, + ...currentEdge.relations, + ]), + ), + }; + return previousEdges; + } + return [...previousEdges, currentEdge]; + }, [] as EntityEdge[]); +}; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts new file mode 100644 index 0000000000..1be4c0db01 --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2025 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 { EntityEdge } from '../types'; +import { GraphTransformer } from './types'; + +/** + * Remove edges going backwards, if unidirectional and we haven't already + * merged the relations + */ +export const removeBackwardEdges: GraphTransformer = ctx => { + const { forwardRelations, edges } = ctx; + + const edgesMapFrom = new Map(); + + edges.forEach(edge => { + const theseEdges = edgesMapFrom.get(edge.from) ?? []; + theseEdges.push(edge); + edgesMapFrom.set(edge.from, theseEdges); + }); + + ctx.edges = edges.filter(edge => { + if (edge.relations.length === 2) { + // If there are two relations, only keep the forward one + if (!forwardRelations.includes(edge.relations[1])) { + edge.relations.pop(); + } + if (!forwardRelations.includes(edge.relations[0])) { + edge.relations.pop(); + } + return edge.relations.length > 0; + } else if (edge.relations.length === 1) { + // If there is only one relation and it's backwards, remove it + if (!forwardRelations.includes(edge.relations[0])) { + return false; + } + } + return true; + }); +}; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts new file mode 100644 index 0000000000..965f01fc14 --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2025 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 { EntityEdge } from '../types'; +import { GraphTransformer } from './types'; + +function minDistance(a: number | undefined, b: number | undefined) { + if (typeof a === 'undefined') return b; + if (typeof b === 'undefined') return a; + return Math.min(a, b); +} + +/** Set the distance of each edge to the distance from a root entity */ +export const setDistances: GraphTransformer = ({ + nodeDistances, + edges, + rootEntityRefs, +}) => { + const edgeMap = new Map(); + edges.forEach(edge => { + const fromEdges = edgeMap.get(edge.from) ?? []; + edgeMap.set(edge.from, [...fromEdges, edge]); + + const toEdges = edgeMap.get(edge.to) ?? []; + edgeMap.set(edge.to, [...toEdges, edge]); + }); + + // Sets the distance on as many edges as possible. + // Returns true if all edges have a distance + const setEdgeDistances = () => { + return ( + edges.filter(edge => { + if ( + rootEntityRefs.includes(edge.from) || + rootEntityRefs.includes(edge.to) + ) { + edge.distance = 1; + return false; + } + + const distance = minDistance( + edgeMap + .get(edge.from)! + .reduce( + (prev, cur) => minDistance(prev, cur.distance), + undefined as number | undefined, + ), + edgeMap + .get(edge.to)! + .reduce( + (prev, cur) => minDistance(prev, cur.distance), + undefined as number | undefined, + ), + ); + + if (typeof distance !== 'undefined') { + edge.distance = minDistance(edge.distance, distance + 1); + } + return typeof edge.distance === 'undefined'; + }).length === 0 + ); + }; + + let distanceComplete = false; + while (!distanceComplete) { + distanceComplete = setEdgeDistances(); + } + + rootEntityRefs.forEach(rootEntityRef => { + nodeDistances.set(rootEntityRef, 0); + }); + edges.forEach(edge => { + const curFrom = nodeDistances.get(edge.from); + const curTo = nodeDistances.get(edge.to); + + const fromDistance = minDistance(curFrom, edge.distance); + const toDistance = minDistance(curTo, edge.distance); + if (typeof fromDistance !== 'undefined') { + nodeDistances.set(edge.from, fromDistance); + } + if (typeof toDistance !== 'undefined') { + nodeDistances.set(edge.to, toDistance); + } + }); +}; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/strip-distant-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/strip-distant-edges.ts new file mode 100644 index 0000000000..aeb7618e5e --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/strip-distant-edges.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2025 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 { EntityEdge } from '../types'; +import { GraphTransformer } from './types'; + +/** + * Removes edges that are further away then other edges leading up to the root. + */ +export const stripDistantEdges: GraphTransformer = options => { + const entityEdges = new Map(); + options.edges.forEach(edge => { + let fromEdges = entityEdges.get(edge.from); + if (!fromEdges) { + fromEdges = []; + entityEdges.set(edge.from, fromEdges); + } + fromEdges.push(edge); + + let toEdges = entityEdges.get(edge.to); + if (!toEdges) { + toEdges = []; + entityEdges.set(edge.to, toEdges); + } + toEdges.push(edge); + }); + + // Remove edges that are further away than other edges leading up to the root + options.edges = options.edges.filter(edge => { + // Get all edges of the 'from' node except this (and similar) edge + const fromEdges = entityEdges + .get(edge.from)! + .filter( + fromEdge => + !(fromEdge.from === edge.from && fromEdge.to === edge.to) && + !(fromEdge.from === edge.to && fromEdge.to === edge.from), + ); + // Get all edges of the 'from' node except this (and similar) edge + const toEdges = entityEdges + .get(edge.to)! + .filter( + toEdge => + !(toEdge.from === edge.from && toEdge.to === edge.to) && + !(toEdge.from === edge.to && toEdge.to === edge.from), + ); + + if (fromEdges.length === 0 || toEdges.length === 0) { + return true; + } + + const shorterFrom = fromEdges.some( + fromEdge => (fromEdge.distance ?? 0) < (edge.distance ?? 0), + ); + const shorterTo = toEdges.some( + toEdge => (toEdge.distance ?? 0) < (edge.distance ?? 0), + ); + + if (shorterFrom && shorterTo) { + // There are shorter edges from both 'from' and 'to' leading to the root, + // exclude this edge + return false; + } + return true; + }); +}; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/types.ts b/plugins/catalog-graph/src/lib/graph-transformations/types.ts new file mode 100644 index 0000000000..6d8fc92392 --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph-transformations/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 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 { EntityEdge } from '../types'; + +export interface TransformerContext { + /** + * The distance from an entity node to a root entity + * + * NOTE: This is not set until the setDistances transformation is applied + */ + nodeDistances: Map; + + edges: EntityEdge[]; + + // Options: + rootEntityRefs: string[]; + unidirectional: boolean; + maxDepth: number; + forwardRelations: string[]; +} + +export type GraphTransformer = (options: TransformerContext) => void; diff --git a/plugins/catalog-graph/src/lib/graph/build-graph.ts b/plugins/catalog-graph/src/lib/graph/build-graph.ts new file mode 100644 index 0000000000..3a6bf57120 --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph/build-graph.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2025 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 type { EntityEdge, RelationPairs } from '../types'; + +export interface BuildGraphOptions { + rootEntityRefs: readonly string[]; + entities: { + [ref: string]: Entity; + }; + includeRelation: (type: string) => boolean; + kinds: readonly string[] | undefined; + mergeRelations: boolean; + relationPairs: RelationPairs; + unidirectional: boolean; +} + +export function buildGraph({ + rootEntityRefs, + entities, + includeRelation, + kinds, + mergeRelations, + relationPairs, + unidirectional, +}: BuildGraphOptions): EntityEdge[] { + const edges: EntityEdge[] = []; + const visitedNodes = new Set(); + const nodeQueue = [...rootEntityRefs]; + + const hasEdge = ( + fromRef: string, + toRef: string, + relation: [string, string?], + ): boolean => { + return edges.some( + edge => + fromRef === edge.from && + toRef === edge.to && + edge.relations.some( + rel => rel[0] === relation[0] && rel[1] === relation[1], + ), + ); + }; + + while (nodeQueue.length > 0) { + const entityRef = nodeQueue.pop()!; + const entity = entities[entityRef]; + visitedNodes.add(entityRef); + + if (entity) { + entity?.relations?.forEach(rel => { + // Check if the related entity should be displayed, if not, ignore + // the relation too + if (!entities[rel.targetRef]) { + return; + } + + if (!includeRelation(rel.type)) { + return; + } + + if ( + kinds && + !kinds.some(kind => + rel.targetRef.startsWith(`${kind.toLocaleLowerCase('en-US')}:`), + ) + ) { + return; + } + + if (mergeRelations) { + const pair = relationPairs.find( + ([l, r]) => l === rel.type || r === rel.type, + ) ?? [rel.type]; + const [left] = pair; + const from = left === rel.type ? entityRef : rel.targetRef; + const to = left === rel.type ? rel.targetRef : entityRef; + if (!unidirectional || !hasEdge(from, to, pair)) { + edges.push({ + from, + to, + relations: pair, + label: 'visible', + }); + } + } else { + if ( + !unidirectional || + !hasEdge(entityRef, rel.targetRef, [rel.type]) + ) { + edges.push({ + from: entityRef, + to: rel.targetRef, + relations: [rel.type], + label: 'visible', + }); + } + } + + if (!visitedNodes.has(rel.targetRef)) { + nodeQueue.push(rel.targetRef); + visitedNodes.add(rel.targetRef); + } + + // if unidirectional add missing relations as entities are only visited once + if (unidirectional) { + const findIndex = edges.findIndex( + edge => + entityRef === edge.from && + rel.targetRef === edge.to && + !edge.relations.includes(rel.type), + ); + if (findIndex >= 0) { + if (mergeRelations) { + const pair = relationPairs.find( + ([l, r]) => l === rel.type || r === rel.type, + ) ?? [rel.type]; + edges[findIndex].relations = [ + ...edges[findIndex].relations, + ...pair, + ]; + } else { + edges[findIndex].relations = [ + ...edges[findIndex].relations, + rel.type, + ]; + } + } + } + }); + } + } + + return edges; +} diff --git a/plugins/catalog-graph/src/lib/graph/index.ts b/plugins/catalog-graph/src/lib/graph/index.ts new file mode 100644 index 0000000000..77741aa7d8 --- /dev/null +++ b/plugins/catalog-graph/src/lib/graph/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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 { buildGraph } from './build-graph'; +export type { BuildGraphOptions } from './build-graph'; diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts b/plugins/catalog-graph/src/lib/types/graph.ts similarity index 100% rename from plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts rename to plugins/catalog-graph/src/lib/types/graph.ts diff --git a/plugins/catalog-graph/src/types/index.ts b/plugins/catalog-graph/src/lib/types/index.ts similarity index 84% rename from plugins/catalog-graph/src/types/index.ts rename to plugins/catalog-graph/src/lib/types/index.ts index 63dfb5e3f2..ba567d679a 100644 --- a/plugins/catalog-graph/src/types/index.ts +++ b/plugins/catalog-graph/src/lib/types/index.ts @@ -14,5 +14,13 @@ * limitations under the License. */ -export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './relations'; export type { RelationPairs } from './relations'; +export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './relations'; + +export type { + EntityEdgeData, + EntityEdge, + EntityNodeData, + EntityNode, +} from './graph'; +export { Direction } from './graph'; diff --git a/plugins/catalog-graph/src/types/relations.ts b/plugins/catalog-graph/src/lib/types/relations.ts similarity index 100% rename from plugins/catalog-graph/src/types/relations.ts rename to plugins/catalog-graph/src/lib/types/relations.ts From 004557156bd6ce35c04dba1b34cf39faadd68c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Sat, 6 Sep 2025 23:00:26 +0200 Subject: [PATCH 5/8] Further adaptations to transformations to align almost identical with how renderings used to be MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .../EntityRelationsGraph.tsx | 4 + .../useEntityRelationNodesAndEdges.ts | 66 +++++++++---- .../src/lib/graph-transformations/index.ts | 11 ++- .../graph-transformations/merge-relations.ts | 12 ++- .../graph-transformations/order-forward.ts | 71 ++++++++++++-- .../remove-backward-edges.ts | 21 +---- .../lib/graph-transformations/set-distance.ts | 92 +++++++------------ .../src/lib/graph-transformations/types.ts | 56 ++++++++++- 8 files changed, 217 insertions(+), 116 deletions(-) diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx index f77ca4b37d..47ce365cb6 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx @@ -33,6 +33,7 @@ import { DefaultRenderNode } from './DefaultRenderNode'; import { RelationPairs } from '../../lib/types'; import { Direction, EntityEdge, EntityNode } from '../../lib/types'; import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges'; +import { GraphTransformationDebugger } from '../../lib/graph-transformations'; /** @public */ export type EntityRelationsGraphClassKey = 'progress' | 'container' | 'graph'; @@ -91,6 +92,7 @@ export type EntityRelationsGraphProps = { renderLabel?: DependencyGraphTypes.RenderLabelFunction; curve?: 'curveStepBefore' | 'curveMonotoneX'; showArrowHeads?: boolean; + onPostTransformation?: GraphTransformationDebugger; }; /** @@ -116,6 +118,7 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => { renderLabel, curve, showArrowHeads, + onPostTransformation, } = props; const theme = useTheme(); @@ -139,6 +142,7 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => { entityFilter, onNodeClick, relationPairs, + onPostTransformation, }); useEffect(() => { diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts index d65100567d..2aa301fef9 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { MouseEvent, useMemo, useState } from 'react'; +import { MouseEvent, useState } from 'react'; import useDebounce from 'react-use/esm/useDebounce'; import { RelationPairs } from '../../lib/types'; import { EntityEdge, EntityNode } from '../../lib/types'; @@ -22,8 +22,12 @@ import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { useRelations } from '../../hooks/useRelations'; import { buildGraph } from '../../lib/graph'; import { - builtInTransformers, - TransformerContext, + BuiltInTransformations, + builtInTransformations, + cloneTransformationContext, + GraphTransformationDebugger, + GraphTransformer, + TransformationContext, } from '../../lib/graph-transformations'; /** @@ -39,6 +43,7 @@ export function useEntityRelationNodesAndEdges({ entityFilter, onNodeClick, relationPairs: incomingRelationPairs, + onPostTransformation, }: { rootEntityRefs: string[]; maxDepth?: number; @@ -49,6 +54,7 @@ export function useEntityRelationNodesAndEdges({ entityFilter?: (entity: Entity) => boolean; onNodeClick?: (value: EntityNode, event: MouseEvent) => void; relationPairs?: RelationPairs; + onPostTransformation?: GraphTransformationDebugger; }): { loading: boolean; nodes?: EntityNode[]; @@ -74,11 +80,6 @@ export function useEntityRelationNodesAndEdges({ relationPairs: incomingRelationPairs, }); - const forwardRelations = useMemo( - () => relationPairs.map(pair => pair[0]), - [relationPairs], - ); - useDebounce( () => { if (!entities || Object.keys(entities).length === 0) { @@ -118,32 +119,62 @@ export function useEntityRelationNodesAndEdges({ unidirectional, }); - const transformerContext: TransformerContext = { + const transformationContext: TransformationContext = { nodeDistances: new Map(), edges, + nodes, rootEntityRefs, unidirectional, maxDepth, - forwardRelations, }; - builtInTransformers['reduce-edges'](transformerContext); - builtInTransformers['set-distances'](transformerContext); + + const debugTransformation = ( + transformation: BuiltInTransformations | GraphTransformer | undefined, + ) => { + onPostTransformation?.( + typeof transformation === 'function' + ? transformation.name + : transformation, + transformationContext, + cloneTransformationContext(transformationContext), + ); + }; + + debugTransformation(undefined); + + const runTransformation = ( + transformation: BuiltInTransformations | GraphTransformer, + ) => { + if (typeof transformation === 'function') { + transformation(transformationContext); + } else { + builtInTransformations[transformation](transformationContext); + } + + debugTransformation(transformation); + }; + + runTransformation('reduce-edges'); + runTransformation('set-distances'); if (unidirectional) { - builtInTransformers['order-forward'](transformerContext); - builtInTransformers['strip-distant-edges'](transformerContext); + runTransformation('strip-distant-edges'); } if (mergeRelations || unidirectional) { // Merge relations even if only unidirectional, the next transformer // 'remove-backward-edges' needs to know about all relations before it // strips away the backward ones - builtInTransformers['merge-relations'](transformerContext); + runTransformation('merge-relations'); } if (unidirectional && !mergeRelations) { - builtInTransformers['remove-backward-edges'](transformerContext); + runTransformation('order-forward'); + runTransformation('remove-backward-edges'); } - setNodesAndEdges({ nodes, edges: transformerContext.edges }); + setNodesAndEdges({ + nodes: transformationContext.nodes, + edges: transformationContext.edges, + }); }, 100, [ @@ -156,7 +187,6 @@ export function useEntityRelationNodesAndEdges({ mergeRelations, onNodeClick, relationPairs, - forwardRelations, ], ); diff --git a/plugins/catalog-graph/src/lib/graph-transformations/index.ts b/plugins/catalog-graph/src/lib/graph-transformations/index.ts index 948596ab3d..3ed4cdba21 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/index.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/index.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -export type { GraphTransformer, TransformerContext } from './types'; +export type { + GraphTransformer, + TransformationContext, + GraphTransformationDebugger, +} from './types'; +export { cloneTransformationContext } from './types'; import { reduceEdges } from './reduce-edges'; import { setDistances } from './set-distance'; @@ -23,7 +28,7 @@ import { stripDistantEdges } from './strip-distant-edges'; import { mergeRelations } from './merge-relations'; import { removeBackwardEdges } from './remove-backward-edges'; -export const builtInTransformers = { +export const builtInTransformations = { 'reduce-edges': reduceEdges, 'set-distances': setDistances, 'order-forward': orderForward, @@ -31,3 +36,5 @@ export const builtInTransformers = { 'merge-relations': mergeRelations, 'remove-backward-edges': removeBackwardEdges, }; + +export type BuiltInTransformations = keyof typeof builtInTransformations; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts index aed9d0dd85..88d4bbcd10 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts @@ -17,14 +17,17 @@ import { EntityEdge } from '../types'; import { GraphTransformer } from './types'; -function concatRelations(a: readonly string[], b: readonly string[]): string[] { - return Array.from(new Set([...a, ...b])); +function concatRelations( + a: readonly string[] | undefined, + b: readonly string[] | undefined, +): string[] { + return Array.from(new Set([...(a ?? []), ...(b ?? [])])); } /** Merge relations in multiple edges into one edge */ export const mergeRelations: GraphTransformer = options => { - // The key is on the form `from-to` const mergedEdges = new Map(); + options.edges.forEach(edge => { const keyForward = `${edge.from}-${edge.to}`; const keyBackward = `${edge.to}-${edge.from}`; @@ -39,12 +42,13 @@ export const mergeRelations: GraphTransformer = options => { ); } else if (edgeBackward) { edgeBackward.relations = concatRelations( - edge.relations, edgeBackward.relations, + edge.relations, ); } else { mergedEdges.set(keyForward, edge); } }); + options.edges = Array.from(mergedEdges.values()); }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts index d832a1411d..964c55e352 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts @@ -14,20 +14,71 @@ * limitations under the License. */ +import { EntityEdge } from '../types'; import { GraphTransformer } from './types'; /** Orders the edges direction so that the graph goes strictly forward */ -export const orderForward: GraphTransformer = ({ nodeDistances, edges }) => { +export const orderForward: GraphTransformer = ({ + rootEntityRefs, + nodes, + edges, +}) => { + const entityEdges = new Map(); edges.forEach(edge => { - const fromDistance = nodeDistances.get(edge.from) ?? 0; - const toDistance = nodeDistances.get(edge.to) ?? 0; - - if (toDistance < fromDistance) { - // Reverse order - const { from, to } = edge; - edge.from = to; - edge.to = from; - edge.relations.reverse(); + let fromEdges = entityEdges.get(edge.from); + if (!fromEdges) { + fromEdges = []; + entityEdges.set(edge.from, fromEdges); } + fromEdges.push(edge); + + let toEdges = entityEdges.get(edge.to); + if (!toEdges) { + toEdges = []; + entityEdges.set(edge.to, toEdges); + } + toEdges.push(edge); + }); + + const visitedNodes = new Set(); + + for (const rootEntityRef of rootEntityRefs) { + let currentNodes: string[] = [rootEntityRef].filter( + node => !visitedNodes.has(node), + ); + + while (currentNodes.length > 0) { + for (const currentNode of currentNodes) { + visitedNodes.add(currentNode); + } + + const nextNodes: string[] = []; + + currentNodes.forEach(node => { + entityEdges.get(node)?.forEach(edge => { + if (edge.to === node && !visitedNodes.has(edge.from)) { + // Reverse direction + const { from, to } = edge; + edge.from = to; + edge.to = from; + edge.relations.reverse(); + } + + nextNodes.push(edge.from, edge.to); + }); + }); + + currentNodes = Array.from(new Set(nextNodes)).filter( + node => !visitedNodes.has(node), + ); + } + } + + const nodeOrder = Array.from(visitedNodes); + + nodes.sort((a, b) => { + const aOrder = nodeOrder.findIndex(node => node === a.id); + const bOrder = nodeOrder.findIndex(node => node === b.id); + return aOrder - bOrder; }); }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts index 1be4c0db01..2c6f634ba9 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts @@ -22,7 +22,7 @@ import { GraphTransformer } from './types'; * merged the relations */ export const removeBackwardEdges: GraphTransformer = ctx => { - const { forwardRelations, edges } = ctx; + const { edges } = ctx; const edgesMapFrom = new Map(); @@ -32,22 +32,9 @@ export const removeBackwardEdges: GraphTransformer = ctx => { edgesMapFrom.set(edge.from, theseEdges); }); - ctx.edges = edges.filter(edge => { - if (edge.relations.length === 2) { - // If there are two relations, only keep the forward one - if (!forwardRelations.includes(edge.relations[1])) { - edge.relations.pop(); - } - if (!forwardRelations.includes(edge.relations[0])) { - edge.relations.pop(); - } - return edge.relations.length > 0; - } else if (edge.relations.length === 1) { - // If there is only one relation and it's backwards, remove it - if (!forwardRelations.includes(edge.relations[0])) { - return false; - } + edges.forEach(edge => { + if (edge.relations.length > 1) { + edge.relations = edge.relations.slice(0, 1); } - return true; }); }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts index 965f01fc14..5986512305 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts @@ -17,12 +17,6 @@ import { EntityEdge } from '../types'; import { GraphTransformer } from './types'; -function minDistance(a: number | undefined, b: number | undefined) { - if (typeof a === 'undefined') return b; - if (typeof b === 'undefined') return a; - return Math.min(a, b); -} - /** Set the distance of each edge to the distance from a root entity */ export const setDistances: GraphTransformer = ({ nodeDistances, @@ -32,67 +26,45 @@ export const setDistances: GraphTransformer = ({ const edgeMap = new Map(); edges.forEach(edge => { const fromEdges = edgeMap.get(edge.from) ?? []; - edgeMap.set(edge.from, [...fromEdges, edge]); + fromEdges.push(edge); + edgeMap.set(edge.from, fromEdges); const toEdges = edgeMap.get(edge.to) ?? []; - edgeMap.set(edge.to, [...toEdges, edge]); + toEdges.push(edge); + edgeMap.set(edge.to, toEdges); }); - // Sets the distance on as many edges as possible. - // Returns true if all edges have a distance - const setEdgeDistances = () => { - return ( - edges.filter(edge => { - if ( - rootEntityRefs.includes(edge.from) || - rootEntityRefs.includes(edge.to) - ) { - edge.distance = 1; - return false; - } - - const distance = minDistance( - edgeMap - .get(edge.from)! - .reduce( - (prev, cur) => minDistance(prev, cur.distance), - undefined as number | undefined, - ), - edgeMap - .get(edge.to)! - .reduce( - (prev, cur) => minDistance(prev, cur.distance), - undefined as number | undefined, - ), - ); - - if (typeof distance !== 'undefined') { - edge.distance = minDistance(edge.distance, distance + 1); - } - return typeof edge.distance === 'undefined'; - }).length === 0 - ); - }; - - let distanceComplete = false; - while (!distanceComplete) { - distanceComplete = setEdgeDistances(); - } - rootEntityRefs.forEach(rootEntityRef => { nodeDistances.set(rootEntityRef, 0); }); - edges.forEach(edge => { - const curFrom = nodeDistances.get(edge.from); - const curTo = nodeDistances.get(edge.to); + const visitedNodes = new Set(rootEntityRefs); + let currentNodes = rootEntityRefs; + let distance = 0; - const fromDistance = minDistance(curFrom, edge.distance); - const toDistance = minDistance(curTo, edge.distance); - if (typeof fromDistance !== 'undefined') { - nodeDistances.set(edge.from, fromDistance); + while (currentNodes.length > 0) { + const thisDistance = ++distance; + + const nextNodes: string[] = []; + currentNodes.forEach(node => { + edgeMap.get(node)?.forEach(edge => { + edge.distance ??= thisDistance; + + const fromDistance = nodeDistances.get(edge.from) ?? thisDistance; + nodeDistances.set(edge.from, fromDistance); + nextNodes.push(edge.from); + + const toDistance = nodeDistances.get(edge.to) ?? thisDistance; + nodeDistances.set(edge.to, toDistance); + nextNodes.push(edge.to); + }); + }); + + for (const currentNode of currentNodes) { + visitedNodes.add(currentNode); } - if (typeof toDistance !== 'undefined') { - nodeDistances.set(edge.to, toDistance); - } - }); + + currentNodes = Array.from(new Set(nextNodes)).filter( + node => !visitedNodes.has(node), + ); + } }; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/types.ts b/plugins/catalog-graph/src/lib/graph-transformations/types.ts index 6d8fc92392..9e498ba027 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/types.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/types.ts @@ -14,23 +14,69 @@ * limitations under the License. */ -import { EntityEdge } from '../types'; +import { EntityEdge, EntityNode } from '../types'; -export interface TransformerContext { +/** + * Contextual information for a graph transformation. + * + * @public + */ +export interface TransformationContext { /** * The distance from an entity node to a root entity * - * NOTE: This is not set until the setDistances transformation is applied + * NOTE: This is empty until the 'set-distances' transformation is applied */ nodeDistances: Map; edges: EntityEdge[]; + nodes: EntityNode[]; // Options: rootEntityRefs: string[]; unidirectional: boolean; maxDepth: number; - forwardRelations: string[]; } -export type GraphTransformer = (options: TransformerContext) => void; +/** + * A function that transforms a graph. The function modifies `nodes` and `edges` + * in place. + * + * @public + */ +export type GraphTransformer = (context: TransformationContext) => void; + +/** + * A function that debugs a graph transformation. + * It is given the three arguments: + * * The transformation that was just applied (or undefined before the first + * transformation). If the transformation is a function, its `.name` property + * will be used. + * * The current state (context) of the graph, which _can_ be mutated + * * A cloned copy of the context, useful for logging (as transformations are + * made in place, the original context is modified and the logs will be + * confusing) + * + * @public + */ +export type GraphTransformationDebugger = ( + transformation: string | undefined, + transformationContext: TransformationContext, + clonedContext: TransformationContext, +) => void; + +/** @internal */ +export function cloneTransformationContext( + transformationContext: TransformationContext, +): TransformationContext { + const clonesContext = JSON.parse( + JSON.stringify(transformationContext), + ) as TransformationContext; + clonesContext.edges = clonesContext.edges.sort((a, b) => { + return a.from.localeCompare(b.from) || a.to.localeCompare(b.to); + }); + + clonesContext.nodeDistances = new Map(transformationContext.nodeDistances); + + return clonesContext; +} From f4f53566305a5fdfde966550dcadc7901f5876ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Sat, 6 Sep 2025 23:04:33 +0200 Subject: [PATCH 6/8] Updated api-report and exported missing types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- plugins/catalog-graph/report.api.md | 23 +++++++++++++++++++++++ plugins/catalog-graph/src/index.ts | 4 ++++ 2 files changed, 27 insertions(+) diff --git a/plugins/catalog-graph/report.api.md b/plugins/catalog-graph/report.api.md index fd1e403473..883c79acf4 100644 --- a/plugins/catalog-graph/report.api.md +++ b/plugins/catalog-graph/report.api.md @@ -181,8 +181,31 @@ export type EntityRelationsGraphProps = { renderLabel?: DependencyGraphTypes.RenderLabelFunction; curve?: 'curveStepBefore' | 'curveMonotoneX'; showArrowHeads?: boolean; + onPostTransformation?: GraphTransformationDebugger; }; +// @public +export type GraphTransformationDebugger = ( + transformation: string | undefined, + transformationContext: TransformationContext, + clonedContext: TransformationContext, +) => void; + // @public export type RelationPairs = [string, string][]; + +// @public +export interface TransformationContext { + // (undocumented) + edges: EntityEdge[]; + // (undocumented) + maxDepth: number; + nodeDistances: Map; + // (undocumented) + nodes: EntityNode[]; + // (undocumented) + rootEntityRefs: string[]; + // (undocumented) + unidirectional: boolean; +} ``` diff --git a/plugins/catalog-graph/src/index.ts b/plugins/catalog-graph/src/index.ts index 68b84c2325..d020e96989 100644 --- a/plugins/catalog-graph/src/index.ts +++ b/plugins/catalog-graph/src/index.ts @@ -35,3 +35,7 @@ export type { EntityNode, } from './lib/types'; export { Direction } from './lib/types'; +export type { + TransformationContext, + GraphTransformationDebugger, +} from './lib/graph-transformations'; From b24de19bb34d39556ba263b246a59e066779e487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Sun, 7 Sep 2025 21:45:16 +0200 Subject: [PATCH 7/8] Adapted test to new multi-root handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- .../useEntityRelationNodesAndEdges.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx index b896fe1ed3..ad70111fa0 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx @@ -593,10 +593,10 @@ describe('useEntityRelationNodesAndEdges', () => { expect(edges).toEqual([ { distance: 1, - from: 'b:d/c2', + from: 'b:d/c1', label: 'visible', - relations: ['partOf', 'hasPart'], - to: 'b:d/c1', + relations: ['hasPart', 'partOf'], + to: 'b:d/c2', }, { distance: 1, From 442fe8ff4840c361373383a3c1cfce167b2879f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Tue, 9 Sep 2025 22:52:16 +0200 Subject: [PATCH 8/8] Removed the onPostTransformation hook and all debug-related logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gustaf Räntilä --- plugins/catalog-graph/report.api.md | 8 ----- .../EntityRelationsGraph.tsx | 4 --- .../useEntityRelationNodesAndEdges.ts | 20 ----------- plugins/catalog-graph/src/index.ts | 5 +-- .../src/lib/graph-transformations/index.ts | 7 +--- .../src/lib/graph-transformations/types.ts | 35 ------------------- 6 files changed, 2 insertions(+), 77 deletions(-) diff --git a/plugins/catalog-graph/report.api.md b/plugins/catalog-graph/report.api.md index 883c79acf4..8194209cf5 100644 --- a/plugins/catalog-graph/report.api.md +++ b/plugins/catalog-graph/report.api.md @@ -181,16 +181,8 @@ export type EntityRelationsGraphProps = { renderLabel?: DependencyGraphTypes.RenderLabelFunction; curve?: 'curveStepBefore' | 'curveMonotoneX'; showArrowHeads?: boolean; - onPostTransformation?: GraphTransformationDebugger; }; -// @public -export type GraphTransformationDebugger = ( - transformation: string | undefined, - transformationContext: TransformationContext, - clonedContext: TransformationContext, -) => void; - // @public export type RelationPairs = [string, string][]; diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx index 47ce365cb6..f77ca4b37d 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx @@ -33,7 +33,6 @@ import { DefaultRenderNode } from './DefaultRenderNode'; import { RelationPairs } from '../../lib/types'; import { Direction, EntityEdge, EntityNode } from '../../lib/types'; import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges'; -import { GraphTransformationDebugger } from '../../lib/graph-transformations'; /** @public */ export type EntityRelationsGraphClassKey = 'progress' | 'container' | 'graph'; @@ -92,7 +91,6 @@ export type EntityRelationsGraphProps = { renderLabel?: DependencyGraphTypes.RenderLabelFunction; curve?: 'curveStepBefore' | 'curveMonotoneX'; showArrowHeads?: boolean; - onPostTransformation?: GraphTransformationDebugger; }; /** @@ -118,7 +116,6 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => { renderLabel, curve, showArrowHeads, - onPostTransformation, } = props; const theme = useTheme(); @@ -142,7 +139,6 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => { entityFilter, onNodeClick, relationPairs, - onPostTransformation, }); useEffect(() => { diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts index 2aa301fef9..7a6f8361e5 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts @@ -24,8 +24,6 @@ import { buildGraph } from '../../lib/graph'; import { BuiltInTransformations, builtInTransformations, - cloneTransformationContext, - GraphTransformationDebugger, GraphTransformer, TransformationContext, } from '../../lib/graph-transformations'; @@ -43,7 +41,6 @@ export function useEntityRelationNodesAndEdges({ entityFilter, onNodeClick, relationPairs: incomingRelationPairs, - onPostTransformation, }: { rootEntityRefs: string[]; maxDepth?: number; @@ -54,7 +51,6 @@ export function useEntityRelationNodesAndEdges({ entityFilter?: (entity: Entity) => boolean; onNodeClick?: (value: EntityNode, event: MouseEvent) => void; relationPairs?: RelationPairs; - onPostTransformation?: GraphTransformationDebugger; }): { loading: boolean; nodes?: EntityNode[]; @@ -129,20 +125,6 @@ export function useEntityRelationNodesAndEdges({ maxDepth, }; - const debugTransformation = ( - transformation: BuiltInTransformations | GraphTransformer | undefined, - ) => { - onPostTransformation?.( - typeof transformation === 'function' - ? transformation.name - : transformation, - transformationContext, - cloneTransformationContext(transformationContext), - ); - }; - - debugTransformation(undefined); - const runTransformation = ( transformation: BuiltInTransformations | GraphTransformer, ) => { @@ -151,8 +133,6 @@ export function useEntityRelationNodesAndEdges({ } else { builtInTransformations[transformation](transformationContext); } - - debugTransformation(transformation); }; runTransformation('reduce-edges'); diff --git a/plugins/catalog-graph/src/index.ts b/plugins/catalog-graph/src/index.ts index d020e96989..95ac5048cb 100644 --- a/plugins/catalog-graph/src/index.ts +++ b/plugins/catalog-graph/src/index.ts @@ -35,7 +35,4 @@ export type { EntityNode, } from './lib/types'; export { Direction } from './lib/types'; -export type { - TransformationContext, - GraphTransformationDebugger, -} from './lib/graph-transformations'; +export type { TransformationContext } from './lib/graph-transformations'; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/index.ts b/plugins/catalog-graph/src/lib/graph-transformations/index.ts index 3ed4cdba21..764a20a8d2 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/index.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/index.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -export type { - GraphTransformer, - TransformationContext, - GraphTransformationDebugger, -} from './types'; -export { cloneTransformationContext } from './types'; +export type { GraphTransformer, TransformationContext } from './types'; import { reduceEdges } from './reduce-edges'; import { setDistances } from './set-distance'; diff --git a/plugins/catalog-graph/src/lib/graph-transformations/types.ts b/plugins/catalog-graph/src/lib/graph-transformations/types.ts index 9e498ba027..dc13057a04 100644 --- a/plugins/catalog-graph/src/lib/graph-transformations/types.ts +++ b/plugins/catalog-graph/src/lib/graph-transformations/types.ts @@ -45,38 +45,3 @@ export interface TransformationContext { * @public */ export type GraphTransformer = (context: TransformationContext) => void; - -/** - * A function that debugs a graph transformation. - * It is given the three arguments: - * * The transformation that was just applied (or undefined before the first - * transformation). If the transformation is a function, its `.name` property - * will be used. - * * The current state (context) of the graph, which _can_ be mutated - * * A cloned copy of the context, useful for logging (as transformations are - * made in place, the original context is modified and the logs will be - * confusing) - * - * @public - */ -export type GraphTransformationDebugger = ( - transformation: string | undefined, - transformationContext: TransformationContext, - clonedContext: TransformationContext, -) => void; - -/** @internal */ -export function cloneTransformationContext( - transformationContext: TransformationContext, -): TransformationContext { - const clonesContext = JSON.parse( - JSON.stringify(transformationContext), - ) as TransformationContext; - clonesContext.edges = clonesContext.edges.sort((a, b) => { - return a.from.localeCompare(b.from) || a.to.localeCompare(b.to); - }); - - clonesContext.nodeDistances = new Map(transformationContext.nodeDistances); - - return clonesContext; -}