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 (
);
};
+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 => (
+
+ )}
+
+
+ );
+ }
+}
+
+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 :
;
+ }
+
+ 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"