feat(catalog): add filter by tags in the catalog
This commit is contained in:
@@ -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))) ?? [];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user