Merge pull request #13869 from lunarway/feature/entity-kind-picker
Implement EntityKindPicker for the catalog
This commit is contained in:
@@ -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.
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user