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:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user