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