Merge branch 'master' into feat/api-auth-client

This commit is contained in:
Patrik Oldsberg
2021-02-10 12:49:21 +01:00
62 changed files with 4691 additions and 1283 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/create-app': patch
---
`@backstage/plugin-catalog-import` has been refactored, so the `App.tsx` of the backstage apps need to be updated:
```diff
// packages/app/src/App.tsx
<Route
path="/catalog-import"
- element={<CatalogImportPage catalogRouteRef={catalogRouteRef} />}
+ element={<CatalogImportPage />}
/>
```
+19
View File
@@ -0,0 +1,19 @@
---
'@backstage/plugin-catalog-import': minor
---
The plugin has been refactored and is now based on a configurable state machine of 'analyze', 'prepare', 'review' & 'finish'.
Depending on the outcome of the 'analyze' stage, different flows are selected ('single-location', 'multiple-locations', 'no-location').
Each flow can define it's own components that guide the user.
During the refactoring, the `catalogRouteRef` property of the `CatalogImportPage` has been removed, so the `App.tsx` of the backstage apps need to be updated:
```diff
// packages/app/src/App.tsx
<Route
path="/catalog-import"
- element={<CatalogImportPage catalogRouteRef={catalogRouteRef} />}
+ element={<CatalogImportPage />}
/>
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': patch
---
Add the `presence` argument to the `CatalogApi` to be able to register optional locations.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Make `EntityRefLink` a `React.forwardRef` in order to use it as root component in other components like `ListItem`.
+2 -5
View File
@@ -23,7 +23,7 @@ import {
SignInPage,
} from '@backstage/core';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
import { ExplorePage } from '@backstage/plugin-explore';
import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
@@ -69,10 +69,7 @@ const catalogRouteRef = createRouteRef({
const routes = (
<FlatRoutes>
<Navigate key="/" to="/catalog" />
<Route
path="/catalog-import"
element={<ImportComponentRouter catalogRouteRef={catalogRouteRef} />}
/>
<Route path="/catalog-import" element={<CatalogImportPage />} />
<Route
path={`${catalogRouteRef.path}`}
element={<CatalogRouter EntityPage={EntityPage} />}
+2 -2
View File
@@ -86,7 +86,7 @@ export class CatalogClient implements CatalogApi {
}
async addLocation(
{ type = 'url', target, dryRun }: AddLocationRequest,
{ type = 'url', target, dryRun, presence }: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise<AddLocationResponse> {
const headers = {
@@ -102,7 +102,7 @@ export class CatalogClient implements CatalogApi {
{
headers,
method: 'POST',
body: JSON.stringify({ type, target }),
body: JSON.stringify({ type, target, presence }),
},
);
+1
View File
@@ -60,6 +60,7 @@ export type AddLocationRequest = {
type?: string;
target: string;
dryRun?: boolean;
presence?: 'optional' | 'required';
};
export type AddLocationResponse = {
@@ -4,7 +4,6 @@ import {
AlertDisplay,
OAuthRequestDialog,
SidebarPage,
createRouteRef,
FlatRoutes,
} from '@backstage/core';
import { apis } from './apis';
@@ -13,7 +12,7 @@ import { AppSidebar } from './sidebar';
import { Route, Navigate } from 'react-router';
import { Router as CatalogRouter } from '@backstage/plugin-catalog';
import { Router as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { SearchPage as SearchRouter } from '@backstage/plugin-search';
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
@@ -29,12 +28,6 @@ const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const deprecatedAppRoutes = app.getRoutes();
const catalogRouteRef = createRouteRef({
path: '/catalog',
title: 'Service Catalog',
});
const App = () => (
<AppProvider>
<AlertDisplay />
@@ -53,10 +46,7 @@ const App = () => (
path="/tech-radar"
element={<TechRadarRouter width={1500} height={800} />}
/>
<Route
path="/catalog-import"
element={<ImportComponentRouter catalogRouteRef={catalogRouteRef} />}
/>
<Route path="/catalog-import" element={<CatalogImportPage />} />
<Route
path="/search"
element={<SearchRouter/>}
+76 -11
View File
@@ -1,21 +1,86 @@
# Catalog import plugin
# Catalog Import
Welcome to the catalog-import plugin!
The Catalog Import Plugin provides a wizard to onboard projects with existing `catalog-info.yaml` files.
It also assists by creating pull requests in repositories where no `catalog-info.yaml` exists.
This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it.
![Catalog Import Plugin](./docs/catalog-import-screenshot.png)
When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import).
Current features:
<img src="./src/assets/catalog-import-screenshot.png" />
- Import `catalog-info.yaml` files from a URL in a repository of one of the supported Git integrations (example `https://github.com/backstage/backstage/catalog-info.yaml`).
- _[GitHub only]_ Search for all `catalog-info.yaml` files in a Git repository (example: `https://github.com/backstage/backstage`).
- _[GitHub only]_ Analyze a repository, generate a Component entity, and create a Pull Request to onboard the repository.
## Running
Some features are not yet available for all supported Git providers.
Just run the backstage.
## Getting Started
```
yarn start && yarn --cwd packages/backend start
1. Install the Catalog Import Plugin:
```bash
# packages/app
yarn add @backstage/plugin-catalog-import
```
## Usage
2. Add the plugin to the app:
Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL.
```ts
// packages/app/src/plugins.ts
export { catalogImportPlugin } from '@backstage/plugin-catalog-import';
```
3. Register the `CatalogImportPage` at the `/catalog-import` path:
```tsx
// packages/app/src/App.tsx
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
<Route path="/catalog-import" element={<CatalogImportPage />} />;
```
## Customizations
### Disable the creation of Pull Requests
The pull request feature can be disabled by options that are passed to the `CatalogImportPage`:
```tsx
// packages/app/src/App.tsx
<Route
path="/catalog-import"
element={<CatalogImportPage options={{ pullRequest: { disable: true } }} />}
/>
```
### Customize the title and body of the Pull Request
The pull request form is filled with a default title and body.
This can be configured by options that are passed to the `CatalogImportPage`:
```tsx
// packages/app/src/App.tsx
<Route
path="/catalog-import"
element={
<CatalogImportPage
options={{
pullRequest: {
preparePullRequest: () => ({
title: 'My title',
body: 'My **markdown** body',
}),
},
}}
/>
}
/>
```
## Development
Use `yarn start` to run a [development version](./dev/index.tsx) of the plugin that can be used to validate each flow with mocked data.
+333 -2
View File
@@ -14,7 +14,338 @@
* limitations under the License.
*/
import { CatalogApi } from '@backstage/catalog-client';
import { Entity, EntityName } from '@backstage/catalog-model';
import { Content, Header, InfoCard, Page } from '@backstage/core';
import { createDevApp } from '@backstage/dev-utils';
import { catalogImportPlugin } from '../src/plugin';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Grid, ListItem, ListItemIcon, ListItemText } from '@material-ui/core';
import AlarmIcon from '@material-ui/icons/Alarm';
import LocationOnIcon from '@material-ui/icons/LocationOn';
import React from 'react';
import {
AnalyzeResult,
CatalogImportApi,
catalogImportApiRef,
EntityListComponent,
ImportStepper,
} from '../src';
import { ImportComponentPage } from '../src/components/ImportComponentPage';
createDevApp().registerPlugin(catalogImportPlugin).render();
const getEntityNames = (url: string): EntityName[] => [
{
kind: 'Component',
namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default',
name: 'component-a',
},
{
kind: 'API',
namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default',
name: 'api-a',
},
];
const getEntities = (url: string): Entity[] => [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default',
name: 'component-a',
},
},
{
apiVersion: '1',
kind: 'API',
metadata: {
namespace: url.replace(/^.*(folder-[^/]+).*|.*()$/, '$1') || 'default',
name: 'api-a',
},
},
];
const locations = [
{
target: 'https://my-location-1',
entities: [
{
kind: 'Domain',
namespace: 'default',
name: 'my-domain',
},
{
kind: 'Group',
namespace: 'groups',
name: 'my-group',
},
{
kind: 'Location',
namespace: 'default',
name: 'my-location',
},
{
kind: 'System',
namespace: 'default',
name: 'my-system',
},
{
kind: 'User',
namespace: 'users',
name: 'my-api',
},
],
},
{
target: 'https://my-location-2',
entities: [
{
kind: 'API',
namespace: 'default',
name: 'my-api',
},
{
kind: 'Component',
namespace: 'default',
name: 'my-component',
},
{
kind: 'Location',
namespace: 'default',
name: 'my-location',
},
],
},
];
createDevApp()
.registerApi({
api: catalogApiRef,
deps: {},
factory: () =>
({
getEntities: async () => {
await new Promise(r => setTimeout(r, 1000));
return {
items: [
{
apiVersion: '1',
kind: 'Group',
metadata: {
name: 'group-a',
namespace: 'default',
},
spec: {
profile: {
displayName: 'Group A',
},
},
},
{
apiVersion: '1',
kind: 'Group',
metadata: {
name: 'group-b',
namespace: 'default',
},
spec: {
profile: {
displayName: 'Group B',
},
},
},
{
apiVersion: '1',
kind: 'Group',
metadata: {
name: 'group-a',
namespace: 'other',
},
spec: {
profile: {
displayName: 'Group A',
},
},
},
] as Entity[],
};
},
addLocation: async location => {
await new Promise(r => setTimeout(r, 1000));
return {
location,
entities: getEntities(location.target),
};
},
} as CatalogApi),
})
.registerApi({
api: catalogImportApiRef,
deps: {},
factory: () =>
({
analyzeUrl: async (url: string): Promise<AnalyzeResult> => {
await new Promise(r => setTimeout(r, 500));
switch (url) {
case 'https://0':
return {
type: 'repository',
url,
integrationType: 'github',
generatedEntities: getEntities(url),
};
case 'https://1':
case 'https://2/catalog-info.yaml':
case 'https://2/folder-a/catalog-info.yaml':
case 'https://2/folder-b/catalog-info.yaml':
return {
type: 'locations',
locations: [
{
target: url.includes('/catalog-info.yaml')
? url
: `${url}/catalog-info.yaml`,
entities: getEntityNames(url),
},
],
};
case 'https://2': {
const urls = [
`${url}/catalog-info.yaml`,
`${url}/folder-a/catalog-info.yaml`,
`${url}/folder-b/catalog-info.yaml`,
];
return {
type: 'locations',
locations: urls.map(u => ({
target: u,
entities: getEntityNames(u),
})),
};
}
default:
throw new Error(`Invalid url ${url}`);
}
},
submitPullRequest: async ({
repositoryUrl,
}): Promise<{
link: string;
location: string;
}> => {
await new Promise(r => setTimeout(r, 2500));
return {
link: `${repositoryUrl}/pulls/1`,
location: `${repositoryUrl}/blob/catalog-info.yaml`,
};
},
} as CatalogImportApi),
})
.addPage({
title: 'Catalog Import',
element: <ImportComponentPage />,
})
.addPage({
title: 'Catalog Import 2',
element: (
<Page themeId="home">
<Header title="Catalog Import" />
<Content>
<Grid container>
<Grid item xs={12} md={6}>
<ImportStepper initialUrl="https://0" variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<ImportStepper initialUrl="https://1" variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<ImportStepper initialUrl="https://2" variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<ImportStepper initialUrl="https://3" variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<ImportStepper />
</Grid>
</Grid>
</Content>
</Page>
),
})
.addPage({
title: 'Components',
element: (
<Page themeId="home">
<Header title="Components" />
<Content>
<Grid container>
<Grid item xs={12} md={6} lg={4}>
<InfoCard
title="EntityListComponent (default)"
variant="gridItem"
>
<EntityListComponent
locations={locations}
locationListItemIcon={() => <LocationOnIcon />}
/>
</InfoCard>
</Grid>
<Grid item xs={12} md={6} lg={4}>
<InfoCard
title="EntityListComponent (clickable locations)"
variant="gridItem"
>
<EntityListComponent
firstListItem={
<ListItem dense>
<ListItemIcon>
<AlarmIcon />
</ListItemIcon>
<ListItemText primary="A custom first item" />
</ListItem>
}
locations={locations}
locationListItemIcon={() => <LocationOnIcon />}
onItemClick={() => {}}
/>
</InfoCard>
</Grid>
<Grid item xs={12} md={6} lg={4}>
<InfoCard
title="EntityListComponent (collapsed)"
variant="gridItem"
>
<EntityListComponent
collapsed
locations={locations}
locationListItemIcon={() => <LocationOnIcon />}
/>
</InfoCard>
</Grid>
<Grid item xs={12} md={6} lg={4}>
<InfoCard
title="EntityListComponent (clickable)"
variant="gridItem"
>
<EntityListComponent
locations={locations}
locationListItemIcon={() => <LocationOnIcon />}
withLinks
/>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
),
})
.render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

+3 -1
View File
@@ -31,6 +31,7 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.7.1",
"@backstage/catalog-client": "^0.3.5",
"@backstage/core": "^0.6.0",
"@backstage/integration": "^0.3.2",
"@backstage/plugin-catalog-react": "^0.0.2",
@@ -39,6 +40,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.0.12",
"@types/react": "^16.9",
"git-url-parse": "^11.4.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -49,12 +51,12 @@
"yaml": "^1.10.0"
},
"devDependencies": {
"@backstage/catalog-client": "^0.3.5",
"@backstage/cli": "^0.6.0",
"@backstage/dev-utils": "^0.1.9",
"@backstage/test-utils": "^0.1.6",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/react-hooks": "^3.3.0",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
@@ -14,29 +14,38 @@
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
import { createApiRef } from '@backstage/core';
import { PartialEntity } from '../util/types';
import { GitHubIntegrationConfig } from '@backstage/integration';
import { PartialEntity } from '../types';
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
id: 'plugin.catalog-import.service',
description: 'Used by the catalog import plugin to make requests',
});
// result of the analyze state
export type AnalyzeResult =
| {
type: 'locations';
locations: Array<{
target: string;
entities: EntityName[];
}>;
}
| {
type: 'repository';
url: string;
integrationType: string;
generatedEntities: PartialEntity[];
};
export interface CatalogImportApi {
submitPrToRepo(options: {
owner: string;
repo: string;
analyzeUrl(url: string): Promise<AnalyzeResult>;
submitPullRequest(options: {
repositoryUrl: string;
fileContent: string;
githubIntegrationConfig: GitHubIntegrationConfig;
title: string;
body: string;
}): Promise<{ link: string; location: string }>;
checkForExistingCatalogInfo(options: {
owner: string;
repo: string;
githubIntegrationConfig: GitHubIntegrationConfig;
}): Promise<{ exists: boolean; url?: string }>;
createRepositoryLocation(options: { location: string }): Promise<void>;
generateEntityDefinitions(options: {
repo: string;
}): Promise<PartialEntity[]>;
}
@@ -14,37 +14,64 @@
* limitations under the License.
*/
import { CatalogImportClient } from './CatalogImportClient';
const octokit = {
repos: {
get: () => Promise.resolve({ data: { default_branch: 'main' } }),
createOrUpdateFileContents: jest.fn().mockImplementation(async () => {}),
},
search: {
code: jest.fn(),
},
git: {
getRef: async () => ({
data: { object: { sha: 'any' } },
}),
createRef: jest.fn().mockImplementation(async () => {}),
},
pulls: {
create: jest.fn().mockImplementation(async () => ({
data: {
html_url: 'http://pull/request/0',
},
})),
},
};
jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => {
return {
repos: {
get: () =>
Promise.resolve({
data: {
default_branch: 'main',
},
}),
},
search: {
code: () =>
Promise.resolve({
data: {
total_count: 2,
items: [
{ path: 'simple/path/catalog-info.yaml' },
{ path: 'co/mple/x/path/catalog-info.yaml' },
{ path: 'catalog-info.yaml' },
],
},
}),
},
};
}),
jest.doMock('@octokit/rest', () => {
class Octokit {
constructor() {
return octokit;
}
}
return { Octokit };
});
// Mock the value to control which integrations are activated
jest.mock('./GitHub', () => ({
getGithubIntegrationConfig: jest.fn(),
}));
import { ConfigReader, OAuthApi, UrlPatternDiscovery } from '@backstage/core';
import { GitHubIntegrationConfig } from '@backstage/integration';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { msw } from '@backstage/test-utils';
import { Octokit } from '@octokit/rest';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogImportClient } from './CatalogImportClient';
import { getGithubIntegrationConfig } from './GitHub';
const server = setupServer();
describe('CatalogImportClient', () => {
msw.setupDefaultHandlers(server);
const mockBaseUrl = 'http://backstage:9191/api/catalog';
const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
const githubAuthApi: jest.Mocked<OAuthApi> = {
getAccessToken: jest.fn(),
};
const identityApi = {
getUserId: () => {
return 'user';
@@ -59,21 +86,259 @@ describe('CatalogImportClient', () => {
return Promise.resolve();
},
};
describe('checkForExistingCatalogInfo', () => {
const cic = new CatalogImportClient({
discoveryApi: { getBaseUrl: () => Promise.resolve('base') },
githubAuthApi: { getAccessToken: (_, __) => Promise.resolve('token') },
const configApi = new ConfigReader({});
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
getEntities: jest.fn(),
addLocation: jest.fn(),
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
};
let catalogImportClient: CatalogImportClient;
let getGithubIntegrationConfigFn: jest.Mock;
beforeEach(() => {
catalogImportClient = new CatalogImportClient({
discoveryApi,
githubAuthApi,
configApi,
identityApi,
configApi: {} as any,
catalogApi,
});
it('should return the closest-to-root catalog-info from multiple responses', async () => {
const respo = await cic.checkForExistingCatalogInfo({
owner: 'test-user',
repo: 'rest-repo',
githubIntegrationConfig: { host: 'https://github.com' },
getGithubIntegrationConfigFn = getGithubIntegrationConfig as jest.Mock;
getGithubIntegrationConfigFn.mockReset();
});
describe('analyzeUrl', () => {
it('should add yaml location', async () => {
catalogApi.addLocation.mockResolvedValueOnce({
location: {
id: 'id-0',
type: 'url',
target: 'http://example.com/folder/catalog-info.yaml',
},
entities: [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'my-entity',
namespace: 'my-namespace',
},
},
],
});
await expect(
catalogImportClient.analyzeUrl(
'http://example.com/folder/catalog-info.yaml',
),
).resolves.toEqual({
locations: [
{
entities: [
{
kind: 'Component',
name: 'my-entity',
namespace: 'my-namespace',
},
],
target: 'http://example.com/folder/catalog-info.yaml',
},
],
type: 'locations',
});
expect(catalogApi.addLocation).toBeCalledTimes(1);
expect(catalogApi.addLocation.mock.calls[0][0]).toEqual({
type: 'url',
target: 'http://example.com/folder/catalog-info.yaml',
dryRun: true,
});
});
it('should ignore missing github integration', async () => {
await expect(
catalogImportClient.analyzeUrl(
'https://github.com/backstage/backstage',
),
).rejects.toThrow(new Error('Invalid url'));
});
it('should find locations from github', async () => {
getGithubIntegrationConfigFn.mockReturnValue({
repo: 'backstage',
owner: 'backstage',
githubIntegrationConfig: {} as GitHubIntegrationConfig,
});
((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({
data: {
total_count: 2,
items: [
{ path: 'simple/path/catalog-info.yaml' },
{ path: 'co/mple/x/path/catalog-info.yaml' },
{ path: 'catalog-info.yaml' },
],
},
});
catalogApi.addLocation.mockImplementation(async ({ type, target }) => ({
location: {
id: 'id-0',
type: type ?? 'url',
target,
},
entities: [
{
apiVersion: '1',
kind: 'k',
metadata: {
name: 'e',
namespace: 'n',
},
},
],
}));
await expect(
catalogImportClient.analyzeUrl(
'https://github.com/backstage/backstage',
),
).resolves.toEqual({
locations: [
{
entities: [{ kind: 'k', name: 'e', namespace: 'n' }],
target:
'https://github.com/backstage/backstage/blob/main/simple/path/catalog-info.yaml',
},
{
entities: [{ kind: 'k', name: 'e', namespace: 'n' }],
target:
'https://github.com/backstage/backstage/blob/main/co/mple/x/path/catalog-info.yaml',
},
{
entities: [{ kind: 'k', name: 'e', namespace: 'n' }],
target:
'https://github.com/backstage/backstage/blob/main/catalog-info.yaml',
},
],
type: 'locations',
});
});
it('should find repository from github', async () => {
getGithubIntegrationConfigFn.mockReturnValue({
repo: 'backstage',
owner: 'backstage',
githubIntegrationConfig: {} as GitHubIntegrationConfig,
});
((new Octokit().search.code as any) as jest.Mock).mockResolvedValueOnce({
data: { total_count: 0, items: [] },
});
server.use(
rest.post(`${mockBaseUrl}/analyze-location`, (req, res, ctx) => {
expect(req.body).toEqual({
location: {
target: 'https://github.com/backstage/backstage',
type: 'url',
},
});
return res(
ctx.json({
generateEntities: [
{
entity: {
kind: 'k',
metadata: { name: 'e', namespace: 'n' },
},
fields: [],
},
],
existingEntityFiles: [],
}),
);
}),
);
await expect(
catalogImportClient.analyzeUrl(
'https://github.com/backstage/backstage',
),
).resolves.toEqual({
type: 'repository',
url: 'https://github.com/backstage/backstage',
integrationType: 'github',
generatedEntities: [
{
kind: 'k',
metadata: { name: 'e', namespace: 'n' },
},
],
});
});
});
describe('submitPullRequest', () => {
it('should create GitHub pull request', async () => {
getGithubIntegrationConfigFn.mockReturnValue({
repo: 'backstage',
owner: 'backstage',
githubIntegrationConfig: {
host: 'github.com',
} as GitHubIntegrationConfig,
});
await expect(
catalogImportClient.submitPullRequest({
repositoryUrl: 'https://github.com/backstage/backstage',
fileContent: 'some content',
title: 'A title',
body: 'A body',
}),
).resolves.toEqual({
link: 'http://pull/request/0',
location:
'https://github.com/backstage/backstage/blob/main/catalog-info.yaml',
});
expect(
((new Octokit().git.createRef as any) as jest.Mock).mock.calls[0][0],
).toEqual({
owner: 'backstage',
repo: 'backstage',
ref: 'refs/heads/backstage-integration',
sha: 'any',
});
expect(
((new Octokit().repos.createOrUpdateFileContents as any) as jest.Mock)
.mock.calls[0][0],
).toEqual({
owner: 'backstage',
repo: 'backstage',
path: 'catalog-info.yaml',
message: 'Add catalog-info.yaml config file',
content: 'c29tZSBjb250ZW50',
branch: 'backstage-integration',
});
expect(
((new Octokit().pulls.create as any) as jest.Mock).mock.calls[0][0],
).toEqual({
owner: 'backstage',
repo: 'backstage',
title: 'A title',
head: 'backstage-integration',
body: 'A body',
base: 'main',
});
expect(respo.exists).toBe(true);
expect(respo.url).toBe('blob/main/catalog-info.yaml');
});
});
});
@@ -14,36 +14,120 @@
* limitations under the License.
*/
import { Octokit } from '@octokit/rest';
import { CatalogApi } from '@backstage/catalog-client';
import { EntityName } from '@backstage/catalog-model';
import {
ConfigApi,
DiscoveryApi,
IdentityApi,
OAuthApi,
ConfigApi,
} from '@backstage/core';
import { CatalogImportApi } from './CatalogImportApi';
import { PartialEntity } from '../util/types';
import { GitHubIntegrationConfig } from '@backstage/integration';
import { Octokit } from '@octokit/rest';
import { PartialEntity } from '../types';
import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi';
import { getGithubIntegrationConfig } from './GitHub';
export class CatalogImportClient implements CatalogImportApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
private readonly githubAuthApi: OAuthApi;
private readonly configApi: ConfigApi;
private readonly catalogApi: CatalogApi;
constructor(options: {
discoveryApi: DiscoveryApi;
githubAuthApi: OAuthApi;
identityApi: IdentityApi;
configApi: ConfigApi;
catalogApi: CatalogApi;
}) {
this.discoveryApi = options.discoveryApi;
this.githubAuthApi = options.githubAuthApi;
this.identityApi = options.identityApi;
this.configApi = options.configApi;
this.catalogApi = options.catalogApi;
}
async generateEntityDefinitions({
async analyzeUrl(url: string): Promise<AnalyzeResult> {
if (url.match(/\.ya?ml$/)) {
const location = await this.catalogApi.addLocation({
type: 'url',
target: url,
dryRun: true,
});
return {
type: 'locations',
locations: [
{
target: location.location.target,
entities: location.entities.map(e => ({
kind: e.kind,
namespace: e.metadata.namespace ?? 'default',
name: e.metadata.name,
})),
},
],
};
}
const ghConfig = getGithubIntegrationConfig(this.configApi, url);
if (ghConfig) {
// TODO: this could be part of the analyze-location endpoint
const locations = await this.checkGitHubForExistingCatalogInfo({
...ghConfig,
url,
});
if (locations.length > 0) {
return {
type: 'locations',
locations,
};
}
return {
type: 'repository',
integrationType: 'github',
url: url,
generatedEntities: await this.generateEntityDefinitions({
repo: url,
}),
};
}
throw new Error('Invalid url');
}
async submitPullRequest({
repositoryUrl,
fileContent,
title,
body,
}: {
repositoryUrl: string;
fileContent: string;
title: string;
body: string;
}): Promise<{ link: string; location: string }> {
const ghConfig = getGithubIntegrationConfig(this.configApi, repositoryUrl);
if (ghConfig) {
return await this.submitGitHubPrToRepo({
...ghConfig,
fileContent,
title,
body,
});
}
throw new Error('unimplemented!');
}
// TODO: this could be part of the catalog api
private async generateEntityDefinitions({
repo,
}: {
repo: string;
@@ -108,15 +192,23 @@ export class CatalogImportClient implements CatalogImportApi {
}
}
async checkForExistingCatalogInfo({
// TODO: this response should better be part of the analyze-locations response and scm-independent / implemented per scm
private async checkGitHubForExistingCatalogInfo({
url,
owner,
repo,
githubIntegrationConfig,
}: {
url: string;
owner: string;
repo: string;
githubIntegrationConfig: GitHubIntegrationConfig;
}): Promise<{ exists: boolean; url?: string }> {
}): Promise<
Array<{
target: string;
entities: EntityName[];
}>
> {
const token = await this.githubAuthApi.getAccessToken(['repo']);
const octo = new Octokit({
auth: token,
@@ -140,28 +232,50 @@ export class CatalogImportClient implements CatalogImportApi {
});
const defaultBranch = repoInformation.data.default_branch;
// Github search sorts returned values with 'best match' using 'multiple factors to boost the most relevant item',
// aka magic.
// Sorting to use the shortest item, closest to the repository root.
const catalogInfoItem = searchResult.data.items
.map(it => it.path)
.sort((a, b) => a.length - b.length)[0];
return {
url: `blob/${defaultBranch}/${catalogInfoItem}`,
exists,
};
return await Promise.all(
searchResult.data.items
.map(
i => `${url.replace(/[\/]*$/, '')}/blob/${defaultBranch}/${i.path}`,
)
.map(
async i =>
({
target: i,
entities: (
await this.catalogApi.addLocation({
type: 'url',
target: i,
dryRun: true,
})
).entities.map(e => ({
kind: e.kind,
namespace: e.metadata.namespace ?? 'default',
name: e.metadata.name,
})),
} as {
target: string;
entities: EntityName[];
}),
),
);
}
return { exists };
return [];
}
async submitPrToRepo({
// TODO: extract this function and implement for non-github
private async submitGitHubPrToRepo({
owner,
repo,
title,
body,
fileContent,
githubIntegrationConfig,
}: {
owner: string;
repo: string;
title: string;
body: string;
fileContent: string;
githubIntegrationConfig: GitHubIntegrationConfig;
}): Promise<{ link: string; location: string }> {
@@ -230,23 +344,13 @@ export class CatalogImportClient implements CatalogImportApi {
);
});
const appTitle =
this.configApi.getOptionalString('app.title') ?? 'Backstage';
const appBaseUrl = this.configApi.getString('app.baseUrl');
const prBody = `This pull request adds a **Backstage entity metadata file** \
to this repository so that the component can be added to the \
[${appTitle} software catalog](${appBaseUrl}).\n\nAfter this pull request is merged, \
the component will become available.\n\nFor more information, read an \
[overview of the Backstage software catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview).`;
const pullRequestResponse = await octo.pulls
.create({
owner,
repo,
title: `Add ${fileName} config file`,
title,
head: branchName,
body: prBody,
body,
base: repoData.data.default_branch,
})
.catch(e => {
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigApi } from '@backstage/core';
import { ScmIntegrations } from '@backstage/integration';
import parseGitUrl from 'git-url-parse';
export const getGithubIntegrationConfig = (
config: ConfigApi,
location: string,
) => {
const { name: repo, owner } = parseGitUrl(location);
const scmIntegrations = ScmIntegrations.fromConfig(config);
const githubIntegrationConfig = scmIntegrations.github.byUrl(location);
if (!githubIntegrationConfig) {
return undefined;
}
return {
repo,
owner,
githubIntegrationConfig: githubIntegrationConfig.config,
};
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,70 @@
/*
* Copyright 2021 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 { Button, CircularProgress } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React, { ComponentProps } from 'react';
const useStyles = makeStyles(theme => ({
wrapper: {
marginTop: theme.spacing(1),
marginRight: theme.spacing(1),
position: 'relative',
},
buttonProgress: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12,
},
button: {
marginTop: theme.spacing(1),
marginRight: theme.spacing(1),
},
}));
export const NextButton = (
props: ComponentProps<typeof Button> & { loading?: boolean },
) => {
const { loading, ...buttonProps } = props;
const classes = useStyles();
return (
<div className={classes.wrapper}>
<Button
color="primary"
variant="contained"
{...buttonProps}
disabled={props.disabled || props.loading}
/>
{props.loading && (
<CircularProgress size="1.5rem" className={classes.buttonProgress} />
)}
{props.loading}
</div>
);
};
export const BackButton = (props: ComponentProps<typeof Button>) => {
const classes = useStyles();
return (
<Button variant="outlined" className={classes.button} {...props}>
{props.children || 'Back'}
</Button>
);
};
@@ -1,200 +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 { Entity } from '@backstage/catalog-model';
import {
errorApiRef,
RouteRef,
StructuredMetadataTable,
useApi,
} from '@backstage/core';
import {
entityRoute,
entityRouteParams,
} from '@backstage/plugin-catalog-react';
import {
Button,
CircularProgress,
Divider,
Grid,
Link,
List,
ListItem,
Typography,
} from '@material-ui/core';
import React, { useCallback, useState } from 'react';
import { generatePath, resolvePath } from 'react-router';
import { Link as RouterLink } from 'react-router-dom';
import * as YAML from 'yaml';
import { PartialEntity } from '../util/types';
import { urlType } from '../util/urls';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
const getEntityCatalogPath = ({
entity,
catalogRouteRef,
}: {
entity: PartialEntity;
catalogRouteRef: RouteRef;
}) => {
const relativeEntityPathInsideCatalog = generatePath(
entityRoute.path,
entityRouteParams(entity as Entity),
);
const resolvedAbsolutePath = resolvePath(
relativeEntityPathInsideCatalog,
catalogRouteRef.path,
)?.pathname;
return resolvedAbsolutePath;
};
type Props = {
nextStep: (options?: { reset: boolean }) => void;
configFile: ConfigSpec;
savePRLink: (PRLink: string) => void;
catalogRouteRef: RouteRef;
};
const ComponentConfigDisplay = ({
nextStep,
configFile,
savePRLink,
catalogRouteRef,
}: Props) => {
const [errorOccurred, setErrorOccurred] = useState(false);
const [submitting, setSubmitting] = useState(false);
const errorApi = useApi(errorApiRef);
const { submitPrToRepo, addLocation } = useGithubRepos();
const onNext = useCallback(async () => {
try {
setSubmitting(true);
if (urlType(configFile.location) === 'tree') {
const result = await submitPrToRepo(configFile);
savePRLink(result.link);
setSubmitting(false);
nextStep();
} else {
addLocation(configFile.location);
setSubmitting(false);
nextStep();
}
} catch (e) {
setErrorOccurred(true);
setSubmitting(false);
errorApi.post(e);
}
}, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]);
return (
<Grid container direction="column" spacing={1}>
{urlType(configFile.location) === 'tree' ? (
<Typography>
Following config object will be submitted in a pull request to the
repository{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>{' '}
and added as a new location to the backend
</Typography>
) : (
<Typography>
Following config object will be added as a new location to the backend{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>
</Typography>
)}
<Grid item>
{urlType(configFile.location) === 'tree' ? (
<pre>{YAML.stringify(configFile.config)}</pre>
) : (
<List>
{configFile.config.map((entity: any, index: number) => {
const entityPath = getEntityCatalogPath({
entity,
catalogRouteRef,
});
return (
<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={entityPath}>
{entityPath}
</Link>
),
}}
/>
</ListItem>
{index < configFile.config.length - 1 && (
<Divider component="li" />
)}
</React.Fragment>
);
})}
</List>
)}
</Grid>
<Grid item container spacing={1}>
{submitting ? (
<Grid item>
<CircularProgress size="2rem" />
</Grid>
) : null}
<Grid item>
<Button
disabled={submitting}
variant="contained"
color="primary"
onClick={onNext}
>
Next
</Button>
{errorOccurred ? (
<Button
style={{ marginLeft: '8px' }}
variant="outlined"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Start again
</Button>
) : null}
</Grid>
</Grid>
</Grid>
);
};
export default ComponentConfigDisplay;
@@ -0,0 +1,165 @@
/*
* Copyright 2021 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 { Entity, EntityName } from '@backstage/catalog-model';
import {
EntityRefLink,
formatEntityRefTitle,
} from '@backstage/plugin-catalog-react';
import {
Collapse,
IconButton,
List,
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import ApartmentIcon from '@material-ui/icons/Apartment';
import CategoryIcon from '@material-ui/icons/Category';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExtensionIcon from '@material-ui/icons/Extension';
import GroupIcon from '@material-ui/icons/Group';
import LocationOnIcon from '@material-ui/icons/LocationOn';
import MemoryIcon from '@material-ui/icons/Memory';
import PersonIcon from '@material-ui/icons/Person';
import WorkIcon from '@material-ui/icons/Work';
import React, { useState } from 'react';
const useStyles = makeStyles(theme => ({
nested: {
paddingLeft: theme.spacing(4),
},
}));
function sortEntities(entities: Array<EntityName | Entity>) {
return entities.sort((a, b) =>
formatEntityRefTitle(a).localeCompare(formatEntityRefTitle(b)),
);
}
function getEntityIcon(entity: { kind: string }): React.ReactElement {
switch (entity.kind.toLowerCase()) {
case 'api':
return <ExtensionIcon />;
case 'component':
return <MemoryIcon />;
case 'domain':
return <ApartmentIcon />;
case 'group':
return <GroupIcon />;
case 'location':
return <LocationOnIcon />;
case 'system':
return <CategoryIcon />;
case 'user':
return <PersonIcon />;
default:
return <WorkIcon />;
}
}
type Props = {
locations: Array<{ target: string; entities: (Entity | EntityName)[] }>;
locationListItemIcon: (target: string) => React.ReactElement;
collapsed?: boolean;
firstListItem?: React.ReactElement;
onItemClick?: (target: string) => void;
withLinks?: boolean;
};
export const EntityListComponent = ({
locations,
collapsed = false,
locationListItemIcon,
onItemClick,
firstListItem,
withLinks = false,
}: Props) => {
const classes = useStyles();
const [expandedUrls, setExpandedUrls] = useState<string[]>([]);
const handleClick = (url: string) => {
setExpandedUrls(urls =>
urls.includes(url) ? urls.filter(u => u !== url) : urls.concat(url),
);
};
return (
<List>
{firstListItem}
{locations.map(r => (
<React.Fragment key={r.target}>
<ListItem
dense
button={Boolean(onItemClick) as any}
onClick={() => onItemClick?.call(this, r.target)}
>
<ListItemIcon>{locationListItemIcon(r.target)}</ListItemIcon>
<ListItemText
primary={r.target}
secondary={`Entities: ${r.entities.length}`}
/>
{collapsed && (
<ListItemSecondaryAction>
<IconButton edge="end" onClick={() => handleClick(r.target)}>
{expandedUrls.includes(r.target) ? (
<ExpandLessIcon />
) : (
<ExpandMoreIcon />
)}
</IconButton>
</ListItemSecondaryAction>
)}
</ListItem>
<Collapse
in={!collapsed || expandedUrls.includes(r.target)}
timeout="auto"
unmountOnExit
>
<List component="div" disablePadding dense>
{sortEntities(r.entities).map(entity => (
<ListItem
component={withLinks ? EntityRefLink : 'div'}
entityRef={withLinks ? entity : undefined}
button={withLinks as any}
key={formatEntityRefTitle(entity)}
className={classes.nested}
>
<ListItemIcon>{getEntityIcon(entity)}</ListItemIcon>
<ListItemText primary={formatEntityRefTitle(entity)} />
</ListItem>
))}
</List>
</Collapse>
</React.Fragment>
))}
</List>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,8 +14,4 @@
* limitations under the License.
*/
export const ComponentIdValidators = {
httpsValidator: (value: any) =>
(typeof value === 'string' && value.match(/^https:\/\//) !== null) ||
'Must start with https://.',
};
export { EntityListComponent } from './EntityListComponent';
@@ -1,106 +0,0 @@
/*
* Copyright 2021 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 { CatalogClient } from '@backstage/catalog-client';
import {
ApiProvider,
ApiRegistry,
DiscoveryApi,
errorApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { catalogImportApiRef, CatalogImportClient } from '../api';
import { RegisterComponentForm } from './ImportComponentForm';
describe('<RegisterComponentForm />', () => {
let apis: ApiRegistry;
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
const identityApi = {
getUserId: () => {
return 'user';
},
getProfile: () => {
return {};
},
getIdToken: () => {
return Promise.resolve('token');
},
signOut: () => {
return Promise.resolve();
},
};
beforeEach(() => {
apis = ApiRegistry.from([
[catalogApiRef, new CatalogClient({ discoveryApi: {} as DiscoveryApi })],
[
catalogImportApiRef,
new CatalogImportClient({
discoveryApi: { getBaseUrl: () => Promise.resolve('base') },
githubAuthApi: {
getAccessToken: (_, __) => Promise.resolve('token'),
},
identityApi,
configApi: {} as any,
}),
],
[errorApiRef, mockErrorApi],
]);
});
async function renderSUT(
nextStep: () => void = () => {},
saveConfig: () => void = () => {},
) {
return await renderInTestApp(
<ApiProvider apis={apis}>
<RegisterComponentForm
nextStep={nextStep}
saveConfig={saveConfig}
repository="GitHub"
/>
</ApiProvider>,
);
}
it('Renders without exploding', async () => {
await renderSUT();
expect(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
).toBeInTheDocument();
});
it('Should have basic URL validation for input', async () => {
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{ target: { value: 'not a url' } },
);
});
await waitFor(() => {
fireEvent.click(screen.getByText('Next'));
});
expect(screen.getByText('Must start with https://.')).toBeInTheDocument();
});
});
@@ -1,154 +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 { errorApiRef, useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
FormControl,
FormHelperText,
TextField,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import { useForm } from 'react-hook-form';
import { useMountedState } from 'react-use';
import { urlType } from '../util/urls';
import { useGithubRepos } from '../util/useGithubRepos';
import { ComponentIdValidators } from '../util/validate';
import { ConfigSpec } from './ImportComponentPage';
const useStyles = makeStyles<BackstageTheme>(theme => ({
form: {
alignItems: 'flex-start',
display: 'flex',
flexFlow: 'column nowrap',
},
submit: {
marginTop: theme.spacing(1),
},
}));
type Props = {
nextStep: () => void;
saveConfig: (configFile: ConfigSpec) => void;
repository: string;
};
export const RegisterComponentForm = ({
nextStep,
saveConfig,
repository,
}: Props) => {
const { register, handleSubmit, errors, formState } = useForm({
mode: 'onChange',
});
const classes = useStyles();
const hasErrors = !!errors.componentLocation;
const dirty = formState?.isDirty;
const catalogApi = useApi(catalogApiRef);
const isMounted = useMountedState();
const errorApi = useApi(errorApiRef);
const {
generateEntityDefinitions,
checkForExistingCatalogInfo,
} = useGithubRepos();
const onSubmit = async (formData: Record<string, string>) => {
const { componentLocation: target } = formData;
async function saveCatalogFileConfig(targetString: string) {
const data = await catalogApi.addLocation({ target: targetString });
saveConfig({
type: 'file',
location: data.location.target,
config: data.entities,
});
}
async function trySaveRepositoryConfig(targetString: string) {
const existingCatalog = await checkForExistingCatalogInfo(targetString);
if (existingCatalog.exists) {
const targetUrl = targetString.endsWith('/')
? `${targetString}${existingCatalog.url}`
: `${targetString}/${existingCatalog.url}`;
await saveCatalogFileConfig(targetUrl);
} else {
saveConfig({
type: 'tree',
location: target,
config: await generateEntityDefinitions(target),
});
}
}
try {
if (!isMounted()) return;
const type = urlType(target);
if (type === 'tree') {
await trySaveRepositoryConfig(target);
} else {
await saveCatalogFileConfig(target);
}
nextStep();
} catch (e) {
errorApi.post(e);
}
};
return (
<form
autoComplete="off"
onSubmit={handleSubmit(onSubmit)}
className={classes.form}
>
<FormControl>
<TextField
id="registerComponentInput"
variant="outlined"
label="Repository URL"
error={hasErrors}
placeholder="https://github.com/backstage/backstage"
name="componentLocation"
required
margin="normal"
helperText={`Enter the full path to the repository in ${repository} to start tracking your component.`}
inputRef={register({
required: true,
validate: ComponentIdValidators,
})}
/>
{errors.componentLocation && (
<FormHelperText error={hasErrors} id="register-component-helper-text">
{errors.componentLocation.message}
</FormHelperText>
)}
</FormControl>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!dirty || hasErrors}
className={classes.submit}
>
Next
</Button>
</form>
);
};
@@ -13,63 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogClient } from '@backstage/catalog-client';
import {
ApiProvider,
ApiRegistry,
configApiRef,
errorApiRef,
ConfigReader,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { msw, renderInTestApp } from '@backstage/test-utils';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { wrapInTestApp } from '@backstage/test-utils';
import { act, render } from '@testing-library/react';
import React from 'react';
import { catalogImportApiRef, CatalogImportClient } from '../api';
import { ImportComponentPage } from './ImportComponentPage';
let codeSearchMockResponse: () => Promise<{
data: {
total_count: number;
items: Array<{ path: string }>;
};
}>;
let findGithubConfigMockResponse = () => ({
host: 'test.localhost',
owner: 'someuser',
});
jest.mock('@backstage/integration', () => ({
readGitHubIntegrationConfigs: () => ({
find: findGithubConfigMockResponse,
}),
}));
jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => {
return {
repos: {
get: () =>
Promise.resolve({
data: {
default_branch: 'main',
},
}),
},
search: {
code: codeSearchMockResponse,
},
};
}),
}));
const OUR_GITHUB_TEST_REPO = 'https://github.com/someuser/somerepo';
describe('<ImportComponentPage />', () => {
const server = setupServer();
msw.setupDefaultHandlers(server);
const identityApi = {
getUserId: () => {
return 'user';
@@ -85,198 +44,41 @@ describe('<ImportComponentPage />', () => {
},
};
beforeEach(() => {
server.use(
rest.post('https://backend.localhost/locations', (_, res, ctx) => {
return res(
ctx.status(201),
ctx.json(require('../mocks/locations-POST-response.json')),
);
}),
rest.post('https://backend.localhost/analyze-location', (_, res, ctx) => {
return res(
ctx.json(require('../mocks/analyze-location-POST-response.json')),
);
}),
);
});
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
let apis: ApiRegistry;
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
beforeEach(() => {
const getBaseUrl = () => Promise.resolve('https://backend.localhost');
apis = ApiRegistry.from([
[
catalogApiRef,
new CatalogClient({
discoveryApi: { getBaseUrl },
}),
],
[
apis = ApiRegistry.with(
configApiRef,
new ConfigReader({ integrations: {} }),
)
.with(catalogApiRef, new CatalogClient({ discoveryApi: {} as any }))
.with(
catalogImportApiRef,
new CatalogImportClient({
discoveryApi: { getBaseUrl },
discoveryApi: {} as any,
githubAuthApi: {
getAccessToken: (_, __) => Promise.resolve('token'),
},
identityApi,
configApi: {} as any,
catalogApi: {} as any,
}),
],
[
configApiRef,
{
getOptional: () => 'Title',
getOptionalConfigArray: () => [],
has: () => true,
getConfig: () => ({
has: () => true,
}),
},
],
[errorApiRef, mockErrorApi],
]);
);
});
async function renderSUT() {
return await renderInTestApp(
<ApiProvider apis={apis}>
<ImportComponentPage catalogRouteRef={{ path: 'path', title: 'ttl' }} />
</ApiProvider>,
);
}
it('Should not explode on non-Github URLs', async () => {
findGithubConfigMockResponse = () => undefined!!;
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{
target: {
value: 'https://test-git-provider.localhost/someuser/somerepo',
},
},
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
const firstStepInput = screen.queryByPlaceholderText(
'https://github.com/backstage/backstage',
);
expect(firstStepInput).not.toBeInTheDocument();
});
});
it('Should offer direct file import from non-Github URLs', async () => {
findGithubConfigMockResponse = () => undefined!!;
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{
target: {
value:
'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml',
},
},
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
const firstStepInput = screen.queryByPlaceholderText(
'https://github.com/backstage/backstage',
);
expect(firstStepInput).not.toBeInTheDocument();
});
expect(
screen.getByText(
'https://test-git-provider.localhost/someuser/somerepo/catalog-info.yaml',
),
).toBeInTheDocument();
});
it('Should use found yaml file directly and not create a pull request if GitHub api returns one', async () => {
findGithubConfigMockResponse = () => ({
host: 'test.localhost',
owner: 'someuser',
});
codeSearchMockResponse = () =>
Promise.resolve({
data: {
total_count: 3,
items: [
{ path: 'simple/path/catalog-info.yaml' },
{ path: 'co/mple/x/path/catalog-info.yaml' },
{ path: 'catalog-info.yaml' },
],
},
});
await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{ target: { value: OUR_GITHUB_TEST_REPO } },
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
expect(
screen.getByText(
'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml',
it('renders without exploding', async () => {
await act(async () => {
const { getByText } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ImportComponentPage />
</ApiProvider>,
),
).toBeInTheDocument();
const pullReqText = screen.queryByText('pull request');
expect(pullReqText).not.toBeInTheDocument();
});
});
it('Should indicate a pull request creation when no yaml file found in the repo', async () => {
findGithubConfigMockResponse = () => ({
host: 'test.localhost',
owner: 'someuser',
});
codeSearchMockResponse = () =>
Promise.resolve({
data: {
total_count: 0,
items: [],
},
});
const { container } = await renderSUT();
await waitFor(() => {
fireEvent.input(
screen.getByPlaceholderText('https://github.com/backstage/backstage'),
{ target: { value: OUR_GITHUB_TEST_REPO } },
);
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
expect(screen.getByText(OUR_GITHUB_TEST_REPO)).toBeInTheDocument();
expect(
await getByText('Start tracking your component in Backstage'),
).toBeInTheDocument();
});
const textNode = container
.querySelector('a[href="https://github.com/someuser/somerepo"]')
?.closest('p');
expect(textNode?.innerHTML).toContain(
'Following config object will be submitted in a pull request to the repository',
);
expect(
screen.queryByText(
'https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml',
),
).not.toBeInTheDocument();
});
});
@@ -14,35 +14,21 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import {
ConfigApi,
configApiRef,
Content,
ContentHeader,
Header,
InfoCard,
Page,
Content,
Header,
SupportButton,
ContentHeader,
RouteRef,
useApi,
configApiRef,
ConfigApi,
} from '@backstage/core';
import { RegisterComponentForm } from './ImportComponentForm';
import ImportStepper from './ImportStepper';
import ComponentConfigDisplay from './ComponentConfigDisplay';
import { ImportFinished } from './ImportFinished';
import { PartialEntity } from '../util/types';
export type ConfigSpec = {
type: 'tree' | 'file';
location: string;
config: PartialEntity[];
};
function manifestGenerationAvailable(configApi: ConfigApi): boolean {
return configApi.has('integrations.github');
}
import { Grid, Typography } from '@material-ui/core';
import React from 'react';
import { ImportStepper } from './ImportStepper';
import { StepperProviderOpts } from './ImportStepper/defaults';
function repositories(configApi: ConfigApi): string[] {
const integrations = configApi.getConfig('integrations');
@@ -63,25 +49,16 @@ function repositories(configApi: ConfigApi): string[] {
}
export const ImportComponentPage = ({
catalogRouteRef,
opts,
}: {
catalogRouteRef: RouteRef;
opts?: StepperProviderOpts;
}) => {
const [activeStep, setActiveStep] = useState(0);
const [configFile, setConfigFile] = useState<ConfigSpec>({
type: 'tree',
location: '',
config: [],
});
const [endLink, setEndLink] = useState<string>('');
const nextStep = (options?: { reset: boolean }) => {
setActiveStep(step => (options?.reset ? 0 : step + 1));
};
const configApi = useApi(configApiRef);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const repos = repositories(configApi);
const repositoryString = repos.join(', ').replace(/, (\w*)$/, ' or $1');
return (
<Page themeId="home">
<Header title="Register an existing component" />
@@ -92,9 +69,11 @@ export const ImportComponentPage = ({
software catalog.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="row-reverse">
<Grid item xs={6}>
<Grid container spacing={2} direction="row-reverse">
<Grid item xs={12} md={4} lg={6} xl={8}>
<InfoCard
title="Register an existing component"
deepLink={{
title: 'Learn more about the Software Catalog',
link:
@@ -102,74 +81,54 @@ export const ImportComponentPage = ({
}}
>
<Typography variant="body2" paragraph>
Ways to register an existing component
Enter the URL to your SCM repository to add it to {appTitle}.
</Typography>
{manifestGenerationAvailable(configApi) && (
<React.Fragment>
<Typography variant="h6">GitHub Repo</Typography>
<Typography variant="body2" paragraph>
If you already have code in a GitHub repository without
Backstage metadata file set up for it, enter the full URL to
your repo and a new pull request with a sample Backstage
metadata Entity File (<code>catalog-info.yaml</code>) will
be opened for you.
</Typography>
</React.Fragment>
)}
<Typography variant="h6">
{repos.length === 1 ? `${repos[0]} ` : ''} Repository &amp;
Entity File
Link to an existing entity file
</Typography>
<Typography variant="subtitle2" color="textSecondary" paragraph>
Example:{' '}
<code>
https://github.com/backstage/backstage/blob/master/catalog-info.yaml
</code>
</Typography>
<Typography variant="body2" paragraph>
If you've already created a {appTitle} metadata file and put it
in your {repositoryString} repository, you can enter the full
URL to that Entity File.
The wizard analyzes the file, previews the entities, and adds
them to the {appTitle} catalog.
</Typography>
{repos.length > 0 && (
<>
<Typography variant="h6">
Link to a {repositoryString} repository
</Typography>
<Typography
variant="subtitle2"
color="textSecondary"
paragraph
>
Example: <code>https://github.com/backstage/backstage</code>
</Typography>
<Typography variant="body2" paragraph>
The wizard discovers all <code>catalog-info.yaml</code>{' '}
files in the repository, previews the entities, and adds
them to the {appTitle} catalog.
</Typography>
{!opts?.pullRequest?.disable && (
<Typography variant="body2" paragraph>
If no entities are found, the wizard will prepare a Pull
Request that adds an example{' '}
<code>catalog-info.yaml</code> and prepares the {appTitle}{' '}
catalog to load all entities as soon as the Pull Request
is merged.
</Typography>
)}
</>
)}
</InfoCard>
</Grid>
<Grid item xs={6}>
<InfoCard>
<ImportStepper
steps={[
{
step: manifestGenerationAvailable(configApi)
? 'Insert GitHub repo URL or Entity File URL'
: 'Insert Entity File URL',
content: (
<RegisterComponentForm
nextStep={nextStep}
saveConfig={setConfigFile}
repository={repositoryString}
/>
),
},
{
step: 'Review',
content: (
<ComponentConfigDisplay
nextStep={nextStep}
configFile={configFile}
savePRLink={setEndLink}
catalogRouteRef={catalogRouteRef}
/>
),
},
{
step: 'Finish',
content: (
<ImportFinished
nextStep={nextStep}
PRLink={endLink}
type={configFile.type}
/>
),
},
]}
activeStep={activeStep}
nextStep={nextStep}
/>
</InfoCard>
<Grid item xs={12} md={8} lg={6} xl={4}>
<ImportStepper opts={opts} />
</Grid>
</Grid>
</Content>
@@ -1,58 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Alert } from '@material-ui/lab';
import { Button, Grid, Link } from '@material-ui/core';
type Props = {
type: 'tree' | 'file';
nextStep: (options?: { reset: boolean }) => void;
PRLink: string;
};
export const ImportFinished = ({ nextStep, PRLink, type }: Props) => {
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Alert severity="success">
{type === 'tree'
? 'Pull requests have been successfully opened. You can start again to import more repositories'
: 'Entity added to catalog successfully'}
</Alert>
</Grid>
<Grid item>
{type === 'tree' ? (
<Link
href={PRLink}
style={{ marginRight: '8px' }}
target="_blank"
rel="noopener noreferrer"
>
View pull request on GitHub
</Link>
) : null}
<Button
variant="contained"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Register another
</Button>
</Grid>
</Grid>
);
};
@@ -1,42 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step';
import StepLabel from '@material-ui/core/StepLabel';
import { StepContent } from '@material-ui/core';
type Props = {
steps: { step: string; content: React.ReactNode }[];
activeStep: number;
nextStep: (options?: { reset: boolean }) => void;
};
export default function ImportStepper({ steps, activeStep }: Props) {
return (
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map(({ step }) => {
const stepProps: { completed?: boolean } = {};
return (
<Step key={step} {...stepProps}>
<StepLabel>{step}</StepLabel>
<StepContent>{steps[activeStep].content}</StepContent>
</Step>
);
})}
</Stepper>
);
}
@@ -0,0 +1,104 @@
/*
* Copyright 2021 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 { configApiRef, InfoCard, useApi } from '@backstage/core';
import { Step, StepContent, Stepper } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React, { useMemo } from 'react';
import { ImportFlows, ImportState, useImportState } from '../useImportState';
import {
defaultGenerateStepper,
defaultStepper,
StepConfiguration,
StepperProvider,
StepperProviderOpts,
} from './defaults';
const useStyles = makeStyles(() => ({
stepperRoot: {
padding: 0,
},
}));
type Props = {
initialUrl?: string;
generateStepper?: (
flow: ImportFlows,
defaults: StepperProvider,
) => StepperProvider;
variant?: string;
opts?: StepperProviderOpts;
};
export const ImportStepper = ({
initialUrl,
generateStepper = defaultGenerateStepper,
variant,
opts,
}: Props) => {
const configApi = useApi(configApiRef);
const classes = useStyles();
const state = useImportState({ initialUrl });
const states = useMemo<StepperProvider>(
() => generateStepper(state.activeFlow, defaultStepper),
[generateStepper, state.activeFlow],
);
const render = (step: StepConfiguration) => {
return (
<Step>
{step.stepLabel}
<StepContent>{step.content}</StepContent>
</Step>
);
};
return (
<InfoCard variant={variant}>
<Stepper
classes={{ root: classes.stepperRoot }}
activeStep={state.activeStepNumber}
orientation="vertical"
>
{render(
states.analyze(
state as Extract<ImportState, { activeState: 'analyze' }>,
{ apis: { configApi }, opts },
),
)}
{render(
states.prepare(
state as Extract<ImportState, { activeState: 'prepare' }>,
{ apis: { configApi }, opts },
),
)}
{render(
states.review(
state as Extract<ImportState, { activeState: 'review' }>,
{ apis: { configApi }, opts },
),
)}
{render(
states.finish(
state as Extract<ImportState, { activeState: 'finish' }>,
{ apis: { configApi }, opts },
),
)}
</Stepper>
</InfoCard>
);
};
@@ -0,0 +1,293 @@
/*
* Copyright 2021 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 { ConfigApi } from '@backstage/core';
import { Box, StepLabel, TextField, Typography } from '@material-ui/core';
import React from 'react';
import { BackButton } from '../Buttons';
import { StepFinishImportLocation } from '../StepFinishImportLocation';
import { StepInitAnalyzeUrl } from '../StepInitAnalyzeUrl';
import {
AutocompleteTextField,
StepPrepareCreatePullRequest,
} from '../StepPrepareCreatePullRequest';
import { StepPrepareSelectLocations } from '../StepPrepareSelectLocations';
import { StepReviewLocation } from '../StepReviewLocation';
import { ImportFlows, ImportState } from '../useImportState';
export type StepperProviderOpts = {
pullRequest?: {
disable?: boolean;
preparePullRequest?: (apis: StepperApis) => { title: string; body: string };
};
};
type StepperApis = {
configApi: ConfigApi;
};
export type StepConfiguration = {
stepLabel: React.ReactElement;
content: React.ReactElement;
};
export type StepperProvider = {
analyze: (
s: Extract<ImportState, { activeState: 'analyze' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
) => StepConfiguration;
prepare: (
s: Extract<ImportState, { activeState: 'prepare' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
) => StepConfiguration;
review: (
s: Extract<ImportState, { activeState: 'review' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
) => StepConfiguration;
finish: (
s: Extract<ImportState, { activeState: 'finish' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
) => StepConfiguration;
};
function defaultPreparePullRequest(apis: StepperApis) {
const appTitle = apis.configApi.getOptionalString('app.title') ?? 'Backstage';
const appBaseUrl = apis.configApi.getString('app.baseUrl');
return {
title: 'Add catalog-info.yaml config file',
body: `This pull request adds a **Backstage entity metadata file** \
to this repository so that the component can be added to the \
[${appTitle} software catalog](${appBaseUrl}).\n\nAfter this pull request is merged, \
the component will become available.\n\nFor more information, read an \
[overview of the Backstage software catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview).`,
};
}
/**
* The default stepper generation function.
*
* Override this function to customize the import flow. Each flow should at
* least override the prepare operation.
*
* @param flow the name of the active flow
* @param defaults the default steps
*/
export function defaultGenerateStepper(
flow: ImportFlows,
defaults: StepperProvider,
): StepperProvider {
switch (flow) {
// the prepare step is skipped but the label of the step is updated
case 'single-location':
return {
...defaults,
prepare: () => ({
stepLabel: (
<StepLabel
optional={
<Typography variant="caption">
Discovered Locations: 1
</Typography>
}
>
Select Locations
</StepLabel>
),
content: <></>,
}),
};
// let the user select one or more of the discovered locations in the prepare step
case 'multiple-locations':
return {
...defaults,
prepare: (state, opts) => {
if (state.analyzeResult.type !== 'locations') {
return defaults.prepare(state, opts);
}
return {
stepLabel: (
<StepLabel
optional={
<Typography variant="caption">
Discovered Locations: {state.analyzeResult.locations.length}
</Typography>
}
>
Select Locations
</StepLabel>
),
content: (
<StepPrepareSelectLocations
analyzeResult={state.analyzeResult}
prepareResult={state.prepareResult}
onPrepare={state.onPrepare}
onGoBack={state.onGoBack}
/>
),
};
},
};
case 'no-location':
return {
...defaults,
prepare: (state, opts) => {
if (state.analyzeResult.type !== 'repository') {
return defaults.prepare(state, opts);
}
const { title, body } = (
opts?.opts?.pullRequest?.preparePullRequest ??
defaultPreparePullRequest
)(opts.apis);
return {
stepLabel: <StepLabel>Create Pull Request</StepLabel>,
content: (
<StepPrepareCreatePullRequest
analyzeResult={state.analyzeResult}
onPrepare={state.onPrepare}
onGoBack={state.onGoBack}
defaultTitle={title}
defaultBody={body}
renderFormFields={({
control,
errors,
groupsLoading,
groups,
register,
}) => (
<>
<Box marginTop={2}>
<Typography variant="h6">Pull Request Details</Typography>
</Box>
<TextField
name="title"
label="Pull Request Title"
placeholder="Add Backstage catalog entity descriptor files"
margin="normal"
variant="outlined"
fullWidth
inputRef={register({ required: true })}
error={Boolean(errors.title)}
required
/>
<TextField
name="body"
label="Pull Request Body"
placeholder="A describing text with Markdown support"
margin="normal"
variant="outlined"
fullWidth
inputRef={register({ required: true })}
error={Boolean(errors.body)}
multiline
required
/>
<Box marginTop={2}>
<Typography variant="h6">Entity Configuration</Typography>
</Box>
<TextField
name="componentName"
label="Name of the created component"
placeholder="my-component"
margin="normal"
variant="outlined"
fullWidth
inputRef={register({ required: true })}
error={Boolean(errors.componentName)}
required
/>
<AutocompleteTextField
name="owner"
control={control}
errors={errors}
options={groups || []}
loading={groupsLoading}
loadingText="Loading groups…"
helperText="Select an owner from the list or enter a reference to a Group or a User"
errorHelperText="required value"
textFieldProps={{
label: 'Entity Owner',
placeholder: 'Group:default/my-group',
}}
rules={{ required: true }}
required
/>
</>
)}
/>
),
};
},
};
default:
return defaults;
}
}
export const defaultStepper: StepperProvider = {
analyze: (state, { opts }) => ({
stepLabel: <StepLabel>Select URL</StepLabel>,
content: (
<StepInitAnalyzeUrl
key="analyze"
analysisUrl={state.analysisUrl}
onAnalysis={state.onAnalysis}
disablePullRequest={opts?.pullRequest?.disable}
/>
),
}),
prepare: state => ({
stepLabel: (
<StepLabel optional={<Typography variant="caption">Optional</Typography>}>
Import Actions
</StepLabel>
),
content: <BackButton onClick={state.onGoBack} />,
}),
review: state => ({
stepLabel: <StepLabel>Review</StepLabel>,
content: (
<StepReviewLocation
prepareResult={state.prepareResult}
onReview={state.onReview}
onGoBack={state.onGoBack}
/>
),
}),
finish: state => ({
stepLabel: <StepLabel>Finish</StepLabel>,
content: (
<StepFinishImportLocation
reviewResult={state.reviewResult}
onReset={state.onReset}
/>
),
}),
};
@@ -14,18 +14,5 @@
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
export type UrlType = 'file' | 'tree';
export function urlType(url: string): UrlType {
const { filepathtype, filepath } = parseGitUrl(url);
if (filepathtype === 'tree' || filepathtype === 'file') {
return filepathtype;
} else if (filepath?.match(/\.ya?ml$/)) {
return 'file';
}
return 'tree';
}
export { ImportStepper } from './ImportStepper';
export { defaultGenerateStepper } from './defaults';
@@ -14,15 +14,13 @@
* limitations under the License.
*/
import { RouteRef } from '@backstage/core';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { ImportComponentPage } from './ImportComponentPage';
import { StepperProviderOpts } from './ImportStepper/defaults';
export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => (
export const Router = ({ options }: { options?: StepperProviderOpts }) => (
<Routes>
<Route
element={<ImportComponentPage catalogRouteRef={catalogRouteRef} />}
/>
<Route element={<ImportComponentPage opts={options} />} />
</Routes>
);
@@ -0,0 +1,65 @@
/*
* Copyright 2021 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 { Link } from '@backstage/core';
import { Grid, Typography } from '@material-ui/core';
import LocationOnIcon from '@material-ui/icons/LocationOn';
import React from 'react';
import { BackButton } from '../Buttons';
import { EntityListComponent } from '../EntityListComponent';
import { ReviewResult } from '../useImportState';
type Props = {
reviewResult: ReviewResult;
onReset: () => void;
};
export const StepFinishImportLocation = ({ reviewResult, onReset }: Props) => (
<>
{reviewResult.type === 'repository' && (
<>
<Typography paragraph>
The following Pull Request has been opened:{' '}
<Link
to={reviewResult.pullRequest.url}
target="_blank"
rel="noreferrer"
>
{reviewResult.pullRequest.url}
</Link>
</Typography>
<Typography paragraph>
Your entities will be imported as soon as the Pull Request is merged.
</Typography>
</>
)}
<Typography>
The following entities have been added to the catalog:
</Typography>
<EntityListComponent
locations={reviewResult.locations}
locationListItemIcon={() => <LocationOnIcon />}
withLinks
/>
<Grid container spacing={0}>
<BackButton onClick={onReset}>Register another</BackButton>
</Grid>
</>
);
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 { StepFinishImportLocation } from './StepFinishImportLocation';
@@ -0,0 +1,401 @@
/*
* 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 { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { AnalyzeResult, catalogImportApiRef } from '../../api/';
import { StepInitAnalyzeUrl } from './StepInitAnalyzeUrl';
describe('<StepInitAnalyzeUrl />', () => {
const catalogImportApi: jest.Mocked<typeof catalogImportApiRef.T> = {
analyzeUrl: jest.fn(),
submitPullRequest: jest.fn(),
};
const errorApi: jest.Mocked<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider
apis={ApiRegistry.with(catalogImportApiRef, catalogImportApi).with(
errorApiRef,
errorApi,
)}
>
{children}
</ApiProvider>
);
const location = {
target: 'url',
entities: [
{
kind: 'component',
namespace: 'default',
name: 'name',
},
],
};
beforeEach(() => {
jest.resetAllMocks();
});
it('renders without exploding', async () => {
const { getByRole } = render(
<StepInitAnalyzeUrl onAnalysis={() => undefined} />,
{
wrapper: Wrapper,
},
);
expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument();
expect(getByRole('textbox', { name: /Repository/i })).toHaveValue('');
});
it('should use default analysis url', async () => {
const { getByRole } = render(
<StepInitAnalyzeUrl
onAnalysis={() => undefined}
analysisUrl="https://default"
/>,
{
wrapper: Wrapper,
},
);
expect(getByRole('textbox', { name: /Repository/i })).toBeInTheDocument();
expect(getByRole('textbox', { name: /Repository/i })).toHaveValue(
'https://default',
);
});
it('should not analyze without url', async () => {
const onAnalysisFn = jest.fn();
const { getByRole } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
await act(async () => {
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0);
expect(onAnalysisFn).toBeCalledTimes(0);
expect(errorApi.post).toBeCalledTimes(0);
});
it('should not analyze invalid value', async () => {
const onAnalysisFn = jest.fn();
const { getByRole, getByText } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'http:/',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0);
expect(onAnalysisFn).toBeCalledTimes(0);
expect(errorApi.post).toBeCalledTimes(0);
expect(getByText('Must start with https://.')).toBeInTheDocument();
});
it('should analyze single location', async () => {
const onAnalysisFn = jest.fn();
const analyzeResult = {
type: 'locations',
locations: [location],
} as AnalyzeResult;
const { getByRole } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
catalogImportApi.analyzeUrl.mockReturnValueOnce(
Promise.resolve(analyzeResult),
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'https://my-repository',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(onAnalysisFn).toBeCalledTimes(1);
expect(onAnalysisFn.mock.calls[0]).toMatchObject([
'single-location',
'https://my-repository',
analyzeResult,
{ prepareResult: analyzeResult },
]);
expect(errorApi.post).toBeCalledTimes(0);
});
it('should analyze multiple locations', async () => {
const onAnalysisFn = jest.fn();
const analyzeResult = {
type: 'locations',
locations: [location, location],
} as AnalyzeResult;
const { getByRole } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
catalogImportApi.analyzeUrl.mockReturnValueOnce(
Promise.resolve(analyzeResult),
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'https://my-repository-1',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(onAnalysisFn).toBeCalledTimes(1);
expect(onAnalysisFn.mock.calls[0]).toMatchObject([
'multiple-locations',
'https://my-repository-1',
analyzeResult,
]);
expect(errorApi.post).toBeCalledTimes(0);
});
it('should not analyze with no locations', async () => {
const onAnalysisFn = jest.fn();
const analyzeResult = {
type: 'locations',
locations: [],
} as AnalyzeResult;
const { getByRole, getByText } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
catalogImportApi.analyzeUrl.mockReturnValueOnce(
Promise.resolve(analyzeResult),
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'https://my-repository-1',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(onAnalysisFn).toBeCalledTimes(0);
expect(
getByText('There are no entities at this location'),
).toBeInTheDocument();
expect(errorApi.post).toBeCalledTimes(0);
});
it('should analyze repository', async () => {
const onAnalysisFn = jest.fn();
const analyzeResult = {
type: 'repository',
url: 'https://my-repository-2',
integrationType: 'github',
generatedEntities: [
{
apiVersion: '1',
kind: 'component',
metadata: {
name: 'component-a',
},
},
],
} as AnalyzeResult;
const { getByRole } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
catalogImportApi.analyzeUrl.mockReturnValueOnce(
Promise.resolve(analyzeResult),
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'https://my-repository-2',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(onAnalysisFn).toBeCalledTimes(1);
expect(onAnalysisFn.mock.calls[0]).toMatchObject([
'no-location',
'https://my-repository-2',
analyzeResult,
]);
expect(errorApi.post).toBeCalledTimes(0);
});
it('should not analyze repository without entities', async () => {
const onAnalysisFn = jest.fn();
const analyzeResult = {
type: 'repository',
url: 'https://my-repository-2',
integrationType: 'github',
generatedEntities: [],
} as AnalyzeResult;
const { getByRole, getByText } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
catalogImportApi.analyzeUrl.mockReturnValueOnce(
Promise.resolve(analyzeResult),
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'https://my-repository-2',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(onAnalysisFn).toBeCalledTimes(0);
expect(
getByText("Couldn't generate entities for your repository"),
).toBeInTheDocument();
expect(errorApi.post).toBeCalledTimes(0);
});
it('should not analyze repository if disabled', async () => {
const onAnalysisFn = jest.fn();
const analyzeResult = {
type: 'repository',
url: 'https://my-repository-2',
integrationType: 'github',
generatedEntities: [
{
apiVersion: '1',
kind: 'component',
metadata: {
name: 'component-a',
},
},
],
} as AnalyzeResult;
const { getByRole, getByText } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} disablePullRequest />,
{
wrapper: Wrapper,
},
);
catalogImportApi.analyzeUrl.mockReturnValueOnce(
Promise.resolve(analyzeResult),
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'https://my-repository-2',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(onAnalysisFn).toBeCalledTimes(0);
expect(
getByText("Couldn't generate entities for your repository"),
).toBeInTheDocument();
expect(errorApi.post).toBeCalledTimes(0);
});
it('should report unknown type to the errorapi', async () => {
const onAnalysisFn = jest.fn();
const { getByRole, getByText } = render(
<StepInitAnalyzeUrl onAnalysis={onAnalysisFn} />,
{
wrapper: Wrapper,
},
);
catalogImportApi.analyzeUrl.mockReturnValueOnce(
Promise.resolve(({ type: 'unknown' } as any) as AnalyzeResult),
);
await act(async () => {
await userEvent.type(
getByRole('textbox', { name: /Repository/i }),
'https://my-repository-2',
);
userEvent.click(getByRole('button', { name: /Analyze/i }));
});
expect(onAnalysisFn).toBeCalledTimes(0);
expect(
getByText(
'Received unknown analysis result of type unknown. Please contact the support team.',
),
).toBeInTheDocument();
expect(errorApi.post).toBeCalledTimes(1);
expect(errorApi.post.mock.calls[0][0]).toMatchObject(
new Error(
'Received unknown analysis result of type unknown. Please contact the support team.',
),
);
});
});
@@ -0,0 +1,159 @@
/*
* Copyright 2021 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 { errorApiRef, useApi } from '@backstage/core';
import { FormHelperText, Grid, TextField } from '@material-ui/core';
import React, { useCallback, useState } from 'react';
import { useForm } from 'react-hook-form';
import { AnalyzeResult, catalogImportApiRef } from '../../api';
import { NextButton } from '../Buttons';
import { ImportFlows, PrepareResult } from '../useImportState';
type FormData = {
url: string;
};
type Props = {
onAnalysis: (
flow: ImportFlows,
url: string,
result: AnalyzeResult,
opts?: { prepareResult?: PrepareResult },
) => void;
disablePullRequest?: boolean;
analysisUrl?: string;
};
/**
* A form that lets the user input a url and analyze it for existing locations or potential entities.
*
* @param onAnalysis is called when the analysis was successful
* @param analysisUrl a url that can be used as a default value
* @param disablePullRequest if true, repositories without entities will abort the wizard
*/
export const StepInitAnalyzeUrl = ({
onAnalysis,
analysisUrl = '',
disablePullRequest = false,
}: Props) => {
const errorApi = useApi(errorApiRef);
const catalogImportApi = useApi(catalogImportApiRef);
const { register, handleSubmit, errors, watch } = useForm<FormData>({
mode: 'onTouched',
defaultValues: {
url: analysisUrl,
},
});
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleResult = useCallback(
async ({ url }: FormData) => {
setSubmitted(true);
try {
const analysisResult = await catalogImportApi.analyzeUrl(url);
switch (analysisResult.type) {
case 'repository':
if (
!disablePullRequest &&
analysisResult.generatedEntities.length > 0
) {
onAnalysis('no-location', url, analysisResult);
} else {
setError("Couldn't generate entities for your repository");
setSubmitted(false);
}
break;
case 'locations': {
if (analysisResult.locations.length === 1) {
onAnalysis('single-location', url, analysisResult, {
prepareResult: analysisResult,
});
} else if (analysisResult.locations.length > 1) {
onAnalysis('multiple-locations', url, analysisResult);
} else {
setError('There are no entities at this location');
setSubmitted(false);
}
break;
}
default: {
const err = `Received unknown analysis result of type ${
(analysisResult as any).type
}. Please contact the support team.`;
setError(err);
setSubmitted(false);
errorApi.post(new Error(err));
break;
}
}
} catch (e) {
setError(e.message);
setSubmitted(false);
}
},
[catalogImportApi, disablePullRequest, errorApi, onAnalysis],
);
return (
<form onSubmit={handleSubmit(handleResult)}>
<TextField
fullWidth
id="url"
name="url"
label="Repository URL"
placeholder="https://github.com/backstage/backstage/blob/master/catalog-info.yaml"
helperText="Enter the full path to your entity file to start tracking your component"
margin="normal"
variant="outlined"
error={Boolean(errors.url)}
inputRef={register({
required: true,
validate: {
httpsValidator: (value: any) =>
(typeof value === 'string' &&
value.match(/^https:\/\//) !== null) ||
'Must start with https://.',
},
})}
required
/>
{errors.url && (
<FormHelperText error>{errors.url.message}</FormHelperText>
)}
{error && <FormHelperText error>{error}</FormHelperText>}
<Grid container spacing={0}>
<NextButton
disabled={Boolean(errors.url) || !watch('url')}
loading={submitted}
type="submit"
>
Analyze
</NextButton>
</Grid>
</form>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 { StepInitAnalyzeUrl } from './StepInitAnalyzeUrl';
@@ -0,0 +1,100 @@
/*
* Copyright 2021 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 { CircularProgress, TextField } from '@material-ui/core';
import { TextFieldProps } from '@material-ui/core/TextField/TextField';
import { Autocomplete } from '@material-ui/lab';
import React from 'react';
import {
Control,
Controller,
FieldErrors,
ValidationRules,
} from 'react-hook-form';
type Props<TFieldValue extends string> = {
name: TFieldValue;
options: string[];
required?: boolean;
control?: Control<Record<string, any>>;
errors?: FieldErrors<Record<TFieldValue, string>>;
rules?: ValidationRules;
loading?: boolean;
loadingText?: string;
helperText?: React.ReactNode;
errorHelperText?: string;
textFieldProps?: Omit<TextFieldProps, 'required' | 'fullWidth'>;
};
export const AutocompleteTextField = <TFieldValue extends string>({
name,
options,
required,
control,
errors,
rules,
loading = false,
loadingText,
helperText,
errorHelperText,
textFieldProps = {},
}: Props<TFieldValue>) => {
return (
<Controller
name={name}
control={control}
rules={rules}
render={({ value, onChange, onBlur }) => (
<Autocomplete
loading={loading}
loadingText={loadingText}
options={options || []}
onChange={(_: any, v: string | null) => onChange(v || '')}
onBlur={onBlur}
value={value}
autoSelect
freeSolo
renderInput={params => (
<TextField
{...params}
helperText={(errors?.[name] && errorHelperText) || helperText}
error={Boolean(errors?.[name])}
margin="normal"
variant="outlined"
required={required}
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? (
<CircularProgress color="inherit" size="1em" />
) : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
{...textFieldProps}
/>
)}
/>
)}
/>
);
};
@@ -0,0 +1,113 @@
/*
* 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 { FormHelperText, TextField } from '@material-ui/core';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PreparePullRequestForm } from './PreparePullRequestForm';
describe('<PreparePullRequestForm />', () => {
it('renders without exploding', async () => {
const onSubmitFn = jest.fn();
const { getByRole } = render(
<PreparePullRequestForm
defaultValues={{ main: 'default' }}
render={({ register }) => (
<>
<TextField name="main" inputRef={register()} />
<button type="submit">Submit</button>{' '}
</>
)}
onSubmit={onSubmitFn}
/>,
);
await act(async () => {
userEvent.click(getByRole('button', { name: /submit/i }));
});
expect(onSubmitFn).toBeCalledTimes(1);
expect(onSubmitFn.mock.calls[0][0]).toMatchObject({ main: 'default' });
});
it('should register a text field', async () => {
const onSubmitFn = jest.fn();
const { getByRole, getByLabelText } = render(
<PreparePullRequestForm
defaultValues={{ main: 'default' }}
render={({ register }) => (
<>
<TextField
id="main"
name="main"
label="Main Field"
inputRef={register()}
/>
<button type="submit">Submit</button>
</>
)}
onSubmit={onSubmitFn}
/>,
);
await act(async () => {
userEvent.clear(getByLabelText('Main Field'));
await userEvent.type(getByLabelText('Main Field'), 'My Text');
userEvent.click(getByRole('button', { name: /submit/i }));
});
expect(onSubmitFn).toBeCalledTimes(1);
expect(onSubmitFn.mock.calls[0][0]).toMatchObject({ main: 'My Text' });
});
it('registers required attribute', async () => {
const onSubmitFn = jest.fn();
const { queryByText, getByRole } = render(
<PreparePullRequestForm
defaultValues={{}}
render={({ errors, register }) => (
<>
<TextField
name="main"
required
inputRef={register({ required: true })}
/>
{errors.main && (
<FormHelperText error>
Error in required main field
</FormHelperText>
)}
<button type="submit">Submit</button>{' '}
</>
)}
onSubmit={onSubmitFn}
/>,
);
expect(queryByText('Error in required main field')).not.toBeInTheDocument();
await act(async () => {
userEvent.click(getByRole('button', { name: /submit/i }));
});
expect(onSubmitFn).not.toBeCalled();
expect(queryByText('Error in required main field')).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
/*
* Copyright 2021 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 {
SubmitHandler,
UnpackNestedValue,
useForm,
UseFormMethods,
UseFormOptions,
} from 'react-hook-form';
type Props<TFieldValues extends Record<string, any>> = Pick<
UseFormOptions<TFieldValues>,
'defaultValues'
> & {
onSubmit: SubmitHandler<TFieldValues>;
render: (
props: Pick<
UseFormMethods<TFieldValues>,
'errors' | 'register' | 'control'
> & {
values: UnpackNestedValue<TFieldValues>;
},
) => React.ReactNode;
};
/**
* A form wrapper that creates a form that is used to prepare a pull request. It
* hosts the form logic.
*
* @param defaultValues the default values of the form
* @param onSubmit a callback that is executed when the form is submitted
* (initiated by a button of type="submit")
* @param render render the form elements
*/
export const PreparePullRequestForm = <
TFieldValues extends Record<string, any>
>({
defaultValues,
onSubmit,
render,
}: Props<TFieldValues>) => {
const { handleSubmit, watch, control, register, errors } = useForm<
TFieldValues
>({ mode: 'onTouched', defaultValues });
return (
<form onSubmit={handleSubmit(onSubmit)}>
{render({ values: watch(), errors, register, control })}
</form>
);
};
@@ -0,0 +1,101 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { makeStyles } from '@material-ui/core';
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent';
const useStyles = makeStyles({
displayNone: {
display: 'none',
},
});
const entities: Entity[] = [
{
apiVersion: '1',
kind: 'Kind',
metadata: {
name: 'name',
},
},
{
apiVersion: '1',
kind: 'Kind_2',
metadata: {
name: 'name',
},
},
];
describe('<PreviewCatalogInfoComponent />', () => {
it('renders without exploding', async () => {
const { getByText } = render(
<PreviewCatalogInfoComponent
repositoryUrl="http://my-repository/a/"
entities={entities}
/>,
);
const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml');
const kindText = getByText('Kind_2');
expect(repositoryUrl).toBeInTheDocument();
expect(repositoryUrl).toBeVisible();
expect(kindText).toBeInTheDocument();
expect(kindText).toBeVisible();
});
it('renders card with custom styles', async () => {
const { result } = renderHook(() => useStyles());
const { getByText } = render(
<PreviewCatalogInfoComponent
repositoryUrl="http://my-repository/a/"
entities={entities}
classes={{ card: result.current.displayNone }}
/>,
);
const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml');
const kindText = getByText('Kind_2');
expect(repositoryUrl).toBeInTheDocument();
expect(repositoryUrl).not.toBeVisible();
expect(kindText).toBeInTheDocument();
expect(kindText).not.toBeVisible();
});
it('renders with custom styles', async () => {
const { result } = renderHook(() => useStyles());
const { getByText } = render(
<PreviewCatalogInfoComponent
repositoryUrl="http://my-repository/a/"
entities={entities}
classes={{ cardContent: result.current.displayNone }}
/>,
);
const repositoryUrl = getByText('http://my-repository/a/catalog-info.yaml');
const kindText = getByText('Kind_2');
expect(repositoryUrl).toBeInTheDocument();
expect(repositoryUrl).toBeVisible();
expect(kindText).toBeInTheDocument();
expect(kindText).not.toBeVisible();
});
});
@@ -0,0 +1,56 @@
/*
* Copyright 2021 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 { Entity } from '@backstage/catalog-model';
import { CodeSnippet } from '@backstage/core';
import { Card, CardContent, CardHeader } from '@material-ui/core';
import React from 'react';
import YAML from 'yaml';
type Props = {
repositoryUrl: string;
entities: Entity[];
classes?: { card?: string; cardContent?: string };
};
export const PreviewCatalogInfoComponent = ({
repositoryUrl,
entities,
classes,
}: Props) => {
return (
<Card variant="outlined" className={classes?.card}>
<CardHeader
title={
<code>{`${repositoryUrl.replace(
/[\/]*$/,
'',
)}/catalog-info.yaml`}</code>
}
/>
<CardContent className={classes?.cardContent}>
<CodeSnippet
text={entities
.map(e => YAML.stringify(e))
.join('---\n')
.trim()}
language="yaml"
/>
</CardContent>
</Card>
);
};
@@ -0,0 +1,83 @@
/*
* 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 { makeStyles } from '@material-ui/core';
import { render } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { PreviewPullRequestComponent } from './PreviewPullRequestComponent';
const useStyles = makeStyles({
displayNone: {
display: 'none',
},
});
describe('<PreviewPullRequestComponent />', () => {
it('renders without exploding', async () => {
const { getByText } = render(
<PreviewPullRequestComponent
title="My Title"
description="My **description**"
/>,
);
const title = getByText('My Title');
const description = getByText('description', { selector: 'strong' });
expect(title).toBeInTheDocument();
expect(title).toBeVisible();
expect(description).toBeInTheDocument();
expect(description).toBeVisible();
});
it('renders card with custom styles', async () => {
const { result } = renderHook(() => useStyles());
const { getByText } = render(
<PreviewPullRequestComponent
title="My Title"
description="My **description**"
classes={{ card: result.current.displayNone }}
/>,
);
const title = getByText('My Title');
const description = getByText('description', { selector: 'strong' });
expect(title).toBeInTheDocument();
expect(title).not.toBeVisible();
expect(description).toBeInTheDocument();
expect(description).not.toBeVisible();
});
it('renders with custom styles', async () => {
const { result } = renderHook(() => useStyles());
const { getByText } = render(
<PreviewPullRequestComponent
title="My Title"
description="My **description**"
classes={{ cardContent: result.current.displayNone }}
/>,
);
const title = getByText('My Title');
const description = getByText('description', { selector: 'strong' });
expect(title).toBeInTheDocument();
expect(title).toBeVisible();
expect(description).toBeInTheDocument();
expect(description).not.toBeVisible();
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2021 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 { MarkdownContent } from '@backstage/core';
import { Card, CardContent, CardHeader } from '@material-ui/core';
import React from 'react';
type Props = {
title: string;
description: string;
classes?: { card?: string; cardContent?: string };
};
export const PreviewPullRequestComponent = ({
title,
description,
classes,
}: Props) => {
return (
<Card variant="outlined" className={classes?.card}>
<CardHeader title={title} subheader="Create a new Pull Request" />
<CardContent className={classes?.cardContent}>
<MarkdownContent content={description} />
</CardContent>
</Card>
);
};
@@ -0,0 +1,287 @@
/*
* 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 { ApiProvider, ApiRegistry } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { TextField } from '@material-ui/core';
import { act, render, RenderResult } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { AnalyzeResult, catalogImportApiRef } from '../../api';
import { StepPrepareCreatePullRequest } from './StepPrepareCreatePullRequest';
describe('<StepPrepareCreatePullRequest />', () => {
const catalogImportApi: jest.Mocked<typeof catalogImportApiRef.T> = {
analyzeUrl: jest.fn(),
submitPullRequest: jest.fn(),
};
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
getEntities: jest.fn(),
addLocation: jest.fn(),
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider
apis={ApiRegistry.with(catalogImportApiRef, catalogImportApi).with(
catalogApiRef,
catalogApi,
)}
>
{children}
</ApiProvider>
);
const onPrepareFn = jest.fn();
const analyzeResult = {
type: 'repository',
url: 'https://my-repository',
integrationType: 'github',
generatedEntities: [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'my-component',
},
spec: {
owner: 'my-owner',
},
},
],
} as Extract<AnalyzeResult, { type: 'repository' }>;
beforeEach(() => {
jest.resetAllMocks();
});
it('renders without exploding', async () => {
catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] }));
await act(async () => {
const { getByText } = render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={({ register }) => {
return (
<>
<TextField name="title" inputRef={register()} />
<TextField name="body" inputRef={register()} />
<TextField name="componentName" inputRef={register()} />
<TextField name="owner" inputRef={register()} />
</>
);
}}
/>,
{
wrapper: Wrapper,
},
);
const title = getByText('My title');
const description = getByText('body', { selector: 'strong' });
expect(title).toBeInTheDocument();
expect(title).toBeVisible();
expect(description).toBeInTheDocument();
expect(description).toBeVisible();
});
});
it('should submit created PR', async () => {
catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] }));
catalogImportApi.submitPullRequest.mockReturnValue(
Promise.resolve({
location: 'https://my/location.yaml',
link: 'https://my/pull',
}),
);
let result: RenderResult;
await act(async () => {
result = await render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={({ register }) => {
return (
<>
<TextField name="title" inputRef={register()} />
<TextField name="body" inputRef={register()} />
<TextField
id="name"
label="name"
name="componentName"
inputRef={register()}
/>
<TextField
id="owner"
label="owner"
name="owner"
inputRef={register()}
/>
</>
);
}}
/>,
{
wrapper: Wrapper,
},
);
await userEvent.type(await result.getByLabelText('name'), '-changed');
await userEvent.type(await result.getByLabelText('owner'), '-changed');
await userEvent.click(
await result.getByRole('button', { name: /Create PR/i }),
);
});
expect(catalogImportApi.submitPullRequest).toBeCalledTimes(1);
expect(catalogImportApi.submitPullRequest.mock.calls[0]).toMatchObject([
{
body: 'My **body**',
fileContent: `apiVersion: "1"
kind: Component
metadata:
name: my-component-changed
namespace: default
spec:
owner: my-owner-changed
`,
repositoryUrl: 'https://my-repository',
title: 'My title',
},
]);
expect(onPrepareFn).toBeCalledTimes(1);
expect(onPrepareFn.mock.calls[0]).toMatchObject([
{
type: 'repository',
url: 'https://my-repository',
integrationType: 'github',
pullRequest: {
url: 'https://my/pull',
},
locations: [
{
entities: [
{
kind: 'Component',
name: 'my-component-changed',
namespace: 'default',
},
],
target: 'https://my/location.yaml',
},
],
},
{
notRepeatable: true,
},
]);
});
it('should show error message', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [] });
catalogImportApi.submitPullRequest.mockRejectedValueOnce(
new Error('some error'),
);
let result: RenderResult;
await act(async () => {
result = await render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={({ register }) => {
return (
<>
<TextField name="title" inputRef={register()} />
<TextField name="body" inputRef={register()} />
<TextField name="componentName" inputRef={register()} />
<TextField name="owner" inputRef={register()} />
</>
);
}}
/>,
{
wrapper: Wrapper,
},
);
await userEvent.click(
await result.getByRole('button', { name: /Create PR/i }),
);
});
expect(result!.getByText('some error')).toBeInTheDocument();
expect(catalogImportApi.submitPullRequest).toBeCalledTimes(1);
expect(onPrepareFn).toBeCalledTimes(0);
});
it('should load groups', async () => {
const renderFormFieldsFn = jest.fn();
catalogApi.getEntities.mockReturnValue(
Promise.resolve({
items: [
{
apiVersion: '1',
kind: 'Group',
metadata: {
name: 'my-group',
},
},
],
}),
);
await act(async () => {
await render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={renderFormFieldsFn}
/>,
{
wrapper: Wrapper,
},
);
});
expect(catalogApi.getEntities).toBeCalledTimes(1);
expect(renderFormFieldsFn).toBeCalled();
expect(renderFormFieldsFn.mock.calls[0][0]).toMatchObject({
groups: [],
groupsLoading: true,
});
expect(
renderFormFieldsFn.mock.calls[
renderFormFieldsFn.mock.calls.length - 1
][0],
).toMatchObject({ groups: ['Group:my-group'], groupsLoading: false });
});
});
@@ -0,0 +1,249 @@
/*
* Copyright 2021 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 { Entity, serializeEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Box, FormHelperText, Grid, Typography } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React, { useCallback, useState } from 'react';
import { UseFormMethods } from 'react-hook-form';
import { useAsync } from 'react-use';
import YAML from 'yaml';
import { AnalyzeResult, catalogImportApiRef } from '../../api';
import { PartialEntity } from '../../types';
import { BackButton, NextButton } from '../Buttons';
import { PrepareResult } from '../useImportState';
import { PreparePullRequestForm } from './PreparePullRequestForm';
import { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent';
import { PreviewPullRequestComponent } from './PreviewPullRequestComponent';
const useStyles = makeStyles(theme => ({
previewCard: {
marginTop: theme.spacing(1),
},
previewCardContent: {
paddingTop: 0,
},
}));
type FormData = {
title: string;
body: string;
componentName: string;
owner: string;
};
type Props = {
analyzeResult: Extract<AnalyzeResult, { type: 'repository' }>;
onPrepare: (
result: PrepareResult,
opts?: { notRepeatable?: boolean },
) => void;
onGoBack?: () => void;
defaultTitle: string;
defaultBody: string;
renderFormFields: (
props: Pick<UseFormMethods<FormData>, 'errors' | 'register' | 'control'> & {
groups: string[];
groupsLoading: boolean;
},
) => React.ReactNode;
};
function generateEntities(
entities: PartialEntity[],
componentName: string,
owner: string,
): Entity[] {
return entities.map(e => ({
...e,
apiVersion: e.apiVersion!,
kind: e.kind!,
metadata: {
...e.metadata,
name: componentName,
namespace: e.metadata?.namespace ?? 'default',
},
spec: {
...e.spec,
owner,
},
}));
}
export const StepPrepareCreatePullRequest = ({
analyzeResult,
onPrepare,
onGoBack,
renderFormFields,
defaultTitle,
defaultBody,
}: Props) => {
const classes = useStyles();
const catalogApi = useApi(catalogApiRef);
const catalogInfoApi = useApi(catalogImportApiRef);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string>();
const { loading: groupsLoading, value: groups } = useAsync(async () => {
const groupEntities = await catalogApi.getEntities({
filter: { kind: 'group' },
});
// TODO: defaultKind (=group), defaultNamespace (=same as entity)
return groupEntities.items.map(e => serializeEntityRef(e) as string).sort();
});
const handleResult = useCallback(
async (data: FormData) => {
setSubmitted(true);
try {
const pr = await catalogInfoApi.submitPullRequest({
repositoryUrl: analyzeResult.url,
title: data.title,
body: data.body,
fileContent: generateEntities(
analyzeResult.generatedEntities,
data.componentName,
data.owner,
)
.map(e => YAML.stringify(e))
.join('---\n'),
});
onPrepare(
{
type: 'repository',
url: analyzeResult.url,
integrationType: analyzeResult.integrationType,
pullRequest: {
url: pr.link,
},
locations: [
{
target: pr.location,
entities: generateEntities(
analyzeResult.generatedEntities,
data.componentName,
data.owner,
).map(e => ({
kind: e.kind,
namespace: e.metadata.namespace!,
name: e.metadata.name,
})),
},
],
},
{ notRepeatable: true },
);
} catch (e) {
setError(e.message);
setSubmitted(false);
}
},
[
analyzeResult.generatedEntities,
analyzeResult.integrationType,
analyzeResult.url,
catalogInfoApi,
onPrepare,
],
);
return (
<>
<Typography>
You entered a link to a {analyzeResult.integrationType} repository but
we didn't found a <code>catalog-info.yaml</code>. Use this form to
create a Pull Request that creates an initial{' '}
<code>catalog-info.yaml</code>.
</Typography>
<PreparePullRequestForm<FormData>
onSubmit={handleResult}
defaultValues={{
title: defaultTitle,
body: defaultBody,
owner:
(analyzeResult.generatedEntities[0]?.spec?.owner as string) || '',
componentName:
analyzeResult.generatedEntities[0]?.metadata?.name || '',
}}
render={({ values, errors, control, register }) => (
<>
{renderFormFields({
errors,
register,
control,
groups: groups ?? [],
groupsLoading,
})}
<Box marginTop={2}>
<Typography variant="h6">Preview Pull Request</Typography>
</Box>
<PreviewPullRequestComponent
title={values.title}
description={values.body}
classes={{
card: classes.previewCard,
cardContent: classes.previewCardContent,
}}
/>
<Box marginTop={2} marginBottom={1}>
<Typography variant="h6">Preview Entities</Typography>
</Box>
<PreviewCatalogInfoComponent
entities={generateEntities(
analyzeResult.generatedEntities,
values.componentName,
values.owner,
)}
repositoryUrl={analyzeResult.url}
classes={{
card: classes.previewCard,
cardContent: classes.previewCardContent,
}}
/>
{error && <FormHelperText error>{error}</FormHelperText>}
<Grid container spacing={0}>
{onGoBack && (
<BackButton onClick={onGoBack} disabled={submitted} />
)}
<NextButton
type="submit"
disabled={Boolean(errors.title || errors.body || errors.owner)}
loading={submitted}
>
Create PR
</NextButton>
</Grid>
</>
)}
/>
</>
);
};
@@ -0,0 +1,21 @@
/*
* Copyright 2021 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 { AutocompleteTextField } from './AutocompleteTextField';
export { PreparePullRequestForm } from './PreparePullRequestForm';
export { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent';
export { PreviewPullRequestComponent } from './PreviewPullRequestComponent';
export { StepPrepareCreatePullRequest } from './StepPrepareCreatePullRequest';
@@ -0,0 +1,202 @@
/*
* 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 { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { AnalyzeResult } from '../../api';
import { StepPrepareSelectLocations } from './StepPrepareSelectLocations';
describe('<StepPrepareSelectLocations />', () => {
const analyzeResult = {
type: 'locations',
locations: [
{
target: 'url',
entities: [
{
kind: 'component',
namespace: 'default',
name: 'name',
},
],
},
{
target: 'url-2',
entities: [
{
kind: 'component',
namespace: 'default',
name: 'name',
},
{
kind: 'api',
namespace: 'default',
name: 'name',
},
],
},
],
} as Extract<AnalyzeResult, { type: 'locations' }>;
beforeEach(() => {
jest.resetAllMocks();
});
it('renders without exploding', async () => {
const { getByRole } = render(
<StepPrepareSelectLocations
analyzeResult={analyzeResult}
onPrepare={() => undefined}
onGoBack={() => undefined}
/>,
);
expect(getByRole('button', { name: /Review/i })).toBeDisabled();
});
it('should select and deselect all', async () => {
const { getByRole, getAllByRole } = render(
<StepPrepareSelectLocations
analyzeResult={analyzeResult}
onPrepare={() => undefined}
onGoBack={() => undefined}
/>,
);
const checkboxes = getAllByRole('checkbox');
checkboxes.forEach(c => expect(c).not.toBeChecked());
expect(getByRole('button', { name: /Review/i })).toBeDisabled();
await act(async () => {
userEvent.click(getByRole('button', { name: /Select All/i }));
});
checkboxes.forEach(c => expect(c).toBeChecked());
expect(getByRole('button', { name: /Review/i })).not.toBeDisabled();
await act(async () => {
userEvent.click(getByRole('button', { name: /Select All/i }));
});
checkboxes.forEach(c => expect(c).not.toBeChecked());
expect(getByRole('button', { name: /Review/i })).toBeDisabled();
});
it('should preselect prepared locations', async () => {
const { getAllByRole } = render(
<StepPrepareSelectLocations
analyzeResult={analyzeResult}
prepareResult={{
...analyzeResult,
locations: [...analyzeResult.locations.slice(0, 1)],
}}
onPrepare={() => undefined}
onGoBack={() => undefined}
/>,
);
const checkboxes = getAllByRole('checkbox');
expect(checkboxes[0]).not.toBeChecked();
expect(checkboxes[1]).toBeChecked();
expect(checkboxes[2]).not.toBeChecked();
});
it('should select items', async () => {
const { getAllByRole } = render(
<StepPrepareSelectLocations
analyzeResult={analyzeResult}
onPrepare={() => undefined}
onGoBack={() => undefined}
/>,
);
const checkboxes = getAllByRole('checkbox');
checkboxes.forEach(c => expect(c).not.toBeChecked());
await act(async () => {
userEvent.click(checkboxes[1]);
});
expect(checkboxes[0]).not.toBeChecked();
expect(checkboxes[1]).toBeChecked();
expect(checkboxes[2]).not.toBeChecked();
await act(async () => {
userEvent.click(checkboxes[1]);
});
checkboxes.forEach(c => expect(c).not.toBeChecked());
});
it('should go back', async () => {
const onGoBack = jest.fn();
const { getByRole } = render(
<StepPrepareSelectLocations
analyzeResult={analyzeResult}
onPrepare={() => undefined}
onGoBack={onGoBack}
/>,
);
await act(async () => {
userEvent.click(getByRole('button', { name: /Back/i }));
});
expect(onGoBack).toBeCalledTimes(1);
});
it('should submit', async () => {
const onPrepare = jest.fn();
const { getAllByRole, getByRole } = render(
<StepPrepareSelectLocations
analyzeResult={analyzeResult}
onPrepare={onPrepare}
onGoBack={() => undefined}
/>,
);
const checkboxes = getAllByRole('checkbox');
await act(async () => {
userEvent.click(checkboxes[1]);
});
await act(async () => {
userEvent.click(getByRole('button', { name: /Review/i }));
});
expect(onPrepare).toBeCalledTimes(1);
expect(onPrepare.mock.calls[0][0]).toMatchObject({
type: 'locations',
locations: [
{
target: 'url',
entities: [
{
kind: 'component',
namespace: 'default',
name: 'name',
},
],
},
],
} as Extract<AnalyzeResult, { type: 'locations' }>);
});
});
@@ -0,0 +1,124 @@
/*
* Copyright 2021 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 {
Checkbox,
Grid,
ListItem,
ListItemIcon,
ListItemText,
Typography,
} from '@material-ui/core';
import React, { useCallback, useState } from 'react';
import { AnalyzeResult } from '../../api';
import { BackButton, NextButton } from '../Buttons';
import { EntityListComponent } from '../EntityListComponent';
import { PrepareResult } from '../useImportState';
type Props = {
analyzeResult: Extract<AnalyzeResult, { type: 'locations' }>;
prepareResult?: PrepareResult;
onPrepare: (result: PrepareResult) => void;
onGoBack?: () => void;
};
/**
* A form that lets a user select one of a list of locations to import
*
* @param analyzeResult the result of the analysis
* @param prepareResult the selectected locations from a previous step
* @param onPrepare called after the selection
* @param onGoBack called to go back to the previous step
*/
export const StepPrepareSelectLocations = ({
analyzeResult,
prepareResult,
onPrepare,
onGoBack,
}: Props) => {
const [selectedUrls, setSelectedUrls] = useState<string[]>(
prepareResult?.locations.map(l => l.target) || [],
);
const handleResult = useCallback(async () => {
onPrepare({
type: 'locations',
locations: analyzeResult.locations.filter((l: any) =>
selectedUrls.includes(l.target),
),
});
}, [analyzeResult.locations, onPrepare, selectedUrls]);
const onItemClick = (url: string) => {
setSelectedUrls(urls =>
urls.includes(url) ? urls.filter(u => u !== url) : urls.concat(url),
);
};
const onSelectAll = () => {
setSelectedUrls(urls =>
urls.length < analyzeResult.locations.length
? analyzeResult.locations.map(l => l.target)
: [],
);
};
return (
<>
<Typography>
Select one or more locations that are present in your git repository:
</Typography>
<EntityListComponent
firstListItem={
<ListItem dense button onClick={onSelectAll}>
<ListItemIcon>
<Checkbox
edge="start"
checked={selectedUrls.length === analyzeResult.locations.length}
indeterminate={
selectedUrls.length > 0 &&
selectedUrls.length < analyzeResult.locations.length
}
tabIndex={-1}
disableRipple
/>
</ListItemIcon>
<ListItemText primary="Select All" />
</ListItem>
}
onItemClick={onItemClick}
locations={analyzeResult.locations}
locationListItemIcon={target => (
<Checkbox
edge="start"
checked={selectedUrls.includes(target)}
tabIndex={-1}
disableRipple
/>
)}
collapsed
/>
<Grid container spacing={0}>
{onGoBack && <BackButton onClick={onGoBack} />}
<NextButton disabled={selectedUrls.length === 0} onClick={handleResult}>
Review
</NextButton>
</Grid>
</>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 { StepPrepareSelectLocations } from './StepPrepareSelectLocations';
@@ -0,0 +1,134 @@
/*
* Copyright 2021 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 { configApiRef, Link, useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { FormHelperText, Grid, Typography } from '@material-ui/core';
import LocationOnIcon from '@material-ui/icons/LocationOn';
import React, { useCallback, useState } from 'react';
import { BackButton, NextButton } from '../Buttons';
import { EntityListComponent } from '../EntityListComponent';
import { PrepareResult, ReviewResult } from '../useImportState';
type Props = {
prepareResult: PrepareResult;
onReview: (result: ReviewResult) => void;
onGoBack?: () => void;
};
export const StepReviewLocation = ({
prepareResult,
onReview,
onGoBack,
}: Props) => {
const catalogApi = useApi(catalogApiRef);
const configApi = useApi(configApiRef);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string>();
const handleImport = useCallback(async () => {
setSubmitted(true);
try {
const result = await Promise.all(
prepareResult.locations.map(l =>
catalogApi.addLocation({
type: 'url',
target: l.target,
presence:
prepareResult.type === 'repository' ? 'optional' : 'required',
}),
),
);
onReview({
...prepareResult,
locations: result.map(r => ({
target: r.location.target,
entities: r.entities,
})),
});
} catch (e) {
// TODO: this error should be handled differently. We add it as 'optional' and
// it is not uncommon that a PR has not been merged yet.
if (
prepareResult.type === 'repository' &&
e.message.startsWith(
'Location was added but has no entities specified yet',
)
) {
onReview({
...prepareResult,
locations: prepareResult.locations.map(l => ({
target: l.target,
entities: [],
})),
});
} else {
setError(e.message);
setSubmitted(false);
}
}
}, [prepareResult, onReview, catalogApi]);
return (
<>
{prepareResult.type === 'repository' && (
<>
<Typography paragraph>
The following Pull Request has been opened:{' '}
<Link
to={prepareResult.pullRequest.url}
target="_blank"
rel="noopener noreferrer"
>
{prepareResult.pullRequest.url}
</Link>
</Typography>
<Typography paragraph>
You can already import the location and {appTitle} will fetch the
entities as soon as the Pull Request is merged.
</Typography>
</>
)}
<Typography>
The following entities will be added to the catalog:
</Typography>
<EntityListComponent
locations={prepareResult.locations}
locationListItemIcon={() => <LocationOnIcon />}
/>
{error && <FormHelperText error>{error}</FormHelperText>}
<Grid container spacing={0}>
{onGoBack && <BackButton onClick={onGoBack} disabled={submitted} />}
<NextButton
disabled={submitted}
loading={submitted}
onClick={() => handleImport()}
>
Import
</NextButton>
</Grid>
</>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 { StepReviewLocation } from './StepReviewLocation';
@@ -0,0 +1,20 @@
/*
* Copyright 2021 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 * from './ImportStepper';
export * from './EntityListComponent';
export * from './StepInitAnalyzeUrl';
export * from './StepPrepareCreatePullRequest';
@@ -0,0 +1,372 @@
/*
* 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 { Entity, EntityName } from '@backstage/catalog-model';
import { cleanup } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react-hooks';
import { AnalyzeResult } from '../api';
import {
ImportState,
PrepareResult,
ReviewResult,
useImportState,
} from './useImportState';
describe('useImportState', () => {
const as = <T extends 'analyze' | 'prepare' | 'review' | 'finish'>(
curr: ImportState,
_: T,
) => curr as Extract<ImportState, { activeState: T }>;
const locationAP: AnalyzeResult & PrepareResult = {
type: 'locations',
locations: [
{
target: 'https://0',
entities: [] as EntityName[],
},
],
};
const locationR: ReviewResult = {
type: 'locations',
locations: [
{
target: 'https://0',
entities: [] as Entity[],
},
],
};
it('should use initial url', async () => {
const { result } = renderHook(() =>
useImportState({ initialUrl: 'http://my-url' }),
);
await cleanup();
expect(result.current).toMatchObject({
activeFlow: 'unknown',
activeStepNumber: 0,
analysisUrl: 'http://my-url',
activeState: 'analyze',
analyzeResult: undefined,
prepareResult: undefined,
reviewResult: undefined,
});
});
describe('onAnalysis & onPrepare & onReview & onReset', () => {
it('should work', async () => {
const { result } = renderHook(() => useImportState());
await cleanup();
expect(result.current).toMatchObject({
activeFlow: 'unknown',
activeStepNumber: 0,
analysisUrl: undefined,
activeState: 'analyze',
analyzeResult: undefined,
prepareResult: undefined,
reviewResult: undefined,
});
act(() => {
as(result.current, 'analyze').onAnalysis(
'single-location',
'http://my-url',
locationAP,
);
});
expect(result.current).toMatchObject({
activeFlow: 'single-location',
activeStepNumber: 1,
analysisUrl: 'http://my-url',
activeState: 'prepare',
analyzeResult: locationAP,
prepareResult: undefined,
reviewResult: undefined,
});
act(() => {
as(result.current, 'prepare').onPrepare(locationAP);
});
expect(result.current).toMatchObject({
activeFlow: 'single-location',
activeStepNumber: 2,
analysisUrl: 'http://my-url',
activeState: 'review',
analyzeResult: locationAP,
prepareResult: locationAP,
reviewResult: undefined,
});
act(() => {
as(result.current, 'review').onReview(locationR);
});
expect(result.current).toMatchObject({
activeFlow: 'single-location',
activeStepNumber: 3,
analysisUrl: 'http://my-url',
activeState: 'finish',
analyzeResult: locationAP,
prepareResult: locationAP,
reviewResult: locationR,
});
act(() => result.current.onReset());
expect(result.current).toMatchObject({
activeFlow: 'unknown',
activeStepNumber: 0,
analysisUrl: undefined,
activeState: 'analyze',
analyzeResult: undefined,
prepareResult: undefined,
reviewResult: locationR,
});
});
it('should work skipped', async () => {
const { result } = renderHook(() => useImportState());
await cleanup();
expect(result.current).toMatchObject({
activeFlow: 'unknown',
activeStepNumber: 0,
analysisUrl: undefined,
activeState: 'analyze',
analyzeResult: undefined,
prepareResult: undefined,
reviewResult: undefined,
});
act(() => {
as(result.current, 'analyze').onAnalysis(
'single-location',
'http://my-url',
locationAP,
{
prepareResult: locationAP,
},
);
});
expect(result.current).toMatchObject({
activeFlow: 'single-location',
activeStepNumber: 2,
analysisUrl: 'http://my-url',
activeState: 'review',
analyzeResult: locationAP,
prepareResult: locationAP,
reviewResult: undefined,
});
act(() => {
as(result.current, 'review').onReview(locationR);
});
expect(result.current).toMatchObject({
activeFlow: 'single-location',
activeStepNumber: 3,
analysisUrl: 'http://my-url',
activeState: 'finish',
analyzeResult: locationAP,
prepareResult: locationAP,
reviewResult: locationR,
});
});
it('should ignore on invalid state', async () => {
const { result } = renderHook(() => useImportState());
await cleanup();
// state 'analyze'
act(() => {
as(result.current, 'prepare').onPrepare(locationAP);
as(result.current, 'review').onReview(locationR);
});
expect(result.current.activeState).toBe('analyze');
expect(result.current.activeFlow).toBe('unknown');
// switch state to 'prepare'
act(() =>
as(result.current, 'analyze').onAnalysis(
'single-location',
'http://my-url',
locationAP,
),
);
// state 'prepare'
act(() => {
as(result.current, 'analyze').onAnalysis(
'multiple-locations',
'http://my-url',
locationAP,
);
as(result.current, 'review').onReview(locationR);
});
expect(result.current.activeState).toBe('prepare');
expect(result.current.activeFlow).toBe('single-location');
// switch to 'review'
act(() => as(result.current, 'prepare').onPrepare(locationAP));
// state 'review'
act(() => {
as(result.current, 'analyze').onAnalysis(
'multiple-locations',
'http://my-url',
locationAP,
);
as(result.current, 'prepare').onPrepare({
type: 'locations',
locations: [],
});
});
expect(result.current.activeState).toBe('review');
expect(result.current.activeFlow).toBe('single-location');
expect(
as(result.current, 'prepare').prepareResult!.locations,
).not.toEqual([]);
// switch to 'finish'
act(() => as(result.current, 'review').onReview(locationR));
expect(result.current.activeState).toBe('finish');
expect(result.current.activeFlow).toBe('single-location');
});
});
describe('onGoBack', () => {
it('should work', async () => {
const { result } = renderHook(() => useImportState());
await cleanup();
expect(result.current.activeStepNumber).toBe(0);
expect(result.current.onGoBack).toBeUndefined();
act(() =>
as(result.current, 'analyze').onAnalysis(
'single-location',
'http://my-url',
locationAP,
),
);
expect(result.current.activeStepNumber).toBe(1);
expect(result.current.onGoBack).not.toBeUndefined();
act(() => result.current.onGoBack!());
expect(result.current.activeStepNumber).toBe(0);
act(() =>
as(result.current, 'analyze').onAnalysis(
'single-location',
'http://my-url',
locationAP,
),
);
act(() => as(result.current, 'prepare').onPrepare(locationAP));
expect(result.current.activeStepNumber).toBe(2);
expect(result.current.onGoBack).not.toBeUndefined();
act(() => result.current.onGoBack!());
expect(result.current.activeStepNumber).toBe(1);
act(() => as(result.current, 'prepare').onPrepare(locationAP));
act(() => as(result.current, 'review').onReview(locationR));
expect(result.current.activeStepNumber).toBe(3);
});
it('should work for skipped', async () => {
const { result } = renderHook(() => useImportState());
await cleanup();
expect(result.current.activeStepNumber).toBe(0);
expect(result.current.onGoBack).toBeUndefined();
act(() =>
as(result.current, 'analyze').onAnalysis(
'single-location',
'http://my-url',
locationAP,
{
prepareResult: locationAP,
},
),
);
expect(result.current.activeStepNumber).toBe(2);
expect(result.current.onGoBack).not.toBeUndefined();
act(() => result.current.onGoBack!());
expect(result.current.activeStepNumber).toBe(0);
expect(result.current.onGoBack).toBeUndefined();
});
describe('should consider prepareNotRepeatable', () => {
it('as true', async () => {
const { result } = renderHook(() => useImportState());
await cleanup();
expect(result.current.onGoBack).toBeUndefined();
act(() =>
as(result.current, 'analyze').onAnalysis(
'multiple-locations',
'http://my-url',
locationAP,
),
);
act(() =>
as(result.current, 'prepare').onPrepare(locationAP, {
notRepeatable: true,
}),
);
expect(result.current.onGoBack).toBeUndefined();
});
it('as false', async () => {
const { result } = renderHook(() => useImportState());
await cleanup();
expect(result.current.onGoBack).toBeUndefined();
act(() =>
as(result.current, 'analyze').onAnalysis(
'multiple-locations',
'http://my-url',
locationAP,
),
);
act(() =>
as(result.current, 'prepare').onPrepare(locationAP, {
notRepeatable: false,
}),
);
expect(result.current.onGoBack).not.toBeUndefined();
});
});
});
});
@@ -0,0 +1,291 @@
/*
* Copyright 2021 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 { Entity, EntityName } from '@backstage/catalog-model';
import { useReducer } from 'react';
import { AnalyzeResult } from '../api';
// the configuration of the stepper
export type ImportFlows =
| 'unknown'
| 'single-location'
| 'multiple-locations'
| 'no-location';
// the available states of the stepper
type ImportStateTypes = 'analyze' | 'prepare' | 'review' | 'finish';
// result of the prepare state
export type PrepareResult =
| {
type: 'locations';
locations: Array<{
target: string;
entities: EntityName[];
}>;
}
| {
type: 'repository';
url: string;
integrationType: string;
pullRequest: {
url: string;
};
locations: Array<{
target: string;
entities: EntityName[];
}>;
};
// result of the review result
export type ReviewResult =
| {
type: 'locations';
locations: Array<{
target: string;
entities: Entity[];
}>;
}
| {
type: 'repository';
url: string;
integrationType: string;
pullRequest: {
url: string;
};
locations: Array<{
target: string;
entities: Entity[];
}>;
};
// function type for the 'analysis' -> 'prepare'/'review' transition
type onAnalysisFn = (
flow: ImportFlows,
url: string,
result: AnalyzeResult,
opts?: { prepareResult?: PrepareResult },
) => void;
// function type for the 'prepare' -> 'review' transition
type onPrepareFn = (
result: PrepareResult,
opts?: { notRepeatable?: boolean },
) => void;
// function type for the 'review' -> 'finish' transition
type onReviewFn = (result: ReviewResult) => void;
// the type interfaces that are available in each state. every state provides
// already known information and means to go to the next, or the previous step.
type State =
| {
activeState: 'analyze';
onAnalysis: onAnalysisFn;
}
| {
activeState: 'prepare';
analyzeResult: AnalyzeResult;
prepareResult?: PrepareResult;
onPrepare: onPrepareFn;
}
| {
activeState: 'review';
analyzeResult: AnalyzeResult;
prepareResult: PrepareResult;
onReview: onReviewFn;
}
| {
activeState: 'finish';
analyzeResult: AnalyzeResult;
prepareResult: PrepareResult;
reviewResult: ReviewResult;
};
export type ImportState = State & {
activeFlow: ImportFlows;
activeStepNumber: number;
analysisUrl?: string;
onGoBack?: () => void;
onReset: () => void;
};
type ReducerActions =
| { type: 'onAnalysis'; args: Parameters<onAnalysisFn> }
| { type: 'onPrepare'; args: Parameters<onPrepareFn> }
| { type: 'onReview'; args: Parameters<onReviewFn> }
| { type: 'onGoBack' }
| { type: 'onReset'; initialUrl?: string };
type ReducerState = {
activeFlow: ImportFlows;
activeState: ImportStateTypes;
analysisUrl?: string;
analyzeResult?: AnalyzeResult;
prepareResult?: PrepareResult;
reviewResult?: ReviewResult;
previousStates: ImportStateTypes[];
};
function init(initialUrl?: string): ReducerState {
return {
activeFlow: 'unknown',
activeState: 'analyze',
analysisUrl: initialUrl,
previousStates: [],
};
}
function reducer(state: ReducerState, action: ReducerActions): ReducerState {
switch (action.type) {
case 'onAnalysis': {
if (state.activeState !== 'analyze') {
return state;
}
const { activeState, previousStates } = state;
const [activeFlow, analysisUrl, analyzeResult, opts] = action.args;
return {
...state,
analysisUrl,
activeFlow,
analyzeResult,
prepareResult: opts?.prepareResult,
activeState: opts?.prepareResult === undefined ? 'prepare' : 'review',
previousStates: previousStates.concat(activeState),
};
}
case 'onPrepare': {
if (state.activeState !== 'prepare') {
return state;
}
const { activeState, previousStates } = state;
const [prepareResult, opts] = action.args;
return {
...state,
prepareResult,
activeState: 'review',
previousStates: opts?.notRepeatable
? []
: previousStates.concat(activeState),
};
}
case 'onReview': {
if (state.activeState !== 'review') {
return state;
}
const { activeState, previousStates } = state;
const [reviewResult] = action.args;
return {
...state,
reviewResult,
activeState: 'finish',
previousStates: previousStates.concat(activeState),
};
}
case 'onGoBack': {
const { activeState, previousStates } = state;
return {
...state,
activeState:
previousStates.length > 0
? previousStates[previousStates.length - 1]
: activeState,
previousStates: previousStates.slice(0, previousStates.length - 1),
};
}
case 'onReset':
return {
...init(action.initialUrl),
// we keep the old reviewResult since the form is animated and an
// undefined value might crash the last step.
reviewResult: state.reviewResult,
};
default:
throw new Error();
}
}
/**
* A hook that manages the state machine of the form. It handles different flows
* which each can implement up to four states:
* 1. analyze
* 2. preview (skippable from analyze->review)
* 3. review
* 4. finish
*
* @param options options
*/
export const useImportState = (options?: {
initialUrl?: string;
}): ImportState => {
const [state, dispatch] = useReducer(reducer, options?.initialUrl, init);
const { activeFlow, activeState, analysisUrl, previousStates } = state;
return {
activeFlow,
activeState,
activeStepNumber: ['analyze', 'prepare', 'review', 'finish'].indexOf(
activeState,
),
analysisUrl: analysisUrl,
analyzeResult: state.analyzeResult!,
prepareResult: state.prepareResult!,
reviewResult: state.reviewResult!,
onAnalysis: (flow, url, result, opts) =>
dispatch({
type: 'onAnalysis',
args: [flow, url, result, opts],
}),
onPrepare: (result, opts) =>
dispatch({
type: 'onPrepare',
args: [result, opts],
}),
onReview: result => dispatch({ type: 'onReview', args: [result] }),
onGoBack:
previousStates.length > 0
? () => dispatch({ type: 'onGoBack' })
: undefined,
onReset: () =>
dispatch({ type: 'onReset', initialUrl: options?.initialUrl }),
};
};
+1
View File
@@ -20,4 +20,5 @@ export {
CatalogImportPage,
} from './plugin';
export { Router } from './components/Router';
export * from './components';
export * from './api';
@@ -1,22 +0,0 @@
{
"existingEntityFiles": [],
"generateEntities": [
{
"entity": {
"apiVersion": "backstage.io/v1alpha1",
"kind": "Component",
"metadata": {
"name": "somerepo",
"annotations": {
"github.com/project-slug": "someuser/somerepo"
}
},
"spec": {
"type": "other",
"lifecycle": "unknown"
}
},
"fields": []
}
]
}
@@ -1,39 +0,0 @@
{
"location": {
"id": "d4a64359-a709-4c91-a9de-0905a033bf22",
"type": "url",
"target": "https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml"
},
"entities": [
{
"metadata": {
"namespace": "default",
"annotations": {
"backstage.io/managed-by-location": "url:https://github.com/someusername/somerepo/blob/master/src/catalog-info.yaml",
"github.com/project-slug": "someusername/somerepo"
},
"name": "somerepo",
"uid": "e992d5ee-7c70-4316-90cf-325f1a0a5146",
"etag": "YWE2M2Q5MzgtNjdkNi00N2QwLWJkZjYtNDM0MTMzMDI4Y2I0",
"generation": 1
},
"apiVersion": "backstage.io/v1alpha1",
"kind": "Component",
"spec": {
"type": "other",
"lifecycle": "unknown",
"owner": "unknown"
},
"relations": [
{
"target": {
"kind": "group",
"namespace": "default",
"name": "unknown"
},
"type": "ownedBy"
}
]
}
]
}
+15 -7
View File
@@ -15,17 +15,17 @@
*/
import {
identityApiRef,
configApiRef,
createApiFactory,
createPlugin,
createRoutableExtension,
createRouteRef,
discoveryApiRef,
githubAuthApiRef,
identityApiRef,
configApiRef,
createRoutableExtension,
} from '@backstage/core';
import { catalogImportApiRef } from './api/CatalogImportApi';
import { CatalogImportClient } from './api/CatalogImportClient';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { catalogImportApiRef, CatalogImportClient } from './api';
export const rootRouteRef = createRouteRef({
path: '',
@@ -42,13 +42,21 @@ export const catalogImportPlugin = createPlugin({
githubAuthApi: githubAuthApiRef,
identityApi: identityApiRef,
configApi: configApiRef,
catalogApi: catalogApiRef,
},
factory: ({ discoveryApi, githubAuthApi, identityApi, configApi }) =>
factory: ({
discoveryApi,
githubAuthApi,
identityApi,
configApi,
catalogApi,
}) =>
new CatalogImportClient({
discoveryApi,
githubAuthApi,
identityApi,
configApi,
identityApi,
catalogApi,
}),
}),
],
@@ -1,121 +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 * as YAML from 'yaml';
import { useApi, configApiRef } from '@backstage/core';
import { catalogImportApiRef } from '../api/CatalogImportApi';
import { ConfigSpec } from '../components/ImportComponentPage';
import parseGitUrl from 'git-url-parse';
// TODO: (O5ten) Refactor into a core API instead of direct usage like this
// https://github.com/backstage/backstage/pull/3613#issuecomment-7408929430
import {
GitHubIntegrationConfig,
readGitHubIntegrationConfigs,
} from '@backstage/integration';
export function useGithubRepos() {
const api = useApi(catalogImportApiRef);
const config = useApi(configApiRef);
const getGithubIntegrationConfig = (location: string) => {
const {
name: repoName,
owner: ownerName,
resource: hostname,
} = parseGitUrl(location);
const configs = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
);
const githubIntegrationConfig = configs.find(v => v.host === hostname);
if (!githubIntegrationConfig) {
throw new Error(
`Unable to locate github-integration for repo-location, ${location}`,
);
}
return {
repoName,
ownerName,
githubIntegrationConfig,
};
};
const submitPrToRepo = async (selectedRepo: ConfigSpec) => {
const {
repoName,
ownerName,
githubIntegrationConfig,
} = getGithubIntegrationConfig(selectedRepo.location);
const submitPRResponse = await api
.submitPrToRepo({
owner: ownerName,
repo: repoName,
fileContent: selectedRepo.config
.map(entity => `---\n${YAML.stringify(entity)}`)
.join('\n'),
githubIntegrationConfig,
})
.catch(e => {
throw new Error(`Failed to submit PR to repo, ${e.message}`);
});
await api
.createRepositoryLocation({
location: submitPRResponse.location,
})
.catch(e => {
throw new Error(`Failed to create repository location, ${e.message}`);
});
return submitPRResponse;
};
const checkForExistingCatalogInfo = async (
location: string,
): Promise<{ exists: boolean; url?: string }> => {
let githubConfig: {
repoName: string;
ownerName: string;
githubIntegrationConfig: GitHubIntegrationConfig;
};
try {
githubConfig = getGithubIntegrationConfig(location);
} catch (e) {
return { exists: false };
}
return await api
.checkForExistingCatalogInfo({
owner: githubConfig.ownerName,
repo: githubConfig.repoName,
githubIntegrationConfig: githubConfig.githubIntegrationConfig,
})
.catch(e => {
throw new Error(
`Failed to inspect repository for existing catalog-info.yaml, ${e.message}`,
);
});
};
return {
submitPrToRepo,
checkForExistingCatalogInfo,
generateEntityDefinitions: (repo: string) =>
api.generateEntityDefinitions({ repo }),
addLocation: (location: string) =>
api.createRepositoryLocation({ location }),
};
}
@@ -1,33 +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 { ComponentIdValidators } from './validate';
describe('ComponentIdValidators', () => {
describe('httpsValidator validator', () => {
const errorMessage = 'Must start with https://.';
test.each([
[true, 'https://example.com'],
[errorMessage, 'http://example.com'],
[errorMessage, 'example.com'],
[errorMessage, 'www.example.com'],
[errorMessage, ''],
[errorMessage, undefined],
])('should return %p for %s', (expected: string | boolean, arg: any) => {
expect(ComponentIdValidators.httpsValidator(arg)).toBe(expected);
});
});
});
@@ -15,13 +15,12 @@
*/
import {
Entity,
EntityName,
ENTITY_DEFAULT_NAMESPACE,
EntityName,
} from '@backstage/catalog-model';
import { Link } from '@material-ui/core';
import { Link } from '@backstage/core';
import React from 'react';
import { generatePath } from 'react-router';
import { Link as RouterLink } from 'react-router-dom';
import { entityRoute } from '../../routes';
import { formatEntityRefTitle } from './format';
@@ -31,41 +30,42 @@ type EntityRefLinkProps = {
children?: React.ReactNode;
};
export const EntityRefLink = ({
entityRef,
defaultKind,
children,
}: EntityRefLinkProps) => {
let kind;
let namespace;
let name;
export const EntityRefLink = React.forwardRef<any, EntityRefLinkProps>(
(props, ref) => {
const { entityRef, defaultKind, children } = props;
if ('metadata' in entityRef) {
kind = entityRef.kind;
namespace = entityRef.metadata.namespace;
name = entityRef.metadata.name;
} else {
kind = entityRef.kind;
namespace = entityRef.namespace;
name = entityRef.name;
}
let kind;
let namespace;
let name;
kind = kind.toLowerCase();
if ('metadata' in entityRef) {
kind = entityRef.kind;
namespace = entityRef.metadata.namespace;
name = entityRef.metadata.name;
} else {
kind = entityRef.kind;
namespace = entityRef.namespace;
name = entityRef.name;
}
const routeParams = {
kind,
namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE,
name,
};
kind = kind.toLowerCase();
// TODO: Use useRouteRef here to generate the path
return (
<Link
component={RouterLink}
to={generatePath(`/catalog/${entityRoute.path}`, routeParams)}
>
{children}
{!children && formatEntityRefTitle(entityRef, { defaultKind })}
</Link>
);
};
const routeParams = {
kind,
namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE,
name,
};
// TODO: Use useRouteRef here to generate the path
return (
<Link
ref={ref}
to={generatePath(`/catalog/${entityRoute.path}`, routeParams)}
{...props}
>
{children}
{!children && formatEntityRefTitle(entityRef, { defaultKind })}
</Link>
);
},
);