diff --git a/frontend/packages/app/src/App.tsx b/frontend/packages/app/src/App.tsx index f51cf54d0b..0d82f79aca 100644 --- a/frontend/packages/app/src/App.tsx +++ b/frontend/packages/app/src/App.tsx @@ -1,9 +1,9 @@ -import React, { FC, Fragment } from 'react'; +import React, { FC } from 'react'; import helloWorld, { MyComponent } from '@backstage/plugin-hello-world'; -import { makeStyles } from '@material-ui/core/styles'; import Typography from '@material-ui/core/Typography'; import SideBar from './components/SideBar'; -import PageHeader from './components/PageHeader'; +//import PageHeader from './components/PageHeader'; +import { Header, Page, InfoCard } from '@backstage/core'; import { LoginComponent } from '@backstage/plugin-login'; import { BrowserRouter as Router, @@ -11,6 +11,15 @@ import { Route, Link as RouterLink, } from 'react-router-dom'; +import { BackstageTheme, withGlobalStyles, theme } from '@backstage/core'; +import { CssBaseline, MuiThemeProvider, makeStyles } from '@material-ui/core'; +import { ThemeProvider } from '@material-ui/styles'; +import { StylesProvider, createGenerateClassName } from '@material-ui/styles'; +import HomePageTimer from './components/HomepageTimer'; + +const generateClassName = createGenerateClassName({ + productionPrefix: 'stajl', +}); const useStyles = makeStyles(theme => ({ root: { @@ -26,8 +35,7 @@ const useStyles = makeStyles(theme => ({ overflowY: 'auto', }, pageBody: { - paddingLeft: theme.spacing(2), - paddingTop: theme.spacing(2), + padding: theme.spacing(2), }, avatarButton: { padding: theme.spacing(2), @@ -36,24 +44,32 @@ const useStyles = makeStyles(theme => ({ const App: FC<{}> = () => { return ( - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); }; const Home: FC<{}> = () => { return ( - + {' '} …with plugin {helloWorld?.id ?? 'wat'}: @@ -62,18 +78,18 @@ const Home: FC<{}> = () => {
Go to Login
-
+ ); }; const Login: FC<{}> = () => { return ( - +
Go to Home
-
+ ); }; @@ -83,12 +99,18 @@ const AppShell: FC<{}> = ({ children }) => { return (
-
- -
{children}
-
+ +
+
+ +
+
{children}
+
+
); }; +const AppContent = withGlobalStyles(AppShell); + export default App; diff --git a/frontend/packages/app/src/components/HomepageTimer/HomepageTimer.tsx b/frontend/packages/app/src/components/HomepageTimer/HomepageTimer.tsx new file mode 100644 index 0000000000..54d269bf9c --- /dev/null +++ b/frontend/packages/app/src/components/HomepageTimer/HomepageTimer.tsx @@ -0,0 +1,58 @@ +import React, { FC } from 'react'; +import { HeaderLabel } from '@backstage/core'; + +const timeFormat = { hour: '2-digit', minute: '2-digit' }; +const nycOptions = { timeZone: 'America/New_York', ...timeFormat }; +const utcOptions = { timeZone: 'UTC', ...timeFormat }; +const lonOptions = { timeZone: 'Europe/London', ...timeFormat }; +const stoOptions = { timeZone: 'Europe/Stockholm', ...timeFormat }; + +const defaultTimes = { + timeNY: '', + timeUTC: '', + timeLON: '', + timeSTO: '', +}; + +function getTimes() { + const d = new Date(); + const lang = window.navigator.language; + + // Using the browser native toLocaleTimeString instead of huge moment-tz + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleTimeString + const timeNY = d.toLocaleTimeString(lang, nycOptions); + const timeUTC = d.toLocaleTimeString(lang, utcOptions); + const timeLON = d.toLocaleTimeString(lang, lonOptions); + const timeSTO = d.toLocaleTimeString(lang, stoOptions); + + return { timeNY, timeUTC, timeLON, timeSTO }; +} + +const HomePageTimer: FC<{}> = () => { + const [{ timeNY, timeUTC, timeLON, timeSTO }, setTimes] = React.useState( + defaultTimes, + ); + + React.useEffect(() => { + setTimes(getTimes()); + + const intervalId = setInterval(() => { + setTimes(getTimes()); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, []); + + return ( + <> + + + + + + ); +}; + +export default HomePageTimer; diff --git a/frontend/packages/app/src/components/HomepageTimer/index.ts b/frontend/packages/app/src/components/HomepageTimer/index.ts new file mode 100644 index 0000000000..122eef2c23 --- /dev/null +++ b/frontend/packages/app/src/components/HomepageTimer/index.ts @@ -0,0 +1 @@ +export { default } from './HomepageTimer'; diff --git a/frontend/packages/app/src/components/PageHeader/PageHeader.tsx b/frontend/packages/app/src/components/PageHeader/PageHeader.tsx deleted file mode 100644 index 38f77ddbc5..0000000000 --- a/frontend/packages/app/src/components/PageHeader/PageHeader.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import React, { FC } from 'react'; - -const useStyles = makeStyles(theme => ({ - root: { - background: 'linear-gradient(262.63deg, #19D15A 4.2%, #1CAB5B 72.01%)', - height: '120px', - paddingTop: theme.spacing(2), - paddingLeft: theme.spacing(2), - color: 'white', - }, -})); - -const SideBar: FC<{}> = () => { - const classes = useStyles(); - - return ( -
- This is Backstage! - - Backstage is an open platform for building developer portals - -
- ); -}; - -export default SideBar; diff --git a/frontend/packages/app/src/components/PageHeader/index.ts b/frontend/packages/app/src/components/PageHeader/index.ts deleted file mode 100644 index e1b036be68..0000000000 --- a/frontend/packages/app/src/components/PageHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PageHeader'; diff --git a/frontend/packages/core/package.json b/frontend/packages/core/package.json index 27c8453567..fb5f3cc6de 100644 --- a/frontend/packages/core/package.json +++ b/frontend/packages/core/package.json @@ -13,7 +13,10 @@ "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", "react": "^16.12.0", - "react-dom": "^16.12.0" + "react-dom": "^16.12.0", + "react-helmet":"5.2.1", + "tinycolor2": "1.4.1", + "react-addons-text-content": "0.0.4" }, "scripts": { "lint": "web-scripts lint", diff --git a/frontend/packages/core/src/assets/bursts/bursts.css b/frontend/packages/core/src/assets/bursts/bursts.css new file mode 100644 index 0000000000..92499bf056 --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/bursts.css @@ -0,0 +1,19 @@ + +.burst { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + z-index: -1; +} + +/* base style of burst shape SVGs */ +.burst-shape { + position: absolute; + right: 0; + height: 100%; + background-repeat: no-repeat; + background-size: cover; + opacity:0.1; +} diff --git a/frontend/packages/core/src/assets/bursts/circle-1.svg b/frontend/packages/core/src/assets/bursts/circle-1.svg new file mode 100644 index 0000000000..9ee5157ab8 --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/circle-1.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/assets/bursts/circle-2.svg b/frontend/packages/core/src/assets/bursts/circle-2.svg new file mode 100644 index 0000000000..15b89e96db --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/circle-2.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/assets/bursts/rectangle-1.svg b/frontend/packages/core/src/assets/bursts/rectangle-1.svg new file mode 100644 index 0000000000..4403b0031a --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/rectangle-1.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/assets/bursts/rectangle-2.svg b/frontend/packages/core/src/assets/bursts/rectangle-2.svg new file mode 100644 index 0000000000..2d307d75f6 --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/rectangle-2.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/assets/bursts/rectangle-3.svg b/frontend/packages/core/src/assets/bursts/rectangle-3.svg new file mode 100644 index 0000000000..947df51df9 --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/rectangle-3.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/assets/bursts/triangle-1.svg b/frontend/packages/core/src/assets/bursts/triangle-1.svg new file mode 100644 index 0000000000..7a33cf3f5c --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/triangle-1.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/assets/bursts/triangle-2.svg b/frontend/packages/core/src/assets/bursts/triangle-2.svg new file mode 100644 index 0000000000..184e714b89 --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/triangle-2.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/assets/bursts/triangle-3.svg b/frontend/packages/core/src/assets/bursts/triangle-3.svg new file mode 100644 index 0000000000..e8b0cb1f7c --- /dev/null +++ b/frontend/packages/core/src/assets/bursts/triangle-3.svg @@ -0,0 +1 @@ + diff --git a/frontend/packages/core/src/index.ts b/frontend/packages/core/src/index.ts index 0b69aa4ff3..f741f0e384 100644 --- a/frontend/packages/core/src/index.ts +++ b/frontend/packages/core/src/index.ts @@ -1,2 +1,12 @@ export * from './types'; export { default as createPlugin } from './createPlugin'; +export { default as Page } from '../src/layout/Page'; +export { shapes, gradients, theme } from '../src/layout/Page'; +export { default as Header } from '../src/layout/Header/Header'; +export { default as HeaderLabel } from '../src/layout/HeaderLabel'; +export { default as InfoCard } from '../src/layout/InfoCard'; +export { default as ErrorBoundary } from '../src/layout/ErrorBoundary'; + +export { default as BackstageTheme } from '../src/theme/BackstageTheme'; +export { withGlobalStyles } from '../src/theme/BackstageTheme'; + diff --git a/frontend/packages/core/src/layout/ContentHeader/ContentHeader.js b/frontend/packages/core/src/layout/ContentHeader/ContentHeader.js new file mode 100644 index 0000000000..ed3e69d37a --- /dev/null +++ b/frontend/packages/core/src/layout/ContentHeader/ContentHeader.js @@ -0,0 +1,69 @@ +import React, { Component, Fragment } from 'react'; +import { Typography, withStyles } from '@material-ui/core'; +import Helmet from 'react-helmet'; +import FavoriteButton from 'shared/components/layout/FavoriteButton'; + +const styles = theme => ({ + container: { + width: '100%', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + alignItems: 'center', + }, + leftItemsBox: { + flex: '1 1 auto', + marginBottom: theme.spacing(1), + minWidth: 0, + overflow: 'visible', + }, + rightItemsBox: { + flex: '0 1 auto', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + marginLeft: theme.spacing(1), + marginBottom: theme.spacing(1), + minWidth: 0, + overflow: 'visible', + }, + description: {}, + title: { + display: 'inline-flex', + }, +}); + +class ContentHeader extends Component { + static defaultProps = { + favoriteable: true, + title: 'Unknown page', + }; + + render() { + const { title, description, favoriteable, children, classes } = this.props; + + return ( + + +
+
+ + {title} + + {favoriteable && } + {description && ( + + {description} + + )} +
+
{children}
+
+
+ ); + } +} + +export default withStyles(styles)(ContentHeader); diff --git a/frontend/packages/core/src/layout/ErrorBoundary/ErrorBoundary.js b/frontend/packages/core/src/layout/ErrorBoundary/ErrorBoundary.js new file mode 100644 index 0000000000..6963083e41 --- /dev/null +++ b/frontend/packages/core/src/layout/ErrorBoundary/ErrorBoundary.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; + +export default class ErrorBoundary extends Component { + constructor(props) { + super(props); + + this.state = { + error: null, + errorInfo: null, + onError: props.onError, + }; + } + + componentDidCatch(error, errorInfo) { + console.error(`ErrorBoundary, error: ${error}, info: ${errorInfo}`); + this.setState({ error, errorInfo }); + + // Exposed for testing + if (ErrorBoundary.onError) { + ErrorBoundary.onError(error, errorInfo); + } + } + + render() { + const { slackChannel } = this.props; + const { error, errorInfo } = this.state; + + if (!errorInfo) { + return this.props.children; + } + + return ( + + ); + } +} + +// Importing Error would mean importing a lot of stuff +// will take it up in a separate PR +const Error = ({ slackChannel }) => { + return ( +
+ Something went wrong here. Please contact {slackChannel} for help. +
+ ); +}; diff --git a/frontend/packages/core/src/layout/ErrorBoundary/index.ts b/frontend/packages/core/src/layout/ErrorBoundary/index.ts new file mode 100644 index 0000000000..257f6a24f6 --- /dev/null +++ b/frontend/packages/core/src/layout/ErrorBoundary/index.ts @@ -0,0 +1 @@ +export { default } from './ErrorBoundary'; diff --git a/frontend/packages/core/src/layout/Header/Burst.js b/frontend/packages/core/src/layout/Header/Burst.js new file mode 100644 index 0000000000..64d95f7e07 --- /dev/null +++ b/frontend/packages/core/src/layout/Header/Burst.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react'; +import '../../assets/bursts/bursts.css'; + +class Burst extends Component { + render() { + const { theme } = this.props; + + return ( +
+
+
+ ); + } +} + +export default Burst; diff --git a/frontend/packages/core/src/layout/Header/Header.js b/frontend/packages/core/src/layout/Header/Header.js new file mode 100644 index 0000000000..f78e43ee76 --- /dev/null +++ b/frontend/packages/core/src/layout/Header/Header.js @@ -0,0 +1,141 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Typography, withStyles, Tooltip } from '@material-ui/core'; +import { Theme } from '../Page/Page'; +// import { Link } from 'shared/components'; +import Burst from './Burst'; +import Helmet from 'react-helmet'; + +class Header extends Component { + static propTypes = { + type: PropTypes.string, + typeLink: PropTypes.string, + title: PropTypes.node.isRequired, + tooltip: PropTypes.string, + subtitle: PropTypes.node, + pageTitleOverride: PropTypes.string, + style: PropTypes.object, + component: PropTypes.object, + }; + + typeFragment() { + const { type, typeLink, classes } = this.props; + if (!type) { + return null; + } + + return typeLink ? ( + // + {type} + // + ) : ( + {type} + ); + } + + titleFragment() { + const { title, pageTitleOverride, classes, tooltip } = this.props; + const FinalTitle = ( + + {title || pageTitleOverride} + + ); + if (tooltip) { + return ( + + {FinalTitle} + + ); + } + return FinalTitle; + } + + subtitleFragment() { + const { subtitle, classes } = this.props; + if (!subtitle) { + return null; + } else if (typeof subtitle !== 'string') { + return subtitle; + } else { + return ( + + {subtitle} + + ); + } + } + + render() { + const { title, pageTitleOverride, children, style, classes } = this.props; + const pageTitle = pageTitleOverride || title; + if (typeof pageTitle !== 'string') { + console.warn('
title prop is not a string, pageTitleOverride should be provided.'); + } + + return ( + + + + {theme => ( +
+ +
+ {this.typeFragment()} + {this.titleFragment()} + {this.subtitleFragment()} +
+
{children}
+
+ )} +
+
+ ); + } +} + +const styles = theme => ({ + header: { + gridArea: 'pageHeader', + padding: theme.spacing(3), + minHeight: 118, + width: 'calc(100vw - 224px)', + boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)', + position: 'relative', + zIndex: 100, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'flex-end', + alignItems: 'center', + }, + leftItemsBox: { + flex: '1 1 auto', + }, + rightItemsBox: { + flex: '0 1 auto', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + }, + title: { + color: theme.palette.bursts.fontColor, + lineHeight: '1.0em', + wordBreak: 'break-all', + fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))', + marginBottom: theme.spacing(1), + }, + subtitle: { + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: '1.0em', + }, + type: { + textTransform: 'uppercase', + fontSize: 9, + opacity: 0.8, + marginBottom: 10, + color: theme.palette.bursts.fontColor, + }, +}); + +export default withStyles(styles)(Header); diff --git a/frontend/packages/core/src/layout/Header/Header.test.js b/frontend/packages/core/src/layout/Header/Header.test.js new file mode 100644 index 0000000000..15d5f3f4b4 --- /dev/null +++ b/frontend/packages/core/src/layout/Header/Header.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Header } from 'shared/components/layout'; +import { wrapInThemedTestApp } from 'testUtils'; + +jest.mock('react-helmet', () => { + return ({ defaultTitle }) =>
defaultTitle: {defaultTitle}
; +}); + +describe('
', () => { + it('should render with title', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Title'); + }); + + it('should set document title', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Title1'); + rendered.getByText('defaultTitle: Title1 | Backstage'); + }); + + it('should override document title', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Title1'); + rendered.getByText('defaultTitle: Title2 | Backstage'); + }); + + it('should have subtitle', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('Subtitle'); + }); + + it('should have type rendered', () => { + const rendered = render(wrapInThemedTestApp(
)); + rendered.getByText('service'); + }); +}); diff --git a/frontend/packages/core/src/layout/Header/index.ts b/frontend/packages/core/src/layout/Header/index.ts new file mode 100644 index 0000000000..579f1ac23f --- /dev/null +++ b/frontend/packages/core/src/layout/Header/index.ts @@ -0,0 +1 @@ +export { default } from './Header'; diff --git a/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.js b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.js new file mode 100644 index 0000000000..d16556d4c2 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.js @@ -0,0 +1,56 @@ +import React, { Fragment } from 'react'; +import { IconButton, List, ListItem, ListItemIcon, ListItemText, Popover } from '@material-ui/core'; +import { KebabMenuIcon } from 'shared/icons'; + +const ActionItem = ({ label, secondaryLabel, icon, disabled = false, onClick, WrapperComponent = React.Fragment }) => { + return ( + + { + if (onClick) { + onClick(event); + } + }} + > + {icon && {icon}} + + + + ); +}; + +const HeaderActionMenu = ({ actionItems }) => { + const [open, setOpen] = React.useState(false); + const anchorElRef = React.useRef(null); + + return ( + + setOpen(true)} + data-testid="header-action-menu" + ref={anchorElRef} + style={{ color: 'white', height: 56, width: 56, marginRight: -4, padding: 0 }} + > + + + setOpen(false)} + > + + {actionItems.map(actionItem => { + return ; + })} + + + + ); +}; + +export default HeaderActionMenu; diff --git a/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js new file mode 100644 index 0000000000..dcfecf5e3f --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderActionMenu/HeaderActionMenu.test.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { wrapInThemedTestApp, Keyboard } from 'testUtils'; +import HeaderActionMenu from './HeaderActionMenu'; + +describe('', () => { + it('renders without any items and without exploding', () => { + render(wrapInThemedTestApp()); + }); + + it('can open the menu and click menu items', () => { + const onClickFunction = jest.fn(); + const rendered = render( + wrapInThemedTestApp(), + ); + expect(rendered.queryByText('Some label')).not.toBeInTheDocument(); + expect(onClickFunction).not.toHaveBeenCalled(); + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(onClickFunction).not.toHaveBeenCalled(); + expect(rendered.getByTestId('header-action-item')).not.toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(rendered.queryByText('Some label')); + expect(onClickFunction).toHaveBeenCalled(); + // We do not expect the dropdown to disappear after click + expect(rendered.queryByText('Some label')).toBeInTheDocument(); + }); + + it('Disabled', async () => { + const rendered = render( + wrapInThemedTestApp(), + ); + + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(rendered.getByTestId('header-action-item')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('Test wrapper, and secondary label', () => { + const onClickFunction = jest.fn(); + const rendered = render( + wrapInThemedTestApp( + , + }, + ]} + />, + ), + ); + + expect(onClickFunction).not.toHaveBeenCalled(); + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(onClickFunction).not.toHaveBeenCalled(); + fireEvent.click(rendered.queryByText('Secondary label')); + expect(onClickFunction).toHaveBeenCalled(); + // We do not expect the dropdown to disappear after click + expect(rendered.queryByText('Some label')).toBeInTheDocument(); + }); + + it('should close when hitting escape', async () => { + const rendered = render(wrapInThemedTestApp()); + + expect(rendered.container.getAttribute('aria-hidden')).toBeNull(); + fireEvent.click(rendered.getByTestId('header-action-menu')); + expect(rendered.container.getAttribute('aria-hidden')).toBe('true'); + await Keyboard.type(rendered, ''); + expect(rendered.container.getAttribute('aria-hidden')).toBeNull(); + }); +}); diff --git a/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.js b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.js new file mode 100644 index 0000000000..91290412b3 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Typography, withStyles } from '@material-ui/core'; +import { Link } from '@material-ui/core'; + +const style = theme => ({ + root: { + textAlign: 'left', + margin: theme.spacing(2), + display: 'inline-block', + }, + label: { + color: '#FFFFFF', + fontWeight: 'bold', + lineHeight: '16px', + letterSpacing: 0, + fontSize: 14, + height: '16px', + marginBottom: 2, + }, + value: { + color: 'rgba(255, 255, 255, 0.8)', + lineHeight: '16px', + fontSize: 14, + height: '16px', + }, +}); + +class HeaderLabel extends Component { + static propTypes = { + label: PropTypes.string.isRequired, + value: PropTypes.node, + url: PropTypes.string, + }; + + render() { + const { label, value, url, classes } = this.props; + const content = ( + {value || ''} + ); + return ( + + {label} + {url ? {content} : content} + + ); + } +} + +export default withStyles(style)(HeaderLabel); diff --git a/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js new file mode 100644 index 0000000000..06eccae61c --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/HeaderLabel.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { HeaderLabel } from 'shared/components/layout'; +import { wrapInThemedTestApp } from 'testUtils'; + +describe('', () => { + it('should have a label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Label')).toBeInTheDocument(); + }); + + it('should say unknown', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('')).toBeInTheDocument(); + }); + + it('should have value', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Value')).toBeInTheDocument(); + }); + + it('should have a link', () => { + const rendered = render(wrapInThemedTestApp()); + const anchor = rendered.container.querySelector('a'); + expect(rendered.getByText('Value')).toBeInTheDocument(); + expect(anchor.href).toBe('http://localhost/test'); + }); +}); diff --git a/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js new file mode 100644 index 0000000000..5c71c1b6ea --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js @@ -0,0 +1,65 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Tooltip, withStyles } from '@material-ui/core'; + +import { StatusError } from 'shared/components/Status'; +import Link from 'shared/components/Link'; +import HeaderLabel from 'shared/components/layout/HeaderLabel'; + +const style = theme => ({ + notVerified: { + color: theme.palette.status.error, + borderRadius: 4, + padding: '3px 6px', + fontSize: '8pt', + opacity: 0.8, + fontWeight: 'bold', + position: 'relative', + top: -4, + backgroundColor: 'pink', + float: 'right', + marginLeft: 14, + }, + label: { float: 'left' }, +}); + +class OwnerHeaderLabel extends Component { + static propTypes = { + owner: PropTypes.object.isRequired, + }; + + render() { + const { owner, classes } = this.props; + const isBadSquad = owner.type !== 'squad'; + + const notVerified = isBadSquad && ( + + + Squad not verified! + + + ); + const label = ( + + + {owner.name} + + + ); + return ( + <> + + {notVerified} + + ); + } +} + +export default withStyles(style)(OwnerHeaderLabel); diff --git a/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js new file mode 100644 index 0000000000..11ae8f3a89 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.test.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { OwnerHeaderLabel } from 'shared/components/layout'; +import { wrapInThemedTestApp } from 'testUtils'; + +const properOwner = { id: 'tools', name: 'tools', type: 'squad' }; +const badOwner = { id: 'tools-xxx', name: 'tools-xxx' }; + +describe('', () => { + it('should have a label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Owner')).toBeInTheDocument(); + expect(rendered.getByText('tools')).toBeInTheDocument(); + expect(rendered.queryByText('Squad not verified!')).not.toBeInTheDocument(); + }); + + it('should have an org link', () => { + const rendered = render(wrapInThemedTestApp()); + const anchor = rendered.container.querySelector('a'); + expect(anchor.href).toBe('http://localhost/org/tools'); + }); + + it('should have WARNING label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('Squad not verified!')).toBeInTheDocument(); + }); + + it('should have status error label', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByLabelText('Status error')).toBeInTheDocument(); + }); + + it('should handle empty input', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByLabelText('Status error')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/layout/HeaderLabel/index.ts b/frontend/packages/core/src/layout/HeaderLabel/index.ts new file mode 100644 index 0000000000..913f71a783 --- /dev/null +++ b/frontend/packages/core/src/layout/HeaderLabel/index.ts @@ -0,0 +1 @@ +export { default } from './HeaderLabel'; diff --git a/frontend/packages/core/src/layout/InfoCard/BottomLink.js b/frontend/packages/core/src/layout/InfoCard/BottomLink.js new file mode 100644 index 0000000000..c6b259cb72 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/BottomLink.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@material-ui/core'; +import { Divider, ListItemText } from '@material-ui/core'; +import { ListItem, ListItemIcon } from '@material-ui/core'; +import ArrowIcon from '@material-ui/icons/ArrowForward'; + +export default class BottomLink extends Component { + static propTypes = { + link: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, + }; + + render() { + const { link, title, onClick } = this.props; + return ( +
+ + + + + + + {title} + + +
+ ); + } +} diff --git a/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js b/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js new file mode 100644 index 0000000000..3941c53b53 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/BottomLink.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from 'testUtils'; + +import BottomLink from './BottomLink'; + +const minProps = { + title: 'A deepLink title', + link: '/mocked', +}; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('A deepLink title')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/layout/InfoCard/InfoCard.js b/frontend/packages/core/src/layout/InfoCard/InfoCard.js new file mode 100644 index 0000000000..2a0b332a9f --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/InfoCard.js @@ -0,0 +1,197 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + Card, + CardActions, + CardContent, + CardHeader, + Divider, + withStyles, +} from '@material-ui/core'; +import ErrorBoundary from '../ErrorBoundary/ErrorBoundary'; +import BottomLink from './BottomLink'; + +import textContent from 'react-addons-text-content'; + +const BoldHeader = withStyles({ title: { fontWeight: '700' } })(CardHeader); +const CardActionsTopRight = withStyles({ + root: { + display: 'inline-block', + paddingRight: '16px', + paddingTop: '16px', + float: 'right', + }, +})(CardActions); + +const VARIANT_STYLES = { + card: { + flex: { + display: 'flex', + flexDirection: 'column', + }, + widget: { + height: 430, + }, + fullHeight: { + height: '100%', + }, + height100: { + display: 'flex', + flexDirection: 'column', + height: 'calc(100% - 10px)', // for pages without content header + marginBottom: '10px', + }, + contentheader: { + height: 'calc(100% - 40px)', // for pages with content header + }, + contentheadertabs: { + height: 'calc(100% - 97px)', // for pages with content header and tabs (Tingle) + }, + noShrink: { + flexShrink: 0, + }, + minheight300: { + minHeight: 300, + overflow: 'initial', + }, + }, + cardContent: { + widget: { + overflowY: 'auto', + height: 332, + width: '100%', + }, + fullHeight: { + height: 'calc(100% - 50px)', + }, + height100: { + height: 'calc(100% - 50px)', + }, + contentRow: { + display: 'flex', + flexDirection: 'row', + }, + }, +}; + +/** + * InfoCard is used to display a paper-styled block on the screen, similar to a panel. + * + * You can custom style an InfoCard with the 'style' (outer container) and 'cardStyle' (inner container) + * styles. + * + * The InfoCard serves as an error boundary. As a result, if you provide a 'slackChannel' property this + * specifies the channel to display in the error component that is displayed if an error occurs + * in any descendent components. + * + * By default the InfoCard has no custom layout of its children, but is treated as a block element. A + * couple common variants are provided and can be specified via the variant property: + * + * Display the card full height suitable for DataGrid: + * + * ... + * + * Variants can be combined in a whitespace delimited list like so: + * + * ... + */ +class InfoCard extends Component { + static propTypes = { + title: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + subheader: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + divider: PropTypes.bool, + deepLink: PropTypes.object, + slackChannel: PropTypes.string, + variant: PropTypes.string, + }; + + render() { + const { + title, + subheader, + divider, + deepLink, + children, + actions, + actionsTopRight, + headerStyle, + headerProps, + classes, + slackChannel, + variant, + } = this.props; + + if (this.props.style) { + console.warn( + 'InfoCard: using `style` property directly, consider migrating your style to variant in InfoCard', + ); + } + if (this.props.cardStyle) { + console.warn( + 'InfoCard: using `cardStyle` property directly, consider migrating your style to variant in InfoCard', + ); + } + + /** + * If variant is specified, we build up styles for that particular variant for both + * the Card and the CardContent (since these need to be synced) + */ + let calculatedStyle = {}; + let calculatedCardStyle = {}; + + if (variant) { + let variants = variant.split(/[\s]+/g); + variants.forEach(name => { + calculatedStyle = { ...calculatedStyle, ...VARIANT_STYLES.card[name] }; + calculatedCardStyle = { + ...calculatedCardStyle, + ...VARIANT_STYLES.cardContent[name], + }; + }); + } + + // Apply the passed styles on top + const computedStyle = { ...calculatedStyle, ...this.props.style }; + const computedCardStyle = { + ...calculatedCardStyle, + ...this.props.cardStyle, + }; + + return ( + + + {title && ( + + )} + {actionsTopRight && ( + {actionsTopRight} + )} + {divider && } + + {children} + + {actions && ( + + {actions} + + )} + {deepLink && } + + + ); + } +} + +export default InfoCard; diff --git a/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js b/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js new file mode 100644 index 0000000000..88c0e08fa9 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/InfoCard.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from 'testUtils'; +import InfoCard from './InfoCard'; + +const minProps = { + title: 'Some title', + deepLink: { + title: 'A deepLink title', + link: '/mocked', + }, +}; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('Some title')).toBeInTheDocument(); + }); + + it('renders a deepLink when prop is set', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('A deepLink title')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/layout/InfoCard/index.js b/frontend/packages/core/src/layout/InfoCard/index.js new file mode 100644 index 0000000000..6e402b0695 --- /dev/null +++ b/frontend/packages/core/src/layout/InfoCard/index.js @@ -0,0 +1 @@ +export { default } from './InfoCard'; diff --git a/frontend/packages/core/src/layout/Navigation/NavItem.js b/frontend/packages/core/src/layout/Navigation/NavItem.js new file mode 100644 index 0000000000..b6d47ab9f6 --- /dev/null +++ b/frontend/packages/core/src/layout/Navigation/NavItem.js @@ -0,0 +1,168 @@ +import React, { Component, Fragment } from 'react'; +import { matchPath } from 'react-router'; +import { ListItem, ListItemIcon, ListItemText, Typography, withStyles, Tooltip, IconButton } from '@material-ui/core'; +import { AlphaLabel, BetaLabel } from 'shared/components/Lifecycle'; +import Link from 'shared/components/Link'; +import { Theme } from 'shared/components/layout/Page'; +import ArrowDropUp from '@material-ui/icons/ArrowDropUp'; +import ArrowDropDown from '@material-ui/icons/ArrowDropDown'; + +const styles = theme => ({ + root: { + display: 'block', + overflow: 'hidden', + '&:first-child': { + borderTopRightRadius: 6, + borderTopLeftRadius: 6, + }, + '&:last-child': { + borderBottomRightRadius: 6, + borderBottomLeftRadius: 6, + }, + }, + child: { + paddingLeft: theme.spacing(7), + '&:last-child': { + borderBottom: '1px solid #727272', + }, + }, + boldLabel: { + color: '#FFFFFF', + fontWeight: 'bolder', + }, + childLabel: { + color: theme.palette.textVerySubtle, + fontSize: 14, + }, + label: { + whiteSpace: 'nowrap', + lineHeight: 1.0, + }, + iconImg: { + width: 24, + height: 24, + }, + icon: { + margin: theme.spacing(0.5, 2, 0.5, 0), + minWidth: 0, + }, + expand: { + color: 'white', + }, +}); + +class NavItem extends Component { + static defaultProps = { + title: 'Mystery Link', + href: '/', + exact: true, // Only used for react-router matchPath to see if this sidebar item is active + icon: null, + isAlpha: false, + isBeta: false, + type: 'other', // Provided by Navigation + child: false, // Whether this is a child of another NavItem (for indenting) + }; + + // If NavItem has React children, the sub-navigation can be expanded or collapsed + state = { + expanded: false, + }; + + static isActive(href, exact) { + return matchPath(window.location.pathname, { path: href, exact: exact }); + } + + maybeActiveNavStyle(theme) { + return NavItem.isActive(this.props.href, this.props.exact) + ? { + background: `linear-gradient(to right, ${theme.activeNavLinkColor} 0px, ${theme.activeNavLinkColor} 6px, #4F4F4F 6px, #4F4F4F 100%)`, + } + : {}; + } + + renderIcon = (condensed, iconElement, title, classes) => { + if (!iconElement) return null; + const listItemIcon = {iconElement}; + return condensed ? ( + + {listItemIcon} + + ) : ( + listItemIcon + ); + }; + + render() { + const { title, href, icon, isAlpha, isBeta, classes, condensed, child, children } = this.props; + const { expanded } = this.state; + const isSection = !!children; + + // Automatically expand nested nav if one of the child elements is active + const isChildActive = React.Children.toArray(children).some(c => NavItem.isActive(c.props.href, c.props.exact)); + const isExpanded = expanded || isChildActive; + const selfAwareChildren = children && React.cloneElement(children, { child: true }); + + let iconElement; + + if (icon) { + iconElement = typeof icon === 'object' ? icon : {title}; + } + + return ( + + {theme => ( + <> + + + {this.renderIcon(condensed, iconElement, title, classes)} + {!condensed && ( + <> + + {title} + {isAlpha && ( + +   + + + )} + {isBeta && ( + +   + + + )} + + } + disableTypography + /> + {isSection && ( + this.setState(prev => ({ expanded: !prev.expanded }))} + style={{ padding: 4 }} + data-testid="expand-toggle" + > + {isExpanded ? ( + + ) : ( + + )} + + )} + + )} + + + {isExpanded && selfAwareChildren} + + )} + + ); + } +} + +export default withStyles(styles)(NavItem); diff --git a/frontend/packages/core/src/layout/Navigation/NavItem.test.js b/frontend/packages/core/src/layout/Navigation/NavItem.test.js new file mode 100644 index 0000000000..997b765a12 --- /dev/null +++ b/frontend/packages/core/src/layout/Navigation/NavItem.test.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { matchPath } from 'react-router'; +import { fireEvent, render } from '@testing-library/react'; + +import NavItem from 'shared/components/layout/NavItem'; +import { wrapInThemedTestApp } from 'testUtils'; + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + matchPath: jest.fn(), +})); + +const minProps = { + title: 'A link title', + href: '/mocked', +}; + +const minChildProps = { + title: 'A child title', + href: '/child', +}; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('A link title')).toBeInTheDocument(); + }); + + it('should have an anchor with correct href', () => { + const rendered = render(wrapInThemedTestApp()); + const anchor = rendered.container.querySelector('a'); + expect(anchor.href).toBe('http://localhost/mocked'); + }); + + it('renders with icon src', () => { + const rendered = render(wrapInThemedTestApp()); + const icon = rendered.getByAltText('A link title'); + expect(icon.src).toBe('http://localhost/mocked-icon.png'); + }); + + it('renders with icon component', () => { + const Falafel = () =>
falafel
; + const rendered = render(wrapInThemedTestApp(} {...minProps} />)); + const icon = rendered.getByText('falafel'); + expect(icon).toBeInTheDocument(); + }); + + it('shows alpha icon', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('α')).toBeInTheDocument(); + }); + + it('shows beta icon', () => { + const rendered = render(wrapInThemedTestApp()); + expect(rendered.getByText('β')).toBeInTheDocument(); + }); + + it('renders nested navigation without exploding', () => { + const { getByText, getByTestId } = render( + wrapInThemedTestApp( + + + , + ), + ); + expect(getByText('A link title')).toBeInTheDocument(); + expect(getByTestId('expand-toggle')).toBeInTheDocument(); + }); + + it('toggles nested navigation', () => { + const { getByText, getByTestId, queryByText } = render( + wrapInThemedTestApp( + + + , + ), + ); + expect(queryByText('A child link')).not.toBeInTheDocument(); + fireEvent.click(getByTestId('expand-toggle')); + expect(getByText('A child title')).toBeInTheDocument(); + }); + + it('shows nested navigation automatically if the child is active', () => { + matchPath.mockReturnValue(true); + const { getByText } = render( + wrapInThemedTestApp( + + + , + ), + ); + expect(getByText('A link title')).toBeInTheDocument(); + expect(getByText('A child title')).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/layout/Navigation/Navigation.js b/frontend/packages/core/src/layout/Navigation/Navigation.js new file mode 100644 index 0000000000..fc5c15340f --- /dev/null +++ b/frontend/packages/core/src/layout/Navigation/Navigation.js @@ -0,0 +1,91 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { List, withStyles } from '@material-ui/core'; +import ChevronLeft from '@material-ui/icons/ChevronLeft'; +import ChevronRight from '@material-ui/icons/ChevronRight'; + +import { IconButton } from 'shared/components'; +import { getFromStore, saveToStore } from 'shared/apis/storage/persistentStore'; + +const styles = theme => ({ + nav: { + gridArea: 'pageNav', + width: 62, + marginLeft: theme.spacing(3), + marginTop: theme.spacing(3), + transition: 'width 0.07s, height 0s', + transitionTimingFunction: 'ease-in', + }, + item: { + display: 'block', + }, + list: { + padding: 0, + border: '0px solid #333333', + borderRadius: 6, + backgroundColor: '#333333', + }, + buttonContainer: { + marginLeft: 'auto', + marginRight: 'auto', + marginTop: theme.spacing(0.5), + width: 48, + }, + button: { + color: '#888', + }, +}); + +class Navigation extends Component { + static propTypes = { + condensable: PropTypes.bool, + }; + + state = { + condensed: false, + }; + + constructor(props) { + super(props); + + const condensedPref = getFromStore('navigation', 'condensed', false); + this.state.condensed = this.props.condensable && condensedPref; + } + + buttonClicked = () => { + const condensed = !this.state.condensed; + saveToStore('navigation', 'condensed', condensed); + this.setState({ condensed }); + }; + + render() { + const { classes, condensable } = this.props; + const { condensed } = this.state; + + const ExpandButton = condensable && ( +
+ + {condensed ? : } + +
+ ); + + const children = React.Children.map(this.props.children, child => { + if (!child) return null; + return React.cloneElement(child, { + condensed: condensed, + }); + }); + + return ( + + ); + } +} + +export default withStyles(styles)(Navigation); diff --git a/frontend/packages/core/src/layout/Navigation/Navigation.test.js b/frontend/packages/core/src/layout/Navigation/Navigation.test.js new file mode 100644 index 0000000000..d20fa0ddf2 --- /dev/null +++ b/frontend/packages/core/src/layout/Navigation/Navigation.test.js @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { Navigation, NavItem } from 'shared/components/layout'; +import { wrapInThemedTestApp } from 'testUtils'; + +import { GcpProjectIcon, OverviewIcon } from 'shared/icons'; + +describe('', () => { + it('renders without exploding', () => { + const nav = ( + + } href={'/'} /> + } href={'/projects'} /> + + ); + const rendered = render(wrapInThemedTestApp(nav)); + expect(rendered.getByText('Overview')).toBeInTheDocument(); + expect(rendered.getByText('Projects')).toBeInTheDocument(); + }); + + it('renders a condenseable navigation bar', () => { + const nav = ( + + } href={'/'} /> + } href={'/projects'} /> + + ); + const rendered = render(wrapInThemedTestApp(nav)); + const btn = rendered.getByTestId('nav-expand-toggle'); + expect(btn).toBeInTheDocument(); + }); + + it('is condenseable', () => { + const nav = ( + + } href={'/'} /> + } href={'/projects'} /> + + ); + const rendered = render(wrapInThemedTestApp(nav)); + const btn = rendered.getByTestId('nav-expand-toggle'); + fireEvent.click(btn); + expect(rendered.queryByText('Overview')).not.toBeInTheDocument(); + }); + + it('should have an anchor with correct href', () => { + const nav = ( + + } href={'/projects'} /> + + ); + const rendered = render(wrapInThemedTestApp(nav)); + const anchor = rendered.container.querySelector('a'); + expect(anchor.href).toBe('http://localhost/projects'); + }); +}); diff --git a/frontend/packages/core/src/layout/Page/Page.js b/frontend/packages/core/src/layout/Page/Page.js new file mode 100644 index 0000000000..8425abeb8a --- /dev/null +++ b/frontend/packages/core/src/layout/Page/Page.js @@ -0,0 +1,37 @@ +import React, { Component } from 'react'; +import { withStyles } from '@material-ui/core'; +import { theme } from './PageThemeProvider'; + +const styles = theme => ({ + root: { + display: 'grid', + gridTemplateAreas: + "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'", + gridTemplateRows: 'auto auto 1fr', + gridTemplateColumns: 'auto 1fr auto', + minHeight: '100%', + paddingBottom: theme.spacing(3), + }, +}); + +export const Theme = React.createContext({}); + +class Page extends Component { + static defaultProps = { + theme: theme.other, + }; + + render() { + const { theme, backgroundColor, classes, children, styles = {}, ...otherProps } = this.props; + + return ( + +
+ {children} +
+
+ ); + } +} + +export default withStyles(styles)(Page); diff --git a/frontend/packages/core/src/layout/Page/PageThemeProvider.js b/frontend/packages/core/src/layout/Page/PageThemeProvider.js new file mode 100644 index 0000000000..d6832b82d3 --- /dev/null +++ b/frontend/packages/core/src/layout/Page/PageThemeProvider.js @@ -0,0 +1,172 @@ +import '../../assets/bursts/bursts.css'; +import circle1 from '../../assets/bursts/circle-1.svg'; +import circle2 from '../../assets/bursts/circle-2.svg'; +import triangle1 from '../../assets/bursts/triangle-1.svg'; +import triangle2 from '../../assets/bursts/triangle-2.svg'; +import triangle3 from '../../assets/bursts/triangle-3.svg'; +import rectangle1 from '../../assets/bursts/rectangle-1.svg'; +import rectangle2 from '../../assets/bursts/rectangle-2.svg'; + +// TODO move this file to shared and out of core + +export const shapes = { + circle1: { + backgroundImage: `url(${circle1})`, + width: 'calc(400px + 20%)', + backgroundPosition: '100px 60%', + }, + circle2: { + backgroundImage: `url(${circle2})`, + width: 'calc(400px + 20%)', + backgroundPosition: '100px 58%', + }, + rectangle1: { + backgroundImage: `url(${rectangle1})`, + width: 'calc(400px + 20%)', + backgroundPosition: '100px 60%', + }, + rectangle2: { + backgroundImage: `url(${rectangle2})`, + width: 'calc(400px + 20%)', + backgroundPosition: '30px 35%', + }, + triangle1: { + backgroundImage: `url(${triangle1})`, + width: 'calc(500px + 30%)', + backgroundPosition: '100px calc(50px + 45%)', + }, + triangle2: { + backgroundImage: `url(${triangle2})`, + width: 'calc(400px + 20%)', + backgroundPosition: '100px 38%', + }, + triangle3: { + backgroundImage: `url(${triangle3})`, + width: 'calc(400px + 20%)', + backgroundPosition: '100px 76%', + }, +}; + +export const gradients = { + blue: 'linear-gradient(135deg, #2D46B9 0%, #509BF5 100%)', + darkBlue: 'linear-gradient(44deg, #1E3264 0%, #A0C3D2 100%)', + brown: 'linear-gradient(44deg, #674638 0%, #C39887 100%)', + green: 'linear-gradient(-90deg, #1DB954 0%, #006350 100%)', + orangeYellow: 'linear-gradient(37deg, #FF6437 0%, #FFC864 100%)', + redOrange: 'linear-gradient(37deg, #A72525 0%, #E6542D 100%)', + pinkOrange: 'linear-gradient(43deg, #F13DA2 0%, #FF8A48 100%)', + purpleBlue: 'linear-gradient(-137deg, #4100F4 0%, #AF2996 100%)', + tealGreen: 'linear-gradient(-137deg, #19E68C 0%, #1D7F6E 100%)', + violetPeach: 'linear-gradient(44deg, #B39AC8 0%, #FCCBD3 100%)', + violetGreen: 'linear-gradient(44deg, #4302F4 0%, #C3EFC8 100%)', + purple: 'linear-gradient(-90deg, #a186bd 0%, #7c5c92 100%)', + tpm: 'linear-gradient(-137deg, #00FFF2 0%, #035355 100%)', + royalBlue: 'linear-gradient(45deg, #000044 0%, #0000DD 61.47%, #0033DD 74%, #4B80D4 100%)', + grey: 'linear-gradient(45deg, #111111 0%, #777777 100%)', + sunset: 'linear-gradient(148deg, #cf8022 0%, #4e6ec7 100%)', + sky: 'linear-gradient(135deg, #69B9FF 0%, #ACCEEC 100%)', +}; + +export const theme = { + service: { + activeNavLinkColor: '#1D7F6E', + gradient: gradients.tealGreen, + burstShape: shapes.rectangle2, + }, + appFeature: { + activeNavLinkColor: '#FF6437', + gradient: gradients.orangeYellow, + burstShape: shapes.triangle1, + }, + app: { + activeNavLinkColor: '#A72525', + gradient: gradients.redOrange, + burstShape: shapes.triangle1, + }, + endpoint: { + activeNavLinkColor: '#9315b0', + gradient: gradients.purpleBlue, + burstShape: shapes.circle1, + }, + pipeline: { + activeNavLinkColor: '#9315b0', + gradient: gradients.purpleBlue, + burstShape: shapes.circle1, + }, + website: { + activeNavLinkColor: '#765d90', + gradient: gradients.purple, + burstShape: shapes.rectangle2, + }, + workflow: { + activeNavLinkColor: '#AF2996', + secondary: '#9315b0', + gradient: gradients.purpleBlue, + burstShape: shapes.circle1, + }, + library: { + activeNavLinkColor: '#B39AC8', + gradient: gradients.sunset, + burstShape: shapes.triangle3, + }, + other: { + activeNavLinkColor: '#8f6858', + gradient: gradients.brown, + burstShape: shapes.rectangle1, + }, + system: { + activeNavLinkColor: '#526C90', + gradient: gradients.darkBlue, + burstShape: shapes.triangle2, + }, + project: { + activeNavLinkColor: '#F13DA2', + gradient: gradients.pinkOrange, + burstShape: shapes.triangle3, + }, + tool: { + activeNavLinkColor: '#B39AC8', + gradient: gradients.violetPeach, + burstShape: shapes.rectangle1, + }, + home: { + activeNavLinkColor: '#00814e', + gradient: gradients.green, + burstShape: shapes.triangle2, + }, + org: { + activeNavLinkColor: '#6044ef', + gradient: gradients.violetGreen, + burstShape: shapes.triangle3, + }, + documentation: { + activeNavLinkColor: '#04c2ba', + gradient: gradients.tpm, + burstShape: shapes.circle2, + }, + blogPost: { + activeNavLinkColor: '#FFF', + gradient: 'linear-gradient(90deg, #033A45 0%, #033A45 100%)', + burstShape: shapes.circle2, + }, + machineLearning: { + activeNavLinkColor: '#0033DD', + gradient: gradients.royalBlue, + burstShape: shapes.circle1, + }, + partnership: { + activeNavLinkColor: '#000000', + gradient: gradients.grey, + burstShape: shapes.rectangle2, + }, + ml: { + activeNavLinkColor: '#B39AC8', + gradient: gradients.blue, + burstShape: shapes.circle1, + }, + scienceBox: { + activeNavLinkColor: '#69B9FF', // matches sky gradient beginning point + gradient: gradients.sky, + burstShape: shapes.circle1, + }, +}; \ No newline at end of file diff --git a/frontend/packages/core/src/layout/Page/index.ts b/frontend/packages/core/src/layout/Page/index.ts new file mode 100644 index 0000000000..889778f7e8 --- /dev/null +++ b/frontend/packages/core/src/layout/Page/index.ts @@ -0,0 +1,2 @@ +export { default } from './Page'; +export { shapes, gradients, theme } from './PageThemeProvider'; \ No newline at end of file diff --git a/frontend/packages/core/src/layout/SupportButton/SupportButton.js b/frontend/packages/core/src/layout/SupportButton/SupportButton.js new file mode 100644 index 0000000000..b8663fcb77 --- /dev/null +++ b/frontend/packages/core/src/layout/SupportButton/SupportButton.js @@ -0,0 +1,114 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'recompose'; +import { List, ListItem, ListItemIcon, Popover, Typography, withStyles } from '@material-ui/core'; +import { withPluginContext } from 'core/app/withPluginContext'; +import { EmailIcon, SlackIcon, SupportIcon } from 'shared/icons'; +import { Button, Link } from 'shared/components'; +import { StackOverflow, StackOverflowTag } from 'shared/components/layout'; + +class SupportButton extends Component { + static defaultProps = { + slackChannel: '#backstage', + }; + + static propTypes = { + slackChannel: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + }; + + state = { + popoverOpen: false, + anchorEl: null, + }; + + button_clickHandler = event => { + this.setState({ anchorEl: event.currentTarget, popoverOpen: true }); + }; + + popover_closeHandler = () => { + this.setState({ popoverOpen: false }); + }; + + render() { + const { slackChannel, email, children, classes, plugin } = this.props; + const tags = plugin ? plugin.stackoverflowTags : undefined; // plugin is undefined on base routes + const { popoverOpen, anchorEl } = this.state; + const slackChannels = slackChannel ? (Array.isArray(slackChannel) ? slackChannel : [slackChannel]) : null; + const contactEmails = email ? (Array.isArray(email) ? email : [email]) : null; + + return ( + + + + + {React.Children.map(children, (child, index) => ( + + {child} + + ))} + {tags && tags.length > 0 && ( + + + {tags.map((tag, idx) => ( + + ))} + + + )} + {slackChannels && ( + + + + +
+ Support + {slackChannels.map((channel, index) => ( + + {channel} + + ))} +
+
+ )} + {contactEmails && ( + + + + +
+ Contact + {contactEmails.map((email, index) => ( + + {email} + + ))} +
+
+ )} +
+
+
+ ); + } +} + +const styles = theme => ({ + leftIcon: { + marginRight: theme.spacing(1), + }, + popoverList: { + minWidth: 260, + maxWidth: 320, + }, +}); + +export default compose(withStyles(styles), withPluginContext)(SupportButton); diff --git a/frontend/packages/core/src/layout/SupportButton/SupportButton.test.js b/frontend/packages/core/src/layout/SupportButton/SupportButton.test.js new file mode 100644 index 0000000000..b2289542f1 --- /dev/null +++ b/frontend/packages/core/src/layout/SupportButton/SupportButton.test.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, fireEvent, waitForElement } from '@testing-library/react'; + +import SupportButton from './SupportButton'; +import { wrapInThemedTestApp, Keyboard } from 'testUtils'; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(); + rendered.getByText('Support'); + + fireEvent.click(rendered.baseElement); + }); + + it('should show popover', () => { + const rendered = render( + wrapInThemedTestApp(), + ); + + expect(rendered.queryByText('Contact')).not.toBeInTheDocument(); + fireEvent.click(rendered.getByText('Support')); + expect(rendered.queryByText('Contact')).toBeInTheDocument(); + rendered.getByText('a@b.com'); + rendered.getByText('a-tag'); + }); + + it('should close popover when hitting escape', async () => { + const rendered = render(wrapInThemedTestApp()); + + fireEvent.click(rendered.getByText('Support')); + expect(rendered.queryByText('Contact')).toBeInTheDocument(); + await Keyboard.type(rendered, ''); + await waitForElement(() => !rendered.queryByText('Contact')); + expect(rendered.queryByText('Contact')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/core/src/theme/BackstageTheme.js b/frontend/packages/core/src/theme/BackstageTheme.js new file mode 100644 index 0000000000..d69a86ac37 --- /dev/null +++ b/frontend/packages/core/src/theme/BackstageTheme.js @@ -0,0 +1,358 @@ +import { createMuiTheme, withStyles } from '@material-ui/core'; +import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; +import { blue, yellow } from '@material-ui/core/colors'; +import tinycolor from 'tinycolor2'; + +export const COLORS = { + PAGE_BACKGROUND: '#F8F8F8', + DEFAULT_PAGE_THEME_COLOR: '#7C3699', + DEFAULT_PAGE_THEME_LIGHT_COLOR: tinycolor('#7C3699') + .lighten(50) + .toString(), + ERROR_BACKGROUND_COLOR: '#FFEBEE', + ERROR_TEXT_COLOR: '#CA001B', + INFO_TEXT_COLOR: '#004e8a', + LINK_TEXT: '#0A6EBE', + LINK_TEXT_HOVER: '#2196F3', + COMPONENT_TYPES: { + SERVICE: '#39732E', + ENDPOINT: '#B71C1C', + PROJECT: '#E657AA', + WORKFLOW: '#c15013', + }, + NAMED: { + WHITE: '#FEFEFE', + }, + STATUS: { + OK: '#1db855', + WARNING: '#f49b20', + ERROR: '#CA001B', + }, +}; + +const extendedThemeConfig = { + props: { + MuiGrid: { + spacing: 2, + }, + MuiSwitch: { + color: 'primary', + }, + }, + palette: { + background: { + default: COLORS.PAGE_BACKGROUND, + informational: '#60a3cb', + }, + status: { + ok: COLORS.STATUS.OK, + warning: COLORS.STATUS.WARNING, + error: COLORS.STATUS.ERROR, + running: '#BEBEBE', + pending: '#5BC0DE', + background: COLORS.NAMED.WHITE, + }, + bursts: { + fontColor: COLORS.NAMED.WHITE, + slackChannelText: '#ddd', + backgroundColor: { + service: COLORS.COMPONENT_TYPES.SERVICE, + endpoint: COLORS.COMPONENT_TYPES.ENDPOINT, + project: COLORS.COMPONENT_TYPES.PROJECT, + default: COLORS.DEFAULT_PAGE_THEME_COLOR, + }, + }, + primary: { + main: blue[500], + }, + border: '#E6E6E6', + textVerySubtle: '#DDD', + textSubtle: '#6E6E6E', + highlight: '#FFFBCC', + errorBackground: COLORS.ERROR_BACKGROUND_COLOR, + warningBackground: '#F59B23', + infoBackground: '#ebf5ff', + errorText: COLORS.ERROR_TEXT_COLOR, + infoText: COLORS.INFO_TEXT_COLOR, + warningText: COLORS.NAMED.WHITE, + linkHover: COLORS.LINK_TEXT_HOVER, + link: COLORS.LINK_TEXT, + gold: yellow.A700, + }, + navigation: { + width: 220, + background: '#333333', + }, + typography: { + h4: { + // Page name/heading | Dialog titles + fontSize: 48, + color: '#2D2D2D', + fontWeight: 'bold', + }, + h3: { + // Page titles + fontSize: 32, + color: '#2D2D2D', + fontWeight: 'bold', + }, + h2: { + // Card titles | Sub headings in dialogs + fontSize: 24, + color: '#2D2D2D', + fontWeight: 'bold', + lineHeight: 1.2, + marginBottom: 6, + }, + header3: { + // Table headers (ALL CAPS!) + fontSize: 12, + color: '#9E9E9E', + }, + menuItem1: { + // Sidebar menu item + fontSize: 16, + color: '#828282', + fontWeight: 'bold', + }, + menuItem2: { + // Dropdown menu items | Form labels | Deep links from cards (Go to...) + fontSize: 16, + color: '#2D2D2D', + }, + text: { + // Table entries | Information/error text/messages | General copy/paragraphs + fontSize: 13, + color: '#2D2D2D', + }, + links: { + // Table entries | Information/error text/messages | General copy/paragraphs + color: '#509BF5', + }, + tabSelected: { + // Selected tab + fontSize: 18, + color: '#2D2D2D', + }, + tabUnselected: { + // Unselected tab + fontSize: 18, + color: '#9E9E9E', + }, + code: { + fontFamily: 'monospace', + whiteSpace: 'pre', + fontSize: '16px', + }, + caption: { + // Restores caption to display: block to match MUI3 behavior; this global override should + // be removed if possible. + display: 'block', + }, + }, +}; + +const createOverrides = theme => { + const overrides = { + MuiAppBar: { + root: { + zIndex: 1049, + height: 44, + }, + colorPrimary: { + backgroundColor: '#212121', + }, + }, + MuiToolbar: { + root: { + '@media (min-width:600px)': { + minHeight: 44, + }, + }, + }, + MuiTableRow: { + root: { + height: 'auto', + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.background.default, + }, + }, + hover: { + '&:hover': { + cursor: 'pointer', + }, + }, + head: { + height: 'auto', + '&:nth-of-type(odd)': { + backgroundColor: COLORS.NAMED.WHITE, + }, + }, + }, + MuiTableCell: { + root: { + wordBreak: 'break-word', + overflow: 'hidden', + verticalAlign: 'middle', + lineHeight: '1', + margin: 0, + padding: '8px', + borderBottom: 0, + }, + head: { + wordBreak: 'break-word', + overflow: 'hidden', + color: 'rgb(179, 179, 179)', + fontWeight: 'normal', + lineHeight: '1', + }, + }, + MuiTabs: { + root: { + minHeight: 24, + }, + }, + MuiTab: { + root: { + color: theme.palette.link, + minHeight: 24, + textTransform: 'initial', + '&:hover': { + color: darken(theme.palette.link, 0.3), + background: lighten(theme.palette.link, 0.95), + }, + [theme.breakpoints.up('md')]: { + minWidth: 120, + fontSize: theme.typography.pxToRem(14), + fontWeight: 500, + }, + }, + textColorPrimary: { + color: theme.palette.link, + }, + }, + MuiTableSortLabel: { + root: { + color: 'inherit', + '&:hover': { + color: 'inherit', + }, + '&:focus': { + color: 'inherit', + }, + }, + active: { + fontWeight: 'bold', + color: 'inherit', + }, + }, + MuiListItemText: { + dense: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, + MuiButton: { + text: { + padding: '6px 16px', + }, + }, + MuiChip: { + root: { + marginRight: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + }, + MuiDivider: { + light: { + backgroundColor: '#727272', + }, + }, + MuiDialogTitle: { + root: { + minWidth: 600, + }, + }, + MuiCardHeader: { + root: { + // Remove bottom padding on titles; there is always a CardContent below that also has padding, + // so without this fix there will be too much space below the title. + paddingBottom: '0', + // Mui 1.2.1 introduced more padding left and right with media queries. + // Question is if we should override them or just go with the defaults + '@media (min-width:600px)': { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, + }, + }, + MuiCardContent: { + root: { + '&:last-child': { + // There is some odd extra whitespace at the bottom of card content, which makes it look + // bottom-heavy and uneven; set it to the same as the other sides + paddingBottom: theme.spacing(2), + }, + // Mui 1.2.1 introduced more padding left and right with media queries. + // Question is if we should override them or just go with the defaults + '@media (min-width:600px)': { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, + }, + }, + MuiCardActions: { + root: { + justifyContent: 'flex-end', + }, + }, + MuiListSubheader: { + sticky: { + // Sticky subheaders need to be opaque so that they overwrite list items as you scroll down. + backgroundColor: theme.palette.background.paper, + }, + }, + Toolbar: { + toolbar: { + // Override the toolbar in dx-react-grid table + marginTop: '-64px', + }, + }, + }; + return { + overrides, + }; +}; + +const extendedTheme = createMuiTheme(extendedThemeConfig); + +// V1 theming +// https://material-ui-next.com/customization/themes/ +// For CSS it is advised to use JSS, see https://material-ui-next.com/customization/css-in-js/ + +const BackstageTheme = { ...extendedTheme, ...createOverrides(extendedTheme) }; + +// Temporary workaround for files incorrectly importing the theme directly +export const V1 = BackstageTheme; + +// Default styles for the application other than the built in mui components +const styles = theme => ({ + '@global': { + html: { + height: '100%', + fontFamily: theme.typography.fontFamily, + }, + body: { + height: '100%', + fontFamily: theme.typography.fontFamily, + }, + a: { + color: 'inherit', + textDecoration: 'none', + }, + }, +}); + +export const withGlobalStyles = withStyles(styles); +export default BackstageTheme; \ No newline at end of file diff --git a/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx b/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx index ec28306367..a9ad846047 100644 --- a/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx +++ b/frontend/packages/plugins/hello-world/src/components/MyComponent/MyComponent.tsx @@ -1,8 +1,8 @@ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState, useEffect, Fragment } from 'react'; import Button from '@material-ui/core/Button'; import { HelloPromiseClient } from '../../proto/hello_grpc_web_pb'; import { HelloRequest } from '../../proto/hello_pb'; -import { Paper, Typography } from '@material-ui/core'; +import { Typography } from '@material-ui/core'; const MyComponent: FC<{}> = () => { const [message, setMessage] = useState(''); @@ -25,7 +25,7 @@ const MyComponent: FC<{}> = () => { }, []); return ( - + {message} {error} @@ -37,7 +37,7 @@ const MyComponent: FC<{}> = () => { > Hello! - + ); }; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fc348a1cce..64df4b69f2 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -876,7 +876,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.2" -"@babel/runtime@7.8.4", "@babel/runtime@^7.0.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6": +"@babel/runtime@7.8.4", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6": version "7.8.4" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.8.4.tgz#d79f5a2040f7caa24d53e563aad49cbc05581308" integrity sha512-neAp3zt80trRVBI1x0azq6c57aNBqYZH8KhMm3TaB7wEI5Q4A2SHfBHE8w9gOhI/lrqxtEbXZgQIrHP+wvSGwQ== @@ -2850,6 +2850,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/history@*": + version "4.7.5" + resolved "https://registry.npmjs.org/@types/history/-/history-4.7.5.tgz#527d20ef68571a4af02ed74350164e7a67544860" + integrity sha512-wLD/Aq2VggCJXSjxEwrMafIP51Z+13H78nXIX0ABEuIGhmB5sNGbR113MOKo+yfw+RDo1ZU3DM6yfnnRF/+ouw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -2932,6 +2937,23 @@ dependencies: "@types/react" "*" +"@types/react-router-dom@^5.1.3": + version "5.1.3" + resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196" + integrity sha512-pCq7AkOvjE65jkGS5fQwQhvUp4+4PVD9g39gXLZViP2UqFiFzsEpB3PKf0O6mdbKsewSK8N14/eegisa/0CwnA== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-router" "*" + +"@types/react-router@*": + version "5.1.4" + resolved "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.4.tgz#7d70bd905543cb6bcbdcc6bd98902332054f31a6" + integrity sha512-PZtnBuyfL07sqCJvGg3z+0+kt6fobc/xmle08jBiezLS8FrmGeiGkJnuxL/8Zgy9L83ypUhniV5atZn/L8n9MQ== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-transition-group@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.2.3.tgz#4924133f7268694058e415bf7aea2d4c21131470" @@ -7504,6 +7526,11 @@ grpc-web@^1.0.7: resolved "https://registry.npmjs.org/grpc-web/-/grpc-web-1.0.7.tgz#9e4fbcf63d3734515332ab59e42baa7d0d290015" integrity sha512-Fkbz1nyvvt6GC6ODcxh9Fen6LLB3OTCgGHzHwM2Eni44SUhzqPz1UQgFp9sfBEfInOhx3yBdwo9ZLjZAmJ+TtA== +gud@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" + integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== + gzip-size@5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" @@ -7637,6 +7664,18 @@ hex-color-regex@^1.1.0: resolved "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== +history@^4.9.0: + version "4.10.1" + resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -7646,7 +7685,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -10352,7 +10391,7 @@ longest@^2.0.1: resolved "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz#781e183296aa94f6d4d916dc335d0d17aefa23f8" integrity sha1-eB4YMpaqlPbU2RbcM10NF676I/g= -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -10712,6 +10751,15 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= +mini-create-react-context@^0.3.0: + version "0.3.2" + resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189" + integrity sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw== + dependencies: + "@babel/runtime" "^7.4.0" + gud "^1.0.0" + tiny-warning "^1.0.2" + mini-css-extract-plugin@0.8.0: version "0.8.0" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" @@ -12053,6 +12101,13 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -12982,7 +13037,7 @@ promzard@^0.3.0: dependencies: read "1" -prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.4, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -13192,6 +13247,11 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-addons-text-content@0.0.4: + version "0.0.4" + resolved "https://registry.npmjs.org/react-addons-text-content/-/react-addons-text-content-0.0.4.tgz#d2e259fdc951d1d8906c08902002108dce8792e5" + integrity sha1-0uJZ/clR0diQbAiQIAIQjc6HkuU= + react-app-polyfill@^1.0.5: version "1.0.6" resolved "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-1.0.6.tgz#890f8d7f2842ce6073f030b117de9130a5f385f0" @@ -13249,11 +13309,62 @@ react-error-overlay@^6.0.5: resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.5.tgz#55d59c2a3810e8b41922e0b4e5f85dcf239bd533" integrity sha512-+DMR2k5c6BqMDSMF8hLH0vYKtKTeikiFW+fj0LClN+XZg4N9b8QUAdHC62CGWNLTi/gnuuemNcNcTFrCvK1f+A== -react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4: +react-fast-compare@^2.0.2: + version "2.0.4" + resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" + integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== + +react-helmet@5.2.1: + version "5.2.1" + resolved "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz#16a7192fdd09951f8e0fe22ffccbf9bb3e591ffa" + integrity sha512-CnwD822LU8NDBnjCpZ4ySh8L6HYyngViTZLfBBb3NjtrpN8m49clH8hidHouq20I51Y6TpCTISCBbqiY5GamwA== + dependencies: + object-assign "^4.1.1" + prop-types "^15.5.4" + react-fast-compare "^2.0.2" + react-side-effect "^1.1.0" + +react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4: version "16.12.0" resolved "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-router-dom@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" + integrity sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.1.2" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418" + integrity sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.3.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-side-effect@^1.1.0: + version "1.2.0" + resolved "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.2.0.tgz#0e940c78faba0c73b9b0eba9cd3dda8dfb7e7dae" + integrity sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w== + dependencies: + shallowequal "^1.0.1" + react-transition-group@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.3.0.tgz#fea832e386cf8796c58b61874a3319704f5ce683" @@ -13765,6 +13876,11 @@ resolve-global@1.0.0, resolve-global@^1.0.0: dependencies: global-dirs "^0.1.1" +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + resolve-url-loader@3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-3.1.1.tgz#28931895fa1eab9be0647d3b2958c100ae3c0bf0" @@ -14218,6 +14334,11 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" +shallowequal@^1.0.1: + version "1.1.0" + resolved "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -15184,16 +15305,26 @@ timsort@^0.3.0: resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-invariant@^1.0.2: + version "1.1.0" + resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" + integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== + tiny-relative-date@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/tiny-relative-date/-/tiny-relative-date-1.3.0.tgz#fa08aad501ed730f31cc043181d995c39a935e07" integrity sha512-MOQHpzllWxDCHHaDno30hhLfbouoYlOI8YlMNtvKe1zXbjEVhbcEovQxvZrPvtiYW630GQDoMMarCnjfyfHA+A== -tiny-warning@^1.0.2: +tiny-warning@^1.0.0, tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tinycolor2@1.4.1: + version "1.4.1" + resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" + integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g= + tmp@^0.0.33: version "0.0.33" resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -15715,6 +15846,11 @@ validate-npm-package-name@^3.0.0, validate-npm-package-name@~3.0.0: dependencies: builtins "^1.0.3" +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"