diff --git a/packages/app/package.json b/packages/app/package.json index c81a23ee6b..a7a370b108 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -24,6 +24,7 @@ "@backstage/plugin-inventory": "^0.1.1-alpha.4", "@backstage/plugin-lighthouse": "^0.1.1-alpha.4", "@backstage/plugin-scaffolder": "^0.1.1-alpha.4", + "@backstage/plugin-tech-radar": "^0.1.1-alpha.4", "@backstage/plugin-welcome": "^0.1.1-alpha.4", "@backstage/theme": "^0.1.1-alpha.4", "@material-ui/core": "^4.9.1", diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index bfa049acfb..a24a25e205 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -24,11 +24,18 @@ import { featureFlagsApiRef, FeatureFlags, } from '@backstage/core'; + import { lighthouseApiRef, LighthouseRestApi, } from '@backstage/plugin-lighthouse'; +import { + techRadarApiRef, + TechRadar, + loadSampleData, +} from '@backstage/plugin-tech-radar'; + const builder = ApiRegistry.builder(); export const alertApiForwarder = new AlertApiForwarder(); @@ -40,5 +47,6 @@ builder.add(errorApiRef, errorApiForwarder); builder.add(featureFlagsApiRef, new FeatureFlags()); builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); +builder.add(techRadarApiRef, new TechRadar(1800, 800, loadSampleData)); export default builder.build() as ApiHolder; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index bde2d9d029..06c5084bd0 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -19,3 +19,4 @@ export { plugin as WelcomePlugin } from '@backstage/plugin-welcome'; export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse'; export { plugin as InventoryPlugin } from '@backstage/plugin-inventory'; export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder'; +export { plugin as TechRadar } from '@backstage/plugin-tech-radar'; diff --git a/packages/cli/src/commands/testCommand.ts b/packages/cli/src/commands/testCommand.ts index 0e180f614e..4d567169dc 100644 --- a/packages/cli/src/commands/testCommand.ts +++ b/packages/cli/src/commands/testCommand.ts @@ -46,8 +46,9 @@ export default async (cmd: Command) => { // already watching !includesAnyOf(args, '--watch', '--watchAll') ) { - const isGitRepo = () => runCheck('git rev-parse --is-inside-work-tree'); - const isMercurialRepo = () => runCheck('hg --cwd . root'); + const isGitRepo = () => + runCheck('git', 'rev-parse', '--is-inside-work-tree'); + const isMercurialRepo = () => runCheck('hg', '--cwd', '.', 'root'); if ((await isGitRepo()) || (await isMercurialRepo())) { args.push('--watch'); diff --git a/packages/cli/src/lib/buildCache/cache.ts b/packages/cli/src/lib/buildCache/cache.ts index 9c73f904ab..8ba2d8851d 100644 --- a/packages/cli/src/lib/buildCache/cache.ts +++ b/packages/cli/src/lib/buildCache/cache.ts @@ -82,19 +82,22 @@ export class Cache { allInputPaths.unshift(paths.ownDir); } - const quotedInputPaths = allInputPaths.map(input => `'${input}'`); - // Make sure we don't have any uncommitted changes to the input, in that case we skip caching. const noChanges = await runCheck( - `git diff --quiet HEAD -- ${quotedInputPaths.join(' ')}`, + 'git', + 'diff', + '--quiet', + 'HEAD', + '--', + ...allInputPaths, ); if (!noChanges) { return undefined; } const trees = []; - for (const quotedInputPath of quotedInputPaths) { - const output = await runPlain(`git ls-tree HEAD ${quotedInputPath}`); + for (const inputPath of allInputPaths) { + const output = await runPlain('git', 'ls-tree', 'HEAD', inputPath); const [, , sha] = output.split(/\s+/, 3); // If we can't get a tree sha it means we're outside of tracked files, so treat as dirty if (!sha) { diff --git a/packages/cli/src/lib/buildCache/options.ts b/packages/cli/src/lib/buildCache/options.ts index 380f84fff5..5edb46e913 100644 --- a/packages/cli/src/lib/buildCache/options.ts +++ b/packages/cli/src/lib/buildCache/options.ts @@ -29,9 +29,7 @@ export type Options = { }; function transformPath(path: string): string { - return resolvePath( - path.replace(//g, paths.targetRoot).replace(/'/g, ''), - ); + return resolvePath(path.replace(//g, paths.targetRoot)); } export async function parseOptions(cmd: Command): Promise { diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts index 9eac69e673..6fff85452f 100644 --- a/packages/cli/src/lib/run.ts +++ b/packages/cli/src/lib/run.ts @@ -18,12 +18,12 @@ import { SpawnOptions, spawn, ChildProcess, - exec as execCb, + execFile as execFileCb, } from 'child_process'; import { ExitCodeError } from './errors'; import { promisify } from 'util'; import { LogFunc } from './logging'; -const exec = promisify(execCb); +const execFile = promisify(execFileCb); type SpawnOptionsPartialEnv = Omit & { env?: Partial; @@ -69,21 +69,21 @@ export async function run( await waitForExit(child, name); } -export async function runPlain(cmd: string) { +export async function runPlain(cmd: string, ...args: string[]) { try { - const { stdout } = await exec(cmd); + const { stdout } = await execFile(cmd, args, { shell: true }); return stdout.trim(); } catch (error) { if (error.stderr) { process.stderr.write(error.stderr); } - throw new ExitCodeError(error.code, cmd); + throw new ExitCodeError(error.code, [cmd, ...args].join(' ')); } } -export async function runCheck(cmd: string): Promise { +export async function runCheck(cmd: string, ...args: string[]) { try { - await exec(cmd); + await execFile(cmd, args, { shell: true }); return true; } catch (error) { return false; 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/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx index a4f485d45c..c636a74308 100644 --- a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx +++ b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx @@ -20,25 +20,30 @@ import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import { Grid, IconButton, makeStyles, Theme } from '@material-ui/core'; -// Generated with https://larsenwork.com/easing-gradients/ -const fadeGradient = ` - hsl(0, 0%, 98%) 0%, - hsla(0, 0%, 98%, 0.987) 8.1%, - hsla(0, 0%, 98%, 0.951) 15.5%, - hsla(0, 0%, 98%, 0.896) 22.5%, - hsla(0, 0%, 98%, 0.825) 29%, - hsla(0, 0%, 98%, 0.741) 35.3%, - hsla(0, 0%, 98%, 0.648) 41.2%, - hsla(0, 0%, 98%, 0.55) 47.1%, - hsla(0, 0%, 98%, 0.45) 52.9%, - hsla(0, 0%, 98%, 0.352) 58.8%, - hsla(0, 0%, 98%, 0.259) 64.7%, - hsla(0, 0%, 98%, 0.175) 71%, - hsla(0, 0%, 98%, 0.104) 77.5%, - hsla(0, 0%, 98%, 0.049) 84.5%, - hsla(0, 0%, 98%, 0.013) 91.9%, - hsla(0, 0%, 98%, 0) 100% -`; +const generateGradientStops = (themeType: 'dark' | 'light') => { + // 97% corresponds to the theme.palette.background.default for the light theme + // 16% for the dark theme + const luminance = themeType === 'dark' ? '16%' : '97%'; + // Generated with https://larsenwork.com/easing-gradients/ + return ` + hsl(0, 0%, ${luminance}) 0%, + hsla(0, 0%, ${luminance}, 0.987) 8.1%, + hsla(0, 0%, ${luminance}, 0.951) 15.5%, + hsla(0, 0%, ${luminance}, 0.896) 22.5%, + hsla(0, 0%, ${luminance}, 0.825) 29%, + hsla(0, 0%, ${luminance}, 0.741) 35.3%, + hsla(0, 0%, ${luminance}, 0.648) 41.2%, + hsla(0, 0%, ${luminance}, 0.55) 47.1%, + hsla(0, 0%, ${luminance}, 0.45) 52.9%, + hsla(0, 0%, ${luminance}, 0.352) 58.8%, + hsla(0, 0%, ${luminance}, 0.259) 64.7%, + hsla(0, 0%, ${luminance}, 0.175) 71%, + hsla(0, 0%, ${luminance}, 0.104) 77.5%, + hsla(0, 0%, ${luminance}, 0.049) 84.5%, + hsla(0, 0%, ${luminance}, 0.013) 91.9%, + hsla(0, 0%, ${luminance}, 0) 100% + `; +}; const fadeSize = 100; const fadePadding = 10; @@ -72,11 +77,15 @@ const useStyles = makeStyles(theme => ({ }, fadeLeft: { left: -fadePadding, - background: `linear-gradient(90deg, ${fadeGradient})`, + background: `linear-gradient(90deg, ${generateGradientStops( + theme.palette.type, + )})`, }, fadeRight: { right: -fadePadding, - background: `linear-gradient(270deg, ${fadeGradient})`, + background: `linear-gradient(270deg, ${generateGradientStops( + theme.palette.type, + )})`, }, fadeHidden: { opacity: 0, diff --git a/packages/core/src/layout/BottomLink/BottomLink.tsx b/packages/core/src/layout/BottomLink/BottomLink.tsx index 9250685504..88c723fd08 100644 --- a/packages/core/src/layout/BottomLink/BottomLink.tsx +++ b/packages/core/src/layout/BottomLink/BottomLink.tsx @@ -24,17 +24,20 @@ import { makeStyles, } from '@material-ui/core'; import ArrowIcon from '@material-ui/icons/ArrowForward'; -import grey from '@material-ui/core/colors/grey'; +import { BackstageTheme } from '@backstage/theme'; import Box from '@material-ui/core/Box'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ root: { maxWidth: 'fit-content', padding: theme.spacing(2, 2, 2, 2.5), }, boxTitle: { margin: 0, - color: grey[900], + color: theme.palette.textSubtle, + }, + arrow: { + color: theme.palette.textSubtle, }, })); @@ -50,7 +53,7 @@ const BottomLink: FC = ({ link, title, onClick }) => { return (
- + @@ -58,7 +61,7 @@ const BottomLink: FC = ({ link, title, onClick }) => { - + 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/packages/storybook/.storybook/config.js b/packages/storybook/.storybook/config.js index 050fac71ea..fab204fd9f 100644 --- a/packages/storybook/.storybook/config.js +++ b/packages/storybook/.storybook/config.js @@ -3,9 +3,12 @@ import { addDecorator } from '@storybook/react'; import { lightTheme, darkTheme } from '@backstage/theme'; import { CssBaseline, ThemeProvider } from '@material-ui/core'; import { useDarkMode } from 'storybook-dark-mode'; +import { Content } from '@backstage/core'; addDecorator(story => ( - {story()} + + {story()} + )); 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", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 6852d67754..01e3e5c81b 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -31,8 +31,8 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.1.2", "react-markdown": "^4.3.1", + "react-router-dom": "^5.1.2", "react-use": "^13.24.0" }, "files": [ diff --git a/plugins/lighthouse/src/components/Audit/AuditRow.tsx b/plugins/lighthouse/src/components/Audit/AuditRow.tsx new file mode 100644 index 0000000000..c42e6a8538 --- /dev/null +++ b/plugins/lighthouse/src/components/Audit/AuditRow.tsx @@ -0,0 +1,86 @@ +/* + * 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 } from 'react'; +import { + Link, + TableCell, + TableRow, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { TrendLine } from '@backstage/core'; +import { + Website, +} from '../../api'; +import { formatTime, CATEGORIES, CATEGORY_LABELS, SparklinesDataByCategory } from '../../utils'; +import AuditStatusIcon from '../AuditStatusIcon'; + +const useStyles = makeStyles(theme => ({ + table: { + minWidth: 650, + }, + status: { + textTransform: 'capitalize', + }, + link: { + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + display: 'inline-block', + }, + statusCell: { whiteSpace: 'nowrap' }, + sparklinesCell: { minWidth: 120 }, +})); + +export const AuditRow: FC<{ + website: Website; + categorySparkline: SparklinesDataByCategory; +}> = ({ website, categorySparkline }) => { + const classes = useStyles(); + + return ( + + + + {website.url} + + + {CATEGORIES.map(category => ( + + + + ))} + + {' '} + + {website.lastAudit.status.toLowerCase()} + + + + {formatTime(website.lastAudit.timeCreated)} + + + ); +}; + +export default AuditRow; diff --git a/plugins/lighthouse/src/components/Audit/index.tsx b/plugins/lighthouse/src/components/Audit/index.tsx new file mode 100644 index 0000000000..349bdf5ee9 --- /dev/null +++ b/plugins/lighthouse/src/components/Audit/index.tsx @@ -0,0 +1,54 @@ +/* + * 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, useState } from 'react'; +import { useInterval } from 'react-use'; +import { Website, lighthouseApiRef } from '../../api'; +import { useApi } from '@backstage/core'; +import { + SparklinesDataByCategory, + buildSparklinesDataForItem, +} from '../../utils'; +import { AuditRow } from './AuditRow'; +export const LIMIT = 10; + +export const Audit: FC<{ + website: Website; + categorySparkline: SparklinesDataByCategory; +}> = ({ website, categorySparkline }) => { + const lighthouseApi = useApi(lighthouseApiRef); + const [websiteState, setWebsiteState] = useState(website); + const [sparklineState, setSparklineState] = useState(categorySparkline); + + const runRefresh = async () => { + const response = await lighthouseApi.getWebsiteForAuditId( + websiteState.lastAudit.id, + ); + const auditStatus = response.lastAudit.status; + if (auditStatus === 'COMPLETED' || auditStatus === 'FAILED') { + setSparklineState(buildSparklinesDataForItem(response)); + setWebsiteState(response); + } + }; + + useInterval( + runRefresh, + websiteState?.lastAudit.status === 'RUNNING' ? 5000 : null, + ); + + return ; +}; + +export default Audit; diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx index 3b82b27645..3ab07434d7 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.test.tsx @@ -17,19 +17,41 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInThemedTestApp } from '@backstage/test-utils'; +import { ApiRegistry, ApiProvider } from '@backstage/core'; import AuditListTable from './AuditListTable'; -import { WebsiteListResponse } from '../../api'; +import { + WebsiteListResponse, + lighthouseApiRef, + LighthouseRestApi, +} from '../../api'; import { formatTime } from '../../utils'; +import mockFetch from 'jest-fetch-mock'; import * as data from '../../__fixtures__/website-list-response.json'; const websiteListResponse = data as WebsiteListResponse; describe('AuditListTable', () => { + let apis: ApiRegistry; + + beforeEach(() => { + apis = ApiRegistry.from([ + [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], + ]); + mockFetch.mockResponse(JSON.stringify(websiteListResponse)); + }); + + const auditList = (websiteList: WebsiteListResponse) => { + return ( + + + + ); + }; it('renders the link to each website', () => { const rendered = render( - wrapInThemedTestApp(), + wrapInThemedTestApp(auditList(websiteListResponse)), ); const link = rendered.queryByText('https://anchor.fm'); const website = websiteListResponse.items.find( @@ -46,7 +68,7 @@ describe('AuditListTable', () => { it('renders the dates that are available for a given row', () => { const rendered = render( - wrapInThemedTestApp(), + wrapInThemedTestApp(auditList(websiteListResponse)), ); const website = websiteListResponse.items.find( w => w.url === 'https://anchor.fm', @@ -60,7 +82,7 @@ describe('AuditListTable', () => { it('renders the status for a given row', async () => { const rendered = render( - wrapInThemedTestApp(), + wrapInThemedTestApp(auditList(websiteListResponse)), ); const completed = await rendered.findAllByText('completed'); @@ -85,9 +107,7 @@ describe('AuditListTable', () => { describe('sparklines', () => { it('correctly maps the data from the website payload', () => { const rendered = render( - wrapInThemedTestApp( - , - ), + wrapInThemedTestApp(auditList(websiteListResponse)), ); const backstageSEO = rendered.getByTitle( 'trendline for SEO category of https://backstage.io', @@ -97,9 +117,7 @@ describe('AuditListTable', () => { it('does not break when no data is available', () => { const rendered = render( - wrapInThemedTestApp( - , - ), + wrapInThemedTestApp(auditList(websiteListResponse)), ); const anchorSEO = rendered.queryByTitle( 'trendline for SEO category of https://anchor.fm', diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index ba0e79f066..778eb71fb5 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -15,7 +15,6 @@ */ import React, { FC, useMemo } from 'react'; import { - Link, Table, TableBody, TableCell, @@ -24,31 +23,11 @@ import { TableRow, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import { TrendLine } from '@backstage/core'; - import { - Audit, - AuditCompleted, - LighthouseCategoryId, Website, } from '../../api'; -import { formatTime } from '../../utils'; -import AuditStatusIcon from '../AuditStatusIcon'; - -export const CATEGORIES: LighthouseCategoryId[] = [ - 'accessibility', - 'performance', - 'seo', - 'best-practices', -]; - -export const CATEGORY_LABELS: Record = { - accessibility: 'Accessibility', - performance: 'Performance', - seo: 'SEO', - 'best-practices': 'Best Practices', - pwa: 'Progressive Web App', -}; +import { CATEGORIES, CATEGORY_LABELS, SparklinesDataByCategory, buildSparklinesDataForItem } from '../../utils'; +import Audit from '../Audit' const useStyles = makeStyles(theme => ({ table: { @@ -66,34 +45,13 @@ const useStyles = makeStyles(theme => ({ sparklinesCell: { minWidth: 120 }, })); -type SparklinesDataByCategory = Record; -function buildSparklinesDataForItem(item: Website): SparklinesDataByCategory { - return item.audits - .filter( - (audit: Audit): audit is AuditCompleted => audit.status === 'COMPLETED', - ) - .reduce((scores, audit) => { - Object.values(audit.categories).forEach(category => { - scores[category.id] = scores[category.id] || []; - scores[category.id].unshift(category.score); - }); - - // edge case: if only one audit exists, force a "flat" sparkline - Object.values(scores).forEach(arr => { - if (arr.length === 1) arr.push(arr[0]); - }); - - return scores; - }, {} as SparklinesDataByCategory); -} - export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { const classes = useStyles(); const categorySparklines: Record = useMemo( () => items.reduce( (res, item) => ({ - ...res, + ...res, [item.url]: buildSparklinesDataForItem(item), }), {}, @@ -118,34 +76,11 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { {items.map(website => ( - - - - {website.url} - - - {CATEGORIES.map(category => ( - - - - ))} - - {' '} - - {website.lastAudit.status.toLowerCase()} - - - {formatTime(website.lastAudit.timeCreated)} - + ))} diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index 4dbbb05295..85be31cc80 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -63,7 +63,7 @@ const AuditList: FC<{}> = () => { if (value?.total && value?.limit) return Math.ceil(value?.total / value?.limit); return 0; - }, [value?.total, value?.limit]); + }, [value]); const history = useHistory(); diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index 7e74661319..8ff0ce5ea8 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -84,7 +84,7 @@ const CreateAudit: FC<{}> = () => { } finally { setSubmitting(false); } - }, [url, emulatedFormFactor, lighthouseApi, setSubmitting]); + }, [url, emulatedFormFactor, lighthouseApi, setSubmitting, errorApi, history]); return ( diff --git a/plugins/lighthouse/src/utils.ts b/plugins/lighthouse/src/utils.ts index 48cc67a88b..1546521083 100644 --- a/plugins/lighthouse/src/utils.ts +++ b/plugins/lighthouse/src/utils.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { useLocation } from 'react-router-dom'; - +import {Website, Audit, LighthouseCategoryId, AuditCompleted} from './api' export function useQuery(): URLSearchParams { return new URLSearchParams(useLocation().search); } @@ -28,3 +28,39 @@ export function formatTime(timestamp: string | Date) { } return date.toUTCString(); } + +export const CATEGORIES: LighthouseCategoryId[] = [ + 'accessibility', + 'performance', + 'seo', + 'best-practices', +]; + +export const CATEGORY_LABELS: Record = { + accessibility: 'Accessibility', + performance: 'Performance', + seo: 'SEO', + 'best-practices': 'Best Practices', + pwa: 'Progressive Web App', +}; + +export type SparklinesDataByCategory = Record; +export function buildSparklinesDataForItem(item: Website): SparklinesDataByCategory { + return item.audits + .filter( + (audit: Audit): audit is AuditCompleted => audit.status === 'COMPLETED', + ) + .reduce((scores, audit) => { + Object.values(audit.categories).forEach(category => { + scores[category.id] = scores[category.id] || []; + scores[category.id].unshift(category.score); + }); + + // edge case: if only one audit exists, force a "flat" sparkline + Object.values(scores).forEach(arr => { + if (arr.length === 1) arr.push(arr[0]); + }); + + return scores; + }, {} as SparklinesDataByCategory); +} \ No newline at end of file diff --git a/plugins/tech-radar/.eslintrc.js b/plugins/tech-radar/.eslintrc.js new file mode 100644 index 0000000000..dd47f29781 --- /dev/null +++ b/plugins/tech-radar/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.js')], +}; diff --git a/plugins/tech-radar/README.md b/plugins/tech-radar/README.md new file mode 100644 index 0000000000..d48cd969a8 --- /dev/null +++ b/plugins/tech-radar/README.md @@ -0,0 +1,148 @@ +# @backstage/plugin-tech-radar + +Screenshot of Tech Radar plugin + +The Backstage integration for the Tech Radar based on [Zalando's Tech Radar](https://opensource.zalando.com/tech-radar/) open sourced on [GitHub](https://github.com/zalando/tech-radar). This is used at [Spotify](https://spotify.github.io) for visualizing the official guidelines of different areas of software development such as languages, frameworks, infrastructure and processes. + +## Purpose + +Zalando explains it very well on their website: + +> The Tech Radar is a tool to inspire and support engineering teams at Zalando to pick the best technologies for new projects; it provides a platform to share knowledge and experience in technologies, to reflect on technology decisions and continuously evolve our technology landscape. Based on the pioneering work of ThoughtWorks, our Tech Radar sets out the changes in technologies that are interesting in software development — changes that we think our engineering teams should pay attention to and consider using in their projects. + +It serves well for teams and companies of all sizes that want to have alignment and wish to visualize it. It scales well for companies who have dozens of different technologies in place. + +## Getting Started + +In your installation, add the dependency to your Backstage installation: + +```sh +yarn add @backstage/plugin-tech-radar +``` + +In your `apis.ts` set up the "out of the box" implementation for Tech Radar: + +```ts +import { ApiHolder, ApiRegistry } from '@backstage/core'; +import { + techRadarApiRef, + TechRadar, + loadSampleData, +} from '@backstage/plugin-tech-radar'; + +const builder = ApiRegistry.builder(); + +builder.add(techRadarApiRef, new TechRadar(1400, 800, loadSampleData)); + +export default builder.build() as ApiHolder; +``` + +It will then be available on your Backstage installation over at + +## Configuration + +The implementation for the TechRadar class is: + +```ts +export interface TechRadarAdditionalOptions { + title?: string; + subtitle?: string; + svgProps?: object; +} + +export interface TechRadarLoaderResponse { + quadrants: RadarQuadrant[]; + rings: RadarRing[]; + entries: RadarEntry[]; +} + +export interface TechRadarApi { + width: number; + height: number; + load: () => Promise; + additionalOpts: TechRadarAdditionalOptions; +} + +// Constructor signature for the `TechRadar` class +// constructor( +// public width: number, +// public height: number, +// public load: () => Promise, +// public additionalOpts: TechRadarAdditionalOptions = {}, +// ) +``` + +The source code is available in [api.ts](src/api.ts). + +## Code Samples + +### Set up Tech Radar with sample data + +See example above. + +### Set up Tech Radar with hard-coded values + +```ts +const hardCodedData = () => + Promise.resolve({ + quadrants: [{ id: 'infrastructure', name: 'Infrastructure' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + moved: 0, + ring: 'use', + url: '#', + key: 'github-actions', + id: 'github-actions', + title: 'GitHub Actions', + quadrant: 'infrastructure', + }, + ], + }); + +builder.add(techRadarApiRef, new TechRadar(1400, 800, hardCodedData)); +``` + +### Set up Tech Radar with an API call + +```ts +const apiRetrievedData = async () => { + const response = await fetch('http://example.com/tech-radar-values.json'); + const json = await response.json(); + return json as TechRadarLoaderResponse; +}; + +builder.add(techRadarApiRef, new TechRadar(1400, 800, apiRetrievedData)); +``` + +### Use a custom title and subtitle + +```ts +builder.add( + techRadarApiRef, + new TechRadar(1400, 800, loadSampleData, { + title: 'My Company Tech Radar', + subtitle: 'Learn about what technologies we use at My Company.', + }), +); +``` + +### Use custom props + +Great for testing through adding a `data-testid` for being able to test with `@testing-library/react` + +```ts +builder.add( + techRadarApiRef, + new TechRadar(1400, 800, loadSampleData, { + svgProps: { + // for the main tag of the visualization + 'data-testid': 'tech-radar-svg', + }, + }), +); + +// Then, in your tests... +// const { getByTestId } = render(...); +// expect(getByTestId('tech-radar-svg')).toBeInTheDocument(); +``` diff --git a/plugins/tech-radar/docs/screenshot.png b/plugins/tech-radar/docs/screenshot.png new file mode 100644 index 0000000000..657268fc09 Binary files /dev/null and b/plugins/tech-radar/docs/screenshot.png differ diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json new file mode 100644 index 0000000000..dd1747b02d --- /dev/null +++ b/plugins/tech-radar/package.json @@ -0,0 +1,40 @@ +{ + "name": "@backstage/plugin-tech-radar", + "version": "0.1.1-alpha.4", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts", + "license": "Apache-2.0", + "private": true, + "scripts": { + "build": "backstage-cli plugin:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "clean": "backstage-cli clean" + }, + "devDependencies": { + "@backstage/cli": "^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", + "@types/color": "^3.0.1", + "@types/d3-force": "^1.2.1", + "@types/jest": "^24.0.0", + "@types/node": "^12.0.0", + "@types/testing-library__jest-dom": "5.0.2", + "jest-fetch-mock": "^3.0.3" + }, + "dependencies": { + "@backstage/core": "^0.1.1-alpha.4", + "@backstage/test-utils-core": "^0.1.1-alpha.4", + "@backstage/theme": "^0.1.1-alpha.4", + "@material-ui/core": "^4.9.1", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "color": "^3.1.2", + "d3-force": "^2.0.1", + "prop-types": "^15.7.2", + "react": "16.13.1", + "react-dom": "16.13.1", + "react-use": "^13.0.0" + } +} diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts new file mode 100644 index 0000000000..78f07bfd4b --- /dev/null +++ b/plugins/tech-radar/src/api.ts @@ -0,0 +1,78 @@ +/* + * 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 { ApiRef } from '@backstage/core'; + +export interface RadarRing { + id: string; + name: string; + color: string; +} + +export interface RadarQuadrant { + id: string; + name: string; +} + +export interface RadarEntry { + key: string; // react key + id: string; + moved: number; + quadrant: string; + ring: string; + title: string; + url: string; +} + +export interface TechRadarLoaderResponse { + quadrants: RadarQuadrant[]; + rings: RadarRing[]; + entries: RadarEntry[]; +} + +export interface TechRadarAdditionalOptions { + title?: string; + subtitle?: string; + svgProps?: object; +} + +export interface TechRadarApi { + width: number; + height: number; + load: () => Promise; + additionalOpts: TechRadarAdditionalOptions; +} + +export const techRadarApiRef = new ApiRef({ + id: 'plugin.techradar', + description: 'Used by the Tech Radar to render the diagram', +}); + +export class TechRadar implements TechRadarApi { + private defaultAdditionalOpts: Partial = { + title: 'Tech Radar', + subtitle: 'Welcome to the Tech Radar!', + }; + + constructor( + public width: number, + public height: number, + public load: () => Promise, + public additionalOpts: TechRadarAdditionalOptions = {}, + ) { + this.additionalOpts = { ...this.defaultAdditionalOpts, ...additionalOpts }; + } +} diff --git a/plugins/tech-radar/src/components/Radar/Radar.js b/plugins/tech-radar/src/components/Radar/Radar.js new file mode 100644 index 0000000000..e353e44589 --- /dev/null +++ b/plugins/tech-radar/src/components/Radar/Radar.js @@ -0,0 +1,231 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { forceCollide, forceSimulation } from 'd3-force'; +import RadarPlot from '../RadarPlot'; +import Segment from '../../utils/segment'; +import color from 'color'; + +export default class Radar extends React.Component { + static adjustQuadrants(quadrants, radius, width, height) { + /* + 0 1 2 3 ← x stops index + │ │ │ │ ↓ y stops index + ┼───────────┼─────────────────────────────┼───────────┼─0 + │ │ . -- ~~~ -- . │ │ + │ │ .-~ ~-. │ │ + │ │ / \ │ │ + │ │ / \ │ │ + ┼───────────┼─────────────────────────────┼───────────┼─1 + ┼───────────┼─────────────────────────────┼───────────┼─2 + │ │ | | │ │ + │ │ \ / │ │ + │ │ \ / │ │ + │ │ `-. .-' │ │ + │ │ ~- . ___ . -~ │ │ + ┼───────────┼─────────────────────────────┼───────────┼─3 + */ + const margin = 16; + const xStops = [ + margin, + width / 2 - radius - margin, + width / 2 + radius + margin, + width - margin, + ]; + const yStops = [margin, height / 2 - margin, height / 2, height - margin]; + + // The quadrant parameters correspond to Q[0..3] above. They are in this order because of the + // original Zalando code; maybe we should refactor them to be in reverse order? + const legendParams = [ + { + x: xStops[2], + y: yStops[2], + width: xStops[3] - xStops[2], + height: yStops[3] - yStops[2], + }, + { + x: xStops[0], + y: yStops[2], + width: xStops[1] - xStops[0], + height: yStops[3] - yStops[2], + }, + { + x: xStops[0], + y: yStops[0], + width: xStops[1] - xStops[0], + height: yStops[1] - yStops[0], + }, + { + x: xStops[2], + y: yStops[0], + width: xStops[3] - xStops[2], + height: yStops[1] - yStops[0], + }, + ]; + + quadrants.forEach((quadrant, idx) => { + const legendParam = legendParams[idx % 4]; + + quadrant.idx = idx; + quadrant.radialMin = (idx * Math.PI) / 2; + quadrant.radialMax = ((idx + 1) * Math.PI) / 2; + quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1; + quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1; + quadrant.legendX = legendParam.x; + quadrant.legendY = legendParam.y; + quadrant.legendWidth = legendParam.width; + quadrant.legendHeight = legendParam.height; + }); + } + + static adjustRings(rings, radius) { + rings.forEach((ring, idx) => { + ring.idx = idx; + ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius; + ring.innerRadius = + ((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius; + }); + } + + static adjustEntries(entries, activeEntry, quadrants, rings, radius) { + let seed = 42; + entries.forEach((entry, idx) => { + const quadrant = quadrants.find(q => { + const match = + typeof entry.quadrant === 'object' + ? entry.quadrant.id + : entry.quadrant; + return q.id === match; + }); + const ring = rings.find(r => { + const match = + typeof entry.ring === 'object' ? entry.ring.id : entry.ring; + return r.id === match; + }); + + if (!quadrant) { + throw new Error( + `Unknown quadrant ${entry.quadrant} for entry ${entry.id}!`, + ); + } + if (!ring) { + throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`); + } + + entry.idx = idx; + entry.quadrant = quadrant; + entry.ring = ring; + entry.segment = new Segment(quadrant, ring, radius, () => seed++); + const point = entry.segment.random(); + entry.x = point.x; + entry.y = point.y; + entry.active = activeEntry ? entry.id === activeEntry.id : false; + entry.color = entry.active + ? entry.ring.color + : color(entry.ring.color) + .desaturate(0.5) + .lighten(0.1) + .string(); + }); + + const simulation = forceSimulation() + .nodes(entries) + .velocityDecay(0.19) + .force( + 'collision', + forceCollide() + .radius(12) + .strength(0.85), + ) + .stop(); + + for ( + let i = 0, + n = Math.ceil( + Math.log(simulation.alphaMin()) / + Math.log(1 - simulation.alphaDecay()), + ); + i < n; + ++i + ) { + simulation.tick(); + + for (const entry of entries) { + entry.x = entry.segment.clipx(entry); + entry.y = entry.segment.clipy(entry); + } + } + } + + constructor(props) { + super(props); + this.state = { activeEntry: null }; + } + + _setActiveEntry(entry) { + this.setState({ activeEntry: entry }); + } + + _clearActiveEntry() { + this.setState({ activeEntry: null }); + } + + render() { + // TODO(dflemstr): most of this method can be heavily memoized if performance becomes a problem + + const { width, height, quadrants, rings, entries } = this.props; + const { activeEntry } = this.state; + const radius = Math.min(width, height) / 2; + + Radar.adjustQuadrants(quadrants, radius, width, height); + Radar.adjustRings(rings, radius); + Radar.adjustEntries(entries, activeEntry, quadrants, rings, radius); + + return ( + { + this.node = node; + }} + width={width} + height={height} + {...this.props.svgProps} + > + this._setActiveEntry(entry)} + onEntryMouseLeave={() => this._clearActiveEntry()} + /> + + ); + } +} + +Radar.propTypes = { + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + quadrants: PropTypes.arrayOf(PropTypes.object).isRequired, + rings: PropTypes.arrayOf(PropTypes.object).isRequired, + entries: PropTypes.arrayOf(PropTypes.object).isRequired, + svgProps: PropTypes.object, +}; diff --git a/plugins/tech-radar/src/components/Radar/Radar.test.tsx b/plugins/tech-radar/src/components/Radar/Radar.test.tsx new file mode 100644 index 0000000000..f68002ade2 --- /dev/null +++ b/plugins/tech-radar/src/components/Radar/Radar.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import Radar from './Radar'; + +const minProps = { + width: 500, + height: 200, + quadrants: [{ id: 'languages', name: 'Languages' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + id: 'typescript', + title: 'TypeScript', + quadrant: 'languages', + moved: 0, + ring: 'use', + url: '#', + }, + ], +}; + +describe('Radar', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + , + ); + + const svg = rendered.container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg!.getAttribute('width')).toEqual('500'); + expect(svg!.getAttribute('height')).toEqual('200'); + }); +}); diff --git a/plugins/tech-radar/src/components/Radar/index.ts b/plugins/tech-radar/src/components/Radar/index.ts new file mode 100644 index 0000000000..0d48a54c71 --- /dev/null +++ b/plugins/tech-radar/src/components/Radar/index.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. + */ + +export { default } from './Radar'; diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.js b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.js new file mode 100644 index 0000000000..072f80ca20 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.js @@ -0,0 +1,124 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core'; + +const styles = { + bubble: { + pointerEvents: 'none', + userSelect: 'none', + opacity: 0, + }, + visibleBubble: { + pointerEvents: 'none', + userSelect: 'none', + opacity: 0.8, + }, + background: { + fill: '#333', + }, + text: { + pointerEvents: 'none', + userSelect: 'none', + fontSize: '10px', + fill: '#fff', + }, +}; + +class RadarBubble extends React.PureComponent { + componentDidMount() { + this._updatePosition(); + } + + componentDidUpdate() { + this._updatePosition(); + } + + _setRect = rect => { + this.rect = rect; + }; + _setNode = node => { + this.node = node; + }; + _setText = text => { + this.text = text; + }; + _setPath = path => { + this.path = path; + }; + + _updatePosition() { + // We can't do this in render() because we need to measure how big the text is to draw the bubble around it + // this.text will not be set during testing because there is no real DOM + if (this.text) { + const { x, y } = this.props; + const bbox = this.text.getBBox(); + const marginX = 5; + const marginY = 4; + this.node.setAttribute( + 'transform', + `translate(${x - bbox.width / 2}, ${y - bbox.height - marginY})`, + ); + this.rect.setAttribute('x', -marginX); + this.rect.setAttribute('y', -bbox.height); + this.rect.setAttribute('width', bbox.width + 2 * marginX); + this.rect.setAttribute('height', bbox.height + marginY); + this.path.setAttribute( + 'transform', + `translate(${bbox.width / 2 - marginX}, ${marginY - 1})`, + ); + } + } + + render() { + const { visible, text, classes } = this.props; + return ( + + + + {text} + + + + ); + } +} + +RadarBubble.propTypes = { + visible: PropTypes.bool.isRequired, + text: PropTypes.string.isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(RadarBubble); diff --git a/plugins/tech-radar/src/components/RadarBubble/index.ts b/plugins/tech-radar/src/components/RadarBubble/index.ts new file mode 100644 index 0000000000..840f4a3d21 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/index.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. + */ + +export { default } from './RadarBubble'; diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.js b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.js new file mode 100644 index 0000000000..a6dc9e1fa0 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.js @@ -0,0 +1,98 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core'; + +const styles = { + text: { + pointerEvents: 'none', + userSelect: 'none', + fontSize: '9px', + fill: '#fff', + textAnchor: 'middle', + }, + + link: { + cursor: 'pointer', + }, +}; + +class RadarEntry extends React.PureComponent { + render() { + const { + moved, + color, + url, + number, + x, + y, + onMouseEnter, + onMouseLeave, + onClick, + classes, + } = this.props; + + const style = { fill: color }; + + let blip; + if (moved > 0) { + blip = ; // triangle pointing up + } else if (moved < 0) { + blip = ; // triangle pointing down + } else { + blip = ; + } + + if (url) { + blip = ( + + {blip} + + ); + } + + return ( + + {blip} + + {number} + + + ); + } +} + +RadarEntry.propTypes = { + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + number: PropTypes.number.isRequired, + color: PropTypes.string.isRequired, + url: PropTypes.string, + moved: PropTypes.number, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + onClick: PropTypes.func, + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(RadarEntry); diff --git a/plugins/tech-radar/src/components/RadarEntry/index.ts b/plugins/tech-radar/src/components/RadarEntry/index.ts new file mode 100644 index 0000000000..661589f3de --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/index.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. + */ + +export { default } from './RadarEntry'; diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.js b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.js new file mode 100644 index 0000000000..c30079e5bf --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.js @@ -0,0 +1,52 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core'; + +const styles = { + text: { + pointerEvents: 'none', + userSelect: 'none', + fontSize: '10px', + fill: '#000', + }, +}; + +class RadarFooter extends React.PureComponent { + render() { + const { x, y, classes } = this.props; + + return ( + + {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} + + ); + } +} + +RadarFooter.propTypes = { + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(RadarFooter); diff --git a/plugins/tech-radar/src/components/RadarFooter/index.ts b/plugins/tech-radar/src/components/RadarFooter/index.ts new file mode 100644 index 0000000000..6f81404a47 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/index.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. + */ + +export { default } from './RadarFooter'; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.js b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.js new file mode 100644 index 0000000000..ce6c046242 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.js @@ -0,0 +1,99 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core'; +import * as CommonPropTypes from '../../utils/prop-types'; + +const styles = { + ring: { + fill: 'none', + stroke: '#bbb', + strokeWidth: '1px', + }, + axis: { + fill: 'none', + stroke: '#bbb', + strokeWidth: '1px', + }, + text: { + pointerEvents: 'none', + userSelect: 'none', + fill: '#e5e5e5', + fontSize: '25px', + fontWeight: 800, + }, +}; + +// A component for the background grid of the radar, with axes, rings etc. It will render around the origin, i.e. +// assume that (0, 0) is in the middle of the drawing. +class RadarGrid extends React.PureComponent { + render() { + const { radius, rings, classes } = this.props; + + const makeRingNode = (ringRadius, ringIndex) => [ + , + + {rings[ringIndex].name} + , + ]; + + const axisNodes = [ + // X axis + , + // Y axis + , + ]; + + const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode); + + return axisNodes.concat(ringNodes); + } +} + +RadarGrid.propTypes = { + radius: PropTypes.number.isRequired, + rings: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.RING)).isRequired, + classes: PropTypes.object.isRequired, // this is the withStyles HOC +}; + +export default withStyles(styles)(RadarGrid); diff --git a/plugins/tech-radar/src/components/RadarGrid/index.ts b/plugins/tech-radar/src/components/RadarGrid/index.ts new file mode 100644 index 0000000000..5bd8afa782 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/index.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. + */ + +export { default } from './RadarGrid'; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.js b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.js new file mode 100644 index 0000000000..674dd34cbb --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.js @@ -0,0 +1,203 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import { withStyles } from '@material-ui/core'; +import * as CommonPropTypes from '../../utils/prop-types'; + +const styles = { + quadrant: { + height: '100%', + width: '100%', + overflow: 'hidden', + pointerEvents: 'none', + }, + quadrantHeading: { + pointerEvents: 'none', + userSelect: 'none', + marginTop: 0, + marginBottom: 'calc(18px * 0.375)', + fontSize: '18px', + }, + rings: { + columns: 3, + }, + ring: { + breakInside: 'avoid-column', + pageBreakInside: 'avoid', + '-webkit-column-break-inside': 'avoid', + fontSize: '12px', + }, + ringHeading: { + pointerEvents: 'none', + userSelect: 'none', + marginTop: 0, + marginBottom: 'calc(12px * 0.375)', + fontSize: '12px', + fontWeight: 800, + }, + ringList: { + listStylePosition: 'inside', + marginTop: 0, + paddingLeft: 0, + fontVariantNumeric: 'proportional-nums', + '-moz-font-feature-settings': 'pnum', + '-webkit-font-feature-settings': 'pnum', + 'font-feature-settings': 'pnum', + }, + entry: { + pointerEvents: 'none', + userSelect: 'none', + fontSize: '11px', + }, + entryLink: { + pointerEvents: 'none', + }, +}; + +class RadarLegend extends React.PureComponent { + static _renderQuadrant( + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + classes, + ) { + return ( + +
+

{quadrant.name}

+
+ {rings.map(ring => + RadarLegend._renderRing( + ring, + RadarLegend._getSegment(segments, quadrant, ring), + onEntryMouseEnter, + onEntryMouseLeave, + classes, + ), + )} +
+
+
+ ); + } + + static _renderRing( + ring, + entries, + onEntryMouseEnter, + onEntryMouseLeave, + classes, + ) { + return ( +
+

{ring.name}

+ {entries.length === 0 ? ( +

(empty)

+ ) : ( +
    + {entries.map(entry => { + let node = {entry.title}; + + if (entry.url) { + node = ( + + {node} + + ); + } + + return ( +
  1. onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseEnter && (() => onEntryMouseLeave(entry)) + } + > + {node} +
  2. + ); + })} +
+ )} +
+ ); + } + + static _getSegment(segmented, quadrant, ring, ringOffset = 0) { + return (segmented[quadrant.idx] || {})[ring.idx + ringOffset] || []; + } + + render() { + const { + quadrants, + rings, + entries, + onEntryMouseEnter, + onEntryMouseLeave, + classes, + } = this.props; + + const segments = {}; + + for (const entry of entries) { + const qidx = entry.quadrant.idx; + const ridx = entry.ring.idx; + const quadrantData = segments[qidx] || (segments[qidx] = {}); + const ringData = quadrantData[ridx] || (quadrantData[ridx] = []); + ringData.push(entry); + } + + return ( + + {quadrants.map(quadrant => + RadarLegend._renderQuadrant( + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + classes, + ), + )} + + ); + } +} + +RadarLegend.propTypes = { + quadrants: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.QUADRANT)) + .isRequired, + rings: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.RING)).isRequired, + entries: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.ENTRY)).isRequired, + onEntryMouseEnter: PropTypes.func, + onEntryMouseLeave: PropTypes.func, + classes: PropTypes.object.isRequired, +}; + +export default withStyles(styles)(RadarLegend); diff --git a/plugins/tech-radar/src/components/RadarLegend/index.ts b/plugins/tech-radar/src/components/RadarLegend/index.ts new file mode 100644 index 0000000000..7214fc7929 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/index.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. + */ + +export { default } from './RadarLegend'; diff --git a/plugins/tech-radar/src/components/RadarPage.test.tsx b/plugins/tech-radar/src/components/RadarPage.test.tsx new file mode 100644 index 0000000000..9913895949 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPage.test.tsx @@ -0,0 +1,155 @@ +/* + * 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 from 'react'; +import { render, waitForElement } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core'; +import { withLogCollector } from '@backstage/test-utils-core'; + +import GetBBoxPolyfill from '../utils/polyfills/getBBox'; +import { techRadarApiRef, TechRadar, loadSampleData } from '../index'; +import RadarPage from './RadarPage'; + +describe('RadarPage', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render a progress bar', async () => { + const errorApi = { post: () => {} }; + const techRadarApi = new TechRadar(1200, 800, loadSampleData, { + svgProps: { 'data-testid': 'tech-radar-svg' }, + }); + + const { getByTestId, queryByTestId } = render( + + + + + , + ); + + expect(getByTestId('progress')).toBeInTheDocument(); + + await waitForElement(() => queryByTestId('tech-radar-svg')); + }); + + it('should render a header with a svg', async () => { + const errorApi = { post: () => {} }; + const techRadarApi = new TechRadar(1200, 800, loadSampleData, { + svgProps: { 'data-testid': 'tech-radar-svg' }, + }); + + const { getByText, getByTestId } = render( + + + + + , + ); + + await waitForElement(() => getByTestId('tech-radar-svg')); + + expect(getByText('Welcome to the Tech Radar!')).toBeInTheDocument(); + expect(getByTestId('tech-radar-svg')).toBeInTheDocument(); + }); + + it('should call the errorApi if load fails', async () => { + const errorApi = { post: jest.fn() }; + const techRadarLoadFail = () => + Promise.reject(new Error('404 Page Not Found')); + const techRadarApi = new TechRadar(1200, 800, techRadarLoadFail, { + svgProps: { 'data-testid': 'tech-radar-svg' }, + }); + + const { queryByTestId } = render( + + + + + , + ); + + await waitForElement(() => !queryByTestId('progress')); + + expect(errorApi.post).toHaveBeenCalledTimes(1); + expect(errorApi.post).toHaveBeenCalledWith(new Error('404 Page Not Found')); + expect(queryByTestId('tech-radar-svg')).not.toBeInTheDocument(); + }); + + it('should not render without errorApiRef', () => { + const techRadarApi = new TechRadar(1200, 800, loadSampleData); + + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + + + , + ); + }).toThrow(); + }).error[0], + ).toMatch( + /^Error: Uncaught \[Error: No implementation available for apiRef{core.error}\]/, + ); + }); + + it('should not render without techRadarApiRef', () => { + const errorApi = { post: () => {} }; + + expect( + withLogCollector(['error'], () => { + expect(() => { + render( + + + + + , + ); + }).toThrow(); + }).error[0], + ).toMatch( + /^Error: Uncaught \[Error: No implementation available for apiRef{plugin.techradar}\]/, + ); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx new file mode 100644 index 0000000000..0ea3d8743a --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -0,0 +1,99 @@ +/* + * 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, { useEffect, useState, FC } from 'react'; +import { Grid } from '@material-ui/core'; +import { + Progress, + Page, + Header, + Content, + pageTheme, + useApi, + errorApiRef, + ErrorApi, +} from '@backstage/core'; +import Radar from '../components/Radar'; +import { techRadarApiRef, TechRadarApi, TechRadarLoaderResponse } from '../api'; + +const useTechRadarLoader = (techRadarApi: TechRadarApi) => { + const [state, setState] = useState<{ + loading: boolean; + error?: Error; + data?: TechRadarLoaderResponse; + }>({ + loading: true, + error: undefined, + data: undefined, + }); + + useEffect(() => { + techRadarApi + .load() + .then((payload: TechRadarLoaderResponse) => { + setState({ loading: false, error: undefined, data: payload }); + }) + .catch((err: Error) => { + setState({ + loading: false, + error: err, + data: undefined, + }); + }); + }, []); + + return state; +}; + +const RadarPage: FC<{}> = () => { + const errorApi = useApi(errorApiRef); + const techRadarApi = useApi(techRadarApiRef); + const { loading, error, data } = useTechRadarLoader(techRadarApi); + + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error && error.message]); + + return ( + +
+ + + + {loading && } + {!loading && !error && ( + + )} + + + + + ); +}; + +export default RadarPage; diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.js b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.js new file mode 100644 index 0000000000..b93d20cbff --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.js @@ -0,0 +1,100 @@ +/* + * 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 from 'react'; +import PropTypes from 'prop-types'; +import * as CommonPropTypes from '../../utils/prop-types'; + +import RadarGrid from '../RadarGrid'; +import RadarEntry from '../RadarEntry'; +import RadarBubble from '../RadarBubble'; +import RadarFooter from '../RadarFooter'; +import RadarLegend from '../RadarLegend'; + +// A component that draws the radar circle. +export default class RadarPlot extends React.PureComponent { + render() { + const { + width, + height, + radius, + quadrants, + rings, + entries, + activeEntry, + onEntryMouseEnter, + onEntryMouseLeave, + } = this.props; + + return ( + + onEntryMouseEnter(entry)) + } + onEntryMouseLeave={ + onEntryMouseLeave && (entry => onEntryMouseLeave(entry)) + } + /> + + + + {entries.map(entry => ( + onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseLeave && (() => onEntryMouseLeave(entry)) + } + /> + ))} + + + + ); + } +} + +RadarPlot.propTypes = { + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + radius: PropTypes.number.isRequired, + rings: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.RING)).isRequired, + quadrants: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.QUADRANT)) + .isRequired, + entries: PropTypes.arrayOf(PropTypes.shape(CommonPropTypes.ENTRY)).isRequired, + activeEntry: PropTypes.object, + onEntryMouseEnter: PropTypes.func, + onEntryMouseLeave: PropTypes.func, +}; diff --git a/plugins/tech-radar/src/components/RadarPlot/index.ts b/plugins/tech-radar/src/components/RadarPlot/index.ts new file mode 100644 index 0000000000..61ea1900d4 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/index.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. + */ + +export { default } from './RadarPlot'; diff --git a/plugins/tech-radar/src/index.ts b/plugins/tech-radar/src/index.ts new file mode 100644 index 0000000000..926a7b410f --- /dev/null +++ b/plugins/tech-radar/src/index.ts @@ -0,0 +1,31 @@ +/* + * 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 { plugin } from './plugin'; + +/** + * The API for configuring the Tech Radar in a Backstage deployment. + */ +export * from './api'; + +/** + * Load sample data for Backstage users to get setup quickly. + * + * @example + * import { techRadarApiRef, TechRadar, loadSampleData } from '@backstage/plugin-tech-radar'; + * builder.add(techRadarApiRef, new TechRadar(800, 500, loadSampleData)); + */ +export { default as loadSampleData } from './sampleData'; diff --git a/plugins/tech-radar/src/plugin.test.ts b/plugins/tech-radar/src/plugin.test.ts new file mode 100644 index 0000000000..6ae65f8271 --- /dev/null +++ b/plugins/tech-radar/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { plugin } from './plugin'; + +describe('tech-radar', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/tech-radar/src/plugin.ts b/plugins/tech-radar/src/plugin.ts new file mode 100644 index 0000000000..3497a66f5d --- /dev/null +++ b/plugins/tech-radar/src/plugin.ts @@ -0,0 +1,25 @@ +/* + * 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 { createPlugin } from '@backstage/core'; +import RadarPage from './components/RadarPage'; + +export const plugin = createPlugin({ + id: 'tech-radar', + register({ router }) { + router.registerRoute('/tech-radar', RadarPage); + }, +}); diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts new file mode 100644 index 0000000000..dc8c67106e --- /dev/null +++ b/plugins/tech-radar/src/sampleData.ts @@ -0,0 +1,107 @@ +/* + * 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 { + RadarRing, + RadarQuadrant, + RadarEntry, + TechRadarLoaderResponse, +} from './api'; + +const rings = new Array(); +rings.push({ id: 'use', name: 'USE', color: '#93c47d' }); +rings.push({ id: 'trial', name: 'TRIAL', color: '#93d2c2' }); +rings.push({ id: 'assess', name: 'ASSESS', color: '#fbdb84' }); +rings.push({ id: 'hold', name: 'HOLD', color: '#efafa9' }); + +const quadrants = new Array(); +quadrants.push({ id: 'infrastructure', name: 'Infrastructure' }); +quadrants.push({ id: 'frameworks', name: 'Frameworks' }); +quadrants.push({ id: 'languages', name: 'Languages' }); +quadrants.push({ id: 'process', name: 'Process' }); + +const entries = new Array(); +entries.push({ + moved: 0, + ring: 'use', + url: '#', + key: 'javascript', + id: 'javascript', + title: 'JavaScript', + quadrant: 'languages', +}); +entries.push({ + moved: 0, + ring: 'use', + url: '#', + key: 'typescript', + id: 'typescript', + title: 'TypeScript', + quadrant: 'languages', +}); +entries.push({ + moved: 0, + ring: 'use', + url: '#', + key: 'webpack', + id: 'webpack', + title: 'Webpack', + quadrant: 'frameworks', +}); +entries.push({ + moved: 0, + ring: 'use', + url: '#', + key: 'react', + id: 'react', + title: 'React', + quadrant: 'frameworks', +}); +entries.push({ + moved: 0, + ring: 'use', + url: '#', + key: 'code-reviews', + id: 'code-reviews', + title: 'Code Reviews', + quadrant: 'process', +}); +entries.push({ + moved: 0, + url: '#', + key: 'mob-programming', + id: 'mob-programming', + title: 'Mob Programming', + quadrant: 'process', + ring: 'assess', +}); +entries.push({ + moved: 0, + ring: 'use', + url: '#', + key: 'github-actions', + id: 'github-actions', + title: 'GitHub Actions', + quadrant: 'infrastructure', +}); + +export default function loadSampleData(): Promise { + return Promise.resolve({ + rings, + quadrants, + entries, + }); +} diff --git a/plugins/tech-radar/src/setupTests.ts b/plugins/tech-radar/src/setupTests.ts new file mode 100644 index 0000000000..1a907ab8e6 --- /dev/null +++ b/plugins/tech-radar/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * 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'; +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/tech-radar/src/utils/polyfills/getBBox.ts b/plugins/tech-radar/src/utils/polyfills/getBBox.ts new file mode 100644 index 0000000000..8504910ab8 --- /dev/null +++ b/plugins/tech-radar/src/utils/polyfills/getBBox.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +// For testing with Jest and JSDOM +// SVGGElement.prototype.getBBox to calculate boundaries. +// JSDOM doesn't support this: https://github.com/jsdom/jsdom/issues/1664 +class GetBBoxPolyfill { + static exists(): boolean { + // @ts-ignore + return typeof window.Element.prototype.getBBox !== 'undefined'; + } + + static create( + x: number = 0, + y: number = 0, + width: number = 1000, + height: number = 500, + ): void { + if (this.exists()) { + return; + } + + Object.defineProperty(window.Element.prototype, 'getBBox', { + writable: false, + value: () => ({ x, y, width, height }), + }); + } + + static remove(): void { + if (!this.exists()) { + return; + } + + // @ts-ignore + delete window.Element.prototype.getBBox; + } +} + +export default GetBBoxPolyfill; diff --git a/plugins/tech-radar/src/utils/prop-types.js b/plugins/tech-radar/src/utils/prop-types.js new file mode 100644 index 0000000000..d5491435b6 --- /dev/null +++ b/plugins/tech-radar/src/utils/prop-types.js @@ -0,0 +1,53 @@ +/* + * 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 PropTypes from 'prop-types'; + +// Parameters for a ring; its index in an array determines how close to the center this ring is. +export const RING = { + id: PropTypes.string.isRequired, + idx: PropTypes.number, + name: PropTypes.string.isRequired, + color: PropTypes.string.isRequired, +}; + +// Parameters for a quadrant (there should be exactly 4 of course) +export const QUADRANT = { + id: PropTypes.string.isRequired, + idx: PropTypes.number, + name: PropTypes.string.isRequired, +}; + +export const ENTRY = { + id: PropTypes.string.isRequired, + idx: PropTypes.number, + // The quadrant where this entry belongs + quadrant: PropTypes.shape(QUADRANT).isRequired, + // The ring where this entry belongs + ring: PropTypes.shape(RING).isRequired, + // The label that's shown in the legend and on hover + title: PropTypes.string.isRequired, + // An URL to a longer description as to why this entry is where it is + url: PropTypes.string, + // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved + moved: PropTypes.number, +}; + +// The same as ENTRY except quadrant/ring are declared by their string ID instead of being the actual objects +export const DECLARED_ENTRY = Object.assign({}, ENTRY, { + quadrant: PropTypes.string.isRequired, + ring: PropTypes.string.isRequired, +}); diff --git a/plugins/tech-radar/src/utils/segment.js b/plugins/tech-radar/src/utils/segment.js new file mode 100644 index 0000000000..a4cb921c48 --- /dev/null +++ b/plugins/tech-radar/src/utils/segment.js @@ -0,0 +1,107 @@ +/* + * 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 default class Segment { + constructor(quadrant, ring, radius, nextSeed) { + this.nextSeed = nextSeed; + this.polarMin = { + t: quadrant.radialMin, + r: ring.innerRadius, + }; + this.polarMax = { + t: quadrant.radialMax, + r: ring.outerRadius, + }; + this.cartesianMin = { + x: 15 * quadrant.offsetX, + y: 15 * quadrant.offsetY, + }; + this.cartesianMax = { + x: radius * quadrant.offsetX, + y: radius * quadrant.offsetY, + }; + } + + clipx(d) { + const c = boundedBox(d, this.cartesianMin, this.cartesianMax); + const p = boundedRing(polar(c), this.polarMin.r + 15, this.polarMax.r - 15); + d.x = cartesian(p).x; + return d.x; + } + + clipy(d) { + const c = boundedBox(d, this.cartesianMin, this.cartesianMax); + const p = boundedRing(polar(c), this.polarMin.r + 15, this.polarMax.r - 15); + d.y = cartesian(p).y; + return d.y; + } + + random() { + return cartesian({ + t: this._randomBetween(this.polarMin.t, this.polarMax.t), + r: this._normalBetween(this.polarMin.r, this.polarMax.r), + }); + } + + // custom random number generator, to make random sequence reproducible + // source: https://stackoverflow.com/questions/521295 + _random() { + const x = Math.sin(this.nextSeed()) * 10000; + return x - Math.floor(x); + } + + _randomBetween(min, max) { + return min + this._random() * (max - min); + } + + _normalBetween(min, max) { + return min + (this._random() + this._random()) * 0.5 * (max - min); + } +} + +function polar({ x, y }) { + return { + t: Math.atan2(y, x), + r: Math.sqrt(x * x + y * y), + }; +} + +function cartesian({ r, t }) { + return { + x: r * Math.cos(t), + y: r * Math.sin(t), + }; +} + +function boundedInterval(value, min, max) { + const low = Math.min(min, max); + const high = Math.max(min, max); + return Math.min(Math.max(value, low), high); +} + +function boundedRing(polarValue, rMin, rMax) { + return { + t: polarValue.t, + r: boundedInterval(polarValue.r, rMin, rMax), + }; +} + +function boundedBox(point, min, max) { + return { + x: boundedInterval(point.x, min.x, max.x), + y: boundedInterval(point.y, min.y, max.y), + }; +} diff --git a/plugins/tech-radar/tsconfig.json b/plugins/tech-radar/tsconfig.json new file mode 100644 index 0000000000..7b73db2f0f --- /dev/null +++ b/plugins/tech-radar/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } +} diff --git a/yarn.lock b/yarn.lock index abb9f7d9a5..d2278c1b17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2925,11 +2925,11 @@ integrity sha512-j3G/YPYBz+PZGkG9Sx1mnUAY/SHK5+DX6FPzjgN0tPcMpOyt6jJrkcEC0Q6QZk+L4SLiDKJ7jshM7fx9oX61UA== "@spotify/eslint-config-oss@^1.0.1": - version "1.0.1" - resolved "https://registry.npmjs.org/@spotify/eslint-config-oss/-/eslint-config-oss-1.0.1.tgz#01a95517e05063487c8e06b0b95cbdda33014a2a" - integrity sha512-G6eVEsp5WnvCglI9uhzhSeUnsKdpJ5gOLyhdZBEtqt8+MdpQSDECIfnkI8mV06dQ8FKtbqVd2NiMbkCC/gwrFQ== + version "1.0.2" + resolved "https://registry.npmjs.org/@spotify/eslint-config-oss/-/eslint-config-oss-1.0.2.tgz#b0e56e549c78dcdd79063ce48521f10c3420f701" + integrity sha512-3Tn6R98f5BWeb8anOhxYdvZywasp1RaJb+y65W7Db5+xiQVmCnSFvIM3jwysQpu1xRdlUJSCqeDHR7S4Nz3thw== dependencies: - eslint-plugin-notice "^0.8.9" + eslint-plugin-notice "^0.9.10" "@spotify/eslint-config-react@^6.2.0": version "6.2.0" @@ -3813,11 +3813,25 @@ dependencies: "@types/node" "*" -"@types/color-name@^1.1.1": +"@types/color-convert@*": + version "1.9.0" + resolved "https://registry.npmjs.org/@types/color-convert/-/color-convert-1.9.0.tgz#bfa8203e41e7c65471e9841d7e306a7cd8b5172d" + integrity sha512-OKGEfULrvSL2VRbkl/gnjjgbbF7ycIlpSsX7Nkab4MOWi5XxmgBYvuiQ7lcCFY5cPDz7MUNaKgxte2VRmtr4Fg== + dependencies: + "@types/color-name" "*" + +"@types/color-name@*", "@types/color-name@^1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/color@^3.0.1": + version "3.0.1" + resolved "https://registry.npmjs.org/@types/color/-/color-3.0.1.tgz#2900490ed04da8116c5058cd5dba3572d5a25071" + integrity sha512-oeUWVaAwI+xINDUx+3F2vJkl/vVB03VChFF/Gl3iQCdbcakjuoJyMOba+3BXRtnBhxZ7uBYqQBi9EpLnvSoztA== + dependencies: + "@types/color-convert" "*" + "@types/compression@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.0.tgz#8dc2a56604873cf0dd4e746d9ae4d31ae77b2390" @@ -3866,6 +3880,11 @@ dependencies: postcss "5 - 7" +"@types/d3-force@^1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83" + integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA== + "@types/debug@^4.1.2": version "4.1.5" resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" @@ -6898,15 +6917,7 @@ color-string@^1.5.2: color-name "^1.0.0" simple-swizzle "^0.2.2" -color@3.0.x: - version "3.0.0" - resolved "https://registry.npmjs.org/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" - integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w== - dependencies: - color-convert "^1.9.1" - color-string "^1.5.2" - -color@^3.0.0: +color@^3.0.0, color@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10" integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg== @@ -7925,6 +7936,30 @@ cz-conventional-changelog@^3.0.2: optionalDependencies: "@commitlint/load" ">6.1.1" +d3-dispatch@1: + version "1.0.6" + resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" + integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== + +d3-force@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/d3-force/-/d3-force-2.0.1.tgz#31750eee8c43535301d571195bf9683beda534e2" + integrity sha512-zh73/N6+MElRojiUG7vmn+3vltaKon7iD5vB/7r9nUaBeftXMzRo5IWEG63DLBCto4/8vr9i3m9lwr1OTJNiCg== + dependencies: + d3-dispatch "1" + d3-quadtree "1" + d3-timer "1" + +d3-quadtree@1: + version "1.0.7" + resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz#ca8b84df7bb53763fe3c2f24bd435137f4e53135" + integrity sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA== + +d3-timer@1: + version "1.0.10" + resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" + integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -8971,10 +9006,10 @@ eslint-plugin-monorepo@^0.2.1: parse-package-name "^0.1.0" path-is-inside "^1.0.2" -eslint-plugin-notice@^0.8.9: - version "0.8.10" - resolved "https://registry.npmjs.org/eslint-plugin-notice/-/eslint-plugin-notice-0.8.10.tgz#a9307cf42ab38c4e548e45197d4f98aaeea6b15b" - integrity sha512-vtUZ3FMt+SJ/2THV6mgtYS3a5Xe0EiASnSY948Z53uOhpUFYCVNyTIOSd1zDGWYe0J4L1shZevMlgPlkYJ34vw== +eslint-plugin-notice@^0.9.10: + version "0.9.10" + resolved "https://registry.npmjs.org/eslint-plugin-notice/-/eslint-plugin-notice-0.9.10.tgz#89cf6377bf1c004a219c4e541321ea9125b408c8" + integrity sha512-rF79EuqdJKu9hhTmwUkNeSvLmmq03m/NXq/NHwUENHbdJ0wtoyOjxZBhW4QCug8v5xYE6cGe3AWkGqSIe9KUbQ== dependencies: find-root "^1.1.0" lodash "^4.17.15"