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/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 {