Clean up and finalize
This commit is contained in:
@@ -14,63 +14,78 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core';
|
||||
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
|
||||
import { CatalogApi, catalogApiRef } from '../../api/types';
|
||||
import { EntityGroup } from '../../data/filters';
|
||||
import { EntityFilterGroupsProvider } from '../../filter';
|
||||
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
|
||||
|
||||
describe('Catalog Filter', () => {
|
||||
const comp1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component-1',
|
||||
},
|
||||
spec: {
|
||||
owner: 'team',
|
||||
},
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
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 comp2 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component-2',
|
||||
},
|
||||
spec: {
|
||||
owner: 'team',
|
||||
},
|
||||
};
|
||||
const comp3 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'my-component-3',
|
||||
},
|
||||
spec: {
|
||||
owner: '',
|
||||
},
|
||||
};
|
||||
const defaultFilterProps = {
|
||||
selectedFilter: EntityGroup.ALL,
|
||||
onFilterChange: (type: EntityGroup) => type,
|
||||
entitiesByFilter: {
|
||||
[EntityGroup.ALL]: [comp1, comp2, comp3],
|
||||
[EntityGroup.STARRED]: [comp1],
|
||||
[EntityGroup.OWNED]: [comp1],
|
||||
},
|
||||
|
||||
const indentityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'tools@example.com',
|
||||
};
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, indentityApi],
|
||||
[storageApiRef, MockStorageApi.create()],
|
||||
])}
|
||||
>
|
||||
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
it('should render the different groups', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{ name: 'Test Group 1', items: [] },
|
||||
{ name: 'Test Group 2', items: [] },
|
||||
];
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
),
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter filterGroups={mockGroups} />,
|
||||
);
|
||||
|
||||
for (const group of mockGroups) {
|
||||
expect(await findByText(group.name)).toBeInTheDocument();
|
||||
}
|
||||
@@ -93,19 +108,16 @@ describe('Catalog Filter', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
),
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter filterGroups={mockGroups} />,
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
for (const item of mockGroups[0].items) {
|
||||
expect(await findByText(item.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the count in each item', async () => {
|
||||
it('selects the first item if no desired initial one is set', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
@@ -113,33 +125,30 @@ describe('Catalog Filter', () => {
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
label: 'First Label',
|
||||
count: 3,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
label: 'Second Label',
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { getAllByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
),
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWrapped(
|
||||
<CatalogFilter filterGroups={mockGroups} onChange={onChange} />,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) {
|
||||
const matcher = new RegExp(
|
||||
`(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`,
|
||||
);
|
||||
const items = await getAllByText(matcher);
|
||||
items.forEach(el => expect(el).toBeInTheDocument());
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: EntityGroup.ALL,
|
||||
label: 'First Label',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should fire the callback when an item is clicked', async () => {
|
||||
it('selects the initial item', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
@@ -147,39 +156,34 @@ describe('Catalog Filter', () => {
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const onSelectedChangeHandler = jest.fn();
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter
|
||||
{...defaultFilterProps}
|
||||
groups={mockGroups}
|
||||
onFilterChange={onSelectedChangeHandler}
|
||||
/>,
|
||||
),
|
||||
renderWrapped(
|
||||
<CatalogFilter
|
||||
filterGroups={mockGroups}
|
||||
onChange={onChange}
|
||||
initiallySelected={EntityGroup.STARRED}
|
||||
/>,
|
||||
);
|
||||
|
||||
const item = mockGroups[0].items[0];
|
||||
|
||||
const element = await findByText(item.label);
|
||||
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id);
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: EntityGroup.STARRED,
|
||||
label: 'Second Label',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render a component when a function is passed to the count component', async () => {
|
||||
it('can change the selected item', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
@@ -187,22 +191,55 @@ describe('Catalog Filter', () => {
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
label: 'First Label',
|
||||
count: () => <b>BACKSTAGE!</b>,
|
||||
},
|
||||
{
|
||||
id: EntityGroup.STARRED,
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
|
||||
),
|
||||
|
||||
const onChange = jest.fn();
|
||||
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter filterGroups={mockGroups} onChange={onChange} />,
|
||||
);
|
||||
|
||||
expect(await findByText('Test Group 1')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: EntityGroup.ALL,
|
||||
label: 'First Label',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(await findByText('Second Label'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
id: EntityGroup.STARRED,
|
||||
label: 'Second Label',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('displays match counts properly', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.OWNED,
|
||||
label: 'First Label',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = renderWrapped(
|
||||
<CatalogFilter filterGroups={mockGroups} />,
|
||||
);
|
||||
|
||||
expect(await findByText('1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,21 +14,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { IconComponent, identityApiRef, useApi } from '@backstage/core';
|
||||
import {
|
||||
Card,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
MenuItem,
|
||||
Typography,
|
||||
Theme,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import type { IconComponent } from '@backstage/core';
|
||||
import { EntityGroup } from '../../data/filters';
|
||||
import { EntitiesByFilter } from '../../hooks/useEntities';
|
||||
import React, { FC, useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
EntityFilterOptions,
|
||||
entityFilters,
|
||||
EntityGroup,
|
||||
} from '../../data/filters';
|
||||
import { FilterGroup, useEntityFilterGroup } from '../../filter';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
|
||||
export type CatalogFilterItem = {
|
||||
id: EntityGroup;
|
||||
@@ -68,21 +73,43 @@ const useStyles = makeStyles<Theme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const CatalogFilter: FC<{
|
||||
selectedFilter: EntityGroup;
|
||||
onFilterChange: (type: EntityGroup) => void;
|
||||
entitiesByFilter: EntitiesByFilter;
|
||||
groups: CatalogFilterGroup[];
|
||||
}> = ({
|
||||
selectedFilter: selectedId,
|
||||
onFilterChange: setSelectedFilter,
|
||||
entitiesByFilter,
|
||||
groups,
|
||||
}) => {
|
||||
type Props = {
|
||||
filterGroups: CatalogFilterGroup[];
|
||||
onChange?: (filterItem: CatalogFilterItem) => void;
|
||||
initiallySelected?: EntityGroup;
|
||||
};
|
||||
|
||||
export const CatalogFilter = ({
|
||||
filterGroups,
|
||||
onChange,
|
||||
initiallySelected,
|
||||
}: Props) => {
|
||||
const classes = useStyles();
|
||||
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter();
|
||||
|
||||
const setCurrent = useCallback(
|
||||
(item: CatalogFilterItem) => {
|
||||
setCurrentFilter(item.id);
|
||||
onChange?.(item);
|
||||
},
|
||||
[onChange, setCurrentFilter],
|
||||
);
|
||||
|
||||
// Make one initial onChange to inform the surroundings about the selected
|
||||
// item
|
||||
useEffect(() => {
|
||||
const items = filterGroups.flatMap(g => g.items);
|
||||
const item = items.find(i => i.id === initiallySelected) || items[0];
|
||||
if (item) {
|
||||
onChange?.(item);
|
||||
}
|
||||
// intentionally only happens on startup
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
{groups.map(group => (
|
||||
{filterGroups.map(group => (
|
||||
<React.Fragment key={group.name}>
|
||||
<Typography variant="subtitle2" className={classes.title}>
|
||||
{group.name}
|
||||
@@ -94,10 +121,8 @@ export const CatalogFilter: FC<{
|
||||
key={item.id}
|
||||
button
|
||||
divider
|
||||
onClick={() => {
|
||||
setSelectedFilter(item.id);
|
||||
}}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => setCurrent(item)}
|
||||
selected={item.id === currentFilter}
|
||||
className={classes.menuItem}
|
||||
>
|
||||
{item.icon && (
|
||||
@@ -111,7 +136,7 @@ export const CatalogFilter: FC<{
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
{entitiesByFilter[item.id]?.length ?? '-'}
|
||||
{getFilterCount(item.id) ?? '-'}
|
||||
</ListItemSecondaryAction>
|
||||
</MenuItem>
|
||||
))}
|
||||
@@ -122,3 +147,55 @@ export const CatalogFilter: FC<{
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function useFilter(): {
|
||||
currentFilter: string;
|
||||
setCurrentFilter: (filterId: string) => void;
|
||||
getFilterCount: (filterId: string) => number | undefined;
|
||||
} {
|
||||
const [currentFilter, setCurrentFilter] = useState('OWNED');
|
||||
const { isStarredEntity } = useStarredEntities();
|
||||
const userId = useApi(identityApiRef).getUserId();
|
||||
|
||||
const filterGroup = useMemo<FilterGroup>(() => {
|
||||
const result: FilterGroup = { filters: {} };
|
||||
const options: EntityFilterOptions = {
|
||||
userId,
|
||||
isStarred: isStarredEntity,
|
||||
};
|
||||
for (const [filterId, filterFn] of Object.entries(entityFilters)) {
|
||||
result.filters[filterId] = entity => filterFn(entity, options);
|
||||
}
|
||||
return result;
|
||||
}, [isStarredEntity, userId]);
|
||||
|
||||
const { setSelectedFilters, state } = useEntityFilterGroup(
|
||||
'primary-sidebar',
|
||||
filterGroup,
|
||||
['OWNED'],
|
||||
);
|
||||
|
||||
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,45 +14,43 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
errorApiRef,
|
||||
storageApiRef,
|
||||
WebStorage,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core';
|
||||
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { CatalogApi } from '../../api/types';
|
||||
import { EntityFilterGroupsProvider } from '../../filter';
|
||||
import { CatalogPage } from './CatalogPage';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
const mockErrorApi = new MockErrorApi();
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
spec: {
|
||||
owner: 'tools@example.com',
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Entity2',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
spec: {
|
||||
owner: 'not-tools@example.com',
|
||||
type: 'service',
|
||||
@@ -62,49 +60,32 @@ describe('CatalogPage', () => {
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
const mockIndentityApi: Partial<IdentityApi> = {
|
||||
const indentityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'tools@example.com',
|
||||
};
|
||||
|
||||
const renderWrapped = (children: React.ReactNode) =>
|
||||
render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, indentityApi],
|
||||
[storageApiRef, MockStorageApi.create()],
|
||||
])}
|
||||
>
|
||||
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
it('should render', async () => {
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, mockErrorApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
|
||||
[identityApiRef, mockIndentityApi],
|
||||
])}
|
||||
>
|
||||
<CatalogPage />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const items = await findByText(/All Services \(2\)/);
|
||||
expect(items).toBeInTheDocument();
|
||||
});
|
||||
it('should filter by owner', async () => {
|
||||
const { findByText, getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, mockErrorApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
|
||||
[identityApiRef, mockIndentityApi],
|
||||
])}
|
||||
>
|
||||
<CatalogPage />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
fireEvent.click(getByText(/Owned/));
|
||||
const items = await findByText(/Owned \(1\)/);
|
||||
expect(items).toBeInTheDocument();
|
||||
const { findByText, getByText } = renderWrapped(<CatalogPage />);
|
||||
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
|
||||
fireEvent.click(getByText(/All/));
|
||||
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,38 +15,31 @@
|
||||
*/
|
||||
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
DismissableBanner,
|
||||
HeaderTabs,
|
||||
SupportButton,
|
||||
} from '@backstage/core';
|
||||
import CatalogLayout from './CatalogLayout';
|
||||
import { Content, ContentHeader, SupportButton } from '@backstage/core';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import {
|
||||
Button,
|
||||
Link,
|
||||
makeStyles,
|
||||
Typography,
|
||||
withStyles,
|
||||
} from '@material-ui/core';
|
||||
import { Button, makeStyles, withStyles } from '@material-ui/core';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import Star from '@material-ui/icons/Star';
|
||||
import StarOutline from '@material-ui/icons/StarBorder';
|
||||
import React, { FC } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { CatalogFilter } from '../CatalogFilter/CatalogFilter';
|
||||
import { CatalogTable } from '../CatalogTable/CatalogTable';
|
||||
import { useEntities } from '../../hooks/useEntities';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import {
|
||||
getCatalogFilterItemByType,
|
||||
EntityGroup,
|
||||
filterGroups,
|
||||
labeledEntityTypes,
|
||||
LabeledEntityType,
|
||||
} from '../../data/filters';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
import {
|
||||
CatalogFilter,
|
||||
CatalogFilterItem,
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
import { CatalogTable } from '../CatalogTable/CatalogTable';
|
||||
import CatalogLayout from './CatalogLayout';
|
||||
import { CatalogTabs } from './CatalogTabs';
|
||||
import { WelcomeBanner } from './WelcomeBanner';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -55,27 +48,14 @@ const useStyles = makeStyles(theme => ({
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
emoji: {
|
||||
fontSize: '125%',
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
export const CatalogPage: FC<{}> = () => {
|
||||
const {
|
||||
entitiesByFilter,
|
||||
error,
|
||||
loading,
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
toggleStarredEntity,
|
||||
isStarredEntity,
|
||||
selectTypeFilter,
|
||||
} = useEntities();
|
||||
|
||||
const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL];
|
||||
|
||||
const CatalogPageContents = () => {
|
||||
const styles = useStyles();
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
const { loading, error, matchingEntities } = useFilteredEntities();
|
||||
const [selectedTab, setSelectedTab] = useState<string>();
|
||||
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
|
||||
|
||||
const YellowStar = withStyles({
|
||||
root: {
|
||||
@@ -105,9 +85,7 @@ export const CatalogPage: FC<{}> = () => {
|
||||
return location.target;
|
||||
}
|
||||
};
|
||||
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
|
||||
return {
|
||||
icon: Edit,
|
||||
tooltip: 'Edit',
|
||||
@@ -129,33 +107,19 @@ export const CatalogPage: FC<{}> = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const onTabChanged = useCallback((type: LabeledEntityType) => {
|
||||
setSelectedTab(type.label);
|
||||
}, []);
|
||||
const onSidebarChanged = useCallback((filterItem: CatalogFilterItem) => {
|
||||
setSelectedSidebarItem(filterItem.label);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CatalogLayout>
|
||||
<HeaderTabs
|
||||
tabs={labeledEntityTypes}
|
||||
onChange={(index: Number) => {
|
||||
selectTypeFilter(labeledEntityTypes[index as number].id);
|
||||
}}
|
||||
/>
|
||||
<CatalogTabs onChange={onTabChanged} />
|
||||
<Content>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
message={
|
||||
<Typography>
|
||||
<span role="img" aria-label="wave" className={styles.emoji}>
|
||||
👋🏼
|
||||
</span>
|
||||
Welcome to Backstage, we are happy to have you. Start by checking
|
||||
out our{' '}
|
||||
<Link href="/welcome" color="textSecondary">
|
||||
getting started
|
||||
</Link>{' '}
|
||||
page.
|
||||
</Typography>
|
||||
}
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
<ContentHeader title="Services">
|
||||
<WelcomeBanner />
|
||||
<ContentHeader title={selectedTab ?? ''}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
variant="contained"
|
||||
@@ -169,19 +133,15 @@ export const CatalogPage: FC<{}> = () => {
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<CatalogFilter
|
||||
groups={filterGroups}
|
||||
selectedFilter={selectedFilter ?? EntityGroup.ALL}
|
||||
onFilterChange={setSelectedFilter}
|
||||
entitiesByFilter={entitiesByFilter}
|
||||
filterGroups={filterGroups}
|
||||
onChange={onSidebarChanged}
|
||||
initiallySelected={EntityGroup.OWNED}
|
||||
/>
|
||||
</div>
|
||||
<CatalogTable
|
||||
titlePreamble={
|
||||
getCatalogFilterItemByType(selectedFilter ?? EntityGroup.ALL)
|
||||
?.label ?? ''
|
||||
}
|
||||
entities={filteredEntities || []}
|
||||
loading={loading && !error}
|
||||
titlePreamble={selectedSidebarItem ?? ''}
|
||||
entities={matchingEntities}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={actions}
|
||||
/>
|
||||
@@ -190,3 +150,9 @@ export const CatalogPage: FC<{}> = () => {
|
||||
</CatalogLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const CatalogPage = () => (
|
||||
<EntityFilterGroupsProvider>
|
||||
<CatalogPageContents />
|
||||
</EntityFilterGroupsProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { HeaderTabs } from '@backstage/core';
|
||||
import { labeledEntityTypes, LabeledEntityType } from '../../data/filters';
|
||||
import { useEntityFilterGroup, FilterGroup } from '../../filter';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
const filterGroup: FilterGroup = {
|
||||
filters: Object.fromEntries(
|
||||
labeledEntityTypes.map(t => [
|
||||
t.id,
|
||||
(entity: Entity) => entity.spec?.type === t.id,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onChange?: (type: LabeledEntityType) => void;
|
||||
};
|
||||
|
||||
export const CatalogTabs = ({ onChange }: Props) => {
|
||||
const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [
|
||||
labeledEntityTypes[0].id,
|
||||
]);
|
||||
|
||||
const onChangeFn = useCallback(
|
||||
(index: Number) => {
|
||||
const type = labeledEntityTypes[index as number];
|
||||
setSelectedFilters([type.id]);
|
||||
onChange?.(type);
|
||||
},
|
||||
[onChange, setSelectedFilters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onChange?.(labeledEntityTypes[0]);
|
||||
}, [onChange]);
|
||||
|
||||
return <HeaderTabs tabs={labeledEntityTypes} onChange={onChangeFn} />;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { DismissableBanner } from '@backstage/core';
|
||||
import { Link, makeStyles, Typography } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
display: 'grid',
|
||||
gridTemplateAreas: "'filters' 'table'",
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
emoji: {
|
||||
fontSize: '125%',
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
export const WelcomeBanner = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
message={
|
||||
<Typography>
|
||||
<span role="img" aria-label="wave" className={classes.emoji}>
|
||||
👋🏼
|
||||
</span>
|
||||
Welcome to Backstage, we are happy to have you. Start by checking out
|
||||
our{' '}
|
||||
<Link href="/welcome" color="textSecondary">
|
||||
getting started
|
||||
</Link>{' '}
|
||||
page.
|
||||
</Typography>
|
||||
}
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ jest.mock('react-router-dom', () => {
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render, wait } from '@testing-library/react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { CatalogApi, catalogApiRef } from '../../api/types';
|
||||
import { EntityPage, getPageTheme } from './EntityPage';
|
||||
@@ -66,7 +66,7 @@ describe('EntityPage', () => {
|
||||
),
|
||||
);
|
||||
|
||||
await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
|
||||
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export const filterGroups: CatalogFilterGroup[] = [
|
||||
items: [
|
||||
{
|
||||
id: EntityGroup.ALL,
|
||||
label: 'All Services',
|
||||
label: 'All',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -69,22 +69,15 @@ export const getCatalogFilterItemByType = (filterType: EntityGroup) => {
|
||||
|
||||
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
|
||||
|
||||
type EntityFilterOptions = Partial<{
|
||||
isStarred: boolean;
|
||||
export type EntityFilterOptions = {
|
||||
isStarred: (entity: Entity) => boolean;
|
||||
userId: string;
|
||||
}>;
|
||||
|
||||
type Owned = {
|
||||
owner: string;
|
||||
};
|
||||
|
||||
export const entityFilters: Record<string, EntityFilter> = {
|
||||
[EntityGroup.OWNED]: (e, { userId }) => {
|
||||
const owner = (e.spec! as Owned).owner;
|
||||
return owner === userId;
|
||||
},
|
||||
[EntityGroup.OWNED]: (e, { userId }) => e.spec?.owner === userId,
|
||||
[EntityGroup.ALL]: () => true,
|
||||
[EntityGroup.STARRED]: (_, { isStarred }) => !!isStarred,
|
||||
[EntityGroup.STARRED]: (e, { isStarred }) => isStarred(e),
|
||||
};
|
||||
|
||||
export const entityTypeFilter = (e: Entity, type: string) =>
|
||||
@@ -92,7 +85,7 @@ export const entityTypeFilter = (e: Entity, type: string) =>
|
||||
|
||||
type EntityType = 'service' | 'website' | 'library' | 'documentation' | 'other';
|
||||
|
||||
type LabeledEntityType = {
|
||||
export type LabeledEntityType = {
|
||||
id: EntityType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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 React, { useCallback, useRef, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '../api/types';
|
||||
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 } = useAsync(() => catalogApi.getEntities());
|
||||
|
||||
const filterGroups = useRef<{
|
||||
[filterGroupId: string]: FilterGroup;
|
||||
}>({});
|
||||
const selectedFilterKeys = useRef<{
|
||||
[filterGroupId: string]: Set<string>;
|
||||
}>({});
|
||||
const [filterGroupStates, setFilterGroupStates] = useState<{
|
||||
[filterGroupId: string]: FilterGroupStates;
|
||||
}>({});
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
|
||||
const rebuild = useCallback(() => {
|
||||
setFilterGroupStates(
|
||||
buildStates(
|
||||
filterGroups.current,
|
||||
selectedFilterKeys.current,
|
||||
entities,
|
||||
error,
|
||||
),
|
||||
);
|
||||
setMatchingEntities(
|
||||
buildMatchingEntities(
|
||||
filterGroups.current,
|
||||
selectedFilterKeys.current,
|
||||
entities,
|
||||
),
|
||||
);
|
||||
}, [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],
|
||||
);
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
setGroupSelectedFilters,
|
||||
filterGroupStates,
|
||||
matchingEntities,
|
||||
};
|
||||
}
|
||||
|
||||
// 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> },
|
||||
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,
|
||||
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 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> },
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
// 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))) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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;
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
matchingEntities: Entity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The context that maintains shared state for all visible filter groups.
|
||||
*/
|
||||
export const filterGroupsContext = createContext<
|
||||
FilterGroupsContext | undefined
|
||||
>(undefined);
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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';
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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;
|
||||
+23
-19
@@ -14,15 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import {
|
||||
EntityFilterGroupsProvider,
|
||||
useEntityFilterGroup,
|
||||
FilterGroupStatesReady,
|
||||
} from './useEntityFilterGroup';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { catalogApiRef } from '..';
|
||||
import { catalogApiRef } from '../api/types';
|
||||
import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
|
||||
import { FilterGroupStatesReady, FilterGroup } from './types';
|
||||
import { useEntityFilterGroup } from './useEntityFilterGroup';
|
||||
import { MockStorageApi } from '@backstage/test-utils';
|
||||
|
||||
describe('useEntityFilterGroup', () => {
|
||||
let catalogApi: jest.Mocked<typeof catalogApiRef.T>;
|
||||
@@ -38,8 +37,12 @@ describe('useEntityFilterGroup', () => {
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
};
|
||||
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
|
||||
storageApiRef,
|
||||
MockStorageApi.create(),
|
||||
);
|
||||
wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ApiProvider apis={ApiRegistry.from([[catalogApiRef, catalogApi]])}>
|
||||
<ApiProvider apis={apis}>
|
||||
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
@@ -47,8 +50,9 @@ describe('useEntityFilterGroup', () => {
|
||||
|
||||
it('works for an empty set of filters', async () => {
|
||||
catalogApi.getEntities.mockResolvedValue([]);
|
||||
const group: FilterGroup = { filters: {} };
|
||||
const { result, wait } = renderHook(
|
||||
() => useEntityFilterGroup('g1', { filters: {} }),
|
||||
() => useEntityFilterGroup('g1', group),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
@@ -63,14 +67,14 @@ describe('useEntityFilterGroup', () => {
|
||||
metadata: { name: 'n' },
|
||||
},
|
||||
]);
|
||||
const group: FilterGroup = {
|
||||
filters: {
|
||||
f1: e => e.metadata.name === 'n',
|
||||
f2: e => e.metadata.name !== 'n',
|
||||
},
|
||||
};
|
||||
const { result, wait } = renderHook(
|
||||
() =>
|
||||
useEntityFilterGroup('g1', {
|
||||
filters: {
|
||||
f1: e => e.metadata.name === 'n',
|
||||
f2: e => e.metadata.name !== 'n',
|
||||
},
|
||||
}),
|
||||
() => useEntityFilterGroup('g1', group),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
@@ -85,7 +89,7 @@ describe('useEntityFilterGroup', () => {
|
||||
matchCount: 0,
|
||||
});
|
||||
|
||||
act(() => result.current.selectItems(['f1']));
|
||||
act(() => result.current.setSelectedFilters(['f1']));
|
||||
|
||||
await wait(() => expect(result.current.state.type).toEqual('ready'));
|
||||
state = result.current.state as FilterGroupStatesReady;
|
||||
@@ -98,7 +102,7 @@ describe('useEntityFilterGroup', () => {
|
||||
matchCount: 0,
|
||||
});
|
||||
|
||||
act(() => result.current.selectItems(['f2']));
|
||||
act(() => result.current.setSelectedFilters(['f2']));
|
||||
|
||||
await wait(() => expect(result.current.state.type).toEqual('ready'));
|
||||
state = result.current.state as FilterGroupStatesReady;
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
// 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 };
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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: false,
|
||||
error: undefined,
|
||||
matchingEntities: context.matchingEntities,
|
||||
};
|
||||
}
|
||||
@@ -1,404 +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 {
|
||||
EntityGroup,
|
||||
entityFilters,
|
||||
entityTypeFilter,
|
||||
labeledEntityTypes,
|
||||
} from '../data/filters';
|
||||
import { useApi, identityApiRef } from '@backstage/core';
|
||||
import { catalogApiRef } from '..';
|
||||
import { useStarredEntities } from './useStarredEntites';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import useStaleWhileRevalidate from 'swr';
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
export type EntitiesByFilter = Record<EntityGroup, Entity[] | undefined>;
|
||||
|
||||
type UseEntities = {
|
||||
selectedFilter: EntityGroup | undefined;
|
||||
setSelectedFilter: (f: EntityGroup) => void;
|
||||
error: Error | null;
|
||||
toggleStarredEntity: any;
|
||||
isStarredEntity: (e: Entity) => boolean;
|
||||
entitiesByFilter: EntitiesByFilter;
|
||||
loading: boolean;
|
||||
selectedTypeFilter: string;
|
||||
selectTypeFilter: (id: string) => void;
|
||||
};
|
||||
|
||||
export type FilterGroup = {
|
||||
filters: {
|
||||
[key: string]: (entity: Entity) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type FilterGroupState = {
|
||||
filters: {
|
||||
[key: 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;
|
||||
|
||||
export type FilterGroupsContext = {
|
||||
register: (filterGroupId: string, filterGroup: FilterGroup) => void;
|
||||
unregister: (filterGroupId: string) => void;
|
||||
setSelectedFilters: (filterGroupId: string, filters: string[]) => void;
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
matchingEntities: Entity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The context that maintains shared state for all visible filter groups.
|
||||
*/
|
||||
export const filterGroupsContext = createContext<FilterGroupsContext>(
|
||||
{} as FilterGroupsContext,
|
||||
);
|
||||
|
||||
/**
|
||||
* Implementation of the shared filter groups state.
|
||||
*/
|
||||
export const EntityFilterGroupsProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const {
|
||||
data: entities,
|
||||
error,
|
||||
} = useStaleWhileRevalidate('catalog/getEntities', async () =>
|
||||
catalogApi.getEntities(),
|
||||
);
|
||||
|
||||
const [filterGroups, setFilterGroups] = useState<{
|
||||
[filterGroupId: string]: FilterGroup;
|
||||
}>({});
|
||||
const [filterGroupStates, setFilterGroupStates] = useState<{
|
||||
[filterGroupId: string]: FilterGroupStates;
|
||||
}>({});
|
||||
const [selectedFilterKeys, setSelectedFilterKeys] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
|
||||
const buildMatchingEntities = useCallback(
|
||||
(excludeFilterGroupId?: string): Entity[] => {
|
||||
// Build one filter fn per filter group
|
||||
const allFilters: ((entity: Entity) => boolean)[] = [];
|
||||
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: ((entity: Entity) => boolean)[] = [];
|
||||
for (const [filterId, filterFn] of Object.entries(
|
||||
filterGroup.filters,
|
||||
)) {
|
||||
if (selectedFilterKeys.has(`${filterGroupId}.${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)));
|
||||
}
|
||||
}
|
||||
|
||||
// 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))) ?? []
|
||||
);
|
||||
},
|
||||
[entities?.filter, filterGroups, selectedFilterKeys],
|
||||
);
|
||||
const buildStates = useCallback((): {
|
||||
[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 || !filterGroups.length) {
|
||||
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(filterGroupId);
|
||||
const groupState: FilterGroupState = { filters: {} };
|
||||
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
|
||||
const isSelected = selectedFilterKeys.has(
|
||||
`${filterGroupId}.${filterId}`,
|
||||
);
|
||||
const matchCount = otherMatchingEntities.filter(entity =>
|
||||
filterFn(entity),
|
||||
).length;
|
||||
groupState.filters[filterId] = { isSelected, matchCount };
|
||||
}
|
||||
result[filterGroupId] = { type: 'ready', state: groupState };
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [
|
||||
buildMatchingEntities,
|
||||
entities,
|
||||
error,
|
||||
filterGroups,
|
||||
selectedFilterKeys,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilterGroupStates(buildStates());
|
||||
setMatchingEntities(buildMatchingEntities());
|
||||
}, [
|
||||
entities,
|
||||
error,
|
||||
filterGroups,
|
||||
selectedFilterKeys,
|
||||
buildStates,
|
||||
buildMatchingEntities,
|
||||
]);
|
||||
|
||||
const register = useCallback(
|
||||
(filterGroupId: string, filterGroup: FilterGroup) => {
|
||||
setFilterGroups(oldGroups => ({
|
||||
...oldGroups,
|
||||
[filterGroupId]: filterGroup,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregister = useCallback((filterGroupId: string) => {
|
||||
setFilterGroups(oldGroups => {
|
||||
const copy = { ...oldGroups };
|
||||
delete copy[filterGroupId];
|
||||
return copy;
|
||||
});
|
||||
setFilterGroupStates(oldStates => {
|
||||
const copy = { ...oldStates };
|
||||
delete copy[filterGroupId];
|
||||
return copy;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setSelectedFilters = useCallback(
|
||||
(filterGroupId: string, filters: string[]) => {
|
||||
const result = new Set<string>();
|
||||
for (const key of selectedFilterKeys) {
|
||||
if (!key.startsWith(`${filterGroupId}.`)) {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
for (const key of filters) {
|
||||
result.add(`${filterGroupId}.${key}`);
|
||||
}
|
||||
setSelectedFilterKeys(result);
|
||||
},
|
||||
[selectedFilterKeys],
|
||||
);
|
||||
|
||||
const state: FilterGroupsContext = {
|
||||
register,
|
||||
unregister,
|
||||
setSelectedFilters,
|
||||
filterGroupStates,
|
||||
matchingEntities,
|
||||
};
|
||||
|
||||
return (
|
||||
<filterGroupsContext.Provider value={state}>
|
||||
{children}
|
||||
</filterGroupsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that exposes the relevant data and operations for a single filter
|
||||
* group.
|
||||
*/
|
||||
/*
|
||||
export const useEntityFilterGroup = (
|
||||
filterGroupId: string,
|
||||
filterGroup: FilterGroup,
|
||||
): EntityFilterGroupOutput<Entity> => {
|
||||
const groupsContext = useContext(filterGroupsContext);
|
||||
if (!groupsContext) {
|
||||
throw new Error('You must be inside an EntityFilterGroupsProvider');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
groupsContext.register(filterGroupId, filterGroup);
|
||||
return () => groupsContext.unregister(filterGroupId);
|
||||
}, []);
|
||||
|
||||
const state = groupsContext.getFilterGroup(filterGroupId);
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {} = state;
|
||||
|
||||
const [filterFuncs, setFilterFuncs] = useState<FilterDefinition<Entity>>(
|
||||
filterFunctions,
|
||||
);
|
||||
|
||||
// and
|
||||
// Object.entries(filterFuncs).filter(([_, {isSelected}]) => isSelected).map(([_, {filterFunction}]) => filterFunction).reduce((acc, func) => (acc.filter(func)), entities)
|
||||
|
||||
return {
|
||||
selectItems: (functionNames: Array<any>) => {
|
||||
const selectedFilterFunctions = Object.fromEntries(
|
||||
Object.entries(filterFunctions).map(([key, { filterFunction }]) => [
|
||||
key,
|
||||
{ isSelected: functionNames.includes(key), filterFunction },
|
||||
]),
|
||||
);
|
||||
setFilterFuncs(selectedFilterFunctions);
|
||||
},
|
||||
filteredItems: entities.filter(entity =>
|
||||
Object.entries(filterFuncs)
|
||||
.filter(([_, { isSelected }]) => isSelected)
|
||||
.map(([_, { filterFunction }]) => filterFunction)
|
||||
.map(filter => filter(entity))
|
||||
.some(v => v === true),
|
||||
),
|
||||
states: Object.keys(filterFuncs).reduce(
|
||||
(acc, val) => ({
|
||||
...acc,
|
||||
[val]: {
|
||||
...filterFuncs[val],
|
||||
count: entities.filter(filterFuncs[val].filterFunction).length,
|
||||
},
|
||||
}),
|
||||
{} as OutputState,
|
||||
),
|
||||
};
|
||||
};
|
||||
*/
|
||||
|
||||
export const useUser = () => {
|
||||
const indentityApi = useApi(identityApiRef);
|
||||
const userId = indentityApi.getUserId();
|
||||
return { userId };
|
||||
};
|
||||
|
||||
export const useEntities = (): UseEntities => {
|
||||
const [selectedFilter, setSelectedFilter] = useState<
|
||||
EntityGroup | undefined
|
||||
>();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
|
||||
const { data: entities, error } = useStaleWhileRevalidate(
|
||||
['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]],
|
||||
async () => catalogApi.getEntities(),
|
||||
);
|
||||
|
||||
const { userId } = useUser();
|
||||
|
||||
const [selectedTypeFilter, selectTypeFilter] = useState<string>(
|
||||
labeledEntityTypes[0].id,
|
||||
);
|
||||
|
||||
const entitiesByFilter = useMemo(() => {
|
||||
const filterEntities = (
|
||||
ents: Entity[] | undefined,
|
||||
filterId: EntityGroup,
|
||||
isStarred: (e: Entity) => boolean,
|
||||
user: string,
|
||||
) => {
|
||||
return ents
|
||||
?.filter((e: Entity) =>
|
||||
entityFilters[filterId](e, {
|
||||
isStarred: isStarred(e),
|
||||
userId: user,
|
||||
}),
|
||||
)
|
||||
.filter(e => entityTypeFilter(e, selectedTypeFilter));
|
||||
};
|
||||
const data = Object.keys(EntityGroup).reduce(
|
||||
(res, key) => ({
|
||||
...res,
|
||||
[key]: filterEntities(
|
||||
entities,
|
||||
key as EntityGroup,
|
||||
isStarredEntity,
|
||||
userId,
|
||||
),
|
||||
}),
|
||||
{} as EntitiesByFilter,
|
||||
);
|
||||
return data;
|
||||
}, [entities, isStarredEntity, userId, selectedTypeFilter]);
|
||||
|
||||
return {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
error,
|
||||
toggleStarredEntity,
|
||||
isStarredEntity,
|
||||
entitiesByFilter,
|
||||
loading: entities === undefined,
|
||||
selectedTypeFilter,
|
||||
selectTypeFilter,
|
||||
};
|
||||
};
|
||||
@@ -1,273 +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 { EntityGroup } from '../data/filters';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { catalogApiRef } from '..';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
export type EntitiesByFilter = Record<EntityGroup, Entity[] | undefined>;
|
||||
|
||||
export type FilterGroup = {
|
||||
filters: {
|
||||
[key: string]: (entity: Entity) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type FilterGroupState = {
|
||||
filters: {
|
||||
[key: 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;
|
||||
|
||||
export type FilterGroupsContext = {
|
||||
register: (filterGroupId: string, filterGroup: FilterGroup) => void;
|
||||
unregister: (filterGroupId: string) => void;
|
||||
setSelectedFilters: (filterGroupId: string, filters: string[]) => void;
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
matchingEntities: Entity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The context that maintains shared state for all visible filter groups.
|
||||
*/
|
||||
export const filterGroupsContext = createContext<FilterGroupsContext>(
|
||||
{} as FilterGroupsContext,
|
||||
);
|
||||
|
||||
/**
|
||||
* Implementation of the shared filter groups state.
|
||||
*/
|
||||
export const EntityFilterGroupsProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: entities, error } = useAsync(() => catalogApi.getEntities());
|
||||
|
||||
const [filterGroups, setFilterGroups] = useState<{
|
||||
[filterGroupId: string]: FilterGroup;
|
||||
}>({});
|
||||
const [filterGroupStates, setFilterGroupStates] = useState<{
|
||||
[filterGroupId: string]: FilterGroupStates;
|
||||
}>({});
|
||||
const [selectedFilterKeys, setSelectedFilterKeys] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
function buildStates(): { [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(filterGroupId);
|
||||
const groupState: FilterGroupState = { filters: {} };
|
||||
for (const [filterId, filterFn] of Object.entries(
|
||||
filterGroup.filters,
|
||||
)) {
|
||||
const isSelected = selectedFilterKeys.has(
|
||||
`${filterGroupId}.${filterId}`,
|
||||
);
|
||||
const matchCount = otherMatchingEntities.filter(entity =>
|
||||
filterFn(entity),
|
||||
).length;
|
||||
groupState.filters[filterId] = { isSelected, matchCount };
|
||||
}
|
||||
result[filterGroupId] = { type: 'ready', state: groupState };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildMatchingEntities(excludeFilterGroupId?: string): Entity[] {
|
||||
// Build one filter fn per filter group
|
||||
const allFilters: ((entity: Entity) => boolean)[] = [];
|
||||
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: ((entity: Entity) => boolean)[] = [];
|
||||
for (const [filterId, filterFn] of Object.entries(
|
||||
filterGroup.filters,
|
||||
)) {
|
||||
if (selectedFilterKeys.has(`${filterGroupId}.${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)));
|
||||
}
|
||||
}
|
||||
|
||||
// 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))) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
setFilterGroupStates(buildStates());
|
||||
setMatchingEntities(buildMatchingEntities());
|
||||
}, [entities, error, filterGroups, selectedFilterKeys]);
|
||||
|
||||
const register = useCallback(
|
||||
(filterGroupId: string, filterGroup: FilterGroup) => {
|
||||
setFilterGroups(oldGroups => ({
|
||||
...oldGroups,
|
||||
[filterGroupId]: filterGroup,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregister = useCallback((filterGroupId: string) => {
|
||||
setFilterGroups(oldGroups => {
|
||||
const copy = { ...oldGroups };
|
||||
delete copy[filterGroupId];
|
||||
return copy;
|
||||
});
|
||||
setFilterGroupStates(oldStates => {
|
||||
const copy = { ...oldStates };
|
||||
delete copy[filterGroupId];
|
||||
return copy;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setSelectedFilters = useCallback(
|
||||
(filterGroupId: string, filters: string[]) => {
|
||||
const result = new Set<string>();
|
||||
for (const key of selectedFilterKeys) {
|
||||
if (!key.startsWith(`${filterGroupId}.`)) {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
for (const key of filters) {
|
||||
result.add(`${filterGroupId}.${key}`);
|
||||
}
|
||||
setSelectedFilterKeys(result);
|
||||
},
|
||||
[setSelectedFilterKeys],
|
||||
);
|
||||
|
||||
const state: FilterGroupsContext = {
|
||||
register,
|
||||
unregister,
|
||||
setSelectedFilters,
|
||||
filterGroupStates,
|
||||
matchingEntities,
|
||||
};
|
||||
|
||||
return (
|
||||
<filterGroupsContext.Provider value={state}>
|
||||
{children}
|
||||
</filterGroupsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type EntityFilterGroupOutput = {
|
||||
state: FilterGroupStates;
|
||||
selectItems: (filters: string[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that exposes the relevant data and operations for a single filter
|
||||
* group.
|
||||
*/
|
||||
export const useEntityFilterGroup = (
|
||||
filterGroupId: string,
|
||||
filterGroup: FilterGroup,
|
||||
): EntityFilterGroupOutput => {
|
||||
const groupsContext = useContext(filterGroupsContext);
|
||||
if (!groupsContext) {
|
||||
throw new Error('You must be inside an EntityFilterGroupsProvider');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
groupsContext.register(filterGroupId, filterGroup);
|
||||
return () => groupsContext.unregister(filterGroupId);
|
||||
}, []);
|
||||
|
||||
const selectItems = useCallback(
|
||||
(filters: string[]) => {
|
||||
groupsContext.setSelectedFilters(filterGroupId, filters);
|
||||
},
|
||||
[groupsContext, filterGroupId],
|
||||
);
|
||||
|
||||
let state = groupsContext.filterGroupStates[filterGroupId];
|
||||
if (!state) {
|
||||
state = { type: 'loading' };
|
||||
}
|
||||
|
||||
return { state, selectItems };
|
||||
};
|
||||
Reference in New Issue
Block a user