From 0d4a12e7f564b9bf1aa5e7d13d995a6cbb4286c0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 5 Feb 2020 13:46:00 +0100 Subject: [PATCH] frontend/core: add some kinda app api --- frontend/packages/app/src/App.tsx | 79 +++++++---- .../app/src/entities/MockEntityCard.tsx | 9 ++ .../app/src/entities/MockEntityPage.tsx | 9 ++ frontend/packages/app/src/entities/index.ts | 32 +++++ frontend/packages/core/package.json | 2 + .../packages/core/src/appApi/AppBuilder.tsx | 107 +++++++++++++++ .../packages/core/src/appApi/AppContext.tsx | 20 +++ .../core/src/appApi/EntityContext.tsx | 42 ++++++ .../packages/core/src/appApi/EntityKind.ts | 5 + .../packages/core/src/appApi/EntityLink.tsx | 43 ++++++ .../core/src/appApi/EntityViewPageBuilder.tsx | 124 ++++++++++++++++++ .../core/src/appApi/OverviewPageBuilder.tsx | 50 +++++++ frontend/packages/core/src/appApi/api.ts | 21 +++ frontend/packages/core/src/appApi/index.ts | 5 + frontend/packages/core/src/appApi/types.ts | 36 +++++ frontend/packages/core/src/index.ts | 1 + 16 files changed, 556 insertions(+), 29 deletions(-) create mode 100644 frontend/packages/app/src/entities/MockEntityCard.tsx create mode 100644 frontend/packages/app/src/entities/MockEntityPage.tsx create mode 100644 frontend/packages/app/src/entities/index.ts create mode 100644 frontend/packages/core/src/appApi/AppBuilder.tsx create mode 100644 frontend/packages/core/src/appApi/AppContext.tsx create mode 100644 frontend/packages/core/src/appApi/EntityContext.tsx create mode 100644 frontend/packages/core/src/appApi/EntityKind.ts create mode 100644 frontend/packages/core/src/appApi/EntityLink.tsx create mode 100644 frontend/packages/core/src/appApi/EntityViewPageBuilder.tsx create mode 100644 frontend/packages/core/src/appApi/OverviewPageBuilder.tsx create mode 100644 frontend/packages/core/src/appApi/api.ts create mode 100644 frontend/packages/core/src/appApi/index.ts create mode 100644 frontend/packages/core/src/appApi/types.ts diff --git a/frontend/packages/app/src/App.tsx b/frontend/packages/app/src/App.tsx index cd85cb85eb..0f22c76671 100644 --- a/frontend/packages/app/src/App.tsx +++ b/frontend/packages/app/src/App.tsx @@ -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 ( - - - - - - - - - - - - - - - - - ); -}; - const Home: FC<{}> = () => { return ( @@ -67,6 +55,12 @@ const Home: FC<{}> = () => {
Go to Login + + Backstage Backend + + + Backstage LB CI/CD +
); @@ -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 ( + + + + + + + + + + + + + + + + + ); +}; + export default App; diff --git a/frontend/packages/app/src/entities/MockEntityCard.tsx b/frontend/packages/app/src/entities/MockEntityCard.tsx new file mode 100644 index 0000000000..97a3f4073d --- /dev/null +++ b/frontend/packages/app/src/entities/MockEntityCard.tsx @@ -0,0 +1,9 @@ +import React, { FC } from 'react'; +import { useEntityUri } from '@backstage/core'; + +const MockEntityPage: FC<{}> = () => { + const uri = useEntityUri(); + return Mock card for {uri}, replace with some userful plugin; +}; + +export default MockEntityPage; diff --git a/frontend/packages/app/src/entities/MockEntityPage.tsx b/frontend/packages/app/src/entities/MockEntityPage.tsx new file mode 100644 index 0000000000..091906f31a --- /dev/null +++ b/frontend/packages/app/src/entities/MockEntityPage.tsx @@ -0,0 +1,9 @@ +import React, { FC } from 'react'; +import { useEntityUri } from '@backstage/core'; + +const MockEntityPage: FC<{}> = () => { + const uri = useEntityUri(); + return Mock page for {uri}, replace with some userful plugin; +}; + +export default MockEntityPage; diff --git a/frontend/packages/app/src/entities/index.ts b/frontend/packages/app/src/entities/index.ts new file mode 100644 index 0000000000..47c55a3c57 --- /dev/null +++ b/frontend/packages/app/src/entities/index.ts @@ -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]; diff --git a/frontend/packages/core/package.json b/frontend/packages/core/package.json index 9c1016141c..b2155fd61a 100644 --- a/frontend/packages/core/package.json +++ b/frontend/packages/core/package.json @@ -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": { diff --git a/frontend/packages/core/src/appApi/AppBuilder.tsx b/frontend/packages/core/src/appApi/AppBuilder.tsx new file mode 100644 index 0000000000..f9045bbbfd --- /dev/null +++ b/frontend/packages/core/src/appApi/AppBuilder.tsx @@ -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 Hello! I am default home page; +}; + +class AppImpl implements App { + constructor(private readonly entities: Map) {} + + 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 | AppComponentBuilder, +) { + if (component instanceof AppComponentBuilder) { + return component.build(app); + } + return component; +} + +export default class AppBuilder { + private readonly entities = new Map(); + 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<{}> = () => ( + + + + ); + + const path = basePath; + entityRoutes.push( + , + ); + } + + if (pages.view) { + const ViewComponent = builtComponent(app, pages.view); + + const Component: FC<{}> = () => { + const { entityId } = useParams<{ entityId: string }>(); + return ( + + + + ); + }; + + const path = `${basePath}/:entityId`; + entityRoutes.push( + , + ); + } + } + + const routes = [...entityRoutes]; + + return () => ( + + + {routes} + + 404 Not Found} /> + + + ); + } +} diff --git a/frontend/packages/core/src/appApi/AppContext.tsx b/frontend/packages/core/src/appApi/AppContext.tsx new file mode 100644 index 0000000000..e8acb424cd --- /dev/null +++ b/frontend/packages/core/src/appApi/AppContext.tsx @@ -0,0 +1,20 @@ +import React, { createContext, useContext, FC } from 'react'; +import { App } from './types'; + +const Context = createContext(undefined); + +type Props = { + app: App; +}; + +export const AppContextProvider: FC = ({ app, children }) => ( + +); + +export const useApp = (): App => { + const app = useContext(Context); + if (!app) { + throw new Error('No app context available'); + } + return app; +}; diff --git a/frontend/packages/core/src/appApi/EntityContext.tsx b/frontend/packages/core/src/appApi/EntityContext.tsx new file mode 100644 index 0000000000..5efe917f4d --- /dev/null +++ b/frontend/packages/core/src/appApi/EntityContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useContext, FC } from 'react'; +import { EntityConfig } from './types'; + +type Value = { + config: EntityConfig; + id?: string; +}; + +const Context = createContext(undefined); + +type Props = { + config: EntityConfig; + id?: string; +}; + +export const EntityContextProvider: FC = ({ config, id, 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}`; +}; diff --git a/frontend/packages/core/src/appApi/EntityKind.ts b/frontend/packages/core/src/appApi/EntityKind.ts new file mode 100644 index 0000000000..058fdcef6d --- /dev/null +++ b/frontend/packages/core/src/appApi/EntityKind.ts @@ -0,0 +1,5 @@ +import { EntityConfig } from './types'; + +export default class EntityKind { + constructor(readonly config: EntityConfig) {} +} diff --git a/frontend/packages/core/src/appApi/EntityLink.tsx b/frontend/packages/core/src/appApi/EntityLink.tsx new file mode 100644 index 0000000000..c26972c467 --- /dev/null +++ b/frontend/packages/core/src/appApi/EntityLink.tsx @@ -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 = ({ subPath, children, ...props }) => { + if ('kind' in props) { + const { kind, id } = props; + return {children}; + } 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 {children}; + } +}; + +export default EntityLink; diff --git a/frontend/packages/core/src/appApi/EntityViewPageBuilder.tsx b/frontend/packages/core/src/appApi/EntityViewPageBuilder.tsx new file mode 100644 index 0000000000..b323cd9504 --- /dev/null +++ b/frontend/packages/core/src/appApi/EntityViewPageBuilder.tsx @@ -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 ( +
{children}
+ ); +}; + +const EntitySidebar: FC<{}> = ({ children }) => { + return {children}; +}; + +const EntitySidebarItem: FC<{ title: string; path: string }> = ({ + title, + path, +}) => { + const entityUri = useEntityUri(); + + return ( + + + {title} + + + ); +}; + +type EntityViewPage = { + title: string; + path: string; + component: ComponentType; +}; + +type Props = { + pages: EntityViewPage[]; +}; + +const EntityViewComponent: FC = ({ pages }) => { + const { kind, id } = useEntity(); + const basePath = `/entity/${kind}/${id}`; + + return ( + + + {pages.map(({ title, path }) => ( + + ))} + + + {pages.map(({ path, component }) => ( + + ))} + + + + ); +}; + +type EntityViewRegistration = + | { + type: 'page'; + title: string; + path: string; + page: AppComponentBuilder; + } + | { + type: 'component'; + title: string; + path: string; + component: ComponentType; + }; + +export default class EntityViewBuilder extends AppComponentBuilder { + private readonly registrations = new Array(); + + 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, + ): EntityViewBuilder { + this.registrations.push({ type: 'component', title, path, component }); + return this; + } + + build(app: App): ComponentType { + 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 () => ; + } +} diff --git a/frontend/packages/core/src/appApi/OverviewPageBuilder.tsx b/frontend/packages/core/src/appApi/OverviewPageBuilder.tsx new file mode 100644 index 0000000000..67a0138436 --- /dev/null +++ b/frontend/packages/core/src/appApi/OverviewPageBuilder.tsx @@ -0,0 +1,50 @@ +import React, { ComponentType, FC } from 'react'; +import { App, AppComponentBuilder } from './types'; + +type Props = { + app: App; + cards: ComponentType[]; +}; + +const OverviewPageComponent: FC = ({ cards }) => { + return ( +
+ {cards.map(CardComponent => ( + + ))} +
+ ); +}; + +type OverviewPageRegistration = { + type: 'component'; + component: ComponentType; +}; + +export default class OverviewPageBuilder extends AppComponentBuilder { + private readonly registrations = new Array(); + private output?: ComponentType; + + addComponent(component: ComponentType): OverviewPageBuilder { + this.registrations.push({ type: 'component', component }); + return this; + } + + build(app: App): ComponentType { + 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 = () => ; + return this.output; + } +} diff --git a/frontend/packages/core/src/appApi/api.ts b/frontend/packages/core/src/appApi/api.ts new file mode 100644 index 0000000000..4d657ee606 --- /dev/null +++ b/frontend/packages/core/src/appApi/api.ts @@ -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(); +} diff --git a/frontend/packages/core/src/appApi/index.ts b/frontend/packages/core/src/appApi/index.ts new file mode 100644 index 0000000000..d198cb66e4 --- /dev/null +++ b/frontend/packages/core/src/appApi/index.ts @@ -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'; diff --git a/frontend/packages/core/src/appApi/types.ts b/frontend/packages/core/src/appApi/types.ts new file mode 100644 index 0000000000..f37848aca0 --- /dev/null +++ b/frontend/packages/core/src/appApi/types.ts @@ -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 { + build(_app: App): ComponentType { + throw new Error('Must override build() in AppComponentBuilder'); + } +} + +export type User = { + id: string; + email: string; +}; + +export type UserApi = { + isLoggedIn(): Promise; + + getUser(): Promise; +}; diff --git a/frontend/packages/core/src/index.ts b/frontend/packages/core/src/index.ts index 353b29ff72..45110734fb 100644 --- a/frontend/packages/core/src/index.ts +++ b/frontend/packages/core/src/index.ts @@ -1,3 +1,4 @@ +export * from './appApi'; export * from './types'; export { default as createPlugin } from './createPlugin'; export { default as Page } from '../src/layout/Page';