Merge pull request #58 from spotify/rugvip/simplify-output

frontend/core: simplify plugin output handling
This commit is contained in:
Patrik Oldsberg
2020-02-06 13:10:53 +01:00
committed by GitHub
12 changed files with 187 additions and 149 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
import {
createEntityKind,
createWidgetView,
createEntityView,
createEntityPage,
} from '@backstage/core';
import ComputerIcon from '@material-ui/icons/Computer';
import MockEntityPage from './MockEntityPage';
@@ -13,10 +13,10 @@ const serviceOverviewPage = createWidgetView()
.addComponent(MockEntityCard)
.addComponent(MockEntityCard);
const serviceView = createEntityView()
.addPage('Overview', 'overview', serviceOverviewPage)
const serviceView = createEntityPage()
.addPage('Overview', '/overview', serviceOverviewPage)
.register(GithubActionsPlugin)
.addComponent('Deployment', 'deployment', MockEntityPage);
.addComponent('Deployment', '/deployment', MockEntityPage);
const serviceEntity = createEntityKind({
kind: 'service',
+3 -3
View File
@@ -1,7 +1,7 @@
import AppBuilder from './app/AppBuilder';
import EntityKind, { EntityConfig } from './entity/EntityKind';
import WidgetViewBuilder from './widgetView/WidgetViewBuilder';
import EntityViewBuilder from './entityView/EntityViewPageBuilder';
import EntityPageBuilder from './entityView/EntityPageBuilder';
import BackstagePlugin, { PluginConfig } from './plugin/Plugin';
export function createApp() {
@@ -16,8 +16,8 @@ export function createWidgetView() {
return new WidgetViewBuilder();
}
export function createEntityView() {
return new EntityViewBuilder();
export function createEntityPage() {
return new EntityPageBuilder();
}
export function createPlugin(config: PluginConfig): BackstagePlugin {
@@ -1,10 +1,10 @@
import React, { ComponentType, FC } from 'react';
import { Route, Switch, useParams } from 'react-router-dom';
import { Route, Switch, useParams, 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 BackstagePlugin, { registerSymbol } from '../plugin/Plugin';
import BackstagePlugin from '../plugin/Plugin';
class AppImpl implements App {
constructor(private readonly entities: Map<string, EntityKind>) {}
@@ -97,8 +97,31 @@ export default class AppBuilder {
const pluginRoutes = new Array<JSX.Element>();
for (const plugin of this.plugins.values()) {
const { routes = [] } = plugin[registerSymbol]();
pluginRoutes.push(...routes);
for (const output of plugin.output()) {
switch (output.type) {
case 'route': {
const { path, component, options = {} } = output;
const { exact = true } = options;
pluginRoutes.push(
<Route
key={path}
path={path}
component={component}
exact={exact}
/>,
);
break;
}
case 'redirect-route': {
const { path, target, options = {} } = output;
const { exact = true } = options;
pluginRoutes.push(
<Redirect key={path} path={path} to={target} exact={exact} />,
);
break;
}
}
}
}
const routes = [...pluginRoutes, ...entityRoutes];
@@ -5,8 +5,7 @@ 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 BackstagePlugin, { outputSymbol } from '../plugin/Plugin';
import { entityViewPage } from '../plugin/outputs';
import BackstagePlugin from '../plugin/Plugin';
const EntityLayout: FC<{}> = ({ children }) => {
const config = useEntityConfig();
@@ -34,43 +33,48 @@ const EntitySidebarItem: FC<{ title: string; path: string }> = ({
);
};
type EntityViewPage = {
type EntityPageNavItem = {
title: string;
target: string;
};
type EntityPageView = {
path: string;
component: ComponentType<any>;
};
type Props = {
pages: EntityViewPage[];
navItems: EntityPageNavItem[];
views: EntityPageView[];
};
const EntityViewComponent: FC<Props> = ({ pages }) => {
const EntityPageComponent: FC<Props> = ({ navItems, views }) => {
const { kind, id } = useEntity();
const basePath = `/entity/${kind}/${id}`;
return (
<EntityLayout>
<EntitySidebar>
{pages.map(({ title, path }) => (
<EntitySidebarItem key={path} title={title} path={path} />
{navItems.map(({ title, target }) => (
<EntitySidebarItem key={target} title={title} path={target} />
))}
</EntitySidebar>
<Switch>
{pages.map(({ path, component }) => (
{views.map(({ path, component }) => (
<Route
key={path}
exact={false}
path={`${basePath}/${path}`}
exact
path={`${basePath}${path}`}
component={component}
/>
))}
<Redirect from={basePath} to={`${basePath}/${pages[0].path}`} />
<Redirect from={basePath} to={`${basePath}${views[0].path}`} />
</Switch>
</EntityLayout>
);
};
type EntityViewRegistration =
type EntityPageRegistration =
| {
type: 'page';
title: string;
@@ -88,14 +92,14 @@ type EntityViewRegistration =
component: ComponentType<any>;
};
export default class EntityViewBuilder extends AppComponentBuilder {
private readonly registrations = new Array<EntityViewRegistration>();
export default class EntityPageBuilder extends AppComponentBuilder {
private readonly registrations = new Array<EntityPageRegistration>();
addPage(
title: string,
path: string,
page: AppComponentBuilder,
): EntityViewBuilder {
): EntityPageBuilder {
this.registrations.push({ type: 'page', title, path, page });
return this;
}
@@ -104,42 +108,60 @@ export default class EntityViewBuilder extends AppComponentBuilder {
title: string,
path: string,
component: ComponentType<any>,
): EntityViewBuilder {
): EntityPageBuilder {
this.registrations.push({ type: 'component', title, path, component });
return this;
}
register(plugin: BackstagePlugin): EntityViewBuilder {
register(plugin: BackstagePlugin): EntityPageBuilder {
this.registrations.push({ type: 'plugin', plugin });
return this;
}
build(app: App): ComponentType<any> {
const pages = this.registrations.map(registration => {
switch (registration.type) {
const navItems = new Array<EntityPageNavItem>();
const views = new Array<EntityPageView>();
for (const reg of this.registrations) {
switch (reg.type) {
case 'page': {
const { title, path, page } = registration;
return { title, path, component: page.build(app) };
const { title, path, page } = reg;
navItems.push({ title, target: path });
views.push({ path, component: page.build(app) });
break;
}
case 'component': {
const { title, path, component } = registration;
return { title, path, component };
const { title, path, component } = reg;
navItems.push({ title, target: path });
views.push({ path, component });
break;
}
case 'plugin': {
const { plugin } = registration;
const output = plugin[outputSymbol](entityViewPage);
if (!output) {
let added = false;
for (const output of reg.plugin.output()) {
switch (output.type) {
case 'entity-page-nav-item':
const { title, target } = output;
navItems.push({ title, 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 ${plugin} was registered as entity view, but did not have any output`,
`Plugin ${reg.plugin} was registered as entity view, but did not provide any output`,
);
}
return output;
break;
}
default:
throw new Error(`Unknown EntityViewBuilder registration`);
}
});
}
return () => <EntityViewComponent pages={pages} />;
return () => <EntityPageComponent navItems={navItems} views={views} />;
}
}
@@ -1,6 +1,5 @@
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import PluginOutputHook from './PluginOutputHook';
import { ComponentType } from 'react';
import { PluginOutput, RoutePath, RouteOptions } from './types';
export type PluginConfig = {
id: string;
@@ -8,101 +7,98 @@ export type PluginConfig = {
};
export type PluginHooks = {
router: Router;
provide<T>(ref: PluginOutputHook<T>, value: T): void;
router: RouterHooks;
entityPage: EntityPageHooks;
};
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
exact?: boolean;
};
export type RedirectOptions = {
// Whether the route path must match exactly, defaults to true.
exact?: boolean;
};
export type Router = {
export type RouterHooks = {
registerRoute(
path: string,
Component: React.ComponentType<any>,
path: RoutePath,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
registerRedirect(
path: string,
target: string,
options?: RedirectOptions,
path: RoutePath,
target: RoutePath,
options?: RouteOptions,
): void;
};
export type PluginRegistrationResult = {
routes?: JSX.Element[];
outputs?: Map<PluginOutputHook<any>, any>;
type EntityPageSidebarItemOptions = {
title: string;
target: RoutePath;
};
export type EntityPageHooks = {
navItem(options: EntityPageSidebarItemOptions): void;
route(
path: RoutePath,
component: ComponentType<any>,
options?: RouteOptions,
): void;
};
export const registerSymbol = Symbol('plugin-register');
export const outputSymbol = Symbol('plugin-output');
export default class Plugin {
private result?: PluginRegistrationResult;
private storedOutput?: PluginOutput[];
constructor(private readonly config: PluginConfig) {}
[registerSymbol](): PluginRegistrationResult {
if (this.result) {
return this.result;
output(): PluginOutput[] {
if (this.storedOutput) {
return this.storedOutput;
}
if (!this.config.register) {
return {};
return [];
}
const { id } = this.config;
const routes = new Array<JSX.Element>();
const outputs = new Map<PluginOutputHook<any>, any>();
const outputs = new Array<PluginOutput>();
this.config.register({
router: {
registerRoute(path, component, options = {}) {
registerRoute(path, component, options) {
if (path.startsWith('/entity/')) {
throw new Error(
`Plugin ${id} tried to register forbidden route ${path}`,
);
}
const { exact = true } = options;
routes.push(
<Route
key={path}
path={path}
component={component}
exact={exact}
/>,
);
outputs.push({ type: 'route', path, component, options });
},
registerRedirect(path, target, options = {}) {
registerRedirect(path, target, options) {
if (path.startsWith('/entity/')) {
throw new Error(
`Plugin ${id} tried to register forbidden redirect ${path}`,
);
}
const { exact = true } = options;
routes.push(
<Redirect key={path} path={path} to={target} exact={exact} />,
);
outputs.push({ type: 'redirect-route', path, target, options });
},
},
provide(hook, value) {
outputs.set(hook, value);
entityPage: {
navItem({ title, target }) {
outputs.push({
type: 'entity-page-nav-item',
target,
title,
});
},
route(path, component, options) {
outputs.push({
type: 'entity-page-view-route',
path,
component,
options,
});
},
},
});
this.result = { routes, outputs };
return this.result;
}
[outputSymbol]<T>(outputHook: PluginOutputHook<T>): T | undefined {
const { outputs } = this[registerSymbol]();
return outputs?.get(outputHook) as T;
this.storedOutput = outputs;
return this.storedOutput;
}
toString() {
@@ -1,11 +0,0 @@
export default class PluginOutputHook<T> {
constructor(private readonly name: string) {}
get T(): T {
throw new Error('use typeof instead');
}
toString() {
return `pluginOutput{${this.name}}`;
}
}
@@ -1,8 +0,0 @@
import PluginOutputHook from './PluginOutputHook';
import { ComponentType } from 'react';
export const entityViewPage = new PluginOutputHook<{
title: string;
path: string;
component: ComponentType<any>;
}>('entity-view-page');
@@ -0,0 +1,41 @@
import { ComponentType } from 'react';
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
exact?: boolean;
};
export type RoutePath = string;
export type RouteOutput = {
type: 'route';
path: RoutePath;
component: ComponentType<{}>;
options?: RouteOptions;
};
export type RedirectRouteOutput = {
type: 'redirect-route';
path: RoutePath;
target: RoutePath;
options?: RouteOptions;
};
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;
target: RoutePath;
};
export type PluginOutput =
| RouteOutput
| RedirectRouteOutput
| EntityPageViewRouteOutput
| EntityPageNavItemOutput;
@@ -16,7 +16,7 @@ type Props = {
export function buildPath(kind: string, id?: string, subPath?: string) {
if (id) {
if (subPath) {
return `/entity/${kind}/${id}/${subPath}`;
return `/entity/${kind}/${id}/${subPath.replace(/^\//, '')}`;
}
return `/entity/${kind}/${id}`;
}
@@ -1,22 +0,0 @@
import React, { FC } from 'react';
import { Switch, Route } from 'react-router-dom';
import BuildListPage from '../BuildListPage';
import BuildDetailsPage from '../BuildDetailsPage';
import { useRouteMatch } from 'react-router-dom';
const BuildPage: FC<{}> = () => {
const match = useRouteMatch();
return (
<Switch>
<Route
path={`${match.path}/:buildId`}
render={({ match }) => (
<BuildDetailsPage buildId={match.params.buildId} />
)}
/>
<Route path={match.path} component={BuildListPage} />
</Switch>
);
};
export default BuildPage;
@@ -1 +0,0 @@
export { default } from './BuildPage';
@@ -1,6 +1,6 @@
import { createPlugin } from '@backstage/core';
import { entityViewPage } from '@backstage/core/src/api/plugin/outputs';
import BuildPage from './components/BuildPage';
import BuildDetailsPage from './components/BuildDetailsPage';
import BuildListPage from './components/BuildListPage';
// export const buildListRoute = createEntityRoute<[]>('/builds')
// export const buildDetailsRoute = createEntityRoute<[number]>('/builds/:buildId')
@@ -8,11 +8,9 @@ import BuildPage from './components/BuildPage';
export default createPlugin({
id: 'github-actions',
register({ provide }) {
provide(entityViewPage, {
title: 'CI/CD',
path: 'builds',
component: BuildPage,
});
register({ entityPage }) {
entityPage.navItem({ title: 'CI/CD', target: '/builds' });
entityPage.route('/builds', BuildListPage);
entityPage.route('/builds/:buildId', BuildDetailsPage);
},
});