Merge pull request #6162 from SDA-SE/feat/catalog-import-customize

Add initial support for customizing the catalog import page
This commit is contained in:
Oliver Sand
2021-09-17 11:26:23 +02:00
committed by GitHub
25 changed files with 676 additions and 331 deletions
+45
View File
@@ -0,0 +1,45 @@
---
'@backstage/plugin-catalog-import': minor
---
Add initial support for customizing the catalog import page.
It is now possible to pass a custom layout to the import page, as it's already
supported by the search page. If no custom layout is passed, the default layout
is used.
```typescript
<Route path="/catalog-import" element={<CatalogImportPage />}>
<Page themeId="home">
<Header title="Register an existing component" />
<Content>
<ContentHeader title="Start tracking your components">
<SupportButton>
Start tracking your component in Backstage by adding it to the
software catalog.
</SupportButton>
</ContentHeader>
<Grid container spacing={2} direction="row-reverse">
<Grid item xs={12} md={4} lg={6} xl={8}>
Hello World
</Grid>
<Grid item xs={12} md={8} lg={6} xl={4}>
<ImportStepper />
</Grid>
</Grid>
</Content>
</Page>
</Route>
```
Previously it was possible to disable and customize the automatic pull request
feature by passing options to `<CatalogImportPage>` (`pullRequest.disable` and
`pullRequest.preparePullRequest`). This functionality is moved to the
`CatalogImportApi` which now provides an optional `preparePullRequest()`
function. The function can either be overridden to generate a different content
for the pull request, or removed to disable this feature.
The export of the long term deprecated legacy `<Router>` is removed, migrate to
`<CatalogImportPage>` instead.
+24 -12
View File
@@ -68,6 +68,11 @@ export interface CatalogImportApi {
// (undocumented)
analyzeUrl(url: string): Promise<AnalyzeResult>;
// (undocumented)
preparePullRequest?(): Promise<{
title: string;
body: string;
}>;
// (undocumented)
submitPullRequest(options: {
repositoryUrl: string;
fileContent: string;
@@ -94,10 +99,16 @@ export class CatalogImportClient implements CatalogImportApi {
identityApi: IdentityApi;
scmIntegrationsApi: ScmIntegrationRegistry;
catalogApi: CatalogApi;
configApi: ConfigApi;
});
// (undocumented)
analyzeUrl(url: string): Promise<AnalyzeResult>;
// (undocumented)
preparePullRequest(): Promise<{
title: string;
body: string;
}>;
// (undocumented)
submitPullRequest({
repositoryUrl,
fileContent,
@@ -114,11 +125,10 @@ export class CatalogImportClient implements CatalogImportApi {
}>;
}
// Warning: (ae-forgotten-export) The symbol "StepperProviderOpts" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "CatalogImportPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const CatalogImportPage: (opts: StepperProviderOpts) => JSX.Element;
export const CatalogImportPage: () => JSX.Element;
// Warning: (ae-missing-release-tag) "catalogImportPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -144,6 +154,11 @@ export function defaultGenerateStepper(
defaults: StepperProvider,
): StepperProvider;
// Warning: (ae-missing-release-tag) "DefaultImportPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DefaultImportPage: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "EntityListComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -155,7 +170,12 @@ export const EntityListComponent: ({
onItemClick,
firstListItem,
withLinks,
}: Props_2) => JSX.Element;
}: Props) => JSX.Element;
// Warning: (ae-missing-release-tag) "ImportInfoCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ImportInfoCard: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "ImportStepper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -165,8 +185,7 @@ export const ImportStepper: ({
initialUrl,
generateStepper,
variant,
opts,
}: Props) => JSX.Element;
}: Props_2) => JSX.Element;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
@@ -203,11 +222,6 @@ export const PreviewPullRequestComponent: ({
classes,
}: Props_7) => JSX.Element;
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const Router: (opts: StepperProviderOpts) => JSX.Element;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
@@ -230,8 +244,6 @@ export const StepPrepareCreatePullRequest: ({
onPrepare,
onGoBack,
renderFormFields,
defaultTitle,
defaultBody,
}: Props_8) => JSX.Element;
// Warnings were encountered during analysis:
+2 -2
View File
@@ -29,7 +29,7 @@ import {
EntityListComponent,
ImportStepper,
} from '../src';
import { ImportComponentPage } from '../src/components/ImportComponentPage';
import { ImportPage } from '../src/components/ImportPage';
import { Content, Header, InfoCard, Page } from '@backstage/core-components';
const getEntityNames = (url: string): EntityName[] => [
@@ -252,7 +252,7 @@ createDevApp()
})
.addPage({
title: 'Catalog Import',
element: <ImportComponentPage />,
element: <ImportPage />,
})
.addPage({
title: 'Catalog Import 2',
@@ -15,8 +15,8 @@
*/
import { EntityName } from '@backstage/catalog-model';
import { PartialEntity } from '../types';
import { createApiRef } from '@backstage/core-plugin-api';
import { PartialEntity } from '../types';
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
id: 'plugin.catalog-import.service',
@@ -42,6 +42,10 @@ export type AnalyzeResult =
export interface CatalogImportApi {
analyzeUrl(url: string): Promise<AnalyzeResult>;
preparePullRequest?(): Promise<{
title: string;
body: string;
}>;
submitPullRequest(options: {
repositoryUrl: string;
fileContent: string;
@@ -115,6 +115,11 @@ describe('CatalogImportClient', () => {
scmIntegrationsApi,
identityApi,
catalogApi,
configApi: new ConfigReader({
app: {
baseUrl: 'https://demo.backstage.io/',
},
}),
});
});
@@ -443,4 +448,13 @@ describe('CatalogImportClient', () => {
});
});
});
describe('preparePullRequest', () => {
test('should prepare pull request details', async () => {
await expect(catalogImportClient.preparePullRequest()).resolves.toEqual({
title: 'Add catalog-info.yaml config file',
body: expect.any(String),
});
});
});
});
@@ -17,6 +17,7 @@
import { CatalogApi } from '@backstage/catalog-client';
import { EntityName } from '@backstage/catalog-model';
import {
ConfigApi,
DiscoveryApi,
IdentityApi,
OAuthApi,
@@ -37,6 +38,7 @@ export class CatalogImportClient implements CatalogImportApi {
private readonly githubAuthApi: OAuthApi;
private readonly scmIntegrationsApi: ScmIntegrationRegistry;
private readonly catalogApi: CatalogApi;
private readonly configApi: ConfigApi;
constructor(options: {
discoveryApi: DiscoveryApi;
@@ -44,12 +46,14 @@ export class CatalogImportClient implements CatalogImportApi {
identityApi: IdentityApi;
scmIntegrationsApi: ScmIntegrationRegistry;
catalogApi: CatalogApi;
configApi: ConfigApi;
}) {
this.discoveryApi = options.discoveryApi;
this.githubAuthApi = options.githubAuthApi;
this.identityApi = options.identityApi;
this.scmIntegrationsApi = options.scmIntegrationsApi;
this.catalogApi = options.catalogApi;
this.configApi = options.configApi;
}
async analyzeUrl(url: string): Promise<AnalyzeResult> {
@@ -114,6 +118,24 @@ export class CatalogImportClient implements CatalogImportApi {
};
}
async preparePullRequest(): Promise<{
title: string;
body: string;
}> {
const appTitle =
this.configApi.getOptionalString('app.title') ?? 'Backstage';
const appBaseUrl = this.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).`,
};
}
async submitPullRequest({
repositoryUrl,
fileContent,
@@ -15,21 +15,19 @@
*/
import { CatalogClient } from '@backstage/catalog-client';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
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';
import {
ApiProvider,
ApiRegistry,
ConfigReader,
} from '@backstage/core-app-api';
import { configApiRef } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { catalogImportApiRef, CatalogImportClient } from '../../api';
import { DefaultImportPage } from './DefaultImportPage';
describe('<ImportComponentPage />', () => {
describe('<DefaultImportPage />', () => {
const identityApi = {
getUserId: () => {
return 'user';
@@ -63,23 +61,20 @@ describe('<ImportComponentPage />', () => {
identityApi,
scmIntegrationsApi: {} as any,
catalogApi: {} as any,
configApi: {} as any,
}),
);
});
it('renders without exploding', async () => {
await act(async () => {
const { getByText } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ImportComponentPage />
</ApiProvider>,
),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<DefaultImportPage />
</ApiProvider>,
);
expect(
await getByText('Start tracking your component in Backstage'),
).toBeInTheDocument();
});
expect(
getByText('Start tracking your component in Backstage'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,57 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 {
Content,
ContentHeader,
Header,
Page,
SupportButton,
} from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { Grid } from '@material-ui/core';
import React from 'react';
import { ImportInfoCard } from '../ImportInfoCard';
import { ImportStepper } from '../ImportStepper';
export const DefaultImportPage = () => {
const configApi = useApi(configApiRef);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
return (
<Page themeId="home">
<Header title="Register an existing component" />
<Content>
<ContentHeader title={`Start tracking your component in ${appTitle}`}>
<SupportButton>
Start tracking your component in {appTitle} by adding it to the
software catalog.
</SupportButton>
</ContentHeader>
<Grid container spacing={2} direction="row-reverse">
<Grid item xs={12} md={4} lg={6} xl={8}>
<ImportInfoCard />
</Grid>
<Grid item xs={12} md={8} lg={6} xl={4}>
<ImportStepper />
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { DefaultImportPage } from './DefaultImportPage';
@@ -1,115 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { Chip, Grid, Typography } from '@material-ui/core';
import React from 'react';
import { ImportStepper } from './ImportStepper';
import { StepperProviderOpts } from './ImportStepper/defaults';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
Content,
ContentHeader,
Header,
InfoCard,
Page,
SupportButton,
} from '@backstage/core-components';
export const ImportComponentPage = (opts: StepperProviderOpts) => {
const configApi = useApi(configApiRef);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const integrations = configApi.getConfig('integrations');
const hasGithubIntegration = integrations.has('github');
return (
<Page themeId="home">
<Header title="Register an existing component" />
<Content>
<ContentHeader title={`Start tracking your component in ${appTitle}`}>
<SupportButton>
Start tracking your component in {appTitle} by adding it to the
software catalog.
</SupportButton>
</ContentHeader>
<Grid container spacing={2} direction="row-reverse">
<Grid item xs={12} md={4} lg={6} xl={8}>
<InfoCard
title="Register an existing component"
deepLink={{
title: 'Learn more about the Software Catalog',
link: 'https://backstage.io/docs/features/software-catalog/software-catalog-overview',
}}
>
<Typography variant="body2" paragraph>
Enter the URL to your source code repository to add it to{' '}
{appTitle}.
</Typography>
<Typography variant="h6">
Link to an existing entity file
</Typography>
<Typography variant="subtitle2" color="textSecondary" paragraph>
Example:{' '}
<code>
https://github.com/backstage/backstage/blob/master/catalog-info.yaml
</code>
</Typography>
<Typography variant="body2" paragraph>
The wizard analyzes the file, previews the entities, and adds
them to the {appTitle} catalog.
</Typography>
{hasGithubIntegration && (
<>
<Typography variant="h6">
Link to a repository{' '}
<Chip label="GitHub only" variant="outlined" size="small" />
</Typography>
<Typography
variant="subtitle2"
color="textSecondary"
paragraph
>
Example: <code>https://github.com/backstage/backstage</code>
</Typography>
<Typography variant="body2" paragraph>
The wizard discovers all <code>catalog-info.yaml</code>{' '}
files in the repository, previews the entities, and adds
them to the {appTitle} catalog.
</Typography>
{!opts?.pullRequest?.disable && (
<Typography variant="body2" paragraph>
If no entities are found, the wizard will prepare a Pull
Request that adds an example{' '}
<code>catalog-info.yaml</code> and prepares the {appTitle}{' '}
catalog to load all entities as soon as the Pull Request
is merged.
</Typography>
)}
</>
)}
</InfoCard>
</Grid>
<Grid item xs={12} md={8} lg={6} xl={4}>
<ImportStepper opts={opts} />
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,88 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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,
ConfigReader,
} from '@backstage/core-app-api';
import { configApiRef } from '@backstage/core-plugin-api';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { CatalogImportApi, catalogImportApiRef } from '../../api';
import { ImportInfoCard } from './ImportInfoCard';
describe('<ImportInfoCard />', () => {
let apis: ApiRegistry;
let catalogImportApi: jest.Mocked<CatalogImportApi>;
beforeEach(() => {
catalogImportApi = {
analyzeUrl: jest.fn(),
submitPullRequest: jest.fn(),
};
apis = ApiRegistry.with(
configApiRef,
new ConfigReader({
integrations: {
github: [{ token: 'my-token' }],
},
}),
).with(catalogImportApiRef, catalogImportApi);
});
it('renders without exploding', async () => {
apis = ApiRegistry.with(
configApiRef,
new ConfigReader({ integrations: {} }),
).with(catalogImportApiRef, catalogImportApi);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<ImportInfoCard />
</ApiProvider>,
);
expect(getByText('Register an existing component')).toBeInTheDocument();
});
it('renders section on GitHub discovery if supported', async () => {
catalogImportApi.preparePullRequest = async () => ({ title: '', body: '' });
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<ImportInfoCard />
</ApiProvider>,
);
expect(getByText(/The wizard discovers all/)).toBeInTheDocument();
});
it('renders section on pull requests if supported', async () => {
catalogImportApi.preparePullRequest = async () => ({ title: '', body: '' });
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<ImportInfoCard />
</ApiProvider>,
);
expect(
getByText(/the wizard will prepare a Pull Request/),
).toBeInTheDocument();
});
});
@@ -0,0 +1,79 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { InfoCard } from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { Chip, Typography } from '@material-ui/core';
import React from 'react';
import { catalogImportApiRef } from '../../api';
export const ImportInfoCard = () => {
const configApi = useApi(configApiRef);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const catalogImportApi = useApi(catalogImportApiRef);
const integrations = configApi.getConfig('integrations');
const hasGithubIntegration = integrations.has('github');
return (
<InfoCard
title="Register an existing component"
deepLink={{
title: 'Learn more about the Software Catalog',
link: 'https://backstage.io/docs/features/software-catalog/software-catalog-overview',
}}
>
<Typography variant="body2" paragraph>
Enter the URL to your source code repository to add it to {appTitle}.
</Typography>
<Typography variant="h6">Link to an existing entity file</Typography>
<Typography variant="subtitle2" color="textSecondary" paragraph>
Example:{' '}
<code>
https://github.com/backstage/backstage/blob/master/catalog-info.yaml
</code>
</Typography>
<Typography variant="body2" paragraph>
The wizard analyzes the file, previews the entities, and adds them to
the {appTitle} catalog.
</Typography>
{hasGithubIntegration && (
<>
<Typography variant="h6">
Link to a repository{' '}
<Chip label="GitHub only" variant="outlined" size="small" />
</Typography>
<Typography variant="subtitle2" color="textSecondary" paragraph>
Example: <code>https://github.com/backstage/backstage</code>
</Typography>
<Typography variant="body2" paragraph>
The wizard discovers all <code>catalog-info.yaml</code> files in the
repository, previews the entities, and adds them to the {appTitle}{' '}
catalog.
</Typography>
{catalogImportApi.preparePullRequest && (
<Typography variant="body2" paragraph>
If no entities are found, the wizard will prepare a Pull Request
that adds an example <code>catalog-info.yaml</code> and prepares
the {appTitle} catalog to load all entities as soon as the Pull
Request is merged.
</Typography>
)}
</>
)}
</InfoCard>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { ImportInfoCard } from './ImportInfoCard';
@@ -0,0 +1,100 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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,
ConfigReader,
} from '@backstage/core-app-api';
import { configApiRef } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { useOutlet } from 'react-router';
import { catalogImportApiRef, CatalogImportClient } from '../../api';
import { ImportPage } from './ImportPage';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useOutlet: jest.fn(),
}));
describe('<ImportPage />', () => {
const identityApi = {
getUserId: () => {
return 'user';
},
getProfile: () => {
return {};
},
getIdToken: () => {
return Promise.resolve('token');
},
signOut: () => {
return Promise.resolve();
},
};
let apis: ApiRegistry;
beforeEach(() => {
apis = ApiRegistry.with(
configApiRef,
new ConfigReader({ integrations: {} }),
)
.with(catalogApiRef, new CatalogClient({ discoveryApi: {} as any }))
.with(
catalogImportApiRef,
new CatalogImportClient({
discoveryApi: {} as any,
githubAuthApi: {
getAccessToken: async () => 'token',
},
identityApi,
scmIntegrationsApi: {} as any,
catalogApi: {} as any,
configApi: new ConfigReader({}),
}),
);
});
afterEach(() => jest.resetAllMocks());
it('renders without exploding', async () => {
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<ImportPage />
</ApiProvider>,
);
expect(
getByText('Start tracking your component in Backstage'),
).toBeInTheDocument();
});
it('renders with custom children', async () => {
(useOutlet as jest.Mock).mockReturnValue(<div>Hello World</div>);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<ImportPage />
</ApiProvider>,
);
expect(getByText('Hello World')).toBeInTheDocument();
});
});
@@ -15,12 +15,11 @@
*/
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { ImportComponentPage } from './ImportComponentPage';
import { StepperProviderOpts } from './ImportStepper/defaults';
import { useOutlet } from 'react-router';
import { DefaultImportPage } from '../DefaultImportPage';
export const Router = (opts: StepperProviderOpts) => (
<Routes>
<Route element={<ImportComponentPage {...opts} />} />
</Routes>
);
export const ImportPage = () => {
const outlet = useOutlet();
return outlet || <DefaultImportPage />;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { ImportPage } from './ImportPage';
@@ -14,21 +14,20 @@
* limitations under the License.
*/
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { Step, StepContent, Stepper } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React, { useMemo } from 'react';
import { catalogImportApiRef } from '../../api';
import { ImportFlows, ImportState, useImportState } from '../useImportState';
import {
defaultGenerateStepper,
defaultStepper,
StepConfiguration,
StepperProvider,
StepperProviderOpts,
} from './defaults';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
const useStyles = makeStyles(() => ({
stepperRoot: {
padding: 0,
@@ -42,16 +41,14 @@ type Props = {
defaults: StepperProvider,
) => StepperProvider;
variant?: InfoCardVariants;
opts?: StepperProviderOpts;
};
export const ImportStepper = ({
initialUrl,
generateStepper = defaultGenerateStepper,
variant,
opts,
}: Props) => {
const configApi = useApi(configApiRef);
const catalogImportApi = useApi(catalogImportApiRef);
const classes = useStyles();
const state = useImportState({ initialUrl });
@@ -79,25 +76,25 @@ export const ImportStepper = ({
{render(
states.analyze(
state as Extract<ImportState, { activeState: 'analyze' }>,
{ apis: { configApi }, opts },
{ apis: { catalogImportApi } },
),
)}
{render(
states.prepare(
state as Extract<ImportState, { activeState: 'prepare' }>,
{ apis: { configApi }, opts },
{ apis: { catalogImportApi } },
),
)}
{render(
states.review(
state as Extract<ImportState, { activeState: 'review' }>,
{ apis: { configApi }, opts },
{ apis: { catalogImportApi } },
),
)}
{render(
states.finish(
state as Extract<ImportState, { activeState: 'finish' }>,
{ apis: { configApi }, opts },
{ apis: { catalogImportApi } },
),
)}
</Stepper>
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { ConfigApi } from '@backstage/core-plugin-api';
import {
Box,
Checkbox,
@@ -35,22 +34,9 @@ import {
} from '../StepPrepareCreatePullRequest';
import { StepPrepareSelectLocations } from '../StepPrepareSelectLocations';
import { StepReviewLocation } from '../StepReviewLocation';
import { StepperApis } from '../types';
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;
@@ -59,41 +45,22 @@ export type StepConfiguration = {
export type StepperProvider = {
analyze: (
s: Extract<ImportState, { activeState: 'analyze' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
opts: { apis: StepperApis },
) => StepConfiguration;
prepare: (
s: Extract<ImportState, { activeState: 'prepare' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
opts: { apis: StepperApis },
) => StepConfiguration;
review: (
s: Extract<ImportState, { activeState: 'review' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
opts: { apis: StepperApis },
) => StepConfiguration;
finish: (
s: Extract<ImportState, { activeState: 'finish' }>,
opts: { apis: StepperApis; opts?: StepperProviderOpts },
opts: { apis: StepperApis },
) => StepConfiguration;
};
function defaultPreparePullRequest(
apis: StepperApis,
{ title, body }: { title?: string; body?: string } = {},
) {
const appTitle = apis.configApi.getOptionalString('app.title') ?? 'Backstage';
const appBaseUrl = apis.configApi.getString('app.baseUrl');
return {
title: title ?? 'Add catalog-info.yaml config file',
body:
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.
*
@@ -169,13 +136,6 @@ export function defaultGenerateStepper(
return defaults.prepare(state, opts);
}
const preparePullRequest =
opts?.opts?.pullRequest?.preparePullRequest;
const { title, body } = defaultPreparePullRequest(
opts.apis,
preparePullRequest ? preparePullRequest(opts.apis) : {},
);
return {
stepLabel: <StepLabel>Create Pull Request</StepLabel>,
content: (
@@ -183,8 +143,6 @@ export function defaultGenerateStepper(
analyzeResult={state.analyzeResult}
onPrepare={state.onPrepare}
onGoBack={state.onGoBack}
defaultTitle={title}
defaultBody={body}
renderFormFields={({
values,
setValue,
@@ -299,14 +257,14 @@ export function defaultGenerateStepper(
}
export const defaultStepper: StepperProvider = {
analyze: (state, { opts }) => ({
analyze: (state, { apis }) => ({
stepLabel: <StepLabel>Select URL</StepLabel>,
content: (
<StepInitAnalyzeUrl
key="analyze"
analysisUrl={state.analysisUrl}
onAnalysis={state.onAnalysis}
disablePullRequest={opts?.pullRequest?.disable}
disablePullRequest={!apis.catalogImportApi.preparePullRequest}
/>
),
}),
@@ -14,15 +14,14 @@
* limitations under the License.
*/
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { errorApiRef } from '@backstage/core-plugin-api';
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';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { errorApiRef } from '@backstage/core-plugin-api';
describe('<StepInitAnalyzeUrl />', () => {
const catalogImportApi: jest.Mocked<typeof catalogImportApiRef.T> = {
analyzeUrl: jest.fn(),
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { errorApiRef } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { TextField } from '@material-ui/core';
import { act, render, screen } from '@testing-library/react';
@@ -25,12 +27,12 @@ import {
generateEntities,
StepPrepareCreatePullRequest,
} from './StepPrepareCreatePullRequest';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
describe('<StepPrepareCreatePullRequest />', () => {
const catalogImportApi: jest.Mocked<typeof catalogImportApiRef.T> = {
analyzeUrl: jest.fn(),
submitPullRequest: jest.fn(),
preparePullRequest: jest.fn(),
};
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
@@ -44,12 +46,16 @@ describe('<StepPrepareCreatePullRequest />', () => {
removeEntityByUid: jest.fn(),
};
const errorApi: jest.Mocked<typeof errorApiRef.T> = {
error$: jest.fn(),
post: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider
apis={ApiRegistry.with(catalogImportApiRef, catalogImportApi).with(
catalogApiRef,
catalogApi,
)}
apis={ApiRegistry.with(catalogImportApiRef, catalogImportApi)
.with(catalogApiRef, catalogApi)
.with(errorApiRef, errorApi)}
>
{children}
</ApiProvider>
@@ -77,16 +83,19 @@ describe('<StepPrepareCreatePullRequest />', () => {
beforeEach(() => {
jest.resetAllMocks();
(catalogImportApi.preparePullRequest! as jest.Mock).mockResolvedValue({
title: 'My title',
body: 'My **body**',
});
});
it('renders without exploding', async () => {
catalogApi.getEntities.mockReturnValue(Promise.resolve({ items: [] }));
await act(async () => {
const { getByText } = render(
const { findByText } = render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={({ register }) => {
@@ -105,8 +114,8 @@ describe('<StepPrepareCreatePullRequest />', () => {
},
);
const title = getByText('My title');
const description = getByText('body', { selector: 'strong' });
const title = await findByText('My title');
const description = await findByText('body', { selector: 'strong' });
expect(title).toBeInTheDocument();
expect(title).toBeVisible();
expect(description).toBeInTheDocument();
@@ -124,10 +133,8 @@ describe('<StepPrepareCreatePullRequest />', () => {
);
await act(async () => {
await render(
render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={({ register }) => {
@@ -154,11 +161,9 @@ describe('<StepPrepareCreatePullRequest />', () => {
},
);
await userEvent.type(await screen.getByLabelText('name'), '-changed');
await userEvent.type(await screen.getByLabelText('owner'), '-changed');
await userEvent.click(
await screen.getByRole('button', { name: /Create PR/i }),
);
userEvent.type(await screen.findByLabelText('name'), '-changed');
userEvent.type(await screen.findByLabelText('owner'), '-changed');
userEvent.click(screen.getByRole('button', { name: /Create PR/i }));
});
expect(catalogImportApi.submitPullRequest).toBeCalledTimes(1);
@@ -212,10 +217,8 @@ spec:
);
await act(async () => {
await render(
render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={({ register }) => {
@@ -234,8 +237,8 @@ spec:
},
);
await userEvent.click(
await screen.getByRole('button', { name: /Create PR/i }),
userEvent.click(
await screen.findByRole('button', { name: /Create PR/i }),
);
});
@@ -261,10 +264,8 @@ spec:
);
await act(async () => {
await render(
render(
<StepPrepareCreatePullRequest
defaultTitle="My title"
defaultBody="My **body**"
analyzeResult={analyzeResult}
onPrepare={onPrepareFn}
renderFormFields={renderFormFieldsFn}
@@ -15,14 +15,14 @@
*/
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import {
catalogApiRef,
formatEntityRefTitle,
} 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 React, { useCallback, useEffect, useState } from 'react';
import { UnpackNestedValue, UseFormReturn } from 'react-hook-form';
import { useAsync } from 'react-use';
import YAML from 'yaml';
@@ -59,9 +59,6 @@ type Props = {
) => void;
onGoBack?: () => void;
defaultTitle: string;
defaultBody: string;
renderFormFields: (
props: Pick<
UseFormReturn<FormData>,
@@ -99,16 +96,30 @@ export const StepPrepareCreatePullRequest = ({
onPrepare,
onGoBack,
renderFormFields,
defaultTitle,
defaultBody,
}: Props) => {
const classes = useStyles();
const catalogApi = useApi(catalogApiRef);
const catalogInfoApi = useApi(catalogImportApiRef);
const catalogImportApi = useApi(catalogImportApiRef);
const errorApi = useApi(errorApiRef);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string>();
const {
loading: prDefaultsLoading,
value: prDefaults,
error: prDefaultsError,
} = useAsync(
() => catalogImportApi.preparePullRequest!(),
[catalogImportApi.preparePullRequest],
);
useEffect(() => {
if (prDefaultsError) {
errorApi.post(prDefaultsError);
}
}, [prDefaultsError, errorApi]);
const { loading: groupsLoading, value: groups } = useAsync(async () => {
const groupEntities = await catalogApi.getEntities({
filter: { kind: 'group' },
@@ -124,7 +135,7 @@ export const StepPrepareCreatePullRequest = ({
setSubmitted(true);
try {
const pr = await catalogInfoApi.submitPullRequest({
const pr = await catalogImportApi.submitPullRequest({
repositoryUrl: analyzeResult.url,
title: data.title,
body: data.body,
@@ -171,7 +182,7 @@ export const StepPrepareCreatePullRequest = ({
analyzeResult.generatedEntities,
analyzeResult.integrationType,
analyzeResult.url,
catalogInfoApi,
catalogImportApi,
onPrepare,
],
);
@@ -184,79 +195,81 @@ export const StepPrepareCreatePullRequest = ({
a Pull Request that creates one.
</Typography>
<PreparePullRequestForm<FormData>
onSubmit={handleResult}
defaultValues={{
title: defaultTitle,
body: defaultBody,
owner:
(analyzeResult.generatedEntities[0]?.spec?.owner as string) || '',
componentName:
analyzeResult.generatedEntities[0]?.metadata?.name || '',
useCodeowners: false,
}}
render={({ values, formState, register, setValue }) => (
<>
{renderFormFields({
values,
formState,
register,
setValue,
groups: groups ?? [],
groupsLoading,
})}
{!prDefaultsLoading && (
<PreparePullRequestForm<FormData>
onSubmit={handleResult}
defaultValues={{
title: prDefaults?.title ?? '',
body: prDefaults?.body ?? '',
owner:
(analyzeResult.generatedEntities[0]?.spec?.owner as string) || '',
componentName:
analyzeResult.generatedEntities[0]?.metadata?.name || '',
useCodeowners: false,
}}
render={({ values, formState, register, setValue }) => (
<>
{renderFormFields({
values,
formState,
register,
setValue,
groups: groups ?? [],
groupsLoading,
})}
<Box marginTop={2}>
<Typography variant="h6">Preview Pull Request</Typography>
</Box>
<Box marginTop={2}>
<Typography variant="h6">Preview Pull Request</Typography>
</Box>
<PreviewPullRequestComponent
title={values.title}
description={values.body}
classes={{
card: classes.previewCard,
cardContent: classes.previewCardContent,
}}
/>
<PreviewPullRequestComponent
title={values.title}
description={values.body}
classes={{
card: classes.previewCard,
cardContent: classes.previewCardContent,
}}
/>
<Box marginTop={2} marginBottom={1}>
<Typography variant="h6">Preview Entities</Typography>
</Box>
<Box marginTop={2} marginBottom={1}>
<Typography variant="h6">Preview Entities</Typography>
</Box>
<PreviewCatalogInfoComponent
entities={generateEntities(
analyzeResult.generatedEntities,
values.componentName,
values.owner,
)}
repositoryUrl={analyzeResult.url}
classes={{
card: classes.previewCard,
cardContent: classes.previewCardContent,
}}
/>
{error && <FormHelperText error>{error}</FormHelperText>}
<Grid container spacing={0}>
{onGoBack && (
<BackButton onClick={onGoBack} disabled={submitted} />
)}
<NextButton
type="submit"
disabled={Boolean(
formState.errors.title ||
formState.errors.body ||
formState.errors.owner,
<PreviewCatalogInfoComponent
entities={generateEntities(
analyzeResult.generatedEntities,
values.componentName,
values.owner,
)}
loading={submitted}
>
Create PR
</NextButton>
</Grid>
</>
)}
/>
repositoryUrl={analyzeResult.url}
classes={{
card: classes.previewCard,
cardContent: classes.previewCardContent,
}}
/>
{error && <FormHelperText error>{error}</FormHelperText>}
<Grid container spacing={0}>
{onGoBack && (
<BackButton onClick={onGoBack} disabled={submitted} />
)}
<NextButton
type="submit"
disabled={Boolean(
formState.errors.title ||
formState.errors.body ||
formState.errors.owner,
)}
loading={submitted}
>
Create PR
</NextButton>
</Grid>
</>
)}
/>
)}
</>
);
};
@@ -14,7 +14,9 @@
* limitations under the License.
*/
export * from './ImportStepper';
export * from './DefaultImportPage';
export * from './EntityListComponent';
export * from './ImportInfoCard';
export * from './ImportStepper';
export * from './StepInitAnalyzeUrl';
export * from './StepPrepareCreatePullRequest';
@@ -0,0 +1,21 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { CatalogImportApi } from '../api';
export type StepperApis = {
catalogImportApi: CatalogImportApi;
};
-1
View File
@@ -25,6 +25,5 @@ export {
catalogImportPlugin as plugin,
CatalogImportPage,
} from './plugin';
export { Router } from './components/Router';
export * from './components';
export * from './api';
+8 -4
View File
@@ -14,10 +14,8 @@
* limitations under the License.
*/
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { catalogImportApiRef, CatalogImportClient } from './api';
import {
configApiRef,
createApiFactory,
createPlugin,
createRoutableExtension,
@@ -26,6 +24,9 @@ import {
githubAuthApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { catalogImportApiRef, CatalogImportClient } from './api';
export const rootRouteRef = createRouteRef({
path: '',
@@ -43,6 +44,7 @@ export const catalogImportPlugin = createPlugin({
identityApi: identityApiRef,
scmIntegrationsApi: scmIntegrationsApiRef,
catalogApi: catalogApiRef,
configApi: configApiRef,
},
factory: ({
discoveryApi,
@@ -50,6 +52,7 @@ export const catalogImportPlugin = createPlugin({
identityApi,
scmIntegrationsApi,
catalogApi,
configApi,
}) =>
new CatalogImportClient({
discoveryApi,
@@ -57,6 +60,7 @@ export const catalogImportPlugin = createPlugin({
scmIntegrationsApi,
identityApi,
catalogApi,
configApi,
}),
}),
],
@@ -67,7 +71,7 @@ export const catalogImportPlugin = createPlugin({
export const CatalogImportPage = catalogImportPlugin.provide(
createRoutableExtension({
component: () => import('./components/Router').then(m => m.Router),
component: () => import('./components/ImportPage').then(m => m.ImportPage),
mountPoint: rootRouteRef,
}),
);