Merge branch 'master' of github.com:spotify/backstage into shmidt-i/location-update-results

This commit is contained in:
Ivan Shmidt
2020-05-28 15:56:38 +02:00
162 changed files with 1327 additions and 346 deletions
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { GoogleAuthProvider, THOUSAND_DAYS_MS } from './provider';
import {
GoogleAuthProvider,
THOUSAND_DAYS_MS,
TEN_MINUTES_MS,
} from './provider';
import passport from 'passport';
import express from 'express';
import * as utils from './../utils';
@@ -52,11 +56,16 @@ describe('GoogleAuthProvider', () => {
});
describe('start authentication handler', () => {
const mockResponse = ({} as unknown) as express.Response;
const mockResponse = ({
send: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
cookie: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const mockNext: express.NextFunction = jest.fn();
it('should initiate authenticate request with provided scopes', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
query: {
scope: 'a,b',
},
@@ -75,11 +84,36 @@ describe('GoogleAuthProvider', () => {
scope: 'a,b',
accessType: 'offline',
prompt: 'consent',
state: expect.any(String),
});
});
it('should set a nonce cookie', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
query: {
scope: 'a,b',
},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.start(mockRequest, mockResponse, mockNext);
expect(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
'google-nonce',
expect.any(String),
expect.objectContaining({
maxAge: TEN_MINUTES_MS,
path: `/auth/${googleAuthProviderConfig.provider}/handler`,
}),
);
});
it('should throw error if no scopes provided', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
query: {},
} as unknown) as express.Request;
@@ -93,7 +127,9 @@ describe('GoogleAuthProvider', () => {
});
describe('logout handler', () => {
const mockRequest = ({} as unknown) as express.Request;
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
it('should perform logout and respond with 200', () => {
const mockResponse: any = ({
@@ -122,7 +158,6 @@ describe('GoogleAuthProvider', () => {
});
describe('redirect frame handler', () => {
const mockRequest = ({} as unknown) as express.Request;
const mockResponse: any = ({
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
@@ -131,6 +166,14 @@ describe('GoogleAuthProvider', () => {
const mockNext: express.NextFunction = jest.fn();
it('should call authenticate and post a response', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
const spyPostMessage = jest
.spyOn(utils, 'postMessageResponse')
.mockImplementation(() => jest.fn());
@@ -164,6 +207,14 @@ describe('GoogleAuthProvider', () => {
});
it('should respond with a error message if no refresh token returned', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
@@ -190,6 +241,14 @@ describe('GoogleAuthProvider', () => {
});
it('should respond with a error message if auth failed', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {
state: 'NONCE',
},
} as unknown) as express.Request;
const spyPassport = jest
.spyOn(passport, 'authenticate')
.mockImplementation((_x, callbackFunc) => {
@@ -214,6 +273,60 @@ describe('GoogleAuthProvider', () => {
error: new Error('Google auth failed, Error: TokenError'),
});
});
it('should respond with a error message if cookie nonce is missing', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: {},
query: { state: 'NONCE' },
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Missing nonce');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
it('should respond with a error message if state nonce is missing', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCE' },
query: {},
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Missing nonce');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
it('should respond with a error message if nonce mismatch', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-nonce': 'NONCA' },
query: { state: 'NONCEB' },
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
googleAuthProviderConfig,
);
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith('Invalid nonce');
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
});
describe('strategy handler', () => {
@@ -245,6 +358,7 @@ describe('GoogleAuthProvider', () => {
it('should respond with a 401', () => {
const mockRequest = ({
cookies: jest.fn(),
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
const googleAuthProvider = new GoogleAuthProvider(
@@ -262,6 +376,7 @@ describe('GoogleAuthProvider', () => {
describe('refresh token cookie, no scope', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
query: {},
} as unknown) as express.Request;
@@ -349,6 +464,7 @@ describe('GoogleAuthProvider', () => {
describe('refresh token cookie and scope', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
query: {
scope: 'a,b',
@@ -387,6 +503,20 @@ describe('GoogleAuthProvider', () => {
scope: 'a,b',
});
});
it('ensures x-requested-with header', () => {
const mockHeaderRequest = ({
header: () => 'TEST',
} as unknown) as express.Request;
googleAuthProvider.refresh(mockHeaderRequest, mockResponse);
expect(mockResponse.send).toBeCalledTimes(1);
expect(mockResponse.send).toBeCalledWith(
'Invalid X-Requested-With header',
);
expect(mockResponse.status).toBeCalledTimes(1);
expect(mockResponse.status).toBeCalledWith(401);
});
});
});
});
@@ -16,6 +16,7 @@
import passport from 'passport';
import express, { CookieOptions } from 'express';
import crypto from 'crypto';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import refresh from 'passport-oauth2-refresh';
import {
@@ -23,10 +24,11 @@ import {
AuthProviderRouteHandlers,
AuthProviderConfig,
} from './../types';
import { postMessageResponse } from './../utils';
import { postMessageResponse, ensuresXRequestedWith } from './../utils';
import { InputError } from '@backstage/backend-common';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
export class GoogleAuthProvider
implements AuthProvider, AuthProviderRouteHandlers {
private readonly providerConfig: AuthProviderConfig;
@@ -39,6 +41,19 @@ export class GoogleAuthProvider
res: express.Response,
next: express.NextFunction,
) {
const nonce = crypto.randomBytes(16).toString('base64');
const options: CookieOptions = {
maxAge: TEN_MINUTES_MS,
secure: false,
sameSite: 'none',
domain: 'localhost',
path: `/auth/${this.providerConfig.provider}/handler`,
httpOnly: true,
};
res.cookie(`${this.providerConfig.provider}-nonce`, nonce, options);
const scope = req.query.scope?.toString() ?? '';
if (!scope) {
throw new InputError('missing scope parameter');
@@ -47,6 +62,7 @@ export class GoogleAuthProvider
scope,
accessType: 'offline',
prompt: 'consent',
state: nonce,
})(req, res, next);
}
@@ -55,6 +71,17 @@ export class GoogleAuthProvider
res: express.Response,
next: express.NextFunction,
) {
const cookieNonce = req.cookies[`${this.providerConfig.provider}-nonce`];
const stateNonce = req.query.state;
if (!cookieNonce || !stateNonce) {
return res.status(401).send('Missing nonce');
}
if (cookieNonce !== stateNonce) {
return res.status(401).send('Invalid nonce');
}
return passport.authenticate('google', (err, user) => {
if (err) {
return postMessageResponse(res, {
@@ -95,7 +122,11 @@ export class GoogleAuthProvider
})(req, res, next);
}
async logout(_req: express.Request, res: express.Response) {
async logout(req: express.Request, res: express.Response) {
if (!ensuresXRequestedWith(req)) {
return res.status(401).send('Invalid X-Requested-With header');
}
const options: CookieOptions = {
maxAge: 0,
secure: false,
@@ -110,6 +141,10 @@ export class GoogleAuthProvider
}
async refresh(req: express.Request, res: express.Response) {
if (!ensuresXRequestedWith(req)) {
return res.status(401).send('Invalid X-Requested-With header');
}
const refreshToken =
req.cookies[`${this.providerConfig.provider}-refresh-token`];
+4 -4
View File
@@ -20,11 +20,11 @@ import { ProviderFactories } from './factories';
export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
const router = Router();
router.get('/start', provider.start);
router.get('/handler/frame', provider.frameHandler);
router.get('/logout', provider.logout);
router.get('/start', provider.start.bind(provider));
router.get('/handler/frame', provider.frameHandler.bind(provider));
router.get('/logout', provider.logout.bind(provider));
if (provider.refresh) {
router.get('/refresh', provider.refresh);
router.get('/refresh', provider.refresh.bind(provider));
}
return router;
};
@@ -39,3 +39,12 @@ export const postMessageResponse = (
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
+1 -1
View File
@@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
@@ -18,6 +18,7 @@ import React, { FC } from 'react';
import {
Content,
ContentHeader,
DismissableBanner,
Header,
HomepageTimer,
SupportButton,
@@ -31,7 +32,7 @@ import {
CatalogFilter,
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { Button, makeStyles } from '@material-ui/core';
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import { filterGroups, defaultFilter } from '../../data/filters';
const useStyles = makeStyles(theme => ({
@@ -65,6 +66,22 @@ const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
<HomepageTimer />
</Header>
<Content>
<DismissableBanner
variant="info"
message={
<Typography>
<span role="img" aria-label="wave" style={{ fontSize: '125%' }}>
👋🏼
</span>{' '}
Welcome to Backstage, we are happy to have you. Start by checking
out our{' '}
<Link href="/welcome" color="textSecondary">
getting started
</Link>{' '}
page.
</Typography>
}
/>
<ContentHeader title="Services">
<Button variant="contained" color="primary" href="/create">
Create Service
@@ -83,8 +83,8 @@ const ComponentPage: FC<ComponentPageProps> = ({
};
return (
<Page theme={pageTheme.home}>
<Header title={catalogRequest?.value?.name || 'Catalog'}>
<Page theme={pageTheme.service}>
<Header title={catalogRequest?.value?.name || 'Catalog'} type="service">
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
</Header>
{confirmationDialogOpen && catalogRequest.value && (
+4 -4
View File
@@ -20,7 +20,7 @@ const ARTIFICIAL_TIMEOUT = 800;
let inMemoryStore = [...mock];
export const MockComponentFactory: ComponentFactory = {
getAllComponents(): Promise<Component[]> {
return new Promise((resolve) =>
return new Promise(resolve =>
setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT),
);
},
@@ -28,7 +28,7 @@ export const MockComponentFactory: ComponentFactory = {
return new Promise((resolve, reject) =>
setTimeout(() => {
const mockComponent = inMemoryStore.find(
(component) => component.name === name,
component => component.name === name,
);
if (mockComponent) return resolve(mockComponent);
return reject({ code: 'Component not found!' });
@@ -36,10 +36,10 @@ export const MockComponentFactory: ComponentFactory = {
);
},
removeComponentByName(name: string): Promise<boolean> {
return new Promise((resolve) =>
return new Promise(resolve =>
setTimeout(() => {
inMemoryStore = inMemoryStore.filter(
(component) => component.name !== name,
component => component.name !== name,
);
resolve(true);
}, ARTIFICIAL_TIMEOUT),
+1 -1
View File
@@ -22,7 +22,7 @@ import { withMockStore } from './data/with-mock-store';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.registerRoute('/catalog', withMockStore(CatalogPage));
router.registerRoute('/', withMockStore(CatalogPage));
router.registerRoute('/catalog/:name/', withMockStore(ComponentPage));
},
});
@@ -22,7 +22,7 @@ import SettingsIcon from '@material-ui/icons/Settings';
import { useSettings } from '../../state';
export type Props = { title?: string };
export const PluginHeader: FC<Props> = ({ title = 'Circle CI' }) => {
export const PluginHeader: FC<Props> = ({ title = 'CircleCI' }) => {
const [, { showSettings }] = useSettings();
const location = useLocation();
const notRoot = !location.pathname.match(/\/circleci\/?$/);
@@ -80,7 +80,7 @@ const Settings = () => {
value={token}
fullWidth
variant="outlined"
onChange={(e) => setToken(e.target.value)}
onChange={e => setToken(e.target.value)}
/>
</ListItem>
<ListItem>
@@ -90,7 +90,7 @@ const Settings = () => {
label="Owner"
variant="outlined"
value={owner}
onChange={(e) => setOwner(e.target.value)}
onChange={e => setOwner(e.target.value)}
/>
</ListItem>
<ListItem>
@@ -100,7 +100,7 @@ const Settings = () => {
fullWidth
variant="outlined"
value={repo}
onChange={(e) => setRepo(e.target.value)}
onChange={e => setRepo(e.target.value)}
/>
</ListItem>
<ListItem>
@@ -35,7 +35,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
</IconLink>
</Box>
);
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
neutral: {},
failed: {
position: 'relative',
@@ -50,8 +50,8 @@ export const ActionOutput: FC<{
const [messages, setMessages] = useState([]);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((actionOutput) => {
.then(res => res.json())
.then(actionOutput => {
if (typeof actionOutput !== 'undefined') {
setMessages(
actionOutput.map(({ message }: { message: string }) => message),
@@ -26,7 +26,7 @@ export const useAsyncPolling = (
while (isPolling.current === true) {
await pollingFn();
await new Promise((resolve) => setTimeout(resolve, interval));
await new Promise(resolve => setTimeout(resolve, interval));
}
};
+1 -1
View File
@@ -29,7 +29,7 @@ export function useSettings() {
if (
stateFromStorage &&
Object.keys(stateFromStorage).some(
(k) => (settings as any)[k] !== stateFromStorage[k],
k => (settings as any)[k] !== stateFromStorage[k],
)
)
dispatch({
+1 -1
View File
@@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
@@ -49,7 +49,7 @@ describe('AuditView', () => {
apis = ApiRegistry.from([
[lighthouseApiRef, new LighthouseRestApi('https://lighthouse')],
]);
id = websiteResponse.audits.find((a) => a.status === 'COMPLETED')
id = websiteResponse.audits.find(a => a.status === 'COMPLETED')
?.id as string;
useParams.mockReturnValue({ id });
});
@@ -101,7 +101,7 @@ describe('AuditView', () => {
await rendered.findByTestId('audit-sidebar');
websiteResponse.audits.forEach((a) => {
websiteResponse.audits.forEach(a => {
expect(
rendered.queryByText(formatTime(a.timeCreated)),
).toBeInTheDocument();
@@ -119,14 +119,14 @@ describe('AuditView', () => {
await rendered.findByTestId('audit-sidebar');
const audit = websiteResponse.audits.find((a) => a.id === id) as Audit;
const audit = websiteResponse.audits.find(a => a.id === id) as Audit;
const auditElement = rendered.getByText(formatTime(audit.timeCreated));
expect(auditElement.parentElement?.parentElement?.className).toContain(
'selected',
);
const notSelectedAudit = websiteResponse.audits.find(
(a) => a.id !== id,
a => a.id !== id,
) as Audit;
const notSelectedAuditElement = rendered.getByText(
formatTime(notSelectedAudit.timeCreated),
@@ -147,7 +147,7 @@ describe('AuditView', () => {
await rendered.findByTestId('audit-sidebar');
websiteResponse.audits.forEach((a) => {
websiteResponse.audits.forEach(a => {
expect(
rendered.getByText(formatTime(a.timeCreated)).parentElement
?.parentElement,
@@ -186,7 +186,7 @@ describe('AuditView', () => {
describe.skip('when a loading audit is accessed', () => {
it('shows a loading view', async () => {
id = websiteResponse.audits.find((a) => a.status === 'RUNNING')
id = websiteResponse.audits.find(a => a.status === 'RUNNING')
?.id as string;
useParams.mockReturnValueOnce({ id });
@@ -206,7 +206,7 @@ describe('AuditView', () => {
describe.skip('when a failed audit is accessed', () => {
it('shows an error message', async () => {
id = websiteResponse.audits.find((a) => a.status === 'FAILED')
id = websiteResponse.audits.find(a => a.status === 'FAILED')
?.id as string;
useParams.mockReturnValueOnce({ id });
@@ -165,7 +165,7 @@ describe('CreateAudit', () => {
fireEvent.click(rendered.getByText(/Create Audit/));
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
await new Promise((r) => setTimeout(r, 0));
await new Promise(r => setTimeout(r, 0));
expect(errorApi.post).toHaveBeenCalledWith(expect.any(Error));
});
@@ -28,7 +28,7 @@ import { BackstageTheme } from '@backstage/theme';
import { Progress } from '@backstage/core';
import { ComponentIdValidators } from '../../util/validate';
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
form: {
alignItems: 'flex-start',
display: 'flex',
@@ -40,6 +40,7 @@ const ScaffolderPage: React.FC<{}> = () => {
return (
<Page theme={pageTheme.home}>
<Header
pageTitleOverride="Create a new component"
title={
<>
Create a new component <Lifecycle alpha shorthand />{' '}
+1 -1
View File
@@ -1,3 +1,3 @@
# sentry-backend
Simple plugin forwarding requests to [Sentry](https://sentry.io) API.
Simple plugin forwarding requests to [Sentry](https://sentry.io) API.
@@ -22,7 +22,7 @@ import { BackstageTheme } from '@backstage/theme';
function stripText(text: string, maxLength: number) {
return text.length > maxLength ? `${text.substr(0, maxLength)}...` : text;
}
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
minWidth: 260,
position: 'relative',
@@ -24,16 +24,16 @@ import { ErrorGraph } from '../ErrorGraph/ErrorGraph';
const columns: TableColumn[] = [
{
title: 'Error',
render: (data) => <ErrorCell sentryIssue={data as SentryIssue} />,
render: data => <ErrorCell sentryIssue={data as SentryIssue} />,
},
{
title: 'Graph',
render: (data) => <ErrorGraph sentryIssue={data as SentryIssue} />,
render: data => <ErrorGraph sentryIssue={data as SentryIssue} />,
},
{
title: 'First seen',
field: 'firstSeen',
render: (data) => {
render: data => {
const { firstSeen } = data as SentryIssue;
return format(firstSeen);
},
@@ -41,7 +41,7 @@ const columns: TableColumn[] = [
{
title: 'Last seen',
field: 'lastSeen',
render: (data) => {
render: data => {
const { lastSeen } = data as SentryIssue;
return format(lastSeen);
},
+1 -1
View File
@@ -33,7 +33,7 @@ function getMockIssues(number: number): SentryIssue[] {
}
export class MockSentryApi implements SentryApi {
fetchIssues(): Promise<SentryIssue[]> {
return new Promise((resolve) => {
return new Promise(resolve => {
setTimeout(() => resolve(getMockIssues(14)), 800);
});
}
+4 -1
View File
@@ -5,7 +5,10 @@
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"private": false,
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
@@ -105,14 +105,14 @@ export default class Radar extends React.Component {
static adjustEntries(entries, activeEntry, quadrants, rings, radius) {
let seed = 42;
entries.forEach((entry, idx) => {
const quadrant = quadrants.find((q) => {
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 ring = rings.find(r => {
const match =
typeof entry.ring === 'object' ? entry.ring.id : entry.ring;
return r.id === match;
@@ -190,7 +190,7 @@ export default class Radar extends React.Component {
return (
<svg
ref={(node) => {
ref={node => {
this.node = node;
}}
width={width}
@@ -205,7 +205,7 @@ export default class Radar extends React.Component {
quadrants={quadrants}
rings={rings}
activeEntry={activeEntry}
onEntryMouseEnter={(entry) => this._setActiveEntry(entry)}
onEntryMouseEnter={entry => this._setActiveEntry(entry)}
onEntryMouseLeave={() => this._clearActiveEntry()}
/>
</svg>
@@ -49,16 +49,16 @@ class RadarBubble extends React.PureComponent {
this._updatePosition();
}
_setRect = (rect) => {
_setRect = rect => {
this.rect = rect;
};
_setNode = (node) => {
_setNode = node => {
this.node = node;
};
_setText = (text) => {
_setText = text => {
this.text = text;
};
_setPath = (path) => {
_setPath = path => {
this.path = path;
};
@@ -84,7 +84,7 @@ class RadarGrid extends React.PureComponent {
/>,
];
const ringNodes = rings.map((r) => r.outerRadius).map(makeRingNode);
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
return axisNodes.concat(ringNodes);
}
@@ -88,7 +88,7 @@ class RadarLegend extends React.PureComponent {
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map((ring) =>
{rings.map(ring =>
RadarLegend._renderRing(
ring,
RadarLegend._getSegment(segments, quadrant, ring),
@@ -117,7 +117,7 @@ class RadarLegend extends React.PureComponent {
<p>(empty)</p>
) : (
<ol className={classes.ringList}>
{entries.map((entry) => {
{entries.map(entry => {
let node = <span className={classes.entry}>{entry.title}</span>;
if (entry.url) {
@@ -175,7 +175,7 @@ class RadarLegend extends React.PureComponent {
return (
<g>
{quadrants.map((quadrant) =>
{quadrants.map(quadrant =>
RadarLegend._renderQuadrant(
segments,
quadrant,
@@ -46,16 +46,16 @@ export default class RadarPlot extends React.PureComponent {
rings={rings}
entries={entries}
onEntryMouseEnter={
onEntryMouseEnter && ((entry) => onEntryMouseEnter(entry))
onEntryMouseEnter && (entry => onEntryMouseEnter(entry))
}
onEntryMouseLeave={
onEntryMouseLeave && ((entry) => onEntryMouseLeave(entry))
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) => (
{entries.map(entry => (
<RadarEntry
key={entry.id}
x={entry.x}
+1 -1
View File
@@ -20,7 +20,7 @@ import WelcomePage from './components/WelcomePage';
export const plugin = createPlugin({
id: 'welcome',
register({ router, featureFlags }) {
router.registerRoute('/', WelcomePage);
router.registerRoute('/welcome', WelcomePage);
featureFlags.register('enable-welcome-box');
},