Catalog Filters (#1000)
* feat(catalog-plugin): Starting to add the sidebar filters component for the different filters applicable * chore(catalog-frontend): fixing pr code review comments * feat(packages/core): export IconComponent for use in other packages * chore(catalog-frontend): Added some tests for the catalog filter component to make sure it renders the correct data * feat(catalog-frontend): added the ability for click handlers when changing selected filter * feat(catalog-frontend): store state in the page component for active filter and moving out the mock data to a better place * feat(catalog-frontend): reworking the selected state and fixing the selected text on the table when you choose the correct filter
This commit is contained in:
@@ -16,7 +16,6 @@
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { SvgIconProps } from '@material-ui/core';
|
||||
|
||||
export type IconComponent = ComponentType<SvgIconProps>;
|
||||
export type SystemIconKey = 'user' | 'group';
|
||||
export type SystemIcons = { [key in SystemIconKey]: IconComponent };
|
||||
|
||||
@@ -46,3 +46,4 @@ export { default as TrendLine } from './components/TrendLine';
|
||||
export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular';
|
||||
export * from './components/Status';
|
||||
export { default as WarningPanel } from './components/WarningPanel';
|
||||
export type { IconComponent } from './icons';
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInThemedTestApp } from '@backstage/test-utils';
|
||||
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
|
||||
|
||||
describe('Catalog Filter', () => {
|
||||
it('should render the different groups', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{ name: 'Test Group 1', items: [] },
|
||||
{ name: 'Test Group 2', items: [] },
|
||||
];
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
for (const group of mockGroups) {
|
||||
expect(await findByText(group.name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the different items and their names', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
expect(await findByText(item.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the count in each item', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
expect(await findByText(item.count!.toString())).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fire the callback when an item is clicked', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const onSelectedChangeHandler = jest.fn();
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogFilter
|
||||
groups={mockGroups}
|
||||
onSelectedChange={onSelectedChangeHandler}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const item = mockGroups[0].items[0];
|
||||
|
||||
const element = await findByText(item.label);
|
||||
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 {
|
||||
Card,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Typography,
|
||||
Theme,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import type { IconComponent } from '@backstage/core';
|
||||
|
||||
export type CatalogFilterItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: IconComponent;
|
||||
count?: number;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type CatalogFilterGroup = {
|
||||
name: string;
|
||||
items: CatalogFilterItem[];
|
||||
};
|
||||
|
||||
export type CatalogFilterProps = {
|
||||
groups: CatalogFilterGroup[];
|
||||
selectedId?: string;
|
||||
onSelectedChange?: (item: CatalogFilterItem) => void;
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
export const CatalogFilter: React.FC<CatalogFilterProps> = ({
|
||||
groups,
|
||||
selectedId,
|
||||
onSelectedChange,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
{groups.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={() => onSelectedChange?.(item)}
|
||||
selected={item.id === selectedId}
|
||||
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>
|
||||
{item.count}
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -17,8 +17,7 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import CatalogPage from './CatalogPage';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { wrapInThemedTestApp } from '@backstage/test-utils';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
|
||||
const testComponentFactory: ComponentFactory = {
|
||||
@@ -28,11 +27,14 @@ const testComponentFactory: ComponentFactory = {
|
||||
};
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
it('should render', async () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CatalogPage componentFactory={testComponentFactory} />
|
||||
</ThemeProvider>,
|
||||
wrapInThemedTestApp(
|
||||
<CatalogPage componentFactory={testComponentFactory} />,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText('Keep track of your software'),
|
||||
|
||||
@@ -27,13 +27,38 @@ import {
|
||||
import { useAsync } from 'react-use';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
import { Button } from '@material-ui/core';
|
||||
import {
|
||||
CatalogFilter,
|
||||
CatalogFilterItem,
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import { filterGroups, defaultFilter } from '../../data/filters';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
display: 'grid',
|
||||
gridTemplateAreas: "'filters' 'table'",
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
type CatalogPageProps = {
|
||||
componentFactory: ComponentFactory;
|
||||
};
|
||||
|
||||
const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
|
||||
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
|
||||
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
|
||||
defaultFilter,
|
||||
);
|
||||
|
||||
const onFilterSelected = React.useCallback(
|
||||
selected => setSelectedFilter(selected),
|
||||
[],
|
||||
);
|
||||
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title="Service Catalog" subtitle="Keep track of your software">
|
||||
@@ -46,11 +71,21 @@ const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
|
||||
</Button>
|
||||
<SupportButton>All your components</SupportButton>
|
||||
</ContentHeader>
|
||||
<CatalogTable
|
||||
components={value || []}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<CatalogFilter
|
||||
groups={filterGroups}
|
||||
selectedId={selectedFilter.id}
|
||||
onSelectedChange={onFilterSelected}
|
||||
/>
|
||||
</div>
|
||||
<CatalogTable
|
||||
titlePreamble={selectedFilter.label}
|
||||
components={value || []}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,9 @@ const components: Component[] = [
|
||||
describe('CatalogTable component', () => {
|
||||
it('should render loading when loading prop it set to true', async () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<CatalogTable components={[]} loading />),
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable titlePreamble="Owned" components={[]} loading />,
|
||||
),
|
||||
);
|
||||
const progress = await rendered.findByTestId('progress');
|
||||
expect(progress).toBeInTheDocument();
|
||||
@@ -38,6 +40,7 @@ describe('CatalogTable component', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable
|
||||
titlePreamble="Owned"
|
||||
components={[]}
|
||||
loading={false}
|
||||
error={{ code: 'error' }}
|
||||
@@ -53,9 +56,16 @@ describe('CatalogTable component', () => {
|
||||
it('should display component names when loading has finished and no error occurred', async () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable components={components} loading={false} />,
|
||||
<CatalogTable
|
||||
titlePreamble="Owned"
|
||||
components={components}
|
||||
loading={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText(`Owned (${components.length})`),
|
||||
).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component1')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component2')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component3')).toBeInTheDocument();
|
||||
|
||||
@@ -63,6 +63,7 @@ const columns: TableColumn[] = [
|
||||
|
||||
type CatalogTableProps = {
|
||||
components: Component[];
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
};
|
||||
@@ -70,6 +71,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
components,
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
@@ -87,7 +89,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
<Table
|
||||
columns={columns}
|
||||
options={{ paging: false }}
|
||||
title={`Owned (${(components && components.length) || 0})`}
|
||||
title={`${titlePreamble} (${(components && components.length) || 0})`}
|
||||
data={components}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
CatalogFilterGroup,
|
||||
CatalogFilterItem,
|
||||
} from '../components/CatalogFilter/CatalogFilter';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
|
||||
export const filterGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: 'owned',
|
||||
label: 'Owned',
|
||||
count: 123,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: 'starred',
|
||||
label: 'Starred',
|
||||
count: 10,
|
||||
icon: StarIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Spotify',
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All Services',
|
||||
count: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
|
||||
Reference in New Issue
Block a user