Merge pull request #13869 from lunarway/feature/entity-kind-picker

Implement EntityKindPicker for the catalog
This commit is contained in:
Fredrik Adelöw
2022-10-03 16:06:36 +02:00
committed by GitHub
7 changed files with 316 additions and 46 deletions
+6
View File
@@ -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.
+1 -1
View File
@@ -176,7 +176,7 @@ export const EntityKindPicker: (
// @public
export interface EntityKindPickerProps {
// (undocumented)
hidden: boolean;
hidden?: boolean;
// (undocumented)
initialFilter?: string;
}
@@ -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('<EntityKindPicker/>', () => {
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(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ filters: { kind: new EntityKindFilter('component') } }}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
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(
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityKindPicker initialFilter="component" hidden />
</MockEntityListContextProvider>,
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
filters: { kind: new EntityKindFilter('component') },
updateFilters,
}}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
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(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters,
}}
>
<EntityKindPicker initialFilter="group" hidden />
</MockEntityListContextProvider>
,
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('group'),
});
});
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { kind: 'component' },
}}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('component'),
});
});
it('respects the query parameter filter value', () => {
const updateFilters = jest.fn();
const queryParameters = { kind: 'API' };
render(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters,
}}
>
<EntityKindPicker initialFilter="component" hidden />
</MockEntityListContextProvider>,
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { kind: 'domain' },
}}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('API'),
kind: new EntityKindFilter('domain'),
});
});
});
@@ -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<string[]>([]);
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 <Alert severity="warning">Kind filter not yet available</Alert>;
return hidden ? null : (
<Box pb={1} pt={1}>
<Select
label="Kind"
items={items}
selected={selectedKind}
onChange={value => setSelectedKind(String(value))}
/>
</Box>
);
};
@@ -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() }],
]}
>
<EntityListProvider>
@@ -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,
@@ -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) {
</ContentHeader>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<EntityKindPicker initialFilter={initialKind} />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />