Scaffolder: Filter/Search cleanup

This commit is contained in:
Oscar Hernandez
2021-02-09 17:21:22 -06:00
parent 44432a08b5
commit 8cea525a32
7 changed files with 155 additions and 104 deletions
@@ -93,13 +93,15 @@ describe('Results Filter', () => {
),
);
it('should render all available tags', async () => {
const tags = ['test', 'java'];
it('should render all available categories', async () => {
const categories = ['test', 'java'];
const { findByText } = renderWrapped(
<ResultsFilter availableTags={tags} />,
<ResultsFilter availableCategories={categories} />,
);
for (const tag of tags) {
expect(await findByText(tag)).toBeInTheDocument();
for (const category of categories) {
expect(
await findByText(category.charAt(0).toUpperCase() + category.slice(1)),
).toBeInTheDocument();
}
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { TemplateEntityV1alpha1, Entity } from '@backstage/catalog-model';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
configApiRef,
Content,
@@ -27,19 +27,7 @@ import {
useApi,
WarningPanel,
} from '@backstage/core';
import { isOwnerOf } from '@backstage/plugin-catalog-react';
import {
Button,
FormControl,
Grid,
IconButton,
Input,
InputAdornment,
Link,
makeStyles,
Toolbar,
Typography,
} from '@material-ui/core';
import { Button, Grid, Link, makeStyles, Typography } from '@material-ui/core';
import React, { useEffect, useMemo, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
@@ -47,12 +35,10 @@ import { TemplateCard, TemplateCardProps } from '../TemplateCard';
import { ResultsFilter } from '../ResultsFilter/ResultsFilter';
import { ScaffolderFilter } from '../ScaffolderFilter';
import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { useOwnUser } from '../useOwnUser';
import { useStarredEntities } from '../../hooks/useStarredEntities';
import Search from '@material-ui/icons/Search';
import Clear from '@material-ui/icons/Clear';
import SearchToolbar from '../SearchToolbar/SearchToolbar';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -61,21 +47,16 @@ const useStyles = makeStyles(theme => ({
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
searchToolbar: {
paddingLeft: 0,
paddingRight: 0,
},
}));
const getTemplateCardProps = (
template: Entity,
template: TemplateEntityV1alpha1,
): TemplateCardProps & { key: string } => {
return {
key: template.metadata.uid!,
name: template.metadata.name,
title: `${(template.metadata.title || template.metadata.name) ?? ''}`,
// TODO: Validate this prop (I changed TemplateEntityV1alpha1 to Entity interface) Remove 'as any'
type: (template as any).spec.type ?? '',
type: template.spec.type ?? '',
description: template.metadata.description ?? '-',
tags: (template.metadata?.tags as string[]) ?? [],
};
@@ -86,22 +67,12 @@ export const ScaffolderPageContents = () => {
const {
loading,
error,
reload, // TODO: Configure reload
matchingEntities,
availableCategories, // TODO: Change tags to Categories
isCatalogEmpty,
filteredEntities,
availableCategories,
} = useFilteredEntities();
// TODO: use Selected Sidebar Item
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
const { value: user } = useOwnUser();
const configApi = useApi(configApiRef);
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
const { isStarredEntity } = useStarredEntities();
// TODO: ButtonGroup from CatalogFilter?
const filterGroups = useMemo<ButtonGroup[]>(
() => [
{
@@ -115,14 +86,8 @@ export const ScaffolderPageContents = () => {
],
},
{
name: 'Personal', // TODO: Do we need owner?
name: 'Personal',
items: [
{
id: 'owned',
label: 'Owned',
icon: SettingsIcon,
filterFn: entity => user !== undefined && isOwnerOf(user, entity),
},
{
id: 'starred',
label: 'Starred',
@@ -132,26 +97,30 @@ export const ScaffolderPageContents = () => {
],
},
],
[isStarredEntity, orgName, user],
[isStarredEntity, orgName],
);
const [search, setSearch] = useState('');
const [matchingEntities, setMatchingEntities] = useState(
[] as TemplateEntityV1alpha1[],
);
const [search, setSearch] = useState('');
const [filteredTemplates, setFilteredTemplates] = useState([] as Entity[]); // TODO: Should I use Entity?
// Match templates by search input value
useEffect(() => {
const searchUppercase = search.toUpperCase();
if (search.length === 0) {
return setFilteredTemplates(matchingEntities);
return setMatchingEntities(filteredEntities);
}
return setFilteredTemplates(
matchingEntities.filter(template => {
// Match search by title|tags
return setMatchingEntities(
filteredEntities.filter(template => {
const { title, tags } = template.metadata;
return (
`${title}`.toUpperCase().indexOf(searchUppercase) !== -1 ||
`${tags}`.toUpperCase().indexOf(searchUppercase) !== -1
tags?.join('').toUpperCase().indexOf(searchUppercase) !== -1
);
}),
);
}, [search, matchingEntities]);
}, [search, filteredEntities]);
return (
<Page themeId="home">
@@ -183,45 +152,16 @@ export const ScaffolderPageContents = () => {
<div className={styles.contentWrapper}>
<div>
<Toolbar className={styles.searchToolbar}>
<FormControl>
<Input
id="input-with-icon-adornment"
placeholder="Search"
autoComplete="off"
onChange={event => setSearch(event.target.value)}
value={search}
startAdornment={
<InputAdornment position="start">
<Search />
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="clear search"
onClick={() => setSearch('')}
edge="end"
disabled={search.length === 0}
>
<Clear />
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Toolbar>
<SearchToolbar search={search} setSearch={setSearch} />
<ScaffolderFilter
buttonGroups={filterGroups} // TODO: filterGroups??
onChange={({ label }) => setSelectedSidebarItem(label)} // TODO: setSelecteSidebarItem
buttonGroups={filterGroups}
initiallySelected="all"
/>
<ResultsFilter availableCategories={availableCategories} />
</div>
<div>
{!filteredTemplates && loading && <Progress />}
{filteredTemplates && !filteredTemplates.length && (
{!matchingEntities && loading && <Progress />}
{matchingEntities && !matchingEntities.length && (
<Typography variant="body2">
Shoot! Looks like you don't have any templates. Check out the
documentation{' '}
@@ -237,9 +177,9 @@ export const ScaffolderPageContents = () => {
</WarningPanel>
)}
<Grid container>
{filteredTemplates &&
filteredTemplates?.length > 0 &&
filteredTemplates.map(template => {
{matchingEntities &&
matchingEntities?.length > 0 &&
matchingEntities.map(template => {
return (
<Grid
key={template.metadata.uid}
@@ -0,0 +1,33 @@
/*
* Copyright 2021 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 React from 'react';
import { fireEvent, render } from '@testing-library/react';
import SearchToolbar from './SearchToolbar';
describe('SearchToolbar', () => {
it('should display search value and execute set callback', async () => {
const setSearchSpy = jest.fn();
const { getByDisplayValue } = render(
<SearchToolbar search="hello" setSearch={setSearchSpy} />,
);
const searchInput = getByDisplayValue('hello');
expect(searchInput).toBeInTheDocument();
fireEvent.change(searchInput, { target: { value: 'world' } });
expect(setSearchSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,74 @@
/*
* 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 React from 'react';
import {
FormControl,
InputAdornment,
makeStyles,
Toolbar,
Input,
IconButton,
} from '@material-ui/core';
import Search from '@material-ui/icons/Search';
import Clear from '@material-ui/icons/Clear';
interface Props {
search: string;
setSearch: Function;
}
const useStyles = makeStyles(_theme => ({
searchToolbar: {
paddingLeft: 0,
paddingRight: 0,
},
}));
const SearchToolbar = ({ search, setSearch }: Props) => {
const styles = useStyles();
return (
<Toolbar className={styles.searchToolbar}>
<FormControl>
<Input
id="input-with-icon-adornment"
placeholder="Search"
autoComplete="off"
onChange={event => setSearch(event.target.value)}
value={search}
startAdornment={
<InputAdornment position="start">
<Search />
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="clear search"
onClick={() => setSearch('')}
edge="end"
disabled={search.length === 0}
>
<Clear />
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Toolbar>
);
};
export default SearchToolbar;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity, TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
@@ -50,7 +50,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
const response = await catalogApi.getEntities({
filter: { kind: 'Template' },
});
return response.items;
return response.items as TemplateEntityV1alpha1[];
});
const filterGroups = useRef<{
@@ -63,7 +63,9 @@ function useProvideEntityFilters(): FilterGroupsContext {
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
const [filteredEntities, setFilteredEntities] = useState<
TemplateEntityV1alpha1[]
>([]);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [isCatalogEmpty, setCatalogEmpty] = useState<boolean>(false);
@@ -81,7 +83,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
error,
),
);
setMatchingEntities(
setFilteredEntities(
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
@@ -146,7 +148,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
loading: !error && !entities,
error,
filterGroupStates,
matchingEntities,
filteredEntities,
availableCategories,
isCatalogEmpty,
};
@@ -158,7 +160,7 @@ function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedCategories: string[],
entities?: Entity[],
entities?: TemplateEntityV1alpha1[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
// On error - all entries are an error state
@@ -205,7 +207,7 @@ function buildStates(
}
// Given all entites, find all possible categories and provide them in a sorted list.
function collectCategories(entities?: Entity[]): string[] {
function collectCategories(entities?: TemplateEntityV1alpha1[]): string[] {
const categories = new Set<string>();
(entities || []).forEach(e => {
if (e.spec?.type) {
@@ -221,9 +223,9 @@ function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedCategories: string[],
entities?: Entity[],
entities?: TemplateEntityV1alpha1[],
excludeFilterGroupId?: string,
): Entity[] {
): TemplateEntityV1alpha1[] {
// Build one filter fn per filter group
const allFilters: EntityFilterFn[] = [];
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
+2 -2
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { createContext } from 'react';
import { FilterGroup, FilterGroupStates } from './types';
@@ -31,7 +31,7 @@ export type FilterGroupsContext = {
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
matchingEntities: Entity[];
filteredEntities: TemplateEntityV1alpha1[];
availableCategories: string[];
isCatalogEmpty: boolean;
};
@@ -29,7 +29,7 @@ export function useFilteredEntities() {
return {
loading: context.loading,
error: context.error,
matchingEntities: context.matchingEntities,
filteredEntities: context.filteredEntities,
availableCategories: context.availableCategories,
isCatalogEmpty: context.isCatalogEmpty,
reload: context.reload,