diff --git a/packages/core/src/api/apis/helpers.ts b/packages/core/src/api/apis/helpers.ts new file mode 100644 index 0000000000..d8f8e8ac11 --- /dev/null +++ b/packages/core/src/api/apis/helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiFactory } from './types'; + +/** + * Used to infer types for a standalone ApiFactory that isn't immediately passed + * to another function. + * This function doesn't actually do anything, it's only used to infer types. + */ +export function createApiFactory( + factory: ApiFactory, +): ApiFactory { + return factory; +} diff --git a/packages/core/src/api/apis/index.ts b/packages/core/src/api/apis/index.ts index 8dd982264d..9ab9a25e50 100644 --- a/packages/core/src/api/apis/index.ts +++ b/packages/core/src/api/apis/index.ts @@ -19,5 +19,6 @@ export { default as ApiRegistry } from './ApiRegistry'; export { default as ApiTestRegistry } from './ApiTestRegistry'; export { default as ApiRef } from './ApiRef'; export * from './types'; +export * from './helpers'; export * from './definitions'; export * from './implementations'; diff --git a/packages/core/src/api/apis/types.ts b/packages/core/src/api/apis/types.ts index 129afdaaa5..c44fd804a5 100644 --- a/packages/core/src/api/apis/types.ts +++ b/packages/core/src/api/apis/types.ts @@ -30,8 +30,8 @@ export type ApiHolder = { get(api: ApiRef): T | undefined; }; -export type ApiFactory = { - implements: ApiRef; - deps: TypesToApiRefs; - factory(deps: D): I extends A ? I : never; +export type ApiFactory = { + implements: ApiRef; + deps: TypesToApiRefs; + factory(deps: Deps): Impl extends Api ? Impl : never; }; diff --git a/packages/dev-utils/.eslintrc.js b/packages/dev-utils/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/dev-utils/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/dev-utils/.npmrc b/packages/dev-utils/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/packages/dev-utils/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/packages/dev-utils/README.md b/packages/dev-utils/README.md new file mode 100644 index 0000000000..04f8404f64 --- /dev/null +++ b/packages/dev-utils/README.md @@ -0,0 +1,24 @@ +# @backstage/dev-utils + +Utilities for developing Backstage plugins. + +This package provides utilities that help in developing plugins for Backstage, like App wrappers and API implementations for standalone serving of apps. + +## Installation + +Install the package via npm or yarn: + +```sh +$ npm install --save-dev @backstage/dev-utils +``` + +or + +```sh +$ yarn add -D @backstage/dev-utils +``` + +## Documentation + +- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json new file mode 100644 index 0000000000..2a241f6a9a --- /dev/null +++ b/packages/dev-utils/package.json @@ -0,0 +1,47 @@ +{ + "name": "@backstage/dev-utils", + "description": "Utilities for developing Backstage plugins.", + "version": "0.1.1-alpha.4", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/spotify/backstage", + "directory": "packages/dev-utils" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "backstage-cli plugin:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/core": "^0.1.1-alpha.4", + "@backstage/test-utils": "^0.1.1-alpha.4", + "@backstage/theme": "^0.1.1-alpha.4", + "@material-ui/core": "^4.9.1", + "@material-ui/icons": "^4.9.1", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "@types/jest": "^24.0.0", + "@types/node": "^12.0.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "react-router": "^5.1.2", + "react-router-dom": "^5.1.2" + }, + "files": [ + "dist" + ] +} diff --git a/packages/dev-utils/src/devApp/apiFactories.test.ts b/packages/dev-utils/src/devApp/apiFactories.test.ts new file mode 100644 index 0000000000..55455346b9 --- /dev/null +++ b/packages/dev-utils/src/devApp/apiFactories.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as apiFactories from './apiFactories'; +import { ApiTestRegistry } from '@backstage/core'; + +describe('apiFactories', () => { + it('should be possible to get an instance of each API', () => { + const registry = new ApiTestRegistry(); + const factories = Object.values(apiFactories); + + for (const factory of factories) { + registry.register(factory); + } + + for (const factory of factories) { + const api = registry.get(factory.implements); + expect(api).toBeDefined(); + } + }); +}); diff --git a/packages/dev-utils/src/devApp/apiFactories.ts b/packages/dev-utils/src/devApp/apiFactories.ts new file mode 100644 index 0000000000..a03faf0033 --- /dev/null +++ b/packages/dev-utils/src/devApp/apiFactories.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + alertApiRef, + errorApiRef, + ErrorApiForwarder, + AlertApi, + createApiFactory, +} from '@backstage/core'; + +// TODO(rugvip): We should likely figure out how to reuse all of these between apps +// and plugin serve with minimal boilerplate. For example we might move everything +// to DI, and provide factories for the default implementations, so this just becomes +// a list of things like `[ErrorApiForwarder.factory, AlertApiDialog.factory]`. + +export const alertApiFactory = createApiFactory({ + implements: alertApiRef, + deps: {}, + factory: (): AlertApi => ({ + // TODO: Figure out how to ship a nicer implementation without having + // to export any external references. + post(alertConfig) { + // eslint-disable-next-line no-alert + alert(`Alert[${alertConfig.severity}]: ${alertConfig.message}`); + }, + }), +}); + +export const errorApiFactory = createApiFactory({ + implements: errorApiRef, + deps: { alertApi: alertApiRef }, + factory: ({ alertApi }) => new ErrorApiForwarder(alertApi), +}); diff --git a/packages/dev-utils/src/devApp/index.tsx b/packages/dev-utils/src/devApp/index.tsx new file mode 100644 index 0000000000..b832b0d2f6 --- /dev/null +++ b/packages/dev-utils/src/devApp/index.tsx @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './render'; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx new file mode 100644 index 0000000000..9c9a72094c --- /dev/null +++ b/packages/dev-utils/src/devApp/render.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, ComponentType } from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter } from 'react-router-dom'; +import BookmarkIcon from '@material-ui/icons/Bookmark'; +import { ThemeProvider, CssBaseline } from '@material-ui/core'; +import { + createApp, + SidebarPage, + Sidebar, + SidebarItem, + SidebarSpacer, + ApiFactory, + createPlugin, + ApiTestRegistry, + ApiHolder, +} from '@backstage/core'; +import { lightTheme } from '@backstage/theme'; +import * as defaultApiFactories from './apiFactories'; + +// TODO(rugvip): export proper plugin type from core that isn't the plugin class +type BackstagePlugin = ReturnType; + +/** + * DevApp builder that is similar to the App builder API, but creates an App + * with the purpose of developing one or more plugins inside it. + */ +class DevAppBuilder { + private readonly plugins = new Array(); + private readonly factories = new Array>(); + + /** + * Register one or more plugins to render in the dev app + */ + registerPlugin(...plugins: BackstagePlugin[]): DevAppBuilder { + this.plugins.push(...plugins); + return this; + } + + /** + * Register an API factory to add to the app + */ + registerApiFactory( + factory: ApiFactory, + ): DevAppBuilder { + this.factories.push(factory); + return this; + } + + /** + * Build a DevApp component using the resources registered so far + */ + build(): ComponentType<{}> { + const app = createApp(); + app.registerApis(this.setupApiRegistry(this.factories)); + app.registerPlugin(...this.plugins); + const AppComponent = app.build(); + + const sidebar = this.setupSidebar(this.plugins); + + const DevApp: FC<{}> = () => { + return ( + + + + + {sidebar} + + + + + + ); + }; + + return DevApp; + } + + /** + * Build and render directory to #root element + */ + render(): void { + const DevApp = this.build(); + + const paths = this.findPluginPaths(this.plugins); + + if (window.location.pathname === '/') { + if (!paths.includes('/') && paths.length > 0) { + window.location.pathname = paths[0]; + } + } + + ReactDOM.render(, document.getElementById('root')); + } + + // Create a sidebar that exposes the touchpoints of a plugin + private setupSidebar(plugins: BackstagePlugin[]): JSX.Element { + const sidebarItems = new Array(); + + for (const plugin of plugins) { + for (const output of plugin.output()) { + switch (output.type) { + case 'route': { + const { path } = output; + sidebarItems.push( + , + ); + break; + } + default: + break; + } + } + } + + return ( + + + {sidebarItems} + + ); + } + + // Set up an API registry that merges together default implementations with ones provided through config. + private setupApiRegistry( + providedFactories: ApiFactory[], + ): ApiHolder { + const providedApis = new Set( + providedFactories.map(factory => factory.implements), + ); + + // Exlude any default API factory that we receive a factory for in the config + const defaultFactories = Object.values( + defaultApiFactories, + ).filter(factory => providedApis.has(factory.implements)); + const allFactories = [...defaultFactories, ...providedFactories]; + + // Use a test registry with dependency injection so that the consumer + // can override APIs but still depend on the default implementations. + const registry = new ApiTestRegistry(); + for (const factory of allFactories) { + registry.register(factory); + } + + return registry; + } + + private findPluginPaths(plugins: BackstagePlugin[]) { + const paths = new Array(); + + for (const plugin of plugins) { + for (const output of plugin.output()) { + if (output.type === 'route') { + paths.push(output.path); + } + } + } + + return paths; + } +} + +// TODO(rugvip): Figure out patterns for how to allow in-house apps to build upon +// this to provide their own plugin dev wrappers. + +/** + * Creates a dev app for rendering one or more plugins and exposing the touchpoints of the plugin. + */ +export function createDevApp() { + return new DevAppBuilder(); +} diff --git a/packages/dev-utils/src/index.ts b/packages/dev-utils/src/index.ts new file mode 100644 index 0000000000..c05e67ddbc --- /dev/null +++ b/packages/dev-utils/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './devApp'; diff --git a/packages/dev-utils/src/setupTests.ts b/packages/dev-utils/src/setupTests.ts new file mode 100644 index 0000000000..8925258421 --- /dev/null +++ b/packages/dev-utils/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom/extend-expect'; diff --git a/packages/dev-utils/tsconfig.json b/packages/dev-utils/tsconfig.json new file mode 100644 index 0000000000..7b73db2f0f --- /dev/null +++ b/packages/dev-utils/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } +} diff --git a/plugins/graphiql/dev/index.tsx b/plugins/graphiql/dev/index.tsx index ed987c1c6a..32d18b4f0b 100644 --- a/plugins/graphiql/dev/index.tsx +++ b/plugins/graphiql/dev/index.tsx @@ -14,56 +14,27 @@ * limitations under the License. */ -import React, { FC } from 'react'; -import ReactDOM from 'react-dom'; -import { BrowserRouter } from 'react-router-dom'; -import HomeIcon from '@material-ui/icons/Home'; -import { ThemeProvider, CssBaseline } from '@material-ui/core'; -import { - createApp, - SidebarPage, - Sidebar, - SidebarItem, - SidebarSpacer, - ApiRegistry, -} from '@backstage/core'; -import { lightTheme } from '@backstage/theme'; +import { createDevApp } from '@backstage/dev-utils'; import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src'; -const graphQlBrowseApi = GraphQLEndpoints.from([ - GraphQLEndpoints.create({ - id: 'gitlab', - title: 'GitLab', - url: 'https://gitlab.com/api/graphql', - }), - GraphQLEndpoints.create({ - id: 'countries', - title: 'Countries', - url: 'https://countries.trevorblades.com/', - }), -]); - -const app = createApp(); -app.registerApis(ApiRegistry.from([[graphQlBrowseApiRef, graphQlBrowseApi]])); -app.registerPlugin(plugin); -const AppComponent = app.build(); - -const App: FC<{}> = () => { - return ( - - - - - - - - - - - - - - ); -}; - -ReactDOM.render(, document.getElementById('root')); +createDevApp() + .registerPlugin(plugin) + .registerApiFactory({ + implements: graphQlBrowseApiRef, + deps: {}, + factory() { + return GraphQLEndpoints.from([ + GraphQLEndpoints.create({ + id: 'gitlab', + title: 'GitLab', + url: 'https://gitlab.com/api/graphql', + }), + GraphQLEndpoints.create({ + id: 'countries', + title: 'Countries', + url: 'https://countries.trevorblades.com/', + }), + ]); + }, + }) + .render(); diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index f2cf8b7930..2db84d275a 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -27,6 +27,8 @@ }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/dev-utils": "^0.1.1-alpha.4", + "@backstage/test-utils": "^0.1.1-alpha.4", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", @@ -38,7 +40,6 @@ }, "dependencies": { "@backstage/core": "^0.1.1-alpha.4", - "@backstage/test-utils": "^0.1.1-alpha.4", "@backstage/theme": "^0.1.1-alpha.4", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", diff --git a/plugins/home-page/dev/index.tsx b/plugins/home-page/dev/index.tsx index c68b8f7a51..d97643057b 100644 --- a/plugins/home-page/dev/index.tsx +++ b/plugins/home-page/dev/index.tsx @@ -14,41 +14,9 @@ * limitations under the License. */ -import React, { FC } from 'react'; -import ReactDOM from 'react-dom'; -import { BrowserRouter } from 'react-router-dom'; -import HomeIcon from '@material-ui/icons/Home'; -import { ThemeProvider, CssBaseline } from '@material-ui/core'; -import { - createApp, - SidebarPage, - Sidebar, - SidebarItem, - SidebarSpacer, -} from '@backstage/core'; -import { lightTheme } from '@backstage/theme'; +import { createDevApp } from '@backstage/dev-utils'; import { plugin } from '../src/plugin'; -const app = createApp(); -app.registerPlugin(plugin); -const AppComponent = app.build(); - -const App: FC<{}> = () => { - return ( - - - - - - - - - - - - - - ); -}; - -ReactDOM.render(, document.getElementById('root')); +createDevApp() + .registerPlugin(plugin) + .render(); diff --git a/plugins/home-page/package.json b/plugins/home-page/package.json index 6aed59a782..17c973bd35 100644 --- a/plugins/home-page/package.json +++ b/plugins/home-page/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/dev-utils": "^0.1.1-alpha.4", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2",