Merge branch 'master' into blam/scaffolder
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -29,9 +29,7 @@ export type Options = {
|
||||
};
|
||||
|
||||
function transformPath(path: string): string {
|
||||
return resolvePath(
|
||||
path.replace(/<repoRoot>/g, paths.targetRoot).replace(/'/g, ''),
|
||||
);
|
||||
return resolvePath(path.replace(/<repoRoot>/g, paths.targetRoot));
|
||||
}
|
||||
|
||||
export async function parseOptions(cmd: Command): Promise<Options> {
|
||||
|
||||
@@ -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<SpawnOptions, 'env'> & {
|
||||
env?: Partial<NodeJS.ProcessEnv>;
|
||||
@@ -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<boolean> {
|
||||
export async function runCheck(cmd: string, ...args: string[]) {
|
||||
try {
|
||||
await exec(cmd);
|
||||
await execFile(cmd, args, { shell: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
|
||||
@@ -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<Api, Impl, Deps>(
|
||||
factory: ApiFactory<Api, Impl, Deps>,
|
||||
): ApiFactory<Api, Impl, Deps> {
|
||||
return factory;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -30,8 +30,8 @@ export type ApiHolder = {
|
||||
get<T>(api: ApiRef<T>): T | undefined;
|
||||
};
|
||||
|
||||
export type ApiFactory<A, I, D> = {
|
||||
implements: ApiRef<A>;
|
||||
deps: TypesToApiRefs<D>;
|
||||
factory(deps: D): I extends A ? I : never;
|
||||
export type ApiFactory<Api, Impl, Deps> = {
|
||||
implements: ApiRef<Api>;
|
||||
deps: TypesToApiRefs<Deps>;
|
||||
factory(deps: Deps): Impl extends Api ? Impl : never;
|
||||
};
|
||||
|
||||
@@ -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>(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,
|
||||
|
||||
@@ -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<BackstageTheme>(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<Props> = ({ link, title, onClick }) => {
|
||||
return (
|
||||
<div>
|
||||
<Divider />
|
||||
<Link href={link} onClick={onClick}>
|
||||
<Link href={link} onClick={onClick} underline="none">
|
||||
<ListItem className={classes.root}>
|
||||
<ListItemText>
|
||||
<Box className={classes.boxTitle} fontWeight="fontWeightBold" m={1}>
|
||||
@@ -58,7 +61,7 @@ const BottomLink: FC<Props> = ({ link, title, onClick }) => {
|
||||
</Box>
|
||||
</ListItemText>
|
||||
<ListItemIcon>
|
||||
<ArrowIcon />
|
||||
<ArrowIcon className={classes.arrow} />
|
||||
</ListItemIcon>
|
||||
</ListItem>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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<typeof createPlugin>;
|
||||
|
||||
/**
|
||||
* 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<BackstagePlugin>();
|
||||
private readonly factories = new Array<ApiFactory<any, any, any>>();
|
||||
|
||||
/**
|
||||
* 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<Api, Impl, Deps>(
|
||||
factory: ApiFactory<Api, Impl, Deps>,
|
||||
): 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 (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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(<DevApp />, document.getElementById('root'));
|
||||
}
|
||||
|
||||
// Create a sidebar that exposes the touchpoints of a plugin
|
||||
private setupSidebar(plugins: BackstagePlugin[]): JSX.Element {
|
||||
const sidebarItems = new Array<JSX.Element>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
for (const output of plugin.output()) {
|
||||
switch (output.type) {
|
||||
case 'route': {
|
||||
const { path } = output;
|
||||
sidebarItems.push(
|
||||
<SidebarItem
|
||||
key={path}
|
||||
to={path}
|
||||
text={path}
|
||||
icon={BookmarkIcon}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarSpacer />
|
||||
{sidebarItems}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// Set up an API registry that merges together default implementations with ones provided through config.
|
||||
private setupApiRegistry(
|
||||
providedFactories: ApiFactory<any, any, any>[],
|
||||
): 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<string>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
}
|
||||
}
|
||||
@@ -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 => (
|
||||
<ThemeProvider theme={useDarkMode() ? darkTheme : lightTheme}>
|
||||
<CssBaseline>{story()}</CssBaseline>
|
||||
<CssBaseline>
|
||||
<Content>{story()}</Content>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
));
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarSpacer />
|
||||
<SidebarItem icon={HomeIcon} to="/graphiql" text="Home" />
|
||||
</Sidebar>
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, 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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarSpacer />
|
||||
<SidebarItem icon={HomeIcon} to="/home" text="Home" />
|
||||
</Sidebar>
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
createDevApp()
|
||||
.registerPlugin(plugin)
|
||||
.render();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 (
|
||||
<TableRow key={website.url}>
|
||||
<TableCell>
|
||||
<Link
|
||||
className={classes.link}
|
||||
href={`/lighthouse/audit/${website.lastAudit.id}`}
|
||||
>
|
||||
{website.url}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{CATEGORIES.map(category => (
|
||||
<TableCell
|
||||
key={`${website.url}|${category}`}
|
||||
className={classes.sparklinesCell}
|
||||
>
|
||||
<TrendLine
|
||||
title={`trendline for ${CATEGORY_LABELS[category]} category of ${website.url}`}
|
||||
data={categorySparkline[category] || []}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className={classes.statusCell}>
|
||||
<AuditStatusIcon audit={website.lastAudit} />{' '}
|
||||
<span className={classes.status}>
|
||||
{website.lastAudit.status.toLowerCase()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatTime(website.lastAudit.timeCreated)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditRow;
|
||||
@@ -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 <AuditRow website={websiteState} categorySparkline={sparklineState} />;
|
||||
};
|
||||
|
||||
export default Audit;
|
||||
@@ -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 (
|
||||
<ApiProvider apis={apis}>
|
||||
<AuditListTable items={websiteList.items} />
|
||||
</ApiProvider>
|
||||
);
|
||||
};
|
||||
it('renders the link to each website', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<AuditListTable items={websiteListResponse.items} />),
|
||||
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(<AuditListTable items={websiteListResponse.items} />),
|
||||
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(<AuditListTable items={websiteListResponse.items} />),
|
||||
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(
|
||||
<AuditListTable items={websiteListResponse.items} />,
|
||||
),
|
||||
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(
|
||||
<AuditListTable items={websiteListResponse.items} />,
|
||||
),
|
||||
wrapInThemedTestApp(auditList(websiteListResponse)),
|
||||
);
|
||||
const anchorSEO = rendered.queryByTitle(
|
||||
'trendline for SEO category of https://anchor.fm',
|
||||
|
||||
@@ -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<LighthouseCategoryId, string> = {
|
||||
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<LighthouseCategoryId, number[]>;
|
||||
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<string, SparklinesDataByCategory> = useMemo(
|
||||
() =>
|
||||
items.reduce(
|
||||
(res, item) => ({
|
||||
...res,
|
||||
...res,
|
||||
[item.url]: buildSparklinesDataForItem(item),
|
||||
}),
|
||||
{},
|
||||
@@ -118,34 +76,11 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => {
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map(website => (
|
||||
<TableRow key={website.url}>
|
||||
<TableCell>
|
||||
<Link
|
||||
className={classes.link}
|
||||
href={`/lighthouse/audit/${website.lastAudit.id}`}
|
||||
>
|
||||
{website.url}
|
||||
</Link>
|
||||
</TableCell>
|
||||
{CATEGORIES.map(category => (
|
||||
<TableCell
|
||||
key={`${website.url}|${category}`}
|
||||
className={classes.sparklinesCell}
|
||||
>
|
||||
<TrendLine
|
||||
title={`trendline for ${CATEGORY_LABELS[category]} category of ${website.url}`}
|
||||
data={categorySparklines[website.url][category] || []}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className={classes.statusCell}>
|
||||
<AuditStatusIcon audit={website.lastAudit} />{' '}
|
||||
<span className={classes.status}>
|
||||
{website.lastAudit.status.toLowerCase()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatTime(website.lastAudit.timeCreated)}</TableCell>
|
||||
</TableRow>
|
||||
<Audit
|
||||
key={website.url}
|
||||
website={website}
|
||||
categorySparkline={categorySparklines[website.url]}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ const CreateAudit: FC<{}> = () => {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [url, emulatedFormFactor, lighthouseApi, setSubmitting]);
|
||||
}, [url, emulatedFormFactor, lighthouseApi, setSubmitting, errorApi, history]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
|
||||
@@ -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<LighthouseCategoryId, string> = {
|
||||
accessibility: 'Accessibility',
|
||||
performance: 'Performance',
|
||||
seo: 'SEO',
|
||||
'best-practices': 'Best Practices',
|
||||
pwa: 'Progressive Web App',
|
||||
};
|
||||
|
||||
export type SparklinesDataByCategory = Record<LighthouseCategoryId, number[]>;
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.js')],
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
# @backstage/plugin-tech-radar
|
||||
|
||||
<img src="docs/screenshot.png" width="700" alt="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 <http://localhost:3000/tech-radar>
|
||||
|
||||
## 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<TechRadarLoaderResponse>;
|
||||
additionalOpts: TechRadarAdditionalOptions;
|
||||
}
|
||||
|
||||
// Constructor signature for the `TechRadar` class
|
||||
// constructor(
|
||||
// public width: number,
|
||||
// public height: number,
|
||||
// public load: () => Promise<TechRadarLoaderResponse>,
|
||||
// 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 <svg> tag of the visualization
|
||||
'data-testid': 'tech-radar-svg',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Then, in your tests...
|
||||
// const { getByTestId } = render(...);
|
||||
// expect(getByTestId('tech-radar-svg')).toBeInTheDocument();
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<TechRadarLoaderResponse>;
|
||||
additionalOpts: TechRadarAdditionalOptions;
|
||||
}
|
||||
|
||||
export const techRadarApiRef = new ApiRef<TechRadarApi>({
|
||||
id: 'plugin.techradar',
|
||||
description: 'Used by the Tech Radar to render the diagram',
|
||||
});
|
||||
|
||||
export class TechRadar implements TechRadarApi {
|
||||
private defaultAdditionalOpts: Partial<TechRadarAdditionalOptions> = {
|
||||
title: 'Tech Radar',
|
||||
subtitle: 'Welcome to the Tech Radar!',
|
||||
};
|
||||
|
||||
constructor(
|
||||
public width: number,
|
||||
public height: number,
|
||||
public load: () => Promise<TechRadarLoaderResponse>,
|
||||
public additionalOpts: TechRadarAdditionalOptions = {},
|
||||
) {
|
||||
this.additionalOpts = { ...this.defaultAdditionalOpts, ...additionalOpts };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
│ │ . -- ~~~ -- . │ │
|
||||
│ │ .-~ ~-. │ │
|
||||
│ <Q3> │ / \ │ <Q2> │
|
||||
│ │ / \ │ │
|
||||
┼───────────┼─────────────────────────────┼───────────┼─1
|
||||
┼───────────┼─────────────────────────────┼───────────┼─2
|
||||
│ │ | | │ │
|
||||
│ │ \ / │ │
|
||||
│ <Q1> │ \ / │ <Q0> │
|
||||
│ │ `-. .-' │ │
|
||||
│ │ ~- . ___ . -~ │ │
|
||||
┼───────────┼─────────────────────────────┼───────────┼─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 (
|
||||
<svg
|
||||
ref={node => {
|
||||
this.node = node;
|
||||
}}
|
||||
width={width}
|
||||
height={height}
|
||||
{...this.props.svgProps}
|
||||
>
|
||||
<RadarPlot
|
||||
width={width}
|
||||
height={height}
|
||||
radius={radius}
|
||||
entries={entries}
|
||||
quadrants={quadrants}
|
||||
rings={rings}
|
||||
activeEntry={activeEntry}
|
||||
onEntryMouseEnter={entry => this._setActiveEntry(entry)}
|
||||
onEntryMouseLeave={() => this._clearActiveEntry()}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<Radar {...minProps} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const svg = rendered.container.querySelector('svg');
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg!.getAttribute('width')).toEqual('500');
|
||||
expect(svg!.getAttribute('height')).toEqual('200');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 (
|
||||
<g
|
||||
ref={this._setNode}
|
||||
x={0}
|
||||
y={0}
|
||||
className={visible ? classes.visibleBubble : classes.bubble}
|
||||
>
|
||||
<rect
|
||||
ref={this._setRect}
|
||||
rx={4}
|
||||
ry={4}
|
||||
className={classes.background}
|
||||
/>
|
||||
<text ref={this._setText} className={classes.text}>
|
||||
{text}
|
||||
</text>
|
||||
<path
|
||||
ref={this._setPath}
|
||||
d="M 0,0 10,0 5,8 z"
|
||||
className={classes.background}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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';
|
||||
@@ -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 = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
|
||||
} else if (moved < 0) {
|
||||
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
|
||||
} else {
|
||||
blip = <circle r={9} style={style} />;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
blip = (
|
||||
<a href={url} className={classes.link}>
|
||||
{blip}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x}, ${y})`}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
{blip}
|
||||
<text y={3} className={classes.text}>
|
||||
{number}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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';
|
||||
@@ -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 (
|
||||
<text
|
||||
transform={`translate(${x}, ${y})`}
|
||||
space="preserve"
|
||||
className={classes.text}
|
||||
>
|
||||
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RadarFooter.propTypes = {
|
||||
x: PropTypes.number.isRequired,
|
||||
y: PropTypes.number.isRequired,
|
||||
classes: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default withStyles(styles)(RadarFooter);
|
||||
@@ -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';
|
||||
@@ -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) => [
|
||||
<circle
|
||||
key={`c${ringIndex}`}
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={ringRadius}
|
||||
className={classes.ring}
|
||||
/>,
|
||||
<text
|
||||
key={`t${ringIndex}`}
|
||||
y={-ringRadius + 42}
|
||||
textAnchor="middle"
|
||||
className={classes.text}
|
||||
>
|
||||
{rings[ringIndex].name}
|
||||
</text>,
|
||||
];
|
||||
|
||||
const axisNodes = [
|
||||
// X axis
|
||||
<line
|
||||
key="x"
|
||||
x1={0}
|
||||
y1={-radius}
|
||||
x2={0}
|
||||
y2={radius}
|
||||
className={classes.axis}
|
||||
/>,
|
||||
// Y axis
|
||||
<line
|
||||
key="y"
|
||||
x1={-radius}
|
||||
y1={0}
|
||||
x2={radius}
|
||||
y2={0}
|
||||
className={classes.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);
|
||||
@@ -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';
|
||||
@@ -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 (
|
||||
<foreignObject
|
||||
key={quadrant.id}
|
||||
x={quadrant.legendX}
|
||||
y={quadrant.legendY}
|
||||
width={quadrant.legendWidth}
|
||||
height={quadrant.legendHeight}
|
||||
>
|
||||
<div className={classes.quadrant}>
|
||||
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
|
||||
<div className={classes.rings}>
|
||||
{rings.map(ring =>
|
||||
RadarLegend._renderRing(
|
||||
ring,
|
||||
RadarLegend._getSegment(segments, quadrant, ring),
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
classes,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
|
||||
static _renderRing(
|
||||
ring,
|
||||
entries,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
classes,
|
||||
) {
|
||||
return (
|
||||
<div key={ring.id} className={classes.ring}>
|
||||
<h3 className={classes.ringHeading}>{ring.name}</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p>(empty)</p>
|
||||
) : (
|
||||
<ol className={classes.ringList}>
|
||||
{entries.map(entry => {
|
||||
let node = <span className={classes.entry}>{entry.title}</span>;
|
||||
|
||||
if (entry.url) {
|
||||
node = (
|
||||
<a className={classes.entryLink} href={entry.url}>
|
||||
{node}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
value={entry.idx + 1}
|
||||
onMouseEnter={
|
||||
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
|
||||
}
|
||||
onMouseLeave={
|
||||
onEntryMouseEnter && (() => onEntryMouseLeave(entry))
|
||||
}
|
||||
>
|
||||
{node}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<g>
|
||||
{quadrants.map(quadrant =>
|
||||
RadarLegend._renderQuadrant(
|
||||
segments,
|
||||
quadrant,
|
||||
rings,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
classes,
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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';
|
||||
@@ -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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, errorApi],
|
||||
[techRadarApiRef, techRadarApi],
|
||||
])}
|
||||
>
|
||||
<RadarPage />
|
||||
</ApiProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, errorApi],
|
||||
[techRadarApiRef, techRadarApi],
|
||||
])}
|
||||
>
|
||||
<RadarPage />
|
||||
</ApiProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, errorApi],
|
||||
[techRadarApiRef, techRadarApi],
|
||||
])}
|
||||
>
|
||||
<RadarPage />
|
||||
</ApiProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([[techRadarApiRef, techRadarApi]])}
|
||||
>
|
||||
<RadarPage />
|
||||
</ApiProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}).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(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
|
||||
<RadarPage />
|
||||
</ApiProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}).toThrow();
|
||||
}).error[0],
|
||||
).toMatch(
|
||||
/^Error: Uncaught \[Error: No implementation available for apiRef{plugin.techradar}\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<ErrorApi>(errorApiRef);
|
||||
const techRadarApi = useApi<TechRadarApi>(techRadarApiRef);
|
||||
const { loading, error, data } = useTechRadarLoader(techRadarApi);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
}, [error && error.message]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
title={techRadarApi.additionalOpts.title}
|
||||
subtitle={techRadarApi.additionalOpts.subtitle}
|
||||
/>
|
||||
<Content>
|
||||
<Grid container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
{loading && <Progress />}
|
||||
{!loading && !error && (
|
||||
<Radar
|
||||
width={techRadarApi.width}
|
||||
height={techRadarApi.height}
|
||||
svgProps={techRadarApi.additionalOpts.svgProps}
|
||||
rings={data!.rings}
|
||||
quadrants={data!.quadrants}
|
||||
entries={data!.entries}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadarPage;
|
||||
@@ -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 (
|
||||
<g>
|
||||
<RadarLegend
|
||||
quadrants={quadrants}
|
||||
rings={rings}
|
||||
entries={entries}
|
||||
onEntryMouseEnter={
|
||||
onEntryMouseEnter && (entry => onEntryMouseEnter(entry))
|
||||
}
|
||||
onEntryMouseLeave={
|
||||
onEntryMouseLeave && (entry => onEntryMouseLeave(entry))
|
||||
}
|
||||
/>
|
||||
<g transform={`translate(${width / 2}, ${height / 2})`}>
|
||||
<RadarGrid radius={radius} rings={rings} />
|
||||
<RadarFooter x={-0.5 * width} y={0.5 * height} />
|
||||
{entries.map(entry => (
|
||||
<RadarEntry
|
||||
key={entry.id}
|
||||
x={entry.x}
|
||||
y={entry.y}
|
||||
color={entry.color}
|
||||
title={entry.title}
|
||||
number={entry.idx + 1}
|
||||
url={entry.url}
|
||||
moved={entry.moved}
|
||||
active={activeEntry && activeEntry.id === entry.id}
|
||||
onMouseEnter={
|
||||
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
|
||||
}
|
||||
onMouseLeave={
|
||||
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<RadarBubble
|
||||
visible={!!activeEntry}
|
||||
text={activeEntry ? activeEntry.title : ''}
|
||||
x={activeEntry ? activeEntry.x : 0}
|
||||
y={activeEntry ? activeEntry.y : 0}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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<RadarRing>();
|
||||
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<RadarQuadrant>();
|
||||
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<RadarEntry>();
|
||||
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<TechRadarLoaderResponse> {
|
||||
return Promise.resolve({
|
||||
rings,
|
||||
quadrants,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user