Copy Catalog page Filter to Scaffolder page
This commit is contained in:
@@ -58,6 +58,7 @@
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^10.4.1",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@testing-library/react-hooks": "^3.3.0",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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>;
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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';
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { TemplateEntityV1alpha1, Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
configApiRef,
|
||||
Content,
|
||||
ContentHeader,
|
||||
errorApiRef,
|
||||
@@ -27,45 +28,112 @@ import {
|
||||
useApi,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { Button, Grid, Link, Typography } from '@material-ui/core';
|
||||
import React, { useEffect } from 'react';
|
||||
import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react';
|
||||
import { Button, Grid, Link, makeStyles, Typography } from '@material-ui/core';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import useStaleWhileRevalidate from 'swr';
|
||||
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
|
||||
import { TemplateCard, TemplateCardProps } from '../TemplateCard';
|
||||
import { ResultsFilter } from '../ResultsFilter/ResultsFilter';
|
||||
import { CatalogFilter } from '../CatalogFilter';
|
||||
import { ButtonGroup } from '../CatalogFilter/CatalogFilter';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
import { useOwnUser } from '../useOwnUser';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntities';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
display: 'grid',
|
||||
gridTemplateAreas: "'filters' 'grid'",
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const getTemplateCardProps = (
|
||||
template: TemplateEntityV1alpha1,
|
||||
template: Entity,
|
||||
): TemplateCardProps & { key: string } => {
|
||||
return {
|
||||
key: template.metadata.uid!,
|
||||
name: template.metadata.name,
|
||||
title: `${(template.metadata.title || template.metadata.name) ?? ''}`,
|
||||
type: template.spec.type ?? '',
|
||||
// TODO: Validate this prop (I changed TemplateEntityV1alpha1 to Entity interface) Remove 'as any'
|
||||
type: (template as any).spec.type ?? '',
|
||||
description: template.metadata.description ?? '-',
|
||||
tags: (template.metadata?.tags as string[]) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
export const ScaffolderPage = () => {
|
||||
export const ScaffolderPageContents = () => {
|
||||
const styles = useStyles();
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
reload,
|
||||
matchingEntities,
|
||||
availableTags, // TODO: Change tags to Categories
|
||||
isCatalogEmpty,
|
||||
} = useFilteredEntities();
|
||||
// TODO: use Selected Sidebar Item
|
||||
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const { data: templates, isValidating, error } = useStaleWhileRevalidate(
|
||||
'templates/all',
|
||||
async () => {
|
||||
const response = await catalogApi.getEntities({
|
||||
filter: { kind: 'Template' },
|
||||
});
|
||||
return response.items as TemplateEntityV1alpha1[];
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: templates /* isValidating*/ /* , error */,
|
||||
} = useStaleWhileRevalidate('templates/all', async () => {
|
||||
const response = await catalogApi.getEntities({
|
||||
filter: { kind: 'Template' },
|
||||
});
|
||||
return response.items as TemplateEntityV1alpha1[];
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
errorApi.post(error);
|
||||
}, [error, errorApi]);
|
||||
|
||||
const { value: user } = useOwnUser();
|
||||
|
||||
const configApi = useApi(configApiRef);
|
||||
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
|
||||
|
||||
const { isStarredEntity } = useStarredEntities();
|
||||
|
||||
// TODO: ButtonGroup from CatalogFilter?
|
||||
const filterGroups = useMemo<ButtonGroup[]>(
|
||||
() => [
|
||||
{
|
||||
name: 'Personal', // TODO: Do we need owner?
|
||||
items: [
|
||||
{
|
||||
id: 'owned',
|
||||
label: 'Owned',
|
||||
icon: SettingsIcon,
|
||||
filterFn: entity => user !== undefined && isOwnerOf(user, entity),
|
||||
},
|
||||
{
|
||||
id: 'starred',
|
||||
label: 'Starred',
|
||||
icon: StarIcon,
|
||||
filterFn: isStarredEntity,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: orgName,
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All',
|
||||
filterFn: () => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[isStarredEntity, orgName, user],
|
||||
);
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
@@ -93,33 +161,59 @@ export const ScaffolderPage = () => {
|
||||
documentation, ...).
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
{!templates && isValidating && <Progress />}
|
||||
{templates && !templates.length && (
|
||||
<Typography variant="body2">
|
||||
Shoot! Looks like you don't have any templates. Check out the
|
||||
documentation{' '}
|
||||
<Link href="https://backstage.io/docs/features/software-templates/adding-templates">
|
||||
here!
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
{error && (
|
||||
<WarningPanel>
|
||||
Oops! Something went wrong loading the templates: {error.message}
|
||||
</WarningPanel>
|
||||
)}
|
||||
<Grid container>
|
||||
{templates &&
|
||||
templates?.length > 0 &&
|
||||
templates.map(template => {
|
||||
return (
|
||||
<Grid key={template.metadata.uid} item xs={12} sm={6} md={3}>
|
||||
<TemplateCard {...getTemplateCardProps(template)} />
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<CatalogFilter
|
||||
buttonGroups={filterGroups} // TODO: filterGroups??
|
||||
onChange={({ label }) => setSelectedSidebarItem(label)} // TODO: setSelecteSidebarItem
|
||||
initiallySelected="owned"
|
||||
/>
|
||||
<ResultsFilter availableTags={availableTags} />
|
||||
</div>
|
||||
<div>
|
||||
{!matchingEntities && loading && <Progress />}
|
||||
{matchingEntities && !matchingEntities.length && (
|
||||
<Typography variant="body2">
|
||||
Shoot! Looks like you don't have any templates. Check out the
|
||||
documentation{' '}
|
||||
<Link href="https://backstage.io/docs/features/software-templates/adding-templates">
|
||||
here!
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
{error && (
|
||||
<WarningPanel>
|
||||
Oops! Something went wrong loading the templates:{' '}
|
||||
{error.message}
|
||||
</WarningPanel>
|
||||
)}
|
||||
<Grid container>
|
||||
{matchingEntities &&
|
||||
matchingEntities?.length > 0 &&
|
||||
matchingEntities.map(template => {
|
||||
return (
|
||||
<Grid
|
||||
key={template.metadata.uid}
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
md={3}
|
||||
>
|
||||
<TemplateCard {...getTemplateCardProps(template)} />
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScaffolderPage = () => (
|
||||
<EntityFilterGroupsProvider>
|
||||
<ScaffolderPageContents />
|
||||
</EntityFilterGroupsProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { UserEntity } from '@backstage/catalog-model';
|
||||
import { identityApiRef, useApi } from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
|
||||
/**
|
||||
* Get the catalog User entity (if any) that matches the logged-in user.
|
||||
*/
|
||||
export function useOwnUser(): AsyncState<UserEntity | undefined> {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const identityApi = useApi(identityApiRef);
|
||||
|
||||
// TODO: get the full entity (or at least the full entity name) from the
|
||||
// identityApi
|
||||
return useAsync(
|
||||
() =>
|
||||
catalogApi.getEntityByName({
|
||||
kind: 'User',
|
||||
namespace: 'default',
|
||||
name: identityApi.getUserId(),
|
||||
}) as Promise<UserEntity | undefined>,
|
||||
[catalogApi, identityApi],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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: 'Template' },
|
||||
});
|
||||
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))) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,37 @@
|
||||
/*
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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, { PropsWithChildren } from 'react';
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useStarredEntities } from './useStarredEntities';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
storageApiRef,
|
||||
WebStorage,
|
||||
StorageApi,
|
||||
} from '@backstage/core';
|
||||
import { MockErrorApi } from '@backstage/test-utils';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
describe('useStarredEntities', () => {
|
||||
let mockStorage: StorageApi | undefined;
|
||||
|
||||
const mockEntity: Entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'mock',
|
||||
},
|
||||
};
|
||||
|
||||
const secondMockEntity: Entity = {
|
||||
apiVersion: '1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
namespace: 'test',
|
||||
name: 'mock2',
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
return (
|
||||
<ApiProvider apis={ApiRegistry.with(storageApiRef, mockStorage)}>
|
||||
{children}
|
||||
</ApiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockStorage = new WebStorage('@backstage', new MockErrorApi()).forBucket(
|
||||
Date.now().toString(), // TODO(blam): need something that changes every test run for now until the MockStorage is implemented
|
||||
);
|
||||
});
|
||||
it('should return an empty set for when there is no items in storage', async () => {
|
||||
const { result } = renderHook(() => useStarredEntities(), { wrapper });
|
||||
|
||||
expect(result.current.starredEntities.size).toBe(0);
|
||||
});
|
||||
it('should return a set with the current items when there is items in storage', async () => {
|
||||
const expectedIds = ['i', 'am', 'some', 'test', 'ids'];
|
||||
const store = mockStorage?.forBucket('settings');
|
||||
await store?.set('starredEntities', expectedIds);
|
||||
|
||||
const { result } = renderHook(() => useStarredEntities(), { wrapper });
|
||||
|
||||
for (const item of expectedIds) {
|
||||
expect(result.current.starredEntities.has(item)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
it('should listen to changes when the storage is set elsewhere', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => useStarredEntities(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.starredEntities.size).toBe(0);
|
||||
expect(result.current.isStarredEntity(mockEntity)).toBeFalsy();
|
||||
|
||||
// Make this happen after awaiting for the next update so we can
|
||||
// catch when the hook re-renders with the latest data
|
||||
setTimeout(() => result.current.toggleStarredEntity(mockEntity), 1);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.starredEntities.size).toBe(1);
|
||||
expect(result.current.isStarredEntity(mockEntity)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should write new entries to the local store when adding a togglging entity', async () => {
|
||||
const { result } = renderHook(() => useStarredEntities(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.toggleStarredEntity(mockEntity);
|
||||
});
|
||||
|
||||
expect(result.current.isStarredEntity(mockEntity)).toBeTruthy();
|
||||
expect(result.current.isStarredEntity(secondMockEntity)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should remove an existing entity when toggling entries', async () => {
|
||||
const { result } = renderHook(() => useStarredEntities(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.toggleStarredEntity(mockEntity);
|
||||
result.current.toggleStarredEntity(secondMockEntity);
|
||||
result.current.toggleStarredEntity(mockEntity);
|
||||
});
|
||||
|
||||
expect(result.current.isStarredEntity(mockEntity)).toBeFalsy();
|
||||
expect(result.current.isStarredEntity(secondMockEntity)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 { storageApiRef, useApi } from '@backstage/core';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
const buildEntityKey = (component: Entity) =>
|
||||
`entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${
|
||||
component.metadata.name
|
||||
}`;
|
||||
|
||||
export const useStarredEntities = () => {
|
||||
const storageApi = useApi(storageApiRef);
|
||||
const settingsStore = storageApi.forBucket('settings');
|
||||
const rawStarredEntityKeys =
|
||||
settingsStore.get<string[]>('starredEntities') ?? [];
|
||||
|
||||
const [starredEntities, setStarredEntities] = useState(
|
||||
new Set(rawStarredEntityKeys),
|
||||
);
|
||||
|
||||
const observedItems = useObservable(
|
||||
settingsStore.observe$<string[]>('starredEntities'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (observedItems?.newValue) {
|
||||
const currentValue = observedItems?.newValue ?? [];
|
||||
setStarredEntities(new Set(currentValue));
|
||||
}
|
||||
}, [observedItems?.newValue]);
|
||||
|
||||
const toggleStarredEntity = useCallback(
|
||||
(entity: Entity) => {
|
||||
const entityKey = buildEntityKey(entity);
|
||||
if (starredEntities.has(entityKey)) {
|
||||
starredEntities.delete(entityKey);
|
||||
} else {
|
||||
starredEntities.add(entityKey);
|
||||
}
|
||||
|
||||
settingsStore.set('starredEntities', Array.from(starredEntities));
|
||||
},
|
||||
[starredEntities, settingsStore],
|
||||
);
|
||||
|
||||
const isStarredEntity = useCallback(
|
||||
(entity: Entity) => {
|
||||
const entityKey = buildEntityKey(entity);
|
||||
return starredEntities.has(entityKey);
|
||||
},
|
||||
[starredEntities],
|
||||
);
|
||||
|
||||
return {
|
||||
starredEntities,
|
||||
toggleStarredEntity,
|
||||
isStarredEntity,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user