Implement customization with an API instead of context

Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
Oliver Sand
2021-08-18 14:06:47 +02:00
parent 1bbb8cee46
commit 66a9e17035
19 changed files with 160 additions and 163 deletions
+11 -1
View File
@@ -1,5 +1,5 @@
---
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-import': minor
---
Add initial support for customizing the catalog import page.
@@ -33,3 +33,13 @@ is used.
</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.
+12 -18
View File
@@ -9,7 +9,6 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { CatalogApi } from '@backstage/catalog-client';
import { ConfigApi } from '@backstage/core-plugin-api';
import { Context } from 'react';
import { Controller } from 'react-hook-form';
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
@@ -69,6 +68,11 @@ export interface CatalogImportApi {
// (undocumented)
analyzeUrl(url: string): Promise<AnalyzeResult>;
// (undocumented)
preparePullRequest?(): {
title: string;
body: string;
};
// (undocumented)
submitPullRequest(options: {
repositoryUrl: string;
fileContent: string;
@@ -95,10 +99,16 @@ export class CatalogImportClient implements CatalogImportApi {
identityApi: IdentityApi;
scmIntegrationsApi: ScmIntegrationRegistry;
catalogApi: CatalogApi;
configApi: ConfigApi;
});
// (undocumented)
analyzeUrl(url: string): Promise<AnalyzeResult>;
// (undocumented)
preparePullRequest(): {
title: string;
body: string;
};
// (undocumented)
submitPullRequest({
repositoryUrl,
fileContent,
@@ -115,11 +125,10 @@ export class CatalogImportClient implements CatalogImportApi {
}>;
}
// Warning: (ae-forgotten-export) The symbol "ImportOptions" 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: ImportOptions) => 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)
//
@@ -168,11 +177,6 @@ export const EntityListComponent: ({
// @public (undocumented)
export const ImportInfoCard: () => JSX.Element;
// Warning: (ae-missing-release-tag) "ImportOptionsContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ImportOptionsContext: Context<ImportOptions>;
// 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)
//
@@ -218,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: ImportOptions) => 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
@@ -249,11 +248,6 @@ export const StepPrepareCreatePullRequest: ({
defaultBody,
}: Props_8) => JSX.Element;
// Warning: (ae-missing-release-tag) "useImportOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const useImportOptions: () => ImportOptions;
// Warnings were encountered during analysis:
//
// src/api/CatalogImportApi.d.ts:14:5 - (ae-forgotten-export) The symbol "PartialEntity" needs to be exported by the entry point index.d.ts
@@ -42,6 +42,10 @@ export type AnalyzeResult =
export interface CatalogImportApi {
analyzeUrl(url: string): Promise<AnalyzeResult>;
preparePullRequest?(): {
title: string;
body: string;
};
submitPullRequest(options: {
repositoryUrl: string;
fileContent: string;
@@ -115,6 +115,7 @@ describe('CatalogImportClient', () => {
scmIntegrationsApi,
identityApi,
catalogApi,
configApi: new ConfigReader({}),
});
});
@@ -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 {
};
}
preparePullRequest(): {
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,
@@ -62,6 +62,7 @@ describe('<DefaultImportPage />', () => {
identityApi,
scmIntegrationsApi: {} as any,
catalogApi: {} as any,
configApi: {} as any,
}),
);
});
@@ -23,19 +23,35 @@ import { configApiRef } from '@backstage/core-plugin-api';
import { wrapInTestApp } from '@backstage/test-utils';
import { act, render } from '@testing-library/react';
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: {} }),
);
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);
await act(async () => {
const { getByText } = render(
wrapInTestApp(
@@ -48,4 +64,38 @@ describe('<ImportInfoCard />', () => {
expect(getByText('Register an existing component')).toBeInTheDocument();
});
});
it('renders section on GitHub discovery if supported', async () => {
catalogImportApi.preparePullRequest = () => ({ title: '', body: '' });
await act(async () => {
const { getByText } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ImportInfoCard />
</ApiProvider>,
),
);
expect(getByText(/The wizard discovers all/)).toBeInTheDocument();
});
});
it('renders section on pull requests if supported', async () => {
catalogImportApi.preparePullRequest = () => ({ title: '', body: '' });
await act(async () => {
const { getByText } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ImportInfoCard />
</ApiProvider>,
),
);
expect(
getByText(/the wizard will prepare a Pull Request/),
).toBeInTheDocument();
});
});
});
@@ -18,12 +18,12 @@ 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 { useImportOptions } from '../ImportOptionsContext';
import { catalogImportApiRef } from '../../api';
export const ImportInfoCard = () => {
const configApi = useApi(configApiRef);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const opts = useImportOptions();
const catalogImportApi = useApi(catalogImportApiRef);
const integrations = configApi.getConfig('integrations');
const hasGithubIntegration = integrations.has('github');
@@ -33,8 +33,7 @@ export const ImportInfoCard = () => {
title="Register an existing component"
deepLink={{
title: 'Learn more about the Software Catalog',
link:
'https://backstage.io/docs/features/software-catalog/software-catalog-overview',
link: 'https://backstage.io/docs/features/software-catalog/software-catalog-overview',
}}
>
<Typography variant="body2" paragraph>
@@ -65,7 +64,7 @@ export const ImportInfoCard = () => {
repository, previews the entities, and adds them to the {appTitle}{' '}
catalog.
</Typography>
{!opts?.pullRequest?.disable && (
{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
@@ -1,24 +0,0 @@
/*
* 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 { createContext, useContext } from 'react';
import { ImportOptions } from '../types';
export const ImportOptionsContext = createContext<ImportOptions>({});
export const useImportOptions = (): ImportOptions => {
return useContext(ImportOptionsContext);
};
@@ -1,17 +0,0 @@
/*
* 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 { ImportOptionsContext, useImportOptions } from './ImportOptionsContext';
@@ -25,9 +25,15 @@ 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 { 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: () => {
@@ -62,10 +68,13 @@ describe('<ImportPage />', () => {
identityApi,
scmIntegrationsApi: {} as any,
catalogApi: {} as any,
configApi: new ConfigReader({}),
}),
);
});
afterEach(() => jest.resetAllMocks());
it('renders without exploding', async () => {
await act(async () => {
const { getByText } = render(
@@ -81,4 +90,20 @@ describe('<ImportPage />', () => {
).toBeInTheDocument();
});
});
it('renders with custom children', async () => {
(useOutlet as jest.Mock).mockReturnValue(<div>Hello World</div>);
await act(async () => {
const { getByText } = render(
wrapInTestApp(
<ApiProvider apis={apis}>
<ImportPage />
</ApiProvider>,
),
);
expect(getByText('Hello World')).toBeInTheDocument();
});
});
});
@@ -17,15 +17,9 @@
import React from 'react';
import { useOutlet } from 'react-router';
import { DefaultImportPage } from '../DefaultImportPage';
import { ImportOptionsContext } from '../ImportOptionsContext';
import { ImportOptions } from '../types';
export const ImportPage = (opts: ImportOptions) => {
export const ImportPage = () => {
const outlet = useOutlet();
return (
<ImportOptionsContext.Provider value={opts}>
{outlet || <DefaultImportPage />}
</ImportOptionsContext.Provider>
);
return outlet || <DefaultImportPage />;
};
@@ -14,11 +14,12 @@
* 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 { useImportOptions } from '../ImportOptionsContext';
import { ImportOptions } from '../types';
import { catalogImportApiRef } from '../../api';
import { ImportFlows, ImportState, useImportState } from '../useImportState';
import {
defaultGenerateStepper,
@@ -27,9 +28,6 @@ import {
StepperProvider,
} from './defaults';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
const useStyles = makeStyles(() => ({
stepperRoot: {
padding: 0,
@@ -43,8 +41,6 @@ type Props = {
defaults: StepperProvider,
) => StepperProvider;
variant?: InfoCardVariants;
/// @deprecated Pass import options via ImportOptionsContext instead.
opts?: ImportOptions;
};
export const ImportStepper = ({
@@ -52,10 +48,9 @@ export const ImportStepper = ({
generateStepper = defaultGenerateStepper,
variant,
}: Props) => {
const configApi = useApi(configApiRef);
const catalogImportApi = useApi(catalogImportApiRef);
const classes = useStyles();
const state = useImportState({ initialUrl });
const opts = useImportOptions();
const states = useMemo<StepperProvider>(
() => generateStepper(state.activeFlow, defaultStepper),
@@ -81,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>
@@ -34,7 +34,7 @@ import {
} from '../StepPrepareCreatePullRequest';
import { StepPrepareSelectLocations } from '../StepPrepareSelectLocations';
import { StepReviewLocation } from '../StepReviewLocation';
import { ImportOptions, StepperApis } from '../types';
import { StepperApis } from '../types';
import { ImportFlows, ImportState } from '../useImportState';
export type StepConfiguration = {
@@ -45,41 +45,22 @@ export type StepConfiguration = {
export type StepperProvider = {
analyze: (
s: Extract<ImportState, { activeState: 'analyze' }>,
opts: { apis: StepperApis; opts?: ImportOptions },
opts: { apis: StepperApis },
) => StepConfiguration;
prepare: (
s: Extract<ImportState, { activeState: 'prepare' }>,
opts: { apis: StepperApis; opts?: ImportOptions },
opts: { apis: StepperApis },
) => StepConfiguration;
review: (
s: Extract<ImportState, { activeState: 'review' }>,
opts: { apis: StepperApis; opts?: ImportOptions },
opts: { apis: StepperApis },
) => StepConfiguration;
finish: (
s: Extract<ImportState, { activeState: 'finish' }>,
opts: { apis: StepperApis; opts?: ImportOptions },
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.
*
@@ -155,12 +136,8 @@ export function defaultGenerateStepper(
return defaults.prepare(state, opts);
}
const preparePullRequest =
opts?.opts?.pullRequest?.preparePullRequest;
const { title, body } = defaultPreparePullRequest(
opts.apis,
preparePullRequest ? preparePullRequest(opts.apis) : {},
);
const { title, body } =
opts.apis.catalogImportApi.preparePullRequest!();
return {
stepLabel: <StepLabel>Create Pull Request</StepLabel>,
@@ -285,14 +262,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}
/>
),
}),
@@ -1,27 +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 React from 'react';
import { Route, Routes } from 'react-router-dom';
import { ImportPage } from './ImportPage';
import { ImportOptions } from './types';
/// @deprecated, use ImportPage instead.
export const Router = (opts: ImportOptions) => (
<Routes>
<Route element={<ImportPage {...opts} />} />
</Routes>
);
@@ -17,7 +17,6 @@
export * from './DefaultImportPage';
export * from './EntityListComponent';
export * from './ImportInfoCard';
export * from './ImportOptionsContext';
export * from './ImportStepper';
export * from './StepInitAnalyzeUrl';
export * from './StepPrepareCreatePullRequest';
+2 -11
View File
@@ -14,17 +14,8 @@
* limitations under the License.
*/
import { ConfigApi } from '@backstage/core-plugin-api';
export type ImportOptions = {
pullRequest?: {
disable?: boolean;
preparePullRequest?: (
apis: StepperApis,
) => { title?: string; body?: string };
};
};
import { CatalogImportApi } from '../api';
export type StepperApis = {
configApi: ConfigApi;
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';
+7 -3
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,
}),
}),
],