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