Remove deprecated hooks

Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
Tim Hansen
2021-05-12 12:08:41 -06:00
parent cf0d361710
commit 5e0ae3428f
15 changed files with 1 additions and 1476 deletions
@@ -1,33 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { CircularProgress, useTheme } from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
export const AllServicesCount = () => {
const theme = useTheme();
const catalogApi = useApi(catalogApiRef);
const { value, loading } = useAsync(() => catalogApi.getEntities());
if (loading) {
return <CircularProgress size={theme.spacing(2)} />;
}
return <span>{value ?? length ?? '-'}</span>;
};
@@ -1,264 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { EntityFilterGroupsProvider } from '../../filter';
import { ButtonGroup, CatalogFilter } from './CatalogFilter';
describe('Catalog Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[],
}),
};
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render the different groups', async () => {
const mockGroups: ButtonGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="" />,
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
}
});
it('should render the different items and their names', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="all" />,
);
for (const item of mockGroups[0].items) {
expect(await findByText(item.label)).toBeInTheDocument();
}
});
it('selects the first item if no desired initial one is set', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
});
it('selects the initial item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
onChange={onChange}
initiallySelected="starred"
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('can change the selected item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
const { findByText } = renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
fireEvent.click(await findByText('Second Label'));
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('displays match counts properly', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'owned',
label: 'First Label',
filterFn: entity => entity.spec?.owner === 'tools@example.com',
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="owned" />,
);
expect(await findByText('1')).toBeInTheDocument();
});
});
@@ -1,219 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { IconComponent } from '@backstage/core';
import {
Card,
List,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
makeStyles,
MenuItem,
Theme,
Typography,
} from '@material-ui/core';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
export type ButtonGroup = {
name: string;
items: {
id: string;
label: string;
icon?: IconComponent;
filterFn: (entity: Entity) => boolean;
}[];
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
backgroundColor: 'rgba(0, 0, 0, .11)',
boxShadow: 'none',
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
listIcon: {
minWidth: 30,
color: theme.palette.text.primary,
},
menuItem: {
minHeight: theme.spacing(6),
},
groupWrapper: {
margin: theme.spacing(1, 1, 2, 1),
},
menuTitle: {
fontWeight: 500,
},
}));
type OnChangeCallback = (item: { id: string; label: string }) => void;
type Props = {
buttonGroups: ButtonGroup[];
initiallySelected: string;
onChange?: OnChangeCallback;
};
/**
* Sidebar filter type and human readable label for it. owned/starred/all
*/
export type CatalogFilterType = {
id: string;
label: string;
};
/**
* The main filter group in the sidebar, toggling owned/starred/all.
*/
export const CatalogFilter = ({
buttonGroups,
onChange,
initiallySelected,
}: Props) => {
const classes = useStyles();
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
buttonGroups,
initiallySelected,
);
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const setCurrent = useCallback(
(item: { id: string; label: string }) => {
setCurrentFilter(item.id);
onChangeRef.current?.({ id: item.id, label: item.label });
},
[setCurrentFilter],
);
// Make one initial onChange to inform the surroundings about the selected
// item
useEffect(() => {
const items = buttonGroups.flatMap(g => g.items);
const item = items.find(i => i.id === initiallySelected) || items[0];
if (item) {
onChangeRef.current?.({ id: item.id, label: item.label });
}
// intentionally only happens on startup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Card className={classes.root}>
{buttonGroups.map(group => (
<React.Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense>
{group.items.map(item => (
<MenuItem
key={item.id}
button
divider
onClick={() => setCurrent(item)}
selected={item.id === currentFilter}
className={classes.menuItem}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography variant="body1" className={classes.menuTitle}>
{item.label}
</Typography>
</ListItemText>
<ListItemSecondaryAction>
{getFilterCount(item.id) ?? '-'}
</ListItemSecondaryAction>
</MenuItem>
))}
</List>
</Card>
</React.Fragment>
))}
</Card>
);
};
function useFilter(
buttonGroups: ButtonGroup[],
initiallySelected: string,
): {
currentFilter: string;
setCurrentFilter: (filterId: string) => void;
getFilterCount: (filterId: string) => number | undefined;
} {
const [currentFilter, setCurrentFilter] = useState(initiallySelected);
const filterGroup = useMemo<FilterGroup>(
() => ({
filters: Object.fromEntries(
buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]),
),
}),
[buttonGroups],
);
const { setSelectedFilters, state } = useEntityFilterGroup(
'primary-sidebar',
filterGroup,
[initiallySelected],
);
const setCurrent = useCallback(
(filterId: string) => {
setCurrentFilter(filterId);
setSelectedFilters([filterId]);
},
[setCurrentFilter, setSelectedFilters],
);
const getFilterCount = useCallback(
(filterId: string) => {
if (state.type !== 'ready') {
return undefined;
}
return state.state.filters[filterId].matchCount;
},
[state],
);
return {
currentFilter,
setCurrentFilter: setCurrent,
getFilterCount,
};
}
@@ -1,17 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CatalogFilter } from './CatalogFilter';
@@ -32,7 +32,6 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { EntityFilterGroupsProvider } from '../../filter';
import { createComponentRouteRef } from '../../routes';
import { CatalogPage } from './CatalogPage';
@@ -115,7 +114,7 @@ describe('CatalogPage', () => {
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
{children}
</ApiProvider>,
{
mountedRoutes: {
@@ -1,95 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { HeaderTabs } from '@backstage/core';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
/**
* A component type, and a human readable label for it.
*/
export type LabeledComponentType = {
id: string;
label: string;
};
/**
* Called on mount, and when the selected tab changes.
*/
export type OnChangeCallback = (tab: LabeledComponentType) => void;
type Props = {
tabs: LabeledComponentType[];
onChange?: OnChangeCallback;
};
/**
* The tabs at the top of the catalog list page, for component type filtering.
*/
export const CatalogTabs = ({ tabs, onChange }: Props) => {
const filterGroup = useMemo<FilterGroup>(() => {
const otherType = 'other';
const wellKnownTypes = tabs.map(t => t.id).filter(t => t !== otherType);
const isOtherType = (entity: Entity) =>
!wellKnownTypes.includes(entity.spec?.type as string);
return {
filters: Object.fromEntries(
tabs.map(t => [
t.id,
(entity: Entity) =>
(t.id === otherType && isOtherType(entity)) ||
entity.spec?.type === t.id,
]),
),
};
}, [tabs]);
const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [
tabs[0].id,
]);
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
// Hold a reference to the callback
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onChangeRef.current?.(tabs[currentTabIndex]);
}, [tabs, currentTabIndex]);
const switchTab = useCallback(
(index: number) => {
const tab = tabs[index];
setSelectedFilters([tab.id]);
setCurrentTabIndex(index);
onChangeRef.current?.(tab);
},
[tabs, setSelectedFilters],
);
return <HeaderTabs tabs={tabs} onChange={switchTab} />;
};
@@ -1,105 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { EntityFilterGroupsProvider } from '../../filter';
import { ResultsFilter } from './ResultsFilter';
describe('Results Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
] as Entity[],
}),
};
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render all available tags', async () => {
const tags = ['test', 'java'];
const { findByText } = renderWrapped(
<ResultsFilter availableTags={tags} />,
);
for (const tag of tags) {
expect(await findByText(tag)).toBeInTheDocument();
}
});
});
@@ -1,121 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Button,
Checkbox,
Divider,
List,
ListItem,
ListItemText,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import React, { useCallback, useContext, useState } from 'react';
import { filterGroupsContext } from '../../filter/context';
const useStyles = makeStyles<Theme>(theme => ({
filterBox: {
display: 'flex',
margin: theme.spacing(2, 0, 0, 0),
},
filterBoxTitle: {
margin: theme.spacing(1, 0, 0, 1),
fontWeight: 'bold',
flex: 1,
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
checkbox: {
padding: theme.spacing(0, 1, 0, 1),
},
}));
type Props = {
availableTags: string[];
};
/**
* The additional results filter in the sidebar.
*/
export const ResultsFilter = ({ availableTags }: Props) => {
const classes = useStyles();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const setSelectedTagsFilter = context?.setSelectedTags;
const updateSelectedTags = useCallback(
(tags: string[]) => {
setSelectedTags(tags);
setSelectedTagsFilter(tags);
},
[setSelectedTags, setSelectedTagsFilter],
);
return (
<>
<div className={classes.filterBox}>
<Typography variant="subtitle2" className={classes.filterBoxTitle}>
Refine Results
</Typography>{' '}
<Button onClick={() => updateSelectedTags([])}>Clear</Button>
</div>
<Divider />
<Typography variant="subtitle2" className={classes.title}>
Tags
</Typography>
<List disablePadding dense>
{availableTags.map(t => {
const labelId = `checkbox-list-label-${t}`;
return (
<ListItem
key={t}
dense
button
onClick={() =>
updateSelectedTags(
selectedTags.includes(t)
? selectedTags.filter(s => s !== t)
: [...selectedTags, t],
)
}
>
<Checkbox
edge="start"
color="primary"
checked={selectedTags.includes(t)}
tabIndex={-1}
disableRipple
className={classes.checkbox}
inputProps={{ 'aria-labelledby': labelId }}
/>
<ListItemText id={labelId} primary={t} />
</ListItem>
);
})}
</List>
</>
);
};
@@ -1,263 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAsyncFn } from 'react-use';
import { filterGroupsContext, FilterGroupsContext } from './context';
import {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
} from './types';
/**
* Implementation of the shared filter groups state.
*/
export const EntityFilterGroupsProvider = ({
children,
}: {
children?: React.ReactNode;
}) => {
const state = useProvideEntityFilters();
return (
<filterGroupsContext.Provider value={state}>
{children}
</filterGroupsContext.Provider>
);
};
// The hook that implements the actual context building
function useProvideEntityFilters(): FilterGroupsContext {
const catalogApi = useApi(catalogApiRef);
const [{ value: entities, error }, doReload] = useAsyncFn(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Component' },
});
return response.items;
});
const filterGroups = useRef<{
[filterGroupId: string]: FilterGroup;
}>({});
const selectedFilterKeys = useRef<{
[filterGroupId: string]: Set<string>;
}>({});
const selectedTags = useRef<string[]>([]);
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [isCatalogEmpty, setCatalogEmpty] = useState<boolean>(false);
useEffect(() => {
doReload();
}, [doReload]);
const rebuild = useCallback(() => {
setFilterGroupStates(
buildStates(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
error,
),
);
setMatchingEntities(
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
),
);
setAvailableTags(collectTags(entities));
setCatalogEmpty(entities !== undefined && entities.length === 0);
}, [entities, error]);
const register = useCallback(
(
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => {
filterGroups.current[filterGroupId] = filterGroup;
selectedFilterKeys.current[filterGroupId] = new Set(
initialSelectedFilterIds ?? [],
);
rebuild();
},
[rebuild],
);
const unregister = useCallback(
(filterGroupId: string) => {
delete filterGroups.current[filterGroupId];
delete selectedFilterKeys.current[filterGroupId];
rebuild();
},
[rebuild],
);
const setGroupSelectedFilters = useCallback(
(filterGroupId: string, filters: string[]) => {
selectedFilterKeys.current[filterGroupId] = new Set(filters);
rebuild();
},
[rebuild],
);
const setSelectedTags = useCallback(
(tags: string[]) => {
selectedTags.current = tags;
rebuild();
},
[rebuild],
);
const reload = useCallback(async () => {
await doReload();
}, [doReload]);
return {
register,
unregister,
setGroupSelectedFilters,
setSelectedTags,
reload,
loading: !error && !entities,
error,
filterGroupStates,
matchingEntities,
availableTags,
isCatalogEmpty,
};
}
// Given all filter groups and what filters are actually selected, along with
// the loading state for entities, generate the state of each individual filter
function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
// On error - all entries are an error state
if (error) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'error', error },
]),
);
}
// On startup - all entries are a loading state
if (!entities) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'loading' },
]),
);
}
const result: { [filterGroupId: string]: FilterGroupStates } = {};
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
const otherMatchingEntities = buildMatchingEntities(
filterGroups,
selectedFilterKeys,
selectedTags,
entities,
filterGroupId,
);
const groupState: FilterGroupState = { filters: {} };
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId);
const matchCount = otherMatchingEntities.filter(entity =>
filterFn(entity),
).length;
groupState.filters[filterId] = { isSelected, matchCount };
}
result[filterGroupId] = { type: 'ready', state: groupState };
}
return result;
}
// Given all entites, find all possible tags and provide them in a sorted list.
function collectTags(entities?: Entity[]): string[] {
const tags = new Set<string>();
(entities || []).forEach(e => {
if (e.metadata.tags) {
e.metadata.tags.forEach(t => tags.add(t));
}
});
return Array.from(tags).sort();
}
// Given all filter groups and what filters are actually selected, extract all
// entities that match all those filter groups.
function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
excludeFilterGroupId?: string,
): Entity[] {
// Build one filter fn per filter group
const allFilters: EntityFilterFn[] = [];
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
if (excludeFilterGroupId === filterGroupId) {
continue;
}
// Pick out all of the filter functions in the group that are actually
// selected
const groupFilters: EntityFilterFn[] = [];
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) {
groupFilters.push(filterFn);
}
}
// Need to match any of the selected filters in the group - if there is
// any at all
if (groupFilters.length) {
allFilters.push(entity => groupFilters.some(fn => fn(entity)));
}
}
// Filter by tags, if at least one tag is selected. Include all entities
// that have at least one of the selected tags
if (selectedTags.length > 0) {
allFilters.push(
entity =>
!!entity.metadata.tags &&
entity.metadata.tags.some(t => selectedTags.includes(t)),
);
}
// All filter groups that had any checked filters need to match. Note that
// every() always returns true for an empty array.
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
}
-44
View File
@@ -1,44 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { createContext } from 'react';
import { FilterGroup, FilterGroupStates } from './types';
export type FilterGroupsContext = {
register: (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => void;
unregister: (filterGroupId: string) => void;
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
setSelectedTags: (tags: string[]) => void;
reload: () => Promise<void>;
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
matchingEntities: Entity[];
availableTags: string[];
isCatalogEmpty: boolean;
};
/**
* The context that maintains shared state for all visible filter groups.
*/
export const filterGroupsContext = createContext<
FilterGroupsContext | undefined
>(undefined);
-28
View File
@@ -1,28 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
export type {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
FilterGroupStatesError,
FilterGroupStatesLoading,
FilterGroupStatesReady,
} from './types';
export { useEntityFilterGroup } from './useEntityFilterGroup';
export { useFilteredEntities } from './useFilteredEntities';
-53
View File
@@ -1,53 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
export type EntityFilterFn = (entity: Entity) => boolean;
export type FilterGroup = {
filters: {
[filterId: string]: EntityFilterFn;
};
};
export type FilterGroupState = {
filters: {
[filterId: string]: {
isSelected: boolean;
matchCount: number;
};
};
};
export type FilterGroupStatesReady = {
type: 'ready';
state: FilterGroupState;
};
export type FilterGroupStatesError = {
type: 'error';
error: Error;
};
export type FilterGroupStatesLoading = {
type: 'loading';
};
export type FilterGroupStates =
| FilterGroupStatesReady
| FilterGroupStatesError
| FilterGroupStatesLoading;
@@ -1,122 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi } from '@backstage/test-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
import { FilterGroup, FilterGroupStatesReady } from './types';
import { useEntityFilterGroup } from './useEntityFilterGroup';
describe('useEntityFilterGroup', () => {
let catalogApi: jest.Mocked<typeof catalogApiRef.T>;
let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element;
beforeEach(() => {
catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(_a => new Promise(() => {})),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
storageApiRef,
MockStorageApi.create(),
);
wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>
</ApiProvider>
);
});
it('works for an empty set of filters', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const group: FilterGroup = { filters: {} };
const { result, waitFor } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await waitFor(() => expect(result.current.state.type).toBe('ready'));
});
it('works for a single group', async () => {
catalogApi.getEntities.mockResolvedValue({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
],
});
const group: FilterGroup = {
filters: {
f1: e => e.metadata.name === 'n',
f2: e => e.metadata.name !== 'n',
},
};
const { result, waitFor } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await waitFor(() => expect(result.current.state.type).toEqual('ready'));
let state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f1']));
await waitFor(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: true,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f2']));
await waitFor(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: true,
matchCount: 0,
});
});
});
@@ -1,73 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { filterGroupsContext } from './context';
import { FilterGroup, FilterGroupStates } from './types';
export type EntityFilterGroupOutput = {
state: FilterGroupStates;
setSelectedFilters: (filterIds: string[]) => void;
};
/**
* Hook that exposes the relevant data and operations for a single filter
* group.
*/
export const useEntityFilterGroup = (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilters?: string[],
): EntityFilterGroupOutput => {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const {
register,
unregister,
setGroupSelectedFilters,
filterGroupStates,
} = context;
// on state changes unregisters and registers the filtergroup
// ensure that it re-registers with the correct filter as the prop changes and not the default
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialMemo = useMemo(() => {
return initialSelectedFilters?.slice();
}, [initialSelectedFilters]);
// Register the group on mount, and unregister on unmount
useEffect(() => {
register(filterGroupId, filterGroup, initialMemo);
return () => unregister(filterGroupId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [register, unregister, filterGroupId, filterGroup]);
const setSelectedFilters = useCallback(
(filters: string[]) => {
setGroupSelectedFilters(filterGroupId, filters);
},
[setGroupSelectedFilters, filterGroupId],
);
let state = filterGroupStates[filterGroupId];
if (!state) {
state = { type: 'loading' };
}
return { state, setSelectedFilters };
};
@@ -1,37 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useContext } from 'react';
import { filterGroupsContext } from './context';
/**
* Hook that exposes the result of applying a set of filter groups.
*/
export function useFilteredEntities() {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
return {
loading: context.loading,
error: context.error,
matchingEntities: context.matchingEntities,
availableTags: context.availableTags,
isCatalogEmpty: context.isCatalogEmpty,
reload: context.reload,
};
}