Merge pull request #6150 from backstage/mob/use-entity-list-provider-in-scaffolder-page

Use `EntityListProvider` in `ScaffolderPage`
This commit is contained in:
Ben Lambert
2021-07-08 16:50:55 +02:00
committed by GitHub
44 changed files with 789 additions and 1622 deletions
+10
View File
@@ -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;
@@ -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[] = [
@@ -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,
});
});
});
@@ -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;
@@ -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';
+92
View File
@@ -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();
});
});
+113
View File
@@ -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,
};
}
+1
View File
@@ -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>
);
+1 -69
View File
@@ -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) {
+1
View File
@@ -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>
</>
);
};
@@ -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))) ?? [];
}
-45
View File
@@ -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);
-53
View File
@@ -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,
};
}