diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index 8e20827280..ca2a66f6b9 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -12,18 +12,20 @@ We want to standardize on a few core entities that we are tracking in the Backst Backstage should eventually support the following core entities: -* **Components** are individual pieces of software -* **APIs** are the boundaries between different components -* **Resources** are physical or virtual infrastructure needed to operate a component +- **Components** are individual pieces of software +- **APIs** are the boundaries between different components +- **Resources** are physical or virtual infrastructure needed to operate a component ![Catalog Core Entities](catalog-core-entities.png) For now, we'll start by only implementing support for the Component entity in the Backstage catalog. This can later be extended to APIs, Resources and other potentially useful entities. ### Component + A component is a piece of software, for example a mobile application feature, web site, backend service or data pipeline (list not exhaustive). A component can be tracked in source control, or use some existing open source or commercial software. It can implement APIs for other components to consume. In turn it might depend on APIs implemented by other components, or resources that are attached to it at runtime. Component entities are typically defined in YAML descriptor files next to the code of the component, and could look like this (actual schema will evolve): + ```yaml apiVersion: backstage.io/v1beta1 kind: Component @@ -34,11 +36,13 @@ spec: ``` ### API + APIs form an abstraction that allows large software ecosystems to scale. Thus, APIs are a first class citizen in the Backstage model and the primary way to discover existing functionality in the ecosystem. APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (e.g. in Protobuf, GraphQL or similar), a data schema (e.g. in Avro, TFRecord or similar), or as code interfaces (e.g. framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top. APIs are typically indexed from existing definitions in source control and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve): + ```yaml apiVersion: backstage.io/v1beta1 kind: API @@ -59,9 +63,11 @@ spec: ``` ### Resource + Resources are the infrastructure your software needs to operate at runtime like Bigtable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and APIs will allow us to visualize and create tooling around them in Backstage. Resources are typically indexed from declarative definitions (e.g. Terraform, GCP Config Connector, AWS Cloud Formation) and/or inventories from cloud providers (e.g. GCP Asset Inventory) and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve): + ```yaml apiVersion: backstage.io/v1beta1 kind: Resource diff --git a/docs/headline.png b/docs/headline.png index b0f9cb8b5c..83d7b14f21 100644 Binary files a/docs/headline.png and b/docs/headline.png differ diff --git a/packages/app/package.json b/packages/app/package.json index ed2f547ad7..15681d0548 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -12,15 +12,16 @@ "@backstage/plugin-lighthouse": "^0.1.1-alpha.6", "@backstage/plugin-register-component": "^0.1.1-alpha.6", "@backstage/plugin-scaffolder": "^0.1.1-alpha.6", + "@backstage/plugin-sentry": "^0.1.1-alpha.6", "@backstage/plugin-tech-radar": "^0.1.1-alpha.6", "@backstage/plugin-welcome": "^0.1.1-alpha.6", "@backstage/theme": "^0.1.1-alpha.6", - "@backstage/plugin-sentry": "^0.1.1-alpha.6", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.12.0", + "react-hot-loader": "^4.12.21", "react-router-dom": "^5.2.0", "react-use": "^14.2.0", "zen-observable": "^0.8.15" @@ -73,10 +74,10 @@ } }, "/catalog/api": { - "target": "http://localhost:3003", + "target": "http://localhost:7000", "changeOrigin": true, "pathRewrite": { - "^/catalog/api/": "/" + "^/catalog/api/": "/catalog/" } } } diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index b4d01e067b..39ff0432ea 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -20,6 +20,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import Root from './components/Root'; import * as plugins from './plugins'; import apis from './apis'; +import { hot } from 'react-hot-loader/root'; const app = createApp({ apis, @@ -41,4 +42,4 @@ const App: FC<{}> = () => ( ); -export default App; +export default hot(App); diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index ffd8fdba22..2d6df2e34e 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,7 +1,8 @@ { "name": "@backstage/catalog-model", "version": "0.1.1-alpha.6", - "main": "dist/index.esm.js", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", "main:src": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -10,7 +11,7 @@ "access": "public" }, "scripts": { - "build": "backstage-cli plugin:build", + "build": "backstage-cli build", "lint": "backstage-cli lint", "test": "backstage-cli test", "prepack": "backstage-cli prepack", diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts new file mode 100644 index 0000000000..ab7a90249a --- /dev/null +++ b/packages/catalog-model/src/location/annotation.ts @@ -0,0 +1,16 @@ +/* + * 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 const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts new file mode 100644 index 0000000000..19e4b17352 --- /dev/null +++ b/packages/cli/src/commands/build.ts @@ -0,0 +1,37 @@ +/* + * 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 { buildPackage, Output } from '../lib/packager'; +import { Command } from 'commander'; + +export default async (cmd: Command) => { + let outputs = new Set(); + + const { outputs: outputsStr } = cmd as { outputs?: string }; + if (outputsStr) { + for (const output of outputsStr.split(',') as (keyof typeof Output)[]) { + if (output in Output) { + outputs.add(Output[output]); + } else { + throw new Error(`Unknown output format: ${output}`); + } + } + } else { + outputs = new Set([Output.types, Output.esm, Output.cjs]); + } + + await buildPackage({ outputs }); +}; diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index 561d79ed55..7e4e5cd36a 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -import { buildPackage } from '../../lib/packager'; +import { buildPackage, Output } from '../../lib/packager'; export default async () => { - await buildPackage(); + await buildPackage({ + outputs: new Set([Output.esm, Output.types]), + }); }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bdd0d3e1f2..8288f776e1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -78,6 +78,12 @@ const main = (argv: string[]) => { .description('Diff an existing plugin with the creation template') .action(actionHandler(() => require('./commands/plugin/diff'))); + program + .command('build') + .description('Build a package for publishing') + .option('--outputs ', 'List of formats to output [types,cjs,esm]') + .action(actionHandler(() => require('./commands/build'))); + program .command('lint') .option('--fix', 'Attempt to automatically fix violations') diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/packager/config.ts index df91f8067a..aaecd4a148 100644 --- a/packages/cli/src/lib/packager/config.ts +++ b/packages/cli/src/lib/packager/config.ts @@ -24,11 +24,14 @@ import esbuild from 'rollup-plugin-esbuild'; import imageFiles from 'rollup-plugin-image-files'; import dts from 'rollup-plugin-dts'; import json from '@rollup/plugin-json'; -import { RollupOptions } from 'rollup'; +import { RollupOptions, OutputOptions } from 'rollup'; +import { BuildOptions, Output } from './types'; import { paths } from '../paths'; -export const makeConfigs = async (): Promise => { +export const makeConfigs = async ( + options: BuildOptions, +): Promise => { const typesInput = paths.resolveTargetRoot( 'dist', relativePath(paths.targetRoot, paths.targetDir), @@ -43,13 +46,27 @@ export const makeConfigs = async (): Promise => { ); } - return [ - { - input: 'src/index.ts', - output: { + const configs = new Array(); + + if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) { + const output = new Array(); + + if (options.outputs.has(Output.cjs)) { + output.push({ + file: 'dist/index.cjs.js', + format: 'commonjs', + }); + } + if (options.outputs.has(Output.esm)) { + output.push({ file: 'dist/index.esm.js', format: 'module', - }, + }); + } + + configs.push({ + input: 'src/index.ts', + output, plugins: [ peerDepsExternal({ includeDependencies: true, @@ -68,14 +85,19 @@ export const makeConfigs = async (): Promise => { target: 'es2019', }), ], - }, - { + }); + } + + if (options.outputs.has(Output.types)) { + configs.push({ input: typesInput, output: { file: 'dist/index.d.ts', format: 'es', }, plugins: [dts()], - }, - ]; + }); + } + + return configs; }; diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index e639599af9..17aae25ec4 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -15,3 +15,5 @@ */ export { buildPackage } from './packager'; +export { Output } from './types'; +export type { BuildOptions } from './types'; diff --git a/packages/cli/src/lib/packager/packager.ts b/packages/cli/src/lib/packager/packager.ts index d3e88d8c3d..0c41d0f767 100644 --- a/packages/cli/src/lib/packager/packager.ts +++ b/packages/cli/src/lib/packager/packager.ts @@ -19,6 +19,7 @@ import chalk from 'chalk'; import { relative as relativePath } from 'path'; import { paths } from '../paths'; import { makeConfigs } from './config'; +import { BuildOptions } from './types'; function formatErrorMessage(error: any) { let msg = ''; @@ -80,7 +81,7 @@ async function build(config: RollupOptions) { } } -export const buildPackage = async () => { - const configs = await makeConfigs(); +export const buildPackage = async (options: BuildOptions) => { + const configs = await makeConfigs(options); await Promise.all(configs.map(build)); }; diff --git a/packages/cli/src/lib/packager/types.ts b/packages/cli/src/lib/packager/types.ts new file mode 100644 index 0000000000..853789df59 --- /dev/null +++ b/packages/cli/src/lib/packager/types.ts @@ -0,0 +1,25 @@ +/* + * 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 enum Output { + esm, + cjs, + types, +} + +export type BuildOptions = { + outputs: Set; +}; diff --git a/packages/core-api/package.json b/packages/core-api/package.json index a8c2b939ac..8139f2c54a 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -20,7 +20,7 @@ "main:src": "src/index.ts", "types": "src/index.ts", "scripts": { - "build": "backstage-cli plugin:build", + "build": "backstage-cli build --outputs types,esm", "lint": "backstage-cli lint", "test": "backstage-cli test", "prepack": "backstage-cli prepack", diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index c066c83b73..4cbc8bcfb1 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -143,6 +143,30 @@ export type OpenIdConnectApi = { logout(): Promise; }; +export type ProfileInfoOptions = { + /** + * If this is set to true, the user will not be prompted to log in, + * and an empty profile will be returned if there is no existing session. + * + * This can be used to perform a check whether the user is logged in, or if you don't + * want to force a user to be logged in, but provide functionality if they already are. + * + * @default false + */ + optional?: boolean; +}; + +export type ProfileInfoApi = { + getProfile(options?: ProfileInfoOptions): Promise; +}; + +export type ProfileInfo = { + provider: string; + email: string; + name?: string; + picture?: string; +}; + /** * Provides authentication towards Google APIs and identities. * @@ -151,7 +175,9 @@ export type OpenIdConnectApi = { * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, * email and expiration information. Do not rely on any other fields, as they might not be present. */ -export const googleAuthApiRef = createApiRef({ +export const googleAuthApiRef = createApiRef< + OAuthApi & OpenIdConnectApi & ProfileInfoApi +>({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', }); diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index 582afbcfea..d65a2c71ee 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -22,6 +22,9 @@ import { OpenIdConnectApi, IdTokenOptions, AccessTokenOptions, + ProfileInfoApi, + ProfileInfoOptions, + ProfileInfo, } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; @@ -39,6 +42,7 @@ type CreateOptions = { }; export type GoogleAuthResponse = { + profile: ProfileInfo; accessToken: string; idToken: string; scope: string; @@ -53,7 +57,7 @@ const DEFAULT_PROVIDER = { const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; -class GoogleAuth implements OAuthApi, OpenIdConnectApi { +class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { static create({ apiOrigin, basePath, @@ -69,6 +73,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { oauthRequestApi: oauthRequestApi, sessionTransform(res: GoogleAuthResponse): GoogleSession { return { + profile: res.profile, idToken: res.idToken, accessToken: res.accessToken, scopes: GoogleAuth.normalizeScopes(res.scope), @@ -123,6 +128,14 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { await this.sessionManager.removeSession(); } + async getProfile(options: ProfileInfoOptions = {}) { + const session = await this.sessionManager.getSession(options); + if (!session) { + return undefined; + } + return session.profile; + } + static normalizeScopes(scopes?: string | string[]): Set { if (!scopes) { return new Set(); diff --git a/packages/core-api/src/apis/implementations/auth/google/types.ts b/packages/core-api/src/apis/implementations/auth/google/types.ts index 96c69c5d4f..ea251c7006 100644 --- a/packages/core-api/src/apis/implementations/auth/google/types.ts +++ b/packages/core-api/src/apis/implementations/auth/google/types.ts @@ -14,7 +14,10 @@ * limitations under the License. */ +import { ProfileInfo } from '../../../definitions'; + export type GoogleSession = { + profile: ProfileInfo; idToken: string; accessToken: string; scopes: Set; diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 64df3deca4..3df21af3f3 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -117,6 +117,10 @@ export class RefreshingAuthSessionManager implements SessionManager { window.location.reload(); // TODO(Rugvip): make this work without reload? } + async getCurrentSession() { + return this.currentSession; + } + private async collapsedSessionRefresh(): Promise { if (this.refreshPromise) { return this.refreshPromise; diff --git a/packages/core/package.json b/packages/core/package.json index 05986a6b1e..818634238b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,7 @@ "main:src": "src/index.ts", "types": "src/index.ts", "scripts": { - "build": "backstage-cli plugin:build", + "build": "backstage-cli build --outputs types,esm", "lint": "backstage-cli lint", "test": "backstage-cli test", "prepack": "backstage-cli prepack", diff --git a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx b/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx index f9bd4ed2ab..d0c3dc62c2 100644 --- a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx +++ b/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx @@ -14,48 +14,285 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { FC, useState, useEffect } from 'react'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { sidebarConfig } from './config'; -import { Avatar, Typography } from '@material-ui/core'; +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(() => { +const useStyles = makeStyles(theme => { const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig; return { root: { width: drawerWidthOpen, display: 'flex', alignItems: 'center', - color: '#b5b5b5', 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 = { - imageUrl: string; - name: string; - hideName?: boolean; + email: string; + imageUrl?: string; + name?: string; + collapsedMode?: boolean; }; export const LoggedUserBadge: FC = ({ imageUrl, name, - hideName = false, + 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 ( -
- - {!hideName && {name}} -
+ <> + + + + {imageUrl ? ( + + ) : ( + + {avatarFallback[0]} + + )} + + {!collapsedMode && ( + + {displayName} + + } + /> + )} + + + + + + + + ); }; diff --git a/packages/core/src/layout/Sidebar/UserBadge.tsx b/packages/core/src/layout/Sidebar/UserBadge.tsx index b030ed2996..d3e02d26cc 100644 --- a/packages/core/src/layout/Sidebar/UserBadge.tsx +++ b/packages/core/src/layout/Sidebar/UserBadge.tsx @@ -14,15 +14,16 @@ * limitations under the License. */ -import React, { FC, useContext } from 'react'; +import React, { FC, useContext, useEffect, useState } from 'react'; import { makeStyles } from '@material-ui/core'; -import People from '@material-ui/icons/People'; +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 { 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 => { @@ -58,18 +59,30 @@ export const SidebarUserBadge: FC<{}> = () => { 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]); - const isUserLoggedIn = false; return (
- {isUserLoggedIn ? ( - + {profile ? ( + <> + + ) : ( - + )} {isOpen && ( All your components @@ -142,7 +149,7 @@ const CatalogPage: FC<{}> = () => { (value && value.map(val => { return { - ...envelopeToComponent(val), + ...entityToComponent(val), location: findLocationForEntity(val, locations), }; })) || diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 015916c3f4..7ff521ee67 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -17,6 +17,8 @@ import React, { FC } from 'react'; import { Component } from '../../data/component'; import { InfoCard, Progress, Table, TableColumn } from '@backstage/core'; import { Typography, Link } from '@material-ui/core'; +import { Link as RouterLink, generatePath } from 'react-router-dom'; +import { entityRoute } from '../../routes'; const columns: TableColumn[] = [ { @@ -24,7 +26,12 @@ const columns: TableColumn[] = [ field: 'name', highlight: true, render: (componentData: any) => ( - {componentData.name} + + {componentData.name} + ), }, { diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx index 346d1fe97c..3762cc8345 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx @@ -30,7 +30,7 @@ import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDi import { SentryIssuesWidget } from '@backstage/plugin-sentry'; import { Grid } from '@material-ui/core'; import { catalogApiRef } from '../..'; -import { envelopeToComponent as entityToComponent } from '../../data/utils'; +import { entityToComponent } from '../../data/utils'; import { Component } from '../../data/component'; const REDIRECT_DELAY = 1000; diff --git a/plugins/catalog/src/data/component.ts b/plugins/catalog/src/data/component.ts index 59e20d7d61..81299762a4 100644 --- a/plugins/catalog/src/data/component.ts +++ b/plugins/catalog/src/data/component.ts @@ -14,11 +14,12 @@ * limitations under the License. */ +import React from 'react'; import { Location } from '@backstage/catalog-model'; export type Component = { name: string; kind: string; - description: string; + description: React.ReactNode; location?: Location; }; diff --git a/plugins/catalog/src/data/utils.ts b/plugins/catalog/src/data/utils.ts deleted file mode 100644 index 8b48f69e4f..0000000000 --- a/plugins/catalog/src/data/utils.ts +++ /dev/null @@ -1,36 +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 { Component } from './component'; -import { - Entity, - Location, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; - -export const envelopeToComponent = (envelope: Entity): Component => { - return { - name: envelope.metadata?.name ?? '', - kind: envelope.kind ?? 'unknown', - description: envelope.metadata?.annotations?.description ?? 'placeholder', - }; -}; -export const findLocationForEntity = ( - entity: Entity, - locations: Location[], -): Location | undefined => { - const entityLocationId = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - return locations.find(location => location.id === entityLocationId); -}; diff --git a/plugins/catalog/src/data/utils.tsx b/plugins/catalog/src/data/utils.tsx new file mode 100644 index 0000000000..d515405bfc --- /dev/null +++ b/plugins/catalog/src/data/utils.tsx @@ -0,0 +1,64 @@ +/* + * 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 { Component } from './component'; +import { Entity, Location } from '@backstage/catalog-model'; +import Edit from '@material-ui/icons/Edit'; +import IconButton from '@material-ui/core/IconButton'; +import { styled } from '@material-ui/core/styles'; +import { LOCATION_ANNOTATION } from '../../../../packages/catalog-model/src/location/annotation'; + +const DescriptionWrapper = styled('span')({ + display: 'flex', + alignItems: 'center', +}); + +const createEditLink = (url: string): string => url.replace('blob', 'edit'); + +export function entityToComponent( + envelope: Entity, + location?: Location, +): Component { + return { + name: envelope.metadata?.name ?? '', + kind: envelope.kind ?? 'unknown', + description: ( + + {envelope.metadata?.annotations?.description ?? 'placeholder'} + {location?.target ? ( + + + + + + ) : null} + + ), + location, + }; +} + +export function findLocationForEntity( + entity: Entity, + locations: Location[], +): Location | undefined { + for (const loc of locations) { + if (loc.id === entity.metadata.annotations?.[LOCATION_ANNOTATION]) { + return loc; + } + } + return undefined; +} diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index f495bd3146..2b986b59ea 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -18,3 +18,4 @@ export { plugin } from './plugin'; export * from './api/CatalogClient'; export * from './api/types'; export * from './types'; +export * from './routes'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 97e470a065..1eee5efe07 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -17,11 +17,12 @@ import { createPlugin } from '@backstage/core'; import CatalogPage from './components/CatalogPage'; import ComponentPage from './components/ComponentPage/ComponentPage'; +import { rootRoute, entityRoute } from './routes'; export const plugin = createPlugin({ id: 'catalog', register({ router }) { - router.registerRoute('/', CatalogPage); - router.registerRoute('/catalog/:name/', ComponentPage); + router.addRoute(rootRoute, CatalogPage); + router.addRoute(entityRoute, ComponentPage); }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts new file mode 100644 index 0000000000..6498d412b8 --- /dev/null +++ b/plugins/catalog/src/routes.ts @@ -0,0 +1,30 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +const NoIcon = () => null; + +export const rootRoute = createRouteRef({ + icon: NoIcon, + path: '/', + title: 'Catalog', +}); +export const entityRoute = createRouteRef({ + icon: NoIcon, + path: '/catalog/:name/', + title: 'Entity', +}); diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 053af6bb51..83be6fb7db 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -18,6 +18,8 @@ }, "dependencies": { "@backstage/core": "^0.1.1-alpha.6", + "@backstage/catalog-model": "^0.1.1-alpha.6", + "@backstage/plugin-catalog": "^0.1.1-alpha.6", "@backstage/theme": "^0.1.1-alpha.6", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", @@ -25,6 +27,8 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^5.7.2", + "react-router": "^5.2.0", + "react-router-dom": "^5.2.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx index 9203093d48..e8b0c3b76e 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx @@ -15,17 +15,29 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import RegisterComponentForm from './RegisterComponentForm'; +import { render, fireEvent, cleanup } from '@testing-library/react'; +import RegisterComponentForm, { Props } from './RegisterComponentForm'; +import { act } from 'react-dom/test-utils'; +const setup = (props?: Partial) => { + return { + rendered: render( + , + ), + }; +}; describe('RegisterComponentForm', () => { + afterEach(() => cleanup()); + it('should initially render a disabled button', async () => { - const rendered = render( - , - ); + const { rendered } = setup(); expect( await rendered.findByText( - 'Enter the full path to the service-info.yaml file in GHE to start tracking your component. It must be in a public repo, on the master branch.', + 'Enter the full path to the service-info.yaml file in GitHub to start tracking your component. It must be in a public repo.', ), ).toBeInTheDocument(); @@ -34,27 +46,21 @@ describe('RegisterComponentForm', () => { }); it('should enable a submit form when data when component url is set ', async () => { - const rendered = render( - , - ); + const { rendered } = setup(); const input = (await rendered.getByRole('textbox')) as HTMLInputElement; - fireEvent.change(input, { - target: { value: 'https://example.com/blob/master/service.yaml' }, + await act(async () => { + // react-hook-form uses `input` event for changes + fireEvent.input(input, { + target: { value: 'https://example.com/blob/master/service.yaml' }, + }); }); - const submit = (await rendered.findByText('Submit')) as HTMLButtonElement; + const submit = (await rendered.getByRole('button')) as HTMLButtonElement; expect(submit.disabled).toBeFalsy(); }); - - it('should hide input on submission ', async () => { - const rendered = render( - , - ); - - expect( - await rendered.findByText( - 'Your component is being registered. Please wait.', - ), - ).toBeInTheDocument(); - }); +}); + +it('should show spinner while submitting', async () => { + const { rendered } = setup({ submitting: true }); + expect(rendered.getByTestId('loading-progress')).toBeInTheDocument(); }); diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index d279afac25..d9fb88e44b 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -20,12 +20,11 @@ import { FormControl, FormHelperText, TextField, - Typography, + LinearProgress, } from '@material-ui/core'; import { useForm } from 'react-hook-form'; import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; -import { Progress } from '@backstage/core'; import { ComponentIdValidators } from '../../util/validate'; const useStyles = makeStyles(theme => ({ @@ -39,57 +38,49 @@ const useStyles = makeStyles(theme => ({ }, })); -type RegisterComponentProps = { - onSubmit: () => any; +export type Props = { + onSubmit: (formData: Record) => Promise; submitting: boolean; }; -const RegisterComponentForm: FC = ({ - onSubmit, - submitting, -}) => { +const RegisterComponentForm: FC = ({ onSubmit, submitting }) => { const { register, handleSubmit, errors, formState } = useForm({ mode: 'onChange', }); const classes = useStyles(); - const hasErrors = !!errors.componentIdInput; + const hasErrors = !!errors.componentLocation; const dirty = formState?.dirty; - if (submitting) { - return ( - <> - - Your component is being registered. Please wait. - - - - ); - } - return ( + + return submitting ? ( + + ) : (
- {errors.componentIdInput && ( + {errors.componentLocation && ( - {errors.componentIdInput.message} + {errors.componentLocation.message} )} diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index ac2645135e..ad47c3a41b 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -15,20 +15,46 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; +import { render, cleanup } from '@testing-library/react'; import RegisterComponentPage from './RegisterComponentPage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; +import { errorApiRef, ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { MemoryRouter } from 'react-router-dom'; +const errorApi = { post: () => {} }; +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(), + getLocationByEntity: jest.fn(), +}; + +const setup = () => ({ + rendered: render( + + + + + + + , + ), +}); describe('RegisterComponentPage', () => { + afterEach(() => cleanup()); + it('should render', () => { - mockFetch.mockResponse(() => new Promise(() => {})); - const rendered = render( - - - , - ); - expect(rendered.getByText('Register Component')).toBeInTheDocument(); + const { rendered } = setup(); + expect( + rendered.getByText('Register existing component'), + ).toBeInTheDocument(); }); }); diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index 114c37fe4c..27654d2080 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -14,50 +14,107 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; -import { Grid } from '@material-ui/core'; +import React, { FC, useState } from 'react'; +import { Grid, makeStyles } from '@material-ui/core'; import { InfoCard, Page, pageTheme, Content, - ContentHeader, - SupportButton, + useApi, + errorApiRef, + Header, } from '@backstage/core'; import RegisterComponentForm from '../RegisterComponentForm'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { useMountedState } from 'react-use'; +import { Entity, Location } from '@backstage/catalog-model'; +import { RegisterComponentResultDialog } from '../RegisterComponentResultDialog'; +const useStyles = makeStyles(theme => ({ + dialogPaper: { + minHeight: 250, + minWidth: 600, + }, + icon: { + width: 20, + marginRight: theme.spacing(1), + }, + contentText: { + paddingBottom: theme.spacing(2), + }, +})); + +const FormStates = { + Idle: 'idle', + Success: 'success', + Submitting: 'submitting', +} as const; + +type ValuesOf = T extends Record ? V : never; const RegisterComponentPage: FC<{}> = () => { - const [isSubmitting, setIsSubmitting] = useState(false); + const classes = useStyles(); + const catalogApi = useApi(catalogApiRef); + const [formState, setFormState] = useState>( + FormStates.Idle, + ); + const isMounted = useMountedState(); - useEffect(() => { - if (isSubmitting) { - setTimeout(() => { - setIsSubmitting(false); - }, 4000); + const errorApi = useApi(errorApiRef); + + const [result, setResult] = useState<{ + data: { + entities: Entity[]; + location: Location; + } | null; + error: null | Error; + }>({ + data: null, + error: null, + }); + + const handleSubmit = async (formData: Record) => { + setFormState(FormStates.Submitting); + const { componentLocation: target } = formData; + try { + const data = await catalogApi.addLocation('github', target); + + if (!isMounted()) return; + + setResult({ error: null, data }); + setFormState(FormStates.Success); + } catch (e) { + errorApi.post(e); + + if (!isMounted()) return; + + setResult({ error: e, data: null }); + setFormState(FormStates.Idle); } - }, [isSubmitting]); - - const onSubmit = () => { - setIsSubmitting(true); }; return ( +
- - Documentation - + {formState === FormStates.Success && ( + setFormState(FormStates.Idle)} + classes={{ paper: classes.dialogPaper }} + /> + )} ); }; diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.test.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.test.tsx new file mode 100644 index 0000000000..d918e72489 --- /dev/null +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.test.tsx @@ -0,0 +1,82 @@ +/* + * 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, { ComponentProps } from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { RegisterComponentResultDialog } from './RegisterComponentResultDialog'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import { MemoryRouter } from 'react-router-dom'; +import { Entity } from '@backstage/catalog-model'; + +const setup = ( + props?: Partial>, +) => ({ + rendered: render( + + + {}} + entities={[]} + {...props} + /> + + , + ), +}); +describe('RegisterComponentResultDialog', () => { + afterEach(() => cleanup()); + + it('should render', () => { + const { rendered } = setup(); + expect( + rendered.getByText('Component Registration Result'), + ).toBeInTheDocument(); + }); +}); + +it('should show a list of components if success', async () => { + const { rendered } = setup({ + entities: [ + { + kind: 'Component', + metadata: { + name: 'Component1', + }, + spec: { + type: 'website', + }, + }, + { + kind: 'Component', + metadata: { + name: 'Component2', + }, + spec: { + type: 'service', + }, + }, + ] as Entity[], + }); + + expect( + rendered.getByText( + 'The following components have been succefully created:', + ), + ).toBeInTheDocument(); + expect(rendered.getByText('Component1')).toBeInTheDocument(); + expect(rendered.getByText('Component2')).toBeInTheDocument(); +}); diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx new file mode 100644 index 0000000000..4d158526a0 --- /dev/null +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -0,0 +1,93 @@ +/* + * 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 { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + List, + ListItem, + Link, + Divider, + DialogActions, + Button, +} from '@material-ui/core'; +import { Entity } from '@backstage/catalog-model'; +import { StructuredMetadataTable } from '@backstage/core'; +import { generatePath } from 'react-router'; +import { + entityRoute, + rootRoute as catalogRootRoute, +} from '@backstage/plugin-catalog'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = { + onClose: () => void; + classes?: Record; + entities: Entity[]; +}; + +export const RegisterComponentResultDialog: FC = ({ + onClose, + classes, + entities, +}) => ( + + Component Registration Result + + + The following components have been succefully created: + + + {entities.map((entity: any, index: number) => ( + + + + {generatePath(entityRoute.path, { + name: entity.metadata.name, + })} + + ), + }} + /> + + {index < entities.length - 1 && } + + ))} + + + + + + +); diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/index.ts b/plugins/register-component/src/components/RegisterComponentResultDialog/index.ts new file mode 100644 index 0000000000..7ed25f3c20 --- /dev/null +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { RegisterComponentResultDialog } from './RegisterComponentResultDialog'; diff --git a/plugins/register-component/src/index.ts b/plugins/register-component/src/index.ts index 3a0a0fe2d3..5b20cb0158 100644 --- a/plugins/register-component/src/index.ts +++ b/plugins/register-component/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { plugin, rootRoute } from './plugin'; diff --git a/plugins/register-component/src/plugin.ts b/plugins/register-component/src/plugin.ts index f32e9dc84f..9c73688a70 100644 --- a/plugins/register-component/src/plugin.ts +++ b/plugins/register-component/src/plugin.ts @@ -14,12 +14,18 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import RegisterComponentPage from './components/RegisterComponentPage'; +export const rootRoute = createRouteRef({ + icon: () => null, + path: '/register-component', + title: 'Register component', +}); + export const plugin = createPlugin({ id: 'register-component', register({ router }) { - router.registerRoute('/register-component', RegisterComponentPage); + router.addRoute(rootRoute, RegisterComponentPage); }, }); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 7f453fde48..ad44e06986 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -24,6 +24,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router-dom": "^5.2.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index e6fed50cef..5d976a20b2 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -24,7 +24,8 @@ import { Page, pageTheme, } from '@backstage/core'; -import { Typography, Link } from '@material-ui/core'; +import { Typography, Link, Button } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; // TODO(blam): Connect to backend const STATIC_DATA = [ @@ -49,7 +50,16 @@ const ScaffolderPage: React.FC<{}> = () => { subtitle="Create new software components using standard templates" /> - + + + NOTE! This feature is WIP. You can follow progress{' '} diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 3a0a0fe2d3..5b20cb0158 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { plugin, rootRoute } from './plugin'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 31bdab2f84..b2cb305e57 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -14,12 +14,18 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import ScaffolderPage from './components/ScaffolderPage'; +export const rootRoute = createRouteRef({ + icon: () => null, + path: '/create', + title: 'Create entity', +}); + export const plugin = createPlugin({ id: 'scaffolder', register({ router }) { - router.registerRoute('/create', ScaffolderPage); + router.addRoute(rootRoute, ScaffolderPage); }, }); diff --git a/yarn.lock b/yarn.lock index b3f5b701c0..008fc8b2fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3739,6 +3739,11 @@ resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== +"@types/jwt-decode@2.2.1": + version "2.2.1" + resolved "https://registry.npmjs.org/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2" + integrity sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A== + "@types/lodash@^4.14.151": version "4.14.155" resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" @@ -11954,6 +11959,11 @@ jsx-ast-utils@^2.2.1, jsx-ast-utils@^2.2.3: array-includes "^3.0.3" object.assign "^4.1.0" +jwt-decode@2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" + integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk= + keyv@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"