diff --git a/.changeset/few-penguins-watch.md b/.changeset/few-penguins-watch.md new file mode 100644 index 0000000000..3d7d9fb457 --- /dev/null +++ b/.changeset/few-penguins-watch.md @@ -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 +}> + +
+ + + + Start tracking your component in Backstage by adding it to the + software catalog. + + + + + + Hello World + + + + + + + + + +``` + +Previously it was possible to disable and customize the automatic pull request +feature by passing options to `` (`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 `` is removed, migrate to +`` instead. diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md index 6752f95748..753244b28f 100644 --- a/plugins/catalog-import/api-report.md +++ b/plugins/catalog-import/api-report.md @@ -68,6 +68,11 @@ export interface CatalogImportApi { // (undocumented) analyzeUrl(url: string): Promise; // (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; // (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: diff --git a/plugins/catalog-import/dev/index.tsx b/plugins/catalog-import/dev/index.tsx index 48d9320ca9..a0cf034782 100644 --- a/plugins/catalog-import/dev/index.tsx +++ b/plugins/catalog-import/dev/index.tsx @@ -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: , + element: , }) .addPage({ title: 'Catalog Import 2', diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 04e98f3d7c..a4645dc7ef 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -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({ id: 'plugin.catalog-import.service', @@ -42,6 +42,10 @@ export type AnalyzeResult = export interface CatalogImportApi { analyzeUrl(url: string): Promise; + preparePullRequest?(): Promise<{ + title: string; + body: string; + }>; submitPullRequest(options: { repositoryUrl: string; fileContent: string; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 2936f956e4..0782b44147 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -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), + }); + }); + }); }); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 2497af99d8..242f8440d1 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -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 { @@ -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, diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx similarity index 74% rename from plugins/catalog-import/src/components/ImportComponentPage.test.tsx rename to plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx index 2e572c8936..57376c33f2 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx @@ -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('', () => { +describe('', () => { const identityApi = { getUserId: () => { return 'user'; @@ -63,23 +61,20 @@ describe('', () => { identityApi, scmIntegrationsApi: {} as any, catalogApi: {} as any, + configApi: {} as any, }), ); }); it('renders without exploding', async () => { - await act(async () => { - const { getByText } = render( - wrapInTestApp( - - - , - ), - ); + const { getByText } = await renderInTestApp( + + + , + ); - expect( - await getByText('Start tracking your component in Backstage'), - ).toBeInTheDocument(); - }); + expect( + getByText('Start tracking your component in Backstage'), + ).toBeInTheDocument(); }); }); diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx new file mode 100644 index 0000000000..8693895ec0 --- /dev/null +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx @@ -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 ( + +
+ + + + Start tracking your component in {appTitle} by adding it to the + software catalog. + + + + + + + + + + + + + + + ); +}; diff --git a/plugins/catalog-import/src/components/DefaultImportPage/index.ts b/plugins/catalog-import/src/components/DefaultImportPage/index.ts new file mode 100644 index 0000000000..5a4d4907e3 --- /dev/null +++ b/plugins/catalog-import/src/components/DefaultImportPage/index.ts @@ -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'; diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx deleted file mode 100644 index bf53af134f..0000000000 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ /dev/null @@ -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 ( - -
- - - - Start tracking your component in {appTitle} by adding it to the - software catalog. - - - - - - - - Enter the URL to your source code repository to add it to{' '} - {appTitle}. - - - Link to an existing entity file - - - Example:{' '} - - https://github.com/backstage/backstage/blob/master/catalog-info.yaml - - - - The wizard analyzes the file, previews the entities, and adds - them to the {appTitle} catalog. - - {hasGithubIntegration && ( - <> - - Link to a 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. - - )} - - )} - - - - - - - - - - ); -}; diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx new file mode 100644 index 0000000000..4e0c3694cd --- /dev/null +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx @@ -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('', () => { + let apis: ApiRegistry; + let catalogImportApi: jest.Mocked; + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + expect( + getByText(/the wizard will prepare a Pull Request/), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx new file mode 100644 index 0000000000..2fab223f20 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -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 ( + + + Enter the URL to your source code repository to add it to {appTitle}. + + Link to an existing entity file + + Example:{' '} + + https://github.com/backstage/backstage/blob/master/catalog-info.yaml + + + + The wizard analyzes the file, previews the entities, and adds them to + the {appTitle} catalog. + + {hasGithubIntegration && ( + <> + + Link to a 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. + + {catalogImportApi.preparePullRequest && ( + + 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. + + )} + + )} + + ); +}; diff --git a/plugins/catalog-import/src/components/ImportInfoCard/index.ts b/plugins/catalog-import/src/components/ImportInfoCard/index.ts new file mode 100644 index 0000000000..c82e88f7e8 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportInfoCard/index.ts @@ -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'; diff --git a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx new file mode 100644 index 0000000000..fe3c3673f8 --- /dev/null +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx @@ -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('', () => { + 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( + + + , + ); + + expect( + getByText('Start tracking your component in Backstage'), + ).toBeInTheDocument(); + }); + + it('renders with custom children', async () => { + (useOutlet as jest.Mock).mockReturnValue(
Hello World
); + + const { getByText } = await renderInTestApp( + + + , + ); + + expect(getByText('Hello World')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-import/src/components/Router.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.tsx similarity index 66% rename from plugins/catalog-import/src/components/Router.tsx rename to plugins/catalog-import/src/components/ImportPage/ImportPage.tsx index c8f1ae65a3..467bd710f0 100644 --- a/plugins/catalog-import/src/components/Router.tsx +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.tsx @@ -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) => ( - - } /> - -); +export const ImportPage = () => { + const outlet = useOutlet(); + + return outlet || ; +}; diff --git a/plugins/catalog-import/src/components/ImportPage/index.ts b/plugins/catalog-import/src/components/ImportPage/index.ts new file mode 100644 index 0000000000..891c5302db --- /dev/null +++ b/plugins/catalog-import/src/components/ImportPage/index.ts @@ -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'; diff --git a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx index 48ee23e444..4112775c2d 100644 --- a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx +++ b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx @@ -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, - { apis: { configApi }, opts }, + { apis: { catalogImportApi } }, ), )} {render( states.prepare( state as Extract, - { apis: { configApi }, opts }, + { apis: { catalogImportApi } }, ), )} {render( states.review( state as Extract, - { apis: { configApi }, opts }, + { apis: { catalogImportApi } }, ), )} {render( states.finish( state as Extract, - { apis: { configApi }, opts }, + { apis: { catalogImportApi } }, ), )} diff --git a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx index 79d859ce32..357ed64e14 100644 --- a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx +++ b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx @@ -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, - opts: { apis: StepperApis; opts?: StepperProviderOpts }, + opts: { apis: StepperApis }, ) => StepConfiguration; prepare: ( s: Extract, - opts: { apis: StepperApis; opts?: StepperProviderOpts }, + opts: { apis: StepperApis }, ) => StepConfiguration; review: ( s: Extract, - opts: { apis: StepperApis; opts?: StepperProviderOpts }, + opts: { apis: StepperApis }, ) => StepConfiguration; finish: ( s: Extract, - 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: Create Pull Request, 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: Select URL, content: ( ), }), diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx index 9cc9532a3d..4a72a653ea 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx @@ -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('', () => { const catalogImportApi: jest.Mocked = { analyzeUrl: jest.fn(), diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index 08b8177291..2212daf0c0 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -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('', () => { const catalogImportApi: jest.Mocked = { analyzeUrl: jest.fn(), submitPullRequest: jest.fn(), + preparePullRequest: jest.fn(), }; const catalogApi: jest.Mocked = { @@ -44,12 +46,16 @@ describe('', () => { removeEntityByUid: jest.fn(), }; + const errorApi: jest.Mocked = { + error$: jest.fn(), + post: jest.fn(), + }; + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( {children} @@ -77,16 +83,19 @@ describe('', () => { 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( { @@ -105,8 +114,8 @@ describe('', () => { }, ); - 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('', () => { ); await act(async () => { - await render( + render( { @@ -154,11 +161,9 @@ describe('', () => { }, ); - 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( { @@ -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( void; onGoBack?: () => void; - defaultTitle: string; - defaultBody: string; - renderFormFields: ( props: Pick< UseFormReturn, @@ -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(); + 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. - - 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 && ( + + 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, + })} - - Preview Pull Request - + + Preview Pull Request + - + - - Preview Entities - + + Preview Entities + - - - {error && {error}} - - - {onGoBack && ( - - )} - - Create PR - - - - )} - /> + repositoryUrl={analyzeResult.url} + classes={{ + card: classes.previewCard, + cardContent: classes.previewCardContent, + }} + /> + + {error && {error}} + + + {onGoBack && ( + + )} + + Create PR + + + + )} + /> + )} ); }; diff --git a/plugins/catalog-import/src/components/index.ts b/plugins/catalog-import/src/components/index.ts index 9b53200317..886c679d2d 100644 --- a/plugins/catalog-import/src/components/index.ts +++ b/plugins/catalog-import/src/components/index.ts @@ -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'; diff --git a/plugins/catalog-import/src/components/types.ts b/plugins/catalog-import/src/components/types.ts new file mode 100644 index 0000000000..baa279ff21 --- /dev/null +++ b/plugins/catalog-import/src/components/types.ts @@ -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; +}; diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts index adee41bdbd..149c3e55c6 100644 --- a/plugins/catalog-import/src/index.ts +++ b/plugins/catalog-import/src/index.ts @@ -25,6 +25,5 @@ export { catalogImportPlugin as plugin, CatalogImportPage, } from './plugin'; -export { Router } from './components/Router'; export * from './components'; export * from './api'; diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index 3ab999ac07..d7d6370de3 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -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, }), );