Merge branch 'master' into feat/api-auth-client
This commit is contained in:
@@ -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 />}
|
||||
/>
|
||||
```
|
||||
@@ -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 />}
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-client': patch
|
||||
---
|
||||
|
||||
Add the `presence` argument to the `CatalogApi` to be able to register optional locations.
|
||||
@@ -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`.
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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/>}
|
||||
|
||||
@@ -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.
|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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 |
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
-6
@@ -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 &
|
||||
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}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
};
|
||||
+2
-15
@@ -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>
|
||||
);
|
||||
|
||||
+65
@@ -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';
|
||||
+100
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+113
@@ -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();
|
||||
});
|
||||
});
|
||||
+67
@@ -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>
|
||||
);
|
||||
};
|
||||
+101
@@ -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();
|
||||
});
|
||||
});
|
||||
+56
@@ -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>
|
||||
);
|
||||
};
|
||||
+83
@@ -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();
|
||||
});
|
||||
});
|
||||
+40
@@ -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>
|
||||
);
|
||||
};
|
||||
+287
@@ -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 });
|
||||
});
|
||||
});
|
||||
+249
@@ -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';
|
||||
+202
@@ -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' }>);
|
||||
});
|
||||
});
|
||||
+124
@@ -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 }),
|
||||
};
|
||||
};
|
||||
@@ -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,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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user