diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 3b411518fd..53e6a45134 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -43,7 +43,7 @@ }, "devDependencies": { "@backstage/cli": "^0.6.11", - "@backstage/core-api": "^0.2.18", + "@backstage/core": "^0.7.10", "@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/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index 9b40af0dbd..f33feb3a57 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -93,7 +93,7 @@ describe('', () => { ); expect(rendered.getByText('Type')).toBeInTheDocument(); - const input = await rendered.getByTestId('select'); + const input = rendered.getByTestId('select'); fireEvent.click(input); await waitFor(() => rendered.getByText('Service')); @@ -119,7 +119,7 @@ describe('', () => { , ); - const input = await rendered.getByTestId('select'); + const input = rendered.getByTestId('select'); fireEvent.click(input); await waitFor(() => rendered.getByText('Service')); diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index a85ddd232b..55ff0c199d 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -16,7 +16,11 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; -import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { + Entity, + RELATION_OWNED_BY, + UserEntity, +} from '@backstage/catalog-model'; import { UserListPicker } from './UserListPicker'; import { MockEntityListContextProvider } from '../../testUtils/providers'; import { @@ -24,90 +28,110 @@ import { ApiRegistry, ConfigApi, configApiRef, -} from '@backstage/core-api'; -import { EntityTagFilter, FilterEnvironment } from '../../types'; + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { EntityTagFilter } from '../../types'; +import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiRef } from '../../api'; +import { MockStorageApi } from '@backstage/test-utils'; -const apis = ApiRegistry.from([ - [ - configApiRef, - ({ - getOptionalString: jest.fn( - (key: string) => - ({ - 'organization.name': 'Test Company', - }[key]), - ), - } as unknown) as ConfigApi, - ], -]); - -const filterEnv: FilterEnvironment = { - user: { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - namespace: 'default', - name: 'testUser', - }, - spec: { - memberOf: [], - }, +const mockUser: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + namespace: 'default', + name: 'testUser', + }, + spec: { + memberOf: [], }, - isStarredEntity: (entity: Entity) => entity.metadata.name === 'component-3', }; -describe('', () => { - const backendEntities: Entity[] = [ - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-1', - name: 'component-1', - tags: ['tag1'], - }, - relations: [ - { - type: RELATION_OWNED_BY, - target: { kind: 'User', namespace: 'default', name: 'testUser' }, - }, - ], - }, - { - apiVersion: '1', - kind: 'Component', - metadata: { - namespace: 'namespace-2', - name: 'component-2', - tags: ['tag1'], - }, - }, - { - 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' }, - }, - ], - }, - ]; +const mockConfigApi = { + getOptionalString: () => 'Test Company', +} as Partial; +const mockCatalogApi = { + getEntityByName: () => Promise.resolve(mockUser), +} as Partial; + +const mockIdentityApi = { + getUserId: () => '', +} as Partial; + +const apis = ApiRegistry.from([ + [configApiRef, mockConfigApi], + [catalogApiRef, mockCatalogApi], + [identityApiRef, mockIdentityApi], + [storageApiRef, MockStorageApi.create()], +]); + +jest.mock('../../hooks', () => { + const actual = jest.requireActual('../../hooks'); + return { + ...actual, + useOwnUser: () => ({ value: mockUser }), + useStarredEntities: () => ({ + isStarredEntity: (entity: Entity) => + entity.metadata.name === 'component-3', + }), + }; +}); + +const backendEntities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'namespace-1', + name: 'component-1', + tags: ['tag1'], + }, + relations: [ + { + type: RELATION_OWNED_BY, + target: { kind: 'User', namespace: 'default', name: 'testUser' }, + }, + ], + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'namespace-2', + name: 'component-2', + tags: ['tag1'], + }, + }, + { + 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' }, + }, + ], + }, +]; + +describe('', () => { it('renders filter groups', () => { const { queryByText } = render( @@ -138,7 +162,7 @@ describe('', () => { it('includes counts alongside each filter', () => { const { getAllByRole } = render( - + , @@ -160,7 +184,6 @@ describe('', () => { value={{ backendEntities, filters: { tags: new EntityTagFilter(['tag1']) }, - filterEnv, }} > diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx index 4ef57b7a48..129e5e6b1a 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx @@ -14,11 +14,15 @@ * limitations under the License. */ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; import { compact } from 'lodash'; import { configApiRef, IconComponent, useApi } from '@backstage/core'; import { UserListFilter, UserListFilterKind } from '../../types'; -import { useEntityListProvider } from '../../hooks'; +import { + useEntityListProvider, + useOwnUser, + useStarredEntities, +} from '../../hooks'; import { Card, List, @@ -96,22 +100,26 @@ function getFilterGroups(orgName: string | undefined): ButtonGroup[] { ]; } -// 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); - const { - filters, - updateFilters, - backendEntities, - filterEnv, - } = useEntityListProvider(); + const { value: user } = useOwnUser(); + const { isStarredEntity } = useStarredEntities(); + + // Static filters; used for generating counts of potentially unselected kinds + const ownedFilter = useMemo( + () => new UserListFilter('owned', user, isStarredEntity), + [user, isStarredEntity], + ); + const starredFilter = useMemo( + () => new UserListFilter('starred', user, isStarredEntity), + [user, isStarredEntity], + ); + + const { filters, updateFilters, backendEntities } = useEntityListProvider(); // To show proper counts for each section, apply all other frontend filters _except_ the user // filter that's controlled by this picker. @@ -121,23 +129,22 @@ export const UserListPicker = () => { useEffect(() => { const filterFn = reduceEntityFilters( compact(Object.values({ ...filters, user: undefined })), - filterEnv, ); setEntitiesWithoutUserFilter(backendEntities.filter(filterFn)); - }, [filters, backendEntities, filterEnv]); + }, [filters, backendEntities]); function setSelectedFilter({ id }: { id: UserListFilterKind }) { - updateFilters({ user: new UserListFilter(id) }); + updateFilters({ user: new UserListFilter(id, user, isStarredEntity) }); } function getFilterCount(id: UserListFilterKind) { switch (id) { case 'owned': return entitiesWithoutUserFilter.filter(entity => - ownedFilter.filterEntity(entity, filterEnv), + ownedFilter.filterEntity(entity), ).length; case 'starred': return entitiesWithoutUserFilter.filter(entity => - starredFilter.filterEntity(entity, filterEnv), + starredFilter.filterEntity(entity), ).length; default: return entitiesWithoutUserFilter.length; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index ae0f7d3a25..0b17e0e48b 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -127,7 +127,9 @@ describe('', () => { const { result, waitFor } = renderHook(() => useEntityListProvider(), { wrapper, initialProps: { - initialFilters: { user: new UserListFilter('owned') }, + initialFilters: { + user: new UserListFilter('owned', mockUser, () => true), + }, }, }); await waitFor(() => !!result.current.entities.length); @@ -147,7 +149,9 @@ describe('', () => { expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); act(() => - result.current.updateFilters({ user: new UserListFilter('owned') }), + result.current.updateFilters({ + user: new UserListFilter('owned', mockUser, () => true), + }), ); expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); expect(result.current.entities.length).toBe(1); diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 99cebd6310..2cfeeca57c 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -20,7 +20,6 @@ import React, { useCallback, useContext, useEffect, - useMemo, useState, } from 'react'; import { useAsyncFn, useDebounce } from 'react-use'; @@ -33,11 +32,8 @@ import { EntityKindFilter, EntityTagFilter, EntityTypeFilter, - FilterEnvironment, UserListFilter, } from '../types'; -import { useOwnUser } from './useOwnUser'; -import { useStarredEntities } from './useStarredEntities'; import { compact, isEqual } from 'lodash'; export type DefaultEntityFilters = { @@ -56,12 +52,6 @@ export type EntityListContextProps< */ filters: EntityFilters; - /** - * The filter environment passed to frontend filters; contains information available in the React - * tree. - */ - filterEnv: FilterEnvironment; - /** * The resolved list of catalog entities, after all filters are applied. */ @@ -101,8 +91,6 @@ export const EntityListProvider = ({ children, }: PropsWithChildren>) => { const catalogApi = useApi(catalogApiRef); - const { value: user } = useOwnUser(); - const { isStarredEntity } = useStarredEntities(); // TODO(timbonicus): is it possible to register initial filters from query params? e.g. if the // query key matches the generic definition, call a constructor with the value @@ -113,14 +101,6 @@ export const EntityListProvider = ({ 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< @@ -153,10 +133,10 @@ export const EntityListProvider = ({ // Apply frontend filters useEffect(() => { const resolvedEntities = (backendEntities ?? []).filter( - reduceEntityFilters(compact(Object.values(filters)), filterEnv), + reduceEntityFilters(compact(Object.values(filters))), ); setEntities(resolvedEntities); - }, [backendEntities, filterEnv, filters]); + }, [backendEntities, filters]); const updateFilters = useCallback( ( @@ -177,7 +157,6 @@ export const EntityListProvider = ({ false, - }, loading: false, }; diff --git a/plugins/catalog-react/src/types.ts b/plugins/catalog-react/src/types.ts index e83eadb3fc..98ea2fb9b0 100644 --- a/plugins/catalog-react/src/types.ts +++ b/plugins/catalog-react/src/types.ts @@ -17,11 +17,6 @@ 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 @@ -39,33 +34,19 @@ export type EntityFilter = { * @param entity * @param env */ - filterEntity?: (entity: Entity, env: FilterEnvironment) => boolean; + filterEntity?: (entity: Entity) => boolean; }; export class EntityKindFilter implements EntityFilter { - private readonly _value: string; - constructor(kind: string) { - this._value = kind; - } - - get value() { - return this._value; - } + constructor(readonly value: string) {} getCatalogFilters(): Record { - return { kind: this._value }; + return { kind: this.value }; } } export class EntityTypeFilter implements EntityFilter { - private _value: string; - constructor(type: string) { - this._value = type; - } - - get value() { - return this._value; - } + constructor(readonly value: string) {} getCatalogFilters(): Record { return { 'spec.type': this.value }; @@ -73,14 +54,7 @@ export class EntityTypeFilter implements EntityFilter { } export class EntityTagFilter implements EntityFilter { - private _values: string[]; - constructor(values: string[]) { - this._values = values; - } - - get values() { - return this._values; - } + constructor(readonly values: string[]) {} filterEntity(entity: Entity): boolean { return this.values.every(v => (entity.metadata.tags ?? []).includes(v)); @@ -89,17 +63,18 @@ export class EntityTagFilter implements EntityFilter { export type UserListFilterKind = 'owned' | 'starred' | 'all'; export class UserListFilter implements EntityFilter { - readonly value: UserListFilterKind; - constructor(value: UserListFilterKind) { - this.value = value; - } + constructor( + readonly value: UserListFilterKind, + readonly user: UserEntity | undefined, + readonly isStarredEntity: (entity: Entity) => boolean, + ) {} - filterEntity(entity: Entity, env: FilterEnvironment): boolean { + filterEntity(entity: Entity): boolean { switch (this.value) { case 'owned': - return env.user !== undefined && isOwnerOf(env.user, entity); + return this.user !== undefined && isOwnerOf(this.user, entity); case 'starred': - return env.isStarredEntity(entity); + return this.isStarredEntity(entity); default: return true; } diff --git a/plugins/catalog-react/src/utils/filters.ts b/plugins/catalog-react/src/utils/filters.ts index afe2b0b3d3..73f1763bb1 100644 --- a/plugins/catalog-react/src/utils/filters.ts +++ b/plugins/catalog-react/src/utils/filters.ts @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { EntityFilter, FilterEnvironment } from '../types'; +import { EntityFilter } from '../types'; export function reduceCatalogFilters( filters: EntityFilter[], @@ -30,10 +30,9 @@ export function reduceCatalogFilters( export function reduceEntityFilters( filters: EntityFilter[], - env: FilterEnvironment, ): (entity: Entity) => boolean { return (entity: Entity) => filters.every( - filter => !filter.filterEntity || filter.filterEntity(entity, env), + filter => !filter.filterEntity || filter.filterEntity(entity), ); } diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 3569a3792c..95bd857221 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -29,9 +29,11 @@ import { EntityListProvider, EntityTagPicker, EntityTypePicker, + useOwnUser, UserListFilter, UserListFilterKind, UserListPicker, + useStarredEntities, } from '@backstage/plugin-catalog-react'; import { createComponentRouteRef } from '../../routes'; @@ -62,9 +64,11 @@ export const CatalogPage = ({ }: CatalogPageProps) => { const styles = useStyles(); const createComponentLink = useRouteRef(createComponentRouteRef); + const { value: user } = useOwnUser(); + const { isStarredEntity } = useStarredEntities(); const initialFilters = { kind: new EntityKindFilter('component'), - user: new UserListFilter(initiallySelectedFilter), + user: new UserListFilter(initiallySelectedFilter, user, isStarredEntity), }; return ( diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index ce8447cf6d..1b40aa498b 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -72,7 +72,9 @@ describe('CatalogTable component', () => { false), + }, }} >