Merge pull request #227 from spotify/rugvip/nonteties

packages/core: removed entity-related APIs
This commit is contained in:
Patrik Oldsberg
2020-03-10 16:48:28 +01:00
committed by GitHub
26 changed files with 12 additions and 705 deletions
-2
View File
@@ -3,7 +3,6 @@ import { BackstageTheme, createApp } from '@spotify-backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import Root from './components/Root';
import entities from './entities';
import * as plugins from './plugins';
const useStyles = makeStyles(theme => ({
@@ -25,7 +24,6 @@ const useStyles = makeStyles(theme => ({
}));
const app = createApp();
app.registerEntityKind(...entities);
app.registerPlugin(...Object.values(plugins));
const AppComponent = app.build();
@@ -1,9 +0,0 @@
import React, { FC } from 'react';
import { useEntityUri } from '@spotify-backstage/core';
const MockEntityPage: FC<{}> = () => {
const uri = useEntityUri();
return <span>Mock card for {uri}, replace with some userful plugin</span>;
};
export default MockEntityPage;
@@ -1,12 +0,0 @@
import React, { FC } from 'react';
import { Typography } from '@material-ui/core';
const MockEntityPage: FC<{}> = () => {
return (
<Typography style={{ padding: 24 }} variant="h3">
This page is intentionally left blank
</Typography>
);
};
export default MockEntityPage;
-45
View File
@@ -1,45 +0,0 @@
import {
createEntityKind,
createWidgetView,
createEntityPage,
} from '@spotify-backstage/core';
import ComputerIcon from '@material-ui/icons/Computer';
import WebIcon from '@material-ui/icons/Web';
import VerifiedUserIcon from '@material-ui/icons/VerifiedUser';
import CloudIcon from '@material-ui/icons/Cloud';
import TimelineIcon from '@material-ui/icons/Timeline';
import CheckCircleIcon from '@material-ui/icons/CheckCircle';
import VpnKeyIcon from '@material-ui/icons/VpnKey';
import DnsIcon from '@material-ui/icons/Dns';
import MockEntityPage from './MockEntityPage';
import MockEntityCard from './MockEntityCard';
/* SERVICE */
const serviceOverviewPage = createWidgetView().add({
size: 4,
component: MockEntityCard,
});
const serviceView = createEntityPage()
.addPage('Overview', WebIcon, '/overview', serviceOverviewPage)
.addComponent('Tests', VerifiedUserIcon, '/tests', MockEntityPage)
.addComponent('Deployment', CloudIcon, '/deployment', MockEntityPage)
.addComponent('Monitoring', TimelineIcon, '/monitoring', MockEntityPage)
.addComponent('Service Levels', CheckCircleIcon, '/sla', MockEntityPage)
.addComponent('Secrets', VpnKeyIcon, '/secrets', MockEntityPage)
.addComponent('DNS', DnsIcon, '/dns', MockEntityPage);
const serviceEntity = createEntityKind({
kind: 'service',
title: 'Service',
color: {
primary: '#f00',
secondary: '#ba5',
},
icon: ComputerIcon,
pages: {
view: serviceView,
},
});
export default [serviceEntity];
-10
View File
@@ -1,25 +1,15 @@
import AppBuilder from './app/AppBuilder';
import EntityKind, { EntityConfig } from './entity/EntityKind';
import WidgetViewBuilder from './widgetView/WidgetViewBuilder';
import EntityPageBuilder from './entityView/EntityPageBuilder';
import BackstagePlugin, { PluginConfig } from './plugin/Plugin';
export function createApp() {
return new AppBuilder();
}
export function createEntityKind(config: EntityConfig) {
return new EntityKind(config);
}
export function createWidgetView() {
return new WidgetViewBuilder();
}
export function createEntityPage() {
return new EntityPageBuilder();
}
export function createPlugin(config: PluginConfig): BackstagePlugin {
return new BackstagePlugin(config);
}
+8 -84
View File
@@ -1,9 +1,7 @@
import React, { ComponentType, FC } from 'react';
import { Route, Switch, useParams, Redirect } from 'react-router-dom';
import React, { ComponentType } from 'react';
import { Route, Switch, Redirect } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { App, AppComponentBuilder } from './types';
import EntityKind, { EntityConfig } from '../entity/EntityKind';
import { EntityContextProvider } from '../entityView/EntityContext';
import { App } from './types';
import BackstagePlugin from '../plugin/Plugin';
import {
IconComponent,
@@ -13,49 +11,17 @@ import {
} from '../../icons';
class AppImpl implements App {
constructor(
private readonly entities: Map<string, EntityKind>,
private readonly systemIcons: SystemIcons,
) {}
getEntityConfig(kind: string): EntityConfig {
const entity = this.entities.get(kind);
if (!entity) {
throw new Error('EntityKind not found');
}
return entity.config;
}
constructor(private readonly systemIcons: SystemIcons) {}
getSystemIcon(key: SystemIconKey): IconComponent {
return this.systemIcons[key];
}
}
function builtComponent(
app: App,
component: ComponentType<any> | AppComponentBuilder,
) {
if (component instanceof AppComponentBuilder) {
return component.build(app);
}
return component;
}
export default class AppBuilder {
private readonly entities = new Map<string, EntityKind>();
private systemIcons = { ...defaultSystemIcons };
private readonly plugins = new Set<BackstagePlugin>();
registerEntityKind(...entity: EntityKind[]) {
for (const e of entity) {
const { kind } = e.config;
if (this.entities.has(e.config.kind)) {
throw new Error(`EntityKind '${kind}' is already registered`);
}
this.entities.set(e.config.kind, e);
}
}
registerIcons(icons: Partial<SystemIcons>) {
this.systemIcons = { ...this.systemIcons, ...icons };
}
@@ -70,49 +36,9 @@ export default class AppBuilder {
}
build(): ComponentType<{}> {
const app = new AppImpl(this.entities, this.systemIcons);
const app = new AppImpl(this.systemIcons);
const entityRoutes = new Array<JSX.Element>();
for (const { config } of this.entities.values()) {
const { kind, pages } = config;
const basePath = `/entity/${kind}`;
if (pages.list) {
const ListComponent = builtComponent(app, pages.list);
const Component: FC<{}> = () => (
<EntityContextProvider config={config}>
<ListComponent />
</EntityContextProvider>
);
const path = basePath;
entityRoutes.push(
<Route key={path} path={path} component={Component} />,
);
}
if (pages.view) {
const ViewComponent = builtComponent(app, pages.view);
const Component: FC<{}> = () => {
const { entityId } = useParams<{ entityId: string }>();
return (
<EntityContextProvider config={config} id={entityId}>
<ViewComponent />
</EntityContextProvider>
);
};
const path = `${basePath}/:entityId`;
entityRoutes.push(
<Route key={path} path={path} component={Component} />,
);
}
}
const pluginRoutes = new Array<JSX.Element>();
const routes = new Array<JSX.Element>();
for (const plugin of this.plugins.values()) {
for (const output of plugin.output()) {
@@ -120,7 +46,7 @@ export default class AppBuilder {
case 'route': {
const { path, component, options = {} } = output;
const { exact = true } = options;
pluginRoutes.push(
routes.push(
<Route
key={path}
path={path}
@@ -133,7 +59,7 @@ export default class AppBuilder {
case 'redirect-route': {
const { path, target, options = {} } = output;
const { exact = true } = options;
pluginRoutes.push(
routes.push(
<Redirect key={path} path={path} to={target} exact={exact} />,
);
break;
@@ -144,8 +70,6 @@ export default class AppBuilder {
}
}
const routes = [...pluginRoutes, ...entityRoutes];
return () => (
<AppContextProvider app={app}>
<Switch>
-2
View File
@@ -1,9 +1,7 @@
import { ComponentType } from 'react';
import { EntityConfig } from '../entity/EntityKind';
import { IconComponent, SystemIconKey } from '../../icons';
export type App = {
getEntityConfig(kind: string): EntityConfig;
getSystemIcon(key: SystemIconKey): IconComponent;
};
@@ -1,21 +0,0 @@
import { ComponentType } from 'react';
import { AppComponentBuilder } from '../app/types';
import { IconComponent } from '../../icons';
export type EntityConfig = {
kind: string;
title: string;
icon: IconComponent;
color: {
primary: string;
secondary: string;
};
pages: {
list?: ComponentType<{}> | AppComponentBuilder;
view?: ComponentType<{}> | AppComponentBuilder;
};
};
export default class EntityKind {
constructor(readonly config: EntityConfig) {}
}
@@ -1,42 +0,0 @@
import React, { createContext, useContext, FC } from 'react';
import { EntityConfig } from '../entity/EntityKind';
type Value = {
config: EntityConfig;
id?: string;
};
const Context = createContext<Value | undefined>(undefined);
type Props = {
config: EntityConfig;
id?: string;
};
export const EntityContextProvider: FC<Props> = ({ config, id, children }) => (
<Context.Provider value={{ config, id }} children={children} />
);
export const useEntity = (): { kind: string; id: string } => {
const value = useContext(Context);
if (!value) {
throw new Error('No entity context available');
}
if (!value.id) {
throw new Error('Entity context does not contain entity id');
}
return { kind: value.config.kind, id: value.id };
};
export const useEntityConfig = (): EntityConfig => {
const value = useContext(Context);
if (!value) {
throw new Error('No entity context available');
}
return value.config;
};
export const useEntityUri = (): string => {
const { kind, id } = useEntity();
return `entity:${kind}:${id}`;
};
@@ -1,114 +0,0 @@
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 '../../icons';
// type AppComponents = {
// EntityPage: ComponentType<EntityPageProps>;
// EntityPageNavbar: ComponentType<EntityPageNavbarProps>;
// EntityPageHeader: ComponentType<EntityPageHeaderProps>;
// };
type EntityPageRegistration =
| {
type: 'page';
title: string;
icon: IconComponent;
path: string;
page: AppComponentBuilder;
}
| {
type: 'plugin';
plugin: BackstagePlugin;
}
| {
type: 'component';
title: string;
icon: IconComponent;
path: string;
component: ComponentType<any>;
};
export default class EntityPageBuilder extends AppComponentBuilder {
private readonly registrations = new Array<EntityPageRegistration>();
addPage(
title: string,
icon: IconComponent,
path: string,
page: AppComponentBuilder,
): EntityPageBuilder {
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,
icon,
path,
component,
});
return this;
}
register(plugin: BackstagePlugin): EntityPageBuilder {
this.registrations.push({ type: 'plugin', plugin });
return this;
}
build(app: App): ComponentType<any> {
const navItems = new Array<EntityPageNavItem>();
const views = new Array<EntityPageView>();
for (const reg of this.registrations) {
switch (reg.type) {
case 'page': {
const { title, icon, path, page } = reg;
navItems.push({ title, icon, target: path });
views.push({ path, component: page.build(app) });
break;
}
case 'component': {
const { title, icon, path, component } = reg;
navItems.push({ title, icon, target: path });
views.push({ path, component });
break;
}
case 'plugin': {
let added = false;
for (const output of reg.plugin.output()) {
switch (output.type) {
case 'entity-page-nav-item':
const { title, icon, target } = output;
navItems.push({ title, icon, target });
added = true;
break;
case 'entity-page-view-route':
const { path, component } = output;
views.push({ path, component });
added = true;
break;
}
}
if (!added) {
throw new Error(
`Plugin ${reg.plugin} was registered as entity view, but did not provide any output`,
);
}
break;
}
}
}
return () => <DefaultEntityPage navItems={navItems} views={views} />;
}
}
-24
View File
@@ -1,24 +0,0 @@
import { ComponentType } from 'react';
import { IconComponent } from '../../icons';
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 = {};
-5
View File
@@ -1,7 +1,2 @@
export * from './api';
export { useApp } from './app/AppContext';
export {
useEntity,
useEntityConfig,
useEntityUri,
} from './entityView/EntityContext';
-47
View File
@@ -1,6 +1,5 @@
import { ComponentType } from 'react';
import { PluginOutput, RoutePath, RouteOptions } from './types';
import { IconComponent } from '../../icons';
import { Widget } from '../widgetView/types';
export type PluginConfig = {
@@ -10,7 +9,6 @@ export type PluginConfig = {
export type PluginHooks = {
router: RouterHooks;
entityPage: EntityPageHooks;
widgets: WidgetHooks;
};
@@ -28,21 +26,6 @@ export type RouterHooks = {
): void;
};
type EntityPageSidebarItemOptions = {
title: string;
icon: IconComponent;
target: RoutePath;
};
export type EntityPageHooks = {
navItem(options: EntityPageSidebarItemOptions): void;
route(
path: RoutePath,
component: ComponentType<any>,
options?: RouteOptions,
): void;
};
export type WidgetHooks = {
add(widget: Widget): void;
};
@@ -63,47 +46,17 @@ export default class Plugin {
return [];
}
const { id } = this.config;
const outputs = new Array<PluginOutput>();
this.config.register({
router: {
registerRoute(path, component, options) {
if (path.startsWith('/entity/')) {
throw new Error(
`Plugin ${id} tried to register forbidden route ${path}`,
);
}
outputs.push({ type: 'route', path, component, options });
},
registerRedirect(path, target, options) {
if (path.startsWith('/entity/')) {
throw new Error(
`Plugin ${id} tried to register forbidden redirect ${path}`,
);
}
outputs.push({ type: 'redirect-route', path, target, options });
},
},
entityPage: {
navItem({ title, icon, target }) {
outputs.push({
type: 'entity-page-nav-item',
title,
icon,
target,
});
},
route(path, component, options) {
outputs.push({
type: 'entity-page-view-route',
path,
component,
options,
});
},
},
widgets: {
add(widget: Widget) {
outputs.push({ type: 'widget', widget });
+1 -21
View File
@@ -1,5 +1,4 @@
import { ComponentType } from 'react';
import { IconComponent } from '../../icons';
import { Widget } from '../widgetView/types';
export type RouteOptions = {
@@ -28,23 +27,4 @@ export type WidgetOutput = {
widget: Widget;
};
export type EntityPageViewRouteOutput = {
type: 'entity-page-view-route';
path: RoutePath;
component: ComponentType<any>;
options?: RouteOptions;
};
export type EntityPageNavItemOutput = {
type: 'entity-page-nav-item';
title: string;
icon: IconComponent;
target: RoutePath;
};
export type PluginOutput =
| RouteOutput
| RedirectRouteOutput
| WidgetOutput
| EntityPageViewRouteOutput
| EntityPageNavItemOutput;
export type PluginOutput = RouteOutput | RedirectRouteOutput | WidgetOutput;
@@ -1,61 +0,0 @@
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>({
root: {
display: 'grid',
gridTemplateAreas: `
'header header'
'navbar content'
`,
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: 'auto 1fr',
minHeight: '100%',
},
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;
@@ -1 +0,0 @@
export { default } from './DefaultEntityPage';
@@ -1,18 +0,0 @@
import React, { FC } from 'react';
import { Header, useEntity, useEntityConfig, pageTheme } 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={pageTheme.service}>
<Header title={`${config.title} - ${id}`} />
</Theme.Provider>
);
};
export default DefaultEntityPageHeader;
@@ -1 +0,0 @@
export { default } from './DefaultEntityPageHeader';
@@ -1,37 +0,0 @@
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)',
height: '100%',
},
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;
@@ -1,71 +0,0 @@
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`,
},
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;
@@ -1 +0,0 @@
export { default } from './DefaultEntityPageNavbar';
@@ -1,51 +0,0 @@
import React, { FC } from 'react';
import { Link, LinkProps } from 'react-router-dom';
type Props = Omit<LinkProps, 'to'> & {
subPath?: string;
} & (
| {
kind: string;
id?: string;
}
| {
uri: string;
}
);
export function buildPath(kind: string, id?: string, subPath?: string) {
if (id) {
if (subPath) {
return `/entity/${kind}/${id}/${subPath.replace(/^\//, '')}`;
}
return `/entity/${kind}/${id}`;
}
return `/entity/${kind}`;
}
const EntityLink: FC<Props> = ({ subPath, children, ...props }) => {
if ('kind' in props) {
const { kind, id } = props;
return (
<Link to={buildPath(kind, id, subPath)} {...props}>
{children}
</Link>
);
} else {
const match = props.uri.match(/entity:([^:]+)(:[^:]+)?/);
if (!match) {
throw new TypeError(`Invalid entity uri: '${props.uri}'`);
}
const [, kind, maybeId] = match;
const id = maybeId ? maybeId.slice(1) : undefined;
return (
<Link to={buildPath(kind, id, subPath)} {...props}>
{children}
</Link>
);
}
};
export default EntityLink;
@@ -1,16 +0,0 @@
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import { useEntity } from '../../api';
import { buildPath } from './EntityLink';
type Props = {
view: string;
};
const RelativeEntityLink: FC<Props> = ({ view, children }) => {
const entity = useEntity();
return <Link to={buildPath(entity.kind, entity.id, view)}>{children}</Link>;
};
export default RelativeEntityLink;
@@ -1,2 +0,0 @@
export { default } from './EntityLink';
export { default as RelativeEntityLink } from './RelativeEntityLink';
-4
View File
@@ -1,8 +1,4 @@
export * from './api';
export {
default as EntityLink,
RelativeEntityLink,
} from './components/EntityLink';
export { default as Page } from './layout/Page';
export { gradients, pageTheme, PageTheme } from './layout/Page';
export { default as Content } from './layout/Content/Content';
@@ -1,9 +1,8 @@
import React, { FC } from 'react';
import { Typography, Grid } from '@material-ui/core';
import { Typography, Link, Grid } from '@material-ui/core';
import HomePageTimer from '../HomepageTimer';
import {
Content,
EntityLink,
InfoCard,
Header,
Page,
@@ -27,9 +26,9 @@ const HomePage: FC<{}> = () => {
return {
id,
entity: (
<EntityLink kind={kind} id={id}>
<Link href={`entity/${kind}/${id}`}>
<Typography color="primary">{id}</Typography>
</EntityLink>
</Link>
),
kind: <Typography>{kind}</Typography>,
};