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