Add support for customizing the catalog graph relations using an API

Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
Gustaf Räntilä
2025-08-13 19:42:00 +01:00
parent 3f6029ce2a
commit ae6b606b72
25 changed files with 750 additions and 184 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-graph': patch
---
Support custom relations by using an API
-24
View File
@@ -14,18 +14,6 @@
* limitations under the License.
*/
import {
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CONSUMES_API,
RELATION_DEPENDENCY_OF,
RELATION_DEPENDS_ON,
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { createApp } from '@backstage/app-defaults';
import { AppRouter, FeatureFlagged, FlatRoutes } from '@backstage/core-app-api';
import {
@@ -158,18 +146,6 @@ const routes = (
<CatalogGraphPage
initialState={{
selectedKinds: ['component', 'domain', 'system', 'api', 'group'],
selectedRelations: [
RELATION_OWNER_OF,
RELATION_OWNED_BY,
RELATION_CONSUMES_API,
RELATION_API_CONSUMED_BY,
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
RELATION_HAS_PART,
RELATION_PART_OF,
RELATION_DEPENDS_ON,
RELATION_DEPENDENCY_OF,
],
}}
/>
}
+39 -13
View File
@@ -50,18 +50,6 @@ To use the catalog graph plugin, you have to add some things to your Backstage a
<CatalogGraphPage
initialState={{
selectedKinds: ['component', 'domain', 'system', 'api', 'group'],
selectedRelations: [
RELATION_OWNER_OF,
RELATION_OWNED_BY,
RELATION_CONSUMES_API,
RELATION_API_CONSUMED_BY,
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
RELATION_HAS_PART,
RELATION_PART_OF,
RELATION_DEPENDS_ON,
RELATION_DEPENDENCY_OF,
],
}}
/>
}
@@ -88,7 +76,7 @@ To use the catalog graph plugin, you have to add some things to your Backstage a
</Grid>
```
### Customization
### Customizing the UI
Copy the default implementation `DefaultRenderNode.tsx` and add more classes to the styles:
@@ -151,6 +139,44 @@ Once you have your custom implementation, you can follow these steps to modify t
<EntityCatalogGraphCard variant=“gridItem” renderNode={MyCustomRenderNode} height={400} />
```
### Custom relations
Implementers with added custom relations can add them to the catalog graph plugin by overriding the default API. This also allows some relations to not be selected by default.
In `packages/app/src/apis.ts`, import the api ref and create the API as:
```ts
import {
ALL_RELATIONS,
ALL_RELATION_PAIRS,
catalogGraphApiRef,
DefaultCatalogGraphApi,
} from '@backstage/plugin-catalog-graph';
// ...
createApiFactory({
api: catalogGraphApiRef,
deps: {},
factory: () =>
new DefaultCatalogGraphApi({
// The relations to support
knownRelations: [...ALL_RELATIONS, 'myRelationOf', 'myRelationFor'],
// The relation pairs to support
knownRelationPairs: [
...ALL_RELATION_PAIRS,
['myRelationOf', 'myRelationFor'],
],
// Select what relations to be shown by default, either by including them,
// or excluding some from all known relations:
defaultRelationTypes: {
// Don't show/select these by default
exclude: ['myRelationOf', 'myRelationFor'],
},
}),
}),
```
## Development
Run `yarn` in the root of this plugin to install all dependencies and then `yarn start` to run a [development version](./dev/index.tsx) of this plugin.
+50
View File
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { DependencyGraphTypes } from '@backstage/core-components';
@@ -19,6 +20,19 @@ import { RouteRef } from '@backstage/core-plugin-api';
// @public
export const ALL_RELATION_PAIRS: RelationPairs;
// @public
export const ALL_RELATIONS: string[];
// @public
export interface CatalogGraphApi {
readonly defaultRelations: string[];
readonly knownRelationPairs: [string, string][];
readonly knownRelations: string[];
}
// @public
export const catalogGraphApiRef: ApiRef<CatalogGraphApi>;
// @public
export const CatalogGraphPage: (
props: {
@@ -62,6 +76,42 @@ export type CustomLabelClassKey = 'text' | 'secondary';
// @public (undocumented)
export type CustomNodeClassKey = 'node' | 'text' | 'clickable';
// @public
export class DefaultCatalogGraphApi implements CatalogGraphApi {
constructor(options?: DefaultCatalogGraphApiOptions);
// (undocumented)
readonly defaultRelations: string[];
// (undocumented)
readonly knownRelationPairs: [string, string][];
// (undocumented)
readonly knownRelations: string[];
}
// @public
export interface DefaultCatalogGraphApiOptions {
// (undocumented)
readonly defaultRelationTypes?: DefaultRelations;
// (undocumented)
readonly knownRelationPairs?: RelationPairs;
// (undocumented)
readonly knownRelations?: string[];
}
// @public
export type DefaultRelations =
| DefaultRelationsInclude
| DefaultRelationsExclude;
// @public (undocumented)
export type DefaultRelationsExclude = {
exclude: string[];
};
// @public (undocumented)
export type DefaultRelationsInclude = {
include: string[];
};
// @public
export enum Direction {
BOTTOM_TOP = 'BT',
@@ -0,0 +1,58 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core-plugin-api';
/**
* Utility API reference for the {@link CatalogGraphApi}.
*
* @public
*/
export const catalogGraphApiRef = createApiRef<CatalogGraphApi>({
id: 'plugin.catalog-graph.service',
});
/** @public */
export type DefaultRelationsInclude = { include: string[] };
/** @public */
export type DefaultRelationsExclude = { exclude: string[] };
/**
* Default relations. Can either be a list of relations to use by default, or
* a list of relations to _not_ use, i.e. include all other relations.
*
* @public
*/
export type DefaultRelations =
| DefaultRelationsInclude
| DefaultRelationsExclude;
/**
* API for driving catalog imports.
*
* @public
*/
export interface CatalogGraphApi {
/** All known relations */
readonly knownRelations: string[];
/** All known relation pairs */
readonly knownRelationPairs: [string, string][];
/** The default relations to show in the graph */
readonly defaultRelations: string[];
}
@@ -0,0 +1,61 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DefaultCatalogGraphApi } from './DefaultCatalogGraphApi';
describe('DefaultCatalogGraphApi', () => {
it('default config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi();
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(true);
expect(defaultRelations.includes('hasPart')).toBe(true);
});
it('empty include config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi({
defaultRelationTypes: { include: [] },
});
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(false);
expect(defaultRelations.includes('hasPart')).toBe(false);
});
it('include config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi({
defaultRelationTypes: { include: ['ownedBy'] },
});
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(true);
expect(defaultRelations.includes('hasPart')).toBe(false);
});
it('exclude config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi({
defaultRelationTypes: { exclude: ['ownedBy', 'ownerOf'] },
});
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(false);
expect(defaultRelations.includes('hasPart')).toBe(true);
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ALL_RELATION_PAIRS, ALL_RELATIONS, RelationPairs } from '../types';
import {
CatalogGraphApi,
DefaultRelations,
DefaultRelationsExclude,
DefaultRelationsInclude,
} from './CatalogGraphApi';
/**
* Options for the {@link DefaultCatalogGraphApi}.
*
* @public
*/
export interface DefaultCatalogGraphApiOptions {
readonly knownRelations?: string[];
readonly knownRelationPairs?: RelationPairs;
readonly defaultRelationTypes?: DefaultRelations;
}
/**
* The default implementation of the {@link CatalogGraphApi}.
*
* @public
*/
export class DefaultCatalogGraphApi implements CatalogGraphApi {
readonly knownRelations: string[];
readonly knownRelationPairs: [string, string][];
readonly defaultRelations: string[];
constructor(
options: DefaultCatalogGraphApiOptions = {
knownRelations: ALL_RELATIONS,
knownRelationPairs: ALL_RELATION_PAIRS,
defaultRelationTypes: { exclude: [] },
},
) {
this.knownRelations = options.knownRelations ?? ALL_RELATIONS;
this.knownRelationPairs = options.knownRelationPairs ?? ALL_RELATION_PAIRS;
const defaultRelations = options.defaultRelationTypes;
if (Array.isArray((defaultRelations as DefaultRelationsInclude).include)) {
const defaultRelationsInclude =
defaultRelations as DefaultRelationsInclude;
this.defaultRelations = this.knownRelations.filter(rel =>
defaultRelationsInclude.include.includes(rel),
);
} else {
const defaultRelationsExclude =
defaultRelations as DefaultRelationsExclude;
this.defaultRelations = this.knownRelations.filter(
rel => !defaultRelationsExclude.exclude.includes(rel),
);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './CatalogGraphApi';
export * from './DefaultCatalogGraphApi';
@@ -35,6 +35,7 @@ import { catalogGraphRouteRef } from '../../routes';
import { CatalogGraphCard } from './CatalogGraphCard';
import Button from '@material-ui/core/Button';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
describe('<CatalogGraphCard/>', () => {
let entity: Entity;
@@ -56,6 +57,7 @@ describe('<CatalogGraphCard/>', () => {
apis = TestApiRegistry.from(
[catalogApiRef, catalog],
[translationApiRef, mockApis.translation()],
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
);
wrapper = (
@@ -32,7 +32,6 @@ import { MouseEvent, ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { catalogGraphRouteRef } from '../../routes';
import {
ALL_RELATION_PAIRS,
Direction,
EntityNode,
EntityRelationsGraph,
@@ -71,7 +70,7 @@ export const CatalogGraphCard = (
const { t } = useTranslationRef(catalogGraphTranslationRef);
const {
variant = 'gridItem',
relationPairs = ALL_RELATION_PAIRS,
relationPairs,
maxDepth = 1,
unidirectional = true,
mergeRelations = true,
@@ -37,7 +37,6 @@ import ToggleButton from '@material-ui/lab/ToggleButton';
import { MouseEvent, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ALL_RELATION_PAIRS,
Direction,
EntityNode,
EntityRelationsGraph,
@@ -133,11 +132,7 @@ export const CatalogGraphPage = (
};
} & Partial<EntityRelationsGraphProps>,
) => {
const {
relationPairs = ALL_RELATION_PAIRS,
initialState,
entityFilter,
} = props;
const { relationPairs, initialState, entityFilter } = props;
const { t } = useTranslationRef(catalogGraphTranslationRef);
const navigate = useNavigate();
const classes = useStyles();
@@ -224,7 +219,6 @@ export const CatalogGraphPage = (
<SelectedRelationsFilter
value={selectedRelations}
onChange={setSelectedRelations}
relationPairs={relationPairs}
/>
<DirectionFilter value={direction} onChange={setDirection} />
<CurveFilter value={curve} onChange={setCurve} />
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { PropsWithChildren } from 'react';
import { ApiProvider } from '@backstage/core-app-api';
import {
RELATION_CHILD_OF,
RELATION_HAS_MEMBER,
@@ -21,18 +23,34 @@ import {
} from '@backstage/catalog-model';
import { waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ALL_RELATION_PAIRS } from '../EntityRelationsGraph';
import { ALL_RELATIONS } from '../../types';
import { SelectedRelationsFilter } from './SelectedRelationsFilter';
import { renderInTestApp } from '@backstage/test-utils';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
function GraphContext(props: PropsWithChildren<{}>) {
return (
<ApiProvider
apis={TestApiRegistry.from([
catalogGraphApiRef,
new DefaultCatalogGraphApi(),
])}
>
{props.children}
</ApiProvider>
);
}
describe('<SelectedRelationsFilter/>', () => {
test('should render current value', async () => {
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={() => {}}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={() => {}}
/>
</GraphContext>,
);
expect(screen.getByText(RELATION_OWNED_BY)).toBeInTheDocument();
@@ -42,11 +60,13 @@ describe('<SelectedRelationsFilter/>', () => {
test('should select value', async () => {
const onChange = jest.fn();
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={onChange}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={onChange}
/>
</GraphContext>,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -66,16 +86,16 @@ describe('<SelectedRelationsFilter/>', () => {
});
});
test('should return undefined if all values are selected', async () => {
test('should return all known relations if all values are selected', async () => {
const onChange = jest.fn();
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={ALL_RELATION_PAIRS.flatMap(p => p).filter(
r => r !== RELATION_HAS_MEMBER,
)}
onChange={onChange}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={ALL_RELATIONS.filter(r => r !== RELATION_HAS_MEMBER)}
onChange={onChange}
/>
</GraphContext>,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -87,18 +107,26 @@ describe('<SelectedRelationsFilter/>', () => {
await userEvent.click(screen.getByText(RELATION_HAS_MEMBER));
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(undefined);
// Same as ALL_RELATIONS but with RELATION_HAS_MEMBER at the end
const allRelationsOrdered = [
...ALL_RELATIONS.filter(rel => rel !== RELATION_HAS_MEMBER),
RELATION_HAS_MEMBER,
];
expect(onChange).toHaveBeenCalledWith(allRelationsOrdered);
});
});
test('should return all values when cleared', async () => {
const onChange = jest.fn();
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[]}
onChange={onChange}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={[]}
onChange={onChange}
/>
</GraphContext>,
);
await userEvent.click(screen.getByRole('combobox'));
@@ -24,9 +24,9 @@ import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useMemo } from 'react';
import { RelationPairs } from '../EntityRelationsGraph';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
import { useRelations } from '../../hooks';
/** @public */
export type SelectedRelationsFilterClassKey = 'formControl';
@@ -41,25 +41,31 @@ const useStyles = makeStyles(
);
export type Props = {
relationPairs: RelationPairs;
relations?: string[];
value: string[] | undefined;
onChange: (value: string[] | undefined) => void;
};
export const SelectedRelationsFilter = ({
relationPairs,
value,
onChange,
}: Props) => {
export const SelectedRelationsFilter = (props: Props) => {
const { relations: incomingRelations, value, onChange } = props;
const classes = useStyles();
const relations = useMemo(() => relationPairs.flat(), [relationPairs]);
const { relations, includeRelation } = useRelations({
relations: incomingRelations,
});
const defaultValue = useMemo(
() => relations.filter(rel => includeRelation(rel)),
[relations, includeRelation],
);
const { t } = useTranslationRef(catalogGraphTranslationRef);
const handleChange = useCallback(
(_: unknown, v: string[]) => {
onChange(relations.every(r => v.includes(r)) ? undefined : v);
onChange(v);
},
[relations, onChange],
[onChange],
);
const handleEmpty = useCallback(() => {
@@ -78,7 +84,7 @@ export const SelectedRelationsFilter = ({
disableCloseOnSelect
aria-label={t('catalogGraphPage.selectedRelationsFilter.title')}
options={relations}
value={value ?? relations}
value={value ?? defaultValue}
onChange={handleChange}
onBlur={handleEmpty}
renderOption={(option, { selected }) => (
@@ -30,7 +30,7 @@ import classNames from 'classnames';
import { MouseEvent, useEffect, useMemo } from 'react';
import { DefaultRenderLabel } from './DefaultRenderLabel';
import { DefaultRenderNode } from './DefaultRenderNode';
import { ALL_RELATION_PAIRS, RelationPairs } from './relations';
import { RelationPairs } from '../../types';
import { Direction, EntityEdge, EntityNode } from './types';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
@@ -109,7 +109,7 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => {
entityFilter,
direction = Direction.LEFT_RIGHT,
onNodeClick,
relationPairs = ALL_RELATION_PAIRS,
relationPairs,
className,
zoom = 'enabled',
renderNode,
@@ -18,8 +18,6 @@ export type {
EntityRelationsGraphProps,
EntityRelationsGraphClassKey,
} from './EntityRelationsGraph';
export { ALL_RELATION_PAIRS } from './relations';
export type { RelationPairs } from './relations';
export { Direction } from './types';
export type {
EntityEdgeData,
@@ -13,16 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PropsWithChildren } from 'react';
import { ApiProvider } from '@backstage/core-app-api';
import {
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
} from '@backstage/catalog-model';
import { TestApiRegistry } from '@backstage/test-utils';
import { renderHook } from '@testing-library/react';
import { pick } from 'lodash';
import { useEntityRelationGraph } from './useEntityRelationGraph';
import { useEntityStore as useEntityStoreMocked } from './useEntityStore';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
jest.mock('./useEntityStore');
@@ -30,6 +34,19 @@ const useEntityStore = useEntityStoreMocked as jest.Mock<
ReturnType<typeof useEntityStoreMocked>
>;
function GraphContext(props: PropsWithChildren<{}>) {
return (
<ApiProvider
apis={TestApiRegistry.from([
catalogGraphApiRef,
new DefaultCatalogGraphApi(),
])}
>
{props.children}
</ApiProvider>
);
}
describe('useEntityRelationGraph', () => {
const requestEntities = jest.fn();
@@ -171,8 +188,9 @@ describe('useEntityRelationGraph', () => {
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
const { result } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: [] }),
{ wrapper: GraphContext },
);
const { entities, loading, error } = result.current;
@@ -190,8 +208,9 @@ describe('useEntityRelationGraph', () => {
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
const { result } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: [] }),
{ wrapper: GraphContext },
);
const { entities, loading, error } = result.current;
@@ -210,8 +229,9 @@ describe('useEntityRelationGraph', () => {
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
const { result } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: [] }),
{ wrapper: GraphContext },
);
const { entities, loading, error } = result.current;
@@ -222,8 +242,9 @@ describe('useEntityRelationGraph', () => {
});
test('should walk relation tree', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }),
const { result, rerender } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -256,11 +277,13 @@ describe('useEntityRelationGraph', () => {
});
test('should limit max depth', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth: 1 },
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth: 1 },
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -277,11 +300,13 @@ describe('useEntityRelationGraph', () => {
test('should update on filter change', async () => {
let maxDepth: number = Number.POSITIVE_INFINITY;
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth },
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth },
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -310,13 +335,15 @@ describe('useEntityRelationGraph', () => {
});
test('should filter by relation', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_HAS_PART, RELATION_PART_OF],
},
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_HAS_PART, RELATION_PART_OF],
},
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -332,14 +359,16 @@ describe('useEntityRelationGraph', () => {
});
test('should filter by kind', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_OWNED_BY, RELATION_OWNER_OF],
kinds: ['k'],
},
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_OWNED_BY, RELATION_OWNER_OF],
kinds: ['k'],
},
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -354,13 +383,15 @@ describe('useEntityRelationGraph', () => {
});
test('should filter by func', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
entityFilter: e => e.metadata.name !== 'c2',
},
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
entityFilter: e => e.metadata.name !== 'c2',
},
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -376,10 +407,12 @@ describe('useEntityRelationGraph', () => {
});
test('should support multiple roots by kind', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -17,6 +17,7 @@ import { Entity } from '@backstage/catalog-model';
import { useEffect, useMemo } from 'react';
import { useEntityStore } from './useEntityStore';
import { pickBy } from 'lodash';
import { useRelations } from '../../hooks/useRelations';
/**
* Discover the graph of entities connected by relations, starting from a set of
@@ -45,6 +46,7 @@ export function useEntityRelationGraph({
error?: Error;
} {
const { entities, loading, error, requestEntities } = useEntityStore();
const { includeRelation } = useRelations({ relations });
useEffect(() => {
const expectedEntities = new Set([...rootEntityRefs]);
@@ -74,7 +76,7 @@ export function useEntityRelationGraph({
}
for (const rel of entity.relations) {
if (
(!relations || relations.includes(rel.type)) &&
includeRelation(rel.type) &&
(!kinds ||
kinds.some(kind =>
rel.targetRef.startsWith(
@@ -98,7 +100,7 @@ export function useEntityRelationGraph({
entities,
rootEntityRefs,
maxDepth,
relations,
includeRelation,
kinds,
entityFilter,
requestEntities,
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PropsWithChildren } from 'react';
import {
DEFAULT_NAMESPACE,
Entity,
@@ -22,11 +23,14 @@ import {
RELATION_PART_OF,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
import { TestApiRegistry } from '@backstage/test-utils';
import { renderHook, waitFor } from '@testing-library/react';
import { filter, keyBy } from 'lodash';
import { useEntityRelationGraph as useEntityRelationGraphMocked } from './useEntityRelationGraph';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
import { EntityNode } from './types';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
jest.mock('./useEntityRelationGraph');
@@ -129,6 +133,19 @@ function deprecatedProperties(entity: Entity): Partial<EntityNode> {
};
}
function GraphContext(props: PropsWithChildren<{}>) {
return (
<ApiProvider
apis={TestApiRegistry.from([
catalogGraphApiRef,
new DefaultCatalogGraphApi(),
])}
>
{props.children}
</ApiProvider>
);
}
describe('useEntityRelationNodesAndEdges', () => {
beforeEach(() => {
useEntityRelationGraph.mockImplementation(({ filter: { kinds } }) => ({
@@ -149,10 +166,12 @@ describe('useEntityRelationNodesAndEdges', () => {
loading: true,
});
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
{ wrapper: GraphContext },
);
const { nodes, edges, loading, error } = result.current;
@@ -170,10 +189,12 @@ describe('useEntityRelationNodesAndEdges', () => {
error: returnError,
});
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
{ wrapper: GraphContext },
);
const { nodes, edges, loading, error } = result.current;
@@ -185,12 +206,14 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should generate unidirectional graph with merged relations', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: true,
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: true,
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -260,12 +283,14 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should generate unidirectional graph', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: false,
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: false,
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -359,12 +384,14 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should generate bidirectional graph with merged relations', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: false,
mergeRelations: true,
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: false,
mergeRelations: true,
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -434,12 +461,14 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should generate bidirectional graph with all relations', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: false,
mergeRelations: false,
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: false,
mergeRelations: false,
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -533,10 +562,12 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should generate graph with multiple root nodes', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -606,11 +637,13 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should filter by relation', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
relations: [RELATION_OWNER_OF],
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
relations: [RELATION_OWNER_OF],
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -662,11 +695,13 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should filter by kind', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
kinds: ['b'],
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
kinds: ['b'],
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -15,10 +15,11 @@
*/
import { MouseEvent, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { RelationPairs, ALL_RELATION_PAIRS } from './relations';
import { RelationPairs } from '../../types';
import { EntityEdge, EntityNode } from './types';
import { useEntityRelationGraph } from './useEntityRelationGraph';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { useRelations } from '../../hooks/useRelations';
/**
* Generate nodes and edges to render the entity graph.
@@ -32,7 +33,7 @@ export function useEntityRelationNodesAndEdges({
relations,
entityFilter,
onNodeClick,
relationPairs = ALL_RELATION_PAIRS,
relationPairs: incomingRelationPairs,
}: {
rootEntityRefs: string[];
maxDepth?: number;
@@ -63,6 +64,11 @@ export function useEntityRelationNodesAndEdges({
},
});
const { relationPairs, includeRelation } = useRelations({
relations,
relationPairs: incomingRelationPairs,
});
useDebounce(
() => {
if (!entities || Object.keys(entities).length === 0) {
@@ -124,7 +130,7 @@ export function useEntityRelationNodesAndEdges({
return;
}
if (relations && !relations.includes(rel.type)) {
if (!includeRelation(rel.type)) {
return;
}
@@ -230,7 +236,7 @@ export function useEntityRelationNodesAndEdges({
entities,
rootEntityRefs,
kinds,
relations,
includeRelation,
unidirectional,
mergeRelations,
onNodeClick,
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type { UseRelationsOptions, UseRelationsResult } from './useRelations';
export { useRelations } from './useRelations';
@@ -0,0 +1,71 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useMemo } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { catalogGraphApiRef } from '../api';
import { RelationPairs } from '../types';
export interface UseRelationsOptions {
relations?: string[];
relationPairs?: RelationPairs;
}
export interface UseRelationsResult {
relations: string[];
relationPairs: RelationPairs;
defaultRelations: string[];
includeRelation: (type: string) => boolean;
}
/**
* Given an optional list of specific relations and/or relation pairs,
* this hook returns the relations, relation pairs and default relations (to
* show/select), falling back to the configured setting.
* It also returns a function `includeRelation` to filter whether a relation
* should be included or not.
*/
export function useRelations(
opts: UseRelationsOptions = {},
): UseRelationsResult {
const { knownRelations, knownRelationPairs, defaultRelations } =
useApi(catalogGraphApiRef);
const relations = opts.relations ?? knownRelations;
const relationPairs = opts.relationPairs ?? knownRelationPairs;
const includeRelation = useCallback(
(type: string) => {
if (opts.relations) {
return opts.relations.includes(type);
}
return defaultRelations.includes(type);
},
[opts.relations, defaultRelations],
);
return useMemo(
() => ({
relations,
relationPairs,
defaultRelations,
includeRelation,
}),
[relations, relationPairs, defaultRelations, includeRelation],
);
}
+3
View File
@@ -23,5 +23,8 @@
export * from './components';
export { CatalogGraphPage, EntityCatalogGraphCard } from './extensions';
export * from './api';
export { catalogGraphPlugin } from './plugin';
export { catalogGraphRouteRef } from './routes';
export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './types';
export type { RelationPairs } from './types';
+9 -1
View File
@@ -13,8 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin } from '@backstage/core-plugin-api';
import { createApiFactory, createPlugin } from '@backstage/core-plugin-api';
import { catalogEntityRouteRef, catalogGraphRouteRef } from './routes';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api';
/**
* Catalog Graph Plugin instance.
@@ -28,4 +29,11 @@ export const catalogGraphPlugin = createPlugin({
externalRoutes: {
catalogEntity: catalogEntityRouteRef,
},
apis: [
createApiFactory({
api: catalogGraphApiRef,
deps: {},
factory: () => new DefaultCatalogGraphApi(),
}),
],
});
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './relations';
export type { RelationPairs } from './relations';
@@ -0,0 +1,77 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CHILD_OF,
RELATION_CONSUMES_API,
RELATION_DEPENDENCY_OF,
RELATION_DEPENDS_ON,
RELATION_HAS_MEMBER,
RELATION_HAS_PART,
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PARENT_OF,
RELATION_PART_OF,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
/**
* A pair of two relations that describe the opposite of each other. The first
* relation is considered as the primary relation.
*
* @public
*/
export type RelationPairs = [string, string][];
/**
* A list of built-in relations
*
* @public
*/
export const ALL_RELATIONS: string[] = [
RELATION_API_CONSUMED_BY,
RELATION_API_PROVIDED_BY,
RELATION_CHILD_OF,
RELATION_CONSUMES_API,
RELATION_DEPENDENCY_OF,
RELATION_DEPENDS_ON,
RELATION_HAS_MEMBER,
RELATION_HAS_PART,
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PARENT_OF,
RELATION_PART_OF,
RELATION_PROVIDES_API,
];
/**
* A list of pairs of entity relations, used to define which relations are
* merged together and which the primary relation is.
*
* @public
*/
export const ALL_RELATION_PAIRS: RelationPairs = [
[RELATION_OWNER_OF, RELATION_OWNED_BY],
[RELATION_CONSUMES_API, RELATION_API_CONSUMED_BY],
[RELATION_API_PROVIDED_BY, RELATION_PROVIDES_API],
[RELATION_HAS_PART, RELATION_PART_OF],
[RELATION_PARENT_OF, RELATION_CHILD_OF],
[RELATION_HAS_MEMBER, RELATION_MEMBER_OF],
[RELATION_DEPENDS_ON, RELATION_DEPENDENCY_OF],
];