Merge pull request #78 from spotify/rugvip/entity-page

front/core: restructure entity page components + styling
This commit is contained in:
Patrik Oldsberg
2020-02-07 10:55:38 +01:00
committed by GitHub
26 changed files with 371 additions and 170 deletions
+3 -28
View File
@@ -1,18 +1,10 @@
import {
BackstageTheme,
createApp,
Header,
InfoCard,
Page,
theme,
} from '@backstage/core';
import { BackstageTheme, createApp, InfoCard } from '@backstage/core';
import HomePagePlugin from '@backstage/plugin-home-page';
//import PageHeader from './components/PageHeader';
import { LoginComponent } from '@backstage/plugin-login';
import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import HomePageTimer from './components/HomepageTimer';
import SideBar from './components/SideBar';
import entities from './entities';
import { LoginBarrier } from './login/LoginBarrier';
@@ -37,20 +29,10 @@ const useStyles = makeStyles(theme => ({
display: 'grid',
// FIXME: Don't used a fixed width here
gridTemplateColumns: '64px auto',
gridTemplateRows: 'auto 1fr',
gridTemplateRows: '1fr',
width: '100%',
height: '100vh',
},
mainContentArea: {
overflowX: 'hidden',
overflowY: 'auto',
},
pageBody: {
padding: theme.spacing(2),
},
avatarButton: {
padding: theme.spacing(2),
},
}));
const currentUser = new MockCurrentUser();
@@ -69,14 +51,7 @@ const AppShell: FC<{}> = ({ children }) => {
return (
<div className={classes.root}>
<SideBar />
<Page theme={theme.home}>
<div className={classes.mainContentArea}>
<Header title="This is Backstage!">
<HomePageTimer />
</Header>
<div className={classes.pageBody}>{children}</div>
</div>
</Page>
{children}
</div>
);
};
@@ -158,7 +158,7 @@ const useStyles = makeStyles(theme => ({
display: 'flex',
flexFlow: 'column nowrap',
alignItems: 'flex-start',
position: 'absolute',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
+4 -2
View File
@@ -4,6 +4,8 @@ import {
createEntityPage,
} from '@backstage/core';
import ComputerIcon from '@material-ui/icons/Computer';
import WebIcon from '@material-ui/icons/Web';
import DnsIcon from '@material-ui/icons/Dns';
import MockEntityPage from './MockEntityPage';
import MockEntityCard from './MockEntityCard';
import GithubActionsPlugin from '@backstage/plugin-github-actions';
@@ -14,9 +16,9 @@ const serviceOverviewPage = createWidgetView()
.addComponent(MockEntityCard);
const serviceView = createEntityPage()
.addPage('Overview', '/overview', serviceOverviewPage)
.addPage('Overview', WebIcon, '/overview', serviceOverviewPage)
.register(GithubActionsPlugin)
.addComponent('Deployment', '/deployment', MockEntityPage);
.addComponent('Deployment', DnsIcon, '/deployment', MockEntityPage);
const serviceEntity = createEntityKind({
kind: 'service',
+2
View File
@@ -4,6 +4,8 @@
"main": "src/index.ts",
"main:src": "src/index.ts",
"devDependencies": {
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@spotify/web-scripts": "^6.0.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
@@ -1,10 +1,11 @@
import { ComponentType } from 'react';
import { AppComponentBuilder } from '../app/types';
import { IconComponent } from '../types';
export type EntityConfig = {
kind: string;
title: string;
icon: React.ComponentType<{ fontSize: number }>;
icon: IconComponent;
color: {
primary: string;
secondary: string;
@@ -1,83 +1,21 @@
import React, { ComponentType, FC } from 'react';
import { Route, Redirect, Switch } from 'react-router-dom';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import { AppComponentBuilder, App } from '../app/types';
import { useEntity, useEntityUri, useEntityConfig } from './EntityContext';
import EntityLink from '../../components/EntityLink/EntityLink';
import React, { ComponentType } from 'react';
import DefaultEntityPage from '../../components/DefaultEntityPage';
import { App, AppComponentBuilder } from '../app/types';
import BackstagePlugin from '../plugin/Plugin';
import { EntityPageNavItem, EntityPageView } from './types';
import { IconComponent } from '../types';
const EntityLayout: FC<{}> = ({ children }) => {
const config = useEntityConfig();
return (
<div style={{ backgroundColor: config.color.primary }}>{children}</div>
);
};
const EntitySidebar: FC<{}> = ({ children }) => {
return <List>{children}</List>;
};
const EntitySidebarItem: FC<{ title: string; path: string }> = ({
title,
path,
}) => {
const entityUri = useEntityUri();
return (
<ListItem>
<EntityLink uri={entityUri} subPath={path}>
{title}
</EntityLink>
</ListItem>
);
};
type EntityPageNavItem = {
title: string;
target: string;
};
type EntityPageView = {
path: string;
component: ComponentType<any>;
};
type Props = {
navItems: EntityPageNavItem[];
views: EntityPageView[];
};
const EntityPageComponent: FC<Props> = ({ navItems, views }) => {
const { kind, id } = useEntity();
const basePath = `/entity/${kind}/${id}`;
return (
<EntityLayout>
<EntitySidebar>
{navItems.map(({ title, target }) => (
<EntitySidebarItem key={target} title={title} path={target} />
))}
</EntitySidebar>
<Switch>
{views.map(({ path, component }) => (
<Route
key={path}
exact
path={`${basePath}${path}`}
component={component}
/>
))}
<Redirect from={basePath} to={`${basePath}${views[0].path}`} />
</Switch>
</EntityLayout>
);
};
// type AppComponents = {
// EntityPage: ComponentType<EntityPageProps>;
// EntityPageNavbar: ComponentType<EntityPageNavbarProps>;
// EntityPageHeader: ComponentType<EntityPageHeaderProps>;
// };
type EntityPageRegistration =
| {
type: 'page';
title: string;
icon: IconComponent;
path: string;
page: AppComponentBuilder;
}
@@ -88,6 +26,7 @@ type EntityPageRegistration =
| {
type: 'component';
title: string;
icon: IconComponent;
path: string;
component: ComponentType<any>;
};
@@ -97,19 +36,27 @@ export default class EntityPageBuilder extends AppComponentBuilder {
addPage(
title: string,
icon: IconComponent,
path: string,
page: AppComponentBuilder,
): EntityPageBuilder {
this.registrations.push({ type: 'page', title, path, page });
this.registrations.push({ type: 'page', title, icon, path, page });
return this;
}
addComponent(
title: string,
icon: IconComponent,
path: string,
component: ComponentType<any>,
): EntityPageBuilder {
this.registrations.push({ type: 'component', title, path, component });
this.registrations.push({
type: 'component',
title,
icon,
path,
component,
});
return this;
}
@@ -125,14 +72,14 @@ export default class EntityPageBuilder extends AppComponentBuilder {
for (const reg of this.registrations) {
switch (reg.type) {
case 'page': {
const { title, path, page } = reg;
navItems.push({ title, target: path });
const { title, icon, path, page } = reg;
navItems.push({ title, icon, target: path });
views.push({ path, component: page.build(app) });
break;
}
case 'component': {
const { title, path, component } = reg;
navItems.push({ title, target: path });
const { title, icon, path, component } = reg;
navItems.push({ title, icon, target: path });
views.push({ path, component });
break;
}
@@ -141,8 +88,8 @@ export default class EntityPageBuilder extends AppComponentBuilder {
for (const output of reg.plugin.output()) {
switch (output.type) {
case 'entity-page-nav-item':
const { title, target } = output;
navItems.push({ title, target });
const { title, icon, target } = output;
navItems.push({ title, icon, target });
added = true;
break;
case 'entity-page-view-route':
@@ -162,6 +109,6 @@ export default class EntityPageBuilder extends AppComponentBuilder {
}
}
return () => <EntityPageComponent navItems={navItems} views={views} />;
return () => <DefaultEntityPage navItems={navItems} views={views} />;
}
}
@@ -0,0 +1,24 @@
import { ComponentType } from 'react';
import { IconComponent } from '../types';
export type EntityPageNavItem = {
icon: IconComponent;
title: string;
target: string;
};
export type EntityPageView = {
path: string;
component: ComponentType<any>;
};
export type EntityPageProps = {
navItems: EntityPageNavItem[];
views: EntityPageView[];
};
export type EntityPageNavbarProps = {
navItems: EntityPageNavItem[];
};
export type EntityPageHeaderProps = {};
@@ -1,5 +1,6 @@
import { ComponentType } from 'react';
import { PluginOutput, RoutePath, RouteOptions } from './types';
import { IconComponent } from '../types';
export type PluginConfig = {
id: string;
@@ -27,6 +28,7 @@ export type RouterHooks = {
type EntityPageSidebarItemOptions = {
title: string;
icon: IconComponent;
target: RoutePath;
};
@@ -79,11 +81,12 @@ export default class Plugin {
},
},
entityPage: {
navItem({ title, target }) {
navItem({ title, icon, target }) {
outputs.push({
type: 'entity-page-nav-item',
target,
title,
icon,
target,
});
},
route(path, component, options) {
@@ -1,4 +1,5 @@
import { ComponentType } from 'react';
import { IconComponent } from '../types';
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
@@ -31,6 +32,7 @@ export type EntityPageViewRouteOutput = {
export type EntityPageNavItemOutput = {
type: 'entity-page-nav-item';
title: string;
icon: IconComponent;
target: RoutePath;
};
+4 -9
View File
@@ -1,10 +1,5 @@
export type User = {
id: string;
email: string;
};
import { ComponentType } from 'react';
export type UserApi = {
isLoggedIn(): Promise<boolean>;
getUser(): Promise<User>;
};
export type IconComponent = ComponentType<{
fontSize: 'inherit' | 'default' | 'small' | 'large';
}>;
@@ -0,0 +1,62 @@
import React, { FC } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { EntityPageProps } from '../../api/entityView/types';
import { useEntity } from '../../api';
import { Switch, Route, Redirect } from 'react-router-dom';
import DefaultEntityPageHeader from '../DefaultEntityPageHeader';
import DefaultEntityPageNavbar from '../DefaultEntityPageNavbar';
const useStyles = makeStyles<Theme>(theme => ({
root: {
display: 'grid',
gridTemplateAreas: `
'header header'
'navbar content'
`,
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: 'auto 1fr',
minHeight: '100%',
paddingBottom: theme.spacing(3),
},
header: {
gridArea: 'header',
},
navbar: {
gridArea: 'navbar',
},
content: {
gridArea: 'content',
},
}));
const DefaultEntityPage: FC<EntityPageProps> = ({ navItems, views }) => {
const classes = useStyles();
const { kind, id } = useEntity();
const basePath = `/entity/${kind}/${id}`;
return (
<div className={classes.root}>
<div className={classes.header}>
<DefaultEntityPageHeader />
</div>
<div className={classes.navbar}>
<DefaultEntityPageNavbar navItems={navItems} />
</div>
<div className={classes.content}>
<Switch>
{views.map(({ path, component }) => (
<Route
key={path}
exact
path={`${basePath}${path}`}
component={component}
/>
))}
<Redirect from={basePath} to={`${basePath}${views[0].path}`} />
</Switch>
</div>
</div>
);
};
export default DefaultEntityPage;
@@ -0,0 +1 @@
export { default } from './DefaultEntityPage';
@@ -0,0 +1,18 @@
import React, { FC } from 'react';
import { Header, useEntity, useEntityConfig, theme } from '../..';
import { EntityPageHeaderProps } from '../../api/entityView/types';
import { Theme } from '../../layout/Page/Page';
const DefaultEntityPageHeader: FC<EntityPageHeaderProps> = () => {
const { id } = useEntity();
const config = useEntityConfig();
// TODO(rugvip): provide theme through entity config
return (
<Theme.Provider value={theme.service}>
<Header title={`${config.title} - ${id}`} />
</Theme.Provider>
);
};
export default DefaultEntityPageHeader;
@@ -0,0 +1 @@
export { default } from './DefaultEntityPageHeader';
@@ -0,0 +1,36 @@
import React, { FC } from 'react';
import { EntityPageNavbarProps } from '../../api/entityView/types';
import { useEntityUri } from '../..';
import { List, makeStyles, Theme } from '@material-ui/core';
import NavbarItem from './NavbarItem';
const useStyles = makeStyles<Theme>({
nav: {
gridArea: 'pageNav',
width: 220,
transition: 'width 0.07s, height 0s',
transitionTimingFunction: 'ease-in',
backgroundColor: '#eeeeee',
boxShadow: '0px 0 4px 0px rgba(0,0,0,0.35)',
},
list: {
padding: 0,
},
});
const DefaultEntityPageNavbar: FC<EntityPageNavbarProps> = ({ navItems }) => {
const classes = useStyles();
const entityUri = useEntityUri();
return (
<nav className={classes.nav}>
<List className={classes.list}>
{navItems.map((navItem, index) => (
<NavbarItem key={index} navItem={navItem} entityUri={entityUri} />
))}
</List>
</nav>
);
};
export default DefaultEntityPageNavbar;
@@ -0,0 +1,72 @@
import React, { FC } from 'react';
import { EntityLink } from '../..';
import {
ListItem,
makeStyles,
Theme,
ListItemIcon,
ListItemText,
Typography,
} from '@material-ui/core';
import { EntityPageNavItem } from '../../api/entityView/types';
const useStyles = makeStyles<Theme>(theme => ({
root: {
display: 'block',
overflow: 'hidden',
borderBottom: `1px solid #d9d9d9`,
paddingLeft: theme.spacing(1),
},
label: {
color: '#333',
fontWeight: 'bolder',
whiteSpace: 'nowrap',
lineHeight: 1.0,
},
iconImg: {
width: 24,
height: 24,
},
icon: {
margin: theme.spacing(0.5, 2, 0.5, 0),
minWidth: 0,
fontSize: 24,
},
expand: {
color: 'white',
},
}));
type Props = {
navItem: EntityPageNavItem;
entityUri: string;
};
const NavbarItem: FC<Props> = ({ navItem, entityUri }) => {
const classes = useStyles();
const IconComponent = navItem.icon;
return (
<EntityLink
className={classes.root}
uri={entityUri}
subPath={navItem.target}
>
<ListItem button className={`${classes.listItemGutters}`}>
<ListItemIcon className={classes.icon}>
<IconComponent fontSize="inherit" />
</ListItemIcon>
<ListItemText
primary={
<Typography variant="subtitle1" className={classes.label}>
{navItem.title}
</Typography>
}
disableTypography
/>
</ListItem>
</EntityLink>
);
};
export default NavbarItem;
@@ -0,0 +1 @@
export { default } from './DefaultEntityPageNavbar';
@@ -1,17 +1,17 @@
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import { Link, LinkProps } from 'react-router-dom';
type Props = {
type Props = Omit<LinkProps, 'to'> & {
subPath?: string;
} & (
| {
kind: string;
id?: string;
}
| {
uri: string;
}
);
| {
kind: string;
id?: string;
}
| {
uri: string;
}
);
export function buildPath(kind: string, id?: string, subPath?: string) {
if (id) {
@@ -26,7 +26,11 @@ export function buildPath(kind: string, id?: string, subPath?: string) {
const EntityLink: FC<Props> = ({ subPath, children, ...props }) => {
if ('kind' in props) {
const { kind, id } = props;
return <Link to={buildPath(kind, id, subPath)}>{children}</Link>;
return (
<Link to={buildPath(kind, id, subPath)} {...props}>
{children}
</Link>
);
} else {
const match = props.uri.match(/entity:([^:]+)(:[^:]+)?/);
if (!match) {
@@ -36,7 +40,11 @@ const EntityLink: FC<Props> = ({ subPath, children, ...props }) => {
const [, kind, maybeId] = match;
const id = maybeId ? maybeId.slice(1) : undefined;
return <Link to={buildPath(kind, id, subPath)}>{children}</Link>;
return (
<Link to={buildPath(kind, id, subPath)} {...props}>
{children}
</Link>
);
}
};
+7 -7
View File
@@ -3,10 +3,10 @@ export {
default as EntityLink,
RelativeEntityLink,
} from './components/EntityLink';
export { default as Page } from '../src/layout/Page';
export { gradients, theme } from '../src/layout/Page';
export { default as Header } from '../src/layout/Header/Header';
export { default as HeaderLabel } from '../src/layout/HeaderLabel';
export { default as InfoCard } from '../src/layout/InfoCard';
export { default as ErrorBoundary } from '../src/layout/ErrorBoundary';
export { default as BackstageTheme } from '../src/theme/BackstageTheme';
export { default as Page } from './layout/Page';
export { gradients, theme } from './layout/Page';
export { default as Header } from './layout/Header/Header';
export { default as HeaderLabel } from './layout/HeaderLabel';
export { default as InfoCard } from './layout/InfoCard';
export { default as ErrorBoundary } from './layout/ErrorBoundary';
export { default as BackstageTheme } from './theme/BackstageTheme';
@@ -103,7 +103,7 @@ const styles = theme => ({
gridArea: 'pageHeader',
padding: theme.spacing(3),
minHeight: 118,
width: '100vw',
width: '100%',
boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)',
position: 'relative',
zIndex: 100,
@@ -10,6 +10,8 @@ import {
LinearProgress,
Typography,
Tooltip,
makeStyles,
Theme,
} from '@material-ui/core';
import { RelativeEntityLink } from '@backstage/core';
import { BuildsClient } from '../../apis/builds';
@@ -28,23 +30,31 @@ const LongText: FC<{ text: string; max: number }> = ({ text, max }) => {
);
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
padding: theme.spacing(2),
},
title: {
paddingBottom: theme.spacing(2),
},
}));
const BuildListPage: FC<{}> = () => {
const classes = useStyles();
const status = useAsync(() => client.listBuilds('entity:spotify:backstage'));
let content: JSX.Element;
if (status.loading) {
return <LinearProgress />;
}
if (status.error) {
return (
<Typography variant="h6" color="error">
content = <LinearProgress />;
} else if (status.error) {
content = (
<Typography variant="h4" color="error">
Failed to load builds, {status.error.message}
</Typography>
);
}
return (
<>
<Typography variant="h4">CI/CD Builds</Typography>
} else {
content = (
<TableContainer component={Paper}>
<Table aria-label="CI/CD builds table">
<TableHead>
@@ -86,7 +96,16 @@ const BuildListPage: FC<{}> = () => {
</TableBody>
</Table>
</TableContainer>
</>
);
}
return (
<div className={classes.root}>
<Typography variant="h4" className={classes.title}>
CI/CD Builds
</Typography>
{content}
</div>
);
};
@@ -1,6 +1,7 @@
import { createPlugin } from '@backstage/core';
import BuildDetailsPage from './components/BuildDetailsPage';
import BuildListPage from './components/BuildListPage';
import BuildIcon from '@material-ui/icons/Build';
// export const buildListRoute = createEntityRoute<[]>('/builds')
// export const buildDetailsRoute = createEntityRoute<[number]>('/builds/:buildId')
@@ -9,7 +10,7 @@ export default createPlugin({
id: 'github-actions',
register({ entityPage }) {
entityPage.navItem({ title: 'CI/CD', target: '/builds' });
entityPage.navItem({ title: 'CI/CD', icon: BuildIcon, target: '/builds' });
entityPage.route('/builds', BuildListPage);
entityPage.route('/builds/:buildUri', BuildDetailsPage);
},
@@ -1,10 +1,16 @@
import React from 'react';
import { render } from '@testing-library/react';
import HomePage from './HomePage';
import { ThemeProvider } from '@material-ui/core';
import { BackstageTheme } from '@backstage/core';
describe('HomePage', () => {
it('should render', () => {
const rendered = render(<HomePage />);
const rendered = render(
<ThemeProvider theme={BackstageTheme}>
<HomePage />
</ThemeProvider>,
);
expect(rendered.baseElement).toBeInTheDocument();
});
});
@@ -1,20 +1,45 @@
import { EntityLink, InfoCard } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { EntityLink, InfoCard, Header, Page, theme } from '@backstage/core';
import { Typography, makeStyles, Theme } from '@material-ui/core';
import React, { FC } from 'react';
import HomePageTimer from '../HomepageTimer';
const useStyles = makeStyles<Theme>(theme => ({
mainContentArea: {
overflowX: 'hidden',
overflowY: 'auto',
},
pageBody: {
padding: theme.spacing(2),
},
avatarButton: {
padding: theme.spacing(2),
},
}));
const HomePage: FC<{}> = () => {
const classes = useStyles();
return (
<InfoCard title="Home Page">
<Typography variant="body1">Welcome to Backstage!</Typography>
<div>
<EntityLink kind="service" id="backstage-backend">
Backstage Backend
</EntityLink>
<EntityLink uri="entity:service:backstage-lb" subPath="ci-cd">
Backstage LB CI/CD
</EntityLink>
<Page theme={theme.home}>
<div className={classes.mainContentArea}>
<Header title="This is Backstage!">
<HomePageTimer />
</Header>
<div className={classes.pageBody}>
<InfoCard title="Home Page">
<Typography variant="body1">Welcome to Backstage!</Typography>
<div>
<EntityLink kind="service" id="backstage-backend">
Backstage Backend
</EntityLink>
<EntityLink uri="entity:service:backstage-lb" subPath="ci-cd">
Backstage LB CI/CD
</EntityLink>
</div>
</InfoCard>
</div>
</div>
</InfoCard>
</Page>
);
};