diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md index 8e20827280..ca2a66f6b9 100644 --- a/docs/architecture-decisions/adr005-catalog-core-entities.md +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -12,18 +12,20 @@ We want to standardize on a few core entities that we are tracking in the Backst Backstage should eventually support the following core entities: -* **Components** are individual pieces of software -* **APIs** are the boundaries between different components -* **Resources** are physical or virtual infrastructure needed to operate a component +- **Components** are individual pieces of software +- **APIs** are the boundaries between different components +- **Resources** are physical or virtual infrastructure needed to operate a component ![Catalog Core Entities](catalog-core-entities.png) For now, we'll start by only implementing support for the Component entity in the Backstage catalog. This can later be extended to APIs, Resources and other potentially useful entities. ### Component + A component is a piece of software, for example a mobile application feature, web site, backend service or data pipeline (list not exhaustive). A component can be tracked in source control, or use some existing open source or commercial software. It can implement APIs for other components to consume. In turn it might depend on APIs implemented by other components, or resources that are attached to it at runtime. Component entities are typically defined in YAML descriptor files next to the code of the component, and could look like this (actual schema will evolve): + ```yaml apiVersion: backstage.io/v1beta1 kind: Component @@ -34,11 +36,13 @@ spec: ``` ### API + APIs form an abstraction that allows large software ecosystems to scale. Thus, APIs are a first class citizen in the Backstage model and the primary way to discover existing functionality in the ecosystem. APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (e.g. in Protobuf, GraphQL or similar), a data schema (e.g. in Avro, TFRecord or similar), or as code interfaces (e.g. framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top. APIs are typically indexed from existing definitions in source control and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve): + ```yaml apiVersion: backstage.io/v1beta1 kind: API @@ -59,9 +63,11 @@ spec: ``` ### Resource + Resources are the infrastructure your software needs to operate at runtime like Bigtable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and APIs will allow us to visualize and create tooling around them in Backstage. Resources are typically indexed from declarative definitions (e.g. Terraform, GCP Config Connector, AWS Cloud Formation) and/or inventories from cloud providers (e.g. GCP Asset Inventory) and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve): + ```yaml apiVersion: backstage.io/v1beta1 kind: Resource diff --git a/packages/app/package.json b/packages/app/package.json index ed2f547ad7..15681d0548 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -12,15 +12,16 @@ "@backstage/plugin-lighthouse": "^0.1.1-alpha.6", "@backstage/plugin-register-component": "^0.1.1-alpha.6", "@backstage/plugin-scaffolder": "^0.1.1-alpha.6", + "@backstage/plugin-sentry": "^0.1.1-alpha.6", "@backstage/plugin-tech-radar": "^0.1.1-alpha.6", "@backstage/plugin-welcome": "^0.1.1-alpha.6", "@backstage/theme": "^0.1.1-alpha.6", - "@backstage/plugin-sentry": "^0.1.1-alpha.6", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.12.0", + "react-hot-loader": "^4.12.21", "react-router-dom": "^5.2.0", "react-use": "^14.2.0", "zen-observable": "^0.8.15" @@ -73,10 +74,10 @@ } }, "/catalog/api": { - "target": "http://localhost:3003", + "target": "http://localhost:7000", "changeOrigin": true, "pathRewrite": { - "^/catalog/api/": "/" + "^/catalog/api/": "/catalog/" } } } diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index b4d01e067b..39ff0432ea 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -20,6 +20,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import Root from './components/Root'; import * as plugins from './plugins'; import apis from './apis'; +import { hot } from 'react-hot-loader/root'; const app = createApp({ apis, @@ -41,4 +42,4 @@ const App: FC<{}> = () => ( ); -export default App; +export default hot(App); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 0cdc5d2618..5445fffb32 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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": { diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index abba8a7523..4ae4676703 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -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 { const findLocationIdInEntity = (e: Entity): string | undefined => e.metadata.annotations?.['backstage.io/managed-by-location']; diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 9e8dc5fbac..5bb6a6ca83 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -25,5 +25,8 @@ export const catalogApiRef = createApiRef({ export interface CatalogApi { getEntities(): Promise; getEntityByName(name: string): Promise; + addLocation(type: string, target: string): Promise; getLocationByEntity(entity: Entity): Promise; } + +export type AddLocationResponse = { location: Location; entities: Entity[] }; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 9ebeb7f546..53cc4f6c44 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -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 = { + 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', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 25d2618a9a..779bcc7693 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -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<{}> = () => { 👋🏼 - {' '} + Welcome to Backstage, we are happy to have you. Start by checking - out our{' '} + out our getting started - {' '} + page. } /> - All your components diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 015916c3f4..7ff521ee67 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -17,6 +17,8 @@ import React, { FC } from 'react'; import { Component } from '../../data/component'; import { InfoCard, Progress, Table, TableColumn } from '@backstage/core'; import { Typography, Link } from '@material-ui/core'; +import { Link as RouterLink, generatePath } from 'react-router-dom'; +import { entityRoute } from '../../routes'; const columns: TableColumn[] = [ { @@ -24,7 +26,12 @@ const columns: TableColumn[] = [ field: 'name', highlight: true, render: (componentData: any) => ( - {componentData.name} + + {componentData.name} + ), }, { diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index f495bd3146..2b986b59ea 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -18,3 +18,4 @@ export { plugin } from './plugin'; export * from './api/CatalogClient'; export * from './api/types'; export * from './types'; +export * from './routes'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 97e470a065..1eee5efe07 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -17,11 +17,12 @@ import { createPlugin } from '@backstage/core'; import CatalogPage from './components/CatalogPage'; import ComponentPage from './components/ComponentPage/ComponentPage'; +import { rootRoute, entityRoute } from './routes'; export const plugin = createPlugin({ id: 'catalog', register({ router }) { - router.registerRoute('/', CatalogPage); - router.registerRoute('/catalog/:name/', ComponentPage); + router.addRoute(rootRoute, CatalogPage); + router.addRoute(entityRoute, ComponentPage); }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts new file mode 100644 index 0000000000..6498d412b8 --- /dev/null +++ b/plugins/catalog/src/routes.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouteRef } from '@backstage/core'; + +const NoIcon = () => null; + +export const rootRoute = createRouteRef({ + icon: NoIcon, + path: '/', + title: 'Catalog', +}); +export const entityRoute = createRouteRef({ + icon: NoIcon, + path: '/catalog/:name/', + title: 'Entity', +}); diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 053af6bb51..83be6fb7db 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -18,6 +18,8 @@ }, "dependencies": { "@backstage/core": "^0.1.1-alpha.6", + "@backstage/catalog-model": "^0.1.1-alpha.6", + "@backstage/plugin-catalog": "^0.1.1-alpha.6", "@backstage/theme": "^0.1.1-alpha.6", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", @@ -25,6 +27,8 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-hook-form": "^5.7.2", + "react-router": "^5.2.0", + "react-router-dom": "^5.2.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx index 9203093d48..e8b0c3b76e 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.test.tsx @@ -15,17 +15,29 @@ */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import RegisterComponentForm from './RegisterComponentForm'; +import { render, fireEvent, cleanup } from '@testing-library/react'; +import RegisterComponentForm, { Props } from './RegisterComponentForm'; +import { act } from 'react-dom/test-utils'; +const setup = (props?: Partial) => { + return { + rendered: render( + , + ), + }; +}; describe('RegisterComponentForm', () => { + afterEach(() => cleanup()); + it('should initially render a disabled button', async () => { - const rendered = render( - , - ); + const { rendered } = setup(); expect( await rendered.findByText( - 'Enter the full path to the service-info.yaml file in GHE to start tracking your component. It must be in a public repo, on the master branch.', + 'Enter the full path to the service-info.yaml file in GitHub to start tracking your component. It must be in a public repo.', ), ).toBeInTheDocument(); @@ -34,27 +46,21 @@ describe('RegisterComponentForm', () => { }); it('should enable a submit form when data when component url is set ', async () => { - const rendered = render( - , - ); + const { rendered } = setup(); const input = (await rendered.getByRole('textbox')) as HTMLInputElement; - fireEvent.change(input, { - target: { value: 'https://example.com/blob/master/service.yaml' }, + await act(async () => { + // react-hook-form uses `input` event for changes + fireEvent.input(input, { + target: { value: 'https://example.com/blob/master/service.yaml' }, + }); }); - const submit = (await rendered.findByText('Submit')) as HTMLButtonElement; + const submit = (await rendered.getByRole('button')) as HTMLButtonElement; expect(submit.disabled).toBeFalsy(); }); - - it('should hide input on submission ', async () => { - const rendered = render( - , - ); - - expect( - await rendered.findByText( - 'Your component is being registered. Please wait.', - ), - ).toBeInTheDocument(); - }); +}); + +it('should show spinner while submitting', async () => { + const { rendered } = setup({ submitting: true }); + expect(rendered.getByTestId('loading-progress')).toBeInTheDocument(); }); diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index d279afac25..d9fb88e44b 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -20,12 +20,11 @@ import { FormControl, FormHelperText, TextField, - Typography, + LinearProgress, } from '@material-ui/core'; import { useForm } from 'react-hook-form'; import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; -import { Progress } from '@backstage/core'; import { ComponentIdValidators } from '../../util/validate'; const useStyles = makeStyles(theme => ({ @@ -39,57 +38,49 @@ const useStyles = makeStyles(theme => ({ }, })); -type RegisterComponentProps = { - onSubmit: () => any; +export type Props = { + onSubmit: (formData: Record) => Promise; submitting: boolean; }; -const RegisterComponentForm: FC = ({ - onSubmit, - submitting, -}) => { +const RegisterComponentForm: FC = ({ onSubmit, submitting }) => { const { register, handleSubmit, errors, formState } = useForm({ mode: 'onChange', }); const classes = useStyles(); - const hasErrors = !!errors.componentIdInput; + const hasErrors = !!errors.componentLocation; const dirty = formState?.dirty; - if (submitting) { - return ( - <> - - Your component is being registered. Please wait. - - - - ); - } - return ( + + return submitting ? ( + + ) : (
- {errors.componentIdInput && ( + {errors.componentLocation && ( - {errors.componentIdInput.message} + {errors.componentLocation.message} )} diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index ac2645135e..ad47c3a41b 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -15,20 +15,46 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; -import mockFetch from 'jest-fetch-mock'; +import { render, cleanup } from '@testing-library/react'; import RegisterComponentPage from './RegisterComponentPage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; +import { errorApiRef, ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { MemoryRouter } from 'react-router-dom'; +const errorApi = { post: () => {} }; +const catalogApi: jest.Mocked = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn((_a, _b) => new Promise(() => {})), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), +}; + +const setup = () => ({ + rendered: render( + + + + + + + , + ), +}); describe('RegisterComponentPage', () => { + afterEach(() => cleanup()); + it('should render', () => { - mockFetch.mockResponse(() => new Promise(() => {})); - const rendered = render( - - - , - ); - expect(rendered.getByText('Register Component')).toBeInTheDocument(); + const { rendered } = setup(); + expect( + rendered.getByText('Register existing component'), + ).toBeInTheDocument(); }); }); diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index 114c37fe4c..27654d2080 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -14,50 +14,107 @@ * limitations under the License. */ -import React, { FC, useEffect, useState } from 'react'; -import { Grid } from '@material-ui/core'; +import React, { FC, useState } from 'react'; +import { Grid, makeStyles } from '@material-ui/core'; import { InfoCard, Page, pageTheme, Content, - ContentHeader, - SupportButton, + useApi, + errorApiRef, + Header, } from '@backstage/core'; import RegisterComponentForm from '../RegisterComponentForm'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import { useMountedState } from 'react-use'; +import { Entity, Location } from '@backstage/catalog-model'; +import { RegisterComponentResultDialog } from '../RegisterComponentResultDialog'; +const useStyles = makeStyles(theme => ({ + dialogPaper: { + minHeight: 250, + minWidth: 600, + }, + icon: { + width: 20, + marginRight: theme.spacing(1), + }, + contentText: { + paddingBottom: theme.spacing(2), + }, +})); + +const FormStates = { + Idle: 'idle', + Success: 'success', + Submitting: 'submitting', +} as const; + +type ValuesOf = T extends Record ? V : never; const RegisterComponentPage: FC<{}> = () => { - const [isSubmitting, setIsSubmitting] = useState(false); + const classes = useStyles(); + const catalogApi = useApi(catalogApiRef); + const [formState, setFormState] = useState>( + FormStates.Idle, + ); + const isMounted = useMountedState(); - useEffect(() => { - if (isSubmitting) { - setTimeout(() => { - setIsSubmitting(false); - }, 4000); + const errorApi = useApi(errorApiRef); + + const [result, setResult] = useState<{ + data: { + entities: Entity[]; + location: Location; + } | null; + error: null | Error; + }>({ + data: null, + error: null, + }); + + const handleSubmit = async (formData: Record) => { + setFormState(FormStates.Submitting); + const { componentLocation: target } = formData; + try { + const data = await catalogApi.addLocation('github', target); + + if (!isMounted()) return; + + setResult({ error: null, data }); + setFormState(FormStates.Success); + } catch (e) { + errorApi.post(e); + + if (!isMounted()) return; + + setResult({ error: e, data: null }); + setFormState(FormStates.Idle); } - }, [isSubmitting]); - - const onSubmit = () => { - setIsSubmitting(true); }; return ( +
- - Documentation - + {formState === FormStates.Success && ( + setFormState(FormStates.Idle)} + classes={{ paper: classes.dialogPaper }} + /> + )} ); }; diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.test.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.test.tsx new file mode 100644 index 0000000000..d918e72489 --- /dev/null +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { RegisterComponentResultDialog } from './RegisterComponentResultDialog'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import { MemoryRouter } from 'react-router-dom'; +import { Entity } from '@backstage/catalog-model'; + +const setup = ( + props?: Partial>, +) => ({ + rendered: render( + + + {}} + entities={[]} + {...props} + /> + + , + ), +}); +describe('RegisterComponentResultDialog', () => { + afterEach(() => cleanup()); + + it('should render', () => { + const { rendered } = setup(); + expect( + rendered.getByText('Component Registration Result'), + ).toBeInTheDocument(); + }); +}); + +it('should show a list of components if success', async () => { + const { rendered } = setup({ + entities: [ + { + kind: 'Component', + metadata: { + name: 'Component1', + }, + spec: { + type: 'website', + }, + }, + { + kind: 'Component', + metadata: { + name: 'Component2', + }, + spec: { + type: 'service', + }, + }, + ] as Entity[], + }); + + expect( + rendered.getByText( + 'The following components have been succefully created:', + ), + ).toBeInTheDocument(); + expect(rendered.getByText('Component1')).toBeInTheDocument(); + expect(rendered.getByText('Component2')).toBeInTheDocument(); +}); diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx new file mode 100644 index 0000000000..4d158526a0 --- /dev/null +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + List, + ListItem, + Link, + Divider, + DialogActions, + Button, +} from '@material-ui/core'; +import { Entity } from '@backstage/catalog-model'; +import { StructuredMetadataTable } from '@backstage/core'; +import { generatePath } from 'react-router'; +import { + entityRoute, + rootRoute as catalogRootRoute, +} from '@backstage/plugin-catalog'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = { + onClose: () => void; + classes?: Record; + entities: Entity[]; +}; + +export const RegisterComponentResultDialog: FC = ({ + onClose, + classes, + entities, +}) => ( + + Component Registration Result + + + The following components have been succefully created: + + + {entities.map((entity: any, index: number) => ( + + + + {generatePath(entityRoute.path, { + name: entity.metadata.name, + })} + + ), + }} + /> + + {index < entities.length - 1 && } + + ))} + + + + + + +); diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/index.ts b/plugins/register-component/src/components/RegisterComponentResultDialog/index.ts new file mode 100644 index 0000000000..7ed25f3c20 --- /dev/null +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { RegisterComponentResultDialog } from './RegisterComponentResultDialog'; diff --git a/plugins/register-component/src/index.ts b/plugins/register-component/src/index.ts index 3a0a0fe2d3..5b20cb0158 100644 --- a/plugins/register-component/src/index.ts +++ b/plugins/register-component/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { plugin, rootRoute } from './plugin'; diff --git a/plugins/register-component/src/plugin.ts b/plugins/register-component/src/plugin.ts index f32e9dc84f..9c73688a70 100644 --- a/plugins/register-component/src/plugin.ts +++ b/plugins/register-component/src/plugin.ts @@ -14,12 +14,18 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import RegisterComponentPage from './components/RegisterComponentPage'; +export const rootRoute = createRouteRef({ + icon: () => null, + path: '/register-component', + title: 'Register component', +}); + export const plugin = createPlugin({ id: 'register-component', register({ router }) { - router.registerRoute('/register-component', RegisterComponentPage); + router.addRoute(rootRoute, RegisterComponentPage); }, }); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 7f453fde48..ad44e06986 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -24,6 +24,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router-dom": "^5.2.0", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index e6fed50cef..5d976a20b2 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -24,7 +24,8 @@ import { Page, pageTheme, } from '@backstage/core'; -import { Typography, Link } from '@material-ui/core'; +import { Typography, Link, Button } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; // TODO(blam): Connect to backend const STATIC_DATA = [ @@ -49,7 +50,16 @@ const ScaffolderPage: React.FC<{}> = () => { subtitle="Create new software components using standard templates" /> - + + + NOTE! This feature is WIP. You can follow progress{' '} diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 3a0a0fe2d3..5b20cb0158 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { plugin, rootRoute } from './plugin'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 31bdab2f84..b2cb305e57 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -14,12 +14,18 @@ * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import ScaffolderPage from './components/ScaffolderPage'; +export const rootRoute = createRouteRef({ + icon: () => null, + path: '/create', + title: 'Create entity', +}); + export const plugin = createPlugin({ id: 'scaffolder', register({ router }) { - router.registerRoute('/create', ScaffolderPage); + router.addRoute(rootRoute, ScaffolderPage); }, });