diff --git a/.changeset/forty-poets-tie.md b/.changeset/forty-poets-tie.md new file mode 100644 index 0000000000..9118100934 --- /dev/null +++ b/.changeset/forty-poets-tie.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Decouple tags picker from backend entities + +`EntityTagPicker` fetches all the tags independently and it doesn't require all the entities to be available client side. diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index c6b985bc8b..2be521203f 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -14,60 +14,49 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityTagFilter } from '../../filters'; import { EntityTagPicker } from './EntityTagPicker'; +import { TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '../../api'; +import { CatalogApi } from '@backstage/catalog-client'; -const taggedEntities: Entity[] = [ - { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'component-1', - tags: ['tag4', 'tag1', 'tag2'], - }, - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'component-2', - tags: ['tag3', 'tag4'], - }, - }, -]; +const tags = ['tag1', 'tag2', 'tag3', 'tag4']; describe('', () => { - it('renders all tags', () => { + const mockCatalogApiRef = { + getEntityFacets: async () => ({ + facets: { 'metadata.tags': tags.map(value => ({ value })) }, + }), + } as unknown as CatalogApi; + + it('renders all tags', async () => { const rendered = render( - - - , + + + + + , ); - expect(rendered.getByText('Tags')).toBeInTheDocument(); + await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument()); fireEvent.click(rendered.getByTestId('tag-picker-expand')); - taggedEntities - .flatMap(e => e.metadata.tags!) - .forEach(tag => { - expect(rendered.getByText(tag)).toBeInTheDocument(); - }); + tags.forEach(tag => { + expect(rendered.getByText(tag)).toBeInTheDocument(); + }); }); - it('renders unique tags in alphabetical order', () => { + it('renders unique tags in alphabetical order', async () => { const rendered = render( - - - , + + + + + , ); - expect(rendered.getByText('Tags')).toBeInTheDocument(); + await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument()); fireEvent.click(rendered.getByTestId('tag-picker-expand')); @@ -79,43 +68,47 @@ describe('', () => { ]); }); - it('respects the query parameter filter value', () => { + it('respects the query parameter filter value', async () => { const updateFilters = jest.fn(); const queryParameters = { tags: ['tag3'] }; render( - - - , + + + + + , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - tags: new EntityTagFilter(['tag3']), - }); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag3']), + }), + ); }); - it('adds tags to filters', () => { + it('adds tags to filters', async () => { const updateFilters = jest.fn(); const rendered = render( - - - , + + + + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: undefined, + }), ); - expect(updateFilters).toHaveBeenLastCalledWith({ - tags: undefined, - }); fireEvent.click(rendered.getByTestId('tag-picker-expand')); fireEvent.click(rendered.getByText('tag1')); @@ -124,23 +117,25 @@ describe('', () => { }); }); - it('removes tags from filters', () => { + it('removes tags from filters', async () => { const updateFilters = jest.fn(); const rendered = render( - - - , + + + + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1']), + }), ); - expect(updateFilters).toHaveBeenLastCalledWith({ - tags: new EntityTagFilter(['tag1']), - }); fireEvent.click(rendered.getByTestId('tag-picker-expand')); expect(rendered.getByLabelText('tag1')).toBeChecked(); @@ -150,30 +145,36 @@ describe('', () => { }); }); - it('responds to external queryParameters changes', () => { + it('responds to external queryParameters changes', async () => { const updateFilters = jest.fn(); const rendered = render( - - - , + + + + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1']), + }), ); - expect(updateFilters).toHaveBeenLastCalledWith({ - tags: new EntityTagFilter(['tag1']), - }); rendered.rerender( - - - , + + + + + , ); expect(updateFilters).toHaveBeenLastCalledWith({ tags: new EntityTagFilter(['tag2']), diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index 5b5e0e7ab9..eafa10696c 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; import { Box, Checkbox, @@ -30,6 +29,9 @@ import { Autocomplete } from '@material-ui/lab'; import React, { useEffect, useMemo, useState } from 'react'; import { useEntityList } from '../../hooks/useEntityListProvider'; import { EntityTagFilter } from '../../filters'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; /** @public */ export type CatalogReactEntityTagPickerClassKey = 'input'; @@ -49,8 +51,17 @@ const checkedIcon = ; /** @public */ export const EntityTagPicker = () => { const classes = useStyles(); - const { updateFilters, backendEntities, filters, queryParameters } = - useEntityList(); + const { updateFilters, filters, queryParameters } = useEntityList(); + + const catalogApi = useApi(catalogApiRef); + const { value: availableTags } = useAsync(async () => { + const facet = 'metadata.tags'; + const { facets } = await catalogApi.getEntityFacets({ + facets: [facet], + }); + + return facets[facet].map(({ value }) => value); + }, []); const queryParamTags = useMemo( () => [queryParameters.tags].flat().filter(Boolean) as string[], @@ -75,19 +86,7 @@ export const EntityTagPicker = () => { }); }, [selectedTags, updateFilters]); - const availableTags = useMemo( - () => - [ - ...new Set( - backendEntities - .flatMap((e: Entity) => e.metadata.tags) - .filter(Boolean) as string[], - ), - ].sort(), - [backendEntities], - ); - - if (!availableTags.length) return null; + if (!availableTags?.length) return null; return (