Merge pull request #5643 from backstage/timbonicus/catalog-entity-context
Add EntityListProvider with hooks/filters
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
+2
-13
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -16,9 +16,9 @@
|
||||
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { identityApiRef, useApi } from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../api';
|
||||
|
||||
/**
|
||||
* Get the catalog User entity (if any) that matches the logged-in user.
|
||||
@@ -24,4 +24,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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -13,5 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './filters';
|
||||
export { getEntityRelations } from './getEntityRelations';
|
||||
export { isOwnerOf } from './isOwnerOf';
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@types/react": "^16.9",
|
||||
"classnames": "^2.2.6",
|
||||
"git-url-parse": "^11.4.4",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-helmet": "6.1.0",
|
||||
|
||||
@@ -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';
|
||||
|
||||
+18
-13
@@ -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))) ?? [];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user