From 8cea525a32e7e3b3a7927a3682a4739a411375c8 Mon Sep 17 00:00:00 2001 From: Oscar Hernandez Date: Tue, 9 Feb 2021 17:21:22 -0600 Subject: [PATCH] Scaffolder: Filter/Search cleanup --- .../ResultsFilter/ResultsFilter.test.tsx | 12 +- .../ScaffolderPage/ScaffolderPage.tsx | 114 +++++------------- .../SearchToolbar/SearchToolbar.test.tsx | 33 +++++ .../SearchToolbar/SearchToolbar.tsx | 74 ++++++++++++ .../src/filter/EntityFilterGroupsProvider.tsx | 20 +-- plugins/scaffolder/src/filter/context.ts | 4 +- .../src/filter/useFilteredEntities.ts | 2 +- 7 files changed, 155 insertions(+), 104 deletions(-) create mode 100644 plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.test.tsx create mode 100644 plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.tsx diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx index 9be1c995e8..22de59b6ef 100644 --- a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -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( - , + , ); - 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(); } }); }); diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 1184c44263..eba8a1bd48 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -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(); - - 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( () => [ { @@ -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 ( @@ -183,45 +152,16 @@ export const ScaffolderPageContents = () => {
- - - setSearch(event.target.value)} - value={search} - startAdornment={ - - - - } - endAdornment={ - - setSearch('')} - edge="end" - disabled={search.length === 0} - > - - - - } - /> - - - + setSelectedSidebarItem(label)} // TODO: setSelecteSidebarItem + buttonGroups={filterGroups} initiallySelected="all" />
- {!filteredTemplates && loading && } - {filteredTemplates && !filteredTemplates.length && ( + {!matchingEntities && loading && } + {matchingEntities && !matchingEntities.length && ( Shoot! Looks like you don't have any templates. Check out the documentation{' '} @@ -237,9 +177,9 @@ export const ScaffolderPageContents = () => { )} - {filteredTemplates && - filteredTemplates?.length > 0 && - filteredTemplates.map(template => { + {matchingEntities && + matchingEntities?.length > 0 && + matchingEntities.map(template => { return ( { + it('should display search value and execute set callback', async () => { + const setSearchSpy = jest.fn(); + const { getByDisplayValue } = render( + , + ); + + const searchInput = getByDisplayValue('hello'); + expect(searchInput).toBeInTheDocument(); + fireEvent.change(searchInput, { target: { value: 'world' } }); + expect(setSearchSpy).toHaveBeenCalled(); + }); +}); diff --git a/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.tsx b/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.tsx new file mode 100644 index 0000000000..4a50fcb257 --- /dev/null +++ b/plugins/scaffolder/src/components/SearchToolbar/SearchToolbar.tsx @@ -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 ( + + + setSearch(event.target.value)} + value={search} + startAdornment={ + + + + } + endAdornment={ + + setSearch('')} + edge="end" + disabled={search.length === 0} + > + + + + } + /> + + + ); +}; + +export default SearchToolbar; diff --git a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx index 1e5474f327..ba99a0294a 100644 --- a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx @@ -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([]); + const [filteredEntities, setFilteredEntities] = useState< + TemplateEntityV1alpha1[] + >([]); const [availableCategories, setAvailableCategories] = useState([]); const [isCatalogEmpty, setCatalogEmpty] = useState(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 }, 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(); (entities || []).forEach(e => { if (e.spec?.type) { @@ -221,9 +223,9 @@ function buildMatchingEntities( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, 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)) { diff --git a/plugins/scaffolder/src/filter/context.ts b/plugins/scaffolder/src/filter/context.ts index e46ebb5507..f0a9d3755e 100644 --- a/plugins/scaffolder/src/filter/context.ts +++ b/plugins/scaffolder/src/filter/context.ts @@ -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; }; diff --git a/plugins/scaffolder/src/filter/useFilteredEntities.ts b/plugins/scaffolder/src/filter/useFilteredEntities.ts index cc2c739546..d3eb553687 100644 --- a/plugins/scaffolder/src/filter/useFilteredEntities.ts +++ b/plugins/scaffolder/src/filter/useFilteredEntities.ts @@ -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,