feat(catalog): add filter by tags in the catalog

This commit is contained in:
Oliver Sand
2020-08-18 21:57:37 +02:00
parent f06588def7
commit 5e6f1c4248
6 changed files with 272 additions and 1 deletions
@@ -35,6 +35,7 @@ import { CatalogTable } from '../CatalogTable/CatalogTable';
import CatalogLayout from './CatalogLayout';
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
import { WelcomeBanner } from './WelcomeBanner';
import { ResultsFilter } from '../ResultsFilter/ResultsFilter';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -47,7 +48,12 @@ const useStyles = makeStyles(theme => ({
const CatalogPageContents = () => {
const styles = useStyles();
const { loading, error, matchingEntities } = useFilteredEntities();
const {
loading,
error,
matchingEntities,
availableTags,
} = useFilteredEntities();
const { isStarredEntity } = useStarredEntities();
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
@@ -140,6 +146,7 @@ const CatalogPageContents = () => {
onChange={({ label }) => setSelectedSidebarItem(label)}
initiallySelected="owned"
/>
<ResultsFilter availableTags={availableTags} />
</div>
<CatalogTable
titlePreamble={selectedSidebarItem ?? ''}
@@ -0,0 +1,102 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityFilterGroupsProvider } from '../../filter';
import { ResultsFilter } from './ResultsFilter';
describe('Results Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
] as Entity[]),
};
const indentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render all available tags', async () => {
const tags = ['test', 'java'];
const { findByText } = renderWrapped(
<ResultsFilter availableTags={tags} />,
);
for (const tag of tags) {
expect(await findByText(tag)).toBeInTheDocument();
}
});
});
@@ -0,0 +1,120 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
Button,
Checkbox,
Divider,
List,
ListItem,
ListItemText,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import React, { useCallback, useContext, useState } from 'react';
import { filterGroupsContext } from '../../filter/context';
const useStyles = makeStyles<Theme>(theme => ({
filterBox: {
display: 'flex',
margin: theme.spacing(2, 0, 0, 0),
},
filterBoxTitle: {
margin: theme.spacing(1, 0, 0, 1),
fontWeight: 'bold',
flex: 1,
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
checkbox: {
padding: theme.spacing(0, 1, 0, 1),
},
}));
type Props = {
availableTags: string[];
};
/**
* The additional results filter in the sidebar.
*/
export const ResultsFilter = ({ availableTags }: Props) => {
const classes = useStyles();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const setSelectedTagsFilter = context?.setSelectedTags;
const updateSelectedTags = useCallback(
(tags: string[]) => {
setSelectedTags(tags);
setSelectedTagsFilter(tags);
},
[setSelectedTags, setSelectedTagsFilter],
);
return (
<>
<div className={classes.filterBox}>
<Typography variant="subtitle2" className={classes.filterBoxTitle}>
Refine Results
</Typography>{' '}
<Button onClick={() => updateSelectedTags([])}>Clear</Button>
</div>
<Divider />
<Typography variant="subtitle2" className={classes.title}>
Tags
</Typography>
<List disablePadding dense>
{availableTags.map(t => {
const labelId = `checkbox-list-label-${t}`;
return (
<ListItem
key={t}
dense
button
onClick={() =>
updateSelectedTags(
selectedTags.includes(t)
? selectedTags.filter(s => s !== t)
: [...selectedTags, t],
)
}
>
<Checkbox
edge="start"
checked={selectedTags.includes(t)}
tabIndex={-1}
disableRipple
className={classes.checkbox}
inputProps={{ 'aria-labelledby': labelId }}
/>
<ListItemText id={labelId} primary={t} />
</ListItem>
);
})}
</List>
</>
);
};
@@ -54,16 +54,19 @@ function useProvideEntityFilters(): FilterGroupsContext {
const selectedFilterKeys = useRef<{
[filterGroupId: string]: Set<string>;
}>({});
const selectedTags = useRef<string[]>([]);
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const rebuild = useCallback(() => {
setFilterGroupStates(
buildStates(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
error,
),
@@ -72,9 +75,11 @@ function useProvideEntityFilters(): FilterGroupsContext {
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
),
);
setAvailableTags(collectTags(entities));
}, [entities, error]);
const register = useCallback(
@@ -109,14 +114,24 @@ function useProvideEntityFilters(): FilterGroupsContext {
[rebuild],
);
const setSelectedTags = useCallback(
(tags: string[]) => {
selectedTags.current = tags;
rebuild();
},
[rebuild],
);
return {
register,
unregister,
setGroupSelectedFilters,
setSelectedTags,
loading: !error && !entities,
error,
filterGroupStates,
matchingEntities,
availableTags,
};
}
@@ -125,6 +140,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
@@ -153,6 +169,7 @@ function buildStates(
const otherMatchingEntities = buildMatchingEntities(
filterGroups,
selectedFilterKeys,
selectedTags,
entities,
filterGroupId,
);
@@ -170,11 +187,23 @@ function buildStates(
return result;
}
// Given all entites, find all possible tags and provide them in a sorted list.
function collectTags(entities?: Entity[]): string[] {
const tags = new Set<string>();
(entities || []).forEach(e => {
if (e.metadata.tags) {
e.metadata.tags.forEach(t => tags.add(t));
}
});
return Array.from(tags).sort();
}
// Given all filter groups and what filters are actually selected, extract all
// entities that match all those filter groups.
function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
excludeFilterGroupId?: string,
): Entity[] {
@@ -201,6 +230,16 @@ function buildMatchingEntities(
}
}
// Filter by tags, if at least one tag is selected. Include all entities
// that have at least one of the selected tags
if (selectedTags.length > 0) {
allFilters.push(
entity =>
!!entity.metadata.tags &&
entity.metadata.tags.some(t => selectedTags.includes(t)),
);
}
// All filter groups that had any checked filters need to match. Note that
// every() always returns true for an empty array.
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
+2
View File
@@ -26,10 +26,12 @@ export type FilterGroupsContext = {
) => void;
unregister: (filterGroupId: string) => void;
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
setSelectedTags: (tags: string[]) => void;
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
matchingEntities: Entity[];
availableTags: string[];
};
/**
@@ -30,5 +30,6 @@ export function useFilteredEntities() {
loading: context.loading,
error: context.error,
matchingEntities: context.matchingEntities,
availableTags: context.availableTags,
};
}