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:
Ben Lambert
2020-05-26 20:06:21 +02:00
committed by GitHub
parent 07b0ec9a90
commit c954f9d5ec
10 changed files with 382 additions and 15 deletions
-1
View File
@@ -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 };
+1
View File
@@ -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}
/>
);
+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 {
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];