Remove FilterEnvironment
Use parameter properties for EntityFilter types Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user