From 5e0ae3428f30017cdad6219d2b12cd461ee462b5 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 12 May 2021 12:08:41 -0600 Subject: [PATCH] Remove deprecated hooks Signed-off-by: Tim Hansen --- .../CatalogFilter/AllServicesCount.tsx | 33 --- .../CatalogFilter/CatalogFilter.test.tsx | 264 ------------------ .../CatalogFilter/CatalogFilter.tsx | 219 --------------- .../src/components/CatalogFilter/index.ts | 17 -- .../CatalogPage/CatalogPage.test.tsx | 3 +- .../components/CatalogPage/CatalogTabs.tsx | 95 ------- .../ResultsFilter/ResultsFilter.test.tsx | 105 ------- .../ResultsFilter/ResultsFilter.tsx | 121 -------- .../src/filter/EntityFilterGroupsProvider.tsx | 263 ----------------- plugins/catalog/src/filter/context.ts | 44 --- plugins/catalog/src/filter/index.ts | 28 -- plugins/catalog/src/filter/types.ts | 53 ---- .../src/filter/useEntityFilterGroup.test.tsx | 122 -------- .../src/filter/useEntityFilterGroup.ts | 73 ----- .../catalog/src/filter/useFilteredEntities.ts | 37 --- 15 files changed, 1 insertion(+), 1476 deletions(-) delete mode 100644 plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx delete mode 100644 plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx delete mode 100644 plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx delete mode 100644 plugins/catalog/src/components/CatalogFilter/index.ts delete mode 100644 plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx delete mode 100644 plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx delete mode 100644 plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx delete mode 100644 plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx delete mode 100644 plugins/catalog/src/filter/context.ts delete mode 100644 plugins/catalog/src/filter/index.ts delete mode 100644 plugins/catalog/src/filter/types.ts delete mode 100644 plugins/catalog/src/filter/useEntityFilterGroup.test.tsx delete mode 100644 plugins/catalog/src/filter/useEntityFilterGroup.ts delete mode 100644 plugins/catalog/src/filter/useFilteredEntities.ts diff --git a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx deleted file mode 100644 index efacfa4320..0000000000 --- a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx +++ /dev/null @@ -1,33 +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 { 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/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx deleted file mode 100644 index 0cc2108985..0000000000 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ /dev/null @@ -1,264 +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 { 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/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx deleted file mode 100644 index 6de4c318b5..0000000000 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ /dev/null @@ -1,219 +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 { 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; -}; - -/** - * Sidebar filter type and human readable label for it. owned/starred/all - */ -export type CatalogFilterType = { - id: string; - label: string; -}; - -/** - * 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/catalog/src/components/CatalogFilter/index.ts b/plugins/catalog/src/components/CatalogFilter/index.ts deleted file mode 100644 index 5103b16307..0000000000 --- a/plugins/catalog/src/components/CatalogFilter/index.ts +++ /dev/null @@ -1,17 +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. - */ - -export { CatalogFilter } from './CatalogFilter'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 2f08fb15c1..97d747f497 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -32,7 +32,6 @@ 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 { createComponentRouteRef } from '../../routes'; import { CatalogPage } from './CatalogPage'; @@ -115,7 +114,7 @@ describe('CatalogPage', () => { [storageApiRef, MockStorageApi.create()], ])} > - {children}, + {children} , { mountedRoutes: { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx b/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx deleted file mode 100644 index f00c892ed3..0000000000 --- a/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx +++ /dev/null @@ -1,95 +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 { Entity } from '@backstage/catalog-model'; -import { HeaderTabs } from '@backstage/core'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { FilterGroup, useEntityFilterGroup } from '../../filter'; - -/** - * A component type, and a human readable label for it. - */ -export type LabeledComponentType = { - id: string; - label: string; -}; - -/** - * Called on mount, and when the selected tab changes. - */ -export type OnChangeCallback = (tab: LabeledComponentType) => void; - -type Props = { - tabs: LabeledComponentType[]; - onChange?: OnChangeCallback; -}; - -/** - * The tabs at the top of the catalog list page, for component type filtering. - */ -export const CatalogTabs = ({ tabs, onChange }: Props) => { - const filterGroup = useMemo(() => { - const otherType = 'other'; - const wellKnownTypes = tabs.map(t => t.id).filter(t => t !== otherType); - const isOtherType = (entity: Entity) => - !wellKnownTypes.includes(entity.spec?.type as string); - - return { - filters: Object.fromEntries( - tabs.map(t => [ - t.id, - (entity: Entity) => - (t.id === otherType && isOtherType(entity)) || - entity.spec?.type === t.id, - ]), - ), - }; - }, [tabs]); - - const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [ - tabs[0].id, - ]); - - const [currentTabIndex, setCurrentTabIndex] = useState(0); - - // Hold a reference to the callback - const onChangeRef = useRef(); - useEffect(() => { - onChangeRef.current = onChange; - }, [onChange]); - - useEffect(() => { - onChangeRef.current?.(tabs[currentTabIndex]); - }, [tabs, currentTabIndex]); - - const switchTab = useCallback( - (index: number) => { - const tab = tabs[index]; - setSelectedFilters([tab.id]); - setCurrentTabIndex(index); - onChangeRef.current?.(tab); - }, - [tabs, setSelectedFilters], - ); - - return ; -}; diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx deleted file mode 100644 index 9be1c995e8..0000000000 --- a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx +++ /dev/null @@ -1,105 +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 { 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/catalog/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx deleted file mode 100644 index 8c5737b7b6..0000000000 --- a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx +++ /dev/null @@ -1,121 +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 { - 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/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx deleted file mode 100644 index 69ae9b83d6..0000000000 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ /dev/null @@ -1,263 +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 { 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: 'Component' }, - }); - 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/catalog/src/filter/context.ts b/plugins/catalog/src/filter/context.ts deleted file mode 100644 index c025480fa6..0000000000 --- a/plugins/catalog/src/filter/context.ts +++ /dev/null @@ -1,44 +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 { 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/catalog/src/filter/index.ts b/plugins/catalog/src/filter/index.ts deleted file mode 100644 index da73147ef9..0000000000 --- a/plugins/catalog/src/filter/index.ts +++ /dev/null @@ -1,28 +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. - */ - -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/catalog/src/filter/types.ts b/plugins/catalog/src/filter/types.ts deleted file mode 100644 index ed08b131bf..0000000000 --- a/plugins/catalog/src/filter/types.ts +++ /dev/null @@ -1,53 +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 { 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/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx deleted file mode 100644 index 8a5bf60b06..0000000000 --- a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx +++ /dev/null @@ -1,122 +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 { 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(), - getOriginLocationByEntity: jest.fn(), - getLocationByEntity: jest.fn(), - getLocationById: jest.fn(), - removeLocationById: 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/catalog/src/filter/useEntityFilterGroup.ts b/plugins/catalog/src/filter/useEntityFilterGroup.ts deleted file mode 100644 index 30214fad78..0000000000 --- a/plugins/catalog/src/filter/useEntityFilterGroup.ts +++ /dev/null @@ -1,73 +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 { 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; - - // on state changes unregisters and registers the filtergroup - // ensure that it re-registers with the correct filter as the prop changes and not the default - // eslint-disable-next-line react-hooks/exhaustive-deps - const initialMemo = useMemo(() => { - return initialSelectedFilters?.slice(); - }, [initialSelectedFilters]); - - // Register the group on mount, and unregister on unmount - useEffect(() => { - register(filterGroupId, filterGroup, initialMemo); - return () => unregister(filterGroupId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [register, unregister, filterGroupId, filterGroup]); - - 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/catalog/src/filter/useFilteredEntities.ts b/plugins/catalog/src/filter/useFilteredEntities.ts deleted file mode 100644 index 2d7dcfd89d..0000000000 --- a/plugins/catalog/src/filter/useFilteredEntities.ts +++ /dev/null @@ -1,37 +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 { 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, - }; -}