Merge pull request #43 from spotify/rugvip/app-api

frontend/core: add some kinda app api
This commit is contained in:
Patrik Oldsberg
2020-02-05 14:47:32 +01:00
committed by GitHub
16 changed files with 556 additions and 29 deletions
+50 -29
View File
@@ -1,19 +1,28 @@
import React, { FC } from 'react';
import {
BackstageTheme,
createApp,
EntityLink,
Header,
InfoCard,
Page,
theme,
withGlobalStyles,
} from '@backstage/core';
import helloWorld, { MyComponent } from '@backstage/plugin-hello-world';
import Typography from '@material-ui/core/Typography';
import SideBar from './components/SideBar';
//import PageHeader from './components/PageHeader';
import { Header, Page, InfoCard } from '@backstage/core';
import { LoginComponent } from '@backstage/plugin-login';
import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import React, { FC } from 'react';
import {
BrowserRouter as Router,
Switch,
Route,
Link as RouterLink,
Route,
Switch,
} from 'react-router-dom';
import { BackstageTheme, withGlobalStyles, theme } from '@backstage/core';
import { CssBaseline, ThemeProvider, makeStyles } from '@material-ui/core';
import HomePageTimer from './components/HomepageTimer';
import SideBar from './components/SideBar';
import entities from './entities';
const useStyles = makeStyles(theme => ({
root: {
@@ -36,27 +45,6 @@ const useStyles = makeStyles(theme => ({
},
}));
const App: FC<{}> = () => {
return (
<CssBaseline>
<ThemeProvider theme={BackstageTheme}>
<AppContent>
<Router>
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/login">
<Login />
</Route>
</Switch>
</Router>
</AppContent>
</ThemeProvider>
</CssBaseline>
);
};
const Home: FC<{}> = () => {
return (
<InfoCard title="Home Page">
@@ -67,6 +55,12 @@ const Home: FC<{}> = () => {
<MyComponent />
<div>
<RouterLink to="/login">Go to Login</RouterLink>
<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>
);
@@ -103,4 +97,31 @@ const AppShell: FC<{}> = ({ children }) => {
const AppContent = withGlobalStyles(AppShell);
const app = createApp();
app.registerEntityKind(...entities);
app.setHomePage(Home);
const AppComponent = app.build();
const App: FC<{}> = () => {
return (
<CssBaseline>
<ThemeProvider theme={BackstageTheme}>
<AppContent>
<Router>
<Switch>
<Route path="/login">
<Login />
</Route>
<Route>
<AppComponent />
</Route>
</Switch>
</Router>
</AppContent>
</ThemeProvider>
</CssBaseline>
);
};
export default App;
@@ -0,0 +1,9 @@
import React, { FC } from 'react';
import { useEntityUri } from '@backstage/core';
const MockEntityPage: FC<{}> = () => {
const uri = useEntityUri();
return <span>Mock card for {uri}, replace with some userful plugin</span>;
};
export default MockEntityPage;
@@ -0,0 +1,9 @@
import React, { FC } from 'react';
import { useEntityUri } from '@backstage/core';
const MockEntityPage: FC<{}> = () => {
const uri = useEntityUri();
return <span>Mock page for {uri}, replace with some userful plugin</span>;
};
export default MockEntityPage;
@@ -0,0 +1,32 @@
import {
createEntityKind,
createOverviewPage,
createEntityView,
} from '@backstage/core';
import ComputerIcon from '@material-ui/icons/Computer';
import MockEntityPage from './MockEntityPage';
import MockEntityCard from './MockEntityCard';
/* SERVICE */
const serviceOverviewPage = createOverviewPage()
.addComponent(MockEntityCard)
.addComponent(MockEntityCard);
const serviceView = createEntityView()
.addPage('Overview', 'overview', serviceOverviewPage)
.addComponent('CI/CD', 'ci-cd', MockEntityPage);
const serviceEntity = createEntityKind({
kind: 'service',
title: 'Service',
color: {
primary: '#f00',
secondary: '#ba5',
},
icon: ComputerIcon,
pages: {
view: serviceView,
},
});
export default [serviceEntity];
+2
View File
@@ -12,10 +12,12 @@
"@types/node": "^12.0.0",
"@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0",
"@types/react-router-dom": "^5.1.3",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet":"5.2.1",
"react-addons-text-content": "0.0.4",
"react-router-dom": "^5.1.2",
"recompose": "0.30.0"
},
"scripts": {
@@ -0,0 +1,107 @@
import React, { ComponentType, FC } from 'react';
import { AppContextProvider } from './AppContext';
import { App, EntityConfig, AppComponentBuilder } from './types';
import { Route, Switch, useParams } from 'react-router-dom';
import EntityKind from './EntityKind';
import { EntityContextProvider } from './EntityContext';
const DefaultHomePage: FC<{}> = () => {
return <span>Hello! I am default home page</span>;
};
class AppImpl implements App {
constructor(private readonly entities: Map<string, EntityKind>) {}
getEntityConfig(kind: string): EntityConfig {
const entity = this.entities.get(kind);
if (!entity) {
throw new Error('EntityKind not found');
}
return entity.config;
}
}
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 homePage: ComponentType = DefaultHomePage;
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);
}
}
setHomePage(page: ComponentType<{}>) {
this.homePage = page;
}
build(): ComponentType<{}> {
const app = new AppImpl(this.entities);
const entityRoutes = [];
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 routes = [...entityRoutes];
return () => (
<AppContextProvider app={app}>
<Switch>
{routes}
<Route exact path="/" component={this.homePage} />
<Route component={() => <span>404 Not Found</span>} />
</Switch>
</AppContextProvider>
);
}
}
@@ -0,0 +1,20 @@
import React, { createContext, useContext, FC } from 'react';
import { App } from './types';
const Context = createContext<App | undefined>(undefined);
type Props = {
app: App;
};
export const AppContextProvider: FC<Props> = ({ app, children }) => (
<Context.Provider value={app} children={children} />
);
export const useApp = (): App => {
const app = useContext(Context);
if (!app) {
throw new Error('No app context available');
}
return app;
};
@@ -0,0 +1,42 @@
import React, { createContext, useContext, FC } from 'react';
import { EntityConfig } from './types';
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}`;
};
@@ -0,0 +1,5 @@
import { EntityConfig } from './types';
export default class EntityKind {
constructor(readonly config: EntityConfig) {}
}
@@ -0,0 +1,43 @@
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
type Props = {
subPath?: string;
} & (
| {
kind: string;
id?: string;
}
| {
uri: string;
}
);
function buildPath(kind: string, id?: string, subPath?: string) {
if (id) {
if (subPath) {
return `/entity/${kind}/${id}/${subPath}`;
}
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)}>{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)}>{children}</Link>;
}
};
export default EntityLink;
@@ -0,0 +1,124 @@
import React, { ComponentType, FC } from 'react';
import { AppComponentBuilder, App } from './types';
import { useEntity, useEntityUri, useEntityConfig } from './EntityContext';
import { Route, Redirect, Switch } from 'react-router-dom';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import EntityLink from './EntityLink';
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 EntityViewPage = {
title: string;
path: string;
component: ComponentType<any>;
};
type Props = {
pages: EntityViewPage[];
};
const EntityViewComponent: FC<Props> = ({ pages }) => {
const { kind, id } = useEntity();
const basePath = `/entity/${kind}/${id}`;
return (
<EntityLayout>
<EntitySidebar>
{pages.map(({ title, path }) => (
<EntitySidebarItem key={path} title={title} path={path} />
))}
</EntitySidebar>
<Switch>
{pages.map(({ path, component }) => (
<Route
key={path}
exact
path={`${basePath}/${path}`}
component={component}
/>
))}
<Redirect from={basePath} to={`${basePath}/${pages[0].path}`} />
</Switch>
</EntityLayout>
);
};
type EntityViewRegistration =
| {
type: 'page';
title: string;
path: string;
page: AppComponentBuilder;
}
| {
type: 'component';
title: string;
path: string;
component: ComponentType<any>;
};
export default class EntityViewBuilder extends AppComponentBuilder {
private readonly registrations = new Array<EntityViewRegistration>();
addPage(
title: string,
path: string,
page: AppComponentBuilder,
): EntityViewBuilder {
this.registrations.push({ type: 'page', title, path, page });
return this;
}
addComponent(
title: string,
path: string,
component: ComponentType<any>,
): EntityViewBuilder {
this.registrations.push({ type: 'component', title, path, component });
return this;
}
build(app: App): ComponentType<any> {
const pages = this.registrations.map(registration => {
switch (registration.type) {
case 'page': {
const { title, path, page } = registration;
return { title, path, component: page.build(app) };
}
case 'component': {
const { title, path, component } = registration;
return { title, path, component };
}
default:
throw new Error(`Unknown EntityViewBuilder registration`);
}
});
return () => <EntityViewComponent pages={pages} />;
}
}
@@ -0,0 +1,50 @@
import React, { ComponentType, FC } from 'react';
import { App, AppComponentBuilder } from './types';
type Props = {
app: App;
cards: ComponentType<any>[];
};
const OverviewPageComponent: FC<Props> = ({ cards }) => {
return (
<div>
{cards.map(CardComponent => (
<CardComponent />
))}
</div>
);
};
type OverviewPageRegistration = {
type: 'component';
component: ComponentType<any>;
};
export default class OverviewPageBuilder extends AppComponentBuilder {
private readonly registrations = new Array<OverviewPageRegistration>();
private output?: ComponentType<any>;
addComponent(component: ComponentType<any>): OverviewPageBuilder {
this.registrations.push({ type: 'component', component });
return this;
}
build(app: App): ComponentType<any> {
if (this.output) {
return this.output;
}
const cards = this.registrations.map(reg => {
switch (reg.type) {
case 'component':
return reg.component;
default:
throw new Error(`Unknown OverviewPageBuilder registration`);
}
});
this.output = () => <OverviewPageComponent app={app} cards={cards} />;
return this.output;
}
}
+21
View File
@@ -0,0 +1,21 @@
import AppBuilder from './AppBuilder';
import { EntityConfig } from './types';
import EntityKind from './EntityKind';
import OverviewPageBuilder from './OverviewPageBuilder';
import EntityViewBuilder from './EntityViewPageBuilder';
export function createApp() {
return new AppBuilder();
}
export function createEntityKind(config: EntityConfig) {
return new EntityKind(config);
}
export function createOverviewPage() {
return new OverviewPageBuilder();
}
export function createEntityView() {
return new EntityViewBuilder();
}
@@ -0,0 +1,5 @@
export * from './types';
export * from './api';
export { useApp } from './AppContext';
export { useEntity, useEntityConfig, useEntityUri } from './EntityContext';
export { default as EntityLink } from './EntityLink';
@@ -0,0 +1,36 @@
import { ComponentType } from 'react';
export type EntityConfig = {
kind: string;
title: string;
icon: React.ComponentType<{ fontSize: number }>;
color: {
primary: string;
secondary: string;
};
pages: {
list?: ComponentType<{}> | AppComponentBuilder;
view?: ComponentType<{}> | AppComponentBuilder;
};
};
export type App = {
getEntityConfig(kind: string): EntityConfig;
};
export class AppComponentBuilder<T = any> {
build(_app: App): ComponentType<T> {
throw new Error('Must override build() in AppComponentBuilder');
}
}
export type User = {
id: string;
email: string;
};
export type UserApi = {
isLoggedIn(): Promise<boolean>;
getUser(): Promise<User>;
};
+1
View File
@@ -1,3 +1,4 @@
export * from './appApi';
export * from './types';
export { default as createPlugin } from './createPlugin';
export { default as Page } from '../src/layout/Page';