diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 579b88053f..0e30c15254 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -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 }) => ( - + + {children} diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 94dd222c41..54202975c3 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -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", diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index b2c38ab506..11689aafff 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -17,3 +17,4 @@ export * from './errors'; export * from './logging'; export * from './middleware'; +export * from './service'; diff --git a/packages/backend-common/src/service/ServiceBuilderImpl.ts b/packages/backend-common/src/service/ServiceBuilderImpl.ts new file mode 100644 index 0000000000..ac35d52112 --- /dev/null +++ b/packages/backend-common/src/service/ServiceBuilderImpl.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/backend-common/src/service/createServiceBuilder.ts b/packages/backend-common/src/service/createServiceBuilder.ts new file mode 100644 index 0000000000..ffd8901def --- /dev/null +++ b/packages/backend-common/src/service/createServiceBuilder.ts @@ -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(); +} diff --git a/packages/backend-common/src/service/index.ts b/packages/backend-common/src/service/index.ts new file mode 100644 index 0000000000..9bba9d6baf --- /dev/null +++ b/packages/backend-common/src/service/index.ts @@ -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'; diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts new file mode 100644 index 0000000000..306f0d587e --- /dev/null +++ b/packages/backend-common/src/service/types.ts @@ -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; +}; diff --git a/packages/backend/package.json b/packages/backend/package.json index 65e2976489..4e2610380c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 5b1fc54330..0f0733a8ec 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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); }); } diff --git a/packages/core/src/components/Tabs/Tab.test.tsx b/packages/core/src/components/Tabs/Tab.test.tsx new file mode 100644 index 0000000000..5b155ef536 --- /dev/null +++ b/packages/core/src/components/Tabs/Tab.test.tsx @@ -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('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('test')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Tabs/Tab.tsx b/packages/core/src/components/Tabs/Tab.tsx new file mode 100644 index 0000000000..93243b2900 --- /dev/null +++ b/packages/core/src/components/Tabs/Tab.tsx @@ -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(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 ; +}; diff --git a/packages/core/src/components/Tabs/TabBar.tsx b/packages/core/src/components/Tabs/TabBar.tsx new file mode 100644 index 0000000000..379d60668b --- /dev/null +++ b/packages/core/src/components/Tabs/TabBar.tsx @@ -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(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 = props => { + const classes = useStyles(props); + return ( + }} + /> + ); +}; diff --git a/packages/core/src/components/Tabs/TabIcon.tsx b/packages/core/src/components/Tabs/TabIcon.tsx new file mode 100644 index 0000000000..ffb2e12cbd --- /dev/null +++ b/packages/core/src/components/Tabs/TabIcon.tsx @@ -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(() => ({ + 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 ( + + {props.children} + + ); +}; diff --git a/packages/core/src/components/Tabs/TabPanel.tsx b/packages/core/src/components/Tabs/TabPanel.tsx new file mode 100644 index 0000000000..ba8ca4bdee --- /dev/null +++ b/packages/core/src/components/Tabs/TabPanel.tsx @@ -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 = props => { + const { children, value, index, ...other } = props; + + return ( + + ); +}; diff --git a/packages/core/src/components/Tabs/Tabs.stories.tsx b/packages/core/src/components/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000000..930a825392 --- /dev/null +++ b/packages/core/src/components/Tabs/Tabs.stories.tsx @@ -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 = () => ( +
+ ({ + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); + +export const Expandable = () => ( +
+ ({ + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); + +export const Icons = () => ( +
+ ({ + icon: , + content:
Content {index}
, + }))} + /> +
+); + +export const IconsAndLabels = () => ( +
+ ({ + icon: , + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); diff --git a/packages/core/src/components/Tabs/Tabs.tsx b/packages/core/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000000..54369de473 --- /dev/null +++ b/packages/core/src/components/Tabs/Tabs.tsx @@ -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((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 = ({ 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([[]]); + const wrapper = useRef() as MutableRefObject; + + 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 ( +
+ +
+ + {navIndex !== 0 && ( + + + + )} + {chunkedTabs[navIndex].map((tab, index) => ( + + ))} + {hasNextNavIndex() && ( + + + + )} + +
+
+ {currentIndex !== false ? ( + chunkedTabs[navIndex].map((tab, index) => ( + + {tab.content} + + )) + ) : ( + // Render if the selected tab index is outside the current rendered chunked array + + {chunkedTabs[value[0]][value[1]].content} + + )} +
+ ); +}; diff --git a/plugins/catalog/src/components/CatalogPage/index.ts b/packages/core/src/components/Tabs/index.ts similarity index 93% rename from plugins/catalog/src/components/CatalogPage/index.ts rename to packages/core/src/components/Tabs/index.ts index 61182e316f..835ab5a8c3 100644 --- a/plugins/catalog/src/components/CatalogPage/index.ts +++ b/packages/core/src/components/Tabs/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './CatalogPage'; +export { Tabs as default } from './Tabs'; diff --git a/packages/core/src/components/Tabs/utils.ts b/packages/core/src/components/Tabs/utils.ts new file mode 100644 index 0000000000..3e0ab6f2c3 --- /dev/null +++ b/packages/core/src/components/Tabs/utils.ts @@ -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; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3c5c5c778..c5749866b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index ddd71cc189..0a875c9035 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -59,6 +59,7 @@ const useStyles = makeStyles(theme => { fontWeight: 'bold', whiteSpace: 'nowrap', lineHeight: 1.0, + flex: '3 1 auto', }, iconContainer: { boxSizing: 'border-box', @@ -84,6 +85,11 @@ const useStyles = makeStyles(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 = ({ ); } - return ( = ({ {text} )} - {children} +
{children}
); }; diff --git a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx b/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx deleted file mode 100644 index d0c3dc62c2..0000000000 --- a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx +++ /dev/null @@ -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 => { - 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 ( - - - - - } - secondary={} - /> - - - - - - - ); - } - - // TODO: Not functional yet to sign in from the sidebar - if (!user) { - return ( - - {icon} - - - - onSignIn()}> - - - - - - ); - } - - const { id, avatarUrl, avatarAlt } = user; - - return ( - - - - {avatarAlt && avatarAlt[0].toUpperCase()} - - - - {id} - - } - secondary={title} - /> - - - onSignOut()}> - - - - - - ); -}; - -const useGoogleLoginState = (open: boolean) => { - const googleAuth = useApi(googleAuthApiRef); - const [loading, setLoading] = useState(true); - const [profile, setProfile] = useState(); - - 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 = ({ - 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 ( - <> - - - - {imageUrl ? ( - - ) : ( - - {avatarFallback[0]} - - )} - - {!collapsedMode && ( - - {displayName} - - } - /> - )} - - - - - - - - - ); -}; diff --git a/packages/core/src/layout/Sidebar/UserBadge.tsx b/packages/core/src/layout/Sidebar/PinButton.tsx similarity index 61% rename from packages/core/src/layout/Sidebar/UserBadge.tsx rename to packages/core/src/layout/Sidebar/PinButton.tsx index d3e02d26cc..8c52eeb24d 100644 --- a/packages/core/src/layout/Sidebar/UserBadge.tsx +++ b/packages/core/src/layout/Sidebar/PinButton.tsx @@ -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(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(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(); - - 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 (
- {profile ? ( - <> - - - ) : ( - - )} {isOpen && (
); }; - -export default ComponentContextMenu; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx index 5b6e7dfe07..aaedf1bcba 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx @@ -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'; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx index 7059709992..d765b9cc0b 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx @@ -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 = ({ - loading, - component, -}) => { + +export const ComponentMetadataCard: FC = ({ loading, component }) => { if (loading) { return ( @@ -41,4 +39,3 @@ const ComponentMetadataCard: FC = ({ ); }; -export default ComponentMetadataCard; diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx index 252f2d4e3f..e0d5c99e4d 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx @@ -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, ], ])} diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx index f26c21b6ca..f487926183 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx @@ -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 = ({ match, history }) => { +export const ComponentPage: FC = ({ 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(errorApiRef); const catalogApi = useApi(catalogApiRef); const { value: component, error, loading } = useAsync(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 = ({ match, history }) => { } }, [error, errorApi, history]); - if (componentName === '') { + if (name === '') { history.push('/catalog'); return null; } @@ -149,4 +150,3 @@ const ComponentPage: FC = ({ match, history }) => { ); }; -export default ComponentPage; diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx index 1318edcca0..9ea4e64908 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx @@ -51,7 +51,7 @@ function useColocatedEntities(component: Component): AsyncState { }, [catalogApi, component]); } -const ComponentRemovalDialog: FC = ({ +export const ComponentRemovalDialog: FC = ({ onConfirm, onCancel, onClose, @@ -114,5 +114,3 @@ const ComponentRemovalDialog: FC = ({ ); }; - -export default ComponentRemovalDialog; diff --git a/plugins/catalog/src/data/component.ts b/plugins/catalog/src/data/component.ts index 86749c6faa..68be7588f0 100644 --- a/plugins/catalog/src/data/component.ts +++ b/plugins/catalog/src/data/component.ts @@ -18,6 +18,7 @@ import { ReactNode } from 'react'; export type Component = { name: string; + namespace?: string; kind: string; metadata: EntityMeta; description: ReactNode; diff --git a/plugins/catalog/src/data/utils.ts b/plugins/catalog/src/data/utils.ts index b731268c41..f84bfee73f 100644 --- a/plugins/catalog/src/data/utils.ts +++ b/plugins/catalog/src/data/utils.ts @@ -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', diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 1eee5efe07..6f9a580edd 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -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', diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index 6498d412b8..69c4e651b4 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -25,6 +25,6 @@ export const rootRoute = createRouteRef({ }); export const entityRoute = createRouteRef({ icon: NoIcon, - path: '/catalog/:name/', + path: '/catalog/:kind/:optionalNamespaceAndName/', title: 'Entity', }); diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index 48130124b7..4da68fe343 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -28,7 +28,7 @@ const catalogApi: jest.Mocked = { /* 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(), }; diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index 4d158526a0..68b06d1751 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -54,34 +54,39 @@ export const RegisterComponentResultDialog: FC = ({ The following components have been succefully created: - {entities.map((entity: any, index: number) => ( - - - - {generatePath(entityRoute.path, { - name: entity.metadata.name, - })} - - ), - }} - /> - - {index < entities.length - 1 && } - - ))} + {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 ( + + + + {entityPath} + + ), + }} + /> + + {index < entities.length - 1 && } + + ); + })}