Merge branch 'master' of github.com:spotify/backstage into feat/star-components

* 'master' of github.com:spotify/backstage:
  chore(catalog): consistent use of named exports
  fix(core): Tabs useEffect dependency list
  Optional namespace and name as one part of URL
  Remove deleted UserBadge component from Sidebar story
  remove LoggedUserBadge
  make the sidebar pin button show up again
  feat(backend-common): add common code for service shell
  await promise.all when setting isSignedIn
  PinButton wip
  List auth providers in UserSettings
  Collapsible sidebar item for auth providers
  fix(core): lint error
  refactor(core): update tabs
  Fix tests
  /catalog/:namespace?/:kind/:name/
  feat(core): add Tabs component
This commit is contained in:
blam
2020-06-10 12:32:38 +02:00
49 changed files with 1102 additions and 571 deletions
+4 -2
View File
@@ -31,8 +31,9 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
SidebarUserSettings,
SidebarThemeToggle,
SidebarPinButton,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
@@ -90,7 +91,8 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
<SidebarUserBadge />
<SidebarUserSettings />
<SidebarPinButton />
</Sidebar>
{children}
</SidebarPage>
+5
View File
@@ -27,12 +27,17 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
"helmet": "^3.22.0",
"morgan": "^1.10.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@types/compression": "^1.7.0",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"@types/http-errors": "^1.6.3",
"@types/morgan": "^1.9.0",
+1
View File
@@ -17,3 +17,4 @@
export * from './errors';
export * from './logging';
export * from './middleware';
export * from './service';
@@ -0,0 +1,117 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import compression from 'compression';
import cors from 'cors';
import express, { Router } from 'express';
import helmet from 'helmet';
import { Server } from 'http';
import { Logger } from 'winston';
import { getRootLogger } from '../logging';
import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '../middleware';
import { ServiceBuilder } from './types';
const DEFAULT_PORT = 7000;
export class ServiceBuilderImpl implements ServiceBuilder {
private port: number | undefined;
private logger: Logger | undefined;
private corsOptions: cors.CorsOptions | undefined;
private routers: [string, Router][];
constructor() {
this.routers = [];
}
setPort(port: number): ServiceBuilder {
this.port = port;
return this;
}
setLogger(logger: Logger): ServiceBuilder {
this.logger = logger;
return this;
}
enableCors(options: cors.CorsOptions): ServiceBuilder {
this.corsOptions = options;
return this;
}
addRouter(root: string, router: Router): ServiceBuilder {
this.routers.push([root, router]);
return this;
}
start(): Promise<Server> {
const app = express();
const { port, logger, corsOptions } = this.getOptions();
app.use(helmet());
if (corsOptions) {
app.use(cors(corsOptions));
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
for (const [root, route] of this.routers) {
app.use(root, route);
}
app.use(notFoundHandler());
app.use(errorHandler());
return new Promise((resolve, reject) => {
app.on('error', e => {
logger.error(`Failed to start up on port ${port}, ${e}`);
reject(e);
});
const server = app.listen(port, () => {
logger.info(`Listening on port ${port}`);
});
resolve(server);
});
}
private getOptions(): {
port: number;
logger: Logger;
corsOptions?: cors.CorsOptions;
} {
let port: number;
if (this.port !== undefined) {
port = this.port;
} else {
port = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT;
}
let logger: Logger;
if (this.logger) {
logger = this.logger;
} else {
logger = getRootLogger();
}
return {
port,
logger,
corsOptions: this.corsOptions,
};
}
}
@@ -0,0 +1,24 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ServiceBuilderImpl } from './ServiceBuilderImpl';
/**
* Creates a new service builder.
*/
export function createServiceBuilder() {
return new ServiceBuilderImpl();
}
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createServiceBuilder } from './createServiceBuilder';
export type { ServiceBuilder } from './types';
@@ -0,0 +1,65 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import cors from 'cors';
import { Router } from 'express';
import { Server } from 'http';
import { Logger } from 'winston';
export type ServiceBuilder = {
/**
* Sets the port to listen on.
*
* If no port is specified, the service will first look for an environment
* variable named PORT and use that if present, otherwise it picks a default
* port (7000).
*
* @param port The port to listen on
*/
setPort(port: number): ServiceBuilder;
/**
* Sets the logger to use for service-specific logging.
*
* If no logger is given, the default root logger is used.
*
* @param logger A winston logger
*/
setLogger(logger: Logger): ServiceBuilder;
/**
* Enables CORS handling using the given settings.
*
* If this method is not called, the resulting service will not have any
* built in CORS handling.
*
* @param options Standard CORS options
*/
enableCors(options: cors.CorsOptions): ServiceBuilder;
/**
* Adds a router (similar to the express .use call) to the service.
*
* @param root The root URL to bind to (e.g. "/api/function1")
* @param router An express router
*/
addRouter(root: string, router: Router): ServiceBuilder;
/**
* Starts the server using the given settings.
*/
start(): Promise<Server>;
};
-5
View File
@@ -24,19 +24,14 @@
"@backstage/plugin-identity-backend": "^0.1.1-alpha.7",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.7",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.7",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"helmet": "^3.22.0",
"knex": "^0.21.1",
"sqlite3": "^4.2.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@types/compression": "^1.7.0",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47",
+19 -38
View File
@@ -22,27 +22,15 @@
* Happy hacking!
*/
import {
errorHandler,
getRootLogger,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { createServiceBuilder, getRootLogger } from '@backstage/backend-common';
import knex from 'knex';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
import identity from './plugins/identity';
import scaffolder from './plugins/scaffolder';
import sentry from './plugins/sentry';
import auth from './plugins/auth';
import identity from './plugins/identity';
import { PluginEnvironment } from './types';
const DEFAULT_PORT = 7000;
const PORT = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT;
function createEnv(plugin: string): PluginEnvironment {
const logger = getRootLogger().child({ type: 'plugin', plugin });
const database = knex({
@@ -57,30 +45,23 @@ function createEnv(plugin: string): PluginEnvironment {
}
async function main() {
const app = express();
const corsOptions: cors.CorsOptions = {
origin: 'http://localhost:3000',
credentials: true,
};
const service = createServiceBuilder()
.enableCors({
origin: 'http://localhost:3000',
credentials: true,
})
.addRouter('/catalog', await catalog(createEnv('catalog')))
.addRouter('/scaffolder', await scaffolder(createEnv('scaffolder')))
.addRouter(
'/sentry',
await sentry(getRootLogger().child({ type: 'plugin', plugin: 'sentry' })),
)
.addRouter('/auth', await auth(createEnv('auth')))
.addRouter('/identity', await identity(createEnv('identity')));
app.use(helmet());
app.use(cors(corsOptions));
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/catalog', await catalog(createEnv('catalog')));
app.use('/scaffolder', await scaffolder(createEnv('scaffolder')));
app.use(
'/sentry',
await sentry(getRootLogger().child({ type: 'plugin', plugin: 'sentry' })),
);
app.use('/auth', await auth(createEnv('auth')));
app.use('/identity', await identity(createEnv('identity')));
app.use(notFoundHandler());
app.use(errorHandler());
app.listen(PORT, () => {
getRootLogger().info(`Listening on port ${PORT}`);
await service.start().catch(err => {
console.log(err);
process.exit(1);
});
}
@@ -0,0 +1,27 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { StyledTab } from './Tab';
describe('<Tab />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<StyledTab label="test" />));
expect(rendered.getByText('test')).toBeInTheDocument();
});
});
+62
View File
@@ -0,0 +1,62 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Tab, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
interface StyledTabProps {
label?: string;
icon?: any; // TODO: define type for material-ui icons
isFirstNav?: boolean;
isFirstIndex?: boolean;
value?: any;
}
const tabMarginLeft = (isFirstNav: boolean, isFirstIndex: boolean) => {
if (isFirstIndex) {
if (isFirstNav) {
return '20px';
}
return '0';
}
return '40px';
};
const useStyles = makeStyles<BackstageTheme, StyledTabProps>(theme => ({
root: {
textTransform: 'none',
height: '64px',
fontWeight: theme.typography.fontWeightBold,
fontSize: theme.typography.pxToRem(13),
color: theme.palette.textSubtle,
marginLeft: props =>
tabMarginLeft(props.isFirstNav as boolean, props.isFirstIndex as boolean),
width: '130px',
minWidth: '130px',
'&:hover': {
outline: 'none',
backgroundColor: 'transparent',
color: theme.palette.textSubtle,
},
},
}));
export const StyledTab = (props: StyledTabProps) => {
const classes = useStyles(props);
const { isFirstNav, isFirstIndex, ...rest } = props;
return <Tab className={classes.root} disableRipple {...rest} />;
};
@@ -0,0 +1,52 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Tabs, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
interface StyledTabsProps {
value: number | boolean;
onChange: (event: React.ChangeEvent<{}>, newValue: number) => void;
}
const useStyles = makeStyles<BackstageTheme>(theme => ({
indicator: {
display: 'flex',
justifyContent: 'center',
backgroundColor: theme.palette.tabbar.indicator,
height: '4px',
},
flexContainer: {
alignItems: 'center',
},
root: {
'&:last-child': {
marginLeft: 'auto',
},
},
}));
export const StyledTabs: FC<StyledTabsProps> = props => {
const classes = useStyles(props);
return (
<Tabs
classes={classes}
{...props}
TabIndicatorProps={{ children: <span /> }}
/>
);
};
@@ -0,0 +1,60 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { IconButton, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
interface StyledIconProps {
ariaLabel: string;
children: any;
isNext?: boolean;
onClick: any;
}
const useStyles = makeStyles<BackstageTheme, StyledIconProps>(() => ({
root: {
color: '#6E6E6E',
overflow: 'visible',
fontSize: '1.5rem',
textAlign: 'center',
borderRadius: '50%',
backgroundColor: '#E6E6E6',
marginLeft: props => (props.isNext ? 'auto' : '0'),
marginRight: props => (props.isNext ? '0' : '10px'),
'&:hover': {
backgroundColor: '#E6E6E6',
opacity: '1',
},
},
}));
export const StyledIcon = (props: StyledIconProps) => {
const classes = useStyles(props);
const { ariaLabel, onClick } = props;
return (
<IconButton
onClick={onClick}
className={classes.root}
size="small"
disableRipple
disableFocusRipple
aria-label={ariaLabel}
>
{props.children}
</IconButton>
);
};
@@ -0,0 +1,39 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import Box from '@material-ui/core/Box';
export interface TabPanelProps {
children: any;
value?: any;
index?: number;
}
export const TabPanel: FC<TabPanelProps> = props => {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
aria-labelledby={`scrollable-auto-tab-${index}`}
{...other}
>
{value === index && <Box p={3}>{children}</Box>}
</div>
);
};
@@ -0,0 +1,71 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Tabs } from './Tabs';
import AccessAlarmIcon from '@material-ui/icons/AccessAlarm';
export default {
title: 'Tabs',
component: Tabs,
};
const containerStyle = {};
export const Default = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const Expandable = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(31)].map((_, index) => ({
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const Icons = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
icon: <AccessAlarmIcon />,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const IconsAndLabels = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
icon: <AccessAlarmIcon />,
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
+165
View File
@@ -0,0 +1,165 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, {
FC,
useRef,
useEffect,
MutableRefObject,
useState,
} from 'react';
import { BackstageTheme } from '@backstage/theme';
import { AppBar } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore';
import NavigateNextIcon from '@material-ui/icons/NavigateNext';
import { chunkArray } from './utils';
import { useWindowSize } from 'react-use';
/* Import Components */
import { TabPanel } from './TabPanel';
import { StyledIcon } from './TabIcon';
import { StyledTab } from './Tab';
import { StyledTabs } from './TabBar';
/* Props Types */
export interface TabProps {
content: any;
label?: string;
icon?: any; // TODO: define type for material-ui icons
}
export interface TabsProps {
tabs: TabProps[];
}
const useStyles = makeStyles<BackstageTheme>((theme: BackstageTheme) => ({
root: {
flexGrow: 1,
width: '100%',
},
styledTabs: {
backgroundColor: theme.palette.background.paper,
},
appbar: {
boxShadow: 'none',
backgroundColor: theme.palette.background.paper,
paddingLeft: '10px',
paddingRight: '10px',
},
}));
export const Tabs: FC<TabsProps> = ({ tabs }) => {
const classes = useStyles();
const [value, setValue] = useState([0, 0]); // [selectedChunckedNavIndex, selectedIndex]
const [navIndex, setNavIndex] = useState(0);
const [numberOfChunkedElement, setNumberOfChunkedElement] = useState(0);
const [chunkedTabs, setChunkedTabs] = useState<TabProps[][]>([[]]);
const wrapper = useRef() as MutableRefObject<HTMLDivElement>;
const { width } = useWindowSize();
const handleChange = (_: React.ChangeEvent<{}>, newValue: number) => {
setValue([navIndex, newValue]);
};
const navigateToPrevChunk = () => {
setNavIndex(navIndex - 1);
};
const navigateToNextChunk = () => {
setNavIndex(navIndex + 1);
};
const hasNextNavIndex = () => navIndex + 1 < chunkedTabs.length;
useEffect(() => {
// Each time the window is resized we calculate how many tabs wwe can render given the window width
const padding = 20; // The AppBar padding
const numberOfTabIcons = navIndex === 0 ? 1 : 2;
const wrapperWidth =
wrapper.current.offsetWidth - padding - numberOfTabIcons * 30;
const flattenIndex = value[0] * numberOfChunkedElement + value[1];
const newChunkedElementSize = Math.floor(wrapperWidth / 170);
setNumberOfChunkedElement(newChunkedElementSize);
setChunkedTabs(chunkArray([...tabs], newChunkedElementSize));
setValue([
Math.floor(flattenIndex / newChunkedElementSize),
flattenIndex % newChunkedElementSize,
]);
// eslint-disable-next-line
}, [width, tabs]);
const currentIndex = navIndex === value[0] ? value[1] : false;
return (
<div className={classes.root}>
<AppBar ref={wrapper} className={classes.appbar} position="static">
<div>
<StyledTabs value={currentIndex} onChange={handleChange}>
{navIndex !== 0 && (
<StyledIcon
onClick={navigateToPrevChunk}
ariaLabel="navigate-before"
>
<NavigateBeforeIcon />
</StyledIcon>
)}
{chunkedTabs[navIndex].map((tab, index) => (
<StyledTab
value={index}
isFirstIndex={index === 0}
isFirstNav={navIndex === 0}
key={index}
icon={tab.icon || undefined}
label={tab.label || undefined}
/>
))}
{hasNextNavIndex() && (
<StyledIcon
isNext
onClick={navigateToNextChunk}
ariaLabel="navigate-next"
>
<NavigateNextIcon />
</StyledIcon>
)}
</StyledTabs>
</div>
</AppBar>
{currentIndex !== false ? (
chunkedTabs[navIndex].map((tab, index) => (
<TabPanel key={index} value={index} index={currentIndex}>
{tab.content}
</TabPanel>
))
) : (
// Render if the selected tab index is outside the current rendered chunked array
<TabPanel
key="panel_outside_chunked_array"
value={value[1]}
index={value[1]}
>
{chunkedTabs[value[0]][value[1]].content}
</TabPanel>
)}
</div>
);
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CatalogPage';
export { Tabs as default } from './Tabs';
@@ -0,0 +1,27 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TabProps } from './Tabs';
export const chunkArray = (
myArray: TabProps[],
chunkSize: number,
): TabProps[][] => {
const results = [];
while (myArray.length) {
results.push(myArray.splice(0, chunkSize));
}
return results;
};
+1
View File
@@ -41,3 +41,4 @@ export * from './components/Status';
export * from './components/Button';
export * from './components/Link';
export { default as WarningPanel } from './components/WarningPanel';
export { default as Tabs } from './components/Tabs';
+7 -2
View File
@@ -59,6 +59,7 @@ const useStyles = makeStyles<Theme>(theme => {
fontWeight: 'bold',
whiteSpace: 'nowrap',
lineHeight: 1.0,
flex: '3 1 auto',
},
iconContainer: {
boxSizing: 'border-box',
@@ -84,6 +85,11 @@ const useStyles = makeStyles<Theme>(theme => {
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
},
secondaryAction: {
width: theme.spacing(6),
textAlign: 'center',
marginRight: theme.spacing(1),
},
selected: {
'&$root': {
borderLeft: `solid ${selectedIndicatorWidth}px #9BF0E1`,
@@ -148,7 +154,6 @@ export const SidebarItem: FC<SidebarItemProps> = ({
</NavLink>
);
}
return (
<NavLink
className={clsx(classes.root, classes.open)}
@@ -166,7 +171,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
{text}
</Typography>
)}
{children}
<div className={classes.secondaryAction}>{children}</div>
</NavLink>
);
};
@@ -1,298 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useState, useEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { sidebarConfig } from './config';
import {
Avatar,
ListItem,
ListItemAvatar,
ListItemText,
Popover,
List,
ListItemIcon,
ListItemSecondaryAction,
IconButton,
Tooltip,
Typography,
} from '@material-ui/core';
import { blueGrey } from '@material-ui/core/colors';
import { useSetState } from 'react-use';
import { Skeleton } from '@material-ui/lab';
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api';
import LogoutIcon from '@material-ui/icons/PowerSettingsNew';
import ControlPointIcon from '@material-ui/icons/ControlPoint';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
const useStyles = makeStyles<Theme>(theme => {
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
return {
root: {
width: drawerWidthOpen,
display: 'flex',
alignItems: 'center',
paddingLeft: 18,
paddingTop: 14,
paddingBottom: 14,
color: '#b5b5b5',
},
avatar: {
width: userBadgeDiameter,
height: userBadgeDiameter,
marginRight: 8,
},
purple: {
color: theme.palette.getContrastText(blueGrey[500]),
backgroundColor: blueGrey[500],
},
listItemText: {
overflow: 'hidden',
textOverflow: 'ellipsis',
},
};
});
const SessionListItem: FC<{
classes: any;
loading: boolean;
title: string;
icon: any;
user: any;
onSignIn: Function;
onSignOut: Function;
}> = ({
classes,
loading,
title,
icon,
user,
onSignIn,
onSignOut,
...props
}) => {
if (loading) {
return (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>
<Skeleton variant="circle" width={40} height={40} />
</ListItemIcon>
<ListItemText
primary={<Skeleton component="span" width={120} />}
secondary={<Skeleton component="span" width={60} />}
/>
<ListItemSecondaryAction>
<IconButton>
<Skeleton variant="circle" width={24} height={24} />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
}
// TODO: Not functional yet to sign in from the sidebar
if (!user) {
return (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>{icon}</ListItemIcon>
<ListItemText primary="Sign In" secondary={title} />
<ListItemSecondaryAction>
<Tooltip
title={`Sign in with ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignIn()}>
<ControlPointIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
const { id, avatarUrl, avatarAlt } = user;
return (
<ListItem {...props}>
<ListItemAvatar>
<Avatar src={avatarUrl} alt={avatarAlt}>
{avatarAlt && avatarAlt[0].toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
className={classes.listItemText}
primary={
<Typography className={classes.listItemText} variant="body2">
{id}
</Typography>
}
secondary={title}
/>
<ListItemSecondaryAction style={{ marginLeft: '30px' }}>
<Tooltip
title={`Sign out from ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignOut()}>
<LogoutIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
};
const useGoogleLoginState = (open: boolean) => {
const googleAuth = useApi(googleAuthApiRef);
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<ProfileInfo>();
useEffect(() => {
let didCancel = false;
if (open) {
googleAuth.getProfile().then(_profile => {
if (!didCancel) {
setProfile(_profile);
setLoading(false);
}
});
}
return () => {
didCancel = true;
};
}, [open, googleAuth]);
if (loading) {
return { loading: true };
}
return { loading: false, isLoggedIn: !!profile, profile };
};
type Props = {
email: string;
imageUrl?: string;
name?: string;
collapsedMode?: boolean;
};
export const LoggedUserBadge: FC<Props> = ({
imageUrl,
name,
email,
collapsedMode = false,
}) => {
const [state, setState] = useSetState({
open: false,
anchorEl: null,
});
const googleAuth = useApi(googleAuthApiRef);
const googleLogin = useGoogleLoginState(state.open);
const handleOpen = (event: {
preventDefault: () => void;
currentTarget: any;
}) => {
// This prevents ghost click.
event.preventDefault();
setState({
open: true,
anchorEl: event.currentTarget,
});
};
const handleClose = () => {
setState({
open: false,
});
};
const handleGoogleSignIn = () => {
googleAuth.getIdToken();
handleClose();
};
const handleGoogleSignOut = () => {
googleAuth.logout();
};
const classes = useStyles();
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
const displayName = name ?? displayEmail;
return (
<>
<List dense>
<ListItem className={classes.root} onClick={handleOpen}>
<ListItemAvatar>
{imageUrl ? (
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
) : (
<Avatar
alt={name}
className={`${classes.avatar} ${classes.purple}`}
>
{avatarFallback[0]}
</Avatar>
)}
</ListItemAvatar>
{!collapsedMode && (
<ListItemText
primary={
<Typography className={classes.listItemText} variant="body2">
{displayName}
</Typography>
}
/>
)}
</ListItem>
</List>
<Popover
transitionDuration={0}
open={state.open}
anchorEl={state.anchorEl}
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
transformOrigin={{ horizontal: 'center', vertical: 'bottom' }}
onClose={handleClose}
>
<List dense>
<SessionListItem
classes={classes}
loading={googleLogin.loading}
title="Google"
icon={AccountCircleIcon}
user={
googleLogin.isLoggedIn && {
id: googleLogin.profile?.email,
avatarUrl: googleLogin.profile?.picture ?? '',
avatarAlt:
googleLogin.profile?.picture ?? googleLogin.profile?.email,
}
}
onSignIn={handleGoogleSignIn}
onSignOut={handleGoogleSignOut}
/>
</List>
</Popover>
</>
);
};
@@ -14,35 +14,32 @@
* limitations under the License.
*/
import React, { FC, useContext, useEffect, useState } from 'react';
import React, { FC, useContext } from 'react';
import { makeStyles } from '@material-ui/core';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import { LoggedUserBadge } from './LoggedUserBadge';
import DoubleArrowIcon from '@material-ui/icons/DoubleArrow';
import { SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
import { SidebarPinStateContext } from './Page';
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api';
const ARROW_BUTTON_SIZE = 20;
const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(theme => {
return {
root: {
position: 'relative',
alignSelf: 'stretch',
},
arrowButtonWrapper: {
position: 'absolute',
right: 0,
width: ARROW_BUTTON_SIZE,
height: ARROW_BUTTON_SIZE,
top: `calc(50% - ${ARROW_BUTTON_SIZE / 2}px)`,
top: -(theme.spacing(6) + ARROW_BUTTON_SIZE) / 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px 0px 0px 2px',
background: theme.palette.pinSidebarButton.icon,
color: theme.palette.pinSidebarButton.background,
background: theme.palette.pinSidebarButton.background,
color: theme.palette.pinSidebarButton.icon,
border: 'none',
outline: 'none',
cursor: 'pointer',
@@ -53,37 +50,15 @@ const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(theme => {
};
});
export const SidebarUserBadge: FC<{}> = () => {
export const SidebarPinButton: FC<{}> = () => {
const { isOpen } = useContext(SidebarContext);
const { isPinned, toggleSidebarPinState } = useContext(
SidebarPinStateContext,
);
const classes = useStyles({ isPinned });
const googleAuth = useApi(googleAuthApiRef);
const [profile, setProfile] = useState<ProfileInfo>();
useEffect(() => {
// TODO(soapraj): How to observe if the user is logged in
// TODO(soapraj): List all the providers supported by the app and let user log in from here
googleAuth.getProfile({ optional: true }).then(googleProfile => {
setProfile(googleProfile);
});
}, [googleAuth]);
return (
<div className={classes.root}>
{profile ? (
<>
<LoggedUserBadge
email={profile.email}
imageUrl={profile.picture}
name={profile.name}
collapsedMode={!isOpen}
/>
</>
) : (
<SidebarItem icon={AccountCircleIcon} text="" disableSelected />
)}
{isOpen && (
<button
className={classes.arrowButtonWrapper}
@@ -22,7 +22,6 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
} from '.';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
@@ -55,6 +54,5 @@ export const SampleSidebar = () => (
<SidebarIntro />
<SidebarSpace />
<SidebarDivider />
<SidebarUserBadge />
</Sidebar>
);
@@ -0,0 +1,188 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useContext, useEffect, useRef } from 'react';
import Collapse from '@material-ui/core/Collapse';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import Divider from '@material-ui/core/Divider';
import {
useApi,
googleAuthApiRef,
githubAuthApiRef,
ProfileInfo,
} from '@backstage/core-api';
import { Avatar, IconButton, makeStyles, Tooltip } from '@material-ui/core';
import PowerButton from '@material-ui/icons/PowerSettingsNew';
type Provider = {
title: string;
api: any;
identity?: boolean;
isSignedIn: boolean;
icon: any;
};
const useProviders = () => {
const googleAuth = useApi(googleAuthApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [providers, setProviders] = useState<Provider[]>([
{
title: 'Google',
api: googleAuth,
identity: true,
isSignedIn: false,
icon: Star,
},
{
title: 'Github',
api: githubAuth,
isSignedIn: false,
icon: StarBorder,
},
]);
const setIsSignedIn = async () => {
const signInChecks = await Promise.all(
providers.map(provider =>
provider.identity
? provider.api.getIdToken({ optional: true })
: provider.api.getAccessToken('', { optional: true }),
),
);
signInChecks.map((result, i) => {
providers[i].isSignedIn = !!result;
});
setProviders(providers);
};
setIsSignedIn();
return providers;
};
const useStyles = makeStyles({
avatar: {
width: 24,
height: 24,
},
});
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
const ref = useRef<Element>(); // for scrolling down when collapse item opens
const providers = useProviders();
const [profile, setProfile] = useState<ProfileInfo>();
const classes = useStyles();
// TODO(soapraj): List all the providers supported by the app and let user log in from here
// TODO(soapraj): How to observe if the user is logged in
useEffect(() => {
const identityProvider = providers.find(
(provider: Provider) => provider.identity,
);
identityProvider?.api
.getProfile({ optional: true })
.then((userProfile: ProfileInfo) => {
setProfile(userProfile);
});
}, [providers, open]);
const handleClick = () => {
setOpen(!open);
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
};
// Close the provider list when sidebar collapse
useEffect(() => {
if (!sidebarOpen && open) setOpen(false);
}, [open, sidebarOpen]);
// Handle main auth info that is shown on the collapsible SidebarItem
let avatar;
let displayName;
if (profile) {
const email = profile.email;
const name = profile.name;
const imageUrl = profile.picture;
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
displayName = name ?? displayEmail;
avatar = imageUrl
? () => <Avatar alt={name} src={imageUrl} className={classes.avatar} />
: () => (
<Avatar alt={name} className={classes.avatar}>
{avatarFallback[0]}
</Avatar>
);
}
return (
<>
<Divider innerRef={ref} />
<SidebarItem
text={displayName || 'Guest'}
onClick={handleClick}
icon={avatar || AccountCircleIcon}
disableSelected
>
{open ? <ExpandLess /> : <ExpandMore />}
</SidebarItem>
<Collapse in={open} timeout="auto" unmountOnExit>
{providers.map((provider: Provider) => (
<SidebarItem
key={provider.title}
text={provider.title}
icon={provider.icon ?? StarBorder}
disableSelected
>
<IconButton
onClick={() =>
provider.isSignedIn
? provider.api.logout()
: provider.api.getAccessToken()
}
>
<Tooltip
placement="top"
arrow
title={
provider.isSignedIn
? `Logout from ${provider.title}`
: `Sign in to ${provider.title}`
}
>
<PowerButton
color={provider.isSignedIn ? 'secondary' : 'primary'}
/>
</Tooltip>
</IconButton>
</SidebarItem>
))}
</Collapse>
</>
);
}
+2 -1
View File
@@ -25,7 +25,7 @@ export {
SidebarSpacer,
} from './Items';
export { IntroCard, SidebarIntro } from './Intro';
export { SidebarUserBadge } from './UserBadge';
export { SidebarPinButton } from './PinButton';
export {
SIDEBAR_INTRO_LOCAL_STORAGE,
SidebarContext,
@@ -33,3 +33,4 @@ export {
} from './config';
export type { SidebarContextType } from './config';
export { SidebarThemeToggle } from './SidebarThemeToggle';
export { SidebarUserSettings } from './UserSettings';
+9 -3
View File
@@ -57,8 +57,11 @@ export const lightTheme = createTheme({
gold: yellow.A700,
sidebar: '#171717',
pinSidebarButton: {
icon: '#BDBDBD',
background: '#404040',
icon: '#181818',
background: '#BDBDBD',
},
tabbar: {
indicator: '#9BF0E1',
},
},
});
@@ -103,8 +106,11 @@ export const darkTheme = createTheme({
gold: yellow.A700,
sidebar: '#424242',
pinSidebarButton: {
icon: '#181818',
icon: '#404040',
background: '#BDBDBD',
},
tabbar: {
indicator: '#9BF0E1',
},
},
});
+3
View File
@@ -44,6 +44,9 @@ type PaletteAdditions = {
link: string;
gold: string;
sidebar: string;
tabbar: {
indicator: string;
};
bursts: {
fontColor: string;
slackChannelText: string;
-3
View File
@@ -18,13 +18,10 @@
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"knex": "^0.21.1",
"lodash": "^4.17.15",
"morgan": "^1.10.0",
@@ -1,71 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
entitiesCatalog: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
logger: Logger;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const {
enableCors,
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
} = options;
const app = express();
app.use(helmet());
if (enableCors) {
app.use(cors());
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use(
'/catalog',
await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
}),
);
app.use(notFoundHandler());
app.use(errorHandler());
return app;
}
@@ -14,13 +14,15 @@
* limitations under the License.
*/
import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { HigherOrderOperations } from '..';
import { DatabaseEntitiesCatalog } from '../catalog/DatabaseEntitiesCatalog';
import { DatabaseLocationsCatalog } from '../catalog/DatabaseLocationsCatalog';
import { DatabaseManager } from '../database/DatabaseManager';
import { HigherOrderOperations, LocationReaders } from '../ingestion';
import { createStandaloneApplication } from './standaloneApplication';
import { createRouter } from './router';
import { LocationReaders } from '../ingestion';
export interface ServerOptions {
port: number;
@@ -33,11 +35,11 @@ export async function startStandaloneServer(
): Promise<Server> {
const logger = options.logger.child({ service: 'catalog-backend' });
logger.debug('Creating application...');
const db = await DatabaseManager.createInMemoryDatabase(logger);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders(options.logger);
const locationReader = new LocationReaders();
const higherOrderOperation = new HigherOrderOperations(
entitiesCatalog,
locationsCatalog,
@@ -45,25 +47,18 @@ export async function startStandaloneServer(
logger,
);
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger.debug('Starting application server...');
const router = await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
});
logger.debug('Starting application server...');
return await new Promise((resolve, reject) => {
const server = app.listen(options.port, (err?: Error) => {
if (err) {
reject(err);
return;
}
logger.info(`Listening on port ${options.port}`);
resolve(server);
});
const service = createServiceBuilder()
.enableCors({ origin: 'http://localhost:3000' })
.addRouter('/catalog', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
+13 -2
View File
@@ -77,9 +77,20 @@ export class CatalogClient implements CatalogApi {
this.cache.set(`get:${JSON.stringify(filter)}`, value);
return value;
}
async getEntityByName(name: string): Promise<DescriptorEnvelope> {
async getEntity({
name,
namespace,
kind,
}: {
name: string;
namespace?: string;
kind: string;
}): Promise<DescriptorEnvelope> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-name/Component/default/${name}`,
`${this.apiOrigin}${this.basePath}/entities/by-name/${kind}/${
namespace ?? 'default'
}/${name}`,
);
const entity = await response.json();
if (entity) return entity;
+5 -1
View File
@@ -23,9 +23,13 @@ export const catalogApiRef = createApiRef<CatalogApi>({
});
export interface CatalogApi {
getEntity(params: {
name: string;
namespace?: string;
kind: string;
}): Promise<Entity>;
getLocationById(id: String): Promise<Location | undefined>;
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
getEntityByName(name: string): Promise<Entity>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
}
@@ -14,20 +14,20 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import CatalogPage from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
import {
ApiRegistry,
ApiProvider,
ApiRegistry,
errorApiRef,
storageApiRef,
WebStorage,
} from '@backstage/core';
import { wrapInTestApp, MockErrorApi } from '@backstage/test-utils';
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { Entity } from '@backstage/catalog-model';
import { CatalogPage } from './CatalogPage';
describe('CatalogPage', () => {
const mockErrorApi = new MockErrorApi();
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { LocationSpec, Entity } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
@@ -26,27 +27,27 @@ import {
SupportButton,
useApi,
} from '@backstage/core';
import { LocationSpec, Entity } from '@backstage/catalog-model';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import GitHub from '@material-ui/icons/GitHub';
import StarOutline from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import Edit from '@material-ui/icons/Edit';
import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { defaultFilter, filterGroups, dataResolvers } from '../../data/filters';
import { entityToComponent, findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
CatalogFilter,
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import CatalogTable from '../CatalogTable/CatalogTable';
import { CatalogTable } from '../CatalogTable/CatalogTable';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -61,7 +62,7 @@ const useStyles = makeStyles(theme => ({
},
}));
const CatalogPage: FC<{}> = () => {
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const {
starredEntities,
@@ -215,5 +216,3 @@ const CatalogPage: FC<{}> = () => {
</Page>
);
};
export default CatalogPage;
@@ -16,7 +16,7 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CatalogTable from './CatalogTable';
import { CatalogTable } from './CatalogTable';
import { Component } from '../../data/component';
const components: Component[] = [
@@ -17,9 +17,8 @@ import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC } from 'react';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { Component } from '../../data/component';
import { entityRoute } from '../../routes';
const columns: TableColumn[] = [
@@ -30,7 +29,15 @@ const columns: TableColumn[] = [
render: (componentData: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, { name: componentData.name })}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
componentData.namespace,
componentData.name,
]
.filter(Boolean)
.join(':'),
kind: componentData.kind,
})}
>
{componentData.name}
</Link>
@@ -54,7 +61,7 @@ type CatalogTableProps = {
actions?: any;
};
const CatalogTable: FC<CatalogTableProps> = ({
export const CatalogTable: FC<CatalogTableProps> = ({
components,
loading,
error,
@@ -87,5 +94,3 @@ const CatalogTable: FC<CatalogTableProps> = ({
/>
);
};
export default CatalogTable;
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ComponentContextMenu from './ComponentContextMenu';
import { ComponentContextMenu } from './ComponentContextMenu';
import { render } from '@testing-library/react';
import * as React from 'react';
import { act } from 'react-dom/test-utils';
@@ -21,11 +21,11 @@ import {
Popover,
Typography,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import Cancel from '@material-ui/icons/Cancel';
import MoreVert from '@material-ui/icons/MoreVert';
import SwapHoriz from '@material-ui/icons/SwapHoriz';
import React, { FC, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
// TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead
const useStyles = makeStyles({
@@ -38,7 +38,7 @@ type ComponentContextMenuProps = {
onUnregisterComponent: () => void;
};
const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
onUnregisterComponent,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
@@ -94,5 +94,3 @@ const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
</div>
);
};
export default ComponentContextMenu;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import ComponentMetadataCard from './ComponentMetadataCard';
import { ComponentMetadataCard } from './ComponentMetadataCard';
import { Component } from '../../data/component';
import { render } from '@testing-library/react';
@@ -13,18 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InfoCard, Progress, StructuredMetadataTable } from '@backstage/core';
import React, { FC } from 'react';
import { Component } from '../../data/component';
import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core';
type ComponentMetadataCardProps = {
type Props = {
loading: boolean;
component: Component | undefined;
};
const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
loading,
component,
}) => {
export const ComponentMetadataCard: FC<Props> = ({ loading, component }) => {
if (loading) {
return (
<InfoCard title="Metadata">
@@ -41,4 +39,3 @@ const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
</InfoCard>
);
};
export default ComponentMetadataCard;
@@ -13,18 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ComponentPage from './ComponentPage';
import { ComponentPage } from './ComponentPage';
import { render, wait } from '@testing-library/react';
import * as React from 'react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { catalogApiRef, CatalogApi } from '../../api/types';
const getTestProps = (componentName: string) => {
const getTestProps = (name: string) => {
return {
match: {
params: {
name: componentName,
optionalNamespaceAndName: name,
kind: 'Component',
},
},
history: {
@@ -46,7 +47,7 @@ describe('ComponentPage', () => {
[
catalogApiRef,
({
async getEntityByName() {},
async getEntity() {},
} as unknown) as CatalogApi,
],
])}
@@ -13,34 +13,34 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import ComponentMetadataCard from '../ComponentMetadataCard/ComponentMetadataCard';
import {
Content,
Header,
pageTheme,
Page,
useApi,
ErrorApi,
errorApiRef,
Header,
HeaderTabs,
Page,
pageTheme,
useApi,
} from '@backstage/core';
import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu';
import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Grid } from '@material-ui/core';
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { entityToComponent } from '../../data/utils';
import { Component } from '../../data/component';
import { entityToComponent } from '../../data/utils';
import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu';
import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard';
import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog';
const REDIRECT_DELAY = 1000;
type ComponentPageProps = {
match: {
params: {
name: string;
optionalNamespaceAndName: string;
kind: string;
};
};
history: {
@@ -48,17 +48,18 @@ type ComponentPageProps = {
};
};
const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [removingPending, setRemovingPending] = useState(false);
const showRemovalDialog = () => setConfirmationDialogOpen(true);
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
const componentName = match.params.name;
const { optionalNamespaceAndName, kind } = match.params;
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi<ErrorApi>(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: component, error, loading } = useAsync<Component>(async () => {
const entity = await catalogApi.getEntityByName(match.params.name);
const entity = await catalogApi.getEntity({ name, namespace, kind });
const location = await catalogApi.getLocationByEntity(entity);
return { ...entityToComponent(entity), location };
});
@@ -72,7 +73,7 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
}
}, [error, errorApi, history]);
if (componentName === '') {
if (name === '') {
history.push('/catalog');
return null;
}
@@ -149,4 +150,3 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
</Page>
);
};
export default ComponentPage;
@@ -51,7 +51,7 @@ function useColocatedEntities(component: Component): AsyncState<Entity[]> {
}, [catalogApi, component]);
}
const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
onConfirm,
onCancel,
onClose,
@@ -114,5 +114,3 @@ const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
</Dialog>
);
};
export default ComponentRemovalDialog;
+1
View File
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
export type Component = {
name: string;
namespace?: string;
kind: string;
metadata: EntityMeta;
description: ReactNode;
+1
View File
@@ -24,6 +24,7 @@ import { Component } from './component';
export function entityToComponent(envelope: Entity): Component {
return {
name: envelope.metadata.name,
namespace: envelope.metadata.namespace,
kind: envelope.kind,
metadata: envelope.metadata,
description: envelope.metadata.annotations?.description ?? 'placeholder',
+3 -3
View File
@@ -15,9 +15,9 @@
*/
import { createPlugin } from '@backstage/core';
import CatalogPage from './components/CatalogPage';
import ComponentPage from './components/ComponentPage/ComponentPage';
import { rootRoute, entityRoute } from './routes';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
import { ComponentPage } from './components/ComponentPage/ComponentPage';
import { entityRoute, rootRoute } from './routes';
export const plugin = createPlugin({
id: 'catalog',
+1 -1
View File
@@ -25,6 +25,6 @@ export const rootRoute = createRouteRef({
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/catalog/:name/',
path: '/catalog/:kind/:optionalNamespaceAndName/',
title: 'Entity',
});
@@ -28,7 +28,7 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
getEntities: jest.fn(),
getEntityByName: jest.fn(),
getEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
};
@@ -54,34 +54,39 @@ export const RegisterComponentResultDialog: FC<Props> = ({
The following components have been succefully created:
</DialogContentText>
<List>
{entities.map((entity: any, index: number) => (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
name: entity.metadata.name,
})}
>
{generatePath(entityRoute.path, {
name: entity.metadata.name,
})}
</Link>
),
}}
/>
</ListItem>
{index < entities.length - 1 && <Divider component="li" />}
</React.Fragment>
))}
{entities.map((entity: any, index: number) => {
const entityPath = generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
});
return (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link component={RouterLink} to={entityPath}>
{entityPath}
</Link>
),
}}
/>
</ListItem>
{index < entities.length - 1 && <Divider component="li" />}
</React.Fragment>
);
})}
</List>
</DialogContent>
<DialogActions>