From 915be577b04cd425da2aee46c29f3e301d7d8695 Mon Sep 17 00:00:00 2001 From: Oscar Hernandez Date: Fri, 5 Feb 2021 16:44:38 -0600 Subject: [PATCH 1/7] Copy Catalog page Filter to Scaffolder page --- plugins/scaffolder/package.json | 1 + .../CatalogFilter/AllServicesCount.tsx | 33 +++ .../CatalogFilter/CatalogFilter.test.tsx | 264 ++++++++++++++++++ .../CatalogFilter/CatalogFilter.tsx | 211 ++++++++++++++ .../src/components/CatalogFilter/index.ts | 17 ++ .../ResultsFilter/ResultsFilter.test.tsx | 105 +++++++ .../ResultsFilter/ResultsFilter.tsx | 121 ++++++++ .../ScaffolderPage/ScaffolderPage.tsx | 182 +++++++++--- .../scaffolder/src/components/useOwnUser.ts | 41 +++ .../src/filter/EntityFilterGroupsProvider.tsx | 263 +++++++++++++++++ plugins/scaffolder/src/filter/context.ts | 44 +++ plugins/scaffolder/src/filter/index.ts | 28 ++ plugins/scaffolder/src/filter/types.ts | 53 ++++ .../src/filter/useEntityFilterGroup.test.tsx | 120 ++++++++ .../src/filter/useEntityFilterGroup.ts | 69 +++++ .../src/filter/useFilteredEntities.ts | 37 +++ .../src/hooks/useStarredEntities.test.tsx | 121 ++++++++ .../src/hooks/useStarredEntities.ts | 75 +++++ 18 files changed, 1741 insertions(+), 44 deletions(-) create mode 100644 plugins/scaffolder/src/components/CatalogFilter/AllServicesCount.tsx create mode 100644 plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.test.tsx create mode 100644 plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.tsx create mode 100644 plugins/scaffolder/src/components/CatalogFilter/index.ts create mode 100644 plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx create mode 100644 plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx create mode 100644 plugins/scaffolder/src/components/useOwnUser.ts create mode 100644 plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx create mode 100644 plugins/scaffolder/src/filter/context.ts create mode 100644 plugins/scaffolder/src/filter/index.ts create mode 100644 plugins/scaffolder/src/filter/types.ts create mode 100644 plugins/scaffolder/src/filter/useEntityFilterGroup.test.tsx create mode 100644 plugins/scaffolder/src/filter/useEntityFilterGroup.ts create mode 100644 plugins/scaffolder/src/filter/useFilteredEntities.ts create mode 100644 plugins/scaffolder/src/hooks/useStarredEntities.test.tsx create mode 100644 plugins/scaffolder/src/hooks/useStarredEntities.ts diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 56beb7b10b..5112aeccf7 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -58,6 +58,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", + "@testing-library/react-hooks": "^3.3.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "cross-fetch": "^3.0.6", diff --git a/plugins/scaffolder/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/scaffolder/src/components/CatalogFilter/AllServicesCount.tsx new file mode 100644 index 0000000000..efacfa4320 --- /dev/null +++ b/plugins/scaffolder/src/components/CatalogFilter/AllServicesCount.tsx @@ -0,0 +1,33 @@ +/* + * 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 { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { CircularProgress, useTheme } from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; + +export const AllServicesCount = () => { + const theme = useTheme(); + const catalogApi = useApi(catalogApiRef); + const { value, loading } = useAsync(() => catalogApi.getEntities()); + + if (loading) { + return ; + } + + return {value ?? length ?? '-'}; +}; diff --git a/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.test.tsx new file mode 100644 index 0000000000..0cc2108985 --- /dev/null +++ b/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -0,0 +1,264 @@ +/* + * 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 { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { ButtonGroup, CatalogFilter } from './CatalogFilter'; + +describe('Catalog Filter', () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + }, + ] as Entity[], + }), + }; + + const identityApi: Partial = { + getUserId: () => 'tools@example.com', + }; + + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + + it('should render the different groups', async () => { + const mockGroups: ButtonGroup[] = [ + { name: 'Test Group 1', items: [] }, + { name: 'Test Group 2', items: [] }, + ]; + const { findByText } = renderWrapped( + , + ); + for (const group of mockGroups) { + expect(await findByText(group.name)).toBeInTheDocument(); + } + }); + + it('should render the different items and their names', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + for (const item of mockGroups[0].items) { + expect(await findByText(item.label)).toBeInTheDocument(); + } + }); + + it('selects the first item if no desired initial one is set', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const onChange = jest.fn(); + + renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); + }); + + it('selects the initial item', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const onChange = jest.fn(); + + renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); + }); + + it('can change the selected item', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; + + const onChange = jest.fn(); + + const { findByText } = renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); + + fireEvent.click(await findByText('Second Label')); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); + }); + + it('displays match counts properly', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'owned', + label: 'First Label', + filterFn: entity => entity.spec?.owner === 'tools@example.com', + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + expect(await findByText('1')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.tsx new file mode 100644 index 0000000000..90a1e15ad0 --- /dev/null +++ b/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.tsx @@ -0,0 +1,211 @@ +/* + * 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 { IconComponent } from '@backstage/core'; +import { + Card, + List, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + makeStyles, + MenuItem, + Theme, + Typography, +} from '@material-ui/core'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; + +export type ButtonGroup = { + name: string; + items: { + id: string; + label: string; + icon?: IconComponent; + filterFn: (entity: Entity) => boolean; + }[]; +}; + +const useStyles = makeStyles(theme => ({ + root: { + backgroundColor: 'rgba(0, 0, 0, .11)', + boxShadow: 'none', + }, + title: { + margin: theme.spacing(1, 0, 0, 1), + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + }, + listIcon: { + minWidth: 30, + color: theme.palette.text.primary, + }, + menuItem: { + minHeight: theme.spacing(6), + }, + groupWrapper: { + margin: theme.spacing(1, 1, 2, 1), + }, + menuTitle: { + fontWeight: 500, + }, +})); + +type OnChangeCallback = (item: { id: string; label: string }) => void; + +type Props = { + buttonGroups: ButtonGroup[]; + initiallySelected: string; + onChange?: OnChangeCallback; +}; + +/** + * The main filter group in the sidebar, toggling owned/starred/all. + */ +export const CatalogFilter = ({ + buttonGroups, + onChange, + initiallySelected, +}: Props) => { + const classes = useStyles(); + const { currentFilter, setCurrentFilter, getFilterCount } = useFilter( + buttonGroups, + initiallySelected, + ); + + const onChangeRef = useRef(); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + const setCurrent = useCallback( + (item: { id: string; label: string }) => { + setCurrentFilter(item.id); + onChangeRef.current?.({ id: item.id, label: item.label }); + }, + [setCurrentFilter], + ); + + // Make one initial onChange to inform the surroundings about the selected + // item + useEffect(() => { + const items = buttonGroups.flatMap(g => g.items); + const item = items.find(i => i.id === initiallySelected) || items[0]; + if (item) { + onChangeRef.current?.({ id: item.id, label: item.label }); + } + // intentionally only happens on startup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {buttonGroups.map(group => ( + + + {group.name} + + + + {group.items.map(item => ( + setCurrent(item)} + selected={item.id === currentFilter} + className={classes.menuItem} + > + {item.icon && ( + + + + )} + + + {item.label} + + + + {getFilterCount(item.id) ?? '-'} + + + ))} + + + + ))} + + ); +}; + +function useFilter( + buttonGroups: ButtonGroup[], + initiallySelected: string, +): { + currentFilter: string; + setCurrentFilter: (filterId: string) => void; + getFilterCount: (filterId: string) => number | undefined; +} { + const [currentFilter, setCurrentFilter] = useState(initiallySelected); + + const filterGroup = useMemo( + () => ({ + filters: Object.fromEntries( + buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]), + ), + }), + [buttonGroups], + ); + + const { setSelectedFilters, state } = useEntityFilterGroup( + 'primary-sidebar', + filterGroup, + [initiallySelected], + ); + + const setCurrent = useCallback( + (filterId: string) => { + setCurrentFilter(filterId); + setSelectedFilters([filterId]); + }, + [setCurrentFilter, setSelectedFilters], + ); + + const getFilterCount = useCallback( + (filterId: string) => { + if (state.type !== 'ready') { + return undefined; + } + return state.state.filters[filterId].matchCount; + }, + [state], + ); + + return { + currentFilter, + setCurrentFilter: setCurrent, + getFilterCount, + }; +} diff --git a/plugins/scaffolder/src/components/CatalogFilter/index.ts b/plugins/scaffolder/src/components/CatalogFilter/index.ts new file mode 100644 index 0000000000..5103b16307 --- /dev/null +++ b/plugins/scaffolder/src/components/CatalogFilter/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { CatalogFilter } from './CatalogFilter'; diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx new file mode 100644 index 0000000000..9be1c995e8 --- /dev/null +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { ResultsFilter } from './ResultsFilter'; + +describe('Results Filter', () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + 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 identityApi: Partial = { + getUserId: () => 'tools@example.com', + }; + + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + + it('should render all available tags', async () => { + const tags = ['test', 'java']; + const { findByText } = renderWrapped( + , + ); + for (const tag of tags) { + expect(await findByText(tag)).toBeInTheDocument(); + } + }); +}); diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx new file mode 100644 index 0000000000..8c5737b7b6 --- /dev/null +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx @@ -0,0 +1,121 @@ +/* + * 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 => ({ + 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([]); + 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 ( + <> +
+ + Refine Results + {' '} + +
+ + + Tags + + + {availableTags.map(t => { + const labelId = `checkbox-list-label-${t}`; + return ( + + updateSelectedTags( + selectedTags.includes(t) + ? selectedTags.filter(s => s !== t) + : [...selectedTags, t], + ) + } + > + + + + ); + })} + + + ); +}; diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 75ee2d07df..3a8701253d 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntityV1alpha1, Entity } from '@backstage/catalog-model'; import { + configApiRef, Content, ContentHeader, errorApiRef, @@ -27,45 +28,112 @@ import { useApi, WarningPanel, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { Button, Grid, Link, Typography } from '@material-ui/core'; -import React, { useEffect } from 'react'; +import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; +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 useStaleWhileRevalidate from 'swr'; +import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { TemplateCard, TemplateCardProps } from '../TemplateCard'; +import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; +import { CatalogFilter } from '../CatalogFilter'; +import { ButtonGroup } from '../CatalogFilter/CatalogFilter'; +import SettingsIcon from '@material-ui/icons/Settings'; +import StarIcon from '@material-ui/icons/Star'; +import { useOwnUser } from '../useOwnUser'; +import { useStarredEntities } from '../../hooks/useStarredEntities'; + +const useStyles = makeStyles(theme => ({ + contentWrapper: { + display: 'grid', + gridTemplateAreas: "'filters' 'grid'", + gridTemplateColumns: '250px 1fr', + gridColumnGap: theme.spacing(2), + }, +})); const getTemplateCardProps = ( - template: TemplateEntityV1alpha1, + template: Entity, ): TemplateCardProps & { key: string } => { return { key: template.metadata.uid!, name: template.metadata.name, title: `${(template.metadata.title || template.metadata.name) ?? ''}`, - type: template.spec.type ?? '', + // TODO: Validate this prop (I changed TemplateEntityV1alpha1 to Entity interface) Remove 'as any' + type: (template as any).spec.type ?? '', description: template.metadata.description ?? '-', tags: (template.metadata?.tags as string[]) ?? [], }; }; -export const ScaffolderPage = () => { +export const ScaffolderPageContents = () => { + const styles = useStyles(); + const { + loading, + error, + reload, + matchingEntities, + availableTags, // TODO: Change tags to Categories + isCatalogEmpty, + } = useFilteredEntities(); + // TODO: use Selected Sidebar Item + const [selectedSidebarItem, setSelectedSidebarItem] = useState(); const catalogApi = useApi(catalogApiRef); const errorApi = useApi(errorApiRef); - - const { data: templates, isValidating, error } = useStaleWhileRevalidate( - 'templates/all', - async () => { - const response = await catalogApi.getEntities({ - filter: { kind: 'Template' }, - }); - return response.items as TemplateEntityV1alpha1[]; - }, - ); - + const { + data: templates /* isValidating*/ /* , error */, + } = useStaleWhileRevalidate('templates/all', async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Template' }, + }); + return response.items as TemplateEntityV1alpha1[]; + }); useEffect(() => { if (!error) return; errorApi.post(error); }, [error, errorApi]); + 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( + () => [ + { + name: 'Personal', // TODO: Do we need owner? + items: [ + { + id: 'owned', + label: 'Owned', + icon: SettingsIcon, + filterFn: entity => user !== undefined && isOwnerOf(user, entity), + }, + { + id: 'starred', + label: 'Starred', + icon: StarIcon, + filterFn: isStarredEntity, + }, + ], + }, + { + name: orgName, + items: [ + { + id: 'all', + label: 'All', + filterFn: () => true, + }, + ], + }, + ], + [isStarredEntity, orgName, user], + ); + return (
{ documentation, ...). - {!templates && isValidating && } - {templates && !templates.length && ( - - Shoot! Looks like you don't have any templates. Check out the - documentation{' '} - - here! - - - )} - {error && ( - - Oops! Something went wrong loading the templates: {error.message} - - )} - - {templates && - templates?.length > 0 && - templates.map(template => { - return ( - - - - ); - })} - + +
+
+ setSelectedSidebarItem(label)} // TODO: setSelecteSidebarItem + initiallySelected="owned" + /> + +
+
+ {!matchingEntities && loading && } + {matchingEntities && !matchingEntities.length && ( + + Shoot! Looks like you don't have any templates. Check out the + documentation{' '} + + here! + + + )} + {error && ( + + Oops! Something went wrong loading the templates:{' '} + {error.message} + + )} + + {matchingEntities && + matchingEntities?.length > 0 && + matchingEntities.map(template => { + return ( + + + + ); + })} + +
+
); }; + +export const ScaffolderPage = () => ( + + + +); diff --git a/plugins/scaffolder/src/components/useOwnUser.ts b/plugins/scaffolder/src/components/useOwnUser.ts new file mode 100644 index 0000000000..29d8a0d11f --- /dev/null +++ b/plugins/scaffolder/src/components/useOwnUser.ts @@ -0,0 +1,41 @@ +/* + * 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 { UserEntity } from '@backstage/catalog-model'; +import { identityApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { useAsync } from 'react-use'; +import { AsyncState } from 'react-use/lib/useAsync'; + +/** + * Get the catalog User entity (if any) that matches the logged-in user. + */ +export function useOwnUser(): AsyncState { + const catalogApi = useApi(catalogApiRef); + const identityApi = useApi(identityApiRef); + + // TODO: get the full entity (or at least the full entity name) from the + // identityApi + return useAsync( + () => + catalogApi.getEntityByName({ + kind: 'User', + namespace: 'default', + name: identityApi.getUserId(), + }) as Promise, + [catalogApi, identityApi], + ); +} diff --git a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx new file mode 100644 index 0000000000..92b99c74be --- /dev/null +++ b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx @@ -0,0 +1,263 @@ +/* + * 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 { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useAsyncFn } from 'react-use'; +import { filterGroupsContext, FilterGroupsContext } from './context'; +import { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, +} from './types'; + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const state = useProvideEntityFilters(); + return ( + + {children} + + ); +}; + +// The hook that implements the actual context building +function useProvideEntityFilters(): FilterGroupsContext { + const catalogApi = useApi(catalogApiRef); + const [{ value: entities, error }, doReload] = useAsyncFn(async () => { + const response = await catalogApi.getEntities({ + filter: { kind: 'Template' }, + }); + return response.items; + }); + + const filterGroups = useRef<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const selectedFilterKeys = useRef<{ + [filterGroupId: string]: Set; + }>({}); + const selectedTags = useRef([]); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [matchingEntities, setMatchingEntities] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + const [isCatalogEmpty, setCatalogEmpty] = useState(false); + + useEffect(() => { + doReload(); + }, [doReload]); + + const rebuild = useCallback(() => { + setFilterGroupStates( + buildStates( + filterGroups.current, + selectedFilterKeys.current, + selectedTags.current, + entities, + error, + ), + ); + setMatchingEntities( + buildMatchingEntities( + filterGroups.current, + selectedFilterKeys.current, + selectedTags.current, + entities, + ), + ); + setAvailableTags(collectTags(entities)); + setCatalogEmpty(entities !== undefined && entities.length === 0); + }, [entities, error]); + + const register = useCallback( + ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => { + filterGroups.current[filterGroupId] = filterGroup; + selectedFilterKeys.current[filterGroupId] = new Set( + initialSelectedFilterIds ?? [], + ); + rebuild(); + }, + [rebuild], + ); + + const unregister = useCallback( + (filterGroupId: string) => { + delete filterGroups.current[filterGroupId]; + delete selectedFilterKeys.current[filterGroupId]; + rebuild(); + }, + [rebuild], + ); + + const setGroupSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + selectedFilterKeys.current[filterGroupId] = new Set(filters); + rebuild(); + }, + [rebuild], + ); + + const setSelectedTags = useCallback( + (tags: string[]) => { + selectedTags.current = tags; + rebuild(); + }, + [rebuild], + ); + + const reload = useCallback(async () => { + await doReload(); + }, [doReload]); + + return { + register, + unregister, + setGroupSelectedFilters, + setSelectedTags, + reload, + loading: !error && !entities, + error, + filterGroupStates, + matchingEntities, + availableTags, + isCatalogEmpty, + }; +} + +// Given all filter groups and what filters are actually selected, along with +// the loading state for entities, generate the state of each individual filter +function buildStates( + filterGroups: { [filterGroupId: string]: FilterGroup }, + selectedFilterKeys: { [filterGroupId: string]: Set }, + selectedTags: string[], + entities?: Entity[], + error?: Error, +): { [filterGroupId: string]: FilterGroupStates } { + // On error - all entries are an error state + if (error) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'error', error }, + ]), + ); + } + + // On startup - all entries are a loading state + if (!entities) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'loading' }, + ]), + ); + } + + const result: { [filterGroupId: string]: FilterGroupStates } = {}; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + const otherMatchingEntities = buildMatchingEntities( + filterGroups, + selectedFilterKeys, + selectedTags, + entities, + filterGroupId, + ); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + 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(); + (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 }, + selectedTags: string[], + entities?: Entity[], + excludeFilterGroupId?: string, +): Entity[] { + // Build one filter fn per filter group + const allFilters: EntityFilterFn[] = []; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + if (excludeFilterGroupId === filterGroupId) { + continue; + } + + // Pick out all of the filter functions in the group that are actually + // selected + const groupFilters: EntityFilterFn[] = []; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) { + groupFilters.push(filterFn); + } + } + + // Need to match any of the selected filters in the group - if there is + // any at all + if (groupFilters.length) { + allFilters.push(entity => groupFilters.some(fn => fn(entity))); + } + } + + // 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))) ?? []; +} diff --git a/plugins/scaffolder/src/filter/context.ts b/plugins/scaffolder/src/filter/context.ts new file mode 100644 index 0000000000..c025480fa6 --- /dev/null +++ b/plugins/scaffolder/src/filter/context.ts @@ -0,0 +1,44 @@ +/* + * 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 { createContext } from 'react'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type FilterGroupsContext = { + register: ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => void; + unregister: (filterGroupId: string) => void; + setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void; + setSelectedTags: (tags: string[]) => void; + reload: () => Promise; + loading: boolean; + error?: Error; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + matchingEntities: Entity[]; + availableTags: string[]; + isCatalogEmpty: boolean; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext< + FilterGroupsContext | undefined +>(undefined); diff --git a/plugins/scaffolder/src/filter/index.ts b/plugins/scaffolder/src/filter/index.ts new file mode 100644 index 0000000000..da73147ef9 --- /dev/null +++ b/plugins/scaffolder/src/filter/index.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +export type { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, + FilterGroupStatesError, + FilterGroupStatesLoading, + FilterGroupStatesReady, +} from './types'; +export { useEntityFilterGroup } from './useEntityFilterGroup'; +export { useFilteredEntities } from './useFilteredEntities'; diff --git a/plugins/scaffolder/src/filter/types.ts b/plugins/scaffolder/src/filter/types.ts new file mode 100644 index 0000000000..ed08b131bf --- /dev/null +++ b/plugins/scaffolder/src/filter/types.ts @@ -0,0 +1,53 @@ +/* + * 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'; + +export type EntityFilterFn = (entity: Entity) => boolean; + +export type FilterGroup = { + filters: { + [filterId: string]: EntityFilterFn; + }; +}; + +export type FilterGroupState = { + filters: { + [filterId: string]: { + isSelected: boolean; + matchCount: number; + }; + }; +}; + +export type FilterGroupStatesReady = { + type: 'ready'; + state: FilterGroupState; +}; + +export type FilterGroupStatesError = { + type: 'error'; + error: Error; +}; + +export type FilterGroupStatesLoading = { + type: 'loading'; +}; + +export type FilterGroupStates = + | FilterGroupStatesReady + | FilterGroupStatesError + | FilterGroupStatesLoading; diff --git a/plugins/scaffolder/src/filter/useEntityFilterGroup.test.tsx b/plugins/scaffolder/src/filter/useEntityFilterGroup.test.tsx new file mode 100644 index 0000000000..685fc751d2 --- /dev/null +++ b/plugins/scaffolder/src/filter/useEntityFilterGroup.test.tsx @@ -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 { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { MockStorageApi } from '@backstage/test-utils'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +import { FilterGroup, FilterGroupStatesReady } from './types'; +import { useEntityFilterGroup } from './useEntityFilterGroup'; + +describe('useEntityFilterGroup', () => { + let catalogApi: jest.Mocked; + let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element; + + beforeEach(() => { + catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn(_a => new Promise(() => {})), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + storageApiRef, + MockStorageApi.create(), + ); + wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + }); + + it('works for an empty set of filters', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + const group: FilterGroup = { filters: {} }; + const { result, waitFor } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await waitFor(() => expect(result.current.state.type).toBe('ready')); + }); + + it('works for a single group', async () => { + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + }, + ], + }); + const group: FilterGroup = { + filters: { + f1: e => e.metadata.name === 'n', + f2: e => e.metadata.name !== 'n', + }, + }; + const { result, waitFor } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await waitFor(() => expect(result.current.state.type).toEqual('ready')); + let state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f1'])); + + await waitFor(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: true, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f2'])); + + await waitFor(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: true, + matchCount: 0, + }); + }); +}); diff --git a/plugins/scaffolder/src/filter/useEntityFilterGroup.ts b/plugins/scaffolder/src/filter/useEntityFilterGroup.ts new file mode 100644 index 0000000000..242238e4f4 --- /dev/null +++ b/plugins/scaffolder/src/filter/useEntityFilterGroup.ts @@ -0,0 +1,69 @@ +/* + * 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 { useCallback, useContext, useEffect, useMemo } from 'react'; +import { filterGroupsContext } from './context'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type EntityFilterGroupOutput = { + state: FilterGroupStates; + setSelectedFilters: (filterIds: string[]) => void; +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilters?: string[], +): EntityFilterGroupOutput => { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + const { + register, + unregister, + setGroupSelectedFilters, + filterGroupStates, + } = context; + + // Intentionally consider initial set only at mount time + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []); + + // Register the group on mount, and unregister on unmount + useEffect(() => { + register(filterGroupId, filterGroup, initialMemo); + return () => unregister(filterGroupId); + }, [register, unregister, filterGroupId, filterGroup, initialMemo]); + + const setSelectedFilters = useCallback( + (filters: string[]) => { + setGroupSelectedFilters(filterGroupId, filters); + }, + [setGroupSelectedFilters, filterGroupId], + ); + + let state = filterGroupStates[filterGroupId]; + if (!state) { + state = { type: 'loading' }; + } + + return { state, setSelectedFilters }; +}; diff --git a/plugins/scaffolder/src/filter/useFilteredEntities.ts b/plugins/scaffolder/src/filter/useFilteredEntities.ts new file mode 100644 index 0000000000..2d7dcfd89d --- /dev/null +++ b/plugins/scaffolder/src/filter/useFilteredEntities.ts @@ -0,0 +1,37 @@ +/* + * 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 { useContext } from 'react'; +import { filterGroupsContext } from './context'; + +/** + * Hook that exposes the result of applying a set of filter groups. + */ +export function useFilteredEntities() { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + + return { + loading: context.loading, + error: context.error, + matchingEntities: context.matchingEntities, + availableTags: context.availableTags, + isCatalogEmpty: context.isCatalogEmpty, + reload: context.reload, + }; +} diff --git a/plugins/scaffolder/src/hooks/useStarredEntities.test.tsx b/plugins/scaffolder/src/hooks/useStarredEntities.test.tsx new file mode 100644 index 0000000000..78d2c4a58f --- /dev/null +++ b/plugins/scaffolder/src/hooks/useStarredEntities.test.tsx @@ -0,0 +1,121 @@ +/* + * 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, { PropsWithChildren } from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { useStarredEntities } from './useStarredEntities'; +import { + ApiProvider, + ApiRegistry, + storageApiRef, + WebStorage, + StorageApi, +} from '@backstage/core'; +import { MockErrorApi } from '@backstage/test-utils'; +import { Entity } from '@backstage/catalog-model'; + +describe('useStarredEntities', () => { + let mockStorage: StorageApi | undefined; + + const mockEntity: Entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock', + }, + }; + + const secondMockEntity: Entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'test', + name: 'mock2', + }, + }; + + const wrapper = ({ children }: PropsWithChildren<{}>) => { + return ( + + {children} + + ); + }; + + beforeEach(() => { + mockStorage = new WebStorage('@backstage', new MockErrorApi()).forBucket( + Date.now().toString(), // TODO(blam): need something that changes every test run for now until the MockStorage is implemented + ); + }); + it('should return an empty set for when there is no items in storage', async () => { + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + expect(result.current.starredEntities.size).toBe(0); + }); + it('should return a set with the current items when there is items in storage', async () => { + const expectedIds = ['i', 'am', 'some', 'test', 'ids']; + const store = mockStorage?.forBucket('settings'); + await store?.set('starredEntities', expectedIds); + + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + for (const item of expectedIds) { + expect(result.current.starredEntities.has(item)).toBeTruthy(); + } + }); + it('should listen to changes when the storage is set elsewhere', async () => { + const { result, waitForNextUpdate } = renderHook( + () => useStarredEntities(), + { wrapper }, + ); + + expect(result.current.starredEntities.size).toBe(0); + expect(result.current.isStarredEntity(mockEntity)).toBeFalsy(); + + // Make this happen after awaiting for the next update so we can + // catch when the hook re-renders with the latest data + setTimeout(() => result.current.toggleStarredEntity(mockEntity), 1); + + await waitForNextUpdate(); + + expect(result.current.starredEntities.size).toBe(1); + expect(result.current.isStarredEntity(mockEntity)).toBeTruthy(); + }); + + it('should write new entries to the local store when adding a togglging entity', async () => { + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + act(() => { + result.current.toggleStarredEntity(mockEntity); + }); + + expect(result.current.isStarredEntity(mockEntity)).toBeTruthy(); + expect(result.current.isStarredEntity(secondMockEntity)).toBeFalsy(); + }); + + it('should remove an existing entity when toggling entries', async () => { + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + act(() => { + result.current.toggleStarredEntity(mockEntity); + result.current.toggleStarredEntity(secondMockEntity); + result.current.toggleStarredEntity(mockEntity); + }); + + expect(result.current.isStarredEntity(mockEntity)).toBeFalsy(); + expect(result.current.isStarredEntity(secondMockEntity)).toBeTruthy(); + }); +}); diff --git a/plugins/scaffolder/src/hooks/useStarredEntities.ts b/plugins/scaffolder/src/hooks/useStarredEntities.ts new file mode 100644 index 0000000000..7cbbbb7ce6 --- /dev/null +++ b/plugins/scaffolder/src/hooks/useStarredEntities.ts @@ -0,0 +1,75 @@ +/* + * 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 { storageApiRef, useApi } from '@backstage/core'; +import { useCallback, useEffect, useState } from 'react'; +import { useObservable } from 'react-use'; + +const buildEntityKey = (component: Entity) => + `entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${ + component.metadata.name + }`; + +export const useStarredEntities = () => { + const storageApi = useApi(storageApiRef); + const settingsStore = storageApi.forBucket('settings'); + const rawStarredEntityKeys = + settingsStore.get('starredEntities') ?? []; + + const [starredEntities, setStarredEntities] = useState( + new Set(rawStarredEntityKeys), + ); + + const observedItems = useObservable( + settingsStore.observe$('starredEntities'), + ); + + useEffect(() => { + if (observedItems?.newValue) { + const currentValue = observedItems?.newValue ?? []; + setStarredEntities(new Set(currentValue)); + } + }, [observedItems?.newValue]); + + const toggleStarredEntity = useCallback( + (entity: Entity) => { + const entityKey = buildEntityKey(entity); + if (starredEntities.has(entityKey)) { + starredEntities.delete(entityKey); + } else { + starredEntities.add(entityKey); + } + + settingsStore.set('starredEntities', Array.from(starredEntities)); + }, + [starredEntities, settingsStore], + ); + + const isStarredEntity = useCallback( + (entity: Entity) => { + const entityKey = buildEntityKey(entity); + return starredEntities.has(entityKey); + }, + [starredEntities], + ); + + return { + starredEntities, + toggleStarredEntity, + isStarredEntity, + }; +}; From bff8620448f9e9e6167920cbc57196f44b68a669 Mon Sep 17 00:00:00 2001 From: Oscar Hernandez Date: Mon, 8 Feb 2021 15:44:40 -0600 Subject: [PATCH 2/7] Scaffolder: Implement search --- .../ScaffolderPage/ScaffolderPage.tsx | 109 +++++++++++++++--- 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 3a8701253d..59480d1aba 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -29,8 +29,27 @@ import { WarningPanel, } from '@backstage/core'; import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; -import { Button, Grid, Link, makeStyles, Typography } from '@material-ui/core'; -import React, { useEffect, useMemo, useState } from 'react'; +import { + Button, + FormControl, + Grid, + IconButton, + Input, + InputAdornment, + InputLabel, + Link, + makeStyles, + TextField, + Toolbar, + Typography, +} from '@material-ui/core'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Link as RouterLink } from 'react-router-dom'; import useStaleWhileRevalidate from 'swr'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; @@ -42,6 +61,8 @@ 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'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -50,6 +71,10 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: '250px 1fr', gridColumnGap: theme.spacing(2), }, + searchToolbar: { + paddingLeft: 0, + paddingRight: 0, + }, })); const getTemplateCardProps = ( @@ -78,6 +103,7 @@ export const ScaffolderPageContents = () => { } = useFilteredEntities(); // TODO: use Selected Sidebar Item const [selectedSidebarItem, setSelectedSidebarItem] = useState(); + const catalogApi = useApi(catalogApiRef); const errorApi = useApi(errorApiRef); const { @@ -103,6 +129,16 @@ export const ScaffolderPageContents = () => { // TODO: ButtonGroup from CatalogFilter? const filterGroups = useMemo( () => [ + { + name: orgName, + items: [ + { + id: 'all', + label: 'All', + filterFn: () => true, + }, + ], + }, { name: 'Personal', // TODO: Do we need owner? items: [ @@ -120,20 +156,28 @@ export const ScaffolderPageContents = () => { }, ], }, - { - name: orgName, - items: [ - { - id: 'all', - label: 'All', - filterFn: () => true, - }, - ], - }, ], [isStarredEntity, orgName, user], ); + const [search, setSearch] = useState(''); + const [filteredTemplates, setFilteredTemplates] = useState([] as Entity[]); // TODO: Should I use Entity? + useEffect(() => { + const searchUppercase = search.toUpperCase(); + if (search.length === 0) { + return setFilteredTemplates(matchingEntities); + } + return setFilteredTemplates( + matchingEntities.filter(template => { + const { title, tags } = template.metadata; + return ( + `${title}`.toUpperCase().indexOf(searchUppercase) !== -1 || + `${tags}`.toUpperCase().indexOf(searchUppercase) !== -1 + ); + }), + ); + }, [search, matchingEntities]); + return (
{
+ + + setSearch(event.target.value)} + value={search} + startAdornment={ + + + + } + endAdornment={ + + setSearch('')} + edge="end" + disabled={search.length === 0} + > + + + + } + /> + + + setSelectedSidebarItem(label)} // TODO: setSelecteSidebarItem - initiallySelected="owned" + initiallySelected="all" />
- {!matchingEntities && loading && } - {matchingEntities && !matchingEntities.length && ( + {!filteredTemplates && loading && } + {filteredTemplates && !filteredTemplates.length && ( Shoot! Looks like you don't have any templates. Check out the documentation{' '} @@ -189,9 +262,9 @@ export const ScaffolderPageContents = () => { )} - {matchingEntities && - matchingEntities?.length > 0 && - matchingEntities.map(template => { + {filteredTemplates && + filteredTemplates?.length > 0 && + filteredTemplates.map(template => { return ( Date: Mon, 8 Feb 2021 16:56:11 -0600 Subject: [PATCH 3/7] Scaffolder: Replace Tag Filter with Category Filter --- .../ResultsFilter/ResultsFilter.tsx | 39 +++++++------- .../AllServicesCount.tsx | 0 .../ScaffolderFilter.test.tsx} | 14 ++--- .../ScaffolderFilter.tsx} | 2 +- .../index.ts | 2 +- .../ScaffolderPage/ScaffolderPage.tsx | 41 +++------------ .../src/filter/EntityFilterGroupsProvider.tsx | 51 +++++++++---------- plugins/scaffolder/src/filter/context.ts | 4 +- .../src/filter/useFilteredEntities.ts | 2 +- 9 files changed, 65 insertions(+), 90 deletions(-) rename plugins/scaffolder/src/components/{CatalogFilter => ScaffolderFilter}/AllServicesCount.tsx (100%) rename plugins/scaffolder/src/components/{CatalogFilter/CatalogFilter.test.tsx => ScaffolderFilter/ScaffolderFilter.test.tsx} (94%) rename plugins/scaffolder/src/components/{CatalogFilter/CatalogFilter.tsx => ScaffolderFilter/ScaffolderFilter.tsx} (99%) rename plugins/scaffolder/src/components/{CatalogFilter => ScaffolderFilter}/index.ts (91%) diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx index 8c5737b7b6..5df70bd203 100644 --- a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx @@ -50,28 +50,28 @@ const useStyles = makeStyles(theme => ({ })); type Props = { - availableTags: string[]; + availableCategories: string[]; }; /** * The additional results filter in the sidebar. */ -export const ResultsFilter = ({ availableTags }: Props) => { +export const ResultsFilter = ({ availableCategories }: Props) => { const classes = useStyles(); - const [selectedTags, setSelectedTags] = useState([]); + const [selectedCategories, setSelectedCategories] = useState([]); const context = useContext(filterGroupsContext); if (!context) { throw new Error(`Must be used inside an EntityFilterGroupsProvider`); } - const setSelectedTagsFilter = context?.setSelectedTags; + const setSelectedCatgoriesFilter = context?.setSelectedCategories; - const updateSelectedTags = useCallback( - (tags: string[]) => { - setSelectedTags(tags); - setSelectedTagsFilter(tags); + const updateSelectedCategories = useCallback( + (categories: string[]) => { + setSelectedCategories(categories); + setSelectedCatgoriesFilter(categories); }, - [setSelectedTags, setSelectedTagsFilter], + [setSelectedCategories, setSelectedCatgoriesFilter], ); return ( @@ -80,14 +80,14 @@ export const ResultsFilter = ({ availableTags }: Props) => { Refine Results {' '} - +
- Tags + Categories - {availableTags.map(t => { + {availableCategories.map(t => { const labelId = `checkbox-list-label-${t}`; return ( { dense button onClick={() => - updateSelectedTags( - selectedTags.includes(t) - ? selectedTags.filter(s => s !== t) - : [...selectedTags, t], + updateSelectedCategories( + selectedCategories.includes(t) + ? selectedCategories.filter(s => s !== t) + : [...selectedCategories, t], ) } > - + ); })} diff --git a/plugins/scaffolder/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/scaffolder/src/components/ScaffolderFilter/AllServicesCount.tsx similarity index 100% rename from plugins/scaffolder/src/components/CatalogFilter/AllServicesCount.tsx rename to plugins/scaffolder/src/components/ScaffolderFilter/AllServicesCount.tsx diff --git a/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx similarity index 94% rename from plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.test.tsx rename to plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx index 0cc2108985..1c9a89c517 100644 --- a/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx @@ -28,7 +28,7 @@ import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { ButtonGroup, CatalogFilter } from './CatalogFilter'; +import { ButtonGroup, ScaffolderFilter } from './ScaffolderFilter'; describe('Catalog Filter', () => { const catalogApi: Partial = { @@ -86,7 +86,7 @@ describe('Catalog Filter', () => { { name: 'Test Group 2', items: [] }, ]; const { findByText } = renderWrapped( - , + , ); for (const group of mockGroups) { expect(await findByText(group.name)).toBeInTheDocument(); @@ -113,7 +113,7 @@ describe('Catalog Filter', () => { ]; const { findByText } = renderWrapped( - , + , ); for (const item of mockGroups[0].items) { @@ -143,7 +143,7 @@ describe('Catalog Filter', () => { const onChange = jest.fn(); renderWrapped( - { const onChange = jest.fn(); renderWrapped( - { const onChange = jest.fn(); const { findByText } = renderWrapped( - { ]; const { findByText } = renderWrapped( - , + , ); expect(await findByText('1')).toBeInTheDocument(); diff --git a/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.tsx similarity index 99% rename from plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.tsx rename to plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.tsx index 90a1e15ad0..14093462b8 100644 --- a/plugins/scaffolder/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.tsx @@ -83,7 +83,7 @@ type Props = { /** * The main filter group in the sidebar, toggling owned/starred/all. */ -export const CatalogFilter = ({ +export const ScaffolderFilter = ({ buttonGroups, onChange, initiallySelected, diff --git a/plugins/scaffolder/src/components/CatalogFilter/index.ts b/plugins/scaffolder/src/components/ScaffolderFilter/index.ts similarity index 91% rename from plugins/scaffolder/src/components/CatalogFilter/index.ts rename to plugins/scaffolder/src/components/ScaffolderFilter/index.ts index 5103b16307..f53e4be89f 100644 --- a/plugins/scaffolder/src/components/CatalogFilter/index.ts +++ b/plugins/scaffolder/src/components/ScaffolderFilter/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { CatalogFilter } from './CatalogFilter'; +export { ScaffolderFilter } from './ScaffolderFilter'; diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 59480d1aba..1184c44263 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -19,7 +19,6 @@ import { configApiRef, Content, ContentHeader, - errorApiRef, Header, Lifecycle, Page, @@ -28,7 +27,7 @@ import { useApi, WarningPanel, } from '@backstage/core'; -import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; +import { isOwnerOf } from '@backstage/plugin-catalog-react'; import { Button, FormControl, @@ -36,27 +35,18 @@ import { IconButton, Input, InputAdornment, - InputLabel, Link, makeStyles, - TextField, Toolbar, Typography, } from '@material-ui/core'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import useStaleWhileRevalidate from 'swr'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { TemplateCard, TemplateCardProps } from '../TemplateCard'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; -import { CatalogFilter } from '../CatalogFilter'; -import { ButtonGroup } from '../CatalogFilter/CatalogFilter'; +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'; @@ -96,29 +86,14 @@ export const ScaffolderPageContents = () => { const { loading, error, - reload, + reload, // TODO: Configure reload matchingEntities, - availableTags, // TODO: Change tags to Categories + availableCategories, // TODO: Change tags to Categories isCatalogEmpty, } = useFilteredEntities(); // TODO: use Selected Sidebar Item const [selectedSidebarItem, setSelectedSidebarItem] = useState(); - const catalogApi = useApi(catalogApiRef); - const errorApi = useApi(errorApiRef); - const { - data: templates /* isValidating*/ /* , error */, - } = useStaleWhileRevalidate('templates/all', async () => { - const response = await catalogApi.getEntities({ - filter: { kind: 'Template' }, - }); - return response.items as TemplateEntityV1alpha1[]; - }); - useEffect(() => { - if (!error) return; - errorApi.post(error); - }, [error, errorApi]); - const { value: user } = useOwnUser(); const configApi = useApi(configApiRef); @@ -237,12 +212,12 @@ export const ScaffolderPageContents = () => { - setSelectedSidebarItem(label)} // TODO: setSelecteSidebarItem initiallySelected="all" /> - +
{!filteredTemplates && loading && } diff --git a/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx b/plugins/scaffolder/src/filter/EntityFilterGroupsProvider.tsx index 92b99c74be..1e5474f327 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 } from '@backstage/catalog-model'; +import { Entity, 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'; @@ -59,12 +59,12 @@ function useProvideEntityFilters(): FilterGroupsContext { const selectedFilterKeys = useRef<{ [filterGroupId: string]: Set; }>({}); - const selectedTags = useRef([]); + const selectedCategories = useRef([]); const [filterGroupStates, setFilterGroupStates] = useState<{ [filterGroupId: string]: FilterGroupStates; }>({}); const [matchingEntities, setMatchingEntities] = useState([]); - const [availableTags, setAvailableTags] = useState([]); + const [availableCategories, setAvailableCategories] = useState([]); const [isCatalogEmpty, setCatalogEmpty] = useState(false); useEffect(() => { @@ -76,7 +76,7 @@ function useProvideEntityFilters(): FilterGroupsContext { buildStates( filterGroups.current, selectedFilterKeys.current, - selectedTags.current, + selectedCategories.current, entities, error, ), @@ -85,11 +85,11 @@ function useProvideEntityFilters(): FilterGroupsContext { buildMatchingEntities( filterGroups.current, selectedFilterKeys.current, - selectedTags.current, + selectedCategories.current, entities, ), ); - setAvailableTags(collectTags(entities)); + setAvailableCategories(collectCategories(entities)); setCatalogEmpty(entities !== undefined && entities.length === 0); }, [entities, error]); @@ -125,9 +125,9 @@ function useProvideEntityFilters(): FilterGroupsContext { [rebuild], ); - const setSelectedTags = useCallback( - (tags: string[]) => { - selectedTags.current = tags; + const setSelectedCategories = useCallback( + (categories: string[]) => { + selectedCategories.current = categories; rebuild(); }, [rebuild], @@ -141,13 +141,13 @@ function useProvideEntityFilters(): FilterGroupsContext { register, unregister, setGroupSelectedFilters, - setSelectedTags, + setSelectedCategories, reload, loading: !error && !entities, error, filterGroupStates, matchingEntities, - availableTags, + availableCategories, isCatalogEmpty, }; } @@ -157,7 +157,7 @@ function useProvideEntityFilters(): FilterGroupsContext { function buildStates( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, - selectedTags: string[], + selectedCategories: string[], entities?: Entity[], error?: Error, ): { [filterGroupId: string]: FilterGroupStates } { @@ -186,7 +186,7 @@ function buildStates( const otherMatchingEntities = buildMatchingEntities( filterGroups, selectedFilterKeys, - selectedTags, + selectedCategories, entities, filterGroupId, ); @@ -204,15 +204,15 @@ 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(); +// Given all entites, find all possible categories and provide them in a sorted list. +function collectCategories(entities?: Entity[]): string[] { + const categories = new Set(); (entities || []).forEach(e => { - if (e.metadata.tags) { - e.metadata.tags.forEach(t => tags.add(t)); + if (e.spec?.type) { + categories.add(e.spec.type as string); } }); - return Array.from(tags).sort(); + return Array.from(categories).sort(); } // Given all filter groups and what filters are actually selected, extract all @@ -220,7 +220,7 @@ function collectTags(entities?: Entity[]): string[] { function buildMatchingEntities( filterGroups: { [filterGroupId: string]: FilterGroup }, selectedFilterKeys: { [filterGroupId: string]: Set }, - selectedTags: string[], + selectedCategories: string[], entities?: Entity[], excludeFilterGroupId?: string, ): Entity[] { @@ -247,13 +247,10 @@ 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)), + // Filter by categories, if at least one category is selected. + if (selectedCategories.length > 0) { + allFilters.push(entity => + selectedCategories.some(c => entity.spec?.type === c), ); } diff --git a/plugins/scaffolder/src/filter/context.ts b/plugins/scaffolder/src/filter/context.ts index c025480fa6..e46ebb5507 100644 --- a/plugins/scaffolder/src/filter/context.ts +++ b/plugins/scaffolder/src/filter/context.ts @@ -26,13 +26,13 @@ export type FilterGroupsContext = { ) => void; unregister: (filterGroupId: string) => void; setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void; - setSelectedTags: (tags: string[]) => void; + setSelectedCategories: (categories: string[]) => void; reload: () => Promise; loading: boolean; error?: Error; filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; matchingEntities: Entity[]; - availableTags: string[]; + availableCategories: string[]; isCatalogEmpty: boolean; }; diff --git a/plugins/scaffolder/src/filter/useFilteredEntities.ts b/plugins/scaffolder/src/filter/useFilteredEntities.ts index 2d7dcfd89d..cc2c739546 100644 --- a/plugins/scaffolder/src/filter/useFilteredEntities.ts +++ b/plugins/scaffolder/src/filter/useFilteredEntities.ts @@ -30,7 +30,7 @@ export function useFilteredEntities() { loading: context.loading, error: context.error, matchingEntities: context.matchingEntities, - availableTags: context.availableTags, + availableCategories: context.availableCategories, isCatalogEmpty: context.isCatalogEmpty, reload: context.reload, }; From 8cea525a32e7e3b3a7927a3682a4739a411375c8 Mon Sep 17 00:00:00 2001 From: Oscar Hernandez Date: Tue, 9 Feb 2021 17:21:22 -0600 Subject: [PATCH 4/7] 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, From 67590600645a97dd45fa5768ea52e366297a5269 Mon Sep 17 00:00:00 2001 From: Oscar Hernandez Date: Wed, 10 Feb 2021 10:29:17 -0600 Subject: [PATCH 5/7] Scaffolder: search/filter cleanup --- .../ResultsFilter/ResultsFilter.tsx | 18 +- .../ScaffolderFilter.test.tsx | 339 +++++++++--------- .../ScaffolderPage/ScaffolderPage.tsx | 22 +- .../scaffolder/src/components/useOwnUser.ts | 41 --- 4 files changed, 192 insertions(+), 228 deletions(-) delete mode 100644 plugins/scaffolder/src/components/useOwnUser.ts diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx index 5df70bd203..19c88a87f7 100644 --- a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx @@ -87,25 +87,27 @@ export const ResultsFilter = ({ availableCategories }: Props) => { Categories - {availableCategories.map(t => { - const labelId = `checkbox-list-label-${t}`; + {availableCategories.map(category => { + const labelId = `checkbox-list-label-${category}`; return ( updateSelectedCategories( - selectedCategories.includes(t) - ? selectedCategories.filter(s => s !== t) - : [...selectedCategories, t], + selectedCategories.includes(category) + ? selectedCategories.filter( + selectedCategory => selectedCategory !== category, + ) + : [...selectedCategories, category], ) } > { /> ); diff --git a/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx index 1c9a89c517..b5ec45d8bb 100644 --- a/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx +++ b/plugins/scaffolder/src/components/ScaffolderFilter/ScaffolderFilter.test.tsx @@ -14,8 +14,9 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; +import React from 'react'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { ApiProvider, ApiRegistry, @@ -23,10 +24,9 @@ import { identityApiRef, storageApiRef, } from '@backstage/core'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; -import { fireEvent, render, waitFor } from '@testing-library/react'; -import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; import { ButtonGroup, ScaffolderFilter } from './ScaffolderFilter'; @@ -80,185 +80,192 @@ describe('Catalog Filter', () => { ), ); - it('should render the different groups', async () => { - const mockGroups: ButtonGroup[] = [ - { name: 'Test Group 1', items: [] }, - { name: 'Test Group 2', items: [] }, - ]; - const { findByText } = renderWrapped( - , - ); - for (const group of mockGroups) { - expect(await findByText(group.name)).toBeInTheDocument(); - } - }); - - it('should render the different items and their names', async () => { - const mockGroups: ButtonGroup[] = [ - { - name: 'Test Group 1', - items: [ - { - id: 'all', - label: 'First Label', - filterFn: () => true, - }, - { - id: 'starred', - label: 'Second Label', - filterFn: () => false, - }, - ], - }, - ]; - - const { findByText } = renderWrapped( - , - ); - - for (const item of mockGroups[0].items) { - expect(await findByText(item.label)).toBeInTheDocument(); - } - }); - - it('selects the first item if no desired initial one is set', async () => { - const mockGroups: ButtonGroup[] = [ - { - name: 'Test Group 1', - items: [ - { - id: 'all', - label: 'First Label', - filterFn: () => true, - }, - { - id: 'starred', - label: 'Second Label', - filterFn: () => false, - }, - ], - }, - ]; - - const onChange = jest.fn(); - - renderWrapped( - , - ); - - await waitFor(() => { - expect(onChange).toHaveBeenLastCalledWith({ - id: 'all', - label: 'First Label', - }); + describe('filter groups', () => { + it('should render the different groups', async () => { + const mockGroups: ButtonGroup[] = [ + { name: 'Test Group 1', items: [] }, + { name: 'Test Group 2', items: [] }, + ]; + const { findByText } = renderWrapped( + , + ); + for (const group of mockGroups) { + expect(await findByText(group.name)).toBeInTheDocument(); + } }); }); - it('selects the initial item', async () => { - const mockGroups: ButtonGroup[] = [ - { - name: 'Test Group 1', - items: [ - { - id: 'all', - label: 'First Label', - filterFn: () => true, - }, - { - id: 'starred', - label: 'Second Label', - filterFn: () => false, - }, - ], - }, - ]; + describe('filter items', () => { + it('should render the different items and their names', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; - const onChange = jest.fn(); + const { findByText } = renderWrapped( + , + ); - renderWrapped( - , - ); - - await waitFor(() => { - expect(onChange).toHaveBeenLastCalledWith({ - id: 'starred', - label: 'Second Label', - }); + for (const item of mockGroups[0].items) { + expect(await findByText(item.label)).toBeInTheDocument(); + } }); - }); - it('can change the selected item', async () => { - const mockGroups: ButtonGroup[] = [ - { - name: 'Test Group 1', - items: [ - { - id: 'all', - label: 'First Label', - filterFn: () => true, - }, - { - id: 'starred', - label: 'Second Label', - filterFn: () => false, - }, - ], - }, - ]; + it('selects the first item if no desired initial one is set', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; - const onChange = jest.fn(); + const onChange = jest.fn(); - const { findByText } = renderWrapped( - , - ); + renderWrapped( + , + ); - await waitFor(() => { - expect(onChange).toHaveBeenLastCalledWith({ - id: 'all', - label: 'First Label', + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); }); }); - fireEvent.click(await findByText('Second Label')); + it('selects the initial item', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; - await waitFor(() => { - expect(onChange).toHaveBeenLastCalledWith({ - id: 'starred', - label: 'Second Label', + const onChange = jest.fn(); + + renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); }); }); - }); - it('displays match counts properly', async () => { - const mockGroups: ButtonGroup[] = [ - { - name: 'Test Group 1', - items: [ - { - id: 'owned', - label: 'First Label', - filterFn: entity => entity.spec?.owner === 'tools@example.com', - }, - ], - }, - ]; + it('can change the selected item', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'all', + label: 'First Label', + filterFn: () => true, + }, + { + id: 'starred', + label: 'Second Label', + filterFn: () => false, + }, + ], + }, + ]; - const { findByText } = renderWrapped( - , - ); + const onChange = jest.fn(); - expect(await findByText('1')).toBeInTheDocument(); + const { findByText } = renderWrapped( + , + ); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); + + fireEvent.click(await findByText('Second Label')); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); + }); + + it('displays match counts properly', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'owned', + label: 'First Label', + filterFn: entity => entity.spec?.owner === 'tools@example.com', + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + expect(await findByText('1')).toBeInTheDocument(); + }); }); }); diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index eba8a1bd48..f3ccd1d440 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import React, { useEffect, useMemo, useState } from 'react'; +import { EntityMeta, TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { configApiRef, Content, @@ -28,7 +29,6 @@ import { WarningPanel, } from '@backstage/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'; import { TemplateCard, TemplateCardProps } from '../TemplateCard'; @@ -36,7 +36,6 @@ import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import { ScaffolderFilter } from '../ScaffolderFilter'; import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter'; import StarIcon from '@material-ui/icons/Star'; -import { useOwnUser } from '../useOwnUser'; import { useStarredEntities } from '../../hooks/useStarredEntities'; import SearchToolbar from '../SearchToolbar/SearchToolbar'; @@ -104,21 +103,18 @@ export const ScaffolderPageContents = () => { [] as TemplateEntityV1alpha1[], ); - // Match templates by search input value + const matchesQuery = (metadata: EntityMeta, query: string) => + `${metadata.title}`.toUpperCase().indexOf(query) !== -1 || + metadata.tags?.join('').toUpperCase().indexOf(query) !== -1; + useEffect(() => { - const searchUppercase = search.toUpperCase(); if (search.length === 0) { return setMatchingEntities(filteredEntities); } - // Match search by title|tags return setMatchingEntities( - filteredEntities.filter(template => { - const { title, tags } = template.metadata; - return ( - `${title}`.toUpperCase().indexOf(searchUppercase) !== -1 || - tags?.join('').toUpperCase().indexOf(searchUppercase) !== -1 - ); - }), + filteredEntities.filter(template => + matchesQuery(template.metadata, search.toUpperCase()), + ), ); }, [search, filteredEntities]); diff --git a/plugins/scaffolder/src/components/useOwnUser.ts b/plugins/scaffolder/src/components/useOwnUser.ts deleted file mode 100644 index 29d8a0d11f..0000000000 --- a/plugins/scaffolder/src/components/useOwnUser.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 { UserEntity } from '@backstage/catalog-model'; -import { identityApiRef, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { useAsync } from 'react-use'; -import { AsyncState } from 'react-use/lib/useAsync'; - -/** - * Get the catalog User entity (if any) that matches the logged-in user. - */ -export function useOwnUser(): AsyncState { - const catalogApi = useApi(catalogApiRef); - const identityApi = useApi(identityApiRef); - - // TODO: get the full entity (or at least the full entity name) from the - // identityApi - return useAsync( - () => - catalogApi.getEntityByName({ - kind: 'User', - namespace: 'default', - name: identityApi.getUserId(), - }) as Promise, - [catalogApi, identityApi], - ); -} From e8e35fb5fd67e99a1d3008204cd546dc235667a1 Mon Sep 17 00:00:00 2001 From: Oscar Hernandez Date: Wed, 10 Feb 2021 12:03:25 -0600 Subject: [PATCH 6/7] Scaffolder: Search/Filter Changeset --- .changeset/itchy-rivers-judge.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/itchy-rivers-judge.md diff --git a/.changeset/itchy-rivers-judge.md b/.changeset/itchy-rivers-judge.md new file mode 100644 index 0000000000..711becd33b --- /dev/null +++ b/.changeset/itchy-rivers-judge.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Adding Search and Filter features to Scaffolder/Templates Grid From 452602094f23e5bb0eb150e649c9661066d2f75e Mon Sep 17 00:00:00 2001 From: Oscar Hernandez Date: Thu, 11 Feb 2021 13:02:38 -0600 Subject: [PATCH 7/7] Scaffolder: PR comments --- .../src/components/ScaffolderPage/ScaffolderPage.tsx | 3 +-- plugins/scaffolder/src/hooks/useStarredEntities.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index f3ccd1d440..74582d0762 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -167,8 +167,7 @@ export const ScaffolderPageContents = () => { )} {error && ( - - Oops! Something went wrong loading the templates:{' '} + {error.message} )} diff --git a/plugins/scaffolder/src/hooks/useStarredEntities.test.tsx b/plugins/scaffolder/src/hooks/useStarredEntities.test.tsx index 78d2c4a58f..f1a8df1f32 100644 --- a/plugins/scaffolder/src/hooks/useStarredEntities.test.tsx +++ b/plugins/scaffolder/src/hooks/useStarredEntities.test.tsx @@ -65,7 +65,7 @@ describe('useStarredEntities', () => { expect(result.current.starredEntities.size).toBe(0); }); - it('should return a set with the current items when there is items in storage', async () => { + it('should return a set with the current items when there are items in storage', async () => { const expectedIds = ['i', 'am', 'some', 'test', 'ids']; const store = mockStorage?.forBucket('settings'); await store?.set('starredEntities', expectedIds); @@ -95,7 +95,7 @@ describe('useStarredEntities', () => { expect(result.current.isStarredEntity(mockEntity)).toBeTruthy(); }); - it('should write new entries to the local store when adding a togglging entity', async () => { + it('should write new entries to the local store when adding a toggling entity', async () => { const { result } = renderHook(() => useStarredEntities(), { wrapper }); act(() => {