Scaffolder: Filter/Search cleanup
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user