Supply the sidebar catalog filter from the page again

This commit is contained in:
Fredrik Adelöw
2020-06-27 22:33:59 +02:00
parent 0d4a76fbf0
commit 52e784f651
4 changed files with 180 additions and 192 deletions
@@ -26,9 +26,8 @@ import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityGroup } from '../../data/filters';
import { EntityFilterGroupsProvider } from '../../filter';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { ButtonGroup, CatalogFilter } from './CatalogFilter';
describe('Catalog Filter', () => {
const catalogApi: Partial<CatalogApi> = {
@@ -79,12 +78,12 @@ describe('Catalog Filter', () => {
);
it('should render the different groups', async () => {
const mockGroups: CatalogFilterGroup[] = [
const mockGroups: ButtonGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = renderWrapped(
<CatalogFilter filterGroups={mockGroups} />,
<CatalogFilter buttonGroups={mockGroups} initiallySelected="" />,
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
@@ -92,24 +91,26 @@ describe('Catalog Filter', () => {
});
it('should render the different items and their names', async () => {
const mockGroups: CatalogFilterGroup[] = [
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter filterGroups={mockGroups} />,
<CatalogFilter buttonGroups={mockGroups} initiallySelected="all" />,
);
for (const item of mockGroups[0].items) {
@@ -118,48 +119,19 @@ describe('Catalog Filter', () => {
});
it('selects the first item if no desired initial one is set', async () => {
const mockGroups: CatalogFilterGroup[] = [
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
label: 'Second Label',
},
],
},
];
const onChange = jest.fn();
renderWrapped(
<CatalogFilter filterGroups={mockGroups} onChange={onChange} />,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: EntityGroup.ALL,
label: 'First Label',
});
});
});
it('selects the initial item', async () => {
const mockGroups: CatalogFilterGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
label: 'First Label',
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
@@ -169,32 +141,71 @@ describe('Catalog Filter', () => {
renderWrapped(
<CatalogFilter
filterGroups={mockGroups}
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
initiallySelected={EntityGroup.STARRED}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: EntityGroup.STARRED,
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: CatalogFilterGroup[] = [
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
@@ -203,12 +214,16 @@ describe('Catalog Filter', () => {
const onChange = jest.fn();
const { findByText } = renderWrapped(
<CatalogFilter filterGroups={mockGroups} onChange={onChange} />,
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
});
});
@@ -217,27 +232,28 @@ describe('Catalog Filter', () => {
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
});
});
});
it('displays match counts properly', async () => {
const mockGroups: CatalogFilterGroup[] = [
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.OWNED,
id: 'owned',
label: 'First Label',
filterFn: entity => entity.spec?.owner === 'tools@example.com',
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter filterGroups={mockGroups} />,
<CatalogFilter buttonGroups={mockGroups} initiallySelected="owned" />,
);
expect(await findByText('1')).toBeInTheDocument();
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { IconComponent, identityApiRef, useApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { IconComponent } from '@backstage/core';
import {
Card,
List,
@@ -27,31 +28,22 @@ import {
Typography,
} from '@material-ui/core';
import React, {
FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
EntityFilterOptions,
entityFilters,
EntityGroup,
} from '../../data/filters';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
import { useStarredEntities } from '../../hooks/useStarredEntites';
export type CatalogFilterItem = {
id: EntityGroup;
label: string;
icon?: IconComponent;
count?: number | FC;
};
export type CatalogFilterGroup = {
export type ButtonGroup = {
name: string;
items: CatalogFilterItem[];
items: {
id: string;
label: string;
icon?: IconComponent;
filterFn: (entity: Entity) => boolean;
}[];
};
const useStyles = makeStyles<Theme>(theme => ({
@@ -83,21 +75,24 @@ const useStyles = makeStyles<Theme>(theme => ({
type OnChangeCallback = (item: { id: string; label: string }) => void;
type Props = {
filterGroups: CatalogFilterGroup[];
buttonGroups: ButtonGroup[];
initiallySelected: string;
onChange?: OnChangeCallback;
initiallySelected?: EntityGroup;
};
/**
* The main filter group in the sidebar, toggling owned/starred/all.
*/
export const CatalogFilter = ({
filterGroups,
buttonGroups,
onChange,
initiallySelected,
}: Props) => {
const classes = useStyles();
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter();
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
buttonGroups,
initiallySelected,
);
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
@@ -105,9 +100,9 @@ export const CatalogFilter = ({
}, [onChange]);
const setCurrent = useCallback(
(item: CatalogFilterItem) => {
(item: { id: string; label: string }) => {
setCurrentFilter(item.id);
onChangeRef.current?.(item);
onChangeRef.current?.({ id: item.id, label: item.label });
},
[setCurrentFilter],
);
@@ -115,10 +110,10 @@ export const CatalogFilter = ({
// Make one initial onChange to inform the surroundings about the selected
// item
useEffect(() => {
const items = filterGroups.flatMap(g => g.items);
const items = buttonGroups.flatMap(g => g.items);
const item = items.find(i => i.id === initiallySelected) || items[0];
if (item) {
onChangeRef.current?.(item);
onChangeRef.current?.({ id: item.id, label: item.label });
}
// intentionally only happens on startup
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -126,7 +121,7 @@ export const CatalogFilter = ({
return (
<Card className={classes.root}>
{filterGroups.map(group => (
{buttonGroups.map(group => (
<React.Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
@@ -165,31 +160,29 @@ export const CatalogFilter = ({
);
};
function useFilter(): {
function useFilter(
buttonGroups: ButtonGroup[],
initiallySelected: string,
): {
currentFilter: string;
setCurrentFilter: (filterId: string) => void;
getFilterCount: (filterId: string) => number | undefined;
} {
const [currentFilter, setCurrentFilter] = useState('OWNED');
const { isStarredEntity } = useStarredEntities();
const userId = useApi(identityApiRef).getUserId();
const [currentFilter, setCurrentFilter] = useState(initiallySelected);
const filterGroup = useMemo<FilterGroup>(() => {
const result: FilterGroup = { filters: {} };
const options: EntityFilterOptions = {
userId,
isStarred: isStarredEntity,
};
for (const [filterId, filterFn] of Object.entries(entityFilters)) {
result.filters[filterId] = entity => filterFn(entity, options);
}
return result;
}, [isStarredEntity, userId]);
const 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,
['OWNED'],
[initiallySelected],
);
const setCurrent = useCallback(
@@ -14,14 +14,22 @@
* limitations under the License.
*/
import { Content, ContentHeader, SupportButton } from '@backstage/core';
import {
Content,
ContentHeader,
identityApiRef,
SupportButton,
useApi,
} from '@backstage/core';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, makeStyles } from '@material-ui/core';
import React, { useState } from 'react';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import React, { useMemo, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { EntityGroup, filterGroups } from '../../data/filters';
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
import { CatalogFilter } from '../CatalogFilter/CatalogFilter';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter';
import { CatalogTable } from '../CatalogTable/CatalogTable';
import CatalogLayout from './CatalogLayout';
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
@@ -36,35 +44,73 @@ const useStyles = makeStyles(theme => ({
},
}));
const tabs: LabeledComponentType[] = [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'library',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
const CatalogPageContents = () => {
const styles = useStyles();
const { loading, error, matchingEntities } = useFilteredEntities();
const { isStarredEntity } = useStarredEntities();
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
const tabs = useMemo<LabeledComponentType[]>(
() => [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'library',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
],
[],
);
const filterGroups = useMemo<ButtonGroup[]>(
() => [
{
name: 'Personal',
items: [
{
id: 'owned',
label: 'Owned',
icon: SettingsIcon,
filterFn: entity => entity.spec?.owner === userId,
},
{
id: 'starred',
label: 'Starred',
icon: StarIcon,
filterFn: isStarredEntity,
},
],
},
{
name: 'Company', // TODO: Replace with Company name, read from app config.
items: [
{
id: 'all',
label: 'All',
filterFn: () => true,
},
],
},
],
[isStarredEntity, userId],
);
return (
<CatalogLayout>
<CatalogTabs
@@ -87,9 +133,9 @@ const CatalogPageContents = () => {
<div className={styles.contentWrapper}>
<div>
<CatalogFilter
filterGroups={filterGroups}
buttonGroups={filterGroups}
onChange={({ label }) => setSelectedSidebarItem(label)}
initiallySelected={EntityGroup.OWNED}
initiallySelected="owned"
/>
</div>
<CatalogTable
-67
View File
@@ -1,67 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { CatalogFilterGroup } from '../components/CatalogFilter/CatalogFilter';
export enum EntityGroup {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: EntityGroup.OWNED,
label: 'Owned',
icon: SettingsIcon,
},
{
id: EntityGroup.STARRED,
label: 'Starred',
icon: StarIcon,
},
],
},
{
// TODO: Replace with Company name, read from app config.
name: 'Company',
items: [
{
id: EntityGroup.ALL,
label: 'All',
},
],
},
];
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
export type EntityFilterOptions = {
isStarred: (entity: Entity) => boolean;
userId: string;
};
export const entityFilters: Record<string, EntityFilter> = {
[EntityGroup.OWNED]: (e, { userId }) => e.spec?.owner === userId,
[EntityGroup.ALL]: () => true,
[EntityGroup.STARRED]: (e, { isStarred }) => isStarred(e),
};