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 ( + +