diff --git a/.changeset/calm-moose-fetch.md b/.changeset/calm-moose-fetch.md new file mode 100644 index 0000000000..96a364c801 --- /dev/null +++ b/.changeset/calm-moose-fetch.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog': patch +--- + +Implemented the visual parts of `EntityKindPicker` so that it can be shown alongside the other filters on the left side of your catalog pages. diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 9e7a07c08b..e8f0b0e6ee 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -176,7 +176,7 @@ export const EntityKindPicker: ( // @public export interface EntityKindPickerProps { // (undocumented) - hidden: boolean; + hidden?: boolean; // (undocumented) initialFilter?: string; } diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index d6404fcf07..65ad3f227a 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -14,46 +14,170 @@ * limitations under the License. */ -import { render } from '@testing-library/react'; -import React from 'react'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { GetEntityFacetsResponse } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { ApiProvider } from '@backstage/core-app-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { capitalize } from 'lodash'; +import { default as React } from 'react'; +import { catalogApiRef } from '../../api'; import { EntityKindFilter } from '../../filters'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityKindPicker } from './EntityKindPicker'; +const entities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component', + }, + }, + { + apiVersion: '1', + kind: 'Domain', + metadata: { + name: 'domain', + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group', + }, + }, +]; + describe('', () => { + const apis = TestApiRegistry.from( + [ + catalogApiRef, + { + getEntityFacets: jest.fn().mockResolvedValue({ + facets: { + kind: entities.map(e => ({ + value: e.kind, + count: 1, + })), + }, + } as GetEntityFacetsResponse), + }, + ], + [ + alertApiRef, + { + post: jest.fn(), + }, + ], + ); + + it('renders available entity kinds', async () => { + const rendered = await renderWithEffects( + + + + + , + ); + expect(rendered.getByText('Kind')).toBeInTheDocument(); + + const input = rendered.getByTestId('select'); + fireEvent.click(input); + + await waitFor(() => rendered.getByText('Domain')); + + entities.forEach(entity => { + expect( + rendered.getByRole('option', { + name: capitalize(entity.kind as string), + }), + ).toBeInTheDocument(); + }); + }); + it('sets the selected kind filter', async () => { const updateFilters = jest.fn(); - render( - - , + const rendered = await renderWithEffects( + + + + + , + ); + const input = rendered.getByTestId('select'); + fireEvent.click(input); + + await waitFor(() => rendered.getByText('Domain')); + fireEvent.click(rendered.getByText('Domain')); + + expect(updateFilters).toHaveBeenLastCalledWith({ + kind: new EntityKindFilter('domain'), + }); + }); + + it('respects the query parameter filter value', async () => { + const updateFilters = jest.fn(); + const queryParameters = { kind: 'group' }; + await renderWithEffects( + + + + , + , ); + expect(updateFilters).toHaveBeenLastCalledWith({ + kind: new EntityKindFilter('group'), + }); + }); + + it('responds to external queryParameters changes', async () => { + const updateFilters = jest.fn(); + const rendered = await renderWithEffects( + + + + + , + ); expect(updateFilters).toHaveBeenLastCalledWith({ kind: new EntityKindFilter('component'), }); - }); - - it('respects the query parameter filter value', () => { - const updateFilters = jest.fn(); - const queryParameters = { kind: 'API' }; - render( - - , + rendered.rerender( + + + + + , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - kind: new EntityKindFilter('API'), + kind: new EntityKindFilter('domain'), }); }); }); diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index 4cfd141509..36ac93101a 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -14,10 +14,117 @@ * limitations under the License. */ -import React, { useEffect, useState } from 'react'; -import { Alert } from '@material-ui/lab'; -import { useEntityList } from '../../hooks'; +import { Select } from '@backstage/core-components'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { Box } from '@material-ui/core'; +import capitalize from 'lodash/capitalize'; +import sortBy from 'lodash/sortBy'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; import { EntityKindFilter } from '../../filters'; +import { useEntityList } from '../../hooks'; + +function useAvailableKinds() { + const catalogApi = useApi(catalogApiRef); + + const [availableKinds, setAvailableKinds] = useState([]); + + const { + error, + loading, + value: facets, + } = useAsync(async () => { + const facet = 'kind'; + const items = await catalogApi + .getEntityFacets({ + facets: [facet], + }) + .then(response => response.facets[facet] || []); + + return items; + }, [catalogApi]); + + const facetsRef = useRef(facets); + useEffect(() => { + const oldFacets = facetsRef.current; + facetsRef.current = facets; + // Delay processing hook until facets load updates have settled to generate list of kinds; + // This prevents resetting the kind filter due to saved kind value from query params not matching the + // empty set of kind values while values are still being loaded; also only run this hook on changes + // to facets + if (loading || oldFacets === facets || !facets) { + return; + } + + const newKinds = [ + ...new Set( + sortBy(facets, f => f.value).map(f => + f.value.toLocaleLowerCase('en-US'), + ), + ), + ]; + + setAvailableKinds(newKinds); + }, [loading, facets, setAvailableKinds]); + + return { loading, error, availableKinds }; +} + +function useEntityKindFilter(opts: { initialFilter: string }): { + loading: boolean; + error?: Error; + availableKinds: string[]; + selectedKind: string; + setSelectedKind: (kind: string) => void; +} { + const { + filters, + queryParameters: { kind: kindParameter }, + updateFilters, + } = useEntityList(); + + const flattenedQueryKind = useMemo( + () => [kindParameter].flat()[0], + [kindParameter], + ); + + const [selectedKind, setSelectedKind] = useState( + flattenedQueryKind ?? filters.kind?.value ?? opts.initialFilter, + ); + + // Set selected kinds on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (flattenedQueryKind) { + setSelectedKind(flattenedQueryKind); + } + }, [flattenedQueryKind]); + + // Set selected kind from filters; this happens when the kind filter is + // updated from another component + useEffect(() => { + if (filters.kind?.value) { + setSelectedKind(filters.kind?.value); + } + }, [filters.kind]); + + const { availableKinds, loading, error } = useAvailableKinds(); + + useEffect(() => { + updateFilters({ + kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined, + }); + }, [selectedKind, updateFilters]); + + return { + loading, + error, + availableKinds, + selectedKind, + setSelectedKind, + }; +} /** * Props for {@link EntityKindPicker}. @@ -26,29 +133,49 @@ import { EntityKindFilter } from '../../filters'; */ export interface EntityKindPickerProps { initialFilter?: string; - hidden: boolean; + hidden?: boolean; } /** @public */ export const EntityKindPicker = (props: EntityKindPickerProps) => { - const { initialFilter, hidden } = props; + const { hidden, initialFilter = 'component' } = props; - const { - updateFilters, - queryParameters: { kind: kindParameter }, - } = useEntityList(); - const [selectedKind] = useState([kindParameter].flat()[0] ?? initialFilter); + const alertApi = useApi(alertApiRef); + + const { error, availableKinds, selectedKind, setSelectedKind } = + useEntityKindFilter({ + initialFilter: initialFilter, + }); useEffect(() => { - updateFilters({ - kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined, - }); - }, [selectedKind, updateFilters]); + if (error) { + alertApi.post({ + message: `Failed to load entity kinds`, + severity: 'error', + }); + } + if (initialFilter) { + setSelectedKind(initialFilter); + } + }, [error, alertApi, initialFilter, setSelectedKind]); - if (hidden) return null; + if (availableKinds?.length === 0 || error) return null; - // TODO(timbonicus): This should load available kinds from the catalog-backend, similar to - // EntityTypePicker. + const items = [ + ...availableKinds.map((kind: string) => ({ + value: kind, + label: capitalize(kind), + })), + ]; - return Kind filter not yet available; + return hidden ? null : ( + +