Merge remote-tracking branch 'origin' into ndudnik/unregister-component
This commit is contained in:
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 673 KiB After Width: | Height: | Size: 691 KiB |
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<{}> = () => (
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
export default hot(App);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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<Output>();
|
||||
|
||||
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 });
|
||||
};
|
||||
@@ -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]),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 <formats>', 'List of formats to output [types,cjs,esm]')
|
||||
.action(actionHandler(() => require('./commands/build')));
|
||||
|
||||
program
|
||||
.command('lint')
|
||||
.option('--fix', 'Attempt to automatically fix violations')
|
||||
|
||||
@@ -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<RollupOptions[]> => {
|
||||
export const makeConfigs = async (
|
||||
options: BuildOptions,
|
||||
): Promise<RollupOptions[]> => {
|
||||
const typesInput = paths.resolveTargetRoot(
|
||||
'dist',
|
||||
relativePath(paths.targetRoot, paths.targetDir),
|
||||
@@ -43,13 +46,27 @@ export const makeConfigs = async (): Promise<RollupOptions[]> => {
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
const configs = new Array<RollupOptions>();
|
||||
|
||||
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
|
||||
const output = new Array<OutputOptions>();
|
||||
|
||||
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<RollupOptions[]> => {
|
||||
target: 'es2019',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
});
|
||||
}
|
||||
|
||||
if (options.outputs.has(Output.types)) {
|
||||
configs.push({
|
||||
input: typesInput,
|
||||
output: {
|
||||
file: 'dist/index.d.ts',
|
||||
format: 'es',
|
||||
},
|
||||
plugins: [dts()],
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
@@ -15,3 +15,5 @@
|
||||
*/
|
||||
|
||||
export { buildPackage } from './packager';
|
||||
export { Output } from './types';
|
||||
export type { BuildOptions } from './types';
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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<Output>;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -143,6 +143,30 @@ export type OpenIdConnectApi = {
|
||||
logout(): Promise<void>;
|
||||
};
|
||||
|
||||
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<ProfileInfo | undefined>;
|
||||
};
|
||||
|
||||
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<OAuthApi & OpenIdConnectApi>({
|
||||
export const googleAuthApiRef = createApiRef<
|
||||
OAuthApi & OpenIdConnectApi & ProfileInfoApi
|
||||
>({
|
||||
id: 'core.auth.google',
|
||||
description: 'Provides authentication towards Google APIs and identities',
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
if (!scopes) {
|
||||
return new Set();
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ProfileInfo } from '../../../definitions';
|
||||
|
||||
export type GoogleSession = {
|
||||
profile: ProfileInfo;
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
|
||||
@@ -117,6 +117,10 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
|
||||
window.location.reload(); // TODO(Rugvip): make this work without reload?
|
||||
}
|
||||
|
||||
async getCurrentSession() {
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
private async collapsedSessionRefresh(): Promise<T> {
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Theme>(() => {
|
||||
const useStyles = makeStyles<Theme>(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 (
|
||||
<ListItem {...props}>
|
||||
<ListItemIcon style={{ marginRight: 0 }}>
|
||||
<Skeleton variant="circle" width={40} height={40} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Skeleton component="span" width={120} />}
|
||||
secondary={<Skeleton component="span" width={60} />}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton>
|
||||
<Skeleton variant="circle" width={24} height={24} />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Not functional yet to sign in from the sidebar
|
||||
if (!user) {
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemIcon style={{ marginRight: 0 }}>{icon}</ListItemIcon>
|
||||
<ListItemText primary="Sign In" secondary={title} />
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip
|
||||
title={`Sign in with ${title}`}
|
||||
placement="bottom-end"
|
||||
PopperProps={{ style: { width: 120 } }}
|
||||
>
|
||||
<IconButton onClick={() => onSignIn()}>
|
||||
<ControlPointIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const { id, avatarUrl, avatarAlt } = user;
|
||||
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={avatarUrl} alt={avatarAlt}>
|
||||
{avatarAlt && avatarAlt[0].toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
className={classes.listItemText}
|
||||
primary={
|
||||
<Typography className={classes.listItemText} variant="body2">
|
||||
{id}
|
||||
</Typography>
|
||||
}
|
||||
secondary={title}
|
||||
/>
|
||||
<ListItemSecondaryAction style={{ marginLeft: '30px' }}>
|
||||
<Tooltip
|
||||
title={`Sign out from ${title}`}
|
||||
placement="bottom-end"
|
||||
PopperProps={{ style: { width: 120 } }}
|
||||
>
|
||||
<IconButton onClick={() => onSignOut()}>
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const useGoogleLoginState = (open: boolean) => {
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
let didCancel = false;
|
||||
|
||||
if (open) {
|
||||
googleAuth.getProfile().then(_profile => {
|
||||
if (!didCancel) {
|
||||
setProfile(_profile);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, [open, googleAuth]);
|
||||
|
||||
if (loading) {
|
||||
return { loading: true };
|
||||
}
|
||||
return { loading: false, isLoggedIn: !!profile, profile };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
hideName?: boolean;
|
||||
email: string;
|
||||
imageUrl?: string;
|
||||
name?: string;
|
||||
collapsedMode?: boolean;
|
||||
};
|
||||
|
||||
export const LoggedUserBadge: FC<Props> = ({
|
||||
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 (
|
||||
<div className={classes.root}>
|
||||
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
{!hideName && <Typography variant="subtitle2">{name}</Typography>}
|
||||
</div>
|
||||
<>
|
||||
<List dense>
|
||||
<ListItem className={classes.root} onClick={handleOpen}>
|
||||
<ListItemAvatar>
|
||||
{imageUrl ? (
|
||||
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
) : (
|
||||
<Avatar
|
||||
alt={name}
|
||||
className={`${classes.avatar} ${classes.purple}`}
|
||||
>
|
||||
{avatarFallback[0]}
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
{!collapsedMode && (
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography className={classes.listItemText} variant="body2">
|
||||
{displayName}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
</List>
|
||||
<Popover
|
||||
transitionDuration={0}
|
||||
open={state.open}
|
||||
anchorEl={state.anchorEl}
|
||||
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
|
||||
transformOrigin={{ horizontal: 'center', vertical: 'bottom' }}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<List dense>
|
||||
<SessionListItem
|
||||
classes={classes}
|
||||
loading={googleLogin.loading}
|
||||
title="Google"
|
||||
icon={AccountCircleIcon}
|
||||
user={
|
||||
googleLogin.isLoggedIn && {
|
||||
id: googleLogin.profile?.email,
|
||||
avatarUrl: googleLogin.profile?.picture ?? '',
|
||||
avatarAlt:
|
||||
googleLogin.profile?.picture ?? googleLogin.profile?.email,
|
||||
}
|
||||
}
|
||||
onSignIn={handleGoogleSignIn}
|
||||
onSignOut={handleGoogleSignOut}
|
||||
/>
|
||||
</List>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,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<BackstageTheme, { isPinned: boolean }>(theme => {
|
||||
@@ -58,18 +59,30 @@ export const SidebarUserBadge: FC<{}> = () => {
|
||||
SidebarPinStateContext,
|
||||
);
|
||||
const classes = useStyles({ isPinned });
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
// TODO(soapraj): How to observe if the user is logged in
|
||||
// TODO(soapraj): List all the providers supported by the app and let user log in from here
|
||||
googleAuth.getProfile({ optional: true }).then(googleProfile => {
|
||||
setProfile(googleProfile);
|
||||
});
|
||||
}, [googleAuth]);
|
||||
|
||||
const isUserLoggedIn = false;
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{isUserLoggedIn ? (
|
||||
<LoggedUserBadge
|
||||
imageUrl="https://via.placeholder.com/200/200"
|
||||
name="Victor Viale"
|
||||
hideName={!isOpen}
|
||||
/>
|
||||
{profile ? (
|
||||
<>
|
||||
<LoggedUserBadge
|
||||
email={profile.email}
|
||||
imageUrl={profile.picture}
|
||||
name={profile.name}
|
||||
collapsedMode={!isOpen}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SidebarItem icon={People} text="Log in" to="/login" disableSelected />
|
||||
<SidebarItem icon={AccountCircleIcon} text="" disableSelected />
|
||||
)}
|
||||
{isOpen && (
|
||||
<button
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,9 +2,13 @@ import {
|
||||
ApiRegistry,
|
||||
alertApiRef,
|
||||
errorApiRef,
|
||||
oauthRequestApiRef,
|
||||
OAuthRequestManager,
|
||||
googleAuthApiRef,
|
||||
AlertApiForwarder,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
GoogleAuth,
|
||||
} from '@backstage/core';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
@@ -13,4 +17,18 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
|
||||
|
||||
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
|
||||
|
||||
const oauthRequestApi = builder.add(
|
||||
oauthRequestApiRef,
|
||||
new OAuthRequestManager(),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
googleAuthApiRef,
|
||||
GoogleAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
export const apis = builder.build();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-saml": "^1.3.3",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
"yn": "^4.0.0",
|
||||
"jwt-decode": "2.2.0",
|
||||
"@types/jwt-decode": "2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
|
||||
@@ -143,6 +143,14 @@ describe('PassportStrategyHelper', () => {
|
||||
class MyCustomRefreshTokenSuccess extends passport.Strategy {
|
||||
// @ts-ignore
|
||||
private _oauth2 = new MyCustomOAuth2Success();
|
||||
userProfile(_accessToken: string, callback: Function) {
|
||||
callback(null, {
|
||||
provider: 'a',
|
||||
email: 'b',
|
||||
name: 'c',
|
||||
picture: 'd',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mockStrategy = new MyCustomRefreshTokenSuccess();
|
||||
|
||||
@@ -16,7 +16,43 @@
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import { RedirectInfo, RefreshTokenResponse } from './types';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: passport.Profile,
|
||||
params: any,
|
||||
): ProfileInfo => {
|
||||
const { provider, displayName: name } = profile;
|
||||
|
||||
let email = '';
|
||||
if (profile.emails) {
|
||||
const [firstEmail] = profile.emails;
|
||||
email = firstEmail.value;
|
||||
}
|
||||
|
||||
if (!email && params.id_token) {
|
||||
try {
|
||||
const decoded: { email: string } = jwtDecoder(params.id_token);
|
||||
email = decoded.email;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse id token and get profile info');
|
||||
}
|
||||
}
|
||||
|
||||
let picture = '';
|
||||
if (profile.photos) {
|
||||
const [firstPhoto] = profile.photos;
|
||||
picture = firstPhoto.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
name,
|
||||
email,
|
||||
picture,
|
||||
};
|
||||
};
|
||||
|
||||
export const executeRedirectStrategy = async (
|
||||
req: express.Request,
|
||||
@@ -98,6 +134,7 @@ export const executeRefreshTokenStrategy = async (
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
accessToken,
|
||||
params,
|
||||
@@ -106,3 +143,24 @@ export const executeRefreshTokenStrategy = async (
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const executeFetchUserProfileStrategy = async (
|
||||
providerstrategy: passport.Strategy,
|
||||
accessToken: string,
|
||||
params: any,
|
||||
): Promise<ProfileInfo> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerstrategy as any;
|
||||
anyStrategy.userProfile(
|
||||
accessToken,
|
||||
(error: Error, passportProfile: passport.Profile) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const profile = makeProfileInfo(passportProfile, params);
|
||||
resolve(profile);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
executeFetchUserProfileStrategy,
|
||||
} from '../PassportStrategyHelper';
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
@@ -27,8 +29,10 @@ import {
|
||||
AuthInfoPrivate,
|
||||
RedirectInfo,
|
||||
AuthProviderConfig,
|
||||
AuthInfoWithProfile,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../OAuthProvider';
|
||||
import passport from 'passport';
|
||||
|
||||
export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
@@ -43,13 +47,14 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
accessToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
profile: any,
|
||||
profile: passport.Profile,
|
||||
done: any,
|
||||
) => {
|
||||
const profileInfo = makeProfileInfo(profile, params);
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
profile,
|
||||
profile: profileInfo,
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
@@ -73,18 +78,28 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
return await executeFrameHandlerStrategy(req, this._strategy);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, scope: string): Promise<AuthInfoBase> {
|
||||
async refresh(
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<AuthInfoWithProfile> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
refreshToken,
|
||||
scope,
|
||||
);
|
||||
|
||||
const profile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
|
||||
export type AuthProviderConfig = {
|
||||
provider: string;
|
||||
@@ -49,7 +48,14 @@ export type AuthInfoBase = {
|
||||
};
|
||||
|
||||
export type AuthInfoWithProfile = AuthInfoBase & {
|
||||
profile: passport.Profile;
|
||||
profile:
|
||||
| {
|
||||
provider: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type AuthInfoPrivate = {
|
||||
@@ -71,6 +77,13 @@ export type RedirectInfo = {
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type ProfileInfo = {
|
||||
provider: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
};
|
||||
|
||||
export type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
params: any;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
curl \
|
||||
--location \
|
||||
--request POST 'localhost:3003/locations' \
|
||||
--request POST 'localhost:7000/catalog/locations' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"type": "github",
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function createStandaloneApplication(
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use(
|
||||
'/',
|
||||
'/catalog',
|
||||
await createRouter({
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
|
||||
@@ -17,15 +17,18 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.6",
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.6",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -63,6 +63,34 @@ export class CatalogClient implements CatalogApi {
|
||||
if (entity) return entity;
|
||||
throw new Error(`'Entity not found: ${name}`);
|
||||
}
|
||||
|
||||
async addLocation(type: string, target: string) {
|
||||
const response = await fetch(
|
||||
`${this.apiOrigin}${this.basePath}/locations`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, target }),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const { location, entities } = await response.json();
|
||||
|
||||
if (!location || entities.length === 0)
|
||||
throw new Error(`Location wasn't added: ${target}`);
|
||||
|
||||
return {
|
||||
location,
|
||||
entities,
|
||||
};
|
||||
}
|
||||
|
||||
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
|
||||
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
|
||||
if (!locationId) return undefined;
|
||||
|
||||
@@ -27,5 +27,8 @@ export interface CatalogApi {
|
||||
getEntities(): Promise<Entity[]>;
|
||||
getEntityByName(name: string): Promise<Entity>;
|
||||
getEntitiesByLocationId(id: string): Promise<Entity[]>;
|
||||
addLocation(type: string, target: string): Promise<AddLocationResponse>;
|
||||
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
|
||||
}
|
||||
|
||||
export type AddLocationResponse = { location: Location; entities: Entity[] };
|
||||
|
||||
@@ -20,11 +20,23 @@ import CatalogPage from './CatalogPage';
|
||||
import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { CatalogApi } from '../../api/types';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
const errorApi = { post: () => {} };
|
||||
const catalogApi = {
|
||||
getEntities: () => Promise.resolve([{ kind: '', metadata: {} }]),
|
||||
getLocationByEntity: () => Promise.resolve({ data: {} }),
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'Component',
|
||||
},
|
||||
] as Entity[]),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
|
||||
import { filterGroups, defaultFilter } from '../../data/filters';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import {
|
||||
Entity,
|
||||
@@ -51,7 +53,7 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent, findLocationForEntity } from '../../data/utils';
|
||||
import { entityToComponent, findLocationForEntity } from '../../data/utils';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
@@ -111,18 +113,23 @@ const CatalogPage: FC<{}> = () => {
|
||||
<Typography>
|
||||
<span role="img" aria-label="wave" style={{ fontSize: '125%' }}>
|
||||
👋🏼
|
||||
</span>{' '}
|
||||
</span>
|
||||
Welcome to Backstage, we are happy to have you. Start by checking
|
||||
out our{' '}
|
||||
out our
|
||||
<Link href="/welcome" color="textSecondary">
|
||||
getting started
|
||||
</Link>{' '}
|
||||
</Link>
|
||||
page.
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ContentHeader title="Services">
|
||||
<Button variant="contained" color="primary" href="/create">
|
||||
<Button
|
||||
component={RouterLink}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
to={scaffolderRootRoute.path}
|
||||
>
|
||||
Create Service
|
||||
</Button>
|
||||
<SupportButton>All your components</SupportButton>
|
||||
@@ -142,7 +149,7 @@ const CatalogPage: FC<{}> = () => {
|
||||
(value &&
|
||||
value.map(val => {
|
||||
return {
|
||||
...envelopeToComponent(val),
|
||||
...entityToComponent(val),
|
||||
location: findLocationForEntity(val, locations),
|
||||
};
|
||||
})) ||
|
||||
|
||||
@@ -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) => (
|
||||
<Link href={`/catalog/${componentData.name}`}>{componentData.name}</Link>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(entityRoute.path, { name: componentData.name })}
|
||||
>
|
||||
{componentData.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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: (
|
||||
<DescriptionWrapper>
|
||||
{envelope.metadata?.annotations?.description ?? 'placeholder'}
|
||||
{location?.target ? (
|
||||
<a href={createEditLink(location?.target)}>
|
||||
<IconButton size="small">
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
</a>
|
||||
) : null}
|
||||
</DescriptionWrapper>
|
||||
),
|
||||
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;
|
||||
}
|
||||
@@ -18,3 +18,4 @@ export { plugin } from './plugin';
|
||||
export * from './api/CatalogClient';
|
||||
export * from './api/types';
|
||||
export * from './types';
|
||||
export * from './routes';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
+30
-24
@@ -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<Props>) => {
|
||||
return {
|
||||
rendered: render(
|
||||
<RegisterComponentForm
|
||||
onSubmit={jest.fn()}
|
||||
submitting={false}
|
||||
{...props}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
};
|
||||
describe('RegisterComponentForm', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('should initially render a disabled button', async () => {
|
||||
const rendered = render(
|
||||
<RegisterComponentForm onSubmit={jest.fn()} submitting={false} />,
|
||||
);
|
||||
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(
|
||||
<RegisterComponentForm onSubmit={jest.fn()} submitting={false} />,
|
||||
);
|
||||
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(
|
||||
<RegisterComponentForm onSubmit={jest.fn()} submitting />,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
+15
-24
@@ -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<BackstageTheme>(theme => ({
|
||||
@@ -39,57 +38,49 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type RegisterComponentProps = {
|
||||
onSubmit: () => any;
|
||||
export type Props = {
|
||||
onSubmit: (formData: Record<string, string>) => Promise<void>;
|
||||
submitting: boolean;
|
||||
};
|
||||
|
||||
const RegisterComponentForm: FC<RegisterComponentProps> = ({
|
||||
onSubmit,
|
||||
submitting,
|
||||
}) => {
|
||||
const RegisterComponentForm: FC<Props> = ({ 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 (
|
||||
<>
|
||||
<Typography variant="subtitle1" paragraph>
|
||||
Your component is being registered. Please wait.
|
||||
</Typography>
|
||||
<Progress />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
return submitting ? (
|
||||
<LinearProgress data-testid="loading-progress" />
|
||||
) : (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={classes.form}
|
||||
data-testid="register-form"
|
||||
>
|
||||
<FormControl>
|
||||
<TextField
|
||||
id="registerComponentInput"
|
||||
variant="outlined"
|
||||
label="Component service file URL"
|
||||
data-testid="componentLocationInput"
|
||||
error={hasErrors}
|
||||
placeholder="https://example.com/user/some-service/blob/master/service-info.yaml"
|
||||
name="componentIdInput"
|
||||
name="componentLocation"
|
||||
required
|
||||
margin="normal"
|
||||
helperText="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."
|
||||
helperText="Enter the full path to the service-info.yaml file in GitHub to start tracking your component. It must be in a public repo."
|
||||
inputRef={register({
|
||||
required: true,
|
||||
validate: ComponentIdValidators,
|
||||
})}
|
||||
/>
|
||||
|
||||
{errors.componentIdInput && (
|
||||
{errors.componentLocation && (
|
||||
<FormHelperText error={hasErrors} id="register-component-helper-text">
|
||||
{errors.componentIdInput.message}
|
||||
{errors.componentLocation.message}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
+35
-9
@@ -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<typeof catalogApiRef.T> = {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = () => ({
|
||||
rendered: render(
|
||||
<MemoryRouter>
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, errorApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
])}
|
||||
>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<RegisterComponentPage />
|
||||
</ThemeProvider>
|
||||
</ApiProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
});
|
||||
describe('RegisterComponentPage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('should render', () => {
|
||||
mockFetch.mockResponse(() => new Promise(() => {}));
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<RegisterComponentPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(rendered.getByText('Register Component')).toBeInTheDocument();
|
||||
const { rendered } = setup();
|
||||
expect(
|
||||
rendered.getByText('Register existing component'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+76
-19
@@ -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> = T extends Record<any, infer V> ? V : never;
|
||||
const RegisterComponentPage: FC<{}> = () => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const classes = useStyles();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const [formState, setFormState] = useState<ValuesOf<typeof FormStates>>(
|
||||
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<string, string>) => {
|
||||
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 (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header title="Register existing component" />
|
||||
<Content>
|
||||
<ContentHeader title="Register Component">
|
||||
<SupportButton>Documentation</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard title="Start tracking your component in Backstage">
|
||||
<RegisterComponentForm
|
||||
onSubmit={onSubmit}
|
||||
submitting={isSubmitting}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={formState === FormStates.Submitting}
|
||||
/>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
{formState === FormStates.Success && (
|
||||
<RegisterComponentResultDialog
|
||||
entities={result.data!.entities}
|
||||
onClose={() => setFormState(FormStates.Idle)}
|
||||
classes={{ paper: classes.dialogPaper }}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
+82
@@ -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<ComponentProps<typeof RegisterComponentResultDialog>>,
|
||||
) => ({
|
||||
rendered: render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<RegisterComponentResultDialog
|
||||
onClose={() => {}}
|
||||
entities={[]}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
});
|
||||
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();
|
||||
});
|
||||
+93
@@ -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<string, string>;
|
||||
entities: Entity[];
|
||||
};
|
||||
|
||||
export const RegisterComponentResultDialog: FC<Props> = ({
|
||||
onClose,
|
||||
classes,
|
||||
entities,
|
||||
}) => (
|
||||
<Dialog open onClose={onClose} classes={classes}>
|
||||
<DialogTitle>Component Registration Result</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
The following components have been succefully created:
|
||||
</DialogContentText>
|
||||
<List>
|
||||
{entities.map((entity: any, index: number) => (
|
||||
<React.Fragment
|
||||
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
|
||||
>
|
||||
<ListItem>
|
||||
<StructuredMetadataTable
|
||||
dense
|
||||
metadata={{
|
||||
name: entity.metadata.name,
|
||||
type: entity.spec.type,
|
||||
link: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(entityRoute.path, {
|
||||
name: entity.metadata.name,
|
||||
})}
|
||||
>
|
||||
{generatePath(entityRoute.path, {
|
||||
name: entity.metadata.name,
|
||||
})}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < entities.length - 1 && <Divider component="li" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button component={RouterLink} to={catalogRootRoute.path} color="default">
|
||||
To Catalog
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -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';
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export { plugin, rootRoute } from './plugin';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<Content>
|
||||
<ContentHeader title="Available templates" />
|
||||
<ContentHeader title="Available templates">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
component={RouterLink}
|
||||
to="/register-component"
|
||||
>
|
||||
Register existing component
|
||||
</Button>
|
||||
</ContentHeader>
|
||||
<Typography variant="body2" paragraph style={{ fontStyle: 'italic' }}>
|
||||
<strong>NOTE!</strong> This feature is WIP. You can follow progress{' '}
|
||||
<Link href="https://github.com/spotify/backstage/milestone/11">
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export { plugin, rootRoute } from './plugin';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user