Merge pull request #58 from spotify/rugvip/simplify-output
frontend/core: simplify plugin output handling
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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];
|
||||
|
||||
+54
-32
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user