& { loading?: boolean },
+) => {
+ const { loading, ...buttonProps } = props;
+ const classes = useStyles();
+
+ return (
+
+
+ {props.loading && (
+
+ )}
+ {props.loading}
+
+ );
+};
+
+export const BackButton = (props: ComponentProps) => {
+ const classes = useStyles();
+
+ return (
+
+ );
+};
diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx
deleted file mode 100644
index 9fb170c85c..0000000000
--- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx
+++ /dev/null
@@ -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 (
-
- {urlType(configFile.location) === 'tree' ? (
-
- Following config object will be submitted in a pull request to the
- repository{' '}
-
- {configFile.location}
- {' '}
- and added as a new location to the backend
-
- ) : (
-
- Following config object will be added as a new location to the backend{' '}
-
- {configFile.location}
-
-
- )}
-
-
- {urlType(configFile.location) === 'tree' ? (
- {YAML.stringify(configFile.config)}
- ) : (
-
- {configFile.config.map((entity: any, index: number) => {
- const entityPath = getEntityCatalogPath({
- entity,
- catalogRouteRef,
- });
- return (
-
-
-
- {entityPath}
-
- ),
- }}
- />
-
- {index < configFile.config.length - 1 && (
-
- )}
-
- );
- })}
-
- )}
-
-
- {submitting ? (
-
-
-
- ) : null}
-
-
- {errorOccurred ? (
-
- ) : null}
-
-
-
- );
-};
-
-export default ComponentConfigDisplay;
diff --git a/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx
new file mode 100644
index 0000000000..d94a1cad19
--- /dev/null
+++ b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx
@@ -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) {
+ return entities.sort((a, b) =>
+ formatEntityRefTitle(a).localeCompare(formatEntityRefTitle(b)),
+ );
+}
+
+function getEntityIcon(entity: { kind: string }): React.ReactElement {
+ switch (entity.kind.toLowerCase()) {
+ case 'api':
+ return ;
+
+ case 'component':
+ return ;
+
+ case 'domain':
+ return ;
+
+ case 'group':
+ return ;
+
+ case 'location':
+ return ;
+
+ case 'system':
+ return ;
+
+ case 'user':
+ return ;
+
+ default:
+ return ;
+ }
+}
+
+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([]);
+
+ const handleClick = (url: string) => {
+ setExpandedUrls(urls =>
+ urls.includes(url) ? urls.filter(u => u !== url) : urls.concat(url),
+ );
+ };
+
+ return (
+
+ {firstListItem}
+ {locations.map(r => (
+
+ onItemClick?.call(this, r.target)}
+ >
+ {locationListItemIcon(r.target)}
+
+
+
+ {collapsed && (
+
+ handleClick(r.target)}>
+ {expandedUrls.includes(r.target) ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+
+ {sortEntities(r.entities).map(entity => (
+
+ {getEntityIcon(entity)}
+
+
+ ))}
+
+
+
+ ))}
+
+ );
+};
diff --git a/plugins/catalog-import/src/util/validate.ts b/plugins/catalog-import/src/components/EntityListComponent/index.ts
similarity index 72%
rename from plugins/catalog-import/src/util/validate.ts
rename to plugins/catalog-import/src/components/EntityListComponent/index.ts
index 056131aaae..fc0efc5481 100644
--- a/plugins/catalog-import/src/util/validate.ts
+++ b/plugins/catalog-import/src/components/EntityListComponent/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx
deleted file mode 100644
index 9aef548856..0000000000
--- a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx
+++ /dev/null
@@ -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('', () => {
- let apis: ApiRegistry;
-
- const mockErrorApi: jest.Mocked = {
- 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(
-
-
- ,
- );
- }
-
- 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();
- });
-});
diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx
deleted file mode 100644
index b3ec050ea3..0000000000
--- a/plugins/catalog-import/src/components/ImportComponentForm.tsx
+++ /dev/null
@@ -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(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) => {
- 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 (
-
- );
-};
diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx
index 90974e671d..ed73d1c435 100644
--- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx
+++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx
@@ -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('', () => {
- const server = setupServer();
- msw.setupDefaultHandlers(server);
-
const identityApi = {
getUserId: () => {
return 'user';
@@ -85,198 +44,41 @@ describe('', () => {
},
};
- 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 = {
- 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(
-
-
- ,
- );
- }
-
- 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(
+
+
+ ,
),
- ).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();
});
});
diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx
index adae137a50..db6f8a8439 100644
--- a/plugins/catalog-import/src/components/ImportComponentPage.tsx
+++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx
@@ -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({
- type: 'tree',
- location: '',
- config: [],
- });
- const [endLink, setEndLink] = useState('');
- 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 (
@@ -92,9 +69,11 @@ export const ImportComponentPage = ({
software catalog.
-
-
+
+
+
- Ways to register an existing component
+ Enter the URL to your SCM repository to add it to {appTitle}.
-
- {manifestGenerationAvailable(configApi) && (
-
- GitHub Repo
-
- 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 (catalog-info.yaml) will
- be opened for you.
-
-
- )}
- {repos.length === 1 ? `${repos[0]} ` : ''} Repository &
- Entity File
+ Link to an existing entity file
+
+
+ Example:{' '}
+
+ https://github.com/backstage/backstage/blob/master/catalog-info.yaml
+
- 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.
+ {repos.length > 0 && (
+ <>
+
+ Link to a {repositoryString} repository
+
+
+ Example: https://github.com/backstage/backstage
+
+
+ The wizard discovers all catalog-info.yaml{' '}
+ files in the repository, previews the entities, and adds
+ them to the {appTitle} catalog.
+
+ {!opts?.pullRequest?.disable && (
+
+ If no entities are found, the wizard will prepare a Pull
+ Request that adds an example{' '}
+ catalog-info.yaml and prepares the {appTitle}{' '}
+ catalog to load all entities as soon as the Pull Request
+ is merged.
+
+ )}
+ >
+ )}
-
-
-
- ),
- },
- {
- step: 'Review',
- content: (
-
- ),
- },
- {
- step: 'Finish',
- content: (
-
- ),
- },
- ]}
- activeStep={activeStep}
- nextStep={nextStep}
- />
-
+
+
+
diff --git a/plugins/catalog-import/src/components/ImportFinished.tsx b/plugins/catalog-import/src/components/ImportFinished.tsx
deleted file mode 100644
index 9b0f8575a6..0000000000
--- a/plugins/catalog-import/src/components/ImportFinished.tsx
+++ /dev/null
@@ -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 (
-
-
-
- {type === 'tree'
- ? 'Pull requests have been successfully opened. You can start again to import more repositories'
- : 'Entity added to catalog successfully'}
-
-
-
- {type === 'tree' ? (
-
- View pull request on GitHub
-
- ) : null}
-
-
-
- );
-};
diff --git a/plugins/catalog-import/src/components/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper.tsx
deleted file mode 100644
index 03752fa7d4..0000000000
--- a/plugins/catalog-import/src/components/ImportStepper.tsx
+++ /dev/null
@@ -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 (
-
- {steps.map(({ step }) => {
- const stepProps: { completed?: boolean } = {};
- return (
-
- {step}
- {steps[activeStep].content}
-
- );
- })}
-
- );
-}
diff --git a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx
new file mode 100644
index 0000000000..f92f03c5cb
--- /dev/null
+++ b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx
@@ -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(
+ () => generateStepper(state.activeFlow, defaultStepper),
+ [generateStepper, state.activeFlow],
+ );
+
+ const render = (step: StepConfiguration) => {
+ return (
+
+ {step.stepLabel}
+ {step.content}
+
+ );
+ };
+
+ return (
+
+
+ {render(
+ states.analyze(
+ state as Extract,
+ { apis: { configApi }, opts },
+ ),
+ )}
+ {render(
+ states.prepare(
+ state as Extract,
+ { apis: { configApi }, opts },
+ ),
+ )}
+ {render(
+ states.review(
+ state as Extract,
+ { apis: { configApi }, opts },
+ ),
+ )}
+ {render(
+ states.finish(
+ state as Extract,
+ { apis: { configApi }, opts },
+ ),
+ )}
+
+
+ );
+};
diff --git a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx
new file mode 100644
index 0000000000..f2dd1d62f4
--- /dev/null
+++ b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx
@@ -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,
+ opts: { apis: StepperApis; opts?: StepperProviderOpts },
+ ) => StepConfiguration;
+ prepare: (
+ s: Extract,
+ opts: { apis: StepperApis; opts?: StepperProviderOpts },
+ ) => StepConfiguration;
+ review: (
+ s: Extract,
+ opts: { apis: StepperApis; opts?: StepperProviderOpts },
+ ) => StepConfiguration;
+ finish: (
+ s: Extract,
+ 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: (
+
+ Discovered Locations: 1
+
+ }
+ >
+ Select Locations
+
+ ),
+ 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: (
+
+ Discovered Locations: {state.analyzeResult.locations.length}
+
+ }
+ >
+ Select Locations
+
+ ),
+ content: (
+
+ ),
+ };
+ },
+ };
+
+ 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: Create Pull Request,
+ content: (
+ (
+ <>
+
+ Pull Request Details
+
+
+
+
+
+
+
+ Entity Configuration
+
+
+
+
+
+ >
+ )}
+ />
+ ),
+ };
+ },
+ };
+
+ default:
+ return defaults;
+ }
+}
+
+export const defaultStepper: StepperProvider = {
+ analyze: (state, { opts }) => ({
+ stepLabel: Select URL,
+ content: (
+
+ ),
+ }),
+
+ prepare: state => ({
+ stepLabel: (
+ Optional}>
+ Import Actions
+
+ ),
+ content: ,
+ }),
+
+ review: state => ({
+ stepLabel: Review,
+ content: (
+
+ ),
+ }),
+
+ finish: state => ({
+ stepLabel: Finish,
+ content: (
+
+ ),
+ }),
+};
diff --git a/plugins/catalog-import/src/util/urls.ts b/plugins/catalog-import/src/components/ImportStepper/index.ts
similarity index 62%
rename from plugins/catalog-import/src/util/urls.ts
rename to plugins/catalog-import/src/components/ImportStepper/index.ts
index c51dc0a999..8d205562a4 100644
--- a/plugins/catalog-import/src/util/urls.ts
+++ b/plugins/catalog-import/src/components/ImportStepper/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/Router.tsx b/plugins/catalog-import/src/components/Router.tsx
index 5175f8f077..294ccfcb31 100644
--- a/plugins/catalog-import/src/components/Router.tsx
+++ b/plugins/catalog-import/src/components/Router.tsx
@@ -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 }) => (
- }
- />
+ } />
);
diff --git a/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx
new file mode 100644
index 0000000000..cc5eb9adef
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepFinishImportLocation/StepFinishImportLocation.tsx
@@ -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' && (
+ <>
+
+ The following Pull Request has been opened:{' '}
+
+ {reviewResult.pullRequest.url}
+
+
+
+
+ Your entities will be imported as soon as the Pull Request is merged.
+
+ >
+ )}
+
+
+ The following entities have been added to the catalog:
+
+
+ }
+ withLinks
+ />
+
+
+ Register another
+
+ >
+);
diff --git a/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts b/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts
new file mode 100644
index 0000000000..5f3d9f425c
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepFinishImportLocation/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx
new file mode 100644
index 0000000000..bfb2434926
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx
@@ -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('', () => {
+ const catalogImportApi: jest.Mocked = {
+ analyzeUrl: jest.fn(),
+ submitPullRequest: jest.fn(),
+ };
+
+ const errorApi: jest.Mocked = {
+ post: jest.fn(),
+ error$: jest.fn(),
+ };
+
+ const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const location = {
+ target: 'url',
+ entities: [
+ {
+ kind: 'component',
+ namespace: 'default',
+ name: 'name',
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders without exploding', async () => {
+ const { getByRole } = render(
+ 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(
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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.',
+ ),
+ );
+ });
+});
diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx
new file mode 100644
index 0000000000..a33fe388d8
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx
@@ -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({
+ mode: 'onTouched',
+ defaultValues: {
+ url: analysisUrl,
+ },
+ });
+
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState(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 (
+
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts
new file mode 100644
index 0000000000..5d36c104c3
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx
new file mode 100644
index 0000000000..3231c3c6a1
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx
@@ -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 = {
+ name: TFieldValue;
+ options: string[];
+ required?: boolean;
+
+ control?: Control>;
+ errors?: FieldErrors>;
+ rules?: ValidationRules;
+
+ loading?: boolean;
+ loadingText?: string;
+
+ helperText?: React.ReactNode;
+ errorHelperText?: string;
+
+ textFieldProps?: Omit;
+};
+
+export const AutocompleteTextField = ({
+ name,
+ options,
+ required,
+ control,
+ errors,
+ rules,
+ loading = false,
+ loadingText,
+ helperText,
+ errorHelperText,
+ textFieldProps = {},
+}: Props) => {
+ return (
+ (
+ onChange(v || '')}
+ onBlur={onBlur}
+ value={value}
+ autoSelect
+ freeSolo
+ renderInput={params => (
+
+ {loading ? (
+
+ ) : null}
+ {params.InputProps.endAdornment}
+
+ ),
+ }}
+ {...textFieldProps}
+ />
+ )}
+ />
+ )}
+ />
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx
new file mode 100644
index 0000000000..0bf7746e6a
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.test.tsx
@@ -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('', () => {
+ it('renders without exploding', async () => {
+ const onSubmitFn = jest.fn();
+
+ const { getByRole } = render(
+ (
+ <>
+
+ {' '}
+ >
+ )}
+ 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(
+ (
+ <>
+
+
+ >
+ )}
+ 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(
+ (
+ <>
+
+ {errors.main && (
+
+ Error in required main field
+
+ )}
+ {' '}
+ >
+ )}
+ 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();
+ });
+});
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx
new file mode 100644
index 0000000000..9d59f4e10e
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreparePullRequestForm.tsx
@@ -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> = Pick<
+ UseFormOptions,
+ 'defaultValues'
+> & {
+ onSubmit: SubmitHandler;
+
+ render: (
+ props: Pick<
+ UseFormMethods,
+ 'errors' | 'register' | 'control'
+ > & {
+ values: UnpackNestedValue;
+ },
+ ) => 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
+>({
+ defaultValues,
+ onSubmit,
+ render,
+}: Props) => {
+ const { handleSubmit, watch, control, register, errors } = useForm<
+ TFieldValues
+ >({ mode: 'onTouched', defaultValues });
+
+ return (
+
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx
new file mode 100644
index 0000000000..5bc22dbd42
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.test.tsx
@@ -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('', () => {
+ it('renders without exploding', async () => {
+ const { getByText } = render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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();
+ });
+});
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx
new file mode 100644
index 0000000000..c10c011820
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx
@@ -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 (
+
+ {`${repositoryUrl.replace(
+ /[\/]*$/,
+ '',
+ )}/catalog-info.yaml`}
+ }
+ />
+
+
+ YAML.stringify(e))
+ .join('---\n')
+ .trim()}
+ language="yaml"
+ />
+
+
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx
new file mode 100644
index 0000000000..1556bd9b41
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.test.tsx
@@ -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('', () => {
+ it('renders without exploding', async () => {
+ const { getByText } = render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ const title = getByText('My Title');
+ const description = getByText('description', { selector: 'strong' });
+ expect(title).toBeInTheDocument();
+ expect(title).toBeVisible();
+ expect(description).toBeInTheDocument();
+ expect(description).not.toBeVisible();
+ });
+});
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx
new file mode 100644
index 0000000000..01af05d43a
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx
new file mode 100644
index 0000000000..0b38fdaf58
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx
@@ -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('', () => {
+ const catalogImportApi: jest.Mocked = {
+ analyzeUrl: jest.fn(),
+ submitPullRequest: jest.fn(),
+ };
+
+ const catalogApi: jest.Mocked = {
+ getEntities: jest.fn(),
+ addLocation: jest.fn(),
+ getEntityByName: jest.fn(),
+ getLocationByEntity: jest.fn(),
+ getLocationById: jest.fn(),
+ removeEntityByUid: jest.fn(),
+ };
+
+ const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ 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;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders without exploding', async () => {
+ catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] }));
+
+ await act(async () => {
+ const { getByText } = render(
+ {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }}
+ />,
+ {
+ 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(
+ {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }}
+ />,
+ {
+ 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(
+ {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }}
+ />,
+ {
+ 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(
+ ,
+ {
+ 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 });
+ });
+});
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx
new file mode 100644
index 0000000000..d9e7b962ab
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx
@@ -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;
+ onPrepare: (
+ result: PrepareResult,
+ opts?: { notRepeatable?: boolean },
+ ) => void;
+ onGoBack?: () => void;
+
+ defaultTitle: string;
+ defaultBody: string;
+
+ renderFormFields: (
+ props: Pick, '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();
+
+ 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 (
+ <>
+
+ You entered a link to a {analyzeResult.integrationType} repository but
+ we didn't found a catalog-info.yaml. Use this form to
+ create a Pull Request that creates an initial{' '}
+ catalog-info.yaml.
+
+
+
+ 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,
+ })}
+
+
+ Preview Pull Request
+
+
+
+
+
+ Preview Entities
+
+
+
+
+ {error && {error}}
+
+
+ {onGoBack && (
+
+ )}
+
+ Create PR
+
+
+ >
+ )}
+ />
+ >
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts
new file mode 100644
index 0000000000..119feee4a1
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx
new file mode 100644
index 0000000000..82da9b9572
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx
@@ -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('', () => {
+ 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;
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders without exploding', async () => {
+ const { getByRole } = render(
+ undefined}
+ onGoBack={() => undefined}
+ />,
+ );
+
+ expect(getByRole('button', { name: /Review/i })).toBeDisabled();
+ });
+
+ it('should select and deselect all', async () => {
+ const { getByRole, getAllByRole } = render(
+ 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(
+ 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(
+ 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(
+ 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(
+ 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);
+ });
+});
diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx
new file mode 100644
index 0000000000..d44472fb5a
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.tsx
@@ -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;
+ 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(
+ 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 (
+ <>
+
+ Select one or more locations that are present in your git repository:
+
+
+
+
+ 0 &&
+ selectedUrls.length < analyzeResult.locations.length
+ }
+ tabIndex={-1}
+ disableRipple
+ />
+
+
+
+ }
+ onItemClick={onItemClick}
+ locations={analyzeResult.locations}
+ locationListItemIcon={target => (
+
+ )}
+ collapsed
+ />
+
+
+ {onGoBack && }
+
+ Review
+
+
+ >
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts b/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts
new file mode 100644
index 0000000000..db98a63be0
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx
new file mode 100644
index 0000000000..eb2268f212
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx
@@ -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();
+
+ 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' && (
+ <>
+
+ The following Pull Request has been opened:{' '}
+
+ {prepareResult.pullRequest.url}
+
+
+
+
+ You can already import the location and {appTitle} will fetch the
+ entities as soon as the Pull Request is merged.
+
+ >
+ )}
+
+
+ The following entities will be added to the catalog:
+
+
+ }
+ />
+
+ {error && {error}}
+
+
+ {onGoBack && }
+ handleImport()}
+ >
+ Import
+
+
+ >
+ );
+};
diff --git a/plugins/catalog-import/src/components/StepReviewLocation/index.ts b/plugins/catalog-import/src/components/StepReviewLocation/index.ts
new file mode 100644
index 0000000000..2c93e98437
--- /dev/null
+++ b/plugins/catalog-import/src/components/StepReviewLocation/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/index.ts b/plugins/catalog-import/src/components/index.ts
new file mode 100644
index 0000000000..180752c873
--- /dev/null
+++ b/plugins/catalog-import/src/components/index.ts
@@ -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';
diff --git a/plugins/catalog-import/src/components/useImportState.test.tsx b/plugins/catalog-import/src/components/useImportState.test.tsx
new file mode 100644
index 0000000000..d1ef3184ad
--- /dev/null
+++ b/plugins/catalog-import/src/components/useImportState.test.tsx
@@ -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 = (
+ curr: ImportState,
+ _: T,
+ ) => curr as Extract;
+
+ 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();
+ });
+ });
+ });
+});
diff --git a/plugins/catalog-import/src/components/useImportState.ts b/plugins/catalog-import/src/components/useImportState.ts
new file mode 100644
index 0000000000..f042c991ef
--- /dev/null
+++ b/plugins/catalog-import/src/components/useImportState.ts
@@ -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 }
+ | { type: 'onPrepare'; args: Parameters }
+ | { type: 'onReview'; args: Parameters }
+ | { 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 }),
+ };
+};
diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts
index 7b4fc17121..562e0499f3 100644
--- a/plugins/catalog-import/src/index.ts
+++ b/plugins/catalog-import/src/index.ts
@@ -20,4 +20,5 @@ export {
CatalogImportPage,
} from './plugin';
export { Router } from './components/Router';
+export * from './components';
export * from './api';
diff --git a/plugins/catalog-import/src/mocks/analyze-location-POST-response.json b/plugins/catalog-import/src/mocks/analyze-location-POST-response.json
deleted file mode 100644
index eaadf1054a..0000000000
--- a/plugins/catalog-import/src/mocks/analyze-location-POST-response.json
+++ /dev/null
@@ -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": []
- }
- ]
-}
diff --git a/plugins/catalog-import/src/mocks/locations-POST-response.json b/plugins/catalog-import/src/mocks/locations-POST-response.json
deleted file mode 100644
index c44a0f4d61..0000000000
--- a/plugins/catalog-import/src/mocks/locations-POST-response.json
+++ /dev/null
@@ -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"
- }
- ]
- }
- ]
-}
diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts
index 60e5128df0..18b3124b3b 100644
--- a/plugins/catalog-import/src/plugin.ts
+++ b/plugins/catalog-import/src/plugin.ts
@@ -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,
}),
}),
],
diff --git a/plugins/catalog-import/src/util/types.ts b/plugins/catalog-import/src/types.ts
similarity index 100%
rename from plugins/catalog-import/src/util/types.ts
rename to plugins/catalog-import/src/types.ts
diff --git a/plugins/catalog-import/src/util/useGithubRepos.ts b/plugins/catalog-import/src/util/useGithubRepos.ts
deleted file mode 100644
index a2d299b9f4..0000000000
--- a/plugins/catalog-import/src/util/useGithubRepos.ts
+++ /dev/null
@@ -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 }),
- };
-}
diff --git a/plugins/catalog-import/src/util/validate.test.ts b/plugins/catalog-import/src/util/validate.test.ts
deleted file mode 100644
index 139c7fb0f8..0000000000
--- a/plugins/catalog-import/src/util/validate.test.ts
+++ /dev/null
@@ -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);
- });
- });
-});
diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx
index ae6f807275..8aa890086e 100644
--- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx
+++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx
@@ -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(
+ (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 (
-
- {children}
- {!children && formatEntityRefTitle(entityRef, { defaultKind })}
-
- );
-};
+ const routeParams = {
+ kind,
+ namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE,
+ name,
+ };
+
+ // TODO: Use useRouteRef here to generate the path
+ return (
+
+ {children}
+ {!children && formatEntityRefTitle(entityRef, { defaultKind })}
+
+ );
+ },
+);