Merge pull request #14366 from GLundh/fix-catalog-2

Catalog kind filter fixes & clean-up
This commit is contained in:
Fredrik Adelöw
2022-12-06 12:00:14 +01:00
committed by GitHub
11 changed files with 268 additions and 138 deletions
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Cleanup and small fixes for the kind selector
+1
View File
@@ -9,6 +9,7 @@ Airbrake
Airbrakes
Alaria
Alef
allowedKinds
Anddddd
Apdex
api
+1
View File
@@ -175,6 +175,7 @@ export const EntityKindPicker: (
// @public
export interface EntityKindPickerProps {
allowedKinds?: string[];
// (undocumented)
hidden?: boolean;
// (undocumented)
@@ -147,37 +147,56 @@ describe('<EntityKindPicker/>', () => {
});
});
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(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { kind: 'component' },
}}
value={{ queryParameters: { kind: 'FROb' } }}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('component'),
});
rendered.rerender(
expect(screen.getByText('FROb')).toBeInTheDocument();
});
it('limits kinds when allowedKinds is set', async () => {
await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { kind: 'domain' },
}}
>
<EntityKindPicker />
<MockEntityListContextProvider>
<EntityKindPicker allowedKinds={['component', 'domain']} />
</MockEntityListContextProvider>
</ApiProvider>,
);
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(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ queryParameters: { kind: 'Frob' } }}
>
<EntityKindPicker allowedKinds={['domain']} />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(screen.getByText('Frob')).toBeInTheDocument();
const input = screen.getByTestId('select');
fireEvent.click(input);
expect(screen.getByRole('option', { name: 'Domain' })).toBeInTheDocument();
});
});
@@ -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<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 };
}
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 : (
<Box pb={1} pt={1}>
<Select
label="Kind"
items={items}
selected={selectedKind}
selected={selectedKind.toLocaleLowerCase('en-US')}
onChange={value => setSelectedKind(String(value))}
/>
</Box>
@@ -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 useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
/**
* 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<string, string> {
// 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<string, string>);
return kindsMap;
}
@@ -166,7 +166,7 @@ describe('<EntityListProvider />', () => {
location: `/catalog?${query}`,
},
});
await waitFor(() => !!result.current.queryParameters);
await act(() => waitFor(() => !!result.current.queryParameters));
expect(result.current.queryParameters).toEqual({
kind: 'component',
type: 'service',
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
@@ -106,14 +106,14 @@ describe('<CatalogKindHeader />', () => {
await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ queryParameters: { kind: 'frob' } }}
value={{ queryParameters: { kind: 'FROb' } }}
>
<CatalogKindHeader />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(screen.getByText('Frobs')).toBeInTheDocument();
expect(screen.getByText('FRObs')).toBeInTheDocument();
});
it('updates the kind filter', async () => {
@@ -166,9 +166,11 @@ describe('<CatalogKindHeader />', () => {
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('template'),
});
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('template'),
}),
);
});
it('limits kinds when allowedKinds is set', async () => {
@@ -16,7 +16,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import {
capitalize,
createStyles,
InputBase,
makeStyles,
@@ -25,13 +24,11 @@ import {
Theme,
} from '@material-ui/core';
import {
catalogApiRef,
EntityKindFilter,
useEntityList,
} from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/lib/useAsync';
import { useApi } from '@backstage/core-plugin-api';
import pluralize from 'pluralize';
import { filterKinds, useAllKinds } from './kindFilterUtils';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -66,12 +63,7 @@ export interface CatalogKindHeaderProps {
export function CatalogKindHeader(props: CatalogKindHeaderProps) {
const { initialFilter = 'component', allowedKinds } = props;
const classes = useStyles();
const catalogApi = useApi(catalogApiRef);
const { value: allKinds } = useAsync(async () => {
return await catalogApi
.getEntityFacets({ facets: ['kind'] })
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
});
const { allKinds } = useAllKinds();
const {
filters,
updateFilters,
@@ -79,13 +71,22 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
} = useEntityList();
const queryParamKind = useMemo(
() => [kindParameter].flat()[0]?.toLocaleLowerCase('en-US'),
() => [kindParameter].flat()[0],
[kindParameter],
);
const [selectedKind, setSelectedKind] = useState(
queryParamKind ?? initialFilter,
queryParamKind ?? filters.kind?.value ?? initialFilter,
);
// Set selected kinds on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamKind) {
setSelectedKind(queryParamKind);
}
}, [queryParamKind]);
// Set selected kind from filters; this happens when the kind filter is
// updated from another component
useEffect(() => {
@@ -100,37 +101,12 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
});
}, [selectedKind, updateFilters]);
// Set selected Kind on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamKind) {
setSelectedKind(queryParamKind);
}
}, [queryParamKind]);
// 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.
const availableKinds = [capitalize(selectedKind)].concat(
allKinds?.filter(k =>
allowedKinds
? allowedKinds.some(
a => a.toLocaleLowerCase('en-US') === k.toLocaleLowerCase('en-US'),
)
: true,
) ?? [],
);
const options = availableKinds.sort().reduce((acc, kind) => {
acc[kind.toLocaleLowerCase('en-US')] = kind;
return acc;
}, {} as Record<string, string>);
const options = filterKinds(allKinds, allowedKinds, selectedKind);
return (
<Select
input={<InputBase value={selectedKind} />}
value={selectedKind}
input={<InputBase />}
value={selectedKind.toLocaleLowerCase('en-US')}
onChange={e => setSelectedKind(e.target.value as string)}
classes={classes}
>
@@ -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<string, string> {
// 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<string, string>);
return kindsMap;
}