Add useEntityListProvider hook

Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
Tim Hansen
2021-05-12 11:57:01 -06:00
parent 1cfef4244e
commit 23114cf9c3
24 changed files with 1161 additions and 217 deletions
+2
View File
@@ -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",
@@ -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('<EntityTagPicker/>', () => {
it('renders all tags', () => {
const rendered = render(
<MockEntityListContextProvider
value={{ entities: taggedEntities, backendEntities: taggedEntities }}
>
<EntityTagPicker />
</MockEntityListContextProvider>,
);
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(
<MockEntityListContextProvider
value={{
entities: taggedEntities,
backendEntities: taggedEntities,
updateFilters,
}}
>
<EntityTagPicker />
</MockEntityListContextProvider>,
);
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(
<MockEntityListContextProvider
value={{
entities: taggedEntities,
backendEntities: taggedEntities,
updateFilters,
filters: { tags: new EntityTagFilter(['tag1']) },
}}
>
<EntityTagPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
expect(rendered.getByLabelText('tag1')).toBeChecked();
fireEvent.click(rendered.getByText('tag1'));
expect(updateFilters).toHaveBeenLastCalledWith({
tags: undefined,
});
});
});
@@ -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>(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 (
<>
<Typography variant="subtitle2" className={classes.title}>
Tags
</Typography>
<List disablePadding dense>
{availableTags.map(tag => {
const labelId = `checkbox-list-label-${tag}`;
return (
<ListItem key={tag} dense button onClick={() => onClick(tag)}>
<Checkbox
edge="start"
color="primary"
checked={(filters.tags?.values ?? []).includes(tag)}
tabIndex={-1}
disableRipple
className={classes.checkbox}
inputProps={{ 'aria-labelledby': labelId }}
/>
<ListItemText id={labelId} primary={tag} />
</ListItem>
);
})}
</List>
</>
);
};
@@ -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';
@@ -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('<UserListPicker />', () => {
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(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(queryByText('Personal')).toBeInTheDocument();
expect(queryByText('Test Company')).toBeInTheDocument();
});
it('renders filters', () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(
getAllByRole('menuitem').map(({ textContent }) => textContent),
).toEqual(['Owned', 'Starred', 'All']);
});
it('includes counts alongside each filter', () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
// 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(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters }}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
fireEvent.click(getByText('Starred'));
expect(updateFilters).toHaveBeenCalledTimes(1);
expect(updateFilters.mock.calls[0][0].user.value).toEqual('starred');
});
});
@@ -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>(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 (
<Card className={classes.root}>
{filterGroups.map(group => (
<Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense>
{group.items.map(item => (
<MenuItem
key={item.id}
button
divider
onClick={() => setSelectedFilter(item)}
selected={item.id === filters.user?.value}
className={classes.menuItem}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography variant="body1" className={classes.menuTitle}>
{item.label}
</Typography>
</ListItemText>
<ListItemSecondaryAction>
{getFilterCount(item.id) ?? '-'}
</ListItemSecondaryAction>
</MenuItem>
))}
</List>
</Card>
</Fragment>
))}
</Card>
);
};
@@ -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';
@@ -16,3 +16,5 @@
export * from './EntityProvider';
export * from './EntityRefLink';
export * from './EntityTable';
export * from './EntityTagPicker';
export * from './UserListPicker';
+6
View File
@@ -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';
@@ -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<EntityFilters>) => void;
loading: boolean;
error?: Error;
};
export const EntityListContext = createContext<
EntityListContextProps<any> | undefined
>(undefined);
export type EntityListProviderProps<
EntityFilters extends DefaultEntityFilters
> = {
initialFilters?: EntityFilters;
};
export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
initialFilters,
children,
}: PropsWithChildren<EntityListProviderProps<EntityFilters>>) => {
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<EntityFilters>(
initialFilters ?? ({} as EntityFilters),
);
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<
Record<string, string | string[]>
>(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<EntityFilter>) =>
setFilters(prevFilters => ({ ...prevFilters, ...patch })),
[],
);
return (
<EntityListContext.Provider
value={{
filters,
entities,
backendEntities,
updateFilters,
loading,
error,
}}
>
{children}
</EntityListContext.Provider>
);
};
export function useEntityListProvider<
EntityFilters extends DefaultEntityFilters
>(): EntityListContextProps<EntityFilters> {
const context = useContext(EntityListContext);
if (!context)
throw new Error(
'useEntityListProvider must be used within EntityListProvider',
);
return context;
}
@@ -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.
+1
View File
@@ -24,4 +24,5 @@ export {
entityRouteRef,
rootRoute,
} from './routes';
export * from './types';
export * from './utils';
@@ -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<EntityListContextProps> }>) => {
const defaultContext: EntityListContextProps = {
entities: [],
backendEntities: [],
updateFilters: jest.fn(),
filters: {},
loading: false,
};
return (
<EntityListContext.Provider value={{ ...defaultContext, ...value }}>
{children}
</EntityListContext.Provider>
);
};
+107
View File
@@ -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<string, string | string[]>;
/**
* 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<string, string | string[]> {
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<string, string | string[]> {
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;
}
}
}
@@ -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<string, string | string[]> {
return filters.reduce((compoundFilter, filter) => {
return {
...compoundFilter,
...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}),
};
}, {} as Record<string, string | string[]>);
}
export function reduceEntityFilters(
filters: EntityFilter[],
env: FilterEnvironment,
): (entity: Entity) => boolean {
return (entity: Entity) =>
filters.every(
filter => !filter.filterEntity || filter.filterEntity(entity, env),
);
}
+1
View File
@@ -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';
+1
View File
@@ -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",
@@ -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<EntityRow>[];
};
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<string>();
const [
selectedSidebarItem,
setSelectedSidebarItem,
] = useState<CatalogFilterType>();
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<unknown>[] = [];
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<LabeledComponentType[]>(
() => [
{
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<ButtonGroup[]>(
() => [
{
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 (
<CatalogLayout>
<CatalogTabs
tabs={tabs}
onChange={({ label }) => setSelectedTab(label)}
/>
<Content>
<ContentHeader title={selectedTab ?? ''}>
<ContentHeader title="Components">
{createComponentLink && (
<Button
component={RouterLink}
@@ -187,45 +81,19 @@ const CatalogPageContents = (props: CatalogPageProps) => {
Create Component
</Button>
)}
{showAddExampleEntities && (
<Button
className={styles.buttonSpacing}
variant="outlined"
color="primary"
onClick={addMockData}
>
Add example components
</Button>
)}
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<div>
<CatalogFilter
buttonGroups={filterGroups}
onChange={({ label, id }) =>
setSelectedSidebarItem({ label, id })
}
initiallySelected={initiallySelectedFilter}
/>
<ResultsFilter availableTags={availableTags} />
</div>
<CatalogTable
titlePreamble={selectedSidebarItem?.label ?? ''}
view={selectedTab}
columns={props.columns}
entities={matchingEntities}
loading={loading}
error={error}
/>
<EntityListProvider initialFilters={initialFilters}>
<div>
<EntityTypePicker />
<UserListPicker />
<EntityTagPicker />
</div>
<CatalogTable columns={columns} />
</EntityListProvider>
</div>
</Content>
</CatalogLayout>
);
};
export const CatalogPage = (props: CatalogPageProps) => (
<EntityFilterGroupsProvider>
<CatalogPageContents {...props} />
</EntityFilterGroupsProvider>
);
@@ -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(
<CatalogTable
titlePreamble="Owned"
entities={[]}
loading={false}
error={{ code: 'error' }}
/>,
),
it('should render error message', async () => {
const rendered = await renderInTestApp(
<EntityListContext.Provider
value={{ ...emptyEntityListContext, error: new Error('error') }}
>
<CatalogTable />
</EntityListContext.Provider>,
);
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(
<CatalogTable
titlePreamble="Owned"
entities={entities}
loading={false}
/>,
),
const rendered = await renderInTestApp(
<EntityListContext.Provider
value={{
...emptyEntityListContext,
entities,
filters: { user: new UserListFilter('owned') },
}}
>
<CatalogTable />
</EntityListContext.Provider>,
);
expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument();
expect(rendered.getByText(/component1/)).toBeInTheDocument();
@@ -94,14 +105,12 @@ describe('CatalogTable component', () => {
},
};
const { getByTitle } = await renderWithEffects(
wrapInTestApp(
<CatalogTable
titlePreamble="Owned"
entities={[entity]}
loading={false}
/>,
),
const { getByTitle } = await renderInTestApp(
<EntityListContext.Provider
value={{ ...emptyEntityListContext, entities: [entity] }}
>
<CatalogTable />
</EntityListContext.Provider>,
);
const editButton = getByTitle('Edit');
@@ -123,14 +132,12 @@ describe('CatalogTable component', () => {
},
};
const { getByTitle } = await renderWithEffects(
wrapInTestApp(
<CatalogTable
titlePreamble="Owned"
entities={[entity]}
loading={false}
/>,
),
const { getByTitle } = await renderInTestApp(
<EntityListContext.Provider
value={{ ...emptyEntityListContext, entities: [entity] }}
>
<CatalogTable />
</EntityListContext.Provider>,
);
const viewButton = getByTitle('View');
@@ -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<EntityRow>[] = [
];
type CatalogTableProps = {
entities: Entity[];
titlePreamble: string;
loading: boolean;
error?: any;
view?: string;
columns?: TableColumn<EntityRow>[];
};
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 (
@@ -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';
@@ -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<string[]>([]);
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 (
<Box pb={1} pt={1}>
<Select
label="Type"
items={items}
selected={filters.type?.value ?? 'all'}
onChange={onChange}
/>
</Box>
);
};
@@ -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 { EntityTypePicker } from './EntityTypePicker';
+1
View File
@@ -19,6 +19,7 @@ export { EntityLayout } from './components/EntityLayout';
export { EntityPageLayout } from './components/EntityPageLayout';
export { CatalogTable } from './components/CatalogTable';
export * from './components/EntitySwitch';
export { EntityTypePicker } from './components/EntityTypePicker';
export { Router } from './components/Router';
export {
CatalogEntityPage,