Merge pull request #1476 from spotify/freben/improve-filters
More catalog page cleanup
This commit is contained in:
@@ -14,18 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, forwardRef } from 'react';
|
||||
import MTable, {
|
||||
MTableCell,
|
||||
MTableHeader,
|
||||
MTableToolbar,
|
||||
MaterialTableProps,
|
||||
Options,
|
||||
Column,
|
||||
} from 'material-table';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { makeStyles, useTheme, Typography } from '@material-ui/core';
|
||||
|
||||
import { makeStyles, Typography, useTheme } from '@material-ui/core';
|
||||
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
|
||||
import AddBox from '@material-ui/icons/AddBox';
|
||||
import ArrowUpward from '@material-ui/icons/ArrowUpward';
|
||||
@@ -42,6 +32,15 @@ import Remove from '@material-ui/icons/Remove';
|
||||
import SaveAlt from '@material-ui/icons/SaveAlt';
|
||||
import Search from '@material-ui/icons/Search';
|
||||
import ViewColumn from '@material-ui/icons/ViewColumn';
|
||||
import MTable, {
|
||||
Column,
|
||||
MaterialTableProps,
|
||||
MTableCell,
|
||||
MTableHeader,
|
||||
MTableToolbar,
|
||||
Options,
|
||||
} from 'material-table';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
const tableIcons = {
|
||||
Add: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
@@ -131,10 +130,10 @@ const useToolbarStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const convertColumns = (
|
||||
columns: TableColumn[],
|
||||
function convertColumns<T extends object>(
|
||||
columns: TableColumn<T>[],
|
||||
theme: BackstageTheme,
|
||||
): TableColumn[] => {
|
||||
): TableColumn<T>[] {
|
||||
return columns.map(column => {
|
||||
const headerStyle: React.CSSProperties = {};
|
||||
const cellStyle: React.CSSProperties = {};
|
||||
@@ -150,25 +149,26 @@ const convertColumns = (
|
||||
cellStyle,
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface TableColumn extends Column<{}> {
|
||||
export interface TableColumn<T extends object = {}> extends Column<T> {
|
||||
highlight?: boolean;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface TableProps extends MaterialTableProps<{}> {
|
||||
columns: TableColumn[];
|
||||
export interface TableProps<T extends object = {}>
|
||||
extends MaterialTableProps<T> {
|
||||
columns: TableColumn<T>[];
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const Table: FC<TableProps> = ({
|
||||
export function Table<T extends object = {}>({
|
||||
columns,
|
||||
options,
|
||||
title,
|
||||
subtitle,
|
||||
...props
|
||||
}) => {
|
||||
}: TableProps<T>) {
|
||||
const cellClasses = useCellStyles();
|
||||
const headerClasses = useHeaderStyles();
|
||||
const toolbarClasses = useToolbarStyles();
|
||||
@@ -183,7 +183,7 @@ export const Table: FC<TableProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<MTable
|
||||
<MTable<T>
|
||||
components={{
|
||||
Cell: cellProps => (
|
||||
<MTableCell className={cellClasses.root} {...cellProps} />
|
||||
@@ -211,4 +211,4 @@ export const Table: FC<TableProps> = ({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
*/
|
||||
|
||||
export { Table } from './Table';
|
||||
export type { TableColumn } from './Table';
|
||||
export type { TableColumn, TableProps } from './Table';
|
||||
export { SubvalueCell } from './SubvalueCell';
|
||||
|
||||
@@ -42,14 +42,15 @@ export type Tab = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const HeaderTabs: React.FC<{
|
||||
tabs: Tab[];
|
||||
onChange?: (index: Number) => void;
|
||||
onChange?: (index: number) => void;
|
||||
}> = ({ tabs, onChange }) => {
|
||||
const [selectedTab, setSelectedTab] = useState<Number>(0);
|
||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||
const styles = useStyles();
|
||||
|
||||
const handleChange = (_: React.ChangeEvent<{}>, index: Number) => {
|
||||
const handleChange = (_: React.ChangeEvent<{}>, index: number) => {
|
||||
setSelectedTab(index);
|
||||
if (onChange) onChange(index);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -26,25 +27,23 @@ import {
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import React, { FC, useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
EntityFilterOptions,
|
||||
entityFilters,
|
||||
EntityGroup,
|
||||
} from '../../data/filters';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
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 => ({
|
||||
@@ -73,35 +72,48 @@ const useStyles = makeStyles<Theme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type OnChangeCallback = (item: { id: string; label: string }) => void;
|
||||
|
||||
type Props = {
|
||||
filterGroups: CatalogFilterGroup[];
|
||||
onChange?: (filterItem: CatalogFilterItem) => void;
|
||||
initiallySelected?: EntityGroup;
|
||||
buttonGroups: ButtonGroup[];
|
||||
initiallySelected: string;
|
||||
onChange?: OnChangeCallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
const setCurrent = useCallback(
|
||||
(item: CatalogFilterItem) => {
|
||||
(item: { id: string; label: string }) => {
|
||||
setCurrentFilter(item.id);
|
||||
onChange?.(item);
|
||||
onChangeRef.current?.({ id: item.id, label: item.label });
|
||||
},
|
||||
[onChange, setCurrentFilter],
|
||||
[setCurrentFilter],
|
||||
);
|
||||
|
||||
// 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) {
|
||||
onChange?.(item);
|
||||
onChangeRef.current?.({ id: item.id, label: item.label });
|
||||
}
|
||||
// intentionally only happens on startup
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -109,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}
|
||||
@@ -148,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,26 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import {
|
||||
Header,
|
||||
HomepageTimer,
|
||||
identityApiRef,
|
||||
Page,
|
||||
pageTheme,
|
||||
identityApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { getTimeBasedGreeting } from './utils/timeUtil';
|
||||
|
||||
const CatalogLayout: FC<{}> = props => {
|
||||
const { children } = props;
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CatalogLayout = ({ children }: Props) => {
|
||||
const greeting = getTimeBasedGreeting();
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const userId = useApi(identityApiRef).getUserId();
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
title={`${greeting.greeting}, ${identityApi.getUserId()}!`}
|
||||
title={`${greeting.greeting}, ${userId}!`}
|
||||
subtitle="Backstage Service Catalog"
|
||||
tooltip={greeting.language}
|
||||
pageTitleOverride="Home"
|
||||
|
||||
@@ -14,31 +14,25 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { Content, ContentHeader, SupportButton } from '@backstage/core';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import { Button, makeStyles, withStyles } from '@material-ui/core';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import Star from '@material-ui/icons/Star';
|
||||
import StarOutline from '@material-ui/icons/StarBorder';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import {
|
||||
EntityGroup,
|
||||
filterGroups,
|
||||
LabeledEntityType,
|
||||
} from '../../data/filters';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
Content,
|
||||
ContentHeader,
|
||||
identityApiRef,
|
||||
SupportButton,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
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 { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
import {
|
||||
CatalogFilter,
|
||||
CatalogFilterItem,
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter';
|
||||
import { CatalogTable } from '../CatalogTable/CatalogTable';
|
||||
import CatalogLayout from './CatalogLayout';
|
||||
import { CatalogTabs } from './CatalogTabs';
|
||||
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
|
||||
import { WelcomeBanner } from './WelcomeBanner';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -52,71 +46,77 @@ const useStyles = makeStyles(theme => ({
|
||||
|
||||
const CatalogPageContents = () => {
|
||||
const styles = useStyles();
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
const { loading, error, matchingEntities } = useFilteredEntities();
|
||||
const { isStarredEntity } = useStarredEntities();
|
||||
const userId = useApi(identityApiRef).getUserId();
|
||||
const [selectedTab, setSelectedTab] = useState<string>();
|
||||
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
|
||||
|
||||
const YellowStar = withStyles({
|
||||
root: {
|
||||
color: '#f3ba37',
|
||||
},
|
||||
})(Star);
|
||||
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 actions = [
|
||||
(rowData: Entity) => {
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
return {
|
||||
icon: GitHub,
|
||||
tooltip: 'View on GitHub',
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(location.target, '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const createEditLink = (location: LocationSpec): string => {
|
||||
switch (location.type) {
|
||||
case 'github':
|
||||
return location.target.replace('/blob/', '/edit/');
|
||||
default:
|
||||
return location.target;
|
||||
}
|
||||
};
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
return {
|
||||
icon: Edit,
|
||||
tooltip: 'Edit',
|
||||
iconProps: { size: 'small' },
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(createEditLink(location), '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const isStarred = isStarredEntity(rowData);
|
||||
return {
|
||||
icon: isStarred ? YellowStar : StarOutline,
|
||||
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
|
||||
onClick: () => toggleStarredEntity(rowData),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
const onTabChanged = useCallback((type: LabeledEntityType) => {
|
||||
setSelectedTab(type.label);
|
||||
}, []);
|
||||
const onSidebarChanged = useCallback((filterItem: CatalogFilterItem) => {
|
||||
setSelectedSidebarItem(filterItem.label);
|
||||
}, []);
|
||||
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 onChange={onTabChanged} />
|
||||
<CatalogTabs
|
||||
tabs={tabs}
|
||||
onChange={({ label }) => setSelectedTab(label)}
|
||||
/>
|
||||
<Content>
|
||||
<WelcomeBanner />
|
||||
<ContentHeader title={selectedTab ?? ''}>
|
||||
@@ -133,9 +133,9 @@ const CatalogPageContents = () => {
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<CatalogFilter
|
||||
filterGroups={filterGroups}
|
||||
onChange={onSidebarChanged}
|
||||
initiallySelected={EntityGroup.OWNED}
|
||||
buttonGroups={filterGroups}
|
||||
onChange={({ label }) => setSelectedSidebarItem(label)}
|
||||
initiallySelected="owned"
|
||||
/>
|
||||
</div>
|
||||
<CatalogTable
|
||||
@@ -143,7 +143,6 @@ const CatalogPageContents = () => {
|
||||
entities={matchingEntities}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
@@ -14,42 +14,72 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { HeaderTabs } from '@backstage/core';
|
||||
import { labeledEntityTypes, LabeledEntityType } from '../../data/filters';
|
||||
import { useEntityFilterGroup, FilterGroup } from '../../filter';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { HeaderTabs } from '@backstage/core';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FilterGroup, useEntityFilterGroup } from '../../filter';
|
||||
|
||||
const filterGroup: FilterGroup = {
|
||||
filters: Object.fromEntries(
|
||||
labeledEntityTypes.map(t => [
|
||||
t.id,
|
||||
(entity: Entity) => entity.spec?.type === t.id,
|
||||
]),
|
||||
),
|
||||
/**
|
||||
* A component type, and a human readable label for it.
|
||||
*/
|
||||
export type LabeledComponentType = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Called on mount, and when the selected tab changes.
|
||||
*/
|
||||
export type OnChangeCallback = (tab: LabeledComponentType) => void;
|
||||
|
||||
type Props = {
|
||||
onChange?: (type: LabeledEntityType) => void;
|
||||
tabs: LabeledComponentType[];
|
||||
onChange?: OnChangeCallback;
|
||||
};
|
||||
|
||||
export const CatalogTabs = ({ onChange }: Props) => {
|
||||
/**
|
||||
* The tabs at the top of the catalog list page, for component type filtering.
|
||||
*/
|
||||
export const CatalogTabs = ({ tabs, onChange }: Props) => {
|
||||
const filterGroup = useMemo<FilterGroup>(() => {
|
||||
return {
|
||||
filters: Object.fromEntries(
|
||||
tabs.map(t => [t.id, (entity: Entity) => entity.spec?.type === t.id]),
|
||||
),
|
||||
};
|
||||
}, [tabs]);
|
||||
|
||||
const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [
|
||||
labeledEntityTypes[0].id,
|
||||
tabs[0].id,
|
||||
]);
|
||||
|
||||
const onChangeFn = useCallback(
|
||||
(index: Number) => {
|
||||
const type = labeledEntityTypes[index as number];
|
||||
setSelectedFilters([type.id]);
|
||||
onChange?.(type);
|
||||
},
|
||||
[onChange, setSelectedFilters],
|
||||
);
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
|
||||
|
||||
// Hold a reference to the callback
|
||||
const onChangeRef = useRef<OnChangeCallback>();
|
||||
useEffect(() => {
|
||||
onChange?.(labeledEntityTypes[0]);
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
return <HeaderTabs tabs={labeledEntityTypes} onChange={onChangeFn} />;
|
||||
useEffect(() => {
|
||||
onChangeRef.current?.(tabs[currentTabIndex]);
|
||||
}, [tabs, currentTabIndex]);
|
||||
|
||||
const switchTab = useCallback(
|
||||
(index: number) => {
|
||||
const tab = tabs[index];
|
||||
setSelectedFilters([tab.id]);
|
||||
setCurrentTabIndex(index);
|
||||
onChangeRef.current?.(tab);
|
||||
},
|
||||
[tabs, setSelectedFilters],
|
||||
);
|
||||
|
||||
return <HeaderTabs tabs={tabs} onChange={switchTab} />;
|
||||
};
|
||||
|
||||
@@ -66,11 +66,9 @@ describe('CatalogTable component', () => {
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText(`Owned (${entites.length})`),
|
||||
).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component1')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component2')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component3')).toBeInTheDocument();
|
||||
expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/component1/)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/component2/)).toBeInTheDocument();
|
||||
expect(rendered.getByText(/component3/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,16 +13,21 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn, TableProps } from '@backstage/core';
|
||||
import { Link } from '@material-ui/core';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import Star from '@material-ui/icons/Star';
|
||||
import StarOutline from '@material-ui/icons/StarBorder';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
import { entityRoute } from '../../routes';
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
const columns: TableColumn<Entity>[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'metadata.name',
|
||||
@@ -63,16 +68,16 @@ type CatalogTableProps = {
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
actions?: any;
|
||||
};
|
||||
|
||||
export const CatalogTable: FC<CatalogTableProps> = ({
|
||||
export const CatalogTable = ({
|
||||
entities,
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
actions,
|
||||
}) => {
|
||||
}: CatalogTableProps) => {
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
@@ -83,8 +88,57 @@ export const CatalogTable: FC<CatalogTableProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const actions: TableProps<Entity>['actions'] = [
|
||||
(rowData: Entity) => {
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
return {
|
||||
icon: () => <GitHub fontSize="small" />,
|
||||
tooltip: 'View on GitHub',
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(location.target, '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const createEditLink = (location: LocationSpec): string => {
|
||||
switch (location.type) {
|
||||
case 'github':
|
||||
return location.target.replace('/blob/', '/edit/');
|
||||
default:
|
||||
return location.target;
|
||||
}
|
||||
};
|
||||
const location = findLocationForEntityMeta(rowData.metadata);
|
||||
return {
|
||||
icon: () => <Edit fontSize="small" />,
|
||||
tooltip: 'Edit',
|
||||
onClick: () => {
|
||||
if (!location) return;
|
||||
window.open(createEditLink(location), '_blank');
|
||||
},
|
||||
hidden: location?.type !== 'github',
|
||||
};
|
||||
},
|
||||
(rowData: Entity) => {
|
||||
const isStarred = isStarredEntity(rowData);
|
||||
return {
|
||||
cellStyle: { paddingLeft: '1em' },
|
||||
icon: () =>
|
||||
isStarred ? (
|
||||
<Star htmlColor="#f3ba37" fontSize="small" />
|
||||
) : (
|
||||
<StarOutline fontSize="small" />
|
||||
),
|
||||
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
|
||||
onClick: () => toggleStarredEntity(rowData),
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
<Table<Entity>
|
||||
isLoading={loading}
|
||||
columns={columns}
|
||||
options={{
|
||||
|
||||
@@ -1,116 +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,
|
||||
CatalogFilterItem,
|
||||
} 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const getCatalogFilterItemByType = (filterType: EntityGroup) => {
|
||||
for (const group of filterGroups) {
|
||||
for (const filter of group.items) {
|
||||
if (filter.id === filterType) {
|
||||
return filter;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
export const entityTypeFilter = (e: Entity, type: string) =>
|
||||
(e.spec as any)?.type === type;
|
||||
|
||||
type EntityType = 'service' | 'website' | 'library' | 'documentation' | 'other';
|
||||
|
||||
export type LabeledEntityType = {
|
||||
id: EntityType;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const labeledEntityTypes: LabeledEntityType[] = [
|
||||
{
|
||||
id: 'service',
|
||||
label: 'Services',
|
||||
},
|
||||
{
|
||||
id: 'website',
|
||||
label: 'Websites',
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
label: 'Libraries',
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
label: 'Documentation',
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
|
||||
@@ -113,6 +113,8 @@ function useProvideEntityFilters(): FilterGroupsContext {
|
||||
register,
|
||||
unregister,
|
||||
setGroupSelectedFilters,
|
||||
loading: !error && !entities,
|
||||
error,
|
||||
filterGroupStates,
|
||||
matchingEntities,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ export type FilterGroupsContext = {
|
||||
) => void;
|
||||
unregister: (filterGroupId: string) => void;
|
||||
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
matchingEntities: Entity[];
|
||||
};
|
||||
|
||||
@@ -27,8 +27,8 @@ export function useFilteredEntities() {
|
||||
}
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
error: undefined,
|
||||
loading: context.loading,
|
||||
error: context.error,
|
||||
matchingEntities: context.matchingEntities,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user