Remove FilterEnvironment

Use parameter properties for EntityFilter types

Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
Tim Hansen
2021-05-25 21:10:43 -06:00
parent bec8aab60e
commit 44ece9881e
11 changed files with 163 additions and 174 deletions
+1 -1
View File
@@ -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",
@@ -93,7 +93,7 @@ describe('<EntityTypePicker/>', () => {
);
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('<EntityTypePicker/>', () => {
</MockEntityListContextProvider>
</ApiProvider>,
);
const input = await rendered.getByTestId('select');
const input = rendered.getByTestId('select');
fireEvent.click(input);
await waitFor(() => rendered.getByText('Service'));
@@ -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('<UserListPicker />', () => {
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<ConfigApi>;
const mockCatalogApi = {
getEntityByName: () => Promise.resolve(mockUser),
} as Partial<CatalogApi>;
const mockIdentityApi = {
getUserId: () => '',
} as Partial<IdentityApi>;
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('<UserListPicker />', () => {
it('renders filter groups', () => {
const { queryByText } = render(
<ApiProvider apis={apis}>
@@ -138,7 +162,7 @@ describe('<UserListPicker />', () => {
it('includes counts alongside each filter', () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities, filterEnv }}>
<MockEntityListContextProvider value={{ backendEntities }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
@@ -160,7 +184,6 @@ describe('<UserListPicker />', () => {
value={{
backendEntities,
filters: { tags: new EntityTagFilter(['tag1']) },
filterEnv,
}}
>
<UserListPicker />
@@ -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;
@@ -127,7 +127,9 @@ describe('<EntityListProvider/>', () => {
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('<EntityListProvider/>', () => {
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);
@@ -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 = <EntityFilters extends DefaultEntityFilters>({
children,
}: PropsWithChildren<EntityListProviderProps<EntityFilters>>) => {
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 = <EntityFilters extends DefaultEntityFilters>({
const [entities, setEntities] = useState<Entity[]>([]);
const [backendEntities, setBackendEntities] = useState<Entity[]>([]);
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 = <EntityFilters extends DefaultEntityFilters>({
// 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 = <EntityFilters extends DefaultEntityFilters>({
<EntityListContext.Provider
value={{
filters,
filterEnv,
entities,
backendEntities,
updateFilters,
@@ -29,10 +29,6 @@ export const MockEntityListContextProvider = ({
backendEntities: [],
updateFilters: jest.fn(),
filters: {},
filterEnv: {
user: undefined,
isStarredEntity: () => false,
},
loading: false,
};
+13 -38
View File
@@ -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<string, string | string[]> {
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<string, string | string[]> {
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;
}
+2 -3
View File
@@ -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),
);
}
@@ -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 (
@@ -72,7 +72,9 @@ describe('CatalogTable component', () => {
<MockEntityListContextProvider
value={{
entities,
filters: { user: new UserListFilter('owned') },
filters: {
user: new UserListFilter('owned', undefined, () => false),
},
}}
>
<CatalogTable />