Merge pull request #5643 from backstage/timbonicus/catalog-entity-context

Add EntityListProvider with hooks/filters
This commit is contained in:
Tim
2021-05-26 21:50:48 -06:00
committed by GitHub
48 changed files with 1929 additions and 1676 deletions
+24
View File
@@ -0,0 +1,24 @@
---
'@backstage/plugin-catalog': minor
'@backstage/plugin-catalog-react': minor
---
The default `CatalogPage` has been reworked to be more composable and make
customization easier. This change only affects those who have replaced the
default `CatalogPage` with a custom implementation; others can safely ignore the
rest of this changelog.
If you created a custom `CatalogPage` to **add or remove tabs** from the
catalog, a custom page is no longer necessary. The fixed tabs have been replaced
with a `spec.type` dropdown that shows all available `Component` types in the
catalog.
For other needs, customizing the `CatalogPage` should now be easier. The new
[CatalogPage.tsx](https://github.com/backstage/backstage/blob/9a4baa74509b6452d7dc054d34cf079f9997166d/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx)
shows the default implementation. Overriding this with your own, similar
`CatalogPage` component in your `App.tsx` routing allows you to adjust the
layout, header, and which filters are available.
See the documentation added on [Catalog
Customization](https://backstage.io/docs/features/software-catalog/catalog-customization)
for instructions.
@@ -0,0 +1,183 @@
---
id: catalog-customization
title: Catalog Customization
# prettier-ignore
description: How to add custom filters or interface elements to the Backstage software catalog
---
The Backstage software catalog comes with a default `CatalogIndexPage` to filter
and find catalog entities. This is already set up by default by
`@backstage/create-app`.
If you want to change the default index page - such as to add a custom filter to
the catalog - you can replace the routing in `App.tsx` to point to your own
`CatalogIndexPage`.
> Note: The catalog index page is designed to have a minimal code footprint to
> support easy customization, but creating a copy does introduce a possibility
> of drifting out of date over time. Be sure to check the catalog
> [CHANGELOG](https://github.com/backstage/backstage/blob/master/plugins/catalog/CHANGELOG.md)
> periodically.
For example, suppose that I want to allow filtering by a custom annotation added
to entities, `company.com/security-tier`. To start, I'll copy the code for the
default catalog page and create a component in a
[new plugin](../../plugins/create-a-plugin.md):
```tsx
// imports, etc omitted for brevity. for full source see:
// https://github.com/backstage/backstage/blob/master/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
export const CustomCatalogPage = () => {
return (
<CatalogLayout>
<Content>
<ContentHeader title="Components">
<CreateComponentButton />
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<EntityListProvider>
<div>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker />
<EntityTagPicker />
</div>
<CatalogTable />
</EntityListProvider>
</div>
</Content>
</CatalogLayout>
);
};
```
The `EntityListProvider` shown here provides a list of entities from the
`catalog-backend`, and a way to hook in filters.
Now we're ready to create a new filter that implements the `EntityFilter`
interface:
```ts
import { EntityFilter } from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
class EntitySecurityTierFilter implements EntityFilter {
constructor(readonly values: string[]) {}
filterEntity(entity: Entity): boolean {
const tier = entity.metadata.annotations?.['company.com/security-tier'];
return tier !== undefined && this.values.includes(tier);
}
}
```
The `EntityFilter` interface permits backend filters, which are passed along to
the `catalog-backend` - or frontend filters, which are applied after entities
are loaded from the backend.
We'll use this filter to extend the default filters in a type-safe way. Let's
create the custom filter shape extending the default somewhere alongside this
filter:
```ts
export type CustomFilters = DefaultEntityFilters & {
securityTiers?: EntitySecurityTierFilter;
};
```
To control this filter, we can create a React component that shows checkboxes
for the security tiers. This component will make use of the
`useEntityListProvider` hook, which accepts this extended filter type as a
[generic](https://www.typescriptlang.org/docs/handbook/2/generics.html)
parameter:
```tsx
export const EntitySecurityTierPicker = () => {
// The securityTiers key is recognized due to the CustomFilter generic
const {
filters: { securityTiers },
updateFilters,
} = useEntityListProvider<CustomFilters>();
// Toggles the value, depending on whether it's already selected
function onChange(value: string) {
const newTiers = securityTiers?.values.includes(value)
? securityTiers.values.filter(tier => tier !== value)
: [...(securityTiers?.values ?? []), value];
updateFilters({
securityTiers: newTiers.length
? new EntitySecurityTierFilter(newTiers)
: undefined,
});
}
const tierOptions = ['1', '2', '3'];
return (
<FormControl component="fieldset">
<Typography variant="button">Security Tier</Typography>
<FormGroup>
{tierOptions.map(tier => (
<FormControlLabel
key={tier}
control={
<Checkbox
checked={securityTiers?.values.includes(tier)}
onChange={() => onChange(tier)}
/>
}
label={`Tier ${tier}`}
/>
))}
</FormGroup>
</FormControl>
);
};
```
Now we can add the component to `CustomCatalogPage`:
```diff
export const CustomCatalogPage = () => {
return (
...
<EntityListProvider>
<div>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker />
+ <EntitySecurityTierPicker />
<EntityTagPicker />
</div>
<CatalogTable />
</EntityListProvider>
...
};
```
This page itself can be exported as a routable extension in the plugin:
```ts
export const CustomCatalogIndexPage = myPlugin.provide(
createRoutableExtension({
component: () =>
import('./components/CustomCatalogPage').then(m => m.CustomCatalogPage),
mountPoint: catalogRouteRef,
}),
);
```
Finally, we can replace the catalog route in the Backstage application with our
new `CustomCatalogIndexPage`.
```diff
# packages/app/src/App.tsx
const routes = (
<FlatRoutes>
<Navigate key="/" to="/catalog" />
- <Route path="/catalog" element={<CatalogIndexPage />} />
+ <Route path="/catalog" element={<CustomCatalogIndexPage />} />
```
The same method can be used to customize the _default_ filters with a different
interface - for such usage, the generic argument isn't needed since the filter
shape remains the same as the default.
+1
View File
@@ -43,6 +43,7 @@
"features/software-catalog/well-known-statuses",
"features/software-catalog/extending-the-model",
"features/software-catalog/external-integrations",
"features/software-catalog/catalog-customization",
"features/software-catalog/software-catalog-api"
]
},
+1
View File
@@ -40,6 +40,7 @@ nav:
- Well-known Statuses: 'features/software-catalog/well-known-statuses.md'
- Extending the model: 'features/software-catalog/extending-the-model.md'
- External integrations: 'features/software-catalog/external-integrations.md'
- Catalog Customization: 'features/software-catalog/catalog-customization.md'
- API: 'features/software-catalog/api.md'
- Kubernetes:
- Overview: 'features/kubernetes/index.md'
+3
View File
@@ -32,6 +32,8 @@
"@backstage/catalog-model": "^0.7.9",
"@backstage/core": "^0.7.9",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"lodash": "^4.17.15",
"react": "^16.13.1",
@@ -41,6 +43,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.6.11",
"@backstage/core": "^0.7.10",
"@backstage/dev-utils": "^0.1.14",
"@backstage/test-utils": "^0.1.11",
"@testing-library/jest-dom": "^5.10.1",
@@ -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 { render } from '@testing-library/react';
import React from 'react';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityKindFilter } from '../../types';
import { EntityKindPicker } from './EntityKindPicker';
describe('<EntityKindPicker/>', () => {
it('sets the selected kind filter', async () => {
const updateFilters = jest.fn();
render(
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityKindPicker initialFilter="component" hidden />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('component'),
});
});
});
@@ -0,0 +1,46 @@
/*
* 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 { Alert } from '@material-ui/lab';
import { useEntityListProvider } from '../../hooks';
import { EntityKindFilter } from '../../types';
type EntityKindFilterProps = {
initialFilter?: string;
hidden: boolean;
};
export const EntityKindPicker = ({
initialFilter,
hidden,
}: EntityKindFilterProps) => {
const [selectedKind] = useState(initialFilter);
const { updateFilters } = useEntityListProvider();
useEffect(() => {
updateFilters({
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
});
}, [selectedKind, updateFilters]);
if (hidden) return null;
// TODO(timbonicus): This should load available kinds from the catalog-backend, similar to
// EntityTypePicker.
return <Alert severity="warning">Kind filter not yet available</Alert>;
};
@@ -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.
@@ -14,15 +14,4 @@
* limitations under the License.
*/
export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
export type {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
FilterGroupStatesError,
FilterGroupStatesLoading,
FilterGroupStatesReady,
} from './types';
export { useEntityFilterGroup } from './useEntityFilterGroup';
export { useFilteredEntities } from './useFilteredEntities';
export { EntityKindPicker } from './EntityKindPicker';
@@ -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 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();
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
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.getByTestId('tag-picker-expand'));
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();
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
expect(rendered.getByLabelText('tag1')).toBeChecked();
fireEvent.click(rendered.getByLabelText('tag1'));
expect(updateFilters).toHaveBeenLastCalledWith({
tags: undefined,
});
});
});
@@ -0,0 +1,82 @@
/*
* 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,
FormControlLabel,
TextField,
Typography,
} from '@material-ui/core';
import { Autocomplete } from '@material-ui/lab';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Entity } from '@backstage/catalog-model';
import { EntityTagFilter } from '../../types';
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
export const EntityTagPicker = () => {
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 onChange = (tags: string[]) => {
updateFilters({
tags: tags.length ? new EntityTagFilter(tags) : undefined,
});
};
return (
<>
<Typography variant="button">Tags</Typography>
<Autocomplete<string>
multiple
options={availableTags}
value={filters.tags?.values ?? []}
onChange={(_: object, value: string[]) => onChange(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
label={option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="tag-picker-expand" />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</>
);
};
@@ -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,137 @@
/*
* 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, waitFor } from '@testing-library/react';
import { capitalize } from 'lodash';
import {
AlertApi,
alertApiRef,
ApiProvider,
ApiRegistry,
} from '@backstage/core';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { EntityTypePicker } from './EntityTypePicker';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { catalogApiRef } from '../../api';
import { EntityKindFilter, EntityTypeFilter } from '../../types';
const entities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
spec: {
type: 'service',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
spec: {
type: 'website',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-3',
},
spec: {
type: 'library',
},
},
];
const apis = ApiRegistry.from([
[
catalogApiRef,
({
getEntities: jest
.fn()
.mockImplementation(() => Promise.resolve({ items: entities })),
} as unknown) as CatalogApi,
],
[
alertApiRef,
({
post: jest.fn(),
} as unknown) as AlertApi,
],
]);
describe('<EntityTypePicker/>', () => {
it('renders available entity types', async () => {
const rendered = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ filters: { kind: new EntityKindFilter('component') } }}
>
<EntityTypePicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(rendered.getByText('Type')).toBeInTheDocument();
const input = rendered.getByTestId('select');
fireEvent.click(input);
await waitFor(() => rendered.getByText('Service'));
entities.forEach(entity => {
expect(
rendered.getByText(capitalize(entity.spec!.type as string)),
).toBeInTheDocument();
});
});
it('sets the selected type filter', async () => {
const updateFilters = jest.fn();
const rendered = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
filters: { kind: new EntityKindFilter('component') },
updateFilters,
}}
>
<EntityTypePicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
const input = rendered.getByTestId('select');
fireEvent.click(input);
await waitFor(() => rendered.getByText('Service'));
fireEvent.click(rendered.getByText('Service'));
expect(updateFilters).toHaveBeenLastCalledWith({
type: new EntityTypeFilter('service'),
});
fireEvent.click(input);
fireEvent.click(rendered.getByText('All'));
expect(updateFilters).toHaveBeenLastCalledWith({ type: undefined });
});
});
@@ -0,0 +1,55 @@
/*
* 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 { capitalize } from 'lodash';
import { Box } from '@material-ui/core';
import { alertApiRef, Select, useApi } from '@backstage/core';
import { useEntityTypeFilter } from '../../hooks/useEntityTypeFilter';
export const EntityTypePicker = () => {
const alertApi = useApi(alertApiRef);
const { error, types, selectedType, setType } = useEntityTypeFilter();
if (!types) return null;
if (error) {
alertApi.post({
message: `Failed to load entity types`,
severity: 'error',
});
return null;
}
const items = [
{ value: 'all', label: 'All' },
...types.map((type: string) => ({
value: type,
label: capitalize(type),
})),
];
return (
<Box pb={1} pt={1}>
<Select
label="Type"
items={items}
selected={selectedType ?? 'all'}
onChange={value => setType(value === 'all' ? undefined : String(value))}
/>
</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';
@@ -0,0 +1,221 @@
/*
* 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,
UserEntity,
} from '@backstage/catalog-model';
import { UserListPicker } from './UserListPicker';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import {
ApiProvider,
ApiRegistry,
ConfigApi,
configApiRef,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { EntityTagFilter, UserListFilter } from '../../types';
import { CatalogApi } from '@backstage/catalog-client';
import { catalogApiRef } from '../../api';
import { MockStorageApi } from '@backstage/test-utils';
const mockUser: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
namespace: 'default',
name: 'testUser',
},
spec: {
memberOf: [],
},
};
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()],
]);
const mockIsStarredEntity = (entity: Entity) =>
entity.metadata.name === 'component-3';
jest.mock('../../hooks', () => {
const actual = jest.requireActual('../../hooks');
return {
...actual,
useOwnUser: () => ({ value: mockUser }),
useStarredEntities: () => ({
isStarredEntity: mockIsStarredEntity,
}),
};
});
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}>
<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('respects other frontend filters in counts', () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
filters: { tags: new EntityTagFilter(['tag1']) },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(
getAllByRole('menuitem').map(
({ nextSibling }) => nextSibling?.textContent,
),
).toEqual(['1', '0', '2']);
});
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).toHaveBeenLastCalledWith({
user: new UserListFilter('starred', mockUser, mockIsStarredEntity),
});
});
});
@@ -0,0 +1,206 @@
/*
* 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, useEffect, useMemo, useState } from 'react';
import { compact } from 'lodash';
import { configApiRef, IconComponent, useApi } from '@backstage/core';
import { 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';
import { reduceEntityFilters } from '../../utils';
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),
},
}));
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',
},
],
},
];
}
type UserListPickerProps = {
initialFilter?: UserListFilterKind;
};
export const UserListPicker = ({ initialFilter }: UserListPickerProps) => {
const classes = useStyles();
const configApi = useApi(configApiRef);
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
const filterGroups = getFilterGroups(orgName);
const { value: user } = useOwnUser();
const { isStarredEntity } = useStarredEntities();
const [selectedUserFilter, setSelectedUserFilter] = useState(initialFilter);
// 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();
useEffect(() => {
updateFilters({
user: selectedUserFilter
? new UserListFilter(selectedUserFilter, user, isStarredEntity)
: undefined,
});
}, [selectedUserFilter, user, isStarredEntity, updateFilters]);
// To show proper counts for each section, apply all other frontend filters _except_ the user
// filter that's controlled by this picker.
const [entitiesWithoutUserFilter, setEntitiesWithoutUserFilter] = useState(
backendEntities,
);
useEffect(() => {
const filterFn = reduceEntityFilters(
compact(Object.values({ ...filters, user: undefined })),
);
setEntitiesWithoutUserFilter(backendEntities.filter(filterFn));
}, [filters, backendEntities]);
function getFilterCount(id: UserListFilterKind) {
switch (id) {
case 'owned':
return entitiesWithoutUserFilter.filter(entity =>
ownedFilter.filterEntity(entity),
).length;
case 'starred':
return entitiesWithoutUserFilter.filter(entity =>
starredFilter.filterEntity(entity),
).length;
default:
return entitiesWithoutUserFilter.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={() => setSelectedUserFilter(item.id)}
selected={item.id === filters.user?.value}
className={classes.menuItem}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography
variant="body1"
data-testid={`user-picker-${item.id}`}
>
{item.label}
</Typography>
</ListItemText>
<ListItemSecondaryAction>
{getFilterCount(item.id) ?? '-'}
</ListItemSecondaryAction>
</MenuItem>
))}
</List>
</Card>
</Fragment>
))}
</Card>
);
};
@@ -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.
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { CatalogFilter } from './CatalogFilter';
export { UserListPicker } from './UserListPicker';
@@ -13,6 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './EntityKindPicker';
export * from './EntityProvider';
export * from './EntityRefLink';
export * from './EntityTable';
export * from './EntityTagPicker';
export * from './EntityTypePicker';
export * from './UserListPicker';
+8
View File
@@ -15,5 +15,13 @@
*/
export { EntityContext, useEntity, useEntityFromUrl } from './useEntity';
export { useEntityCompoundName } from './useEntityCompoundName';
export {
EntityListContext,
EntityListProvider,
useEntityListProvider,
} from './useEntityListProvider';
export type { DefaultEntityFilters } from './useEntityListProvider';
export { useEntityTypeFilter } from './useEntityTypeFilter';
export { useOwnUser } from './useOwnUser';
export { useRelatedEntities } from './useRelatedEntities';
export { useStarredEntities } from './useStarredEntities';
@@ -0,0 +1,200 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import {
ApiProvider,
ApiRegistry,
ConfigApi,
configApiRef,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { MockStorageApi } from '@backstage/test-utils';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity, UserEntity } from '@backstage/catalog-model';
import {
EntityListProvider,
useEntityListProvider,
} from './useEntityListProvider';
import { catalogApiRef } from '../api';
import {
EntityKindFilter,
EntityTypeFilter,
UserListFilter,
UserListFilterKind,
} from '../types';
import { EntityKindPicker, UserListPicker } from '../components';
const mockUser: UserEntity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'guest',
},
spec: {
memberOf: [],
},
};
const entities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
relations: [
{
type: 'ownedBy',
target: {
name: 'guest',
namespace: 'default',
kind: 'User',
},
},
],
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
},
];
const mockConfigApi = {
getOptionalString: () => '',
} as Partial<ConfigApi>;
const mockIdentityApi: Partial<IdentityApi> = {
getUserId: () => 'guest@example.com',
};
const mockCatalogApi: Partial<CatalogApi> = {
getEntities: jest
.fn()
.mockImplementation(() => Promise.resolve({ items: entities })),
getEntityByName: () => Promise.resolve(mockUser),
};
const apis = ApiRegistry.from([
[configApiRef, mockConfigApi],
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
]);
const wrapper = ({
userFilter,
children,
}: PropsWithChildren<{ userFilter: UserListFilterKind }>) => {
return (
<ApiProvider apis={apis}>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</ApiProvider>
);
};
describe('<EntityListProvider/>', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('resolves backend filters', async () => {
const { result, waitForValueToChange } = renderHook(
() => useEntityListProvider(),
{
wrapper,
},
);
await waitForValueToChange(() => result.current.backendEntities);
expect(result.current.backendEntities.length).toBe(2);
expect(mockCatalogApi.getEntities).toHaveBeenCalledWith({
filter: { kind: 'component' },
});
});
it('resolves frontend filters', async () => {
const { result, waitFor } = renderHook(() => useEntityListProvider(), {
wrapper,
initialProps: {
userFilter: 'owned',
},
});
await waitFor(() => !!result.current.entities.length);
expect(result.current.backendEntities.length).toBe(2);
expect(result.current.entities.length).toBe(1);
});
it('does not fetch when only frontend filters change', async () => {
const { result, waitFor } = renderHook(() => useEntityListProvider(), {
wrapper,
});
await waitFor(() => !!result.current.entities.length);
expect(result.current.entities.length).toBe(2);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
act(() =>
result.current.updateFilters({
user: new UserListFilter('owned', mockUser, () => true),
}),
);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
expect(result.current.entities.length).toBe(1);
});
it('debounces multiple filter changes', async () => {
const { result, waitForNextUpdate, waitForValueToChange } = renderHook(
() => useEntityListProvider(),
{
wrapper,
},
);
await waitForValueToChange(() => result.current.backendEntities);
expect(result.current.backendEntities.length).toBe(2);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('component') });
result.current.updateFilters({ type: new EntityTypeFilter('service') });
});
await waitForNextUpdate();
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(2);
});
it('returns an error on catalogApi failure', async () => {
const { result, waitForNextUpdate, waitForValueToChange } = renderHook(
() => useEntityListProvider(),
{
wrapper,
},
);
await waitForValueToChange(() => result.current.backendEntities);
expect(result.current.backendEntities.length).toBe(2);
mockCatalogApi.getEntities = jest.fn().mockRejectedValue('error');
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
});
await waitForNextUpdate();
expect(result.current.error).toBeDefined();
});
});
@@ -0,0 +1,169 @@
/*
* 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,
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,
UserListFilter,
} from '../types';
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>
| ((prevFilters: EntityFilters) => Partial<EntityFilters>),
) => void;
loading: boolean;
error?: Error;
};
export const EntityListContext = createContext<
EntityListContextProps<any> | undefined
>(undefined);
export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
children,
}: PropsWithChildren<{}>) => {
const catalogApi = useApi(catalogApiRef);
const [filters, setFilters] = useState<EntityFilters>({} as EntityFilters);
const [entities, setEntities] = useState<Entity[]>([]);
const [backendEntities, setBackendEntities] = useState<Entity[]>([]);
// 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))),
);
setEntities(resolvedEntities);
}, [backendEntities, filters]);
const updateFilters = useCallback(
(
update:
| Partial<EntityFilter>
| ((prevFilters: EntityFilters) => Partial<EntityFilters>),
) => {
if (typeof update === 'function') {
setFilters(prevFilters => ({ ...prevFilters, ...update(prevFilters) }));
} else {
setFilters(prevFilters => ({ ...prevFilters, ...update }));
}
},
[],
);
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;
}
@@ -0,0 +1,97 @@
/*
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '../api';
import {
DefaultEntityFilters,
useEntityListProvider,
} from './useEntityListProvider';
import { EntityTypeFilter } from '../types';
type EntityTypeReturn = {
loading: boolean;
error?: Error;
types: string[];
selectedType: string | undefined;
setType: (type: string | undefined) => void;
};
/**
* A hook built on top of `useEntityListProvider` for enabling selection of valid `spec.type` values
* based on the selected EntityKindFilter.
*/
export function useEntityTypeFilter(): EntityTypeReturn {
const catalogApi = useApi(catalogApiRef);
const {
filters: { kind: kindFilter, type: typeFilter },
updateFilters,
} = useEntityListProvider();
const [types, setTypes] = useState<string[]>([]);
const kind = useMemo(() => kindFilter?.value, [kindFilter]);
// Load all valid spec.type values straight from the catalogApi, paying attention to only the
// kind filter for a complete list.
const { error, loading, value: entities } = useAsync(async () => {
if (kind) {
const items = await catalogApi
.getEntities({
filter: { kind },
fields: ['spec.type'],
})
.then(response => response.items);
return items;
}
return [];
}, [kind, catalogApi]);
useEffect(() => {
// Resolve the unique set of types from returned entities; could be optimized by a new endpoint
// in the catalog-backend that does this, rather than loading entities with redundant types.
const newTypes = [
...new Set(
(entities ?? []).map(e => e.spec?.type).filter(Boolean) as string[],
),
].sort();
setTypes(newTypes);
// Reset type filter if no longer applicable
updateFilters((oldFilters: DefaultEntityFilters) =>
oldFilters.type && !newTypes.includes(oldFilters.type.value)
? { type: undefined }
: {},
);
}, [updateFilters, entities]);
const setType = useCallback(
(type: string | undefined) =>
updateFilters({
type: type === undefined ? undefined : new EntityTypeFilter(type),
}),
[updateFilters],
);
return {
loading,
error,
types,
selectedType: typeFilter?.value,
setType,
};
}
@@ -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.
+2
View File
@@ -24,4 +24,6 @@ export {
entityRouteRef,
rootRoute,
} from './routes';
export * from './testUtils';
export * from './types';
export * from './utils';
@@ -0,0 +1,16 @@
/*
* 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 { MockEntityListContextProvider } from './providers';
@@ -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>
);
};
+82
View File
@@ -0,0 +1,82 @@
/*
* 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 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) => boolean;
};
export class EntityKindFilter implements EntityFilter {
constructor(readonly value: string) {}
getCatalogFilters(): Record<string, string | string[]> {
return { kind: this.value };
}
}
export class EntityTypeFilter implements EntityFilter {
constructor(readonly value: string) {}
getCatalogFilters(): Record<string, string | string[]> {
return { 'spec.type': this.value };
}
}
export class EntityTagFilter implements EntityFilter {
constructor(readonly values: string[]) {}
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 {
constructor(
readonly value: UserListFilterKind,
readonly user: UserEntity | undefined,
readonly isStarredEntity: (entity: Entity) => boolean,
) {}
filterEntity(entity: Entity): boolean {
switch (this.value) {
case 'owned':
return this.user !== undefined && isOwnerOf(this.user, entity);
case 'starred':
return this.isStarredEntity(entity);
default:
return true;
}
}
}
@@ -0,0 +1,38 @@
/*
* 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 } 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[],
): (entity: Entity) => boolean {
return (entity: Entity) =>
filters.every(
filter => !filter.filterEntity || filter.filterEntity(entity),
);
}
+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",
@@ -1,264 +0,0 @@
/*
* 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 { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { EntityFilterGroupsProvider } from '../../filter';
import { ButtonGroup, CatalogFilter } from './CatalogFilter';
describe('Catalog Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[],
}),
};
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render the different groups', async () => {
const mockGroups: ButtonGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="" />,
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
}
});
it('should render the different items and their names', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="all" />,
);
for (const item of mockGroups[0].items) {
expect(await findByText(item.label)).toBeInTheDocument();
}
});
it('selects the first item if no desired initial one is set', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
});
it('selects the initial item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
onChange={onChange}
initiallySelected="starred"
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('can change the selected item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
const { findByText } = renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
fireEvent.click(await findByText('Second Label'));
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('displays match counts properly', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'owned',
label: 'First Label',
filterFn: entity => entity.spec?.owner === 'tools@example.com',
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="owned" />,
);
expect(await findByText('1')).toBeInTheDocument();
});
});
@@ -1,219 +0,0 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { IconComponent } from '@backstage/core';
import {
Card,
List,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
makeStyles,
MenuItem,
Theme,
Typography,
} from '@material-ui/core';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
export type ButtonGroup = {
name: string;
items: {
id: string;
label: string;
icon?: IconComponent;
filterFn: (entity: Entity) => boolean;
}[];
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
backgroundColor: 'rgba(0, 0, 0, .11)',
boxShadow: 'none',
},
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,
},
}));
type OnChangeCallback = (item: { id: string; label: string }) => void;
type Props = {
buttonGroups: ButtonGroup[];
initiallySelected: string;
onChange?: OnChangeCallback;
};
/**
* Sidebar filter type and human readable label for it. owned/starred/all
*/
export type CatalogFilterType = {
id: string;
label: string;
};
/**
* The main filter group in the sidebar, toggling owned/starred/all.
*/
export const CatalogFilter = ({
buttonGroups,
onChange,
initiallySelected,
}: Props) => {
const classes = useStyles();
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
buttonGroups,
initiallySelected,
);
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const setCurrent = useCallback(
(item: { id: string; label: string }) => {
setCurrentFilter(item.id);
onChangeRef.current?.({ id: item.id, label: item.label });
},
[setCurrentFilter],
);
// Make one initial onChange to inform the surroundings about the selected
// item
useEffect(() => {
const items = buttonGroups.flatMap(g => g.items);
const item = items.find(i => i.id === initiallySelected) || items[0];
if (item) {
onChangeRef.current?.({ id: item.id, label: item.label });
}
// intentionally only happens on startup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Card className={classes.root}>
{buttonGroups.map(group => (
<React.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={() => setCurrent(item)}
selected={item.id === currentFilter}
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>
</React.Fragment>
))}
</Card>
);
};
function useFilter(
buttonGroups: ButtonGroup[],
initiallySelected: string,
): {
currentFilter: string;
setCurrentFilter: (filterId: string) => void;
getFilterCount: (filterId: string) => number | undefined;
} {
const [currentFilter, setCurrentFilter] = useState(initiallySelected);
const filterGroup = useMemo<FilterGroup>(
() => ({
filters: Object.fromEntries(
buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]),
),
}),
[buttonGroups],
);
const { setSelectedFilters, state } = useEntityFilterGroup(
'primary-sidebar',
filterGroup,
[initiallySelected],
);
const setCurrent = useCallback(
(filterId: string) => {
setCurrentFilter(filterId);
setSelectedFilters([filterId]);
},
[setCurrentFilter, setSelectedFilters],
);
const getFilterCount = useCallback(
(filterId: string) => {
if (state.type !== 'ready') {
return undefined;
}
return state.state.filters[filterId].matchCount;
},
[state],
);
return {
currentFilter,
setCurrentFilter: setCurrent,
getFilterCount,
};
}
@@ -29,10 +29,13 @@ import {
storageApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import {
MockStorageApi,
renderWithEffects,
wrapInTestApp,
} from '@backstage/test-utils';
import { fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { EntityFilterGroupsProvider } from '../../filter';
import { createComponentRouteRef } from '../../routes';
import { CatalogPage } from './CatalogPage';
@@ -106,7 +109,7 @@ describe('CatalogPage', () => {
};
const renderWrapped = (children: React.ReactNode) =>
render(
renderWithEffects(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
@@ -115,7 +118,7 @@ describe('CatalogPage', () => {
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
{children}
</ApiProvider>,
{
mountedRoutes: {
@@ -129,35 +132,35 @@ describe('CatalogPage', () => {
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const { findByText, getByText } = renderWrapped(<CatalogPage />);
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
fireEvent.click(getByText(/All/));
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
const { getByText, getByTestId } = await renderWrapped(<CatalogPage />);
expect(getByText(/Owned \(1\)/)).toBeInTheDocument();
fireEvent.click(getByTestId('user-picker-all'));
expect(getByText(/All \(2\)/)).toBeInTheDocument();
});
it('should set initial filter correctly', async () => {
const { findByText } = renderWrapped(
const { getByText } = await renderWrapped(
<CatalogPage initiallySelectedFilter="all" />,
);
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
expect(getByText(/All \(2\)/)).toBeInTheDocument();
});
// this test is for fixing the bug after favoriting an entity, the matching entities defaulting
// to "owned" filter and not based on the selected filter
it('should render the correct entities filtered on the selectedfilter', async () => {
const { findByText, findAllByTitle, getByText } = renderWrapped(
const { getByText, findAllByTitle, getByTestId } = await renderWrapped(
<CatalogPage />,
);
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
expect(await findByText(/Starred/)).toBeInTheDocument();
fireEvent.click(getByText(/Starred/));
expect(await findByText(/Starred \(0\)/)).toBeInTheDocument();
fireEvent.click(getByText(/All/));
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
expect(getByText(/Owned \(1\)/)).toBeInTheDocument();
expect(getByText(/Starred/)).toBeInTheDocument();
fireEvent.click(getByTestId('user-picker-starred'));
expect(getByText(/Starred \(0\)/)).toBeInTheDocument();
fireEvent.click(getByTestId('user-picker-all'));
expect(getByText(/All \(2\)/)).toBeInTheDocument();
const starredIcons = await findAllByTitle('Add to favorites');
fireEvent.click(starredIcons[0]);
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
expect(getByText(/All \(2\)/)).toBeInTheDocument();
fireEvent.click(getByText(/Starred/));
waitFor(() => expect(findByText(/Starred \(1\)/)).toBeInTheDocument());
fireEvent.click(getByTestId('user-picker-starred'));
waitFor(() => expect(getByText(/Starred \(1\)/)).toBeInTheDocument());
});
});
@@ -14,40 +14,27 @@
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core';
import {
configApiRef,
Content,
ContentHeader,
errorApiRef,
SupportButton,
TableColumn,
useApi,
useRouteRef,
} from '@backstage/core';
import {
catalogApiRef,
isOwnerOf,
useStarredEntities,
EntityKindPicker,
EntityListProvider,
EntityTagPicker,
EntityTypePicker,
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 { CreateComponentButton } from '../CreateComponentButton';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -62,170 +49,35 @@ 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;
return (
<CatalogLayout>
<CatalogTabs
tabs={tabs}
onChange={({ label }) => setSelectedTab(label)}
/>
<Content>
<ContentHeader title={selectedTab ?? ''}>
{createComponentLink && (
<Button
component={RouterLink}
variant="contained"
color="primary"
to={createComponentLink()}
>
Create Component
</Button>
)}
{showAddExampleEntities && (
<Button
className={styles.buttonSpacing}
variant="outlined"
color="primary"
onClick={addMockData}
>
Add example components
</Button>
)}
<ContentHeader title="Components">
<CreateComponentButton />
<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>
<div>
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityTagPicker />
</div>
<CatalogTable columns={columns} />
</EntityListProvider>
</div>
</Content>
</CatalogLayout>
);
};
export const CatalogPage = (props: CatalogPageProps) => (
<EntityFilterGroupsProvider>
<CatalogPageContents {...props} />
</EntityFilterGroupsProvider>
);
@@ -1,95 +0,0 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { HeaderTabs } from '@backstage/core';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
/**
* A component type, and a human readable label for it.
*/
export type LabeledComponentType = {
id: string;
label: string;
};
/**
* Called on mount, and when the selected tab changes.
*/
export type OnChangeCallback = (tab: LabeledComponentType) => void;
type Props = {
tabs: LabeledComponentType[];
onChange?: OnChangeCallback;
};
/**
* The tabs at the top of the catalog list page, for component type filtering.
*/
export const CatalogTabs = ({ tabs, onChange }: Props) => {
const filterGroup = useMemo<FilterGroup>(() => {
const otherType = 'other';
const wellKnownTypes = tabs.map(t => t.id).filter(t => t !== otherType);
const isOtherType = (entity: Entity) =>
!wellKnownTypes.includes(entity.spec?.type as string);
return {
filters: Object.fromEntries(
tabs.map(t => [
t.id,
(entity: Entity) =>
(t.id === otherType && isOtherType(entity)) ||
entity.spec?.type === t.id,
]),
),
};
}, [tabs]);
const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [
tabs[0].id,
]);
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
// Hold a reference to the callback
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onChangeRef.current?.(tabs[currentTabIndex]);
}, [tabs, currentTabIndex]);
const switchTab = useCallback(
(index: number) => {
const tab = tabs[index];
setSelectedFilters([tab.id]);
setCurrentTabIndex(index);
onChangeRef.current?.(tab);
},
[tabs, setSelectedFilters],
);
return <HeaderTabs tabs={tabs} onChange={switchTab} />;
};
@@ -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 {
MockEntityListContextProvider,
UserListFilter,
} from '@backstage/plugin-catalog-react';
const entities: Entity[] = [
{
@@ -51,16 +55,11 @@ 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(
<MockEntityListContextProvider value={{ error: new Error('error') }}>
<CatalogTable />
</MockEntityListContextProvider>,
);
const errorMessage = await rendered.findByText(
/Could not fetch catalog entities./,
@@ -69,14 +68,17 @@ 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(
<MockEntityListContextProvider
value={{
entities,
filters: {
user: new UserListFilter('owned', undefined, () => false),
},
}}
>
<CatalogTable />
</MockEntityListContextProvider>,
);
expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument();
expect(rendered.getByText(/component1/)).toBeInTheDocument();
@@ -94,14 +96,10 @@ describe('CatalogTable component', () => {
},
};
const { getByTitle } = await renderWithEffects(
wrapInTestApp(
<CatalogTable
titlePreamble="Owned"
entities={[entity]}
loading={false}
/>,
),
const { getByTitle } = await renderInTestApp(
<MockEntityListContextProvider value={{ entities: [entity] }}>
<CatalogTable />
</MockEntityListContextProvider>,
);
const editButton = getByTitle('Edit');
@@ -123,14 +121,10 @@ describe('CatalogTable component', () => {
},
};
const { getByTitle } = await renderWithEffects(
wrapInTestApp(
<CatalogTable
titlePreamble="Owned"
entities={[entity]}
loading={false}
/>,
),
const { getByTitle } = await renderInTestApp(
<MockEntityListContextProvider value={{ entities: [entity] }}>
<CatalogTable />
</MockEntityListContextProvider>,
);
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,16 @@ 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();
const { loading, error, entities, filters } = useEntityListProvider();
const showTypeColumn = filters.type !== undefined;
// TODO(timbonicus): we should show filter chips for all filters instead
const titlePreamble = capitalize(filters.user?.value ?? 'all');
if (error) {
return (
@@ -152,7 +143,7 @@ export const CatalogTable = ({
const typeColumn = (columns || defaultColumns).find(c => c.title === 'Type');
if (typeColumn) {
typeColumn.hidden = view !== 'Other';
typeColumn.hidden = !showTypeColumn;
}
return (
@@ -168,7 +159,7 @@ export const CatalogTable = ({
padding: 'dense',
pageSizeOptions: [20, 50, 100],
}}
title={`${titlePreamble} (${(entities && entities.length) || 0})`}
title={`${titlePreamble} (${entities.length})`}
data={rows}
actions={actions}
/>
@@ -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';
@@ -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.
@@ -14,20 +14,25 @@
* limitations under the License.
*/
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { CircularProgress, useTheme } from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { Link as RouterLink } from 'react-router-dom';
import { Button } from '@material-ui/core';
import { useRouteRef } from '@backstage/core';
import { createComponentRouteRef } from '../../routes';
export const AllServicesCount = () => {
const theme = useTheme();
const catalogApi = useApi(catalogApiRef);
const { value, loading } = useAsync(() => catalogApi.getEntities());
export const CreateComponentButton = () => {
const createComponentLink = useRouteRef(createComponentRouteRef);
if (loading) {
return <CircularProgress size={theme.spacing(2)} />;
}
if (!createComponentLink) return null;
return <span>{value ?? length ?? '-'}</span>;
return (
<Button
component={RouterLink}
variant="contained"
color="primary"
to={createComponentLink()}
>
Create Component
</Button>
);
};
@@ -0,0 +1,16 @@
/*
* 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 { CreateComponentButton } from './CreateComponentButton';
@@ -1,105 +0,0 @@
/*
* 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 { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { EntityFilterGroupsProvider } from '../../filter';
import { ResultsFilter } from './ResultsFilter';
describe('Results Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
] as Entity[],
}),
};
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render all available tags', async () => {
const tags = ['test', 'java'];
const { findByText } = renderWrapped(
<ResultsFilter availableTags={tags} />,
);
for (const tag of tags) {
expect(await findByText(tag)).toBeInTheDocument();
}
});
});
@@ -1,121 +0,0 @@
/*
* 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 {
Button,
Checkbox,
Divider,
List,
ListItem,
ListItemText,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import React, { useCallback, useContext, useState } from 'react';
import { filterGroupsContext } from '../../filter/context';
const useStyles = makeStyles<Theme>(theme => ({
filterBox: {
display: 'flex',
margin: theme.spacing(2, 0, 0, 0),
},
filterBoxTitle: {
margin: theme.spacing(1, 0, 0, 1),
fontWeight: 'bold',
flex: 1,
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
checkbox: {
padding: theme.spacing(0, 1, 0, 1),
},
}));
type Props = {
availableTags: string[];
};
/**
* The additional results filter in the sidebar.
*/
export const ResultsFilter = ({ availableTags }: Props) => {
const classes = useStyles();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const setSelectedTagsFilter = context?.setSelectedTags;
const updateSelectedTags = useCallback(
(tags: string[]) => {
setSelectedTags(tags);
setSelectedTagsFilter(tags);
},
[setSelectedTags, setSelectedTagsFilter],
);
return (
<>
<div className={classes.filterBox}>
<Typography variant="subtitle2" className={classes.filterBoxTitle}>
Refine Results
</Typography>{' '}
<Button onClick={() => updateSelectedTags([])}>Clear</Button>
</div>
<Divider />
<Typography variant="subtitle2" className={classes.title}>
Tags
</Typography>
<List disablePadding dense>
{availableTags.map(t => {
const labelId = `checkbox-list-label-${t}`;
return (
<ListItem
key={t}
dense
button
onClick={() =>
updateSelectedTags(
selectedTags.includes(t)
? selectedTags.filter(s => s !== t)
: [...selectedTags, t],
)
}
>
<Checkbox
edge="start"
color="primary"
checked={selectedTags.includes(t)}
tabIndex={-1}
disableRipple
className={classes.checkbox}
inputProps={{ 'aria-labelledby': labelId }}
/>
<ListItemText id={labelId} primary={t} />
</ListItem>
);
})}
</List>
</>
);
};
@@ -1,263 +0,0 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { filterGroupsContext, FilterGroupsContext } from './context';
import {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
} from './types';
/**
* Implementation of the shared filter groups state.
*/
export const EntityFilterGroupsProvider = ({
children,
}: {
children?: React.ReactNode;
}) => {
const state = useProvideEntityFilters();
return (
<filterGroupsContext.Provider value={state}>
{children}
</filterGroupsContext.Provider>
);
};
// The hook that implements the actual context building
function useProvideEntityFilters(): FilterGroupsContext {
const catalogApi = useApi(catalogApiRef);
const [{ value: entities, error }, doReload] = useAsyncFn(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Component' },
});
return response.items;
});
const filterGroups = useRef<{
[filterGroupId: string]: FilterGroup;
}>({});
const selectedFilterKeys = useRef<{
[filterGroupId: string]: Set<string>;
}>({});
const selectedTags = useRef<string[]>([]);
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [isCatalogEmpty, setCatalogEmpty] = useState<boolean>(false);
useEffect(() => {
doReload();
}, [doReload]);
const rebuild = useCallback(() => {
setFilterGroupStates(
buildStates(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
error,
),
);
setMatchingEntities(
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
),
);
setAvailableTags(collectTags(entities));
setCatalogEmpty(entities !== undefined && entities.length === 0);
}, [entities, error]);
const register = useCallback(
(
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => {
filterGroups.current[filterGroupId] = filterGroup;
selectedFilterKeys.current[filterGroupId] = new Set(
initialSelectedFilterIds ?? [],
);
rebuild();
},
[rebuild],
);
const unregister = useCallback(
(filterGroupId: string) => {
delete filterGroups.current[filterGroupId];
delete selectedFilterKeys.current[filterGroupId];
rebuild();
},
[rebuild],
);
const setGroupSelectedFilters = useCallback(
(filterGroupId: string, filters: string[]) => {
selectedFilterKeys.current[filterGroupId] = new Set(filters);
rebuild();
},
[rebuild],
);
const setSelectedTags = useCallback(
(tags: string[]) => {
selectedTags.current = tags;
rebuild();
},
[rebuild],
);
const reload = useCallback(async () => {
await doReload();
}, [doReload]);
return {
register,
unregister,
setGroupSelectedFilters,
setSelectedTags,
reload,
loading: !error && !entities,
error,
filterGroupStates,
matchingEntities,
availableTags,
isCatalogEmpty,
};
}
// Given all filter groups and what filters are actually selected, along with
// the loading state for entities, generate the state of each individual filter
function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
// On error - all entries are an error state
if (error) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'error', error },
]),
);
}
// On startup - all entries are a loading state
if (!entities) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'loading' },
]),
);
}
const result: { [filterGroupId: string]: FilterGroupStates } = {};
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
const otherMatchingEntities = buildMatchingEntities(
filterGroups,
selectedFilterKeys,
selectedTags,
entities,
filterGroupId,
);
const groupState: FilterGroupState = { filters: {} };
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId);
const matchCount = otherMatchingEntities.filter(entity =>
filterFn(entity),
).length;
groupState.filters[filterId] = { isSelected, matchCount };
}
result[filterGroupId] = { type: 'ready', state: groupState };
}
return result;
}
// Given all entites, find all possible tags and provide them in a sorted list.
function collectTags(entities?: Entity[]): string[] {
const tags = new Set<string>();
(entities || []).forEach(e => {
if (e.metadata.tags) {
e.metadata.tags.forEach(t => tags.add(t));
}
});
return Array.from(tags).sort();
}
// Given all filter groups and what filters are actually selected, extract all
// entities that match all those filter groups.
function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
excludeFilterGroupId?: string,
): Entity[] {
// Build one filter fn per filter group
const allFilters: EntityFilterFn[] = [];
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
if (excludeFilterGroupId === filterGroupId) {
continue;
}
// Pick out all of the filter functions in the group that are actually
// selected
const groupFilters: EntityFilterFn[] = [];
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) {
groupFilters.push(filterFn);
}
}
// Need to match any of the selected filters in the group - if there is
// any at all
if (groupFilters.length) {
allFilters.push(entity => groupFilters.some(fn => fn(entity)));
}
}
// Filter by tags, if at least one tag is selected. Include all entities
// that have at least one of the selected tags
if (selectedTags.length > 0) {
allFilters.push(
entity =>
!!entity.metadata.tags &&
entity.metadata.tags.some(t => selectedTags.includes(t)),
);
}
// All filter groups that had any checked filters need to match. Note that
// every() always returns true for an empty array.
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
}
-44
View File
@@ -1,44 +0,0 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { createContext } from 'react';
import { FilterGroup, FilterGroupStates } from './types';
export type FilterGroupsContext = {
register: (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => void;
unregister: (filterGroupId: string) => void;
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
setSelectedTags: (tags: string[]) => void;
reload: () => Promise<void>;
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
matchingEntities: Entity[];
availableTags: string[];
isCatalogEmpty: boolean;
};
/**
* The context that maintains shared state for all visible filter groups.
*/
export const filterGroupsContext = createContext<
FilterGroupsContext | undefined
>(undefined);
-53
View File
@@ -1,53 +0,0 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
export type EntityFilterFn = (entity: Entity) => boolean;
export type FilterGroup = {
filters: {
[filterId: string]: EntityFilterFn;
};
};
export type FilterGroupState = {
filters: {
[filterId: string]: {
isSelected: boolean;
matchCount: number;
};
};
};
export type FilterGroupStatesReady = {
type: 'ready';
state: FilterGroupState;
};
export type FilterGroupStatesError = {
type: 'error';
error: Error;
};
export type FilterGroupStatesLoading = {
type: 'loading';
};
export type FilterGroupStates =
| FilterGroupStatesReady
| FilterGroupStatesError
| FilterGroupStatesLoading;
@@ -1,122 +0,0 @@
/*
* 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 { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi } from '@backstage/test-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
import { FilterGroup, FilterGroupStatesReady } from './types';
import { useEntityFilterGroup } from './useEntityFilterGroup';
describe('useEntityFilterGroup', () => {
let catalogApi: jest.Mocked<typeof catalogApiRef.T>;
let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element;
beforeEach(() => {
catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(_a => new Promise(() => {})),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
storageApiRef,
MockStorageApi.create(),
);
wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>
</ApiProvider>
);
});
it('works for an empty set of filters', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const group: FilterGroup = { filters: {} };
const { result, waitFor } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await waitFor(() => expect(result.current.state.type).toBe('ready'));
});
it('works for a single group', async () => {
catalogApi.getEntities.mockResolvedValue({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
],
});
const group: FilterGroup = {
filters: {
f1: e => e.metadata.name === 'n',
f2: e => e.metadata.name !== 'n',
},
};
const { result, waitFor } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await waitFor(() => expect(result.current.state.type).toEqual('ready'));
let state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f1']));
await waitFor(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: true,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f2']));
await waitFor(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: true,
matchCount: 0,
});
});
});
@@ -1,73 +0,0 @@
/*
* 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 { useCallback, useContext, useEffect, useMemo } from 'react';
import { filterGroupsContext } from './context';
import { FilterGroup, FilterGroupStates } from './types';
export type EntityFilterGroupOutput = {
state: FilterGroupStates;
setSelectedFilters: (filterIds: string[]) => void;
};
/**
* Hook that exposes the relevant data and operations for a single filter
* group.
*/
export const useEntityFilterGroup = (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilters?: string[],
): EntityFilterGroupOutput => {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const {
register,
unregister,
setGroupSelectedFilters,
filterGroupStates,
} = context;
// on state changes unregisters and registers the filtergroup
// ensure that it re-registers with the correct filter as the prop changes and not the default
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialMemo = useMemo(() => {
return initialSelectedFilters?.slice();
}, [initialSelectedFilters]);
// Register the group on mount, and unregister on unmount
useEffect(() => {
register(filterGroupId, filterGroup, initialMemo);
return () => unregister(filterGroupId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [register, unregister, filterGroupId, filterGroup]);
const setSelectedFilters = useCallback(
(filters: string[]) => {
setGroupSelectedFilters(filterGroupId, filters);
},
[setGroupSelectedFilters, filterGroupId],
);
let state = filterGroupStates[filterGroupId];
if (!state) {
state = { type: 'loading' };
}
return { state, setSelectedFilters };
};
@@ -1,37 +0,0 @@
/*
* 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 { useContext } from 'react';
import { filterGroupsContext } from './context';
/**
* Hook that exposes the result of applying a set of filter groups.
*/
export function useFilteredEntities() {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
return {
loading: context.loading,
error: context.error,
matchingEntities: context.matchingEntities,
availableTags: context.availableTags,
isCatalogEmpty: context.isCatalogEmpty,
reload: context.reload,
};
}