diff --git a/.changeset/mean-insects-hope.md b/.changeset/mean-insects-hope.md new file mode 100644 index 0000000000..0a71a9c1ee --- /dev/null +++ b/.changeset/mean-insects-hope.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Make `useRelatedEntities` use `getEntitiesByRefs` under the hood diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index ea51d3989d..d664ce78e8 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -32,12 +32,7 @@ describe('', () => { getApiDefinitionWidget: jest.fn(), } as any; const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), + getEntitiesByRefs: jest.fn(), } as any; let Wrapper: React.ComponentType>; @@ -99,7 +94,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx index 6ab55c5c66..169311d0c8 100644 --- a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -32,12 +32,7 @@ describe('', () => { getApiDefinitionWidget: jest.fn(), } as any; const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), + getEntitiesByRefs: jest.fn(), } as any; let Wrapper: React.ComponentType>; @@ -99,7 +94,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 5f69e90f9f..3dbc4edd63 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -32,12 +32,7 @@ describe('', () => { getApiDefinitionWidget: jest.fn(), } as any; const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), + getEntitiesByRefs: jest.fn(), } as any; let Wrapper: React.ComponentType>; @@ -99,7 +94,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index f7a18cf671..20a60562b0 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -28,12 +28,7 @@ import { ConsumingComponentsCard } from './ConsumingComponentsCard'; describe('', () => { const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), + getEntitiesByRefs: jest.fn(), } as any; let Wrapper: React.ComponentType>; @@ -102,7 +97,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 8b003bf2e2..0801d7103a 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -28,12 +28,7 @@ import { ProvidingComponentsCard } from './ProvidingComponentsCard'; describe('', () => { const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), + getEntitiesByRefs: jest.fn(), } as any; let Wrapper: React.ComponentType>; @@ -102,7 +97,7 @@ describe('', () => { }, ], }; - catalogApi.getEntities.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 90bddecd71..2fab514de1 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -555,7 +555,7 @@ export function useEntityTypeFilter(): { setSelectedTypes: (types: string[]) => void; }; -// @public (undocumented) +// @public export function useRelatedEntities( entity: Entity, relationFilter: { diff --git a/plugins/catalog-react/src/hooks/useRelatedEntities.test.tsx b/plugins/catalog-react/src/hooks/useRelatedEntities.test.tsx new file mode 100644 index 0000000000..0d415274e0 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useRelatedEntities.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { TestApiProvider } from '@backstage/test-utils'; +import { WrapperComponent, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { catalogApiRef } from '../api'; +import { useRelatedEntities } from './useRelatedEntities'; + +describe('useRelatedEntities', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'test' }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/the-owners-1', + }, + { + type: 'ownedBy', + targetRef: 'group:default/the-owners-2', + }, + { + type: 'partOf', + targetRef: 'component:default/larger-thing', + }, + ], + }; + + const catalogApi = { + getEntitiesByRefs: jest.fn(), + }; + + const wrapper: WrapperComponent<{}> = ({ children }) => { + return ( + + {children} + + ); + }; + + it('filters and requests entities', async () => { + catalogApi.getEntitiesByRefs.mockResolvedValueOnce({ + items: [entity, null], // one of them doesn't exist + }); + + const rendered = renderHook( + () => useRelatedEntities(entity, { type: 'ownedby', kind: 'grOUP' }), + { wrapper }, + ); + + expect(rendered.result.current).toEqual({ loading: true }); + + await rendered.waitForValueToChange(() => rendered.result.current.loading); + + expect(catalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ + entityRefs: ['group:default/the-owners-1', 'group:default/the-owners-2'], + }); + + expect(rendered.result.current).toEqual({ + loading: false, + entities: [entity], // filtered out the null + }); + }); +}); diff --git a/plugins/catalog-react/src/hooks/useRelatedEntities.ts b/plugins/catalog-react/src/hooks/useRelatedEntities.ts index 4cf5690ac3..7ec0a13d53 100644 --- a/plugins/catalog-react/src/hooks/useRelatedEntities.ts +++ b/plugins/catalog-react/src/hooks/useRelatedEntities.ts @@ -13,15 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity, parseEntityRef } from '@backstage/catalog-model'; import { useApi } from '@backstage/core-plugin-api'; -import { chunk, groupBy } from 'lodash'; import useAsync from 'react-use/lib/useAsync'; import { catalogApiRef } from '../api'; -const BATCH_SIZE = 20; - -/** @public */ +/** + * Fetches all entities that appear in the entity's relations, optionally + * filtered by relation type and kind. + * + * @public + */ export function useRelatedEntities( entity: Entity, relationFilter: { type?: string; kind?: string }, @@ -32,70 +35,30 @@ export function useRelatedEntities( } { const filterByTypeLower = relationFilter?.type?.toLocaleLowerCase('en-US'); const filterByKindLower = relationFilter?.kind?.toLocaleLowerCase('en-US'); - const catalogApi = useApi(catalogApiRef); + const { loading, value: entities, error, } = useAsync(async () => { - const relations = entity.relations - ?.map(r => ({ type: r.type, target: parseEntityRef(r.targetRef) })) - .filter( - r => - (!filterByTypeLower || - r.type.toLocaleLowerCase('en-US') === filterByTypeLower) && - (!filterByKindLower || r.target.kind === filterByKindLower), - ); + const relations = entity.relations?.filter( + r => + (!filterByTypeLower || + r.type.toLocaleLowerCase('en-US') === filterByTypeLower) && + (!filterByKindLower || + parseEntityRef(r.targetRef).kind === filterByKindLower), + ); - if (!relations) { + if (!relations?.length) { return []; } - // Group the relations by kind and namespace to reduce the size of the request query string. - // Without this grouping, the kind and namespace would need to be specified for each relation, e.g. - // `filter=kind=component,namespace=default,name=example1&filter=kind=component,namespace=default,name=example2` - // with grouping, we can generate a query a string like - // `filter=kind=component,namespace=default,name=example1,example2` - const relationsByKindAndNamespace = Object.values( - groupBy(relations, ({ target }) => { - return `${target.kind}:${target.namespace}`.toLocaleLowerCase('en-US'); - }), - ); + const { items } = await catalogApi.getEntitiesByRefs({ + entityRefs: relations.map(r => r.targetRef), + }); - // Split the names within each group into batches to further reduce the query string length. - const batchedRelationsByKindAndNamespace: { - kind: string; - namespace: string; - nameBatches: string[][]; - }[] = []; - for (const rs of relationsByKindAndNamespace) { - batchedRelationsByKindAndNamespace.push({ - // All relations in a group have the same kind and namespace, so its arbitrary which we pick - kind: rs[0].target.kind, - namespace: rs[0].target.namespace, - nameBatches: chunk( - rs.map(r => r.target.name), - BATCH_SIZE, - ), - }); - } - - const results = await Promise.all( - batchedRelationsByKindAndNamespace.flatMap(rs => { - return rs.nameBatches.map(names => { - return catalogApi.getEntities({ - filter: { - kind: rs.kind, - 'metadata.namespace': rs.namespace, - 'metadata.name': names, - }, - }); - }); - }), - ); - - return results.flatMap(r => r.items); + return items.filter((x): x is Entity => Boolean(x)); }, [entity, filterByTypeLower, filterByKindLower]); return { diff --git a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx index e5900d75e0..0ebda6e64d 100644 --- a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx @@ -27,12 +27,14 @@ import React from 'react'; import { DependencyOfComponentsCard } from './DependencyOfComponentsCard'; describe('', () => { - const getEntities: jest.MockedFunction = jest.fn(); + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -85,7 +87,7 @@ describe('', () => { }, ], }; - getEntities.mockResolvedValue({ + getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx index 9255ee4f36..ee8931143f 100644 --- a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx @@ -27,12 +27,14 @@ import React from 'react'; import { DependsOnComponentsCard } from './DependsOnComponentsCard'; describe('', () => { - const getEntities: jest.MockedFunction = jest.fn(); + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -85,7 +87,7 @@ describe('', () => { }, ], }; - getEntities.mockResolvedValue({ + getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx index 3b0d7dcff3..0e760f9155 100644 --- a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx @@ -27,12 +27,14 @@ import React from 'react'; import { DependsOnResourcesCard } from './DependsOnResourcesCard'; describe('', () => { - const getEntities: jest.MockedFunction = jest.fn(); + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -85,7 +87,7 @@ describe('', () => { }, ], }; - getEntities.mockResolvedValue({ + getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx index 3651421797..67c3f6b650 100644 --- a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx @@ -27,12 +27,14 @@ import React from 'react'; import { HasComponentsCard } from './HasComponentsCard'; describe('', () => { - const getEntities: jest.MockedFunction = jest.fn(); + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -85,7 +87,7 @@ describe('', () => { }, ], }; - getEntities.mockResolvedValue({ + getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx index 4bf6524a9c..d78d307c96 100644 --- a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx @@ -27,12 +27,14 @@ import React from 'react'; import { HasResourcesCard } from './HasResourcesCard'; describe('', () => { - const getEntities: jest.MockedFunction = jest.fn(); + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -80,7 +82,7 @@ describe('', () => { }, ], }; - getEntities.mockResolvedValue({ + getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx index 97e69e93d1..713313b7c6 100644 --- a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx @@ -27,12 +27,14 @@ import React from 'react'; import { HasSubcomponentsCard } from './HasSubcomponentsCard'; describe('', () => { - const getEntities: jest.MockedFunction = jest.fn(); + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -85,7 +87,7 @@ describe('', () => { }, ], }; - getEntities.mockResolvedValue({ + getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx index 224e0c6703..9844708f85 100644 --- a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx @@ -27,12 +27,14 @@ import React from 'react'; import { HasSystemsCard } from './HasSystemsCard'; describe('', () => { - const getEntities: jest.MockedFunction = jest.fn(); + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -85,7 +87,7 @@ describe('', () => { }, ], }; - getEntities.mockResolvedValue({ + getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1',