Merge branch 'master' into mob/scaffolder-frontend

This commit is contained in:
Johan Haals
2021-02-19 10:01:10 +01:00
20 changed files with 1811 additions and 47 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Adding Search and Filter features to Scaffolder/Templates Grid
+1
View File
@@ -64,6 +64,7 @@
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/humanize-duration": "^3.18.1",
"@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,107 @@
/*
* 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 categories', async () => {
const categories = ['test', 'java'];
const { findByText } = renderWrapped(
<ResultsFilter availableCategories={categories} />,
);
for (const category of categories) {
expect(
await findByText(category.charAt(0).toUpperCase() + category.slice(1)),
).toBeInTheDocument();
}
});
});
@@ -0,0 +1,126 @@
/*
* 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 = {
availableCategories: string[];
};
/**
* The additional results filter in the sidebar.
*/
export const ResultsFilter = ({ availableCategories }: Props) => {
const classes = useStyles();
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const setSelectedCatgoriesFilter = context?.setSelectedCategories;
const updateSelectedCategories = useCallback(
(categories: string[]) => {
setSelectedCategories(categories);
setSelectedCatgoriesFilter(categories);
},
[setSelectedCategories, setSelectedCatgoriesFilter],
);
return (
<>
<div className={classes.filterBox}>
<Typography variant="subtitle2" className={classes.filterBoxTitle}>
Refine Results
</Typography>{' '}
<Button onClick={() => updateSelectedCategories([])}>Clear</Button>
</div>
<Divider />
<Typography variant="subtitle2" className={classes.title}>
Categories
</Typography>
<List disablePadding dense>
{availableCategories.map(category => {
const labelId = `checkbox-list-label-${category}`;
return (
<ListItem
key={category}
dense
button
onClick={() =>
updateSelectedCategories(
selectedCategories.includes(category)
? selectedCategories.filter(
selectedCategory => selectedCategory !== category,
)
: [...selectedCategories, category],
)
}
>
<Checkbox
edge="start"
color="primary"
checked={selectedCategories.includes(category)}
tabIndex={-1}
disableRipple
className={classes.checkbox}
inputProps={{ 'aria-labelledby': labelId }}
/>
<ListItemText
id={labelId}
primary={category.charAt(0).toUpperCase() + category.slice(1)}
/>
</ListItem>
);
})}
</List>
</>
);
};
@@ -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,271 @@
/*
* 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 from 'react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { EntityFilterGroupsProvider } from '../../filter';
import { ButtonGroup, ScaffolderFilter } from './ScaffolderFilter';
describe('Catalog Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[],
}),
};
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
describe('filter groups', () => {
it('should render the different groups', async () => {
const mockGroups: ButtonGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = renderWrapped(
<ScaffolderFilter buttonGroups={mockGroups} initiallySelected="" />,
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
}
});
});
describe('filter items', () => {
it('should render the different items and their names', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const { findByText } = renderWrapped(
<ScaffolderFilter buttonGroups={mockGroups} initiallySelected="all" />,
);
for (const item of mockGroups[0].items) {
expect(await findByText(item.label)).toBeInTheDocument();
}
});
it('selects the first item if no desired initial one is set', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
renderWrapped(
<ScaffolderFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
});
it('selects the initial item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
renderWrapped(
<ScaffolderFilter
buttonGroups={mockGroups}
onChange={onChange}
initiallySelected="starred"
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('can change the selected item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const onChange = jest.fn();
const { findByText } = renderWrapped(
<ScaffolderFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
fireEvent.click(await findByText('Second Label'));
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('displays match counts properly', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'owned',
label: 'First Label',
filterFn: entity => entity.spec?.owner === 'tools@example.com',
},
],
},
];
const { findByText } = renderWrapped(
<ScaffolderFilter
buttonGroups={mockGroups}
initiallySelected="owned"
/>,
);
expect(await findByText('1')).toBeInTheDocument();
});
});
});
@@ -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 ScaffolderFilter = ({
buttonGroups,
onChange,
initiallySelected,
}: Props) => {
const classes = useStyles();
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
buttonGroups,
initiallySelected,
);
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const setCurrent = useCallback(
(item: { id: string; label: string }) => {
setCurrentFilter(item.id);
onChangeRef.current?.({ id: item.id, label: item.label });
},
[setCurrentFilter],
);
// Make one initial onChange to inform the surroundings about the selected
// item
useEffect(() => {
const items = buttonGroups.flatMap(g => g.items);
const item = items.find(i => i.id === initiallySelected) || items[0];
if (item) {
onChangeRef.current?.({ id: item.id, label: item.label });
}
// intentionally only happens on startup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Card className={classes.root}>
{buttonGroups.map(group => (
<React.Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense>
{group.items.map(item => (
<MenuItem
key={item.id}
button
divider
onClick={() => setCurrent(item)}
selected={item.id === currentFilter}
className={classes.menuItem}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography variant="body1" className={classes.menuTitle}>
{item.label}
</Typography>
</ListItemText>
<ListItemSecondaryAction>
{getFilterCount(item.id) ?? '-'}
</ListItemSecondaryAction>
</MenuItem>
))}
</List>
</Card>
</React.Fragment>
))}
</Card>
);
};
function useFilter(
buttonGroups: ButtonGroup[],
initiallySelected: string,
): {
currentFilter: string;
setCurrentFilter: (filterId: string) => void;
getFilterCount: (filterId: string) => number | undefined;
} {
const [currentFilter, setCurrentFilter] = useState(initiallySelected);
const filterGroup = useMemo<FilterGroup>(
() => ({
filters: Object.fromEntries(
buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]),
),
}),
[buttonGroups],
);
const { setSelectedFilters, state } = useEntityFilterGroup(
'primary-sidebar',
filterGroup,
[initiallySelected],
);
const setCurrent = useCallback(
(filterId: string) => {
setCurrentFilter(filterId);
setSelectedFilters([filterId]);
},
[setCurrentFilter, setSelectedFilters],
);
const getFilterCount = useCallback(
(filterId: string) => {
if (state.type !== 'ready') {
return undefined;
}
return state.state.filters[filterId].matchCount;
},
[state],
);
return {
currentFilter,
setCurrentFilter: setCurrent,
getFilterCount,
};
}
@@ -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 { ScaffolderFilter } from './ScaffolderFilter';
@@ -14,11 +14,12 @@
* limitations under the License.
*/
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import React, { useEffect, useMemo, useState } from 'react';
import { EntityMeta, TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
configApiRef,
Content,
ContentHeader,
errorApiRef,
Header,
Lifecycle,
Page,
@@ -27,12 +28,25 @@ 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 { Button, Grid, Link, makeStyles, Typography } from '@material-ui/core';
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 { ScaffolderFilter } from '../ScaffolderFilter';
import { ButtonGroup } from '../ScaffolderFilter/ScaffolderFilter';
import StarIcon from '@material-ui/icons/Star';
import { useStarredEntities } from '../../hooks/useStarredEntities';
import SearchToolbar from '../SearchToolbar/SearchToolbar';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'grid'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
}));
const getTemplateCardProps = (
template: TemplateEntityV1alpha1,
@@ -47,24 +61,62 @@ const getTemplateCardProps = (
};
};
export const ScaffolderPage = () => {
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[];
},
export const ScaffolderPageContents = () => {
const styles = useStyles();
const {
loading,
error,
filteredEntities,
availableCategories,
} = useFilteredEntities();
const configApi = useApi(configApiRef);
const orgName = configApi.getOptionalString('organization.name') ?? 'Company';
const { isStarredEntity } = useStarredEntities();
const filterGroups = useMemo<ButtonGroup[]>(
() => [
{
name: orgName,
items: [
{
id: 'all',
label: 'All',
filterFn: () => true,
},
],
},
{
name: 'Personal',
items: [
{
id: 'starred',
label: 'Starred',
icon: StarIcon,
filterFn: isStarredEntity,
},
],
},
],
[isStarredEntity, orgName],
);
const [search, setSearch] = useState('');
const [matchingEntities, setMatchingEntities] = useState(
[] as TemplateEntityV1alpha1[],
);
const matchesQuery = (metadata: EntityMeta, query: string) =>
`${metadata.title}`.toUpperCase().indexOf(query) !== -1 ||
metadata.tags?.join('').toUpperCase().indexOf(query) !== -1;
useEffect(() => {
if (!error) return;
errorApi.post(error);
}, [error, errorApi]);
if (search.length === 0) {
return setMatchingEntities(filteredEntities);
}
return setMatchingEntities(
filteredEntities.filter(template =>
matchesQuery(template.metadata, search.toUpperCase()),
),
);
}, [search, filteredEntities]);
return (
<Page themeId="home">
@@ -93,33 +145,58 @@ 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>
<SearchToolbar search={search} setSearch={setSearch} />
<ScaffolderFilter
buttonGroups={filterGroups}
initiallySelected="all"
/>
<ResultsFilter availableCategories={availableCategories} />
</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 title="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,33 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import SearchToolbar from './SearchToolbar';
describe('SearchToolbar', () => {
it('should display search value and execute set callback', async () => {
const setSearchSpy = jest.fn();
const { getByDisplayValue } = render(
<SearchToolbar search="hello" setSearch={setSearchSpy} />,
);
const searchInput = getByDisplayValue('hello');
expect(searchInput).toBeInTheDocument();
fireEvent.change(searchInput, { target: { value: 'world' } });
expect(setSearchSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,74 @@
/*
* 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 from 'react';
import {
FormControl,
InputAdornment,
makeStyles,
Toolbar,
Input,
IconButton,
} from '@material-ui/core';
import Search from '@material-ui/icons/Search';
import Clear from '@material-ui/icons/Clear';
interface Props {
search: string;
setSearch: Function;
}
const useStyles = makeStyles(_theme => ({
searchToolbar: {
paddingLeft: 0,
paddingRight: 0,
},
}));
const SearchToolbar = ({ search, setSearch }: Props) => {
const styles = useStyles();
return (
<Toolbar className={styles.searchToolbar}>
<FormControl>
<Input
id="input-with-icon-adornment"
placeholder="Search"
autoComplete="off"
onChange={event => setSearch(event.target.value)}
value={search}
startAdornment={
<InputAdornment position="start">
<Search />
</InputAdornment>
}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="clear search"
onClick={() => setSearch('')}
edge="end"
disabled={search.length === 0}
>
<Clear />
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Toolbar>
);
};
export default SearchToolbar;
@@ -0,0 +1,262 @@
/*
* 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 { TemplateEntityV1alpha1 } 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 as TemplateEntityV1alpha1[];
});
const filterGroups = useRef<{
[filterGroupId: string]: FilterGroup;
}>({});
const selectedFilterKeys = useRef<{
[filterGroupId: string]: Set<string>;
}>({});
const selectedCategories = useRef<string[]>([]);
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [filteredEntities, setFilteredEntities] = useState<
TemplateEntityV1alpha1[]
>([]);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [isCatalogEmpty, setCatalogEmpty] = useState<boolean>(false);
useEffect(() => {
doReload();
}, [doReload]);
const rebuild = useCallback(() => {
setFilterGroupStates(
buildStates(
filterGroups.current,
selectedFilterKeys.current,
selectedCategories.current,
entities,
error,
),
);
setFilteredEntities(
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
selectedCategories.current,
entities,
),
);
setAvailableCategories(collectCategories(entities));
setCatalogEmpty(entities !== undefined && entities.length === 0);
}, [entities, error]);
const register = useCallback(
(
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => {
filterGroups.current[filterGroupId] = filterGroup;
selectedFilterKeys.current[filterGroupId] = new Set(
initialSelectedFilterIds ?? [],
);
rebuild();
},
[rebuild],
);
const unregister = useCallback(
(filterGroupId: string) => {
delete filterGroups.current[filterGroupId];
delete selectedFilterKeys.current[filterGroupId];
rebuild();
},
[rebuild],
);
const setGroupSelectedFilters = useCallback(
(filterGroupId: string, filters: string[]) => {
selectedFilterKeys.current[filterGroupId] = new Set(filters);
rebuild();
},
[rebuild],
);
const setSelectedCategories = useCallback(
(categories: string[]) => {
selectedCategories.current = categories;
rebuild();
},
[rebuild],
);
const reload = useCallback(async () => {
await doReload();
}, [doReload]);
return {
register,
unregister,
setGroupSelectedFilters,
setSelectedCategories,
reload,
loading: !error && !entities,
error,
filterGroupStates,
filteredEntities,
availableCategories,
isCatalogEmpty,
};
}
// Given all filter groups and what filters are actually selected, along with
// the loading state for entities, generate the state of each individual filter
function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedCategories: string[],
entities?: TemplateEntityV1alpha1[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
// On error - all entries are an error state
if (error) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'error', error },
]),
);
}
// On startup - all entries are a loading state
if (!entities) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'loading' },
]),
);
}
const result: { [filterGroupId: string]: FilterGroupStates } = {};
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
const otherMatchingEntities = buildMatchingEntities(
filterGroups,
selectedFilterKeys,
selectedCategories,
entities,
filterGroupId,
);
const groupState: FilterGroupState = { filters: {} };
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId);
const matchCount = otherMatchingEntities.filter(entity =>
filterFn(entity),
).length;
groupState.filters[filterId] = { isSelected, matchCount };
}
result[filterGroupId] = { type: 'ready', state: groupState };
}
return result;
}
// Given all entites, find all possible categories and provide them in a sorted list.
function collectCategories(entities?: TemplateEntityV1alpha1[]): string[] {
const categories = new Set<string>();
(entities || []).forEach(e => {
if (e.spec?.type) {
categories.add(e.spec.type as string);
}
});
return Array.from(categories).sort();
}
// Given all filter groups and what filters are actually selected, extract all
// entities that match all those filter groups.
function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedCategories: string[],
entities?: TemplateEntityV1alpha1[],
excludeFilterGroupId?: string,
): TemplateEntityV1alpha1[] {
// Build one filter fn per filter group
const allFilters: EntityFilterFn[] = [];
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
if (excludeFilterGroupId === filterGroupId) {
continue;
}
// Pick out all of the filter functions in the group that are actually
// selected
const groupFilters: EntityFilterFn[] = [];
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) {
groupFilters.push(filterFn);
}
}
// Need to match any of the selected filters in the group - if there is
// any at all
if (groupFilters.length) {
allFilters.push(entity => groupFilters.some(fn => fn(entity)));
}
}
// Filter by categories, if at least one category is selected.
if (selectedCategories.length > 0) {
allFilters.push(entity =>
selectedCategories.some(c => entity.spec?.type === c),
);
}
// All filter groups that had any checked filters need to match. Note that
// every() always returns true for an empty array.
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
}
+44
View File
@@ -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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { createContext } from 'react';
import { FilterGroup, FilterGroupStates } from './types';
export type FilterGroupsContext = {
register: (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => void;
unregister: (filterGroupId: string) => void;
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
setSelectedCategories: (categories: string[]) => void;
reload: () => Promise<void>;
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
filteredEntities: TemplateEntityV1alpha1[];
availableCategories: string[];
isCatalogEmpty: boolean;
};
/**
* The context that maintains shared state for all visible filter groups.
*/
export const filterGroupsContext = createContext<
FilterGroupsContext | undefined
>(undefined);
+28
View File
@@ -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';
+53
View File
@@ -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,
filteredEntities: context.filteredEntities,
availableCategories: context.availableCategories,
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 are 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 toggling 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,
};
};