diff --git a/.changeset/dull-eagles-sin.md b/.changeset/dull-eagles-sin.md
new file mode 100644
index 0000000000..8689ea3fc2
--- /dev/null
+++ b/.changeset/dull-eagles-sin.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-catalog-graph': minor
+---
+
+Support custom relations by using an API to define known relations and which to show by default
+Fixes "simplified" bug (#30121) which caused graphs not to be simplified
diff --git a/.changeset/nine-socks-share.md b/.changeset/nine-socks-share.md
new file mode 100644
index 0000000000..82c96e554e
--- /dev/null
+++ b/.changeset/nine-socks-share.md
@@ -0,0 +1,5 @@
+---
+'@backstage/core-components': minor
+---
+
+Add optional `distance` property to `DependencyEdge` to reflect the distance to a root.
diff --git a/packages/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/packages/core-components/report.api.md b/packages/core-components/report.api.md
index effd6ac6ed..b89b61ac77 100644
--- a/packages/core-components/report.api.md
+++ b/packages/core-components/report.api.md
@@ -292,6 +292,7 @@ export namespace DependencyGraphTypes {
from: string;
to: string;
label?: string;
+ distance?: number;
};
export type DependencyNode = T & {
id: string;
diff --git a/packages/core-components/src/components/DependencyGraph/types.ts b/packages/core-components/src/components/DependencyGraph/types.ts
index e8fc9f6da3..a449637e49 100644
--- a/packages/core-components/src/components/DependencyGraph/types.ts
+++ b/packages/core-components/src/components/DependencyGraph/types.ts
@@ -46,6 +46,10 @@ export namespace DependencyGraphTypes {
* Label assigned and rendered with the Edge
*/
label?: string;
+ /**
+ * Distance to a root entity
+ */
+ distance?: number;
};
/**
diff --git a/plugins/catalog-graph/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-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md
index 3ac88050ba..fe852d8cf6 100644
--- a/plugins/catalog-graph/report-alpha.api.md
+++ b/plugins/catalog-graph/report-alpha.api.md
@@ -84,9 +84,9 @@ const _default: OverridableFrontendPlugin<
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: OverridableFrontendPlugin<
rootEntityRefs?: string[] | undefined;
maxDepth?: number | undefined;
kinds?: string[] | undefined;
- unidirectional?: boolean | undefined;
mergeRelations?: boolean | undefined;
relationPairs?: [string, string][] | undefined;
+ unidirectional?: boolean | undefined;
selectedRelations?: string[] | undefined;
selectedKinds?: string[] | undefined;
showFilters?: boolean | undefined;
diff --git a/plugins/catalog-graph/report.api.md b/plugins/catalog-graph/report.api.md
index 25479bd9b7..8194209cf5 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',
@@ -135,4 +185,19 @@ export type EntityRelationsGraphProps = {
// @public
export type RelationPairs = [string, string][];
+
+// @public
+export interface TransformationContext {
+ // (undocumented)
+ edges: EntityEdge[];
+ // (undocumented)
+ maxDepth: number;
+ nodeDistances: Map;
+ // (undocumented)
+ nodes: EntityNode[];
+ // (undocumented)
+ rootEntityRefs: string[];
+ // (undocumented)
+ unidirectional: boolean;
+}
```
diff --git a/plugins/catalog-graph/src/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..edb7b8f509
--- /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 '../lib/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..2f07b89d3f 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx
+++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx
@@ -32,14 +32,12 @@ import { MouseEvent, ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { catalogGraphRouteRef } from '../../routes';
import {
- ALL_RELATION_PAIRS,
- 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';
@@ -71,7 +69,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..7e6420da1f 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx
+++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.tsx
@@ -37,9 +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,
EntityRelationsGraphProps,
} from '../EntityRelationsGraph';
@@ -52,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 =
@@ -133,11 +131,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 +218,6 @@ export const CatalogGraphPage = (
diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx
index 1262797a43..d5cee68971 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx
+++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.test.tsx
@@ -16,7 +16,7 @@
import { waitFor, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { Direction } from '../EntityRelationsGraph';
+import { Direction } from '../../lib/types';
import { DirectionFilter } from './DirectionFilter';
import { renderInTestApp } from '@backstage/test-utils';
diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx
index 5d21f61c6c..060a1d32c6 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx
+++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx
@@ -16,7 +16,7 @@
import { Select, SelectedItems } from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import { useCallback } from 'react';
-import { Direction } from '../EntityRelationsGraph';
+import { Direction } from '../../lib/types';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedRelationsFilter.test.tsx
index 057842bcdc..09e09ee822 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 '../../lib/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/CatalogGraphPage/useCatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.tsx
index c255281ff0..fa3d67520d 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.tsx
+++ b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.test.tsx
@@ -18,7 +18,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { ReactNode } from 'react';
import { act } from 'react-dom/test-utils';
import { BrowserRouter } from 'react-router-dom';
-import { Direction } from '../EntityRelationsGraph';
+import { Direction } from '../../lib/types';
import { useCatalogGraphPage } from './useCatalogGraphPage';
const wrapper = ({ children }: { children?: ReactNode }) => {
diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts
index 1f6fb74997..efc2170153 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts
+++ b/plugins/catalog-graph/src/components/CatalogGraphPage/useCatalogGraphPage.ts
@@ -28,7 +28,7 @@ import {
useState,
} from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
-import { Direction } from '../EntityRelationsGraph';
+import { Direction } from '../../lib/types';
export type CatalogGraphPageValue = {
rootEntityNames: CompoundEntityRef[];
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx
index a38f11fca3..c9ddddb4b3 100644
--- a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderLabel.tsx
@@ -15,7 +15,7 @@
*/
import { DependencyGraphTypes } from '@backstage/core-components';
import makeStyles from '@material-ui/core/styles/makeStyles';
-import { EntityEdgeData } from './types';
+import { EntityEdgeData } from '../../lib/types';
import classNames from 'classnames';
/** @public */
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx
index 3541e4511e..97c17af3ec 100644
--- a/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/DefaultRenderNode.tsx
@@ -20,7 +20,7 @@ import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
import { useLayoutEffect, useRef, useState } from 'react';
import { EntityIcon } from './EntityIcon';
-import { EntityNodeData } from './types';
+import { EntityNodeData } from '../../lib/types';
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
/** @public */
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx
index 933aa230be..f77ca4b37d 100644
--- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.tsx
@@ -30,8 +30,8 @@ import classNames from 'classnames';
import { MouseEvent, useEffect, useMemo } from 'react';
import { DefaultRenderLabel } from './DefaultRenderLabel';
import { DefaultRenderNode } from './DefaultRenderNode';
-import { ALL_RELATION_PAIRS, RelationPairs } from './relations';
-import { Direction, EntityEdge, EntityNode } from './types';
+import { RelationPairs } from '../../lib/types';
+import { Direction, EntityEdge, EntityNode } from '../../lib/types';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
/** @public */
@@ -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..4a325591ca 100644
--- a/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/index.ts
@@ -18,14 +18,5 @@ export type {
EntityRelationsGraphProps,
EntityRelationsGraphClassKey,
} from './EntityRelationsGraph';
-export { ALL_RELATION_PAIRS } from './relations';
-export type { RelationPairs } from './relations';
-export { Direction } from './types';
-export type {
- EntityEdgeData,
- EntityEdge,
- EntityNodeData,
- EntityNode,
-} from './types';
export type { CustomLabelClassKey } from './DefaultRenderLabel';
export type { CustomNodeClassKey } from './DefaultRenderNode';
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationGraph.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..1b99cfc074 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,19 +100,21 @@ export function useEntityRelationGraph({
entities,
rootEntityRefs,
maxDepth,
- relations,
+ includeRelation,
kinds,
entityFilter,
requestEntities,
]);
- const filteredEntities = useMemo(
- () =>
- entityFilter
- ? pickBy(entities, (value, _key) => entityFilter(value))
- : entities,
- [entities, entityFilter],
- );
+ const filteredEntities = useMemo(() => {
+ if (loading) {
+ return {};
+ }
+
+ return entityFilter
+ ? pickBy(entities, (value, _key) => entityFilter(value))
+ : entities;
+ }, [loading, entities, entityFilter]);
return {
entities: filteredEntities,
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx
similarity index 83%
rename from plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.ts
rename to plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.test.tsx
index 08b718d789..ad70111fa0 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 { EntityNode } from '../../lib/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(() => {
@@ -233,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],
@@ -260,12 +280,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(() => {
@@ -308,63 +330,38 @@ 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',
- },
]);
});
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(() => {
@@ -407,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],
@@ -434,12 +435,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(() => {
@@ -482,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],
@@ -533,10 +544,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(() => {
@@ -579,24 +592,21 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
+ distance: 1,
from: 'b:d/c1',
label: 'visible',
relations: ['hasPart', 'partOf'],
to: 'b:d/c2',
},
{
+ 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'],
@@ -606,11 +616,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(() => {
@@ -653,6 +665,7 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
+ distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
@@ -662,11 +675,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(() => {
@@ -703,12 +718,14 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
+ distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
+ distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts
index 19a6dbe566..7a6f8361e5 100644
--- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityRelationNodesAndEdges.ts
@@ -15,10 +15,18 @@
*/
import { MouseEvent, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
-import { RelationPairs, ALL_RELATION_PAIRS } from './relations';
-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 {
+ BuiltInTransformations,
+ builtInTransformations,
+ GraphTransformer,
+ TransformationContext,
+} from '../../lib/graph-transformations';
/**
* Generate nodes and edges to render the entity graph.
@@ -32,7 +40,7 @@ export function useEntityRelationNodesAndEdges({
relations,
entityFilter,
onNodeClick,
- relationPairs = ALL_RELATION_PAIRS,
+ relationPairs: incomingRelationPairs,
}: {
rootEntityRefs: string[];
maxDepth?: number;
@@ -63,6 +71,11 @@ export function useEntityRelationNodesAndEdges({
},
});
+ const { relationPairs, includeRelation } = useRelations({
+ relations,
+ relationPairs: incomingRelationPairs,
+ });
+
useDebounce(
() => {
if (!entities || Object.keys(entities).length === 0) {
@@ -92,145 +105,64 @@ export function useEntityRelationNodesAndEdges({
return node;
});
- const edges: EntityEdge[] = [];
- const visitedNodes = new Set();
- const nodeQueue = [...rootEntityRefs];
+ const edges = buildGraph({
+ rootEntityRefs,
+ entities,
+ includeRelation,
+ kinds,
+ mergeRelations,
+ relationPairs,
+ unidirectional,
+ });
- const hasEdge = (
- fromRef: string,
- toRef: string,
- relation: [string, string?],
- ): boolean => {
- return edges.some(
- edge =>
- fromRef === edge.from &&
- toRef === edge.to &&
- edge.relations.some(
- rel => rel[0] === relation[0] && rel[1] === relation[1],
- ),
- );
+ const transformationContext: TransformationContext = {
+ nodeDistances: new Map(),
+ edges,
+ nodes,
+
+ rootEntityRefs,
+ unidirectional,
+ maxDepth,
};
- 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 (relations && !relations.includes(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,
- ];
- }
- }
- }
- });
+ const runTransformation = (
+ transformation: BuiltInTransformations | GraphTransformer,
+ ) => {
+ if (typeof transformation === 'function') {
+ transformation(transformationContext);
+ } else {
+ builtInTransformations[transformation](transformationContext);
}
+ };
+
+ runTransformation('reduce-edges');
+ runTransformation('set-distances');
+ if (unidirectional) {
+ runTransformation('strip-distant-edges');
+ }
+ if (mergeRelations || unidirectional) {
+ // Merge relations even if only unidirectional, the next transformer
+ // 'remove-backward-edges' needs to know about all relations before it
+ // strips away the backward ones
+ runTransformation('merge-relations');
+ }
+ if (unidirectional && !mergeRelations) {
+ runTransformation('order-forward');
+ runTransformation('remove-backward-edges');
}
- // 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: transformationContext.nodes,
+ edges: transformationContext.edges,
+ });
},
100,
[
+ maxDepth,
entities,
rootEntityRefs,
kinds,
- relations,
+ includeRelation,
unidirectional,
mergeRelations,
onNodeClick,
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts
index c55becdd49..5880468317 100644
--- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts
+++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.ts
@@ -16,7 +16,7 @@
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
-import { Dispatch, useCallback, useRef, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
import useAsyncFn from 'react-use/esm/useAsyncFn';
// TODO: This is a good use case for a graphql API, once it is available in the
@@ -29,7 +29,7 @@ export function useEntityStore(): {
entities: { [ref: string]: Entity };
loading: boolean;
error?: Error;
- requestEntities: Dispatch;
+ requestEntities: (entityRefs: string[]) => void;
} {
const catalogClient = useApi(catalogApiRef);
const state = useRef({
diff --git a/plugins/catalog-graph/src/hooks/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..2c734a2cb1
--- /dev/null
+++ b/plugins/catalog-graph/src/hooks/useRelations.ts
@@ -0,0 +1,77 @@
+/*
+ * 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 '../lib/types';
+
+export interface UseRelationsOptions {
+ relations?: string[];
+ relationPairs?: RelationPairs;
+}
+
+export interface UseRelationsResult {
+ relations: string[];
+ relationPairs: RelationPairs;
+ defaultRelations: string[];
+ relationsToInclude: 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 relationsToInclude = opts.relations ?? defaultRelations;
+
+ const includeRelation = useCallback(
+ (type: string) => {
+ return relationsToInclude.includes(type);
+ },
+ [relationsToInclude],
+ );
+
+ return useMemo(
+ () => ({
+ relations,
+ relationPairs,
+ defaultRelations,
+ relationsToInclude,
+ includeRelation,
+ }),
+ [
+ relations,
+ relationPairs,
+ defaultRelations,
+ relationsToInclude,
+ includeRelation,
+ ],
+ );
+}
diff --git a/plugins/catalog-graph/src/index.ts b/plugins/catalog-graph/src/index.ts
index af81b076f2..95ac5048cb 100644
--- a/plugins/catalog-graph/src/index.ts
+++ b/plugins/catalog-graph/src/index.ts
@@ -23,5 +23,16 @@
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 './lib/types';
+export type {
+ RelationPairs,
+ EntityEdgeData,
+ EntityEdge,
+ EntityNodeData,
+ EntityNode,
+} from './lib/types';
+export { Direction } from './lib/types';
+export type { TransformationContext } from './lib/graph-transformations';
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/index.ts b/plugins/catalog-graph/src/lib/graph-transformations/index.ts
new file mode 100644
index 0000000000..764a20a8d2
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/index.ts
@@ -0,0 +1,35 @@
+/*
+ * 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, TransformationContext } 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 builtInTransformations = {
+ 'reduce-edges': reduceEdges,
+ 'set-distances': setDistances,
+ 'order-forward': orderForward,
+ 'strip-distant-edges': stripDistantEdges,
+ 'merge-relations': mergeRelations,
+ 'remove-backward-edges': removeBackwardEdges,
+};
+
+export type BuiltInTransformations = keyof typeof builtInTransformations;
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts
new file mode 100644
index 0000000000..88d4bbcd10
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/merge-relations.ts
@@ -0,0 +1,54 @@
+/*
+ * 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[] | undefined,
+ b: readonly string[] | undefined,
+): string[] {
+ return Array.from(new Set([...(a ?? []), ...(b ?? [])]));
+}
+
+/** Merge relations in multiple edges into one edge */
+export const mergeRelations: GraphTransformer = options => {
+ const mergedEdges = new Map();
+
+ options.edges.forEach(edge => {
+ const keyForward = `${edge.from}-${edge.to}`;
+ const keyBackward = `${edge.to}-${edge.from}`;
+
+ const edgeForward = mergedEdges.get(keyForward);
+ const edgeBackward = mergedEdges.get(keyBackward);
+
+ if (edgeForward) {
+ edgeForward.relations = concatRelations(
+ edgeForward.relations,
+ edge.relations,
+ );
+ } else if (edgeBackward) {
+ edgeBackward.relations = concatRelations(
+ edgeBackward.relations,
+ edge.relations,
+ );
+ } else {
+ mergedEdges.set(keyForward, edge);
+ }
+ });
+
+ options.edges = Array.from(mergedEdges.values());
+};
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts
new file mode 100644
index 0000000000..964c55e352
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/order-forward.ts
@@ -0,0 +1,84 @@
+/*
+ * 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';
+
+/** Orders the edges direction so that the graph goes strictly forward */
+export const orderForward: GraphTransformer = ({
+ rootEntityRefs,
+ nodes,
+ edges,
+}) => {
+ const entityEdges = new Map();
+ 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);
+ });
+
+ const visitedNodes = new Set();
+
+ for (const rootEntityRef of rootEntityRefs) {
+ let currentNodes: string[] = [rootEntityRef].filter(
+ node => !visitedNodes.has(node),
+ );
+
+ while (currentNodes.length > 0) {
+ for (const currentNode of currentNodes) {
+ visitedNodes.add(currentNode);
+ }
+
+ const nextNodes: string[] = [];
+
+ currentNodes.forEach(node => {
+ entityEdges.get(node)?.forEach(edge => {
+ if (edge.to === node && !visitedNodes.has(edge.from)) {
+ // Reverse direction
+ const { from, to } = edge;
+ edge.from = to;
+ edge.to = from;
+ edge.relations.reverse();
+ }
+
+ nextNodes.push(edge.from, edge.to);
+ });
+ });
+
+ currentNodes = Array.from(new Set(nextNodes)).filter(
+ node => !visitedNodes.has(node),
+ );
+ }
+ }
+
+ const nodeOrder = Array.from(visitedNodes);
+
+ nodes.sort((a, b) => {
+ const aOrder = nodeOrder.findIndex(node => node === a.id);
+ const bOrder = nodeOrder.findIndex(node => node === b.id);
+ return aOrder - bOrder;
+ });
+};
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/reduce-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/reduce-edges.ts
new file mode 100644
index 0000000000..115aec438c
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/reduce-edges.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EntityEdge } from '../types';
+import { GraphTransformer } from './types';
+
+/**
+ * Reduce edges as the dependency graph anyway ignores duplicated edges
+ * regarding from / to
+ * Additionally, this will improve rendering speed for the dependency graph
+ */
+export const reduceEdges: GraphTransformer = ctx => {
+ ctx.edges = ctx.edges.reduce((previousEdges, currentEdge) => {
+ const indexFound = previousEdges.findIndex(
+ previousEdge =>
+ previousEdge.from === currentEdge.from &&
+ previousEdge.to === currentEdge.to,
+ );
+ if (indexFound >= 0) {
+ previousEdges[indexFound] = {
+ ...previousEdges[indexFound],
+ relations: Array.from(
+ new Set([
+ ...previousEdges[indexFound].relations,
+ ...currentEdge.relations,
+ ]),
+ ),
+ };
+ return previousEdges;
+ }
+ return [...previousEdges, currentEdge];
+ }, [] as EntityEdge[]);
+};
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts
new file mode 100644
index 0000000000..2c6f634ba9
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/remove-backward-edges.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 { edges } = ctx;
+
+ const edgesMapFrom = new Map();
+
+ edges.forEach(edge => {
+ const theseEdges = edgesMapFrom.get(edge.from) ?? [];
+ theseEdges.push(edge);
+ edgesMapFrom.set(edge.from, theseEdges);
+ });
+
+ edges.forEach(edge => {
+ if (edge.relations.length > 1) {
+ edge.relations = edge.relations.slice(0, 1);
+ }
+ });
+};
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts
new file mode 100644
index 0000000000..5986512305
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/set-distance.ts
@@ -0,0 +1,70 @@
+/*
+ * 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';
+
+/** Set the distance of each edge to the distance from a root entity */
+export const setDistances: GraphTransformer = ({
+ nodeDistances,
+ edges,
+ rootEntityRefs,
+}) => {
+ const edgeMap = new Map();
+ edges.forEach(edge => {
+ const fromEdges = edgeMap.get(edge.from) ?? [];
+ fromEdges.push(edge);
+ edgeMap.set(edge.from, fromEdges);
+
+ const toEdges = edgeMap.get(edge.to) ?? [];
+ toEdges.push(edge);
+ edgeMap.set(edge.to, toEdges);
+ });
+
+ rootEntityRefs.forEach(rootEntityRef => {
+ nodeDistances.set(rootEntityRef, 0);
+ });
+ const visitedNodes = new Set(rootEntityRefs);
+ let currentNodes = rootEntityRefs;
+ let distance = 0;
+
+ while (currentNodes.length > 0) {
+ const thisDistance = ++distance;
+
+ const nextNodes: string[] = [];
+ currentNodes.forEach(node => {
+ edgeMap.get(node)?.forEach(edge => {
+ edge.distance ??= thisDistance;
+
+ const fromDistance = nodeDistances.get(edge.from) ?? thisDistance;
+ nodeDistances.set(edge.from, fromDistance);
+ nextNodes.push(edge.from);
+
+ const toDistance = nodeDistances.get(edge.to) ?? thisDistance;
+ nodeDistances.set(edge.to, toDistance);
+ nextNodes.push(edge.to);
+ });
+ });
+
+ for (const currentNode of currentNodes) {
+ visitedNodes.add(currentNode);
+ }
+
+ currentNodes = Array.from(new Set(nextNodes)).filter(
+ node => !visitedNodes.has(node),
+ );
+ }
+};
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/strip-distant-edges.ts b/plugins/catalog-graph/src/lib/graph-transformations/strip-distant-edges.ts
new file mode 100644
index 0000000000..aeb7618e5e
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/strip-distant-edges.ts
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { EntityEdge } from '../types';
+import { GraphTransformer } from './types';
+
+/**
+ * Removes edges that are further away then other edges leading up to the root.
+ */
+export const stripDistantEdges: GraphTransformer = options => {
+ const entityEdges = new Map();
+ options.edges.forEach(edge => {
+ let fromEdges = entityEdges.get(edge.from);
+ if (!fromEdges) {
+ fromEdges = [];
+ entityEdges.set(edge.from, fromEdges);
+ }
+ fromEdges.push(edge);
+
+ let toEdges = entityEdges.get(edge.to);
+ if (!toEdges) {
+ toEdges = [];
+ entityEdges.set(edge.to, toEdges);
+ }
+ toEdges.push(edge);
+ });
+
+ // Remove edges that are further away than other edges leading up to the root
+ options.edges = options.edges.filter(edge => {
+ // Get all edges of the 'from' node except this (and similar) edge
+ const fromEdges = entityEdges
+ .get(edge.from)!
+ .filter(
+ fromEdge =>
+ !(fromEdge.from === edge.from && fromEdge.to === edge.to) &&
+ !(fromEdge.from === edge.to && fromEdge.to === edge.from),
+ );
+ // Get all edges of the 'from' node except this (and similar) edge
+ const toEdges = entityEdges
+ .get(edge.to)!
+ .filter(
+ toEdge =>
+ !(toEdge.from === edge.from && toEdge.to === edge.to) &&
+ !(toEdge.from === edge.to && toEdge.to === edge.from),
+ );
+
+ if (fromEdges.length === 0 || toEdges.length === 0) {
+ return true;
+ }
+
+ const shorterFrom = fromEdges.some(
+ fromEdge => (fromEdge.distance ?? 0) < (edge.distance ?? 0),
+ );
+ const shorterTo = toEdges.some(
+ toEdge => (toEdge.distance ?? 0) < (edge.distance ?? 0),
+ );
+
+ if (shorterFrom && shorterTo) {
+ // There are shorter edges from both 'from' and 'to' leading to the root,
+ // exclude this edge
+ return false;
+ }
+ return true;
+ });
+};
diff --git a/plugins/catalog-graph/src/lib/graph-transformations/types.ts b/plugins/catalog-graph/src/lib/graph-transformations/types.ts
new file mode 100644
index 0000000000..dc13057a04
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph-transformations/types.ts
@@ -0,0 +1,47 @@
+/*
+ * 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, EntityNode } from '../types';
+
+/**
+ * Contextual information for a graph transformation.
+ *
+ * @public
+ */
+export interface TransformationContext {
+ /**
+ * The distance from an entity node to a root entity
+ *
+ * NOTE: This is empty until the 'set-distances' transformation is applied
+ */
+ nodeDistances: Map;
+
+ edges: EntityEdge[];
+ nodes: EntityNode[];
+
+ // Options:
+ rootEntityRefs: string[];
+ unidirectional: boolean;
+ maxDepth: number;
+}
+
+/**
+ * A function that transforms a graph. The function modifies `nodes` and `edges`
+ * in place.
+ *
+ * @public
+ */
+export type GraphTransformer = (context: TransformationContext) => void;
diff --git a/plugins/catalog-graph/src/lib/graph/build-graph.ts b/plugins/catalog-graph/src/lib/graph/build-graph.ts
new file mode 100644
index 0000000000..3a6bf57120
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph/build-graph.ts
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Entity } from '@backstage/catalog-model';
+
+import type { EntityEdge, RelationPairs } from '../types';
+
+export interface BuildGraphOptions {
+ rootEntityRefs: readonly string[];
+ entities: {
+ [ref: string]: Entity;
+ };
+ includeRelation: (type: string) => boolean;
+ kinds: readonly string[] | undefined;
+ mergeRelations: boolean;
+ relationPairs: RelationPairs;
+ unidirectional: boolean;
+}
+
+export function buildGraph({
+ rootEntityRefs,
+ entities,
+ includeRelation,
+ kinds,
+ mergeRelations,
+ relationPairs,
+ unidirectional,
+}: BuildGraphOptions): EntityEdge[] {
+ const edges: EntityEdge[] = [];
+ const visitedNodes = new Set();
+ const nodeQueue = [...rootEntityRefs];
+
+ const hasEdge = (
+ fromRef: string,
+ toRef: string,
+ relation: [string, string?],
+ ): boolean => {
+ return edges.some(
+ edge =>
+ fromRef === edge.from &&
+ toRef === edge.to &&
+ edge.relations.some(
+ rel => rel[0] === relation[0] && rel[1] === relation[1],
+ ),
+ );
+ };
+
+ while (nodeQueue.length > 0) {
+ const entityRef = nodeQueue.pop()!;
+ const entity = entities[entityRef];
+ visitedNodes.add(entityRef);
+
+ if (entity) {
+ entity?.relations?.forEach(rel => {
+ // Check if the related entity should be displayed, if not, ignore
+ // the relation too
+ if (!entities[rel.targetRef]) {
+ return;
+ }
+
+ if (!includeRelation(rel.type)) {
+ return;
+ }
+
+ if (
+ kinds &&
+ !kinds.some(kind =>
+ rel.targetRef.startsWith(`${kind.toLocaleLowerCase('en-US')}:`),
+ )
+ ) {
+ return;
+ }
+
+ if (mergeRelations) {
+ const pair = relationPairs.find(
+ ([l, r]) => l === rel.type || r === rel.type,
+ ) ?? [rel.type];
+ const [left] = pair;
+ const from = left === rel.type ? entityRef : rel.targetRef;
+ const to = left === rel.type ? rel.targetRef : entityRef;
+ if (!unidirectional || !hasEdge(from, to, pair)) {
+ edges.push({
+ from,
+ to,
+ relations: pair,
+ label: 'visible',
+ });
+ }
+ } else {
+ if (
+ !unidirectional ||
+ !hasEdge(entityRef, rel.targetRef, [rel.type])
+ ) {
+ edges.push({
+ from: entityRef,
+ to: rel.targetRef,
+ relations: [rel.type],
+ label: 'visible',
+ });
+ }
+ }
+
+ if (!visitedNodes.has(rel.targetRef)) {
+ nodeQueue.push(rel.targetRef);
+ visitedNodes.add(rel.targetRef);
+ }
+
+ // if unidirectional add missing relations as entities are only visited once
+ if (unidirectional) {
+ const findIndex = edges.findIndex(
+ edge =>
+ entityRef === edge.from &&
+ rel.targetRef === edge.to &&
+ !edge.relations.includes(rel.type),
+ );
+ if (findIndex >= 0) {
+ if (mergeRelations) {
+ const pair = relationPairs.find(
+ ([l, r]) => l === rel.type || r === rel.type,
+ ) ?? [rel.type];
+ edges[findIndex].relations = [
+ ...edges[findIndex].relations,
+ ...pair,
+ ];
+ } else {
+ edges[findIndex].relations = [
+ ...edges[findIndex].relations,
+ rel.type,
+ ];
+ }
+ }
+ }
+ });
+ }
+ }
+
+ return edges;
+}
diff --git a/plugins/catalog-graph/src/lib/graph/index.ts b/plugins/catalog-graph/src/lib/graph/index.ts
new file mode 100644
index 0000000000..77741aa7d8
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/graph/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { buildGraph } from './build-graph';
+export type { BuildGraphOptions } from './build-graph';
diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts b/plugins/catalog-graph/src/lib/types/graph.ts
similarity index 100%
rename from plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts
rename to plugins/catalog-graph/src/lib/types/graph.ts
diff --git a/plugins/catalog-graph/src/lib/types/index.ts b/plugins/catalog-graph/src/lib/types/index.ts
new file mode 100644
index 0000000000..ba567d679a
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/types/index.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 { RelationPairs } from './relations';
+export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './relations';
+
+export type {
+ EntityEdgeData,
+ EntityEdge,
+ EntityNodeData,
+ EntityNode,
+} from './graph';
+export { Direction } from './graph';
diff --git a/plugins/catalog-graph/src/lib/types/relations.ts b/plugins/catalog-graph/src/lib/types/relations.ts
new file mode 100644
index 0000000000..d6a683cf4f
--- /dev/null
+++ b/plugins/catalog-graph/src/lib/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],
+];
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/scaffolder-backend-module-rails/report.api.md b/plugins/scaffolder-backend-module-rails/report.api.md
index a8abcfee1e..c7bbd19a72 100644
--- a/plugins/scaffolder-backend-module-rails/report.api.md
+++ b/plugins/scaffolder-backend-module-rails/report.api.md
@@ -48,6 +48,7 @@ export function createFetchRailsAction(options: {
railsArguments?:
| {
template?: string | undefined;
+ api?: boolean | undefined;
database?:
| 'sqlite3'
| 'mysql'
@@ -59,7 +60,6 @@ export function createFetchRailsAction(options: {
| 'jdbcpostgresql'
| 'jdbc'
| undefined;
- api?: boolean | undefined;
force?: boolean | undefined;
minimal?: boolean | undefined;
railsVersion?: 'edge' | 'master' | 'dev' | 'fromImage' | undefined;