Merge pull request #1111 from spotify/mob/register-unregister-components

Register component flow
This commit is contained in:
Ivan Shmidt
2020-06-05 11:07:25 +02:00
committed by GitHub
26 changed files with 503 additions and 104 deletions
@@ -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
+4 -3
View File
@@ -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/"
}
}
}
+2 -1
View File
@@ -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);
+6 -3
View File
@@ -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": {
+28
View File
@@ -43,6 +43,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 findLocationIdInEntity = (e: Entity): string | undefined =>
e.metadata.annotations?.['backstage.io/managed-by-location'];
+3
View File
@@ -25,5 +25,8 @@ export const catalogApiRef = createApiRef<CatalogApi>({
export interface CatalogApi {
getEntities(): Promise<Entity[]>;
getEntityByName(name: 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, Location } from '@backstage/catalog-model';
@@ -118,18 +120,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>
@@ -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>
),
},
{
+1
View File
@@ -18,3 +18,4 @@ export { plugin } from './plugin';
export * from './api/CatalogClient';
export * from './api/types';
export * from './types';
export * from './routes';
+3 -2
View File
@@ -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);
},
});
+30
View File
@@ -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',
});
+4
View File
@@ -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": {
@@ -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();
});
@@ -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>
@@ -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();
});
});
@@ -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>
);
};
@@ -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();
});
@@ -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';
+1 -1
View File
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export { plugin, rootRoute } from './plugin';
+8 -2
View File
@@ -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);
},
});
+1
View File
@@ -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">
+1 -1
View File
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export { plugin, rootRoute } from './plugin';
+8 -2
View File
@@ -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);
},
});