Fixed "simplified" in the catalog-graph, solving #30121
Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': minor
|
||||
---
|
||||
|
||||
Add optional `distance` property to `DependencyEdge` to reflect the distance to a root.
|
||||
@@ -291,6 +291,7 @@ export namespace DependencyGraphTypes {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
distance?: number;
|
||||
};
|
||||
export type DependencyNode<T = {}> = T & {
|
||||
id: string;
|
||||
|
||||
@@ -46,6 +46,10 @@ export namespace DependencyGraphTypes {
|
||||
* Label assigned and rendered with the Edge
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Distance to a root entity
|
||||
*/
|
||||
distance?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
+28
-46
@@ -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],
|
||||
|
||||
+47
-131
@@ -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<string>();
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string[]>;
|
||||
requestEntities: (entityRefs: string[]) => void;
|
||||
} {
|
||||
const catalogClient = useApi(catalogApiRef);
|
||||
const state = useRef({
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<string>([...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<string, EntityEdge>();
|
||||
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());
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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[]);
|
||||
};
|
||||
@@ -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<string, EntityEdge[]>();
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -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<string, EntityEdge[]>();
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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<string, EntityEdge[]>();
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -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<string, number>;
|
||||
|
||||
edges: EntityEdge[];
|
||||
|
||||
// Options:
|
||||
rootEntityRefs: string[];
|
||||
unidirectional: boolean;
|
||||
maxDepth: number;
|
||||
forwardRelations: string[];
|
||||
}
|
||||
|
||||
export type GraphTransformer = (options: TransformerContext) => void;
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
+9
-1
@@ -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';
|
||||
Reference in New Issue
Block a user