diff --git a/.changeset/brave-bags-sniff.md b/.changeset/brave-bags-sniff.md new file mode 100644 index 0000000000..3fa542a4e3 --- /dev/null +++ b/.changeset/brave-bags-sniff.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Fixes in kind selectors (now OwnershipCards work again) +EntityKindPicker now accepts an optional allowedKinds prop, just like CatalogKindHeader. diff --git a/.changeset/neat-lies-know.md b/.changeset/neat-lies-know.md new file mode 100644 index 0000000000..01fc002668 --- /dev/null +++ b/.changeset/neat-lies-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Cleanup and small fixes for the kind selector diff --git a/.github/vale/Vocab/Backstage/accept.txt b/.github/vale/Vocab/Backstage/accept.txt index bba59094e3..e05f1acd91 100644 --- a/.github/vale/Vocab/Backstage/accept.txt +++ b/.github/vale/Vocab/Backstage/accept.txt @@ -9,6 +9,7 @@ Airbrake Airbrakes Alaria Alef +allowedKinds Anddddd Apdex api diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index e8f0b0e6ee..92bfc4de96 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -175,6 +175,7 @@ export const EntityKindPicker: ( // @public export interface EntityKindPickerProps { + allowedKinds?: string[]; // (undocumented) hidden?: boolean; // (undocumented) diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index 8a6b7206c6..04d8df3d16 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -147,37 +147,56 @@ describe('', () => { }); }); - it('responds to external queryParameters changes', async () => { - const updateFilters = jest.fn(); - const rendered = await renderWithEffects( + it('renders unknown kinds provided in query parameters', async () => { + await renderWithEffects( , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - kind: new EntityKindFilter('component'), - }); - rendered.rerender( + + expect(screen.getByText('FROb')).toBeInTheDocument(); + }); + + it('limits kinds when allowedKinds is set', async () => { + await renderWithEffects( - - + + , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - kind: new EntityKindFilter('domain'), - }); + + const input = screen.getByTestId('select'); + fireEvent.click(input); + + expect( + screen.getByRole('option', { name: 'Component' }), + ).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Domain' })).toBeInTheDocument(); + expect( + screen.queryByRole('option', { name: 'Template' }), + ).not.toBeInTheDocument(); + }); + + it('renders kind from the query parameter even when not in allowedKinds', async () => { + await renderWithEffects( + + + + + , + ); + + expect(screen.getByText('Frob')).toBeInTheDocument(); + + const input = screen.getByTestId('select'); + fireEvent.click(input); + expect(screen.getByRole('option', { name: 'Domain' })).toBeInTheDocument(); }); }); diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index c626a68d10..65ec949b4a 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -17,64 +17,15 @@ 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 React, { useEffect, useMemo, useState } from 'react'; 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 }; -} +import { filterKinds, useAllKinds } from './kindFilterUtils'; function useEntityKindFilter(opts: { initialFilter: string }): { loading: boolean; error?: Error; - availableKinds: string[]; + allKinds: string[]; selectedKind: string; setSelectedKind: (kind: string) => void; } { @@ -84,22 +35,22 @@ function useEntityKindFilter(opts: { initialFilter: string }): { updateFilters, } = useEntityList(); - const flattenedQueryKind = useMemo( + const queryParamKind = useMemo( () => [kindParameter].flat()[0], [kindParameter], ); const [selectedKind, setSelectedKind] = useState( - flattenedQueryKind ?? filters.kind?.value ?? opts.initialFilter, + queryParamKind ?? 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); + if (queryParamKind) { + setSelectedKind(queryParamKind); } - }, [flattenedQueryKind]); + }, [queryParamKind]); // Set selected kind from filters; this happens when the kind filter is // updated from another component @@ -109,18 +60,18 @@ function useEntityKindFilter(opts: { initialFilter: string }): { } }, [filters.kind]); - const { availableKinds, loading, error } = useAvailableKinds(); - useEffect(() => { updateFilters({ kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined, }); }, [selectedKind, updateFilters]); + const { allKinds, loading, error } = useAllKinds(); + return { loading, error, - availableKinds, + allKinds: allKinds ?? [], selectedKind, setSelectedKind, }; @@ -132,17 +83,22 @@ function useEntityKindFilter(opts: { initialFilter: string }): { * @public */ export interface EntityKindPickerProps { + /** + * Entity kinds to show in the dropdown; by default all kinds are fetched from the catalog and + * displayed. + */ + allowedKinds?: string[]; initialFilter?: string; hidden?: boolean; } /** @public */ export const EntityKindPicker = (props: EntityKindPickerProps) => { - const { hidden, initialFilter = 'component' } = props; + const { allowedKinds, hidden, initialFilter = 'component' } = props; const alertApi = useApi(alertApiRef); - const { error, availableKinds, selectedKind, setSelectedKind } = + const { error, allKinds, selectedKind, setSelectedKind } = useEntityKindFilter({ initialFilter: initialFilter, }); @@ -156,21 +112,21 @@ export const EntityKindPicker = (props: EntityKindPickerProps) => { } }, [error, alertApi]); - if (availableKinds?.length === 0 || error) return null; + if (error) return null; - const items = [ - ...availableKinds.map((kind: string) => ({ - value: kind, - label: capitalize(kind), - })), - ]; + const options = filterKinds(allKinds, allowedKinds, selectedKind); + + const items = Object.keys(options).map(key => ({ + value: key, + label: options[key], + })); return hidden ? null : ( } - value={selectedKind} + input={} + value={selectedKind.toLocaleLowerCase('en-US')} onChange={e => setSelectedKind(e.target.value as string)} classes={classes} > diff --git a/plugins/catalog/src/components/CatalogKindHeader/kindFilterUtils.ts b/plugins/catalog/src/components/CatalogKindHeader/kindFilterUtils.ts new file mode 100644 index 0000000000..4f74d10a6c --- /dev/null +++ b/plugins/catalog/src/components/CatalogKindHeader/kindFilterUtils.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2022 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 { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import useAsync from 'react-use/lib/useAsync'; + +/** + * Fetch and return all availible kinds. + */ +export function useAllKinds(): { + loading: boolean; + error?: Error; + allKinds: string[]; +} { + const catalogApi = useApi(catalogApiRef); + + const { + error, + loading, + value: allKinds, + } = useAsync(async () => { + const items = await catalogApi + .getEntityFacets({ facets: ['kind'] }) + .then(response => response.facets.kind?.map(f => f.value).sort() || []); + return items; + }, [catalogApi]); + + return { loading, error, allKinds: allKinds ?? [] }; +} + +/** + * Filter and capitalize accessible kinds. + */ +export function filterKinds( + allKinds: string[], + allowedKinds?: string[], + forcedKinds?: string, +): Record { + // Before allKinds is loaded, or when a kind is entered manually in the URL, selectedKind may not + // be present in allKinds. It should still be shown in the dropdown, but may not have the nice + // enforced casing from the catalog-backend. This makes a key/value record for the Select options, + // including selectedKind if it's unknown - but allows the selectedKind to get clobbered by the + // more proper catalog kind if it exists. + let availableKinds = allKinds; + if (allowedKinds) { + availableKinds = availableKinds.filter(k => + allowedKinds.some( + a => a.toLocaleLowerCase('en-US') === k.toLocaleLowerCase('en-US'), + ), + ); + } + if ( + forcedKinds && + !allKinds.some( + a => + a.toLocaleLowerCase('en-US') === forcedKinds.toLocaleLowerCase('en-US'), + ) + ) { + availableKinds = availableKinds.concat([forcedKinds]); + } + + const kindsMap = availableKinds.sort().reduce((acc, kind) => { + acc[kind.toLocaleLowerCase('en-US')] = kind; + return acc; + }, {} as Record); + + return kindsMap; +}