Merge branch 'master' of github.com:spotify/backstage into catalog-edit-link
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
SidebarUserBadge,
|
||||
SidebarThemeToggle,
|
||||
} from '@backstage/core';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
root: {
|
||||
@@ -56,9 +57,11 @@ const SidebarLogo: FC<{}> = () => {
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Link href="/" underline="none" className={classes.link}>
|
||||
{isOpen ? <LogoFull /> : <LogoIcon />}
|
||||
</Link>
|
||||
<NavLink to="/">
|
||||
<Link underline="none" className={classes.link}>
|
||||
{isOpen ? <LogoFull /> : <LogoIcon />}
|
||||
</Link>
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ app. Until then, feel free to experiment here!
|
||||
To run the example backend, first go to the project root and run
|
||||
|
||||
```bash
|
||||
yarn tsc
|
||||
yarn install
|
||||
yarn build
|
||||
```
|
||||
|
||||
@@ -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 const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export type { Location, LocationSpec } from './types';
|
||||
export { locationSchema, locationSpecSchema } from './validation';
|
||||
export { LOCATION_ANNOTATION } from './annotation';
|
||||
|
||||
@@ -143,6 +143,7 @@ export async function installWithLocalDeps(dir: string) {
|
||||
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
|
||||
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
delete pkgJson.devDependencies[`@backstage/${name}`];
|
||||
|
||||
await fs
|
||||
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
|
||||
|
||||
@@ -143,6 +143,30 @@ export type OpenIdConnectApi = {
|
||||
logout(): Promise<void>;
|
||||
};
|
||||
|
||||
export type ProfileInfoOptions = {
|
||||
/**
|
||||
* If this is set to true, the user will not be prompted to log in,
|
||||
* and an empty profile will be returned if there is no existing session.
|
||||
*
|
||||
* This can be used to perform a check whether the user is logged in, or if you don't
|
||||
* want to force a user to be logged in, but provide functionality if they already are.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
optional?: boolean;
|
||||
};
|
||||
|
||||
export type ProfileInfoApi = {
|
||||
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
|
||||
};
|
||||
|
||||
export type ProfileInfo = {
|
||||
provider: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides authentication towards Google APIs and identities.
|
||||
*
|
||||
@@ -151,7 +175,9 @@ export type OpenIdConnectApi = {
|
||||
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
|
||||
* email and expiration information. Do not rely on any other fields, as they might not be present.
|
||||
*/
|
||||
export const googleAuthApiRef = createApiRef<OAuthApi & OpenIdConnectApi>({
|
||||
export const googleAuthApiRef = createApiRef<
|
||||
OAuthApi & OpenIdConnectApi & ProfileInfoApi
|
||||
>({
|
||||
id: 'core.auth.google',
|
||||
description: 'Provides authentication towards Google APIs and identities',
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
OpenIdConnectApi,
|
||||
IdTokenOptions,
|
||||
AccessTokenOptions,
|
||||
ProfileInfoApi,
|
||||
ProfileInfoOptions,
|
||||
ProfileInfo,
|
||||
} from '../../../definitions/auth';
|
||||
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
@@ -39,6 +42,7 @@ type CreateOptions = {
|
||||
};
|
||||
|
||||
export type GoogleAuthResponse = {
|
||||
profile: ProfileInfo;
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
@@ -53,7 +57,7 @@ const DEFAULT_PROVIDER = {
|
||||
|
||||
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
|
||||
static create({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
@@ -69,6 +73,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
oauthRequestApi: oauthRequestApi,
|
||||
sessionTransform(res: GoogleAuthResponse): GoogleSession {
|
||||
return {
|
||||
profile: res.profile,
|
||||
idToken: res.idToken,
|
||||
accessToken: res.accessToken,
|
||||
scopes: GoogleAuth.normalizeScopes(res.scope),
|
||||
@@ -123,6 +128,14 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
await this.sessionManager.removeSession();
|
||||
}
|
||||
|
||||
async getProfile(options: ProfileInfoOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
return session.profile;
|
||||
}
|
||||
|
||||
static normalizeScopes(scopes?: string | string[]): Set<string> {
|
||||
if (!scopes) {
|
||||
return new Set();
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ProfileInfo } from '../../../definitions';
|
||||
|
||||
export type GoogleSession = {
|
||||
profile: ProfileInfo;
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
|
||||
@@ -117,6 +117,10 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
|
||||
window.location.reload(); // TODO(Rugvip): make this work without reload?
|
||||
}
|
||||
|
||||
async getCurrentSession() {
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
private async collapsedSessionRefresh(): Promise<T> {
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TODO(blam): Remove this implementation when the Tabs are ready
|
||||
// This is just a temporary solution to implementing tabs for now
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles, Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
tabsWrapper: {
|
||||
gridArea: 'pageSubheader',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
defaultTab: {
|
||||
padding: theme.spacing(3, 3),
|
||||
...theme.typography.caption,
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 'bold',
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
selected: {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}));
|
||||
|
||||
export type Tab = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.tabsWrapper}>
|
||||
<Tabs
|
||||
indicatorColor="primary"
|
||||
textColor="inherit"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="scrollable auto tabs example"
|
||||
value={0}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
label={tab.label}
|
||||
key={tab.id}
|
||||
value={index}
|
||||
className={styles.defaultTab}
|
||||
classes={{ selected: styles.selected }}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,48 +14,285 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import { sidebarConfig } from './config';
|
||||
import { Avatar, Typography } from '@material-ui/core';
|
||||
import {
|
||||
Avatar,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Popover,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { blueGrey } from '@material-ui/core/colors';
|
||||
import { useSetState } from 'react-use';
|
||||
import { Skeleton } from '@material-ui/lab';
|
||||
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api';
|
||||
import LogoutIcon from '@material-ui/icons/PowerSettingsNew';
|
||||
import ControlPointIcon from '@material-ui/icons/ControlPoint';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => {
|
||||
const useStyles = makeStyles<Theme>(theme => {
|
||||
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
|
||||
return {
|
||||
root: {
|
||||
width: drawerWidthOpen,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#b5b5b5',
|
||||
paddingLeft: 18,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
color: '#b5b5b5',
|
||||
},
|
||||
avatar: {
|
||||
width: userBadgeDiameter,
|
||||
height: userBadgeDiameter,
|
||||
marginRight: 8,
|
||||
},
|
||||
purple: {
|
||||
color: theme.palette.getContrastText(blueGrey[500]),
|
||||
backgroundColor: blueGrey[500],
|
||||
},
|
||||
listItemText: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SessionListItem: FC<{
|
||||
classes: any;
|
||||
loading: boolean;
|
||||
title: string;
|
||||
icon: any;
|
||||
user: any;
|
||||
onSignIn: Function;
|
||||
onSignOut: Function;
|
||||
}> = ({
|
||||
classes,
|
||||
loading,
|
||||
title,
|
||||
icon,
|
||||
user,
|
||||
onSignIn,
|
||||
onSignOut,
|
||||
...props
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemIcon style={{ marginRight: 0 }}>
|
||||
<Skeleton variant="circle" width={40} height={40} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Skeleton component="span" width={120} />}
|
||||
secondary={<Skeleton component="span" width={60} />}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton>
|
||||
<Skeleton variant="circle" width={24} height={24} />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Not functional yet to sign in from the sidebar
|
||||
if (!user) {
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemIcon style={{ marginRight: 0 }}>{icon}</ListItemIcon>
|
||||
<ListItemText primary="Sign In" secondary={title} />
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip
|
||||
title={`Sign in with ${title}`}
|
||||
placement="bottom-end"
|
||||
PopperProps={{ style: { width: 120 } }}
|
||||
>
|
||||
<IconButton onClick={() => onSignIn()}>
|
||||
<ControlPointIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const { id, avatarUrl, avatarAlt } = user;
|
||||
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={avatarUrl} alt={avatarAlt}>
|
||||
{avatarAlt && avatarAlt[0].toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
className={classes.listItemText}
|
||||
primary={
|
||||
<Typography className={classes.listItemText} variant="body2">
|
||||
{id}
|
||||
</Typography>
|
||||
}
|
||||
secondary={title}
|
||||
/>
|
||||
<ListItemSecondaryAction style={{ marginLeft: '30px' }}>
|
||||
<Tooltip
|
||||
title={`Sign out from ${title}`}
|
||||
placement="bottom-end"
|
||||
PopperProps={{ style: { width: 120 } }}
|
||||
>
|
||||
<IconButton onClick={() => onSignOut()}>
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const useGoogleLoginState = (open: boolean) => {
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
let didCancel = false;
|
||||
|
||||
if (open) {
|
||||
googleAuth.getProfile().then(_profile => {
|
||||
if (!didCancel) {
|
||||
setProfile(_profile);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, [open, googleAuth]);
|
||||
|
||||
if (loading) {
|
||||
return { loading: true };
|
||||
}
|
||||
return { loading: false, isLoggedIn: !!profile, profile };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
hideName?: boolean;
|
||||
email: string;
|
||||
imageUrl?: string;
|
||||
name?: string;
|
||||
collapsedMode?: boolean;
|
||||
};
|
||||
|
||||
export const LoggedUserBadge: FC<Props> = ({
|
||||
imageUrl,
|
||||
name,
|
||||
hideName = false,
|
||||
email,
|
||||
collapsedMode = false,
|
||||
}) => {
|
||||
const [state, setState] = useSetState({
|
||||
open: false,
|
||||
anchorEl: null,
|
||||
});
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const googleLogin = useGoogleLoginState(state.open);
|
||||
|
||||
const handleOpen = (event: {
|
||||
preventDefault: () => void;
|
||||
currentTarget: any;
|
||||
}) => {
|
||||
// This prevents ghost click.
|
||||
event.preventDefault();
|
||||
setState({
|
||||
open: true,
|
||||
anchorEl: event.currentTarget,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState({
|
||||
open: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
googleAuth.getIdToken();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleGoogleSignOut = () => {
|
||||
googleAuth.logout();
|
||||
};
|
||||
|
||||
const classes = useStyles();
|
||||
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
|
||||
const emailTrimmed = email.split('@')[0];
|
||||
const displayEmail =
|
||||
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
|
||||
const displayName = name ?? displayEmail;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
{!hideName && <Typography variant="subtitle2">{name}</Typography>}
|
||||
</div>
|
||||
<>
|
||||
<List dense>
|
||||
<ListItem className={classes.root} onClick={handleOpen}>
|
||||
<ListItemAvatar>
|
||||
{imageUrl ? (
|
||||
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
) : (
|
||||
<Avatar
|
||||
alt={name}
|
||||
className={`${classes.avatar} ${classes.purple}`}
|
||||
>
|
||||
{avatarFallback[0]}
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
{!collapsedMode && (
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography className={classes.listItemText} variant="body2">
|
||||
{displayName}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
</List>
|
||||
<Popover
|
||||
transitionDuration={0}
|
||||
open={state.open}
|
||||
anchorEl={state.anchorEl}
|
||||
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
|
||||
transformOrigin={{ horizontal: 'center', vertical: 'bottom' }}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<List dense>
|
||||
<SessionListItem
|
||||
classes={classes}
|
||||
loading={googleLogin.loading}
|
||||
title="Google"
|
||||
icon={AccountCircleIcon}
|
||||
user={
|
||||
googleLogin.isLoggedIn && {
|
||||
id: googleLogin.profile?.email,
|
||||
avatarUrl: googleLogin.profile?.picture ?? '',
|
||||
avatarAlt:
|
||||
googleLogin.profile?.picture ?? googleLogin.profile?.email,
|
||||
}
|
||||
}
|
||||
onSignIn={handleGoogleSignIn}
|
||||
onSignOut={handleGoogleSignOut}
|
||||
/>
|
||||
</List>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useContext } from 'react';
|
||||
import React, { FC, useContext, useEffect, useState } from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import People from '@material-ui/icons/People';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
import { SidebarContext } from './config';
|
||||
import { SidebarItem } from './Items';
|
||||
import { LoggedUserBadge } from './LoggedUserBadge';
|
||||
import DoubleArrowIcon from '@material-ui/icons/DoubleArrow';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { SidebarPinStateContext } from './Page';
|
||||
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api';
|
||||
|
||||
const ARROW_BUTTON_SIZE = 20;
|
||||
const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(theme => {
|
||||
@@ -58,18 +59,30 @@ export const SidebarUserBadge: FC<{}> = () => {
|
||||
SidebarPinStateContext,
|
||||
);
|
||||
const classes = useStyles({ isPinned });
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
// TODO(soapraj): How to observe if the user is logged in
|
||||
// TODO(soapraj): List all the providers supported by the app and let user log in from here
|
||||
googleAuth.getProfile({ optional: true }).then(googleProfile => {
|
||||
setProfile(googleProfile);
|
||||
});
|
||||
}, [googleAuth]);
|
||||
|
||||
const isUserLoggedIn = false;
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{isUserLoggedIn ? (
|
||||
<LoggedUserBadge
|
||||
imageUrl="https://via.placeholder.com/200/200"
|
||||
name="Victor Viale"
|
||||
hideName={!isOpen}
|
||||
/>
|
||||
{profile ? (
|
||||
<>
|
||||
<LoggedUserBadge
|
||||
email={profile.email}
|
||||
imageUrl={profile.picture}
|
||||
name={profile.name}
|
||||
collapsedMode={!isOpen}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SidebarItem icon={People} text="Log in" to="/login" disableSelected />
|
||||
<SidebarItem icon={AccountCircleIcon} text="" disableSelected />
|
||||
)}
|
||||
{isOpen && (
|
||||
<button
|
||||
|
||||
@@ -24,3 +24,4 @@ export * from './InfoCard';
|
||||
export * from './Page';
|
||||
export * from './Sidebar';
|
||||
export * from './TabbedCard';
|
||||
export * from './HeaderTabs';
|
||||
|
||||
@@ -2,9 +2,13 @@ import {
|
||||
ApiRegistry,
|
||||
alertApiRef,
|
||||
errorApiRef,
|
||||
oauthRequestApiRef,
|
||||
OAuthRequestManager,
|
||||
googleAuthApiRef,
|
||||
AlertApiForwarder,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
GoogleAuth,
|
||||
} from '@backstage/core';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
@@ -13,4 +17,18 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
|
||||
|
||||
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
|
||||
|
||||
const oauthRequestApi = builder.add(
|
||||
oauthRequestApiRef,
|
||||
new OAuthRequestManager(),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
googleAuthApiRef,
|
||||
GoogleAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
export const apis = builder.build();
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-saml": "^1.3.3",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
"yn": "^4.0.0",
|
||||
"jwt-decode": "2.2.0",
|
||||
"@types/jwt-decode": "2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
|
||||
@@ -143,6 +143,14 @@ describe('PassportStrategyHelper', () => {
|
||||
class MyCustomRefreshTokenSuccess extends passport.Strategy {
|
||||
// @ts-ignore
|
||||
private _oauth2 = new MyCustomOAuth2Success();
|
||||
userProfile(_accessToken: string, callback: Function) {
|
||||
callback(null, {
|
||||
provider: 'a',
|
||||
email: 'b',
|
||||
name: 'c',
|
||||
picture: 'd',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mockStrategy = new MyCustomRefreshTokenSuccess();
|
||||
|
||||
@@ -16,7 +16,43 @@
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import { RedirectInfo, RefreshTokenResponse } from './types';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: passport.Profile,
|
||||
params: any,
|
||||
): ProfileInfo => {
|
||||
const { provider, displayName: name } = profile;
|
||||
|
||||
let email = '';
|
||||
if (profile.emails) {
|
||||
const [firstEmail] = profile.emails;
|
||||
email = firstEmail.value;
|
||||
}
|
||||
|
||||
if (!email && params.id_token) {
|
||||
try {
|
||||
const decoded: { email: string } = jwtDecoder(params.id_token);
|
||||
email = decoded.email;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse id token and get profile info');
|
||||
}
|
||||
}
|
||||
|
||||
let picture = '';
|
||||
if (profile.photos) {
|
||||
const [firstPhoto] = profile.photos;
|
||||
picture = firstPhoto.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
name,
|
||||
email,
|
||||
picture,
|
||||
};
|
||||
};
|
||||
|
||||
export const executeRedirectStrategy = async (
|
||||
req: express.Request,
|
||||
@@ -98,6 +134,7 @@ export const executeRefreshTokenStrategy = async (
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
accessToken,
|
||||
params,
|
||||
@@ -106,3 +143,24 @@ export const executeRefreshTokenStrategy = async (
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const executeFetchUserProfileStrategy = async (
|
||||
providerstrategy: passport.Strategy,
|
||||
accessToken: string,
|
||||
params: any,
|
||||
): Promise<ProfileInfo> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerstrategy as any;
|
||||
anyStrategy.userProfile(
|
||||
accessToken,
|
||||
(error: Error, passportProfile: passport.Profile) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const profile = makeProfileInfo(passportProfile, params);
|
||||
resolve(profile);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
executeFetchUserProfileStrategy,
|
||||
} from '../PassportStrategyHelper';
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
@@ -27,8 +29,10 @@ import {
|
||||
AuthInfoPrivate,
|
||||
RedirectInfo,
|
||||
AuthProviderConfig,
|
||||
AuthInfoWithProfile,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../OAuthProvider';
|
||||
import passport from 'passport';
|
||||
|
||||
export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
@@ -43,13 +47,14 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
accessToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
profile: any,
|
||||
profile: passport.Profile,
|
||||
done: any,
|
||||
) => {
|
||||
const profileInfo = makeProfileInfo(profile, params);
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
profile,
|
||||
profile: profileInfo,
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
@@ -73,18 +78,28 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
return await executeFrameHandlerStrategy(req, this._strategy);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, scope: string): Promise<AuthInfoBase> {
|
||||
async refresh(
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<AuthInfoWithProfile> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
refreshToken,
|
||||
scope,
|
||||
);
|
||||
|
||||
const profile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
|
||||
export type AuthProviderConfig = {
|
||||
provider: string;
|
||||
@@ -49,7 +48,14 @@ export type AuthInfoBase = {
|
||||
};
|
||||
|
||||
export type AuthInfoWithProfile = AuthInfoBase & {
|
||||
profile: passport.Profile;
|
||||
profile:
|
||||
| {
|
||||
provider: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type AuthInfoPrivate = {
|
||||
@@ -71,6 +77,13 @@ export type RedirectInfo = {
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type ProfileInfo = {
|
||||
provider: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
};
|
||||
|
||||
export type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
params: any;
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
apiVersion: backstage.io/v1beta1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: component1
|
||||
name: playlist-proxy
|
||||
spec:
|
||||
type: service
|
||||
---
|
||||
apiVersion: backstage.io/v1beta1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: component2
|
||||
name: artist-web
|
||||
spec:
|
||||
type: website
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
curl \
|
||||
--location \
|
||||
--request POST 'localhost:3003/locations' \
|
||||
--request POST 'localhost:7000/catalog/locations' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"type": "github",
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
InputError,
|
||||
NotFoundError,
|
||||
} from '@backstage/backend-common';
|
||||
import type { Entity, EntityMeta, Location } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
EntityMeta,
|
||||
Location,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import Knex from 'knex';
|
||||
import lodash from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -167,7 +172,7 @@ export class CommonDatabase implements Database {
|
||||
annotations: {
|
||||
...(newEntity.metadata?.annotations ?? {}),
|
||||
...(request.locationId
|
||||
? { 'backstage.io/managed-by-location': request.locationId }
|
||||
? { [LOCATION_ANNOTATION]: request.locationId }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
Location,
|
||||
LocationSpec,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import lodash from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
@@ -23,8 +28,6 @@ import { IngestionModel } from '../ingestion';
|
||||
import { AddLocationResult, HigherOrderOperation } from './types';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
|
||||
|
||||
/**
|
||||
* Placeholder for operations that span several catalogs and/or stretches out
|
||||
* in time.
|
||||
|
||||
@@ -81,6 +81,8 @@ describe('Integration: GitHubLocationSource', () => {
|
||||
});
|
||||
|
||||
it('fetches the fixture from backstage repo', async () => {
|
||||
(fetch as any).mockResolvedValueOnce(new Response('component3'));
|
||||
|
||||
const PERMANENT_LINK =
|
||||
'https://github.com/spotify/backstage/blob/ee84a874f8e37f87940cbe515a86c07a2db29541/plugins/catalog-backend/fixtures/one_component.yaml';
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function createStandaloneApplication(
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use(
|
||||
'/',
|
||||
'/catalog',
|
||||
await createRouter({
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
|
||||
import { CatalogApi } from './types';
|
||||
import { DescriptorEnvelope } from '../types';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
Location,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
export class CatalogClient implements CatalogApi {
|
||||
private apiOrigin: string;
|
||||
@@ -31,6 +35,22 @@ export class CatalogClient implements CatalogApi {
|
||||
this.apiOrigin = apiOrigin;
|
||||
this.basePath = basePath;
|
||||
}
|
||||
async getLocationById(id: String): Promise<Location | undefined> {
|
||||
const response = await fetch(
|
||||
`${this.apiOrigin}${this.basePath}/locations/${id}`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const location = await response.json();
|
||||
if (location) return location.data;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
async getEntitiesByLocationId(id: string): Promise<Entity[]> {
|
||||
const response = await fetch(
|
||||
`${this.apiOrigin}${this.basePath}/entities?${LOCATION_ANNOTATION}=${id}`,
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
async getEntities(): Promise<DescriptorEnvelope[]> {
|
||||
const response = await fetch(`${this.apiOrigin}${this.basePath}/entities`);
|
||||
return await response.json();
|
||||
@@ -72,20 +92,11 @@ export class CatalogClient implements CatalogApi {
|
||||
}
|
||||
|
||||
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
|
||||
const findLocationIdInEntity = (e: Entity): string | undefined =>
|
||||
e.metadata.annotations?.['backstage.io/managed-by-location'];
|
||||
|
||||
const locationId = findLocationIdInEntity(entity);
|
||||
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
|
||||
if (!locationId) return undefined;
|
||||
|
||||
const response = await fetch(
|
||||
`${this.apiOrigin}${this.basePath}/locations/${locationId}`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const location = await response.json();
|
||||
if (location) return location.data;
|
||||
}
|
||||
const location = this.getLocationById(locationId);
|
||||
|
||||
return undefined;
|
||||
return location;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,10 @@ export const catalogApiRef = createApiRef<CatalogApi>({
|
||||
});
|
||||
|
||||
export interface CatalogApi {
|
||||
getLocationById(id: String): Promise<Location | undefined>;
|
||||
getEntities(): Promise<Entity[]>;
|
||||
getEntityByName(name: string): Promise<Entity>;
|
||||
getEntitiesByLocationId(id: string): Promise<Entity[]>;
|
||||
addLocation(type: string, target: string): Promise<AddLocationResponse>;
|
||||
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useCallback, useState, useEffect } from 'react';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
Page,
|
||||
pageTheme,
|
||||
useApi,
|
||||
HeaderTabs,
|
||||
} from '@backstage/core';
|
||||
import { useAsync, useMountedState } from 'react-use';
|
||||
import { useAsync } from 'react-use';
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
import {
|
||||
CatalogFilter,
|
||||
@@ -37,7 +38,11 @@ import { filterGroups, defaultFilter } from '../../data/filters';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
Location,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -49,17 +54,15 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent } from '../../data/utils';
|
||||
import { entityToComponent, findLocationForEntity } from '../../data/utils';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
|
||||
defaultFilter,
|
||||
);
|
||||
const isMounted = useMountedState();
|
||||
|
||||
const onFilterSelected = useCallback(
|
||||
selected => setSelectedFilter(selected),
|
||||
@@ -67,25 +70,26 @@ const CatalogPage: FC<{}> = () => {
|
||||
);
|
||||
const styles = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
const { value: locations } = useAsync(async () => {
|
||||
const getLocationDataForEntities = async (entities: Entity[]) => {
|
||||
return Promise.all(
|
||||
entities.map(entity => catalogApi.getLocationByEntity(entity)),
|
||||
entities.map(entity => {
|
||||
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
|
||||
if (!locationId) return undefined;
|
||||
|
||||
return catalogApi.getLocationById(locationId);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (value) {
|
||||
getLocationDataForEntities(value)
|
||||
.then(
|
||||
(location): Location[] =>
|
||||
location.filter(l => !!l) as Array<Location>,
|
||||
)
|
||||
.then(location => {
|
||||
if (isMounted()) setLocations(location);
|
||||
});
|
||||
return getLocationDataForEntities(value).then(
|
||||
(location): Location[] =>
|
||||
location.filter(loc => !!loc) as Array<Location>,
|
||||
);
|
||||
}
|
||||
}, [value, catalogApi, isMounted]);
|
||||
|
||||
return [];
|
||||
}, [value, catalogApi, catalogApi]);
|
||||
const actions = [
|
||||
(rowData: Component) => ({
|
||||
icon: GitHub,
|
||||
@@ -99,20 +103,32 @@ const CatalogPage: FC<{}> = () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const findLocationForEntity = (
|
||||
entity: Entity,
|
||||
l: Location[],
|
||||
): Location | undefined => {
|
||||
const entityLocationId =
|
||||
entity.metadata.annotations?.['backstage.io/managed-by-location'];
|
||||
return l.find(location => location.id === entityLocationId);
|
||||
};
|
||||
// TODO: replace me with the proper tabs implemntation
|
||||
const tabs = [
|
||||
{
|
||||
id: 'services',
|
||||
label: 'Services',
|
||||
},
|
||||
{
|
||||
id: 'websites',
|
||||
label: 'Websites',
|
||||
},
|
||||
{
|
||||
id: 'libs',
|
||||
label: 'Libraries',
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
label: 'Documentation',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title="Service Catalog" subtitle="Keep track of your software">
|
||||
<HomepageTimer />
|
||||
</Header>
|
||||
<HeaderTabs tabs={tabs} />
|
||||
<Content>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
@@ -130,6 +146,7 @@ const CatalogPage: FC<{}> = () => {
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
<ContentHeader title="Services">
|
||||
<Button
|
||||
component={RouterLink}
|
||||
@@ -149,22 +166,24 @@ const CatalogPage: FC<{}> = () => {
|
||||
onSelectedChange={onFilterSelected}
|
||||
/>
|
||||
</div>
|
||||
<CatalogTable
|
||||
titlePreamble={selectedFilter.label}
|
||||
components={
|
||||
(value &&
|
||||
value.map(val =>
|
||||
envelopeToComponent(
|
||||
val,
|
||||
findLocationForEntity(val, locations),
|
||||
),
|
||||
)) ||
|
||||
[]
|
||||
}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={actions}
|
||||
/>
|
||||
{locations && (
|
||||
<CatalogTable
|
||||
titlePreamble={selectedFilter.label}
|
||||
components={
|
||||
(value &&
|
||||
value.map(val => {
|
||||
return {
|
||||
...entityToComponent(val),
|
||||
location: findLocationForEntity(val, locations),
|
||||
};
|
||||
})) ||
|
||||
[]
|
||||
}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Content>
|
||||
</Page>
|
||||
|
||||
@@ -24,13 +24,16 @@ import {
|
||||
useApi,
|
||||
ErrorApi,
|
||||
errorApiRef,
|
||||
HeaderTabs,
|
||||
} from '@backstage/core';
|
||||
import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu';
|
||||
import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog';
|
||||
|
||||
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent } from '../../data/utils';
|
||||
import { entityToComponent } from '../../data/utils';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
const REDIRECT_DELAY = 1000;
|
||||
|
||||
@@ -54,18 +57,20 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
const errorApi = useApi<ErrorApi>(errorApiRef);
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const catalogRequest = useAsync(() =>
|
||||
catalogApi.getEntityByName(match.params.name),
|
||||
);
|
||||
const { value: component, error, loading } = useAsync<Component>(async () => {
|
||||
const entity = await catalogApi.getEntityByName(match.params.name);
|
||||
const location = await catalogApi.getLocationByEntity(entity);
|
||||
return { ...entityToComponent(entity), location };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (catalogRequest.error) {
|
||||
if (error) {
|
||||
errorApi.post(new Error('Component not found!'));
|
||||
setTimeout(() => {
|
||||
history.push('/catalog');
|
||||
history.push('/');
|
||||
}, REDIRECT_DELAY);
|
||||
}
|
||||
}, [catalogRequest.error, errorApi, history]);
|
||||
}, [error, errorApi, history]);
|
||||
|
||||
if (componentName === '') {
|
||||
history.push('/catalog');
|
||||
@@ -76,17 +81,47 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
setConfirmationDialogOpen(false);
|
||||
setRemovingPending(true);
|
||||
// await componentFactory.removeComponentByName(componentName);
|
||||
history.push('/catalog');
|
||||
|
||||
await catalogApi;
|
||||
history.push('/');
|
||||
};
|
||||
|
||||
const component = envelopeToComponent(catalogRequest.value! ?? {});
|
||||
// TODO - Replace with proper tabs implementation
|
||||
const tabs = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
},
|
||||
{
|
||||
id: 'ci',
|
||||
label: 'CI/CD',
|
||||
},
|
||||
{
|
||||
id: 'tests',
|
||||
label: 'Tests',
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
label: 'API',
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
label: 'Monitoring',
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
label: 'Quality',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title={component.name || 'Catalog'}>
|
||||
<Header title={component?.name || 'Catalog'}>
|
||||
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
|
||||
</Header>
|
||||
{confirmationDialogOpen && catalogRequest.value && (
|
||||
<HeaderTabs tabs={tabs} />
|
||||
|
||||
{confirmationDialogOpen && component && (
|
||||
<ComponentRemovalDialog
|
||||
component={component}
|
||||
onClose={hideRemovalDialog}
|
||||
@@ -98,7 +133,7 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ComponentMetadataCard
|
||||
loading={catalogRequest.loading || removingPending}
|
||||
loading={loading || removingPending}
|
||||
component={component}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
useTheme,
|
||||
} from '@material-ui/core';
|
||||
import { Component } from '../../data/component';
|
||||
import { useAsync } from 'react-use';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { catalogApiRef } from '../../api/types';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
type ComponentRemovalDialogProps = {
|
||||
onConfirm: () => any;
|
||||
@@ -38,18 +42,28 @@ const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
|
||||
onClose,
|
||||
component,
|
||||
}) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value } = useAsync(async () => {
|
||||
let colocatedEntities: Array<Entity> = [];
|
||||
const locationId = component.location?.id;
|
||||
if (locationId) {
|
||||
colocatedEntities = await catalogApi.getEntitiesByLocationId(locationId);
|
||||
}
|
||||
return colocatedEntities;
|
||||
});
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const infoMessage = `This action will unregister ${
|
||||
value ? value.map(e => e.metadata.name).join(', ') : ''
|
||||
} from location with target ${component.location?.target}. To undo,
|
||||
just re-register the component in Backstage.`;
|
||||
return (
|
||||
<Dialog fullScreen={fullScreen} open onClose={onClose}>
|
||||
<DialogTitle id="responsive-dialog-title">
|
||||
Are you sure you want to unregister this component?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This action will unregister {component.name}. To undo, just
|
||||
re-register the component in Backstage.
|
||||
</DialogContentText>
|
||||
<DialogContentText>{infoMessage}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel} color="primary">
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Component } from './component';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
import {
|
||||
Entity,
|
||||
Location,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import { styled } from '@material-ui/core/styles';
|
||||
@@ -34,7 +38,7 @@ const createEditLink = (location: Location): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export function envelopeToComponent(
|
||||
export function entityToComponent(
|
||||
envelope: Entity,
|
||||
location?: Location,
|
||||
): Component {
|
||||
@@ -56,3 +60,15 @@ export function envelopeToComponent(
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
export function findLocationForEntity(
|
||||
entity: Entity,
|
||||
locations: Location[],
|
||||
): Location | undefined {
|
||||
for (const loc of locations) {
|
||||
if (loc.id === entity.metadata.annotations?.[LOCATION_ANNOTATION]) {
|
||||
return loc;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+2
@@ -30,6 +30,8 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
getEntitiesByLocationId: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = () => ({
|
||||
|
||||
@@ -35,8 +35,8 @@ export interface RadarEntry {
|
||||
key: string; // react key
|
||||
id: string;
|
||||
moved: number;
|
||||
quadrant: string;
|
||||
ring: string;
|
||||
quadrant: RadarQuadrant;
|
||||
ring: RadarRing;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
/*
|
||||
* 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,
|
||||
};
|
||||
@@ -31,9 +31,9 @@ const minProps = {
|
||||
{
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
quadrant: 'languages',
|
||||
quadrant: { id: 'languages', name: 'Languages' },
|
||||
moved: 0,
|
||||
ring: 'use',
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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, useRef } from 'react';
|
||||
import RadarPlot from '../RadarPlot';
|
||||
import { Ring, Quadrant, Entry } from '../../utils/types';
|
||||
import { adjustQuadrants, adjustRings, adjustEntries } from './utils';
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
height: number;
|
||||
quadrants: Quadrant[];
|
||||
rings: Ring[];
|
||||
entries: Entry[];
|
||||
svgProps?: object;
|
||||
};
|
||||
|
||||
const Radar: FC<Props> = props => {
|
||||
const { width, height, quadrants, rings, entries } = props;
|
||||
const radius = Math.min(width, height) / 2;
|
||||
|
||||
const [activeEntry, setActiveEntry] = useState<Entry | null>();
|
||||
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);
|
||||
|
||||
return (
|
||||
<svg ref={node} width={width} height={height} {...props.svgProps}>
|
||||
<RadarPlot
|
||||
width={width}
|
||||
height={height}
|
||||
radius={radius}
|
||||
entries={entries}
|
||||
quadrants={quadrants}
|
||||
rings={rings}
|
||||
activeEntry={activeEntry || undefined}
|
||||
onEntryMouseEnter={entry => setActiveEntry(entry)}
|
||||
onEntryMouseLeave={() => setActiveEntry(null)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Radar;
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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 color from 'color';
|
||||
import { forceCollide, forceSimulation } from 'd3-force';
|
||||
import Segment from '../../utils/segment';
|
||||
import { Ring, Quadrant, Entry } from '../../utils/types';
|
||||
|
||||
export const adjustQuadrants = (
|
||||
quadrants: Quadrant[],
|
||||
radius: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) => {
|
||||
/*
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
export const adjustEntries = (
|
||||
entries: Entry[],
|
||||
activeEntry: Entry | null | undefined,
|
||||
quadrants: Quadrant[],
|
||||
rings: Ring[],
|
||||
radius: number,
|
||||
) => {
|
||||
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) {
|
||||
if (entry.segment) {
|
||||
entry.x = entry.segment.clipx(entry);
|
||||
entry.y = entry.segment.clipy(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const adjustRings = (rings: Ring[], radius: number) => {
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* 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,119 @@
|
||||
/*
|
||||
* 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, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
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',
|
||||
},
|
||||
}));
|
||||
|
||||
const RadarBubble: FC<Props> = props => {
|
||||
const classes = useStyles(props);
|
||||
const { visible, text } = props;
|
||||
|
||||
const textElem = useRef<SVGTextElement>(null);
|
||||
const svgElem = useRef<SVGGElement>(null);
|
||||
const rectElem = useRef<SVGRectElement>(null);
|
||||
const pathElem = useRef<SVGPathElement>(null);
|
||||
|
||||
const updatePosition = () => {
|
||||
if (textElem.current) {
|
||||
const { x, y } = props;
|
||||
const bbox = textElem.current.getBBox();
|
||||
const marginX = 5;
|
||||
const marginY = 4;
|
||||
|
||||
if (svgElem.current) {
|
||||
svgElem.current.setAttribute(
|
||||
'transform',
|
||||
`translate(${x - bbox.width / 2}, ${y - bbox.height - marginY})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rectElem.current) {
|
||||
rectElem.current.setAttribute('x', String(-marginX));
|
||||
rectElem.current.setAttribute('y', String(-bbox.height));
|
||||
rectElem.current.setAttribute(
|
||||
'width',
|
||||
String(bbox.width + 2 * marginX),
|
||||
);
|
||||
rectElem.current.setAttribute('height', String(bbox.height + marginY));
|
||||
}
|
||||
|
||||
if (pathElem.current) {
|
||||
pathElem.current.setAttribute(
|
||||
'transform',
|
||||
`translate(${bbox.width / 2 - marginX}, ${marginY - 1})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePosition();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
return (
|
||||
<g
|
||||
ref={svgElem}
|
||||
x={0}
|
||||
y={0}
|
||||
className={visible ? classes.visibleBubble : classes.bubble}
|
||||
>
|
||||
<rect ref={rectElem} rx={4} ry={4} className={classes.background} />
|
||||
<text ref={textElem} className={classes.text}>
|
||||
{text}
|
||||
</text>
|
||||
<path
|
||||
ref={pathElem}
|
||||
d="M 0,0 10,0 5,8 z"
|
||||
className={classes.background}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadarBubble;
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* 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,95 @@
|
||||
/*
|
||||
* 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 { makeStyles, Theme } from '@material-ui/core';
|
||||
|
||||
type Props = {
|
||||
x: number;
|
||||
y: number;
|
||||
number: number;
|
||||
color: string;
|
||||
url?: string;
|
||||
moved?: number;
|
||||
onMouseEnter?: (event: React.MouseEvent<SVGGElement, MouseEvent>) => void;
|
||||
onMouseLeave?: (event: React.MouseEvent<SVGGElement, MouseEvent>) => void;
|
||||
onClick?: (event: React.MouseEvent<SVGGElement, MouseEvent>) => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
text: {
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
fontSize: '9px',
|
||||
fill: '#fff',
|
||||
textAnchor: 'middle',
|
||||
},
|
||||
|
||||
link: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}));
|
||||
|
||||
const RadarEntry: FC<Props> = props => {
|
||||
const classes = useStyles(props);
|
||||
|
||||
const {
|
||||
moved,
|
||||
color,
|
||||
url,
|
||||
number,
|
||||
x,
|
||||
y,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x}, ${y})`}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
>
|
||||
{blip}
|
||||
<text y={3} className={classes.text}>
|
||||
{number}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadarEntry;
|
||||
+20
-27
@@ -14,39 +14,32 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withStyles } from '@material-ui/core';
|
||||
import React, { FC } from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
|
||||
const styles = {
|
||||
type Props = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
text: {
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
fontSize: '10px',
|
||||
fill: '#000',
|
||||
},
|
||||
}));
|
||||
|
||||
const RadarFooter: FC<Props> = props => {
|
||||
const { x, y } = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
return (
|
||||
<text transform={`translate(${x}, ${y})`} className={classes.text}>
|
||||
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
export default RadarFooter;
|
||||
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* 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,96 @@
|
||||
/*
|
||||
* 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 { makeStyles, Theme } from '@material-ui/core';
|
||||
import { Ring } from '../../utils/types';
|
||||
|
||||
type Props = {
|
||||
radius: number;
|
||||
rings: Ring[];
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
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.
|
||||
const RadarGrid = (props: Props) => {
|
||||
const { radius, rings } = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [
|
||||
<circle
|
||||
key={`c${ringIndex}`}
|
||||
cx={0}
|
||||
cy={0}
|
||||
r={ringRadius}
|
||||
className={classes.ring}
|
||||
/>,
|
||||
<text
|
||||
key={`t${ringIndex}`}
|
||||
y={ringRadius !== undefined ? -ringRadius + 42 : undefined}
|
||||
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)}</>;
|
||||
};
|
||||
|
||||
export default RadarGrid;
|
||||
+117
-91
@@ -13,12 +13,23 @@
|
||||
* 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';
|
||||
import React, { FC } from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
import { Quadrant, Ring, Entry } from '../../utils/types';
|
||||
|
||||
const styles = {
|
||||
type Segments = {
|
||||
[k: number]: { [k: number]: Entry[] };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
quadrants: Quadrant[];
|
||||
rings: Ring[];
|
||||
entries: Entry[];
|
||||
onEntryMouseEnter?: (entry: Entry) => void;
|
||||
onEntryMouseLeave?: (entry: Entry) => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
quadrant: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
@@ -66,50 +77,29 @@ const styles = {
|
||||
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>
|
||||
);
|
||||
}
|
||||
const RadarLegend: FC<Props> = props => {
|
||||
const classes = useStyles(props);
|
||||
|
||||
static _renderRing(
|
||||
ring,
|
||||
entries,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
classes,
|
||||
) {
|
||||
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 _renderRing = (
|
||||
ring: Ring,
|
||||
entries: Entry[],
|
||||
onEntryMouseEnter?: Props['onEntryMouseEnter'],
|
||||
onEntryMouseLeave?: Props['onEntryMouseEnter'],
|
||||
) => {
|
||||
return (
|
||||
<div key={ring.id} className={classes.ring}>
|
||||
<h3 className={classes.ringHeading}>{ring.name}</h3>
|
||||
@@ -131,12 +121,12 @@ class RadarLegend extends React.PureComponent {
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
value={entry.idx + 1}
|
||||
value={(entry.idx || 0) + 1}
|
||||
onMouseEnter={
|
||||
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
|
||||
}
|
||||
onMouseLeave={
|
||||
onEntryMouseEnter && (() => onEntryMouseLeave(entry))
|
||||
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
|
||||
}
|
||||
>
|
||||
{node}
|
||||
@@ -147,57 +137,93 @@ class RadarLegend extends React.PureComponent {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
static _getSegment(segmented, quadrant, ring, ringOffset = 0) {
|
||||
return (segmented[quadrant.idx] || {})[ring.idx + ringOffset] || [];
|
||||
}
|
||||
const _renderQuadrant = (
|
||||
segments: Segments,
|
||||
quadrant: Quadrant,
|
||||
rings: Ring[],
|
||||
onEntryMouseEnter: Props['onEntryMouseEnter'],
|
||||
onEntryMouseLeave: Props['onEntryMouseLeave'],
|
||||
) => {
|
||||
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 =>
|
||||
_renderRing(
|
||||
ring,
|
||||
_getSegment(segments, quadrant, ring),
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
quadrants,
|
||||
rings,
|
||||
entries,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
classes,
|
||||
} = this.props;
|
||||
|
||||
const segments = {};
|
||||
const _setupSegments = (entries: Entry[]) => {
|
||||
const segments: 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] = []);
|
||||
let quadrantData: { [k: number]: Entry[] } = {};
|
||||
if (qidx !== undefined) {
|
||||
if (segments[qidx] === undefined) {
|
||||
segments[qidx] = {};
|
||||
}
|
||||
|
||||
quadrantData = segments[qidx];
|
||||
}
|
||||
|
||||
let ringData = [];
|
||||
if (ridx !== undefined) {
|
||||
if (quadrantData[ridx] === undefined) {
|
||||
quadrantData[ridx] = [];
|
||||
}
|
||||
|
||||
ringData = quadrantData[ridx];
|
||||
}
|
||||
|
||||
ringData.push(entry);
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
{quadrants.map(quadrant =>
|
||||
RadarLegend._renderQuadrant(
|
||||
segments,
|
||||
quadrant,
|
||||
rings,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
classes,
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
|
||||
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,
|
||||
const {
|
||||
quadrants,
|
||||
rings,
|
||||
entries,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
} = props;
|
||||
|
||||
const segments: Segments = _setupSegments(entries);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{quadrants.map(quadrant =>
|
||||
_renderQuadrant(
|
||||
segments,
|
||||
quadrant,
|
||||
rings,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
),
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default withStyles(styles)(RadarLegend);
|
||||
export default RadarLegend;
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* 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,92 @@
|
||||
/*
|
||||
* 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 { Quadrant, Ring, Entry } from '../../utils/types';
|
||||
|
||||
import RadarGrid from '../RadarGrid';
|
||||
import RadarEntry from '../RadarEntry';
|
||||
import RadarBubble from '../RadarBubble';
|
||||
import RadarFooter from '../RadarFooter';
|
||||
import RadarLegend from '../RadarLegend';
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
height: number;
|
||||
radius: number;
|
||||
rings: Ring[];
|
||||
quadrants: Quadrant[];
|
||||
entries: Entry[];
|
||||
activeEntry?: Entry;
|
||||
onEntryMouseEnter?: (entry: Entry) => void;
|
||||
onEntryMouseLeave?: (entry: Entry) => void;
|
||||
};
|
||||
|
||||
// A component that draws the radar circle.
|
||||
const RadarPlot: FC<Props> = props => {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
quadrants,
|
||||
rings,
|
||||
entries,
|
||||
activeEntry,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
} = 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 || 0}
|
||||
y={entry.y || 0}
|
||||
color={entry.color || ''}
|
||||
number={((entry && entry.idx) || 0) + 1}
|
||||
url={entry.url}
|
||||
moved={entry.moved}
|
||||
onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))}
|
||||
onMouseLeave={onEntryMouseLeave && (() => onEntryMouseLeave(entry))}
|
||||
/>
|
||||
))}
|
||||
<RadarBubble
|
||||
visible={!!activeEntry}
|
||||
text={activeEntry ? activeEntry.title : ''}
|
||||
x={activeEntry ? activeEntry.x || 0 : 0}
|
||||
y={activeEntry ? activeEntry.y || 0 : 0}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export default RadarPlot;
|
||||
@@ -36,48 +36,48 @@ quadrants.push({ id: 'process', name: 'Process' });
|
||||
const entries = new Array<RadarEntry>();
|
||||
entries.push({
|
||||
moved: 0,
|
||||
ring: 'use',
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
key: 'javascript',
|
||||
id: 'javascript',
|
||||
title: 'JavaScript',
|
||||
quadrant: 'languages',
|
||||
quadrant: { id: 'languages', name: 'Languages' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
ring: 'use',
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
key: 'typescript',
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
quadrant: 'languages',
|
||||
quadrant: { id: 'languages', name: 'Languages' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
ring: 'use',
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
key: 'webpack',
|
||||
id: 'webpack',
|
||||
title: 'Webpack',
|
||||
quadrant: 'frameworks',
|
||||
quadrant: { id: 'frameworks', name: 'Frameworks' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
ring: 'use',
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
key: 'react',
|
||||
id: 'react',
|
||||
title: 'React',
|
||||
quadrant: 'frameworks',
|
||||
quadrant: { id: 'frameworks', name: 'Frameworks' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
ring: 'use',
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
key: 'code-reviews',
|
||||
id: 'code-reviews',
|
||||
title: 'Code Reviews',
|
||||
quadrant: 'process',
|
||||
quadrant: { id: 'process', name: 'Process' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
@@ -85,17 +85,17 @@ entries.push({
|
||||
key: 'mob-programming',
|
||||
id: 'mob-programming',
|
||||
title: 'Mob Programming',
|
||||
quadrant: 'process',
|
||||
ring: 'assess',
|
||||
quadrant: { id: 'process', name: 'Process' },
|
||||
ring: { id: 'assess', name: 'ASSESS', color: '#fbdb84' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
ring: 'use',
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
key: 'github-actions',
|
||||
id: 'github-actions',
|
||||
title: 'GitHub Actions',
|
||||
quadrant: 'infrastructure',
|
||||
quadrant: { id: 'infrastructure', name: 'Infrastructure' },
|
||||
});
|
||||
|
||||
export default function getSampleData(): Promise<TechRadarLoaderResponse> {
|
||||
|
||||
+43
-24
@@ -14,40 +14,59 @@
|
||||
* 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,
|
||||
export type Ring = {
|
||||
id: string;
|
||||
idx?: number;
|
||||
name: string;
|
||||
color: string;
|
||||
outerRadius?: number;
|
||||
innerRadius?: number;
|
||||
};
|
||||
|
||||
// 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 type Quadrant = {
|
||||
id: string;
|
||||
idx?: number;
|
||||
name: string;
|
||||
legendX?: number;
|
||||
legendY?: number;
|
||||
legendWidth?: number;
|
||||
legendHeight?: number;
|
||||
radialMin?: number;
|
||||
radialMax?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
};
|
||||
|
||||
export const ENTRY = {
|
||||
id: PropTypes.string.isRequired,
|
||||
idx: PropTypes.number,
|
||||
export type Segment = {
|
||||
clipx: Function;
|
||||
clipy: Function;
|
||||
random: Function;
|
||||
};
|
||||
|
||||
export type Entry = {
|
||||
id: string;
|
||||
idx?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
color?: string;
|
||||
segment?: Segment;
|
||||
// The quadrant where this entry belongs
|
||||
quadrant: PropTypes.shape(QUADRANT).isRequired,
|
||||
quadrant: Quadrant;
|
||||
// The ring where this entry belongs
|
||||
ring: PropTypes.shape(RING).isRequired,
|
||||
ring: Ring;
|
||||
// The label that's shown in the legend and on hover
|
||||
title: PropTypes.string.isRequired,
|
||||
title: string;
|
||||
// An URL to a longer description as to why this entry is where it is
|
||||
url: PropTypes.string,
|
||||
url?: string;
|
||||
// How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved
|
||||
moved: PropTypes.number,
|
||||
moved?: number;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
// 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,
|
||||
});
|
||||
// The same as Entry except quadrant/ring are declared by their string ID instead of being the actual objects
|
||||
export type DeclaredEntry = Entry & {
|
||||
quadrant: string;
|
||||
ring: string;
|
||||
};
|
||||
@@ -2665,17 +2665,6 @@
|
||||
telejson "^3.2.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/channel-postmessage@5.3.18":
|
||||
version "5.3.18"
|
||||
resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-5.3.18.tgz#93d46740b5cc9b36ddd073f0715b54c4959953bf"
|
||||
integrity sha512-awxBW/aVfNtY9QvYZgsPaMXgUpC2+W3vEyQcl/w4ce0YVH+7yWx3wt3Ku49lQwxZwDrxP3QoC0U+mkPc9hBJwA==
|
||||
dependencies:
|
||||
"@storybook/channels" "5.3.18"
|
||||
"@storybook/client-logger" "5.3.18"
|
||||
core-js "^3.0.1"
|
||||
global "^4.3.2"
|
||||
telejson "^3.2.0"
|
||||
|
||||
"@storybook/channel-postmessage@5.3.19":
|
||||
version "5.3.19"
|
||||
resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-5.3.19.tgz#ef9fe974c2a529d89ce342ff7acf5cc22805bae9"
|
||||
@@ -2701,29 +2690,6 @@
|
||||
dependencies:
|
||||
core-js "^3.0.1"
|
||||
|
||||
"@storybook/client-api@5.3.18":
|
||||
version "5.3.18"
|
||||
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-5.3.18.tgz#e71041796f95888de0e4524734418e6b120b060a"
|
||||
integrity sha512-QiXTDUpjdyW19BlocLw07DrkOnEzVaWGJcRze2nSs29IKKuq1Ncv2LOAZt6ySSq0PmIKsjBou3bmS1/aXmDMdw==
|
||||
dependencies:
|
||||
"@storybook/addons" "5.3.18"
|
||||
"@storybook/channel-postmessage" "5.3.18"
|
||||
"@storybook/channels" "5.3.18"
|
||||
"@storybook/client-logger" "5.3.18"
|
||||
"@storybook/core-events" "5.3.18"
|
||||
"@storybook/csf" "0.0.1"
|
||||
"@types/webpack-env" "^1.15.0"
|
||||
core-js "^3.0.1"
|
||||
eventemitter3 "^4.0.0"
|
||||
global "^4.3.2"
|
||||
is-plain-object "^3.0.0"
|
||||
lodash "^4.17.15"
|
||||
memoizerific "^1.11.3"
|
||||
qs "^6.6.0"
|
||||
stable "^0.1.8"
|
||||
ts-dedent "^1.1.0"
|
||||
util-deprecate "^1.0.2"
|
||||
|
||||
"@storybook/client-api@5.3.19":
|
||||
version "5.3.19"
|
||||
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-5.3.19.tgz#7a5630bb8fffb92742b1773881e9004ee7fdf8e0"
|
||||
@@ -2761,33 +2727,6 @@
|
||||
dependencies:
|
||||
core-js "^3.0.1"
|
||||
|
||||
"@storybook/components@5.3.18":
|
||||
version "5.3.18"
|
||||
resolved "https://registry.npmjs.org/@storybook/components/-/components-5.3.18.tgz#528f6ab1660981e948993a04b407a6fad7751589"
|
||||
integrity sha512-LIN4aVCCDY7klOwtuqQhfYz4tHaMADhXEzZpij+3r8N68Inck6IJ1oo9A9umXQPsTioQi8e6FLobH1im90j/2A==
|
||||
dependencies:
|
||||
"@storybook/client-logger" "5.3.18"
|
||||
"@storybook/theming" "5.3.18"
|
||||
"@types/react-syntax-highlighter" "11.0.4"
|
||||
"@types/react-textarea-autosize" "^4.3.3"
|
||||
core-js "^3.0.1"
|
||||
global "^4.3.2"
|
||||
lodash "^4.17.15"
|
||||
markdown-to-jsx "^6.9.1"
|
||||
memoizerific "^1.11.3"
|
||||
polished "^3.3.1"
|
||||
popper.js "^1.14.7"
|
||||
prop-types "^15.7.2"
|
||||
react "^16.8.3"
|
||||
react-dom "^16.8.3"
|
||||
react-focus-lock "^2.1.0"
|
||||
react-helmet-async "^1.0.2"
|
||||
react-popper-tooltip "^2.8.3"
|
||||
react-syntax-highlighter "^11.0.2"
|
||||
react-textarea-autosize "^7.1.0"
|
||||
simplebar-react "^1.0.0-alpha.6"
|
||||
ts-dedent "^1.1.0"
|
||||
|
||||
"@storybook/components@5.3.19":
|
||||
version "5.3.19"
|
||||
resolved "https://registry.npmjs.org/@storybook/components/-/components-5.3.19.tgz#aac1f9eea1247cc85bd93b10fca803876fb84a6b"
|
||||
@@ -3739,6 +3678,16 @@
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
|
||||
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
|
||||
|
||||
"@types/json5@^0.0.29":
|
||||
version "0.0.29"
|
||||
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||
|
||||
"@types/jwt-decode@2.2.1":
|
||||
version "2.2.1"
|
||||
resolved "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2"
|
||||
integrity sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A==
|
||||
|
||||
"@types/lodash@^4.14.151":
|
||||
version "4.14.155"
|
||||
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
|
||||
@@ -4880,7 +4829,7 @@ array-unique@^0.3.2:
|
||||
resolved "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
|
||||
integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
|
||||
|
||||
array.prototype.flat@^1.2.1:
|
||||
array.prototype.flat@^1.2.1, array.prototype.flat@^1.2.3:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b"
|
||||
integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==
|
||||
@@ -8157,7 +8106,7 @@ eslint-config-prettier@^6.0.0:
|
||||
dependencies:
|
||||
get-stdin "^6.0.0"
|
||||
|
||||
eslint-import-resolver-node@^0.3.2:
|
||||
eslint-import-resolver-node@^0.3.3:
|
||||
version "0.3.3"
|
||||
resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404"
|
||||
integrity sha512-b8crLDo0M5RSe5YG8Pu2DYBj71tSB6OvXkfzwbJU2w7y8P4/yo0MyF8jU26IEuEuHF2K5/gcAJE3LhQGqBBbVg==
|
||||
@@ -8165,7 +8114,7 @@ eslint-import-resolver-node@^0.3.2:
|
||||
debug "^2.6.9"
|
||||
resolve "^1.13.1"
|
||||
|
||||
eslint-module-utils@^2.1.1:
|
||||
eslint-module-utils@^2.1.1, eslint-module-utils@^2.6.0:
|
||||
version "2.6.0"
|
||||
resolved "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6"
|
||||
integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==
|
||||
@@ -8173,38 +8122,31 @@ eslint-module-utils@^2.1.1:
|
||||
debug "^2.6.9"
|
||||
pkg-dir "^2.0.0"
|
||||
|
||||
eslint-module-utils@^2.4.1:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.5.2.tgz#7878f7504824e1b857dd2505b59a8e5eda26a708"
|
||||
integrity sha512-LGScZ/JSlqGKiT8OC+cYRxseMjyqt6QO54nl281CK93unD89ijSeRV6An8Ci/2nvWVKe8K/Tqdm75RQoIOCr+Q==
|
||||
dependencies:
|
||||
debug "^2.6.9"
|
||||
pkg-dir "^2.0.0"
|
||||
|
||||
eslint-plugin-cypress@^2.10.3:
|
||||
version "2.10.3"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.10.3.tgz#82eba7e014954149d590402eecd0d4e147cc7f14"
|
||||
integrity sha512-CvFeoCquShfO8gHNIKA1VpUTz78WtknMebLemBd1lRbcmJNjwpqCqpQYUG/XVja8GjdX/e2TJXYa+EUBxehtUg==
|
||||
version "2.11.1"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.1.tgz#a945e2774b88211e2c706a059d431e262b5c2862"
|
||||
integrity sha512-MxMYoReSO5+IZMGgpBZHHSx64zYPSPTpXDwsgW7ChlJTF/sA+obqRbHplxD6sBStE+g4Mi0LCLkG4t9liu//mQ==
|
||||
dependencies:
|
||||
globals "^11.12.0"
|
||||
|
||||
eslint-plugin-import@^2.20.2:
|
||||
version "2.20.2"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.20.2.tgz#91fc3807ce08be4837141272c8b99073906e588d"
|
||||
integrity sha512-FObidqpXrR8OnCh4iNsxy+WACztJLXAHBO5hK79T1Hc77PgQZkyDGA5Ag9xAvRpglvLNxhH/zSmZ70/pZ31dHg==
|
||||
version "2.21.1"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.21.1.tgz#3398318e5e4abbd23395c4964ce61538705154c8"
|
||||
integrity sha512-qYOOsgUv63vHof7BqbzuD+Ud34bXHxFJxntuAC1ZappFZXYbRIek3aJ7jc9i2dHDGDyZ/0zlO0cpioES265Lsw==
|
||||
dependencies:
|
||||
array-includes "^3.0.3"
|
||||
array.prototype.flat "^1.2.1"
|
||||
array-includes "^3.1.1"
|
||||
array.prototype.flat "^1.2.3"
|
||||
contains-path "^0.1.0"
|
||||
debug "^2.6.9"
|
||||
doctrine "1.5.0"
|
||||
eslint-import-resolver-node "^0.3.2"
|
||||
eslint-module-utils "^2.4.1"
|
||||
eslint-import-resolver-node "^0.3.3"
|
||||
eslint-module-utils "^2.6.0"
|
||||
has "^1.0.3"
|
||||
minimatch "^3.0.4"
|
||||
object.values "^1.1.0"
|
||||
object.values "^1.1.1"
|
||||
read-pkg-up "^2.0.0"
|
||||
resolve "^1.12.0"
|
||||
resolve "^1.17.0"
|
||||
tsconfig-paths "^3.9.0"
|
||||
|
||||
eslint-plugin-jest@^23.6.0:
|
||||
version "23.8.2"
|
||||
@@ -11954,6 +11896,11 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3:
|
||||
array-includes "^3.0.3"
|
||||
object.assign "^4.1.0"
|
||||
|
||||
jwt-decode@2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
|
||||
integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=
|
||||
|
||||
keyv@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
|
||||
@@ -12664,14 +12611,6 @@ markdown-to-jsx@^6.11.4:
|
||||
prop-types "^15.6.2"
|
||||
unquote "^1.1.0"
|
||||
|
||||
markdown-to-jsx@^6.9.1:
|
||||
version "6.11.0"
|
||||
resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-6.11.0.tgz#a2e3f2bc781c3402d8bb0f8e0a12a186474623b0"
|
||||
integrity sha512-RH7LCJQ4RFmPqVeZEesKaO1biRzB/k4utoofmTCp3Eiw6D7qfvK8fzZq/2bjEJAtVkfPrM5SMt5APGf2rnaKMg==
|
||||
dependencies:
|
||||
prop-types "^15.6.2"
|
||||
unquote "^1.1.0"
|
||||
|
||||
material-table@^1.58.0:
|
||||
version "1.58.2"
|
||||
resolved "https://registry.npmjs.org/material-table/-/material-table-1.58.2.tgz#dc0d19652848e6bb92f747d122bd7d4681cca6dc"
|
||||
@@ -18248,6 +18187,16 @@ tsc-watch@^4.2.3:
|
||||
string-argv "^0.1.1"
|
||||
strip-ansi "^4.0.0"
|
||||
|
||||
tsconfig-paths@^3.9.0:
|
||||
version "3.9.0"
|
||||
resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b"
|
||||
integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==
|
||||
dependencies:
|
||||
"@types/json5" "^0.0.29"
|
||||
json5 "^1.0.1"
|
||||
minimist "^1.2.0"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@1.10.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
|
||||
@@ -19058,9 +19007,9 @@ websocket-driver@>=0.5.1:
|
||||
websocket-extensions ">=0.1.1"
|
||||
|
||||
websocket-extensions@>=0.1.1:
|
||||
version "0.1.3"
|
||||
resolved "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29"
|
||||
integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
|
||||
integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
|
||||
|
||||
whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5:
|
||||
version "1.0.5"
|
||||
|
||||
Reference in New Issue
Block a user