diff --git a/.changeset/calm-moose-fetch.md b/.changeset/calm-moose-fetch.md new file mode 100644 index 0000000000..29d581bf23 --- /dev/null +++ b/.changeset/calm-moose-fetch.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog': patch +--- + +Implement `EntityKindPicker` which allows users to filter the catalog kinds much like the `CatalogKindHeader`. + +The new picker is more accessible though listed as any other filter options in the catalog. diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index ee43a0a5c1..8f95f5cb13 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -161,7 +161,7 @@ export type EntityFilter = { export class EntityKindFilter implements EntityFilter { constructor(value: string); // (undocumented) - getCatalogFilters(): Record; + getCatalogFilters(): Record; // (undocumented) toQueryValue(): string; // (undocumented) @@ -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 774d07c544..36ac93101a 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -14,115 +14,168 @@ * limitations under the License. */ -import { useApi } from '@backstage/core-plugin-api'; -import { - Box, - Checkbox, - FormControlLabel, - makeStyles, - TextField, - Typography, -} from '@material-ui/core'; -import CheckBoxIcon from '@material-ui/icons/CheckBox'; -import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import { Autocomplete } from '@material-ui/lab'; -import React, { useEffect, useMemo, useState } from 'react'; +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'; -const useStyles = makeStyles( - { - input: {}, - }, - { - name: 'CatalogReactEntityKindPicker', - }, -); +function useAvailableKinds() { + const catalogApi = useApi(catalogApiRef); -const icon = ; -const checkedIcon = ; + const [availableKinds, setAvailableKinds] = useState([]); -/** @public */ -export const EntityKindPicker = () => { - const classes = useStyles(); const { - updateFilters, + 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: kindsParameter }, + queryParameters: { kind: kindParameter }, + updateFilters, } = useEntityList(); - const catalogApi = useApi(catalogApiRef); - const { value: availableKinds } = useAsync(async () => { - const facet = 'kind'; - const { facets } = await catalogApi.getEntityFacets({ - facets: [facet], - }); - - return facets[facet].map(({ value }) => value); - }, [filters.kind]); - - const queryParamKinds = useMemo( - () => [kindsParameter].flat().filter(Boolean) as string[], - [kindsParameter], + const flattenedQueryKind = useMemo( + () => [kindParameter].flat()[0], + [kindParameter], ); - const [selectedKinds, setSelectedKinds] = useState( - queryParamKinds.length ? queryParamKinds : filters.kind?.getKinds() ?? [], + 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 (queryParamKinds.length) { - setSelectedKinds(queryParamKinds); + if (flattenedQueryKind) { + setSelectedKind(flattenedQueryKind); } - }, [queryParamKinds]); + }, [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: selectedKinds.length - ? new EntityKindFilter(selectedKinds) - : undefined, + kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined, }); - }, [selectedKinds, updateFilters]); + }, [selectedKind, updateFilters]); - if (!availableKinds?.length) return null; + return { + loading, + error, + availableKinds, + selectedKind, + setSelectedKind, + }; +} - return ( +/** + * Props for {@link EntityKindPicker}. + * + * @public + */ +export interface EntityKindPickerProps { + initialFilter?: string; + hidden?: boolean; +} + +/** @public */ +export const EntityKindPicker = (props: EntityKindPickerProps) => { + const { hidden, initialFilter = 'component' } = props; + + const alertApi = useApi(alertApiRef); + + const { error, availableKinds, selectedKind, setSelectedKind } = + useEntityKindFilter({ + initialFilter: initialFilter, + }); + + useEffect(() => { + if (error) { + alertApi.post({ + message: `Failed to load entity kinds`, + severity: 'error', + }); + } + if (initialFilter) { + setSelectedKind(initialFilter); + } + }, [error, alertApi, initialFilter, setSelectedKind]); + + if (availableKinds?.length === 0 || error) return null; + + const items = [ + ...availableKinds.map((kind: string) => ({ + value: kind, + label: capitalize(kind), + })), + ]; + + return hidden ? null : ( - - Kinds - setSelectedKinds(value)} - renderOption={(option, { selected }) => ( - - } - label={option} - /> - )} - size="small" - popupIcon={} - renderInput={params => ( - - )} - /> - +