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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
-32
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,9 @@ type PaletteAdditions = {
|
||||
link: string;
|
||||
gold: string;
|
||||
sidebar: string;
|
||||
tabbar: {
|
||||
indicator: string;
|
||||
};
|
||||
bursts: {
|
||||
fontColor: string;
|
||||
slackChannelText: string;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
|
||||
|
||||
export type Component = {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
kind: string;
|
||||
metadata: EntityMeta;
|
||||
description: ReactNode;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -25,6 +25,6 @@ export const rootRoute = createRouteRef({
|
||||
});
|
||||
export const entityRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/catalog/:name/',
|
||||
path: '/catalog/:kind/:optionalNamespaceAndName/',
|
||||
title: 'Entity',
|
||||
});
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
};
|
||||
|
||||
+33
-28
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user