Switch tag picker to Autocomplete

Fix UserList counts not applying other filters

Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
Tim Hansen
2021-05-21 20:22:39 -06:00
parent 8fc260df19
commit 09230dd5d8
7 changed files with 98 additions and 74 deletions
+1
View File
@@ -33,6 +33,7 @@
"@backstage/core": "^0.7.9",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"lodash": "^4.17.15",
"react": "^16.13.1",
@@ -50,6 +50,8 @@ describe('<EntityTagPicker/>', () => {
</MockEntityListContextProvider>,
);
expect(rendered.getByText('Tags')).toBeInTheDocument();
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
taggedEntities
.flatMap(e => e.metadata.tags!)
.forEach(tag => {
@@ -72,6 +74,7 @@ describe('<EntityTagPicker/>', () => {
);
expect(updateFilters).not.toHaveBeenCalled();
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
fireEvent.click(rendered.getByText('tag1'));
expect(updateFilters).toHaveBeenLastCalledWith({
tags: new EntityTagFilter(['tag1']),
@@ -93,9 +96,10 @@ describe('<EntityTagPicker/>', () => {
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
expect(rendered.getByLabelText('tag1')).toBeChecked();
fireEvent.click(rendered.getByText('tag1'));
fireEvent.click(rendered.getByLabelText('tag1'));
expect(updateFilters).toHaveBeenLastCalledWith({
tags: undefined,
});
@@ -17,31 +17,22 @@
import React, { useMemo } from 'react';
import {
Checkbox,
List,
ListItem,
ListItemText,
makeStyles,
Theme,
FormControlLabel,
TextField,
Typography,
} from '@material-ui/core';
import { Autocomplete } from '@material-ui/lab';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Entity } from '@backstage/catalog-model';
import { EntityTagFilter } from '../../types';
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
const useStyles = makeStyles<Theme>(theme => ({
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
checkbox: {
padding: theme.spacing(0, 1, 0, 1),
},
}));
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
export const EntityTagPicker = () => {
const classes = useStyles();
const { updateFilters, backendEntities, filters } = useEntityListProvider();
const availableTags = useMemo(
() => [
@@ -56,40 +47,36 @@ export const EntityTagPicker = () => {
if (!availableTags.length) return null;
const onClick = (tag: string) => {
const tags = filters.tags?.values ?? [];
const newTags = tags.includes(tag)
? [...tags.filter((t: string) => t !== tag)]
: [...tags, tag];
const onChange = (tags: string[]) => {
updateFilters({
tags: newTags.length ? new EntityTagFilter(newTags) : undefined,
tags: tags.length ? new EntityTagFilter(tags) : undefined,
});
};
return (
<>
<Typography variant="subtitle2" className={classes.title}>
Tags
</Typography>
<List disablePadding dense>
{availableTags.map(tag => {
const labelId = `checkbox-list-label-${tag}`;
return (
<ListItem key={tag} dense button onClick={() => onClick(tag)}>
<Typography variant="button">Tags</Typography>
<Autocomplete<string>
multiple
options={availableTags}
value={filters.tags?.values ?? []}
onChange={(_: object, value: string[]) => onChange(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
edge="start"
color="primary"
checked={(filters.tags?.values ?? []).includes(tag)}
tabIndex={-1}
disableRipple
className={classes.checkbox}
inputProps={{ 'aria-labelledby': labelId }}
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
<ListItemText id={labelId} primary={tag} />
</ListItem>
);
})}
</List>
}
label={option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="tag-picker-expand" />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</>
);
};
@@ -25,6 +25,7 @@ import {
ConfigApi,
configApiRef,
} from '@backstage/core-api';
import { EntityTagFilter } from '../../types';
const apis = ApiRegistry.from([
[
@@ -68,7 +69,7 @@ describe('<UserListPicker />', () => {
metadata: {
namespace: 'namespace-1',
name: 'component-1',
tags: [],
tags: ['tag1'],
},
relations: [
{
@@ -83,7 +84,7 @@ describe('<UserListPicker />', () => {
metadata: {
namespace: 'namespace-2',
name: 'component-2',
tags: [],
tags: ['tag1'],
},
},
{
@@ -157,6 +158,27 @@ describe('<UserListPicker />', () => {
).toEqual(['2', '1', '4']);
});
it('respects other frontend filters in counts', () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
filters: { tags: new EntityTagFilter(['tag1']) },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(
getAllByRole('menuitem').map(
({ nextSibling }) => nextSibling?.textContent,
),
).toEqual(['1', '0', '2']);
});
it('updates user filter when a menuitem is selected', () => {
const updateFilters = jest.fn();
const { getByText } = render(
@@ -14,18 +14,11 @@
* limitations under the License.
*/
import React, { Fragment } from 'react';
import React, { Fragment, useEffect, useState } from 'react';
import { compact } from 'lodash';
import { configApiRef, IconComponent, useApi } from '@backstage/core';
import {
FilterEnvironment,
UserListFilter,
UserListFilterKind,
} from '../../types';
import {
useEntityListProvider,
useOwnUser,
useStarredEntities,
} from '../../hooks';
import { UserListFilter, UserListFilterKind } from '../../types';
import { useEntityListProvider } from '../../hooks';
import {
Card,
List,
@@ -39,6 +32,7 @@ import {
} from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { reduceEntityFilters } from '../../utils';
const useStyles = makeStyles<Theme>(theme => ({
root: {
@@ -62,9 +56,6 @@ const useStyles = makeStyles<Theme>(theme => ({
groupWrapper: {
margin: theme.spacing(1, 1, 2, 1),
},
menuTitle: {
fontWeight: 500,
},
}));
export type ButtonGroup = {
@@ -115,19 +106,25 @@ export const UserListPicker = () => {
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
const filterGroups = getFilterGroups(orgName);
// Unfortunate FilterEnvironment duplication for static filters used for counts
const { value: user } = useOwnUser();
const { isStarredEntity } = useStarredEntities();
const filterEnv: FilterEnvironment = {
user: user,
isStarredEntity: isStarredEntity,
};
const {
filters: { user: userFilter },
filters,
updateFilters,
backendEntities,
filterEnv,
} = useEntityListProvider();
// To show proper counts for each section, apply all other frontend filters _except_ the user
// filter that's controlled by this picker.
const [entitiesWithoutUserFilter, setEntitiesWithoutUserFilter] = useState(
backendEntities,
);
useEffect(() => {
const filterFn = reduceEntityFilters(
compact(Object.values({ ...filters, user: undefined })),
filterEnv,
);
setEntitiesWithoutUserFilter(backendEntities.filter(filterFn));
}, [filters, backendEntities, filterEnv]);
function setSelectedFilter({ id }: { id: UserListFilterKind }) {
updateFilters({ user: new UserListFilter(id) });
}
@@ -135,15 +132,15 @@ export const UserListPicker = () => {
function getFilterCount(id: UserListFilterKind) {
switch (id) {
case 'owned':
return backendEntities.filter(entity =>
return entitiesWithoutUserFilter.filter(entity =>
ownedFilter.filterEntity(entity, filterEnv),
).length;
case 'starred':
return backendEntities.filter(entity =>
return entitiesWithoutUserFilter.filter(entity =>
starredFilter.filterEntity(entity, filterEnv),
).length;
default:
return backendEntities.length;
return entitiesWithoutUserFilter.length;
}
}
@@ -162,7 +159,7 @@ export const UserListPicker = () => {
button
divider
onClick={() => setSelectedFilter(item)}
selected={item.id === userFilter?.value}
selected={item.id === filters.user?.value}
className={classes.menuItem}
>
{item.icon && (
@@ -173,7 +170,6 @@ export const UserListPicker = () => {
<ListItemText>
<Typography
variant="body1"
className={classes.menuTitle}
data-testid={`user-picker-${item.id}`}
>
{item.label}
@@ -56,6 +56,12 @@ export type EntityListContextProps<
*/
filters: EntityFilters;
/**
* The filter environment passed to frontend filters; contains information available in the React
* tree.
*/
filterEnv: FilterEnvironment;
/**
* The resolved list of catalog entities, after all filters are applied.
*/
@@ -171,6 +177,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
<EntityListContext.Provider
value={{
filters,
filterEnv,
entities,
backendEntities,
updateFilters,
@@ -15,6 +15,7 @@
*/
import React, { PropsWithChildren } from 'react';
import { useOwnUser, useStarredEntities } from '../hooks';
import {
EntityListContext,
EntityListContextProps,
@@ -24,11 +25,17 @@ export const MockEntityListContextProvider = ({
children,
value,
}: PropsWithChildren<{ value: Partial<EntityListContextProps> }>) => {
const { value: user } = useOwnUser();
const { isStarredEntity } = useStarredEntities();
const defaultContext: EntityListContextProps = {
entities: [],
backendEntities: [],
updateFilters: jest.fn(),
filters: {},
filterEnv: {
user,
isStarredEntity,
},
loading: false,
};