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