entityTypeFilter improvements
- Supports selecting multiple types - Switch TemplateTypePicker to FormGroup instead of List Co-authored-by: Joe Porpeglia <josephp@spotify.com> Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { capitalize } from 'lodash';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { useEntityTypeFilter } from '../../hooks/useEntityTypeFilter';
|
||||
|
||||
@@ -24,9 +24,14 @@ import { Select } from '@backstage/core-components';
|
||||
|
||||
export const EntityTypePicker = () => {
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const { error, types, selectedType, setType } = useEntityTypeFilter();
|
||||
const {
|
||||
error,
|
||||
availableTypes,
|
||||
selectedTypes,
|
||||
setSelectedTypes,
|
||||
} = useEntityTypeFilter();
|
||||
|
||||
if (!types) return null;
|
||||
if (!availableTypes) return null;
|
||||
|
||||
if (error) {
|
||||
alertApi.post({
|
||||
@@ -38,7 +43,7 @@ export const EntityTypePicker = () => {
|
||||
|
||||
const items = [
|
||||
{ value: 'all', label: 'All' },
|
||||
...types.map((type: string) => ({
|
||||
...availableTypes.map((type: string) => ({
|
||||
value: type,
|
||||
label: capitalize(type),
|
||||
})),
|
||||
@@ -49,8 +54,10 @@ export const EntityTypePicker = () => {
|
||||
<Select
|
||||
label="Type"
|
||||
items={items}
|
||||
selected={selectedType ?? 'all'}
|
||||
onChange={value => setType(value === 'all' ? undefined : String(value))}
|
||||
selected={selectedTypes.length ? selectedTypes[0] : 'all'}
|
||||
onChange={value =>
|
||||
setSelectedTypes(value === 'all' ? [] : [String(value)])
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,8 @@ export class EntityKindFilter implements EntityFilter {
|
||||
export class EntityTypeFilter implements EntityFilter {
|
||||
constructor(readonly value: string | string[]) {}
|
||||
|
||||
getTypes() {
|
||||
// Simplify `string | string[]` for consumers, always returns an array
|
||||
getTypes(): string[] {
|
||||
return Array.isArray(this.value) ? this.value : [this.value];
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
|
||||
};
|
||||
|
||||
export function useEntityListProvider<
|
||||
EntityFilters extends DefaultEntityFilters
|
||||
EntityFilters extends DefaultEntityFilters = DefaultEntityFilters
|
||||
>(): EntityListContextProps<EntityFilters> {
|
||||
const context = useContext(EntityListContext);
|
||||
if (!context)
|
||||
|
||||
@@ -27,10 +27,9 @@ import { EntityTypeFilter } from '../filters';
|
||||
type EntityTypeReturn = {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
types: string[];
|
||||
selectedType: string | undefined;
|
||||
setType: (type: string | undefined) => void;
|
||||
setTypes: (types: string[]) => void;
|
||||
availableTypes: string[];
|
||||
selectedTypes: string[];
|
||||
setSelectedTypes: (types: string[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -44,7 +43,7 @@ export function useEntityTypeFilter(): EntityTypeReturn {
|
||||
updateFilters,
|
||||
} = useEntityListProvider();
|
||||
|
||||
const [allTypes, setAllTypes] = useState<string[]>([]);
|
||||
const [availableTypes, setAvailableTypes] = useState<string[]>([]);
|
||||
const kind = useMemo(() => kindFilter?.value, [kindFilter]);
|
||||
|
||||
// Load all valid spec.type values straight from the catalogApi, paying attention to only the
|
||||
@@ -65,12 +64,23 @@ export function useEntityTypeFilter(): EntityTypeReturn {
|
||||
useEffect(() => {
|
||||
// Resolve the unique set of types from returned entities; could be optimized by a new endpoint
|
||||
// in the catalog-backend that does this, rather than loading entities with redundant types.
|
||||
const newTypes = [
|
||||
...new Set(
|
||||
(entities ?? []).map(e => e.spec?.type).filter(Boolean) as string[],
|
||||
),
|
||||
].sort();
|
||||
setAllTypes(newTypes);
|
||||
if (!entities) return;
|
||||
|
||||
// Sort by entity count descending, so the most common types appear on top
|
||||
const countByType = entities.reduce((acc, entity) => {
|
||||
if (typeof entity.spec?.type !== 'string') return acc;
|
||||
|
||||
if (!acc[entity.spec.type]) {
|
||||
acc[entity.spec.type] = 0;
|
||||
}
|
||||
acc[entity.spec.type] += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const newTypes = Object.entries(countByType)
|
||||
.sort(([, count1], [, count2]) => count2 - count1)
|
||||
.map(([type]) => type);
|
||||
setAvailableTypes(newTypes);
|
||||
|
||||
// Update type filter to only valid values when the list of available types has changed
|
||||
updateFilters((oldFilters: DefaultEntityFilters) => {
|
||||
@@ -89,25 +99,19 @@ export function useEntityTypeFilter(): EntityTypeReturn {
|
||||
});
|
||||
}, [updateFilters, entities]);
|
||||
|
||||
const setTypes = useCallback(
|
||||
const setSelectedTypes = useCallback(
|
||||
(types: string[]) =>
|
||||
updateFilters({
|
||||
type: types.length ? undefined : new EntityTypeFilter(types),
|
||||
type: types.length ? new EntityTypeFilter(types) : undefined,
|
||||
}),
|
||||
[updateFilters],
|
||||
);
|
||||
|
||||
const setType = (type: string | undefined) =>
|
||||
setTypes(type === undefined ? [] : [type]);
|
||||
|
||||
// TODO(timbonicus): selectedType should be selectedTypes
|
||||
// TODO(timbonicus): remove setType, make this only array-based
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
types: allTypes,
|
||||
selectedType: typeFilter?.value,
|
||||
setType,
|
||||
setTypes,
|
||||
availableTypes,
|
||||
selectedTypes: typeFilter?.getTypes() ?? [],
|
||||
setSelectedTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const CatalogTable = ({ columns, actions }: CatalogTableProps) => {
|
||||
const { loading, error, entities, filters } = useEntityListProvider();
|
||||
|
||||
const showTypeColumn = filters.type === undefined;
|
||||
// TODO(timbonicus): we should show filter chips for all filters instead
|
||||
// TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar
|
||||
const titlePreamble = capitalize(filters.user?.value ?? 'all');
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -15,31 +15,40 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import {
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
checkbox: {
|
||||
padding: theme.spacing(0, 1, 0, 1),
|
||||
padding: theme.spacing(1, 1, 1, 2),
|
||||
},
|
||||
}));
|
||||
|
||||
export const TemplateTypePicker = () => {
|
||||
const classes = useStyles();
|
||||
const alertApi = useApi(alertApiRef);
|
||||
// TODO(timbonicus): Use new setTypes returned from the hook
|
||||
const { error, types, selectedType } = useEntityTypeFilter();
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
availableTypes,
|
||||
selectedTypes,
|
||||
setSelectedTypes,
|
||||
} = useEntityTypeFilter();
|
||||
|
||||
if (!types) return null;
|
||||
if (loading) return <Progress />;
|
||||
|
||||
if (!availableTypes) return null;
|
||||
|
||||
if (error) {
|
||||
alertApi.post({
|
||||
@@ -49,49 +58,33 @@ export const TemplateTypePicker = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleSelection(type: string) {
|
||||
setSelectedTypes(
|
||||
selectedTypes.includes(type)
|
||||
? selectedTypes.filter(t => t !== type)
|
||||
: [...selectedTypes, type],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box pb={1} pt={1}>
|
||||
<Typography variant="button">Categories</Typography>
|
||||
<List disablePadding dense>
|
||||
{types.map(type => {
|
||||
const labelId = `checkbox-list-label-${type}`;
|
||||
return (
|
||||
<ListItem
|
||||
key={type}
|
||||
dense
|
||||
button
|
||||
onClick={() => {}}
|
||||
// TODO(timbonicus): Update to use setTypes
|
||||
// setSelectedCategories(
|
||||
// selectedCategories.includes(type)
|
||||
// ? selectedCategories.filter(
|
||||
// selectedCategory => selectedCategory !== type,
|
||||
// )
|
||||
// : [...selectedCategories, type],
|
||||
// )
|
||||
// }
|
||||
>
|
||||
<FormGroup>
|
||||
{availableTypes.map(type => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
edge="start"
|
||||
color="primary"
|
||||
// TODO: Fix me
|
||||
// checked={selectedTypes.includes(type)}
|
||||
checked={type === selectedType}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
checked={selectedTypes.includes(type)}
|
||||
onChange={() => toggleSelection(type)}
|
||||
name={`entity-type-option-${type}`}
|
||||
className={classes.checkbox}
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
<ListItemText
|
||||
id={labelId}
|
||||
primary={
|
||||
type.charAt(0).toLocaleUpperCase('en-US') + type.slice(1)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
}
|
||||
label={capitalize(type)}
|
||||
key={type}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user