Merge pull request #30889 from grantila/grantila/support-custom-relations-in-catalog-graph

Add support for customizing the catalog graph relations using an API
This commit is contained in:
Fredrik Adelöw
2025-09-15 20:11:18 +02:00
committed by GitHub
48 changed files with 1521 additions and 383 deletions
+39 -13
View File
@@ -50,18 +50,6 @@ To use the catalog graph plugin, you have to add some things to your Backstage a
<CatalogGraphPage
initialState={{
selectedKinds: ['component', 'domain', 'system', 'api', 'group'],
selectedRelations: [
RELATION_OWNER_OF,
RELATION_OWNED_BY,
RELATION_CONSUMES_API,
RELATION_API_CONSUMED_BY,
RELATION_PROVIDES_API,
RELATION_API_PROVIDED_BY,
RELATION_HAS_PART,
RELATION_PART_OF,
RELATION_DEPENDS_ON,
RELATION_DEPENDENCY_OF,
],
}}
/>
}
@@ -88,7 +76,7 @@ To use the catalog graph plugin, you have to add some things to your Backstage a
</Grid>
```
### Customization
### Customizing the UI
Copy the default implementation `DefaultRenderNode.tsx` and add more classes to the styles:
@@ -151,6 +139,44 @@ Once you have your custom implementation, you can follow these steps to modify t
<EntityCatalogGraphCard variant=“gridItem” renderNode={MyCustomRenderNode} height={400} />
```
### Custom relations
Implementers with added custom relations can add them to the catalog graph plugin by overriding the default API. This also allows some relations to not be selected by default.
In `packages/app/src/apis.ts`, import the api ref and create the API as:
```ts
import {
ALL_RELATIONS,
ALL_RELATION_PAIRS,
catalogGraphApiRef,
DefaultCatalogGraphApi,
} from '@backstage/plugin-catalog-graph';
// ...
createApiFactory({
api: catalogGraphApiRef,
deps: {},
factory: () =>
new DefaultCatalogGraphApi({
// The relations to support
knownRelations: [...ALL_RELATIONS, 'myRelationOf', 'myRelationFor'],
// The relation pairs to support
knownRelationPairs: [
...ALL_RELATION_PAIRS,
['myRelationOf', 'myRelationFor'],
],
// Select what relations to be shown by default, either by including them,
// or excluding some from all known relations:
defaultRelationTypes: {
// Don't show/select these by default
exclude: ['myRelationOf', 'myRelationFor'],
},
}),
}),
```
## Development
Run `yarn` in the root of this plugin to install all dependencies and then `yarn start` to run a [development version](./dev/index.tsx) of this plugin.
+2 -2
View File
@@ -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;
+65
View File
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { DependencyGraphTypes } from '@backstage/core-components';
@@ -19,6 +20,19 @@ import { RouteRef } from '@backstage/core-plugin-api';
// @public
export const ALL_RELATION_PAIRS: RelationPairs;
// @public
export const ALL_RELATIONS: string[];
// @public
export interface CatalogGraphApi {
readonly defaultRelations: string[];
readonly knownRelationPairs: [string, string][];
readonly knownRelations: string[];
}
// @public
export const catalogGraphApiRef: ApiRef<CatalogGraphApi>;
// @public
export const CatalogGraphPage: (
props: {
@@ -62,6 +76,42 @@ export type CustomLabelClassKey = 'text' | 'secondary';
// @public (undocumented)
export type CustomNodeClassKey = 'node' | 'text' | 'clickable';
// @public
export class DefaultCatalogGraphApi implements CatalogGraphApi {
constructor(options?: DefaultCatalogGraphApiOptions);
// (undocumented)
readonly defaultRelations: string[];
// (undocumented)
readonly knownRelationPairs: [string, string][];
// (undocumented)
readonly knownRelations: string[];
}
// @public
export interface DefaultCatalogGraphApiOptions {
// (undocumented)
readonly defaultRelationTypes?: DefaultRelations;
// (undocumented)
readonly knownRelationPairs?: RelationPairs;
// (undocumented)
readonly knownRelations?: string[];
}
// @public
export type DefaultRelations =
| DefaultRelationsInclude
| DefaultRelationsExclude;
// @public (undocumented)
export type DefaultRelationsExclude = {
exclude: string[];
};
// @public (undocumented)
export type DefaultRelationsInclude = {
include: string[];
};
// @public
export enum Direction {
BOTTOM_TOP = 'BT',
@@ -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<string, number>;
// (undocumented)
nodes: EntityNode[];
// (undocumented)
rootEntityRefs: string[];
// (undocumented)
unidirectional: boolean;
}
```
@@ -0,0 +1,58 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core-plugin-api';
/**
* Utility API reference for the {@link CatalogGraphApi}.
*
* @public
*/
export const catalogGraphApiRef = createApiRef<CatalogGraphApi>({
id: 'plugin.catalog-graph.service',
});
/** @public */
export type DefaultRelationsInclude = { include: string[] };
/** @public */
export type DefaultRelationsExclude = { exclude: string[] };
/**
* Default relations. Can either be a list of relations to use by default, or
* a list of relations to _not_ use, i.e. include all other relations.
*
* @public
*/
export type DefaultRelations =
| DefaultRelationsInclude
| DefaultRelationsExclude;
/**
* API for driving catalog imports.
*
* @public
*/
export interface CatalogGraphApi {
/** All known relations */
readonly knownRelations: string[];
/** All known relation pairs */
readonly knownRelationPairs: [string, string][];
/** The default relations to show in the graph */
readonly defaultRelations: string[];
}
@@ -0,0 +1,61 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DefaultCatalogGraphApi } from './DefaultCatalogGraphApi';
describe('DefaultCatalogGraphApi', () => {
it('default config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi();
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(true);
expect(defaultRelations.includes('hasPart')).toBe(true);
});
it('empty include config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi({
defaultRelationTypes: { include: [] },
});
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(false);
expect(defaultRelations.includes('hasPart')).toBe(false);
});
it('include config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi({
defaultRelationTypes: { include: ['ownedBy'] },
});
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(true);
expect(defaultRelations.includes('hasPart')).toBe(false);
});
it('exclude config', async () => {
const { defaultRelations } = new DefaultCatalogGraphApi({
defaultRelationTypes: { exclude: ['ownedBy', 'ownerOf'] },
});
expect(defaultRelations.includes('')).toBe(false);
expect(defaultRelations.includes('fooRelation')).toBe(false);
expect(defaultRelations.includes('ownedBy')).toBe(false);
expect(defaultRelations.includes('hasPart')).toBe(true);
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ALL_RELATION_PAIRS, ALL_RELATIONS, RelationPairs } from '../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),
);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './CatalogGraphApi';
export * from './DefaultCatalogGraphApi';
@@ -35,6 +35,7 @@ import { catalogGraphRouteRef } from '../../routes';
import { CatalogGraphCard } from './CatalogGraphCard';
import Button from '@material-ui/core/Button';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
describe('<CatalogGraphCard/>', () => {
let entity: Entity;
@@ -56,6 +57,7 @@ describe('<CatalogGraphCard/>', () => {
apis = TestApiRegistry.from(
[catalogApiRef, catalog],
[translationApiRef, mockApis.translation()],
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
);
wrapper = (
@@ -32,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,
@@ -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<EntityRelationsGraphProps>,
) => {
const {
relationPairs = ALL_RELATION_PAIRS,
initialState,
entityFilter,
} = props;
const { relationPairs, initialState, entityFilter } = props;
const { t } = useTranslationRef(catalogGraphTranslationRef);
const navigate = useNavigate();
const classes = useStyles();
@@ -224,7 +218,6 @@ export const CatalogGraphPage = (
<SelectedRelationsFilter
value={selectedRelations}
onChange={setSelectedRelations}
relationPairs={relationPairs}
/>
<DirectionFilter value={direction} onChange={setDirection} />
<CurveFilter value={curve} onChange={setCurve} />
@@ -16,7 +16,7 @@
import { waitFor, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
import { DirectionFilter } from './DirectionFilter';
import { renderInTestApp } from '@backstage/test-utils';
@@ -16,7 +16,7 @@
import { Select, SelectedItems } from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import { useCallback } from 'react';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
@@ -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 (
<ApiProvider
apis={TestApiRegistry.from([
catalogGraphApiRef,
new DefaultCatalogGraphApi(),
])}
>
{props.children}
</ApiProvider>
);
}
describe('<SelectedRelationsFilter/>', () => {
test('should render current value', async () => {
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={() => {}}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={() => {}}
/>
</GraphContext>,
);
expect(screen.getByText(RELATION_OWNED_BY)).toBeInTheDocument();
@@ -42,11 +60,13 @@ describe('<SelectedRelationsFilter/>', () => {
test('should select value', async () => {
const onChange = jest.fn();
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={onChange}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
onChange={onChange}
/>
</GraphContext>,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -66,16 +86,16 @@ describe('<SelectedRelationsFilter/>', () => {
});
});
test('should return undefined if all values are selected', async () => {
test('should return all known relations if all values are selected', async () => {
const onChange = jest.fn();
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={ALL_RELATION_PAIRS.flatMap(p => p).filter(
r => r !== RELATION_HAS_MEMBER,
)}
onChange={onChange}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={ALL_RELATIONS.filter(r => r !== RELATION_HAS_MEMBER)}
onChange={onChange}
/>
</GraphContext>,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -87,18 +107,26 @@ describe('<SelectedRelationsFilter/>', () => {
await userEvent.click(screen.getByText(RELATION_HAS_MEMBER));
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith(undefined);
// Same as ALL_RELATIONS but with RELATION_HAS_MEMBER at the end
const allRelationsOrdered = [
...ALL_RELATIONS.filter(rel => rel !== RELATION_HAS_MEMBER),
RELATION_HAS_MEMBER,
];
expect(onChange).toHaveBeenCalledWith(allRelationsOrdered);
});
});
test('should return all values when cleared', async () => {
const onChange = jest.fn();
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[]}
onChange={onChange}
/>,
<GraphContext>
<SelectedRelationsFilter
relations={ALL_RELATIONS}
value={[]}
onChange={onChange}
/>
</GraphContext>,
);
await userEvent.click(screen.getByRole('combobox'));
@@ -24,9 +24,9 @@ import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useMemo } from 'react';
import { RelationPairs } from '../EntityRelationsGraph';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
import { useRelations } from '../../hooks';
/** @public */
export type SelectedRelationsFilterClassKey = 'formControl';
@@ -41,25 +41,31 @@ const useStyles = makeStyles(
);
export type Props = {
relationPairs: RelationPairs;
relations?: string[];
value: string[] | undefined;
onChange: (value: string[] | undefined) => void;
};
export const SelectedRelationsFilter = ({
relationPairs,
value,
onChange,
}: Props) => {
export const SelectedRelationsFilter = (props: Props) => {
const { relations: incomingRelations, value, onChange } = props;
const classes = useStyles();
const relations = useMemo(() => relationPairs.flat(), [relationPairs]);
const { relations, includeRelation } = useRelations({
relations: incomingRelations,
});
const defaultValue = useMemo(
() => relations.filter(rel => includeRelation(rel)),
[relations, includeRelation],
);
const { t } = useTranslationRef(catalogGraphTranslationRef);
const handleChange = useCallback(
(_: unknown, v: string[]) => {
onChange(relations.every(r => v.includes(r)) ? undefined : v);
onChange(v);
},
[relations, onChange],
[onChange],
);
const handleEmpty = useCallback(() => {
@@ -78,7 +84,7 @@ export const SelectedRelationsFilter = ({
disableCloseOnSelect
aria-label={t('catalogGraphPage.selectedRelationsFilter.title')}
options={relations}
value={value ?? relations}
value={value ?? defaultValue}
onChange={handleChange}
onBlur={handleEmpty}
renderOption={(option, { selected }) => (
@@ -18,7 +18,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { ReactNode } from 'react';
import { act } from 'react-dom/test-utils';
import { BrowserRouter } from 'react-router-dom';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
import { useCatalogGraphPage } from './useCatalogGraphPage';
const wrapper = ({ children }: { children?: ReactNode }) => {
@@ -28,7 +28,7 @@ import {
useState,
} from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
export type CatalogGraphPageValue = {
rootEntityNames: CompoundEntityRef[];
@@ -15,7 +15,7 @@
*/
import { DependencyGraphTypes } from '@backstage/core-components';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { EntityEdgeData } from './types';
import { EntityEdgeData } from '../../lib/types';
import classNames from 'classnames';
/** @public */
@@ -20,7 +20,7 @@ import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
import { useLayoutEffect, useRef, useState } from 'react';
import { EntityIcon } from './EntityIcon';
import { EntityNodeData } from './types';
import { EntityNodeData } from '../../lib/types';
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
/** @public */
@@ -30,8 +30,8 @@ import classNames from 'classnames';
import { MouseEvent, useEffect, useMemo } from 'react';
import { DefaultRenderLabel } from './DefaultRenderLabel';
import { DefaultRenderNode } from './DefaultRenderNode';
import { 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,
@@ -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';
@@ -13,16 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PropsWithChildren } from 'react';
import { ApiProvider } from '@backstage/core-app-api';
import {
RELATION_HAS_PART,
RELATION_OWNED_BY,
RELATION_OWNER_OF,
RELATION_PART_OF,
} from '@backstage/catalog-model';
import { TestApiRegistry } from '@backstage/test-utils';
import { renderHook } from '@testing-library/react';
import { pick } from 'lodash';
import { useEntityRelationGraph } from './useEntityRelationGraph';
import { useEntityStore as useEntityStoreMocked } from './useEntityStore';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
jest.mock('./useEntityStore');
@@ -30,6 +34,19 @@ const useEntityStore = useEntityStoreMocked as jest.Mock<
ReturnType<typeof useEntityStoreMocked>
>;
function GraphContext(props: PropsWithChildren<{}>) {
return (
<ApiProvider
apis={TestApiRegistry.from([
catalogGraphApiRef,
new DefaultCatalogGraphApi(),
])}
>
{props.children}
</ApiProvider>
);
}
describe('useEntityRelationGraph', () => {
const requestEntities = jest.fn();
@@ -171,8 +188,9 @@ describe('useEntityRelationGraph', () => {
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
const { result } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: [] }),
{ wrapper: GraphContext },
);
const { entities, loading, error } = result.current;
@@ -190,8 +208,9 @@ describe('useEntityRelationGraph', () => {
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
const { result } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: [] }),
{ wrapper: GraphContext },
);
const { entities, loading, error } = result.current;
@@ -210,8 +229,9 @@ describe('useEntityRelationGraph', () => {
requestEntities,
});
const { result } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: [] }),
const { result } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: [] }),
{ wrapper: GraphContext },
);
const { entities, loading, error } = result.current;
@@ -222,8 +242,9 @@ describe('useEntityRelationGraph', () => {
});
test('should walk relation tree', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }),
const { result, rerender } = renderHook(
() => useEntityRelationGraph({ rootEntityRefs: ['b:d/c'] }),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -256,11 +277,13 @@ describe('useEntityRelationGraph', () => {
});
test('should limit max depth', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth: 1 },
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth: 1 },
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -277,11 +300,13 @@ describe('useEntityRelationGraph', () => {
test('should update on filter change', async () => {
let maxDepth: number = Number.POSITIVE_INFINITY;
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth },
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: { maxDepth },
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -310,13 +335,15 @@ describe('useEntityRelationGraph', () => {
});
test('should filter by relation', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_HAS_PART, RELATION_PART_OF],
},
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_HAS_PART, RELATION_PART_OF],
},
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -332,14 +359,16 @@ describe('useEntityRelationGraph', () => {
});
test('should filter by kind', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_OWNED_BY, RELATION_OWNER_OF],
kinds: ['k'],
},
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
relations: [RELATION_OWNED_BY, RELATION_OWNER_OF],
kinds: ['k'],
},
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -354,13 +383,15 @@ describe('useEntityRelationGraph', () => {
});
test('should filter by func', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
entityFilter: e => e.metadata.name !== 'c2',
},
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c'],
filter: {
entityFilter: e => e.metadata.name !== 'c2',
},
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -376,10 +407,12 @@ describe('useEntityRelationGraph', () => {
});
test('should support multiple roots by kind', async () => {
const { result, rerender } = renderHook(() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
const { result, rerender } = renderHook(
() =>
useEntityRelationGraph({
rootEntityRefs: ['b:d/c', 'b:d/c2'],
}),
{ wrapper: GraphContext },
);
// Simulate rerendering as this is triggered automatically due to the mock
@@ -17,6 +17,7 @@ import { Entity } from '@backstage/catalog-model';
import { useEffect, useMemo } from 'react';
import { useEntityStore } from './useEntityStore';
import { pickBy } from 'lodash';
import { useRelations } from '../../hooks/useRelations';
/**
* Discover the graph of entities connected by relations, starting from a set of
@@ -45,6 +46,7 @@ export function useEntityRelationGraph({
error?: Error;
} {
const { entities, loading, error, requestEntities } = useEntityStore();
const { includeRelation } = useRelations({ relations });
useEffect(() => {
const expectedEntities = new Set([...rootEntityRefs]);
@@ -74,7 +76,7 @@ export function useEntityRelationGraph({
}
for (const rel of entity.relations) {
if (
(!relations || relations.includes(rel.type)) &&
includeRelation(rel.type) &&
(!kinds ||
kinds.some(kind =>
rel.targetRef.startsWith(
@@ -98,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,
@@ -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<EntityNode> {
};
}
function GraphContext(props: PropsWithChildren<{}>) {
return (
<ApiProvider
apis={TestApiRegistry.from([
catalogGraphApiRef,
new DefaultCatalogGraphApi(),
])}
>
{props.children}
</ApiProvider>
);
}
describe('useEntityRelationNodesAndEdges', () => {
beforeEach(() => {
useEntityRelationGraph.mockImplementation(({ filter: { kinds } }) => ({
@@ -149,10 +166,12 @@ describe('useEntityRelationNodesAndEdges', () => {
loading: true,
});
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
{ wrapper: GraphContext },
);
const { nodes, edges, loading, error } = result.current;
@@ -170,10 +189,12 @@ describe('useEntityRelationNodesAndEdges', () => {
error: returnError,
});
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
}),
{ wrapper: GraphContext },
);
const { nodes, edges, loading, error } = result.current;
@@ -185,12 +206,14 @@ describe('useEntityRelationNodesAndEdges', () => {
});
test('should generate unidirectional graph with merged relations', async () => {
const { result } = renderHook(() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: true,
}),
const { result } = renderHook(
() =>
useEntityRelationNodesAndEdges({
rootEntityRefs: ['b:d/c'],
unidirectional: true,
mergeRelations: true,
}),
{ wrapper: GraphContext },
);
await waitFor(() => {
@@ -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],
@@ -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<string>();
const nodeQueue = [...rootEntityRefs];
const edges = buildGraph({
rootEntityRefs,
entities,
includeRelation,
kinds,
mergeRelations,
relationPairs,
unidirectional,
});
const hasEdge = (
fromRef: string,
toRef: string,
relation: [string, string?],
): boolean => {
return edges.some(
edge =>
fromRef === edge.from &&
toRef === edge.to &&
edge.relations.some(
rel => rel[0] === relation[0] && rel[1] === relation[1],
),
);
const 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,
@@ -16,7 +16,7 @@
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Dispatch, useCallback, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import useAsyncFn from 'react-use/esm/useAsyncFn';
// TODO: This is a good use case for a graphql API, once it is available in the
@@ -29,7 +29,7 @@ export function useEntityStore(): {
entities: { [ref: string]: Entity };
loading: boolean;
error?: Error;
requestEntities: Dispatch<string[]>;
requestEntities: (entityRefs: string[]) => void;
} {
const catalogClient = useApi(catalogApiRef);
const state = useRef({
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type { UseRelationsOptions, UseRelationsResult } from './useRelations';
export { useRelations } from './useRelations';
@@ -0,0 +1,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,
],
);
}
+11
View File
@@ -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';
@@ -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;
@@ -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<string>([...(a ?? []), ...(b ?? [])]));
}
/** Merge relations in multiple edges into one edge */
export const mergeRelations: GraphTransformer = options => {
const mergedEdges = new Map<string, EntityEdge>();
options.edges.forEach(edge => {
const keyForward = `${edge.from}-${edge.to}`;
const keyBackward = `${edge.to}-${edge.from}`;
const edgeForward = mergedEdges.get(keyForward);
const edgeBackward = mergedEdges.get(keyBackward);
if (edgeForward) {
edgeForward.relations = concatRelations(
edgeForward.relations,
edge.relations,
);
} else if (edgeBackward) {
edgeBackward.relations = concatRelations(
edgeBackward.relations,
edge.relations,
);
} else {
mergedEdges.set(keyForward, edge);
}
});
options.edges = Array.from(mergedEdges.values());
};
@@ -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<string, EntityEdge[]>();
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<string>();
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;
});
};
@@ -0,0 +1,46 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityEdge } from '../types';
import { GraphTransformer } from './types';
/**
* Reduce edges as the dependency graph anyway ignores duplicated edges
* regarding from / to
* Additionally, this will improve rendering speed for the dependency graph
*/
export const reduceEdges: GraphTransformer = ctx => {
ctx.edges = ctx.edges.reduce((previousEdges, currentEdge) => {
const indexFound = previousEdges.findIndex(
previousEdge =>
previousEdge.from === currentEdge.from &&
previousEdge.to === currentEdge.to,
);
if (indexFound >= 0) {
previousEdges[indexFound] = {
...previousEdges[indexFound],
relations: Array.from(
new Set([
...previousEdges[indexFound].relations,
...currentEdge.relations,
]),
),
};
return previousEdges;
}
return [...previousEdges, currentEdge];
}, [] as EntityEdge[]);
};
@@ -0,0 +1,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<string, EntityEdge[]>();
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);
}
});
};
@@ -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<string, EntityEdge[]>();
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<string>(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),
);
}
};
@@ -0,0 +1,78 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityEdge } from '../types';
import { GraphTransformer } from './types';
/**
* Removes edges that are further away then other edges leading up to the root.
*/
export const stripDistantEdges: GraphTransformer = options => {
const entityEdges = new Map<string, EntityEdge[]>();
options.edges.forEach(edge => {
let fromEdges = entityEdges.get(edge.from);
if (!fromEdges) {
fromEdges = [];
entityEdges.set(edge.from, fromEdges);
}
fromEdges.push(edge);
let toEdges = entityEdges.get(edge.to);
if (!toEdges) {
toEdges = [];
entityEdges.set(edge.to, toEdges);
}
toEdges.push(edge);
});
// Remove edges that are further away than other edges leading up to the root
options.edges = options.edges.filter(edge => {
// Get all edges of the 'from' node except this (and similar) edge
const fromEdges = entityEdges
.get(edge.from)!
.filter(
fromEdge =>
!(fromEdge.from === edge.from && fromEdge.to === edge.to) &&
!(fromEdge.from === edge.to && fromEdge.to === edge.from),
);
// Get all edges of the 'from' node except this (and similar) edge
const toEdges = entityEdges
.get(edge.to)!
.filter(
toEdge =>
!(toEdge.from === edge.from && toEdge.to === edge.to) &&
!(toEdge.from === edge.to && toEdge.to === edge.from),
);
if (fromEdges.length === 0 || toEdges.length === 0) {
return true;
}
const shorterFrom = fromEdges.some(
fromEdge => (fromEdge.distance ?? 0) < (edge.distance ?? 0),
);
const shorterTo = toEdges.some(
toEdge => (toEdge.distance ?? 0) < (edge.distance ?? 0),
);
if (shorterFrom && shorterTo) {
// There are shorter edges from both 'from' and 'to' leading to the root,
// exclude this edge
return false;
}
return true;
});
};
@@ -0,0 +1,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<string, number>;
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;
@@ -0,0 +1,151 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import type { EntityEdge, RelationPairs } from '../types';
export interface BuildGraphOptions {
rootEntityRefs: readonly string[];
entities: {
[ref: string]: Entity;
};
includeRelation: (type: string) => boolean;
kinds: readonly string[] | undefined;
mergeRelations: boolean;
relationPairs: RelationPairs;
unidirectional: boolean;
}
export function buildGraph({
rootEntityRefs,
entities,
includeRelation,
kinds,
mergeRelations,
relationPairs,
unidirectional,
}: BuildGraphOptions): EntityEdge[] {
const edges: EntityEdge[] = [];
const visitedNodes = new Set<string>();
const nodeQueue = [...rootEntityRefs];
const hasEdge = (
fromRef: string,
toRef: string,
relation: [string, string?],
): boolean => {
return edges.some(
edge =>
fromRef === edge.from &&
toRef === edge.to &&
edge.relations.some(
rel => rel[0] === relation[0] && rel[1] === relation[1],
),
);
};
while (nodeQueue.length > 0) {
const entityRef = nodeQueue.pop()!;
const entity = entities[entityRef];
visitedNodes.add(entityRef);
if (entity) {
entity?.relations?.forEach(rel => {
// Check if the related entity should be displayed, if not, ignore
// the relation too
if (!entities[rel.targetRef]) {
return;
}
if (!includeRelation(rel.type)) {
return;
}
if (
kinds &&
!kinds.some(kind =>
rel.targetRef.startsWith(`${kind.toLocaleLowerCase('en-US')}:`),
)
) {
return;
}
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
) ?? [rel.type];
const [left] = pair;
const from = left === rel.type ? entityRef : rel.targetRef;
const to = left === rel.type ? rel.targetRef : entityRef;
if (!unidirectional || !hasEdge(from, to, pair)) {
edges.push({
from,
to,
relations: pair,
label: 'visible',
});
}
} else {
if (
!unidirectional ||
!hasEdge(entityRef, rel.targetRef, [rel.type])
) {
edges.push({
from: entityRef,
to: rel.targetRef,
relations: [rel.type],
label: 'visible',
});
}
}
if (!visitedNodes.has(rel.targetRef)) {
nodeQueue.push(rel.targetRef);
visitedNodes.add(rel.targetRef);
}
// if unidirectional add missing relations as entities are only visited once
if (unidirectional) {
const findIndex = edges.findIndex(
edge =>
entityRef === edge.from &&
rel.targetRef === edge.to &&
!edge.relations.includes(rel.type),
);
if (findIndex >= 0) {
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
) ?? [rel.type];
edges[findIndex].relations = [
...edges[findIndex].relations,
...pair,
];
} else {
edges[findIndex].relations = [
...edges[findIndex].relations,
rel.type,
];
}
}
}
});
}
}
return edges;
}
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { buildGraph } from './build-graph';
export type { BuildGraphOptions } from './build-graph';
@@ -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';
@@ -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],
];
+9 -1
View File
@@ -13,8 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin } from '@backstage/core-plugin-api';
import { createApiFactory, createPlugin } from '@backstage/core-plugin-api';
import { catalogEntityRouteRef, catalogGraphRouteRef } from './routes';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api';
/**
* Catalog Graph Plugin instance.
@@ -28,4 +29,11 @@ export const catalogGraphPlugin = createPlugin({
externalRoutes: {
catalogEntity: catalogEntityRouteRef,
},
apis: [
createApiFactory({
api: catalogGraphApiRef,
deps: {},
factory: () => new DefaultCatalogGraphApi(),
}),
],
});
@@ -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;