From 23114cf9c35b3dea8b7b5ddbfb94584a26bfd5e6 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 12 May 2021 11:57:01 -0600 Subject: [PATCH 01/15] Add useEntityListProvider hook Signed-off-by: Tim Hansen --- plugins/catalog-react/package.json | 2 + .../EntityTagPicker/EntityTagPicker.test.tsx | 103 ++++++++++ .../EntityTagPicker/EntityTagPicker.tsx | 95 +++++++++ .../src/components/EntityTagPicker/index.ts | 17 ++ .../UserListPicker/UserListPicker.test.tsx | 177 ++++++++++++++++ .../UserListPicker/UserListPicker.tsx | 185 +++++++++++++++++ .../src/components/UserListPicker/index.ts | 17 ++ plugins/catalog-react/src/components/index.ts | 2 + plugins/catalog-react/src/hooks/index.ts | 6 + .../src/hooks/useEntityListProvider.tsx | 181 +++++++++++++++++ .../src/hooks}/useOwnUser.ts | 2 +- plugins/catalog-react/src/index.ts | 1 + .../catalog-react/src/testUtils/providers.tsx | 40 ++++ plugins/catalog-react/src/types.ts | 107 ++++++++++ plugins/catalog-react/src/utils/filters.ts | 39 ++++ plugins/catalog-react/src/utils/index.ts | 1 + plugins/catalog/package.json | 1 + .../components/CatalogPage/CatalogPage.tsx | 190 +++--------------- .../CatalogTable/CatalogTable.test.tsx | 77 +++---- .../components/CatalogTable/CatalogTable.tsx | 30 +-- .../src/components/CatalogTable/index.ts | 3 +- .../EntityTypePicker/EntityTypePicker.tsx | 84 ++++++++ .../src/components/EntityTypePicker/index.ts | 17 ++ plugins/catalog/src/index.ts | 1 + 24 files changed, 1161 insertions(+), 217 deletions(-) create mode 100644 plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx create mode 100644 plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx create mode 100644 plugins/catalog-react/src/components/EntityTagPicker/index.ts create mode 100644 plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx create mode 100644 plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx create mode 100644 plugins/catalog-react/src/components/UserListPicker/index.ts create mode 100644 plugins/catalog-react/src/hooks/useEntityListProvider.tsx rename plugins/{catalog/src/components => catalog-react/src/hooks}/useOwnUser.ts (95%) create mode 100644 plugins/catalog-react/src/testUtils/providers.tsx create mode 100644 plugins/catalog-react/src/types.ts create mode 100644 plugins/catalog-react/src/utils/filters.ts create mode 100644 plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx create mode 100644 plugins/catalog/src/components/EntityTypePicker/index.ts diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index abce5a1996..9e2730da57 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -32,6 +32,7 @@ "@backstage/catalog-model": "^0.7.9", "@backstage/core": "^0.7.9", "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", "@types/react": "^16.9", "lodash": "^4.17.15", "react": "^16.13.1", @@ -41,6 +42,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.11", + "@backstage/core-api": "^0.2.18", "@backstage/dev-utils": "^0.1.14", "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx new file mode 100644 index 0000000000..cbd18da592 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; +import { EntityTagPicker } from './EntityTagPicker'; +import { EntityTagFilter } from '../../types'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; + +const taggedEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-1', + tags: ['tag1', 'tag2'], + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component-2', + tags: ['tag3', 'tag4'], + }, + }, +]; + +describe('', () => { + it('renders all tags', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Tags')).toBeInTheDocument(); + taggedEntities + .flatMap(e => e.metadata.tags!) + .forEach(tag => { + expect(rendered.getByText(tag)).toBeInTheDocument(); + }); + }); + + it('adds tags to filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + + fireEvent.click(rendered.getByText('tag1')); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1']), + }); + }); + + it('removes tags from filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).not.toHaveBeenCalled(); + expect(rendered.getByLabelText('tag1')).toBeChecked(); + + fireEvent.click(rendered.getByText('tag1')); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: undefined, + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx new file mode 100644 index 0000000000..9b52677ac7 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo } from 'react'; +import { + Checkbox, + List, + ListItem, + ListItemText, + makeStyles, + Theme, + Typography, +} from '@material-ui/core'; +import { Entity } from '@backstage/catalog-model'; +import { EntityTagFilter } from '../../types'; +import { useEntityListProvider } from '../../hooks/useEntityListProvider'; + +const useStyles = makeStyles(theme => ({ + title: { + margin: theme.spacing(1, 0, 0, 1), + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + }, + checkbox: { + padding: theme.spacing(0, 1, 0, 1), + }, +})); + +export const EntityTagPicker = () => { + const classes = useStyles(); + const { updateFilters, backendEntities, filters } = useEntityListProvider(); + const availableTags = useMemo( + () => [ + ...new Set( + backendEntities + .flatMap((e: Entity) => e.metadata.tags) + .filter(Boolean) as string[], + ), + ], + [backendEntities], + ); + + if (!availableTags.length) return null; + + const onClick = (tag: string) => { + const tags = filters.tags?.values ?? []; + const newTags = tags.includes(tag) + ? [...tags.filter((t: string) => t !== tag)] + : [...tags, tag]; + updateFilters({ + tags: newTags.length ? new EntityTagFilter(newTags) : undefined, + }); + }; + + return ( + <> + + Tags + + + {availableTags.map(tag => { + const labelId = `checkbox-list-label-${tag}`; + return ( + onClick(tag)}> + + + + ); + })} + + + ); +}; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/index.ts b/plugins/catalog-react/src/components/EntityTagPicker/index.ts new file mode 100644 index 0000000000..5e797e1ef5 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityTagPicker/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityTagPicker } from './EntityTagPicker'; diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx new file mode 100644 index 0000000000..0d16371f6a --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -0,0 +1,177 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { UserListPicker } from './UserListPicker'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { + ApiProvider, + ApiRegistry, + ConfigApi, + configApiRef, +} from '@backstage/core-api'; + +const apis = ApiRegistry.from([ + [ + configApiRef, + ({ + getOptionalString: jest.fn( + (key: string) => + ({ + 'organization.name': 'Test Company', + }[key]), + ), + } as unknown) as ConfigApi, + ], +]); + +jest.mock('../../hooks', () => ({ + useOwnUser: jest.fn().mockReturnValue({ + value: { + apiVersion: '1', + kind: 'User', + metadata: { + namespace: 'default', + name: 'testUser', + }, + }, + }), + useStarredEntities: jest.fn().mockReturnValue({ + isStarredEntity: jest.fn( + (entity: Entity) => entity.metadata.name === 'component-3', + ), + }), + useEntityListProvider: jest.requireActual('../../hooks') + .useEntityListProvider, +})); + +describe('', () => { + const backendEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'namespace-1', + name: 'component-1', + tags: [], + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'User', namespace: 'default', name: 'testUser' }, + }, + ], + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'namespace-2', + name: 'component-2', + tags: [], + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'namespace-2', + name: 'component-3', + tags: [], + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'namespace-2', + name: 'component-4', + tags: [], + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'User', namespace: 'default', name: 'testUser' }, + }, + ], + }, + ]; + + it('renders filter groups', () => { + const { queryByText } = render( + + + + + , + ); + + expect(queryByText('Personal')).toBeInTheDocument(); + expect(queryByText('Test Company')).toBeInTheDocument(); + }); + + it('renders filters', () => { + const { getAllByRole } = render( + + + + + , + ); + + expect( + getAllByRole('menuitem').map(({ textContent }) => textContent), + ).toEqual(['Owned', 'Starred', 'All']); + }); + + it('includes counts alongside each filter', () => { + const { getAllByRole } = render( + + + + + , + ); + + // Material UI renders ListItemSecondaryActions outside the + // menuitem itself, so we pick off the next sibling. + expect( + getAllByRole('menuitem').map( + ({ nextSibling }) => nextSibling?.textContent, + ), + ).toEqual(['2', '1', '4']); + }); + + it('updates user filter when a menuitem is selected', () => { + const updateFilters = jest.fn(); + const { getByText } = render( + + + + + , + ); + + fireEvent.click(getByText('Starred')); + + expect(updateFilters).toHaveBeenCalledTimes(1); + expect(updateFilters.mock.calls[0][0].user.value).toEqual('starred'); + }); +}); diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx new file mode 100644 index 0000000000..16df12c2a7 --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx @@ -0,0 +1,185 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Fragment } from 'react'; +import { configApiRef, IconComponent, useApi } from '@backstage/core'; +import { + FilterEnvironment, + UserListFilter, + UserListFilterKind, +} from '../../types'; +import { + useEntityListProvider, + useOwnUser, + useStarredEntities, +} from '../../hooks'; +import { + Card, + List, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + makeStyles, + MenuItem, + Theme, + Typography, +} from '@material-ui/core'; +import SettingsIcon from '@material-ui/icons/Settings'; +import StarIcon from '@material-ui/icons/Star'; + +const useStyles = makeStyles(theme => ({ + root: { + backgroundColor: 'rgba(0, 0, 0, .11)', + boxShadow: 'none', + margin: theme.spacing(1, 0, 1, 0), + }, + 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, + }, +})); + +export type ButtonGroup = { + name: string; + items: { + id: 'owned' | 'starred' | 'all'; + label: string; + icon?: IconComponent; + }[]; +}; + +function getFilterGroups(orgName: string | undefined): ButtonGroup[] { + return [ + { + name: 'Personal', + items: [ + { + id: 'owned', + label: 'Owned', + icon: SettingsIcon, + }, + { + id: 'starred', + label: 'Starred', + icon: StarIcon, + }, + ], + }, + { + name: orgName ?? 'Company', + items: [ + { + id: 'all', + label: 'All', + }, + ], + }, + ]; +} + +// Static filters; only used for generating counts of potentially unselected kinds +const ownedFilter = new UserListFilter('owned'); +const starredFilter = new UserListFilter('starred'); + +export const UserListPicker = () => { + const classes = useStyles(); + const configApi = useApi(configApiRef); + const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; + const filterGroups = getFilterGroups(orgName); + + // Unfortunate FilterEnvironment duplication for static filters used for counts + const { value: user } = useOwnUser(); + const { isStarredEntity } = useStarredEntities(); + const filterEnv: FilterEnvironment = { + user: user, + isStarredEntity: isStarredEntity, + }; + + const { filters, updateFilters, backendEntities } = useEntityListProvider(); + function setSelectedFilter({ id }: { id: UserListFilterKind }) { + updateFilters({ user: new UserListFilter(id) }); + } + + function getFilterCount(id: UserListFilterKind) { + switch (id) { + case 'owned': + return backendEntities.filter(entity => + ownedFilter.filterEntity(entity, filterEnv), + ).length; + case 'starred': + return backendEntities.filter(entity => + starredFilter.filterEntity(entity, filterEnv), + ).length; + default: + return backendEntities.length; + } + } + + return ( + + {filterGroups.map(group => ( + + + {group.name} + + + + {group.items.map(item => ( + setSelectedFilter(item)} + selected={item.id === filters.user?.value} + className={classes.menuItem} + > + {item.icon && ( + + + + )} + + + {item.label} + + + + {getFilterCount(item.id) ?? '-'} + + + ))} + + + + ))} + + ); +}; diff --git a/plugins/catalog-react/src/components/UserListPicker/index.ts b/plugins/catalog-react/src/components/UserListPicker/index.ts new file mode 100644 index 0000000000..ad45965c17 --- /dev/null +++ b/plugins/catalog-react/src/components/UserListPicker/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { UserListPicker } from './UserListPicker'; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index 5181b8f0ea..f427d3ac63 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -16,3 +16,5 @@ export * from './EntityProvider'; export * from './EntityRefLink'; export * from './EntityTable'; +export * from './EntityTagPicker'; +export * from './UserListPicker'; diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index d964c22a3b..46be6e9604 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -15,5 +15,11 @@ */ export { EntityContext, useEntity, useEntityFromUrl } from './useEntity'; export { useEntityCompoundName } from './useEntityCompoundName'; +export { + EntityListContext, + EntityListProvider, + useEntityListProvider, +} from './useEntityListProvider'; +export { useOwnUser } from './useOwnUser'; export { useRelatedEntities } from './useRelatedEntities'; export { useStarredEntities } from './useStarredEntities'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx new file mode 100644 index 0000000000..a9bac64158 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -0,0 +1,181 @@ +/* + * 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, { + createContext, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useAsyncFn, useDebounce } from 'react-use'; +import { useApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { reduceCatalogFilters, reduceEntityFilters } from '../utils'; +import { catalogApiRef } from '../api'; +import { + EntityFilter, + EntityKindFilter, + EntityTagFilter, + EntityTypeFilter, + FilterEnvironment, + UserListFilter, +} from '../types'; +import { useOwnUser } from './useOwnUser'; +import { useStarredEntities } from './useStarredEntities'; +import { compact, isEqual } from 'lodash'; + +export type DefaultEntityFilters = { + kind?: EntityKindFilter; + type?: EntityTypeFilter; + user?: UserListFilter; + tags?: EntityTagFilter; +}; + +export type EntityListContextProps< + EntityFilters extends DefaultEntityFilters = DefaultEntityFilters +> = { + /** + * The currently registered filters, adhering to the shape of DefaultEntityFilters or an extension + * of that default (to add custom filter types). + */ + filters: EntityFilters; + + /** + * The resolved list of catalog entities, after all filters are applied. + */ + entities: Entity[]; + + /** + * The resolved list of catalog entities, after _only catalog-backend_ filters are applied. + */ + backendEntities: Entity[]; + + /** + * Update one or more of the registered filters. Optional filters can be set to `undefined` to + * reset the filter. + */ + updateFilters: (filters: Partial) => void; + + loading: boolean; + error?: Error; +}; + +export const EntityListContext = createContext< + EntityListContextProps | undefined +>(undefined); + +export type EntityListProviderProps< + EntityFilters extends DefaultEntityFilters +> = { + initialFilters?: EntityFilters; +}; + +export const EntityListProvider = ({ + initialFilters, + children, +}: PropsWithChildren>) => { + const catalogApi = useApi(catalogApiRef); + const { value: user } = useOwnUser(); + const { isStarredEntity } = useStarredEntities(); + + // TODO(timbonicus): should query params be registered as initialFilters when present? + const [filters, setFilters] = useState( + initialFilters ?? ({} as EntityFilters), + ); + + const [entities, setEntities] = useState([]); + const [backendEntities, setBackendEntities] = useState([]); + + const filterEnv: FilterEnvironment = useMemo( + () => ({ + user, + isStarredEntity, + }), + [user, isStarredEntity], + ); + + // Store resolved catalog-backend filters and deep compare on filter updates, to avoid refetching + // when only frontend filters change + const [backendFilters, setBackendFilters] = useState< + Record + >(reduceCatalogFilters(compact(Object.values(filters)))); + + useEffect(() => { + const newBackendFilters = reduceCatalogFilters( + compact(Object.values(filters)), + ); + if (!isEqual(newBackendFilters, backendFilters)) { + setBackendFilters(newBackendFilters); + } + }, [backendFilters, filters]); + + const [{ loading, error }, refresh] = useAsyncFn(async () => { + // TODO(timbonicus): should limit fields here, but would need filter fields + table columns + const items = await catalogApi + .getEntities({ + filter: backendFilters, + }) + .then(response => response.items); + setBackendEntities(items); + }, [backendFilters, catalogApi]); + + // Slight debounce on the catalog-backend call, to prevent eager refresh on multiple programmatic + // filter changes. + useDebounce(refresh, 10, [backendFilters]); + + // Apply frontend filters + useEffect(() => { + const resolvedEntities = (backendEntities ?? []).filter( + reduceEntityFilters(compact(Object.values(filters)), filterEnv), + ); + setEntities(resolvedEntities); + }, [backendEntities, filterEnv, filters]); + + const updateFilters = useCallback( + (patch: Partial) => + setFilters(prevFilters => ({ ...prevFilters, ...patch })), + [], + ); + + return ( + + {children} + + ); +}; + +export function useEntityListProvider< + EntityFilters extends DefaultEntityFilters +>(): EntityListContextProps { + const context = useContext(EntityListContext); + if (!context) + throw new Error( + 'useEntityListProvider must be used within EntityListProvider', + ); + return context; +} diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog-react/src/hooks/useOwnUser.ts similarity index 95% rename from plugins/catalog/src/components/useOwnUser.ts rename to plugins/catalog-react/src/hooks/useOwnUser.ts index 29d8a0d11f..d79cfbe92c 100644 --- a/plugins/catalog/src/components/useOwnUser.ts +++ b/plugins/catalog-react/src/hooks/useOwnUser.ts @@ -16,9 +16,9 @@ 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'; +import { catalogApiRef } from '../api'; /** * Get the catalog User entity (if any) that matches the logged-in user. diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts index af3eca4e0a..b0bd35471d 100644 --- a/plugins/catalog-react/src/index.ts +++ b/plugins/catalog-react/src/index.ts @@ -24,4 +24,5 @@ export { entityRouteRef, rootRoute, } from './routes'; +export * from './types'; export * from './utils'; diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx new file mode 100644 index 0000000000..956df15c1f --- /dev/null +++ b/plugins/catalog-react/src/testUtils/providers.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { + EntityListContext, + EntityListContextProps, +} from '../hooks/useEntityListProvider'; + +export const MockEntityListContextProvider = ({ + children, + value, +}: PropsWithChildren<{ value: Partial }>) => { + const defaultContext: EntityListContextProps = { + entities: [], + backendEntities: [], + updateFilters: jest.fn(), + filters: {}, + loading: false, + }; + + return ( + + {children} + + ); +}; diff --git a/plugins/catalog-react/src/types.ts b/plugins/catalog-react/src/types.ts new file mode 100644 index 0000000000..e83eadb3fc --- /dev/null +++ b/plugins/catalog-react/src/types.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity, UserEntity } from '@backstage/catalog-model'; +import { isOwnerOf } from './utils'; + +export type FilterEnvironment = { + user: UserEntity | undefined; + isStarredEntity: (entity: Entity) => boolean; +}; + +export type EntityFilter = { + /** + * Get filters to add to the catalog-backend request. These are a dot-delimited field with + * value(s) to accept, extracted on the backend by parseEntityFilterParams. For example: + * { field: 'kind', values: ['component'] } + * { field: 'metadata.name', values: ['component-1', 'component-2'] } + */ + getCatalogFilters?: () => Record; + + /** + * Filter entities on the frontend after a catalog-backend request. This function will be called + * with each backend-resolved entity. This is used when frontend information is required for + * filtering, such as a user's starred entities. + * + * @param entity + * @param env + */ + filterEntity?: (entity: Entity, env: FilterEnvironment) => boolean; +}; + +export class EntityKindFilter implements EntityFilter { + private readonly _value: string; + constructor(kind: string) { + this._value = kind; + } + + get value() { + return this._value; + } + + getCatalogFilters(): Record { + return { kind: this._value }; + } +} + +export class EntityTypeFilter implements EntityFilter { + private _value: string; + constructor(type: string) { + this._value = type; + } + + get value() { + return this._value; + } + + getCatalogFilters(): Record { + return { 'spec.type': this.value }; + } +} + +export class EntityTagFilter implements EntityFilter { + private _values: string[]; + constructor(values: string[]) { + this._values = values; + } + + get values() { + return this._values; + } + + filterEntity(entity: Entity): boolean { + return this.values.every(v => (entity.metadata.tags ?? []).includes(v)); + } +} + +export type UserListFilterKind = 'owned' | 'starred' | 'all'; +export class UserListFilter implements EntityFilter { + readonly value: UserListFilterKind; + constructor(value: UserListFilterKind) { + this.value = value; + } + + filterEntity(entity: Entity, env: FilterEnvironment): boolean { + switch (this.value) { + case 'owned': + return env.user !== undefined && isOwnerOf(env.user, entity); + case 'starred': + return env.isStarredEntity(entity); + default: + return true; + } + } +} diff --git a/plugins/catalog-react/src/utils/filters.ts b/plugins/catalog-react/src/utils/filters.ts new file mode 100644 index 0000000000..afe2b0b3d3 --- /dev/null +++ b/plugins/catalog-react/src/utils/filters.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { EntityFilter, FilterEnvironment } from '../types'; + +export function reduceCatalogFilters( + filters: EntityFilter[], +): Record { + return filters.reduce((compoundFilter, filter) => { + return { + ...compoundFilter, + ...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}), + }; + }, {} as Record); +} + +export function reduceEntityFilters( + filters: EntityFilter[], + env: FilterEnvironment, +): (entity: Entity) => boolean { + return (entity: Entity) => + filters.every( + filter => !filter.filterEntity || filter.filterEntity(entity, env), + ); +} diff --git a/plugins/catalog-react/src/utils/index.ts b/plugins/catalog-react/src/utils/index.ts index 2efb35703e..8d045e08ac 100644 --- a/plugins/catalog-react/src/utils/index.ts +++ b/plugins/catalog-react/src/utils/index.ts @@ -13,5 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export * from './filters'; export { getEntityRelations } from './getEntityRelations'; export { isOwnerOf } from './isOwnerOf'; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 6354f00c5d..ee541ce8ce 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -44,6 +44,7 @@ "@types/react": "^16.9", "classnames": "^2.2.6", "git-url-parse": "^11.4.4", + "lodash": "^4.17.21", "react": "^16.13.1", "react-dom": "^16.13.1", "react-helmet": "6.1.0", diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index bbb26d7be7..e442122987 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -14,40 +14,30 @@ * limitations under the License. */ +import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { Button, makeStyles } from '@material-ui/core'; import { - configApiRef, Content, ContentHeader, - errorApiRef, SupportButton, TableColumn, - useApi, useRouteRef, } from '@backstage/core'; import { - catalogApiRef, - isOwnerOf, - useStarredEntities, + EntityKindFilter, + EntityListProvider, + EntityTagPicker, + UserListFilter, + UserListFilterKind, + UserListPicker, } from '@backstage/plugin-catalog-react'; -import { Button, makeStyles } from '@material-ui/core'; -import SettingsIcon from '@material-ui/icons/Settings'; -import StarIcon from '@material-ui/icons/Star'; -import React, { useCallback, useMemo, useState } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; -import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { createComponentRouteRef } from '../../routes'; -import { - ButtonGroup, - CatalogFilter, - CatalogFilterType, -} from '../CatalogFilter/CatalogFilter'; -import { CatalogTable } from '../CatalogTable/CatalogTable'; +import { CatalogTable } from '../CatalogTable'; import { EntityRow } from '../CatalogTable/types'; -import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; -import { useOwnUser } from '../useOwnUser'; import CatalogLayout from './CatalogLayout'; -import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; +import { EntityTypePicker } from '../EntityTypePicker'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -62,121 +52,25 @@ const useStyles = makeStyles(theme => ({ })); export type CatalogPageProps = { - initiallySelectedFilter?: string; + initiallySelectedFilter?: UserListFilterKind; columns?: TableColumn[]; }; -const CatalogPageContents = (props: CatalogPageProps) => { +export const CatalogPage = ({ + initiallySelectedFilter = 'owned', + columns, +}: CatalogPageProps) => { const styles = useStyles(); - const { - loading, - error, - reload, - matchingEntities, - availableTags, - isCatalogEmpty, - } = useFilteredEntities(); - const configApi = useApi(configApiRef); - const catalogApi = useApi(catalogApiRef); - const errorApi = useApi(errorApiRef); - const { isStarredEntity } = useStarredEntities(); - const [selectedTab, setSelectedTab] = useState(); - const [ - selectedSidebarItem, - setSelectedSidebarItem, - ] = useState(); - const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; - const initiallySelectedFilter = - selectedSidebarItem?.id ?? props.initiallySelectedFilter ?? 'owned'; const createComponentLink = useRouteRef(createComponentRouteRef); - const addMockData = useCallback(async () => { - try { - const promises: Promise[] = []; - const root = configApi.getConfig('catalog.exampleEntityLocations'); - for (const type of root.keys()) { - for (const target of root.getStringArray(type)) { - promises.push(catalogApi.addLocation({ target })); - } - } - await Promise.all(promises); - await reload(); - } catch (err) { - errorApi.post(err); - } - }, [catalogApi, configApi, errorApi, reload]); - - const tabs = useMemo( - () => [ - { - id: 'service', - label: 'Services', - }, - { - id: 'website', - label: 'Websites', - }, - { - id: 'library', - label: 'Libraries', - }, - { - id: 'documentation', - label: 'Documentation', - }, - { - id: 'other', - label: 'Other', - }, - ], - [], - ); - - const { value: user } = useOwnUser(); - - const filterGroups = useMemo( - () => [ - { - name: 'Personal', - 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], - ); - - const showAddExampleEntities = - configApi.has('catalog.exampleEntityLocations') && isCatalogEmpty; + const initialFilters = { + kind: new EntityKindFilter('component'), + user: new UserListFilter(initiallySelectedFilter), + }; return ( - setSelectedTab(label)} - /> - + {createComponentLink && ( )} - {showAddExampleEntities && ( - - )} All your software catalog entities
-
- - setSelectedSidebarItem({ label, id }) - } - initiallySelected={initiallySelectedFilter} - /> - -
- + +
+ + + +
+ +
); }; - -export const CatalogPage = (props: CatalogPageProps) => ( - - - -); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index ffd5f3d190..56d2db6bba 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -20,9 +20,13 @@ import { EDIT_URL_ANNOTATION, } from '@backstage/catalog-model'; import { act, fireEvent } from '@testing-library/react'; -import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { renderInTestApp } from '@backstage/test-utils'; import * as React from 'react'; import { CatalogTable } from './CatalogTable'; +import { + EntityListContext, + UserListFilter, +} from '@backstage/plugin-catalog-react'; const entities: Entity[] = [ { @@ -42,6 +46,14 @@ const entities: Entity[] = [ }, ]; +const emptyEntityListContext = { + entities: [], + backendEntities: [], + filters: [], + loading: false, + updateFilters: () => {}, +}; + describe('CatalogTable component', () => { beforeEach(() => { window.open = jest.fn(); @@ -51,16 +63,13 @@ describe('CatalogTable component', () => { jest.resetAllMocks(); }); - it('should render error message when error is passed in props', async () => { - const rendered = await renderWithEffects( - wrapInTestApp( - , - ), + it('should render error message', async () => { + const rendered = await renderInTestApp( + + + , ); const errorMessage = await rendered.findByText( /Could not fetch catalog entities./, @@ -69,14 +78,16 @@ describe('CatalogTable component', () => { }); it('should display entity names when loading has finished and no error occurred', async () => { - const rendered = await renderWithEffects( - wrapInTestApp( - , - ), + const rendered = await renderInTestApp( + + + , ); expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument(); expect(rendered.getByText(/component1/)).toBeInTheDocument(); @@ -94,14 +105,12 @@ describe('CatalogTable component', () => { }, }; - const { getByTitle } = await renderWithEffects( - wrapInTestApp( - , - ), + const { getByTitle } = await renderInTestApp( + + + , ); const editButton = getByTitle('Edit'); @@ -123,14 +132,12 @@ describe('CatalogTable component', () => { }, }; - const { getByTitle } = await renderWithEffects( - wrapInTestApp( - , - ), + const { getByTitle } = await renderInTestApp( + + + , ); const viewButton = getByTitle('View'); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 03dece50fe..ca45e2d07b 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,11 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Entity, - RELATION_OWNED_BY, - RELATION_PART_OF, -} from '@backstage/catalog-model'; +import { RELATION_OWNED_BY, RELATION_PART_OF } from '@backstage/catalog-model'; import { CodeSnippet, Table, @@ -28,10 +24,12 @@ import { import { formatEntityRefTitle, getEntityRelations, + useEntityListProvider, useStarredEntities, } from '@backstage/plugin-catalog-react'; import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; +import { capitalize } from 'lodash'; import React from 'react'; import { getEntityMetadataEditUrl, @@ -55,23 +53,17 @@ const defaultColumns: TableColumn[] = [ ]; type CatalogTableProps = { - entities: Entity[]; - titlePreamble: string; - loading: boolean; - error?: any; - view?: string; columns?: TableColumn[]; }; -export const CatalogTable = ({ - entities, - loading, - error, - titlePreamble, - view, - columns, -}: CatalogTableProps) => { +export const CatalogTable = ({ columns }: CatalogTableProps) => { const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); + // TODO(timbonicus): should the component loading entities register which fields it's interested in? + const { loading, error, entities, filters } = useEntityListProvider(); + + const showTypeColumn = filters.type !== undefined; + // TODO(timbonicus): this makes less sense with more complex filters, should we show filter chips instead? + const titlePreamble = capitalize(filters.user?.value ?? 'all'); if (error) { return ( @@ -152,7 +144,7 @@ export const CatalogTable = ({ const typeColumn = (columns || defaultColumns).find(c => c.title === 'Type'); if (typeColumn) { - typeColumn.hidden = view !== 'Other'; + typeColumn.hidden = !showTypeColumn; } return ( diff --git a/plugins/catalog/src/components/CatalogTable/index.ts b/plugins/catalog/src/components/CatalogTable/index.ts index 9148a22a41..280d5b4bcb 100644 --- a/plugins/catalog/src/components/CatalogTable/index.ts +++ b/plugins/catalog/src/components/CatalogTable/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * Copyright 2021 Spotify AB * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { CatalogTable } from './CatalogTable'; diff --git a/plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx b/plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx new file mode 100644 index 0000000000..e8ef1de8c6 --- /dev/null +++ b/plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect, useState } from 'react'; +import { capitalize } from 'lodash'; +import { Box } from '@material-ui/core'; +import { Select, useApi } from '@backstage/core'; +import { + catalogApiRef, + EntityTypeFilter, + useEntityListProvider, +} from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; + +export const EntityTypePicker = () => { + const catalogApi = useApi(catalogApiRef); + const { filters, updateFilters } = useEntityListProvider(); + const [types, setTypes] = useState([]); + + const kindFilter = filters.kind?.value; + + // Load all valid spec.type values straight from the catalogApi - we want the full set for the + // selected kinds, not an otherwise filtered set. + useEffect(() => { + async function loadTypesForKinds() { + if (kindFilter) { + const response = await catalogApi.getEntities({ + filter: { kind: kindFilter }, + fields: ['spec.type'], + }); + const entities: Entity[] = response.items ?? []; + const newTypes = [ + ...new Set( + entities.map(e => e.spec?.type).filter(Boolean) as string[], + ), + ].sort(); + setTypes(newTypes); + + if (filters.type && !newTypes.includes(filters.type.value)) { + updateFilters({ type: undefined }); + } + } + } + loadTypesForKinds(); + }, [filters.type, catalogApi, kindFilter, updateFilters]); + + const onChange = (value: any) => { + updateFilters({ type: new EntityTypeFilter(value) }); + }; + + if (!kindFilter) return null; + + const items = [ + { value: 'all', label: 'All' }, + ...types.map(type => ({ + value: type, + label: capitalize(type), + })), + ]; + + return ( + + From 5e0ae3428f30017cdad6219d2b12cd461ee462b5 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 12 May 2021 12:08:41 -0600 Subject: [PATCH 03/15] 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, - }; -} From 5666efa9da685403cf2249791c6bb4427e3a5298 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 12 May 2021 14:18:48 -0600 Subject: [PATCH 04/15] Move type-filtering logic to a hook Signed-off-by: Tim Hansen --- plugins/catalog-react/src/hooks/index.ts | 1 + .../src/hooks/useEntityTypeFilter.tsx | 97 +++++++++++++++++++ .../EntityTypePicker/EntityTypePicker.tsx | 62 ++---------- 3 files changed, 105 insertions(+), 55 deletions(-) create mode 100644 plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx diff --git a/plugins/catalog-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts index 77026d1245..5fbdc1ced2 100644 --- a/plugins/catalog-react/src/hooks/index.ts +++ b/plugins/catalog-react/src/hooks/index.ts @@ -21,6 +21,7 @@ export { useEntityListProvider, } from './useEntityListProvider'; export type { DefaultEntityFilters } from './useEntityListProvider'; +export { useEntityTypeFilter } from './useEntityTypeFilter'; export { useOwnUser } from './useOwnUser'; export { useRelatedEntities } from './useRelatedEntities'; export { useStarredEntities } from './useStarredEntities'; diff --git a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx new file mode 100644 index 0000000000..c5e03b90c6 --- /dev/null +++ b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '../api'; +import { + DefaultEntityFilters, + useEntityListProvider, +} from './useEntityListProvider'; +import { EntityTypeFilter } from '../types'; + +type EntityTypeReturn = { + loading: boolean; + error?: Error; + types: string[]; + selectedType: string | undefined; + setType: (type: string | undefined) => void; +}; + +/** + * A hook built on top of `useEntityListProvider` for enabling selection of valid `spec.type` values + * based on the selected EntityKindFilter. + */ +export function useEntityTypeFilter(): EntityTypeReturn { + const catalogApi = useApi(catalogApiRef); + const { + filters: { kind: kindFilter, type: typeFilter }, + updateFilters, + } = useEntityListProvider(); + + const [types, setTypes] = useState([]); + const kind = useMemo(() => kindFilter?.value, [kindFilter]); + + // Load all valid spec.type values straight from the catalogApi, paying attention to only the + // kind filter for a complete list. + const { error, loading, value: entities } = useAsync(async () => { + if (kind) { + const items = await catalogApi + .getEntities({ + filter: { kind }, + fields: ['spec.type'], + }) + .then(response => response.items); + return items; + } + return []; + }, [kind, catalogApi]); + + useEffect(() => { + // Resolve the unique set of types from returned entities; could be optimized by a new endpoint + // in the catalog-backend that does this, rather than loading entities with redundant types. + const newTypes = [ + ...new Set( + (entities ?? []).map(e => e.spec?.type).filter(Boolean) as string[], + ), + ].sort(); + setTypes(newTypes); + + // Reset type filter if no longer applicable + updateFilters((oldFilters: DefaultEntityFilters) => + oldFilters.type && !newTypes.includes(oldFilters.type.value) + ? { type: undefined } + : {}, + ); + }, [updateFilters, entities]); + + const setType = useCallback( + (type: string | undefined) => + updateFilters({ + type: type === undefined ? undefined : new EntityTypeFilter(type), + }), + [updateFilters], + ); + + return { + loading, + error, + types, + selectedType: typeFilter?.value, + setType, + }; +} diff --git a/plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx b/plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx index 94ad2323c9..d404c8ea85 100644 --- a/plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx +++ b/plugins/catalog/src/components/EntityTypePicker/EntityTypePicker.tsx @@ -14,65 +14,21 @@ * limitations under the License. */ -import React, { useEffect, useMemo, useState } from 'react'; +import React from 'react'; import { capitalize } from 'lodash'; -import { useAsync } from 'react-use'; import { Box } from '@material-ui/core'; import { alertApiRef, Select, useApi } from '@backstage/core'; -import { - catalogApiRef, - DefaultEntityFilters, - EntityTypeFilter, - useEntityListProvider, -} from '@backstage/plugin-catalog-react'; +import { useEntityTypeFilter } from '@backstage/plugin-catalog-react'; export const EntityTypePicker = () => { - const catalogApi = useApi(catalogApiRef); const alertApi = useApi(alertApiRef); - - const { - filters: { kind: kindFilter, type: typeFilter }, - updateFilters, - } = useEntityListProvider(); - const [types, setTypes] = useState([]); - - const kind = useMemo(() => kindFilter?.value, [kindFilter]); - - // Load all valid spec.type values straight from the catalogApi - we want the full set for the - // selected kinds, not an otherwise filtered set. - const { error, value: entities } = useAsync(async () => { - if (kind) { - const items = await catalogApi - .getEntities({ - filter: { kind }, - fields: ['spec.type'], - }) - .then(response => response.items); - return items; - } - return []; - }, [kind, catalogApi]); - - useEffect(() => { - const newTypes = [ - ...new Set( - (entities ?? []).map(e => e.spec?.type).filter(Boolean) as string[], - ), - ].sort(); - setTypes(newTypes); - - updateFilters((oldFilters: DefaultEntityFilters) => - oldFilters.type && !newTypes.includes(oldFilters.type.value) - ? { type: undefined } - : {}, - ); - }, [updateFilters, entities]); + const { error, types, selectedType, setType } = useEntityTypeFilter(); if (!types) return null; if (error) { alertApi.post({ - message: `Failed to load types for ${kind}`, + message: `Failed to load entity types`, severity: 'error', }); return null; @@ -80,23 +36,19 @@ export const EntityTypePicker = () => { const items = [ { value: 'all', label: 'All' }, - ...types.map(type => ({ + ...types.map((type: string) => ({ value: type, label: capitalize(type), })), ]; - const onChange = (value: any) => { - updateFilters({ type: new EntityTypeFilter(value) }); - }; - return (