Merge pull request #1381 from ayshiff/refactor/tech-radar

Refactor: Tech Radar Code Quality & Style
This commit is contained in:
Fredrik Adelöw
2020-06-29 15:01:52 +02:00
committed by GitHub
21 changed files with 579 additions and 188 deletions
+1 -1
View File
@@ -27,7 +27,6 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"color": "^3.1.2",
"d3-force": "^2.0.1",
"prop-types": "^15.7.2",
@@ -43,6 +42,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/color": "^3.0.1",
"@types/d3-force": "^1.2.1",
"@types/react": "^16.9",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
+2 -1
View File
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '@backstage/core';
import { MovedState } from './utils/types';
/**
* Types related to the Radar's visualization.
@@ -34,7 +35,7 @@ export interface RadarQuadrant {
export interface RadarEntry {
key: string; // react key
id: string;
moved: number;
moved: MovedState;
quadrant: RadarQuadrant;
ring: RadarRing;
title: string;
@@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import Radar from './Radar';
import Radar, { Props } from './Radar';
const minProps = {
const minProps: Props = {
width: 500,
height: 200,
quadrants: [{ id: 'languages', name: 'Languages' }],
@@ -14,12 +14,12 @@
* limitations under the License.
*/
import React, { FC, useState, useRef } from 'react';
import React, { useState, useRef } from 'react';
import RadarPlot from '../RadarPlot';
import { Ring, Quadrant, Entry } from '../../utils/types';
import type { Ring, Quadrant, Entry } from '../../utils/types';
import { adjustQuadrants, adjustRings, adjustEntries } from './utils';
type Props = {
export type Props = {
width: number;
height: number;
quadrants: Quadrant[];
@@ -28,17 +28,17 @@ type Props = {
svgProps?: object;
};
const Radar: FC<Props> = props => {
const Radar = (props: Props): JSX.Element => {
const { width, height, quadrants, rings, entries } = props;
const radius = Math.min(width, height) / 2;
const [activeEntry, setActiveEntry] = useState<Entry | null>();
const [activeEntry, setActiveEntry] = useState<Entry>();
const node = useRef<SVGSVGElement>(null);
// TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem
adjustQuadrants(quadrants, radius, width, height);
adjustRings(rings, radius);
adjustEntries(entries, activeEntry, quadrants, rings, radius);
adjustEntries(entries, quadrants, rings, radius, activeEntry);
return (
<svg ref={node} width={width} height={height} {...props.svgProps}>
@@ -49,9 +49,9 @@ const Radar: FC<Props> = props => {
entries={entries}
quadrants={quadrants}
rings={rings}
activeEntry={activeEntry || undefined}
activeEntry={activeEntry}
onEntryMouseEnter={entry => setActiveEntry(entry)}
onEntryMouseLeave={() => setActiveEntry(null)}
onEntryMouseLeave={() => setActiveEntry(undefined)}
/>
</svg>
);
@@ -17,7 +17,7 @@
import color from 'color';
import { forceCollide, forceSimulation } from 'd3-force';
import Segment from '../../utils/segment';
import { Ring, Quadrant, Entry } from '../../utils/types';
import type { Ring, Quadrant, Entry } from '../../utils/types';
export const adjustQuadrants = (
quadrants: Quadrant[],
@@ -81,14 +81,14 @@ export const adjustQuadrants = (
},
];
quadrants.forEach((quadrant, idx) => {
const legendParam = legendParams[idx % 4];
quadrants.forEach((quadrant, index) => {
const legendParam = legendParams[index % 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.index = index;
quadrant.radialMin = (index * Math.PI) / 2;
quadrant.radialMax = ((index + 1) * Math.PI) / 2;
quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1;
quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1;
quadrant.legendX = legendParam.x;
quadrant.legendY = legendParam.y;
quadrant.legendWidth = legendParam.width;
@@ -98,13 +98,13 @@ export const adjustQuadrants = (
export const adjustEntries = (
entries: Entry[],
activeEntry: Entry | null | undefined,
quadrants: Quadrant[],
rings: Ring[],
radius: number,
activeEntry?: Entry,
) => {
let seed = 42;
entries.forEach((entry, idx) => {
entries.forEach((entry, index) => {
const quadrant = quadrants.find(q => {
const match =
typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant;
@@ -124,7 +124,7 @@ export const adjustEntries = (
throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`);
}
entry.idx = idx;
entry.index = index;
entry.quadrant = quadrant;
entry.ring = ring;
entry.segment = new Segment(quadrant, ring, radius, () => seed++);
@@ -163,10 +163,10 @@ export const adjustEntries = (
};
export const adjustRings = (rings: Ring[], radius: number) => {
rings.forEach((ring, idx) => {
ring.idx = idx;
ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius;
rings.forEach((ring, index) => {
ring.index = index;
ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius;
ring.innerRadius =
((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius;
((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius;
});
};
@@ -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 { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarBubble, { Props } from './RadarBubble';
const minProps: Props = {
visible: true,
text: 'RadarBubble',
x: 2,
y: 2,
};
describe('RadarBubble', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarBubble {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByText(minProps.text)).toBeInTheDocument();
});
});
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React, { FC, useRef, useLayoutEffect } from 'react';
import React, { useRef, useLayoutEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
export type Props = {
visible: boolean;
text: string;
x: number;
@@ -46,7 +46,7 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarBubble: FC<Props> = props => {
const RadarBubble = (props: Props): JSX.Element => {
const classes = useStyles(props);
const { visible, text } = props;
@@ -98,6 +98,7 @@ const RadarBubble: FC<Props> = props => {
x={0}
y={0}
className={visible ? classes.visibleBubble : classes.bubble}
data-testid="radar-bubble"
>
<rect ref={rectElem} rx={4} ry={4} className={classes.background} />
<text ref={textElem} className={classes.text}>
@@ -14,50 +14,38 @@
* limitations under the License.
*/
import React, { useEffect, useState, FC } from 'react';
import React, { useEffect } from 'react';
import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core';
import { useAsync } from 'react-use';
import Radar from '../components/Radar';
import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api';
import getSampleData from '../sampleData';
const useTechRadarLoader = (props: TechRadarComponentProps) => {
const errorApi = useApi<ErrorApi>(errorApiRef);
const [state, setState] = useState<{
loading: boolean;
error?: Error;
data?: TechRadarLoaderResponse;
}>({
loading: true,
error: undefined,
data: undefined,
});
const { getData } = props;
useEffect(() => {
if (!getData) {
return;
const state = useAsync(async () => {
if (getData) {
const response: TechRadarLoaderResponse = await getData();
return response;
}
getData()
.then((payload: TechRadarLoaderResponse) => {
setState({ loading: false, error: undefined, data: payload });
})
.catch((err: Error) => {
errorApi.post(err);
setState({
loading: false,
error: err,
data: undefined,
});
});
return undefined;
}, [getData, errorApi]);
useEffect(() => {
const { error } = state;
if (error) {
errorApi.post(error);
}
}, [errorApi, state]);
return state;
};
const RadarComponent: FC<TechRadarComponentProps> = props => {
const { loading, error, data } = useTechRadarLoader(props);
const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
const { loading, error, value: data } = useTechRadarLoader(props);
return (
<>
@@ -0,0 +1,56 @@
/*
* 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 RadarEntry, { Props } from './RadarEntry';
const minProps: Props = {
x: 2,
y: 2,
value: 2,
color: 'red',
};
describe('RadarEntry', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarEntry {...minProps} />
</svg>
</ThemeProvider>,
);
const radarEntry = rendered.getByTestId('radar-entry');
const { x, y } = minProps;
expect(radarEntry).toBeInTheDocument();
expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
expect(rendered.getByText(String(minProps.value))).toBeInTheDocument();
});
});
@@ -14,13 +14,14 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { WithLink } from '../../utils/components';
type Props = {
export type Props = {
x: number;
y: number;
number: number;
value: number;
color: string;
url?: string;
moved?: number;
@@ -43,14 +44,27 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarEntry: FC<Props> = props => {
const makeBlip = (color: string, moved?: number) => {
const style = { fill: color };
let blip = <circle r={9} style={style} />;
if (moved && moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved && moved < 0) {
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
}
return blip;
};
const RadarEntry = (props: Props): JSX.Element => {
const classes = useStyles(props);
const {
moved,
color,
url,
number,
value,
x,
y,
onMouseEnter,
@@ -58,24 +72,7 @@ const RadarEntry: FC<Props> = props => {
onClick,
} = props;
const style = { fill: color };
let blip;
if (moved && moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved && 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>
);
}
const blip = makeBlip(color, moved);
return (
<g
@@ -83,10 +80,13 @@ const RadarEntry: FC<Props> = props => {
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
data-testid="radar-entry"
>
{blip}
<WithLink url={url} className={classes.link}>
{blip}
</WithLink>
<text y={3} className={classes.text}>
{number}
{value}
</text>
</g>
);
@@ -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 { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarFooter, { Props } from './RadarFooter';
const minProps: Props = {
x: 2,
y: 2,
};
describe('RadarFooter', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarFooter {...minProps} />
</svg>
</ThemeProvider>,
);
const radarFooter = rendered.getByTestId('radar-footer');
const { x, y } = minProps;
expect(radarFooter).toBeInTheDocument();
expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
});
});
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
export type Props = {
x: number;
y: number;
};
@@ -31,12 +31,16 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarFooter: FC<Props> = props => {
const RadarFooter = (props: Props): JSX.Element => {
const { x, y } = props;
const classes = useStyles(props);
return (
<text transform={`translate(${x}, ${y})`} className={classes.text}>
<text
data-testid="radar-footer"
transform={`translate(${x}, ${y})`}
className={classes.text}
>
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
</text>
);
@@ -0,0 +1,51 @@
/*
* 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 RadarGrid, { Props } from './RadarGrid';
const minProps: Props = {
radius: 5,
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
};
describe('RadarGrid', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarGrid {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument();
expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument();
});
});
@@ -16,9 +16,9 @@
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Ring } from '../../utils/types';
import type { Ring } from '../../utils/types';
type Props = {
export type Props = {
radius: number;
rings: Ring[];
};
@@ -49,7 +49,7 @@ const RadarGrid = (props: Props) => {
const { radius, rings } = props;
const classes = useStyles(props);
const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [
const makeRingNode = (ringIndex: number, ringRadius?: number) => [
<circle
key={`c${ringIndex}`}
cx={0}
@@ -76,6 +76,7 @@ const RadarGrid = (props: Props) => {
x2={0}
y2={radius}
className={classes.axis}
data-testid="radar-grid-x-line"
/>,
// Y axis
<line
@@ -85,10 +86,13 @@ const RadarGrid = (props: Props) => {
x2={radius}
y2={0}
className={classes.axis}
data-testid="radar-grid-y-line"
/>,
];
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
const ringNodes = rings
.map(r => r.outerRadius)
.map((ringRadius, ringIndex) => makeRingNode(ringIndex, ringRadius));
return <>{axisNodes.concat(...ringNodes)}</>;
};
@@ -0,0 +1,62 @@
/*
* 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 RadarLegend, { Props } from './RadarLegend';
const minProps: Props = {
quadrants: [{ id: 'languages', name: 'Languages' }],
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
entries: [
{
id: 'typescript',
title: 'TypeScript',
quadrant: { id: 'languages', name: 'Languages' },
moved: 0,
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
},
],
};
describe('RadarLegend', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarLegend {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1);
expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1);
});
});
@@ -13,15 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Quadrant, Ring, Entry } from '../../utils/types';
import type { Quadrant, Ring, Entry } from '../../utils/types';
import { WithLink } from '../../utils/components';
type Segments = {
[k: number]: { [k: number]: Entry[] };
};
type Props = {
export type Props = {
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
@@ -29,7 +30,7 @@ type Props = {
onEntryMouseLeave?: (entry: Entry) => void;
};
const useStyles = makeStyles<Theme>(() => ({
const useStyles = makeStyles<Theme>(theme => ({
quadrant: {
height: '100%',
width: '100%',
@@ -40,7 +41,7 @@ const useStyles = makeStyles<Theme>(() => ({
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: 'calc(18px * 0.375)',
marginBottom: theme.spacing(8 / (18 * 0.375)),
fontSize: '18px',
},
rings: {
@@ -56,7 +57,7 @@ const useStyles = makeStyles<Theme>(() => ({
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: 'calc(12px * 0.375)',
marginBottom: theme.spacing(8 / (12 * 0.375)),
fontSize: '12px',
fontWeight: 800,
},
@@ -79,73 +80,81 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarLegend: FC<Props> = props => {
const RadarLegend = (props: Props): JSX.Element => {
const classes = useStyles(props);
const _getSegment = (
const getSegment = (
segmented: Segments,
quadrant: Quadrant,
ring: Ring,
ringOffset = 0,
) => {
const qidx = quadrant.idx;
const ridx = ring.idx;
const segmentedData = qidx === undefined ? {} : segmented[qidx] || {};
return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || [];
const quadrantIndex = quadrant.index;
const ringIndex = ring.index;
const segmentedData =
quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {};
return ringIndex === undefined
? []
: segmentedData[ringIndex + ringOffset] || [];
};
const _renderRing = (
ring: Ring,
entries: Entry[],
onEntryMouseEnter?: Props['onEntryMouseEnter'],
onEntryMouseLeave?: Props['onEntryMouseEnter'],
) => {
type RadarLegendRingProps = {
ring: Ring;
entries: Entry[];
onEntryMouseEnter?: Props['onEntryMouseEnter'];
onEntryMouseLeave?: Props['onEntryMouseEnter'];
};
const RadarLegendRing = ({
ring,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendRingProps) => {
return (
<div key={ring.id} className={classes.ring}>
<div data-testid="radar-ring" 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 || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
{node}
</li>
);
})}
{entries.map(entry => (
<li
key={entry.id}
value={(entry.index || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
<WithLink url={entry.url} className={classes.entryLink}>
<span className={classes.entry}>{entry.title}</span>
</WithLink>
</li>
))}
</ol>
)}
</div>
);
};
const _renderQuadrant = (
segments: Segments,
quadrant: Quadrant,
rings: Ring[],
onEntryMouseEnter: Props['onEntryMouseEnter'],
onEntryMouseLeave: Props['onEntryMouseLeave'],
) => {
type RadarLegendQuadrantProps = {
segments: Segments;
quadrant: Quadrant;
rings: Ring[];
onEntryMouseEnter: Props['onEntryMouseEnter'];
onEntryMouseLeave: Props['onEntryMouseLeave'];
};
const RadarLegendQuadrant = ({
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendQuadrantProps) => {
return (
<foreignObject
key={quadrant.id}
@@ -153,46 +162,48 @@ const RadarLegend: FC<Props> = props => {
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
data-testid="radar-quadrant"
>
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map(ring =>
_renderRing(
ring,
_getSegment(segments, quadrant, ring),
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
{rings.map(ring => (
<RadarLegendRing
key={ring.id}
ring={ring}
entries={getSegment(segments, quadrant, ring)}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</div>
</div>
</foreignObject>
);
};
const _setupSegments = (entries: Entry[]) => {
const setupSegments = (entries: Entry[]) => {
const segments: Segments = {};
for (const entry of entries) {
const qidx = entry.quadrant.idx;
const ridx = entry.ring.idx;
const quadrantIndex = entry.quadrant.index;
const ringIndex = entry.ring.index;
let quadrantData: { [k: number]: Entry[] } = {};
if (qidx !== undefined) {
if (segments[qidx] === undefined) {
segments[qidx] = {};
if (quadrantIndex !== undefined) {
if (segments[quadrantIndex] === undefined) {
segments[quadrantIndex] = {};
}
quadrantData = segments[qidx];
quadrantData = segments[quadrantIndex];
}
let ringData = [];
if (ridx !== undefined) {
if (quadrantData[ridx] === undefined) {
quadrantData[ridx] = [];
if (ringIndex !== undefined) {
if (quadrantData[ringIndex] === undefined) {
quadrantData[ringIndex] = [];
}
ringData = quadrantData[ridx];
ringData = quadrantData[ringIndex];
}
ringData.push(entry);
@@ -209,19 +220,20 @@ const RadarLegend: FC<Props> = props => {
onEntryMouseLeave,
} = props;
const segments: Segments = _setupSegments(entries);
const segments: Segments = setupSegments(entries);
return (
<g>
{quadrants.map(quadrant =>
_renderQuadrant(
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
<g data-testid="radar-legend">
{quadrants.map(quadrant => (
<RadarLegendQuadrant
key={quadrant.id}
segments={segments}
quadrant={quadrant}
rings={rings}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</g>
);
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { Grid } from '@material-ui/core';
import {
Content,
@@ -29,7 +29,7 @@ import {
import RadarComponent from '../components/RadarComponent';
import { techRadarApiRef, TechRadarApi } from '../api';
const RadarPage: FC<{}> = () => {
const RadarPage = (): JSX.Element => {
const techRadarApi = useApi<TechRadarApi>(techRadarApiRef);
return (
@@ -0,0 +1,67 @@
/*
* 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 RadarPlot, { Props } from './RadarPlot';
const minProps: Props = {
width: 500,
height: 200,
radius: 50,
quadrants: [{ id: 'languages', name: 'Languages' }],
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
entries: [
{
id: 'typescript',
title: 'TypeScript',
quadrant: { id: 'languages', name: 'Languages' },
moved: 0,
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
},
],
};
describe('RadarPlot', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarPlot {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-plot')).toBeInTheDocument();
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
expect(rendered.getByTestId('radar-footer')).toBeInTheDocument();
expect(rendered.getByTestId('radar-bubble')).toBeInTheDocument();
expect(rendered.getAllByTestId('radar-entry')).toHaveLength(1);
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { Quadrant, Ring, Entry } from '../../utils/types';
import React from 'react';
import type { Quadrant, Ring, Entry } from '../../utils/types';
import RadarGrid from '../RadarGrid';
import RadarEntry from '../RadarEntry';
@@ -23,7 +23,7 @@ import RadarBubble from '../RadarBubble';
import RadarFooter from '../RadarFooter';
import RadarLegend from '../RadarLegend';
type Props = {
export type Props = {
width: number;
height: number;
radius: number;
@@ -36,7 +36,7 @@ type Props = {
};
// A component that draws the radar circle.
const RadarPlot: FC<Props> = props => {
const RadarPlot = (props: Props): JSX.Element => {
const {
width,
height,
@@ -50,7 +50,7 @@ const RadarPlot: FC<Props> = props => {
} = props;
return (
<g>
<g data-testid="radar-plot">
<RadarLegend
quadrants={quadrants}
rings={rings}
@@ -71,7 +71,7 @@ const RadarPlot: FC<Props> = props => {
x={entry.x || 0}
y={entry.y || 0}
color={entry.color || ''}
number={((entry && entry.idx) || 0) + 1}
value={(entry?.index || 0) + 1}
url={entry.url}
moved={entry.moved}
onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))}
@@ -80,9 +80,9 @@ const RadarPlot: FC<Props> = props => {
))}
<RadarBubble
visible={!!activeEntry}
text={activeEntry ? activeEntry.title : ''}
x={activeEntry ? activeEntry.x || 0 : 0}
y={activeEntry ? activeEntry.y || 0 : 0}
text={activeEntry?.title || ''}
x={activeEntry?.x || 0}
y={activeEntry?.y || 0}
/>
</g>
</g>
@@ -0,0 +1,35 @@
/*
* 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';
type WithLinkProps = {
url?: string;
className: string;
children: React.ReactNode;
};
export const WithLink = ({
url,
className,
children,
}: WithLinkProps): JSX.Element =>
url ? (
<a href={url} className={className}>
{children}
</a>
) : (
<>{children}</>
);
+10 -4
View File
@@ -17,7 +17,7 @@
// Parameters for a ring; its index in an array determines how close to the center this ring is.
export type Ring = {
id: string;
idx?: number;
index?: number;
name: string;
color: string;
outerRadius?: number;
@@ -27,7 +27,7 @@ export type Ring = {
// Parameters for a quadrant (there should be exactly 4 of course)
export type Quadrant = {
id: string;
idx?: number;
index?: number;
name: string;
legendX?: number;
legendY?: number;
@@ -45,9 +45,15 @@ export type Segment = {
random: Function;
};
export enum MovedState {
Down = -1,
NoChange = 0,
Up = 1,
}
export type Entry = {
id: string;
idx?: number;
index?: number;
x?: number;
y?: number;
color?: string;
@@ -61,7 +67,7 @@ export type Entry = {
// An URL to a longer description as to why this entry is where it is
url?: string;
// How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved
moved?: number;
moved?: MovedState;
active?: boolean;
};