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 : (
+
+
+ );
};
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
index 3353312955..d8c89e0858 100644
--- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
@@ -17,6 +17,7 @@
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
+ alertApiRef,
ConfigApi,
configApiRef,
IdentityApi,
@@ -91,6 +92,7 @@ const wrapper = ({
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
+ [alertApiRef, { post: jest.fn() }],
]}
>
diff --git a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx
index 67eedb9acc..b0aaeae658 100644
--- a/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx
+++ b/plugins/catalog/src/components/CatalogKindHeader/CatalogKindHeader.tsx
@@ -69,6 +69,7 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
});
const {
+ filters,
updateFilters,
queryParameters: { kind: kindParameter },
} = useEntityList();
@@ -81,6 +82,14 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
queryParamKind ?? initialFilter,
);
+ // 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]);
+
useEffect(() => {
updateFilters({
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx
index 39c19d717a..2bae104d90 100644
--- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx
+++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx
@@ -34,6 +34,7 @@ import {
EntityTypePicker,
UserListFilterKind,
UserListPicker,
+ EntityKindPicker,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { createComponentRouteRef } from '../../routes';
@@ -83,6 +84,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
+