Merge pull request #6150 from backstage/mob/use-entity-list-provider-in-scaffolder-page
Use `EntityListProvider` in `ScaffolderPage`
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': minor
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Updated the software templates list page (`ScaffolderPage`) to use the `useEntityListProvider` hook from #5643. This reduces the code footprint, making it easier to customize the display of this page, and consolidates duplicate approaches to querying the catalog with filters.
|
||||
|
||||
- The `useEntityTypeFilter` hook has been updated along with the underlying `EntityTypeFilter` to work with multiple values, to allow more flexibility for different user interfaces. It's unlikely that this change affects you; however, if you're using either of these directly, you'll need to update your usage.
|
||||
- `SearchToolbar` was renamed to `EntitySearchBar` and moved to `catalog-react` to be usable by other entity list pages
|
||||
- `UserListPicker` now has an `availableTypes` prop to restrict which user-related options to present
|
||||
@@ -17,7 +17,7 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { EntityKindFilter } from '../../types';
|
||||
import { EntityKindFilter } from '../../filters';
|
||||
import { EntityKindPicker } from './EntityKindPicker';
|
||||
|
||||
describe('<EntityKindPicker/>', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { useEntityListProvider } from '../../hooks';
|
||||
import { EntityKindFilter } from '../../types';
|
||||
import { EntityKindFilter } from '../../filters';
|
||||
|
||||
type EntityKindFilterProps = {
|
||||
initialFilter?: string;
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { EntityLifecycleFilter } from '../../types';
|
||||
import { EntityLifecycleFilter } from '../../filters';
|
||||
import { EntityLifecyclePicker } from './EntityLifecyclePicker';
|
||||
|
||||
const sampleEntities: Entity[] = [
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { Autocomplete } from '@material-ui/lab';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
|
||||
import { EntityLifecycleFilter } from '../../types';
|
||||
import { EntityLifecycleFilter } from '../../filters';
|
||||
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { EntityOwnerFilter } from '../../types';
|
||||
import { EntityOwnerFilter } from '../../filters';
|
||||
import { EntityOwnerPicker } from './EntityOwnerPicker';
|
||||
|
||||
const sampleEntities: Entity[] = [
|
||||
|
||||
@@ -28,7 +28,7 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { Autocomplete } from '@material-ui/lab';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
|
||||
import { EntityOwnerFilter } from '../../types';
|
||||
import { EntityOwnerFilter } from '../../filters';
|
||||
import { getEntityRelations } from '../../utils';
|
||||
import { formatEntityRefTitle } from '../EntityRefLink';
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { EntitySearchBar } from './EntitySearchBar';
|
||||
import { DefaultEntityFilters } from '../../hooks/useEntityListProvider';
|
||||
import { EntityTextFilter } from '../../filters';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
|
||||
describe('EntitySearchBar', () => {
|
||||
it('should display search value and execute set callback', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
|
||||
const filters: DefaultEntityFilters = {
|
||||
text: new EntityTextFilter('hello'),
|
||||
};
|
||||
|
||||
const { getByDisplayValue } = render(
|
||||
<MockEntityListContextProvider value={{ updateFilters, filters }}>
|
||||
<EntitySearchBar />
|
||||
</MockEntityListContextProvider>,
|
||||
);
|
||||
|
||||
const searchInput = getByDisplayValue('hello');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'world' } });
|
||||
await waitFor(() => expect(updateFilters.mock.calls.length).toBe(1));
|
||||
expect(updateFilters).toHaveBeenCalledWith({
|
||||
text: new EntityTextFilter('world'),
|
||||
});
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } });
|
||||
await waitFor(() => expect(updateFilters.mock.calls.length).toBe(2));
|
||||
expect(updateFilters).toHaveBeenCalledWith({
|
||||
text: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
-12
@@ -13,22 +13,21 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
makeStyles,
|
||||
Toolbar,
|
||||
Input,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import Search from '@material-ui/icons/Search';
|
||||
import Clear from '@material-ui/icons/Clear';
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
setSearch: Function;
|
||||
}
|
||||
import Search from '@material-ui/icons/Search';
|
||||
import React, { useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
|
||||
import { EntityTextFilter } from '../../filters';
|
||||
|
||||
const useStyles = makeStyles(_theme => ({
|
||||
searchToolbar: {
|
||||
@@ -37,8 +36,22 @@ const useStyles = makeStyles(_theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const SearchToolbar = ({ search, setSearch }: Props) => {
|
||||
export const EntitySearchBar = () => {
|
||||
const styles = useStyles();
|
||||
|
||||
const { filters, updateFilters } = useEntityListProvider();
|
||||
const [search, setSearch] = useState(filters.text?.value ?? '');
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
updateFilters({
|
||||
text: search.length ? new EntityTextFilter(search) : undefined,
|
||||
});
|
||||
},
|
||||
250,
|
||||
[search, updateFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<Toolbar className={styles.searchToolbar}>
|
||||
<FormControl>
|
||||
@@ -70,5 +83,3 @@ const SearchToolbar = ({ search, setSearch }: Props) => {
|
||||
</Toolbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchToolbar;
|
||||
+2
-13
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* 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 { EntitySearchBar } from './EntitySearchBar';
|
||||
@@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { EntityTagFilter } from '../../types';
|
||||
import { EntityTagFilter } from '../../filters';
|
||||
import { EntityTagPicker } from './EntityTagPicker';
|
||||
|
||||
const taggedEntities: Entity[] = [
|
||||
|
||||
@@ -28,7 +28,7 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { Autocomplete } from '@material-ui/lab';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
|
||||
import { EntityTagFilter } from '../../types';
|
||||
import { EntityTagFilter } from '../../filters';
|
||||
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityTypePicker } from './EntityTypePicker';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { EntityKindFilter, EntityTypeFilter } from '../../types';
|
||||
import { EntityKindFilter, EntityTypeFilter } from '../../filters';
|
||||
|
||||
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
@@ -123,7 +123,7 @@ describe('<EntityTypePicker/>', () => {
|
||||
fireEvent.click(rendered.getByText('Service'));
|
||||
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
type: new EntityTypeFilter('service'),
|
||||
type: new EntityTypeFilter(['service']),
|
||||
});
|
||||
|
||||
fireEvent.click(input);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { capitalize } from 'lodash';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { useEntityTypeFilter } from '../../hooks/useEntityTypeFilter';
|
||||
|
||||
@@ -24,7 +24,12 @@ import { Select } from '@backstage/core-components';
|
||||
|
||||
export const EntityTypePicker = () => {
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const { error, types, selectedType, setType } = useEntityTypeFilter();
|
||||
const {
|
||||
error,
|
||||
availableTypes,
|
||||
selectedTypes,
|
||||
setSelectedTypes,
|
||||
} = useEntityTypeFilter();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -35,13 +40,11 @@ export const EntityTypePicker = () => {
|
||||
}
|
||||
}, [error, alertApi]);
|
||||
|
||||
if (!types || error) {
|
||||
return null;
|
||||
}
|
||||
if (!availableTypes || error) return null;
|
||||
|
||||
const items = [
|
||||
{ value: 'all', label: 'All' },
|
||||
...types.map((type: string) => ({
|
||||
...availableTypes.map((type: string) => ({
|
||||
value: type,
|
||||
label: capitalize(type),
|
||||
})),
|
||||
@@ -52,8 +55,10 @@ export const EntityTypePicker = () => {
|
||||
<Select
|
||||
label="Type"
|
||||
items={items}
|
||||
selected={selectedType ?? 'all'}
|
||||
onChange={value => setType(value === 'all' ? undefined : String(value))}
|
||||
selected={selectedTypes.length ? selectedTypes[0] : 'all'}
|
||||
onChange={value =>
|
||||
setSelectedTypes(value === 'all' ? [] : [String(value)])
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { UserListPicker } from './UserListPicker';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { EntityTagFilter, UserListFilter } from '../../types';
|
||||
import { EntityTagFilter, UserListFilter } from '../../filters';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { MockStorageApi } from '@backstage/test-utils';
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import React, { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { compact } from 'lodash';
|
||||
import { UserListFilter, UserListFilterKind } from '../../types';
|
||||
import { UserListFilterKind } from '../../types';
|
||||
import { UserListFilter } from '../../filters';
|
||||
import {
|
||||
useEntityListProvider,
|
||||
useOwnUser,
|
||||
@@ -106,13 +107,27 @@ function getFilterGroups(orgName: string | undefined): ButtonGroup[] {
|
||||
|
||||
type UserListPickerProps = {
|
||||
initialFilter?: UserListFilterKind;
|
||||
availableFilters?: UserListFilterKind[];
|
||||
};
|
||||
|
||||
export const UserListPicker = ({ initialFilter }: UserListPickerProps) => {
|
||||
export const UserListPicker = ({
|
||||
initialFilter,
|
||||
availableFilters,
|
||||
}: UserListPickerProps) => {
|
||||
const classes = useStyles();
|
||||
const configApi = useApi(configApiRef);
|
||||
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
|
||||
const filterGroups = getFilterGroups(orgName);
|
||||
|
||||
// Remove group items that aren't in availableFilters and exclude
|
||||
// any now-empty groups.
|
||||
const filterGroups = getFilterGroups(orgName)
|
||||
.map(filterGroup => ({
|
||||
...filterGroup,
|
||||
items: filterGroup.items.filter(
|
||||
({ id }) => !availableFilters || availableFilters.includes(id),
|
||||
),
|
||||
}))
|
||||
.filter(({ items }) => !!items.length);
|
||||
|
||||
const { value: user } = useOwnUser();
|
||||
const { isStarredEntity } = useStarredEntities();
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './EntityLifecyclePicker';
|
||||
export * from './EntityOwnerPicker';
|
||||
export * from './EntityProvider';
|
||||
export * from './EntityRefLink';
|
||||
export * from './EntitySearchBar';
|
||||
export * from './EntityTable';
|
||||
export * from './EntityTagPicker';
|
||||
export * from './EntityTypePicker';
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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, TemplateEntityV1beta2 } from '@backstage/catalog-model';
|
||||
import { EntityTextFilter } from './filters';
|
||||
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'react-app',
|
||||
tags: ['react', 'experimental'],
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'gRPC service',
|
||||
tags: ['gRPC', 'java'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const templates: TemplateEntityV1beta2[] = [
|
||||
{
|
||||
apiVersion: 'backstage.io/v1beta2',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'react-app',
|
||||
title: 'Create React App Template',
|
||||
tags: ['react', 'experimental'],
|
||||
},
|
||||
spec: {
|
||||
type: '',
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1beta2',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'gRPC service',
|
||||
title: 'Spring Boot gRPC Service',
|
||||
tags: ['gRPC', 'java'],
|
||||
},
|
||||
spec: {
|
||||
type: '',
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('EntityTextFilter', () => {
|
||||
it('should search name', () => {
|
||||
const filter = new EntityTextFilter('app');
|
||||
expect(filter.filterEntity(entities[0])).toBeTruthy();
|
||||
expect(filter.filterEntity(entities[1])).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should search template title', () => {
|
||||
const filter = new EntityTextFilter('spring');
|
||||
expect(filter.filterEntity(templates[0])).toBeFalsy();
|
||||
expect(filter.filterEntity(templates[1])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should search tags', () => {
|
||||
const filter = new EntityTextFilter('java');
|
||||
expect(filter.filterEntity(entities[0])).toBeFalsy();
|
||||
expect(filter.filterEntity(entities[1])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
const filter = new EntityTextFilter('JaVa');
|
||||
expect(filter.filterEntity(entities[0])).toBeFalsy();
|
||||
expect(filter.filterEntity(entities[1])).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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,
|
||||
RELATION_OWNED_BY,
|
||||
} from '@backstage/catalog-model';
|
||||
import { EntityFilter, UserListFilterKind } from './types';
|
||||
import { getEntityRelations, isOwnerOf } from './utils';
|
||||
import { formatEntityRefTitle } from './components/EntityRefLink';
|
||||
|
||||
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 | string[]) {}
|
||||
|
||||
// Simplify `string | string[]` for consumers, always returns an array
|
||||
getTypes(): string[] {
|
||||
return Array.isArray(this.value) ? this.value : [this.value];
|
||||
}
|
||||
|
||||
getCatalogFilters(): Record<string, string | string[]> {
|
||||
return { 'spec.type': this.getTypes() };
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityTagFilter implements EntityFilter {
|
||||
constructor(readonly values: string[]) {}
|
||||
|
||||
filterEntity(entity: Entity): boolean {
|
||||
return this.values.every(v => (entity.metadata.tags ?? []).includes(v));
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityTextFilter implements EntityFilter {
|
||||
constructor(readonly value: string) {}
|
||||
|
||||
filterEntity(entity: Entity): boolean {
|
||||
const upperCaseValue = this.value.toLocaleUpperCase('en-US');
|
||||
|
||||
return (
|
||||
entity.metadata.name
|
||||
.toLocaleUpperCase('en-US')
|
||||
.includes(upperCaseValue) ||
|
||||
`${entity.metadata.title}`
|
||||
.toLocaleUpperCase('en-US')
|
||||
.includes(upperCaseValue) ||
|
||||
entity.metadata.tags
|
||||
?.join('')
|
||||
.toLocaleUpperCase('en-US')
|
||||
.indexOf(upperCaseValue) !== -1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityOwnerFilter implements EntityFilter {
|
||||
constructor(readonly values: string[]) {}
|
||||
|
||||
filterEntity(entity: Entity): boolean {
|
||||
return this.values.some(v =>
|
||||
getEntityRelations(entity, RELATION_OWNED_BY).some(
|
||||
o => formatEntityRefTitle(o, { defaultKind: 'group' }) === v,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityLifecycleFilter implements EntityFilter {
|
||||
constructor(readonly values: string[]) {}
|
||||
|
||||
filterEntity(entity: Entity): boolean {
|
||||
return this.values.some(v => entity.spec?.lifecycle === v);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,8 @@ import {
|
||||
useEntityListProvider,
|
||||
} from './useEntityListProvider';
|
||||
import { catalogApiRef } from '../api';
|
||||
import {
|
||||
EntityKindFilter,
|
||||
EntityTypeFilter,
|
||||
UserListFilter,
|
||||
UserListFilterKind,
|
||||
} from '../types';
|
||||
import { UserListFilterKind } from '../types';
|
||||
import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters';
|
||||
import { EntityKindPicker, UserListPicker } from '../components';
|
||||
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
|
||||
@@ -26,14 +26,15 @@ import React, {
|
||||
import { useAsyncFn, useDebounce } from 'react-use';
|
||||
import { catalogApiRef } from '../api';
|
||||
import {
|
||||
EntityFilter,
|
||||
EntityKindFilter,
|
||||
EntityLifecycleFilter,
|
||||
EntityOwnerFilter,
|
||||
EntityTagFilter,
|
||||
EntityTextFilter,
|
||||
EntityTypeFilter,
|
||||
UserListFilter,
|
||||
} from '../types';
|
||||
} from '../filters';
|
||||
import { EntityFilter } from '../types';
|
||||
import { reduceCatalogFilters, reduceEntityFilters } from '../utils';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -44,6 +45,7 @@ export type DefaultEntityFilters = {
|
||||
owners?: EntityOwnerFilter;
|
||||
lifecycles?: EntityLifecycleFilter;
|
||||
tags?: EntityTagFilter;
|
||||
text?: EntityTextFilter;
|
||||
};
|
||||
|
||||
export type EntityListContextProps<
|
||||
@@ -105,31 +107,40 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
|
||||
// The main async filter worker. Note that while it has a lot of dependencies
|
||||
// in terms of its implementation, the triggering only happens (debounced)
|
||||
// based on the requested filters changing.
|
||||
const [{ loading, error }, refresh] = useAsyncFn(async () => {
|
||||
const compacted = compact(Object.values(requestedFilters));
|
||||
const entityFilter = reduceEntityFilters(compacted);
|
||||
const backendFilter = reduceCatalogFilters(compacted);
|
||||
const previousBackendFilter = reduceCatalogFilters(
|
||||
compact(Object.values(outputState.appliedFilters)),
|
||||
);
|
||||
const [{ loading, error }, refresh] = useAsyncFn(
|
||||
async () => {
|
||||
const compacted = compact(Object.values(requestedFilters));
|
||||
const entityFilter = reduceEntityFilters(compacted);
|
||||
const backendFilter = reduceCatalogFilters(compacted);
|
||||
const previousBackendFilter = reduceCatalogFilters(
|
||||
compact(Object.values(outputState.appliedFilters)),
|
||||
);
|
||||
|
||||
if (!isEqual(previousBackendFilter, backendFilter)) {
|
||||
// TODO(timbonicus): should limit fields here, but would need filter
|
||||
// fields + table columns
|
||||
const response = await catalogApi.getEntities({ filter: backendFilter });
|
||||
setOutputState({
|
||||
appliedFilters: requestedFilters,
|
||||
backendEntities: response.items,
|
||||
entities: response.items.filter(entityFilter),
|
||||
});
|
||||
} else {
|
||||
setOutputState({
|
||||
appliedFilters: requestedFilters,
|
||||
backendEntities: outputState.backendEntities,
|
||||
entities: outputState.backendEntities.filter(entityFilter),
|
||||
});
|
||||
}
|
||||
}, [catalogApi, requestedFilters, outputState]);
|
||||
// TODO(mtlewis): currently entities will never be requested unless
|
||||
// there's at least one filter, we should allow an initial request
|
||||
// to happen with no filters.
|
||||
if (!isEqual(previousBackendFilter, backendFilter)) {
|
||||
// TODO(timbonicus): should limit fields here, but would need filter
|
||||
// fields + table columns
|
||||
const response = await catalogApi.getEntities({
|
||||
filter: backendFilter,
|
||||
});
|
||||
setOutputState({
|
||||
appliedFilters: requestedFilters,
|
||||
backendEntities: response.items,
|
||||
entities: response.items.filter(entityFilter),
|
||||
});
|
||||
} else {
|
||||
setOutputState({
|
||||
appliedFilters: requestedFilters,
|
||||
backendEntities: outputState.backendEntities,
|
||||
entities: outputState.backendEntities.filter(entityFilter),
|
||||
});
|
||||
}
|
||||
},
|
||||
[catalogApi, requestedFilters, outputState],
|
||||
{ loading: true },
|
||||
);
|
||||
|
||||
// Slight debounce on the refresh, since (especially on page load) several
|
||||
// filters will be calling this in rapid succession.
|
||||
@@ -167,7 +178,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
|
||||
};
|
||||
|
||||
export function useEntityListProvider<
|
||||
EntityFilters extends DefaultEntityFilters
|
||||
EntityFilters extends DefaultEntityFilters = DefaultEntityFilters
|
||||
>(): EntityListContextProps<EntityFilters> {
|
||||
const context = useContext(EntityListContext);
|
||||
if (!context)
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '../api';
|
||||
import {
|
||||
DefaultEntityFilters,
|
||||
useEntityListProvider,
|
||||
} from './useEntityListProvider';
|
||||
import { EntityTypeFilter } from '../types';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { EntityTypeFilter } from '../filters';
|
||||
|
||||
type EntityTypeReturn = {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
types: string[];
|
||||
selectedType: string | undefined;
|
||||
setType: (type: string | undefined) => void;
|
||||
availableTypes: string[];
|
||||
selectedTypes: string[];
|
||||
setSelectedTypes: (types: string[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,7 @@ export function useEntityTypeFilter(): EntityTypeReturn {
|
||||
updateFilters,
|
||||
} = useEntityListProvider();
|
||||
|
||||
const [types, setTypes] = useState<string[]>([]);
|
||||
const [availableTypes, setAvailableTypes] = useState<string[]>([]);
|
||||
const kind = useMemo(() => kindFilter?.value, [kindFilter]);
|
||||
|
||||
// Load all valid spec.type values straight from the catalogApi, paying attention to only the
|
||||
@@ -64,25 +64,45 @@ export function useEntityTypeFilter(): EntityTypeReturn {
|
||||
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);
|
||||
if (!entities) return;
|
||||
|
||||
// Reset type filter if no longer applicable
|
||||
updateFilters((oldFilters: DefaultEntityFilters) =>
|
||||
oldFilters.type && !newTypes.includes(oldFilters.type.value)
|
||||
? { type: undefined }
|
||||
: {},
|
||||
);
|
||||
// Sort by entity count descending, so the most common types appear on top
|
||||
const countByType = entities.reduce((acc, entity) => {
|
||||
if (typeof entity.spec?.type !== 'string') return acc;
|
||||
|
||||
if (!acc[entity.spec.type]) {
|
||||
acc[entity.spec.type] = 0;
|
||||
}
|
||||
acc[entity.spec.type] += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const newTypes = Object.entries(countByType)
|
||||
.sort(([, count1], [, count2]) => count2 - count1)
|
||||
.map(([type]) => type);
|
||||
setAvailableTypes(newTypes);
|
||||
|
||||
// Update type filter to only valid values when the list of available types has changed
|
||||
updateFilters((oldFilters: DefaultEntityFilters) => {
|
||||
// No filter previously set; no-op
|
||||
if (!oldFilters.type) {
|
||||
return {};
|
||||
}
|
||||
const stillValidTypes = oldFilters.type
|
||||
.getTypes()
|
||||
.filter(value => newTypes.includes(value));
|
||||
if (!stillValidTypes.length) {
|
||||
// None of the previously selected types are present any more; clear the filter
|
||||
return { type: undefined };
|
||||
}
|
||||
return { type: new EntityTypeFilter(stillValidTypes) };
|
||||
});
|
||||
}, [updateFilters, entities]);
|
||||
|
||||
const setType = useCallback(
|
||||
(type: string | undefined) =>
|
||||
const setSelectedTypes = useCallback(
|
||||
(types: string[]) =>
|
||||
updateFilters({
|
||||
type: type === undefined ? undefined : new EntityTypeFilter(type),
|
||||
type: types.length ? new EntityTypeFilter(types) : undefined,
|
||||
}),
|
||||
[updateFilters],
|
||||
);
|
||||
@@ -90,8 +110,8 @@ export function useEntityTypeFilter(): EntityTypeReturn {
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
types,
|
||||
selectedType: typeFilter?.value,
|
||||
setType,
|
||||
availableTypes,
|
||||
selectedTypes: typeFilter?.getTypes() ?? [],
|
||||
setSelectedTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type { CatalogApi } from '@backstage/catalog-client';
|
||||
export { catalogApiRef } from './api';
|
||||
export * from './components';
|
||||
export * from './hooks';
|
||||
export * from './filters';
|
||||
export {
|
||||
catalogRouteRef,
|
||||
entityRoute,
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import React, { PropsWithChildren, useCallback, useState } from 'react';
|
||||
import {
|
||||
DefaultEntityFilters,
|
||||
EntityListContext,
|
||||
EntityListContextProps,
|
||||
} from '../hooks/useEntityListProvider';
|
||||
@@ -23,17 +24,47 @@ import {
|
||||
export const MockEntityListContextProvider = ({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{ value: Partial<EntityListContextProps> }>) => {
|
||||
}: PropsWithChildren<{
|
||||
value: Partial<EntityListContextProps>;
|
||||
}>) => {
|
||||
// Provides a default implementation that stores filter state, for testing components that
|
||||
// reflect filter state.
|
||||
const [filters, setFilters] = useState<DefaultEntityFilters>(
|
||||
value.filters ?? {},
|
||||
);
|
||||
const updateFilters = useCallback(
|
||||
(
|
||||
update:
|
||||
| Partial<DefaultEntityFilters>
|
||||
| ((
|
||||
prevFilters: DefaultEntityFilters,
|
||||
) => Partial<DefaultEntityFilters>),
|
||||
) => {
|
||||
setFilters(prevFilters => {
|
||||
const newFilters =
|
||||
typeof update === 'function' ? update(prevFilters) : update;
|
||||
return { ...prevFilters, ...newFilters };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const defaultContext: EntityListContextProps = {
|
||||
entities: [],
|
||||
backendEntities: [],
|
||||
updateFilters: jest.fn(),
|
||||
filters: {},
|
||||
updateFilters: updateFilters,
|
||||
filters: filters,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
// Extract value.filters to avoid overwriting it; some tests exercise filter updates. The value
|
||||
// provided is used as the initial seed in useState above.
|
||||
const { filters: _, ...otherContextFields } = value;
|
||||
|
||||
return (
|
||||
<EntityListContext.Provider value={{ ...defaultContext, ...value }}>
|
||||
<EntityListContext.Provider
|
||||
value={{ ...defaultContext, ...otherContextFields }}
|
||||
>
|
||||
{children}
|
||||
</EntityListContext.Provider>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Entity,
|
||||
RELATION_OWNED_BY,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { getEntityRelations, isOwnerOf } from './utils';
|
||||
import { formatEntityRefTitle } from './components/EntityRefLink';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export type EntityFilter = {
|
||||
/**
|
||||
@@ -42,66 +36,4 @@ export type EntityFilter = {
|
||||
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 class EntityOwnerFilter implements EntityFilter {
|
||||
constructor(readonly values: string[]) {}
|
||||
|
||||
filterEntity(entity: Entity): boolean {
|
||||
return this.values.some(v =>
|
||||
getEntityRelations(entity, RELATION_OWNED_BY).some(
|
||||
o => formatEntityRefTitle(o, { defaultKind: 'group' }) === v,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityLifecycleFilter implements EntityFilter {
|
||||
constructor(readonly values: string[]) {}
|
||||
|
||||
filterEntity(entity: Entity): boolean {
|
||||
return this.values.some(v => entity.spec?.lifecycle === 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const CatalogTable = ({ columns, actions }: CatalogTableProps) => {
|
||||
const { loading, error, entities, filters } = useEntityListProvider();
|
||||
|
||||
const showTypeColumn = filters.type === undefined;
|
||||
// TODO(timbonicus): we should show filter chips for all filters instead
|
||||
// TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar
|
||||
const titlePreamble = capitalize(filters.user?.value ?? 'all');
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"humanize-duration": "^3.25.1",
|
||||
"immer": "^9.0.1",
|
||||
"json-schema": "^0.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^1.25.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { 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';
|
||||
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import {
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
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 categories', async () => {
|
||||
const categories = ['test', 'java'];
|
||||
const { findByText } = renderWrapped(
|
||||
<ResultsFilter availableCategories={categories} />,
|
||||
);
|
||||
for (const category of categories) {
|
||||
expect(
|
||||
await findByText(
|
||||
category.charAt(0).toLocaleUpperCase('en-US') + category.slice(1),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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, { useContext } 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 = {
|
||||
availableCategories: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The additional results filter in the sidebar.
|
||||
*/
|
||||
export const ResultsFilter = ({ availableCategories }: Props) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const context = useContext(filterGroupsContext);
|
||||
if (!context) {
|
||||
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
|
||||
}
|
||||
|
||||
const { selectedCategories, setSelectedCategories } = context;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.filterBox}>
|
||||
<Typography variant="subtitle2" className={classes.filterBoxTitle}>
|
||||
Refine Results
|
||||
</Typography>{' '}
|
||||
<Button onClick={() => setSelectedCategories([])}>Clear</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
<Typography variant="subtitle2" className={classes.title}>
|
||||
Categories
|
||||
</Typography>
|
||||
<List disablePadding dense>
|
||||
{availableCategories.map(category => {
|
||||
const labelId = `checkbox-list-label-${category}`;
|
||||
return (
|
||||
<ListItem
|
||||
key={category}
|
||||
dense
|
||||
button
|
||||
onClick={() =>
|
||||
setSelectedCategories(
|
||||
selectedCategories.includes(category)
|
||||
? selectedCategories.filter(
|
||||
selectedCategory => selectedCategory !== category,
|
||||
)
|
||||
: [...selectedCategories, category],
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
color="primary"
|
||||
checked={selectedCategories.includes(category)}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
className={classes.checkbox}
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
<ListItemText
|
||||
id={labelId}
|
||||
primary={
|
||||
category.charAt(0).toLocaleUpperCase('en-US') +
|
||||
category.slice(1)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,271 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { EntityFilterGroupsProvider } from '../../filter';
|
||||
import { ButtonGroup, ScaffolderFilter } from './ScaffolderFilter';
|
||||
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import {
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
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>,
|
||||
),
|
||||
);
|
||||
|
||||
describe('filter groups', () => {
|
||||
it('should render the different groups', async () => {
|
||||
const mockGroups: ButtonGroup[] = [
|
||||
{ name: 'Test Group 1', items: [] },
|
||||
{ name: 'Test Group 2', items: [] },
|
||||
];
|
||||
const { findByText } = renderWrapped(
|
||||
<ScaffolderFilter buttonGroups={mockGroups} initiallySelected="" />,
|
||||
);
|
||||
for (const group of mockGroups) {
|
||||
expect(await findByText(group.name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter items', () => {
|
||||
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(
|
||||
<ScaffolderFilter 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(
|
||||
<ScaffolderFilter
|
||||
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(
|
||||
<ScaffolderFilter
|
||||
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(
|
||||
<ScaffolderFilter
|
||||
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(
|
||||
<ScaffolderFilter
|
||||
buttonGroups={mockGroups}
|
||||
initiallySelected="owned"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
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';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* The main filter group in the sidebar, toggling owned/starred/all.
|
||||
*/
|
||||
export const ScaffolderFilter = ({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -14,32 +14,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntityMeta, TemplateEntityV1beta2 } from '@backstage/catalog-model';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
ItemCardGrid,
|
||||
Lifecycle,
|
||||
WarningPanel,
|
||||
Page,
|
||||
Progress,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { useStarredEntities } from '@backstage/plugin-catalog-react';
|
||||
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
EntityKindPicker,
|
||||
EntityListProvider,
|
||||
EntitySearchBar,
|
||||
EntityTagPicker,
|
||||
UserListPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
|
||||
import { registerComponentRouteRef } from '../../routes';
|
||||
import { ResultsFilter } from '../ResultsFilter/ResultsFilter';
|
||||
import { ScaffolderFilter } from '../ScaffolderFilter';
|
||||
import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter';
|
||||
import SearchToolbar from '../SearchToolbar/SearchToolbar';
|
||||
import { TemplateCard } from '../TemplateCard';
|
||||
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { TemplateList } from '../TemplateList';
|
||||
import { TemplateTypePicker } from '../TemplateTypePicker';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -52,63 +48,9 @@ const useStyles = makeStyles(theme => ({
|
||||
|
||||
export const ScaffolderPageContents = () => {
|
||||
const styles = useStyles();
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
filteredEntities,
|
||||
availableCategories,
|
||||
} = useFilteredEntities();
|
||||
const configApi = useApi(configApiRef);
|
||||
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
|
||||
const { isStarredEntity } = useStarredEntities();
|
||||
const filterGroups = useMemo<ButtonGroup[]>(
|
||||
() => [
|
||||
{
|
||||
name: orgName,
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All',
|
||||
filterFn: () => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: 'starred',
|
||||
label: 'Starred',
|
||||
icon: StarIcon,
|
||||
filterFn: isStarredEntity,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[isStarredEntity, orgName],
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [matchingEntities, setMatchingEntities] = useState(
|
||||
[] as TemplateEntityV1beta2[],
|
||||
);
|
||||
|
||||
const matchesQuery = (metadata: EntityMeta, query: string) =>
|
||||
`${metadata.title}`.toLocaleUpperCase('en-US').includes(query) ||
|
||||
metadata.tags?.join('').toLocaleUpperCase('en-US').indexOf(query) !== -1;
|
||||
|
||||
const registerComponentLink = useRouteRef(registerComponentRouteRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (search.length === 0) {
|
||||
return setMatchingEntities(filteredEntities);
|
||||
}
|
||||
return setMatchingEntities(
|
||||
filteredEntities.filter(template =>
|
||||
matchesQuery(template.metadata, search.toLocaleUpperCase('en-US')),
|
||||
),
|
||||
);
|
||||
}, [search, filteredEntities]);
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
@@ -141,42 +83,17 @@ export const ScaffolderPageContents = () => {
|
||||
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<SearchToolbar search={search} setSearch={setSearch} />
|
||||
<ScaffolderFilter
|
||||
buttonGroups={filterGroups}
|
||||
initiallySelected="all"
|
||||
<EntitySearchBar />
|
||||
<EntityKindPicker initialFilter="template" hidden />
|
||||
<UserListPicker
|
||||
initialFilter="all"
|
||||
availableFilters={['all', 'starred']}
|
||||
/>
|
||||
<ResultsFilter availableCategories={availableCategories} />
|
||||
<TemplateTypePicker />
|
||||
<EntityTagPicker />
|
||||
</div>
|
||||
<div>
|
||||
{loading && <Progress />}
|
||||
|
||||
{error && (
|
||||
<WarningPanel title="Oops! Something went wrong loading the templates">
|
||||
{error.message}
|
||||
</WarningPanel>
|
||||
)}
|
||||
|
||||
{!error &&
|
||||
!loading &&
|
||||
matchingEntities &&
|
||||
!matchingEntities.length && (
|
||||
<Typography variant="body2">
|
||||
No templates found that match your filter. Learn more about{' '}
|
||||
<Link href="https://backstage.io/docs/features/software-templates/adding-templates">
|
||||
adding templates
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<ItemCardGrid>
|
||||
{matchingEntities &&
|
||||
matchingEntities?.length > 0 &&
|
||||
matchingEntities.map((template, i) => (
|
||||
<TemplateCard key={i} template={template} />
|
||||
))}
|
||||
</ItemCardGrid>
|
||||
<TemplateList />
|
||||
</div>
|
||||
</div>
|
||||
</Content>
|
||||
@@ -185,7 +102,7 @@ export const ScaffolderPageContents = () => {
|
||||
};
|
||||
|
||||
export const ScaffolderPage = () => (
|
||||
<EntityFilterGroupsProvider>
|
||||
<EntityListProvider>
|
||||
<ScaffolderPageContents />
|
||||
</EntityFilterGroupsProvider>
|
||||
</EntityListProvider>
|
||||
);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 SearchToolbar from './SearchToolbar';
|
||||
|
||||
describe('SearchToolbar', () => {
|
||||
it('should display search value and execute set callback', async () => {
|
||||
const setSearchSpy = jest.fn();
|
||||
const { getByDisplayValue } = render(
|
||||
<SearchToolbar search="hello" setSearch={setSearchSpy} />,
|
||||
);
|
||||
|
||||
const searchInput = getByDisplayValue('hello');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
fireEvent.change(searchInput, { target: { value: 'world' } });
|
||||
expect(setSearchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { TemplateEntityV1beta2 } from '@backstage/catalog-model';
|
||||
import {
|
||||
ItemCardGrid,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { useEntityListProvider } from '@backstage/plugin-catalog-react';
|
||||
import { Link, Typography } from '@material-ui/core';
|
||||
import { TemplateCard } from '../TemplateCard';
|
||||
|
||||
export const TemplateList = () => {
|
||||
const { loading, error, entities } = useEntityListProvider();
|
||||
return (
|
||||
<>
|
||||
{loading && <Progress />}
|
||||
|
||||
{error && (
|
||||
<WarningPanel title="Oops! Something went wrong loading the templates">
|
||||
{error.message}
|
||||
</WarningPanel>
|
||||
)}
|
||||
|
||||
{!error && !loading && !entities.length && (
|
||||
<Typography variant="body2">
|
||||
No templates found that match your filter. Learn more about{' '}
|
||||
<Link href="https://backstage.io/docs/features/software-templates/adding-templates">
|
||||
adding templates
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<ItemCardGrid>
|
||||
{entities &&
|
||||
entities?.length > 0 &&
|
||||
entities.map((template, i) => (
|
||||
<TemplateCard
|
||||
key={i}
|
||||
template={template as TemplateEntityV1beta2}
|
||||
/>
|
||||
))}
|
||||
</ItemCardGrid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
-2
@@ -13,5 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ScaffolderFilter } from './ScaffolderFilter';
|
||||
export { TemplateList } from './TemplateList';
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 } from '@testing-library/react';
|
||||
import { capitalize } from 'lodash';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { TemplateTypePicker } from './TemplateTypePicker';
|
||||
import {
|
||||
catalogApiRef,
|
||||
EntityKindFilter,
|
||||
MockEntityListContextProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { renderWithEffects } from '../../../../../packages/test-utils-core/src';
|
||||
|
||||
const entities: Entity[] = [
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'template-1',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'template-2',
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: '1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'template-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('<TemplateTypePicker/>', () => {
|
||||
it('renders available entity types', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
filters: { kind: new EntityKindFilter('template') },
|
||||
backendEntities: entities,
|
||||
}}
|
||||
>
|
||||
<TemplateTypePicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(rendered.getByText('Categories')).toBeInTheDocument();
|
||||
|
||||
entities.forEach(entity => {
|
||||
expect(
|
||||
rendered.getByLabelText(capitalize(entity.spec!.type as string)),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the selected type filters', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
filters: { kind: new EntityKindFilter('template') },
|
||||
backendEntities: entities,
|
||||
}}
|
||||
>
|
||||
<TemplateTypePicker />
|
||||
</MockEntityListContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByLabelText('Service')).not.toBeChecked();
|
||||
expect(rendered.getByLabelText('Website')).not.toBeChecked();
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Service'));
|
||||
expect(rendered.getByLabelText('Service')).toBeChecked();
|
||||
expect(rendered.getByLabelText('Website')).not.toBeChecked();
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Website'));
|
||||
expect(rendered.getByLabelText('Service')).toBeChecked();
|
||||
expect(rendered.getByLabelText('Website')).toBeChecked();
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Service'));
|
||||
expect(rendered.getByLabelText('Service')).not.toBeChecked();
|
||||
expect(rendered.getByLabelText('Website')).toBeChecked();
|
||||
|
||||
fireEvent.click(rendered.getByLabelText('Website'));
|
||||
expect(rendered.getByLabelText('Service')).not.toBeChecked();
|
||||
expect(rendered.getByLabelText('Website')).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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/capitalize';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
checkbox: {
|
||||
padding: theme.spacing(1, 1, 1, 2),
|
||||
},
|
||||
}));
|
||||
|
||||
export const TemplateTypePicker = () => {
|
||||
const classes = useStyles();
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const {
|
||||
error,
|
||||
loading,
|
||||
availableTypes,
|
||||
selectedTypes,
|
||||
setSelectedTypes,
|
||||
} = useEntityTypeFilter();
|
||||
|
||||
if (loading) return <Progress />;
|
||||
|
||||
if (!availableTypes) return null;
|
||||
|
||||
if (error) {
|
||||
alertApi.post({
|
||||
message: `Failed to load entity types`,
|
||||
severity: 'error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleSelection(type: string) {
|
||||
setSelectedTypes(
|
||||
selectedTypes.includes(type)
|
||||
? selectedTypes.filter(t => t !== type)
|
||||
: [...selectedTypes, type],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pb={1} pt={1}>
|
||||
<Typography variant="button">Categories</Typography>
|
||||
<FormGroup>
|
||||
{availableTypes.map(type => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedTypes.includes(type)}
|
||||
onChange={() => toggleSelection(type)}
|
||||
className={classes.checkbox}
|
||||
/>
|
||||
}
|
||||
label={capitalize(type)}
|
||||
key={type}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { TemplateTypePicker } from './TemplateTypePicker';
|
||||
@@ -1,263 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { TemplateEntityV1beta2 } from '@backstage/catalog-model';
|
||||
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';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* 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: 'Template' },
|
||||
});
|
||||
return response.items as TemplateEntityV1beta2[];
|
||||
});
|
||||
|
||||
const filterGroups = useRef<{
|
||||
[filterGroupId: string]: FilterGroup;
|
||||
}>({});
|
||||
const selectedFilterKeys = useRef<{
|
||||
[filterGroupId: string]: Set<string>;
|
||||
}>({});
|
||||
const selectedCategories = useRef<string[]>([]);
|
||||
const [filterGroupStates, setFilterGroupStates] = useState<{
|
||||
[filterGroupId: string]: FilterGroupStates;
|
||||
}>({});
|
||||
const [filteredEntities, setFilteredEntities] = useState<
|
||||
TemplateEntityV1beta2[]
|
||||
>([]);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [isCatalogEmpty, setCatalogEmpty] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
doReload();
|
||||
}, [doReload]);
|
||||
|
||||
const rebuild = useCallback(() => {
|
||||
setFilterGroupStates(
|
||||
buildStates(
|
||||
filterGroups.current,
|
||||
selectedFilterKeys.current,
|
||||
selectedCategories.current,
|
||||
entities,
|
||||
error,
|
||||
),
|
||||
);
|
||||
setFilteredEntities(
|
||||
buildMatchingEntities(
|
||||
filterGroups.current,
|
||||
selectedFilterKeys.current,
|
||||
selectedCategories.current,
|
||||
entities,
|
||||
),
|
||||
);
|
||||
setAvailableCategories(collectCategories(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 setSelectedCategories = useCallback(
|
||||
(categories: string[]) => {
|
||||
selectedCategories.current = categories;
|
||||
rebuild();
|
||||
},
|
||||
[rebuild],
|
||||
);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
await doReload();
|
||||
}, [doReload]);
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
setGroupSelectedFilters,
|
||||
setSelectedCategories,
|
||||
reload,
|
||||
selectedCategories: selectedCategories.current,
|
||||
loading: !error && !entities,
|
||||
error,
|
||||
filterGroupStates,
|
||||
filteredEntities,
|
||||
availableCategories,
|
||||
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> },
|
||||
selectedCategories: string[],
|
||||
entities?: TemplateEntityV1beta2[],
|
||||
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,
|
||||
selectedCategories,
|
||||
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 categories and provide them in a sorted list.
|
||||
function collectCategories(entities?: TemplateEntityV1beta2[]): string[] {
|
||||
const categories = new Set<string>();
|
||||
(entities || []).forEach(e => {
|
||||
if (e.spec?.type) {
|
||||
categories.add(e.spec.type as string);
|
||||
}
|
||||
});
|
||||
return Array.from(categories).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> },
|
||||
selectedCategories: string[],
|
||||
entities?: TemplateEntityV1beta2[],
|
||||
excludeFilterGroupId?: string,
|
||||
): TemplateEntityV1beta2[] {
|
||||
// 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 categories, if at least one category is selected.
|
||||
if (selectedCategories.length > 0) {
|
||||
allFilters.push(entity =>
|
||||
selectedCategories.some(c => entity.spec?.type === c),
|
||||
);
|
||||
}
|
||||
|
||||
// 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,45 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { TemplateEntityV1beta2 } 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;
|
||||
setSelectedCategories: (categories: string[]) => void;
|
||||
reload: () => Promise<void>;
|
||||
selectedCategories: string[];
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
filteredEntities: TemplateEntityV1beta2[];
|
||||
availableCategories: 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 The Backstage Authors
|
||||
*
|
||||
* 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,124 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { 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';
|
||||
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { storageApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
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,69 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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;
|
||||
|
||||
// Intentionally consider initial set only at mount time
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []);
|
||||
|
||||
// Register the group on mount, and unregister on unmount
|
||||
useEffect(() => {
|
||||
register(filterGroupId, filterGroup, initialMemo);
|
||||
return () => unregister(filterGroupId);
|
||||
}, [register, unregister, filterGroupId, filterGroup, initialMemo]);
|
||||
|
||||
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 The Backstage Authors
|
||||
*
|
||||
* 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,
|
||||
filteredEntities: context.filteredEntities,
|
||||
availableCategories: context.availableCategories,
|
||||
isCatalogEmpty: context.isCatalogEmpty,
|
||||
reload: context.reload,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user