Add useEntityListProvider hook
Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user