Resolve review comments
This commit is contained in:
@@ -35,7 +35,7 @@ import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
|
||||
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
|
||||
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
|
||||
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
|
||||
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
|
||||
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
|
||||
import { Route, Navigate } from 'react-router';
|
||||
|
||||
import { EntityPage } from './components/catalog/EntityPage';
|
||||
@@ -69,7 +69,7 @@ const catalogRouteRef = createRouteRef({
|
||||
const routes = (
|
||||
<FlatRoutes>
|
||||
<Navigate key="/" to="/catalog" />
|
||||
<Route path="/catalog-import" element={<ImportComponentRouter />} />
|
||||
<Route path="/catalog-import" element={<CatalogImportPage />} />
|
||||
<Route
|
||||
path={`${catalogRouteRef.path}`}
|
||||
element={<CatalogRouter EntityPage={EntityPage} />}
|
||||
|
||||
@@ -7,7 +7,7 @@ It also assists by creating pull requests in repositories where no `catalog-info
|
||||
|
||||
Current features:
|
||||
|
||||
- Import `catalog-info.yaml` files from a URL in a repository of one of the supported Git integrations (example `https://github.com/backstage/backstage/catalog.info`).
|
||||
- Import `catalog-info.yaml` files from a URL in a repository of one of the supported Git integrations (example `https://github.com/backstage/backstage/catalog-info.yaml`).
|
||||
- _[GitHub only]_ Search for all `catalog-info.yaml` files in a Git repository (example: `https://github.com/backstage/backstage`).
|
||||
- _[GitHub only]_ Analyze a repository, generate a Component entity, and create a Pull Request to onboard the repository.
|
||||
|
||||
@@ -28,40 +28,38 @@ yarn add @backstage/plugin-catalog-import
|
||||
```ts
|
||||
// packages/app/src/plugins.ts
|
||||
|
||||
export { plugin as CatalogImportPlugin } from '@backstage/plugin-catalog-import';
|
||||
export { catalogImportPlugin } from '@backstage/plugin-catalog-import';
|
||||
```
|
||||
|
||||
3. Register the `ImportComponentRouter` at the `/catalog-import` path:
|
||||
3. Register the `CatalogImportPage` at the `/catalog-import` path:
|
||||
|
||||
```tsx
|
||||
// packages/app/src/App.tsx
|
||||
|
||||
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
|
||||
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
|
||||
|
||||
<Route path="/catalog-import" element={<ImportComponentRouter />} />;
|
||||
<Route path="/catalog-import" element={<CatalogImportPage />} />;
|
||||
```
|
||||
|
||||
## Customizations
|
||||
|
||||
### Disable the creation of Pull Requests
|
||||
|
||||
The pull request feature can be disabled by options that are passed to the `ImportComponentRouter`:
|
||||
The pull request feature can be disabled by options that are passed to the `CatalogImportPage`:
|
||||
|
||||
```tsx
|
||||
// packages/app/src/App.tsx
|
||||
|
||||
<Route
|
||||
path="/catalog-import"
|
||||
element={
|
||||
<ImportComponentRouter options={{ pullRequest: { disable: true } }} />
|
||||
}
|
||||
element={<CatalogImportPage options={{ pullRequest: { disable: true } }} />}
|
||||
/>
|
||||
```
|
||||
|
||||
### Customize the title and body of the Pull Request
|
||||
|
||||
The pull request form is filled with a default title and body.
|
||||
This can be configured by options that are passed to the `ImportComponentRouter`:
|
||||
This can be configured by options that are passed to the `CatalogImportPage`:
|
||||
|
||||
```tsx
|
||||
// packages/app/src/App.tsx
|
||||
@@ -69,7 +67,7 @@ This can be configured by options that are passed to the `ImportComponentRouter`
|
||||
<Route
|
||||
path="/catalog-import"
|
||||
element={
|
||||
<ImportComponentRouter
|
||||
<CatalogImportPage
|
||||
options={{
|
||||
pullRequest: {
|
||||
preparePullRequest: () => ({
|
||||
|
||||
@@ -16,15 +16,18 @@
|
||||
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { Content, Header, Page } from '@backstage/core';
|
||||
import { Content, Header, InfoCard, Page } from '@backstage/core';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { Grid, ListItem, ListItemIcon, ListItemText } from '@material-ui/core';
|
||||
import AlarmIcon from '@material-ui/icons/Alarm';
|
||||
import LocationOnIcon from '@material-ui/icons/LocationOn';
|
||||
import React from 'react';
|
||||
import {
|
||||
AnalyzeResult,
|
||||
CatalogImportApi,
|
||||
catalogImportApiRef,
|
||||
EntityListComponent,
|
||||
ImportStepper,
|
||||
} from '../src';
|
||||
import { ImportComponentPage } from '../src/components/ImportComponentPage';
|
||||
@@ -61,6 +64,59 @@ const getEntities = (url: string): Entity[] => [
|
||||
},
|
||||
];
|
||||
|
||||
const locations = [
|
||||
{
|
||||
target: 'https://my-location-1',
|
||||
entities: [
|
||||
{
|
||||
kind: 'Domain',
|
||||
namespace: 'default',
|
||||
name: 'my-domain',
|
||||
},
|
||||
{
|
||||
kind: 'Group',
|
||||
namespace: 'groups',
|
||||
name: 'my-group',
|
||||
},
|
||||
{
|
||||
kind: 'Location',
|
||||
namespace: 'default',
|
||||
name: 'my-location',
|
||||
},
|
||||
{
|
||||
kind: 'System',
|
||||
namespace: 'default',
|
||||
name: 'my-system',
|
||||
},
|
||||
{
|
||||
kind: 'User',
|
||||
namespace: 'users',
|
||||
name: 'my-api',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
target: 'https://my-location-2',
|
||||
entities: [
|
||||
{
|
||||
kind: 'API',
|
||||
namespace: 'default',
|
||||
name: 'my-api',
|
||||
},
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'my-component',
|
||||
},
|
||||
{
|
||||
kind: 'Location',
|
||||
namespace: 'default',
|
||||
name: 'my-location',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
createDevApp()
|
||||
.registerApi({
|
||||
api: catalogApiRef,
|
||||
@@ -225,4 +281,71 @@ createDevApp()
|
||||
</Page>
|
||||
),
|
||||
})
|
||||
.addPage({
|
||||
title: 'Components',
|
||||
element: (
|
||||
<Page themeId="home">
|
||||
<Header title="Components" />
|
||||
<Content>
|
||||
<Grid container>
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<InfoCard
|
||||
title="EntityListComponent (default)"
|
||||
variant="gridItem"
|
||||
>
|
||||
<EntityListComponent
|
||||
locations={locations}
|
||||
locationListItemIcon={() => <LocationOnIcon />}
|
||||
/>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<InfoCard
|
||||
title="EntityListComponent (clickable locations)"
|
||||
variant="gridItem"
|
||||
>
|
||||
<EntityListComponent
|
||||
firstListItem={
|
||||
<ListItem dense>
|
||||
<ListItemIcon>
|
||||
<AlarmIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="A custom first item" />
|
||||
</ListItem>
|
||||
}
|
||||
locations={locations}
|
||||
locationListItemIcon={() => <LocationOnIcon />}
|
||||
onItemClick={() => {}}
|
||||
/>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<InfoCard
|
||||
title="EntityListComponent (collapsed)"
|
||||
variant="gridItem"
|
||||
>
|
||||
<EntityListComponent
|
||||
collapsed
|
||||
locations={locations}
|
||||
locationListItemIcon={() => <LocationOnIcon />}
|
||||
/>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
<InfoCard
|
||||
title="EntityListComponent (clickable)"
|
||||
variant="gridItem"
|
||||
>
|
||||
<EntityListComponent
|
||||
locations={locations}
|
||||
locationListItemIcon={() => <LocationOnIcon />}
|
||||
withLinks
|
||||
/>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
),
|
||||
})
|
||||
.render();
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@octokit/rest": "^18.0.12",
|
||||
"@types/react": "^16.9",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"git-url-parse": "^11.4.4",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { PartialEntity } from '../types';
|
||||
|
||||
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
|
||||
id: 'plugin.catalog-import.service',
|
||||
@@ -35,7 +36,7 @@ export type AnalyzeResult =
|
||||
type: 'repository';
|
||||
url: string;
|
||||
integrationType: string;
|
||||
generatedEntities: Entity[];
|
||||
generatedEntities: PartialEntity[];
|
||||
};
|
||||
|
||||
export interface CatalogImportApi {
|
||||
@@ -43,7 +44,6 @@ export interface CatalogImportApi {
|
||||
|
||||
submitPullRequest(options: {
|
||||
repositoryUrl: string;
|
||||
integrationType: string;
|
||||
fileContent: string;
|
||||
title: string;
|
||||
body: string;
|
||||
|
||||
@@ -284,7 +284,6 @@ describe('CatalogImportClient', () => {
|
||||
|
||||
await expect(
|
||||
catalogImportClient.submitPullRequest({
|
||||
integrationType: 'github',
|
||||
repositoryUrl: 'https://github.com/backstage/backstage',
|
||||
fileContent: 'some content',
|
||||
title: 'A title',
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { ConfigApi, DiscoveryApi, OAuthApi } from '@backstage/core';
|
||||
import { GitHubIntegrationConfig } from '@backstage/integration';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fetch from 'cross-fetch';
|
||||
import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi';
|
||||
import { getGithubIntegrationConfig } from './GitHub';
|
||||
|
||||
@@ -94,20 +93,19 @@ export class CatalogImportClient implements CatalogImportApi {
|
||||
}
|
||||
|
||||
async submitPullRequest({
|
||||
integrationType,
|
||||
repositoryUrl,
|
||||
fileContent,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
repositoryUrl: string;
|
||||
integrationType: string;
|
||||
fileContent: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<{ link: string; location: string }> {
|
||||
const ghConfig = getGithubIntegrationConfig(this.configApi, repositoryUrl);
|
||||
if (integrationType === 'github' && ghConfig) {
|
||||
|
||||
if (ghConfig) {
|
||||
return await this.submitGitHubPrToRepo({
|
||||
...ghConfig,
|
||||
fileContent,
|
||||
|
||||
@@ -15,28 +15,25 @@
|
||||
*/
|
||||
|
||||
import { ConfigApi } from '@backstage/core';
|
||||
|
||||
// TODO: (O5ten) Refactor into a core API instead of direct usage like this
|
||||
// https://github.com/backstage/backstage/pull/3613#issuecomment-7408929430
|
||||
import { readGitHubIntegrationConfigs } from '@backstage/integration';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
|
||||
export const getGithubIntegrationConfig = (
|
||||
config: ConfigApi,
|
||||
location: string,
|
||||
) => {
|
||||
const { name: repo, owner, resource: hostname } = parseGitUrl(location);
|
||||
const { name: repo, owner } = parseGitUrl(location);
|
||||
|
||||
const scmIntegrations = ScmIntegrations.fromConfig(config);
|
||||
const githubIntegrationConfig = scmIntegrations.github.byUrl(location);
|
||||
|
||||
const configs = readGitHubIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.github') ?? [],
|
||||
);
|
||||
const githubIntegrationConfig = configs.find(v => v.host === hostname);
|
||||
if (!githubIntegrationConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
owner,
|
||||
githubIntegrationConfig,
|
||||
githubIntegrationConfig: githubIntegrationConfig.config,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ const useStyles = makeStyles(theme => ({
|
||||
export const NextButton = (
|
||||
props: ComponentProps<typeof Button> & { loading?: boolean },
|
||||
) => {
|
||||
const { loading, ...buttonProps } = props;
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
@@ -47,11 +48,11 @@ export const NextButton = (
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
{...{ ...props, loading: undefined }}
|
||||
{...buttonProps}
|
||||
disabled={props.disabled || props.loading}
|
||||
/>
|
||||
{props.loading && (
|
||||
<CircularProgress size={24} className={classes.buttonProgress} />
|
||||
<CircularProgress size="1.5rem" className={classes.buttonProgress} />
|
||||
)}
|
||||
{props.loading}
|
||||
</div>
|
||||
|
||||
@@ -64,12 +64,12 @@ function getEntityIcon(entity: { kind: string }): React.ReactElement {
|
||||
case 'domain':
|
||||
return <ApartmentIcon />;
|
||||
|
||||
case 'location':
|
||||
return <LocationOnIcon />;
|
||||
|
||||
case 'group':
|
||||
return <GroupIcon />;
|
||||
|
||||
case 'location':
|
||||
return <LocationOnIcon />;
|
||||
|
||||
case 'system':
|
||||
return <CategoryIcon />;
|
||||
|
||||
@@ -81,6 +81,15 @@ function getEntityIcon(entity: { kind: string }): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
locations: Array<{ target: string; entities: (Entity | EntityName)[] }>;
|
||||
locationListItemIcon: (target: string) => React.ReactElement;
|
||||
collapsed?: boolean;
|
||||
firstListItem?: React.ReactElement;
|
||||
onItemClick?: (target: string) => void;
|
||||
withLinks?: boolean;
|
||||
};
|
||||
|
||||
export const EntityListComponent = ({
|
||||
locations,
|
||||
collapsed = false,
|
||||
@@ -88,14 +97,7 @@ export const EntityListComponent = ({
|
||||
onItemClick,
|
||||
firstListItem,
|
||||
withLinks = false,
|
||||
}: {
|
||||
locations: Array<{ target: string; entities: (Entity | EntityName)[] }>;
|
||||
locationListItemIcon: (target: string) => React.ReactElement;
|
||||
collapsed?: boolean;
|
||||
firstListItem?: React.ReactElement;
|
||||
onItemClick?: (target: string) => void;
|
||||
withLinks?: boolean;
|
||||
}) => {
|
||||
}: Props) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [expandedUrls, setExpandedUrls] = useState<string[]>([]);
|
||||
@@ -144,7 +146,7 @@ export const EntityListComponent = ({
|
||||
<List component="div" disablePadding dense>
|
||||
{sortEntities(r.entities).map(entity => (
|
||||
<ListItem
|
||||
component={withLinks ? EntityRefLink : 'li'}
|
||||
component={withLinks ? EntityRefLink : 'div'}
|
||||
entityRef={withLinks ? entity : undefined}
|
||||
button={withLinks as any}
|
||||
key={formatEntityRefTitle(entity)}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
@@ -31,12 +30,6 @@ import React from 'react';
|
||||
import { ImportStepper } from './ImportStepper';
|
||||
import { StepperProviderOpts } from './ImportStepper/defaults';
|
||||
|
||||
export type ConfigSpec = {
|
||||
type: 'tree' | 'file';
|
||||
location: string;
|
||||
config: Entity[];
|
||||
};
|
||||
|
||||
function repositories(configApi: ConfigApi): string[] {
|
||||
const integrations = configApi.getConfig('integrations');
|
||||
const repositories = [];
|
||||
@@ -116,7 +109,7 @@ export const ImportComponentPage = ({
|
||||
Example: <code>https://github.com/backstage/backstage</code>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
The wizard discovery all <code>catalog-info.yaml</code>{' '}
|
||||
The wizard discovers all <code>catalog-info.yaml</code>{' '}
|
||||
files in the repository, previews the entities, and adds
|
||||
them to the {appTitle} catalog.
|
||||
</Typography>
|
||||
|
||||
@@ -33,12 +33,7 @@ const useStyles = makeStyles(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const ImportStepper = ({
|
||||
initialUrl,
|
||||
generateStepper = defaultGenerateStepper,
|
||||
variant,
|
||||
opts,
|
||||
}: {
|
||||
type Props = {
|
||||
initialUrl?: string;
|
||||
generateStepper?: (
|
||||
flow: ImportFlows,
|
||||
@@ -46,7 +41,14 @@ export const ImportStepper = ({
|
||||
) => StepperProvider;
|
||||
variant?: string;
|
||||
opts?: StepperProviderOpts;
|
||||
}) => {
|
||||
};
|
||||
|
||||
export const ImportStepper = ({
|
||||
initialUrl,
|
||||
generateStepper = defaultGenerateStepper,
|
||||
variant,
|
||||
opts,
|
||||
}: Props) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const classes = useStyles();
|
||||
const state = useImportState({ initialUrl });
|
||||
|
||||
@@ -181,7 +181,7 @@ export function defaultGenerateStepper(
|
||||
<TextField
|
||||
name="title"
|
||||
label="Pull Request Title"
|
||||
placeholder="Add catalog files for the Backstage"
|
||||
placeholder="Add Backstage catalog entity descriptor files"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
@@ -193,7 +193,7 @@ export function defaultGenerateStepper(
|
||||
<TextField
|
||||
name="body"
|
||||
label="Pull Request Body"
|
||||
placeholder="A decsribing text with Markdown support"
|
||||
placeholder="A describing text with Markdown support"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
|
||||
@@ -38,7 +38,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A form that lets the user input an url and analyze it for existing locations or potential entities.
|
||||
* A form that lets the user input a url and analyze it for existing locations or potential entities.
|
||||
*
|
||||
* @param onAnalysis is called when the analysis was successful
|
||||
* @param analysisUrl a url that can be used as a default value
|
||||
@@ -118,10 +118,11 @@ export const StepInitAnalyzeUrl = ({
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleResult)}>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="url"
|
||||
name="url"
|
||||
label="Repository URL"
|
||||
placeholder="https://github.com/backstage/backstage"
|
||||
placeholder="https://github.com/backstage/backstage/blob/master/catalog-info.yaml"
|
||||
helperText="Enter the full path to your entity file to start tracking your component"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export const AutocompleteTextField = <TFieldValue extends string>({
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
<CircularProgress color="inherit" size="1em" />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
|
||||
-1
@@ -169,7 +169,6 @@ metadata:
|
||||
spec:
|
||||
owner: my-owner-changed
|
||||
`,
|
||||
integrationType: 'github',
|
||||
repositoryUrl: 'https://my-repository',
|
||||
title: 'My title',
|
||||
},
|
||||
|
||||
+6
-4
@@ -24,6 +24,7 @@ import { UseFormMethods } from 'react-hook-form';
|
||||
import { useAsync } from 'react-use';
|
||||
import YAML from 'yaml';
|
||||
import { AnalyzeResult, catalogImportApiRef } from '../../api';
|
||||
import { PartialEntity } from '../../types';
|
||||
import { BackButton, NextButton } from '../Buttons';
|
||||
import { PrepareResult } from '../useImportState';
|
||||
import { PreparePullRequestForm } from './PreparePullRequestForm';
|
||||
@@ -66,7 +67,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function generateEntities(
|
||||
entities: Entity[],
|
||||
entities: PartialEntity[],
|
||||
componentName: string,
|
||||
owner: string,
|
||||
): Entity[] {
|
||||
@@ -117,7 +118,6 @@ export const StepPrepareCreatePullRequest = ({
|
||||
try {
|
||||
const pr = await catalogInfoApi.submitPullRequest({
|
||||
repositoryUrl: analyzeResult.url,
|
||||
integrationType: analyzeResult.integrationType,
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
fileContent: generateEntities(
|
||||
@@ -177,7 +177,7 @@ export const StepPrepareCreatePullRequest = ({
|
||||
<code>catalog-info.yaml</code>.
|
||||
</Typography>
|
||||
|
||||
<PreparePullRequestForm
|
||||
<PreparePullRequestForm<FormData>
|
||||
onSubmit={handleResult}
|
||||
defaultValues={{
|
||||
title: defaultTitle,
|
||||
@@ -230,7 +230,9 @@ export const StepPrepareCreatePullRequest = ({
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
|
||||
<Grid container spacing={0}>
|
||||
{onGoBack && <BackButton onClick={onGoBack} />}
|
||||
{onGoBack && (
|
||||
<BackButton onClick={onGoBack} disabled={submitted} />
|
||||
)}
|
||||
<NextButton
|
||||
type="submit"
|
||||
disabled={Boolean(errors.title || errors.body || errors.owner)}
|
||||
|
||||
@@ -120,7 +120,7 @@ export const StepReviewLocation = ({
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
|
||||
<Grid container spacing={0}>
|
||||
{onGoBack && <BackButton onClick={onGoBack} />}
|
||||
{onGoBack && <BackButton onClick={onGoBack} disabled={submitted} />}
|
||||
<NextButton
|
||||
disabled={submitted}
|
||||
loading={submitted}
|
||||
|
||||
@@ -193,17 +193,18 @@ describe('useImportState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on invalid state', async () => {
|
||||
it('should ignore on invalid state', async () => {
|
||||
const { result } = renderHook(() => useImportState());
|
||||
await cleanup();
|
||||
|
||||
// state 'analyze'
|
||||
await expect(
|
||||
act(async () => as(result.current, 'prepare').onPrepare(locationAP)),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
act(async () => as(result.current, 'review').onReview(locationR)),
|
||||
).rejects.toThrow();
|
||||
act(() => {
|
||||
as(result.current, 'prepare').onPrepare(locationAP);
|
||||
as(result.current, 'review').onReview(locationR);
|
||||
});
|
||||
|
||||
expect(result.current.activeState).toBe('analyze');
|
||||
expect(result.current.activeFlow).toBe('unknown');
|
||||
|
||||
// switch state to 'prepare'
|
||||
act(() =>
|
||||
@@ -214,33 +215,46 @@ describe('useImportState', () => {
|
||||
),
|
||||
);
|
||||
|
||||
await expect(async () =>
|
||||
// state 'prepare'
|
||||
act(() => {
|
||||
as(result.current, 'analyze').onAnalysis(
|
||||
'single-location',
|
||||
'multiple-locations',
|
||||
'http://my-url',
|
||||
locationAP,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
act(async () => as(result.current, 'review').onReview(locationR)),
|
||||
).rejects.toThrow();
|
||||
);
|
||||
as(result.current, 'review').onReview(locationR);
|
||||
});
|
||||
|
||||
expect(result.current.activeState).toBe('prepare');
|
||||
expect(result.current.activeFlow).toBe('single-location');
|
||||
|
||||
// switch to 'review'
|
||||
act(() => as(result.current, 'prepare').onPrepare(locationAP));
|
||||
|
||||
await expect(async () =>
|
||||
// state 'review'
|
||||
act(() => {
|
||||
as(result.current, 'analyze').onAnalysis(
|
||||
'single-location',
|
||||
'multiple-locations',
|
||||
'http://my-url',
|
||||
locationAP,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
act(async () => as(result.current, 'prepare').onPrepare(locationAP)),
|
||||
).rejects.toThrow();
|
||||
);
|
||||
as(result.current, 'prepare').onPrepare({
|
||||
type: 'locations',
|
||||
locations: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.activeState).toBe('review');
|
||||
expect(result.current.activeFlow).toBe('single-location');
|
||||
expect(
|
||||
as(result.current, 'prepare').prepareResult!.locations,
|
||||
).not.toEqual([]);
|
||||
|
||||
// switch to 'finish'
|
||||
act(() => as(result.current, 'review').onReview(locationR));
|
||||
|
||||
expect(result.current.activeState).toBe('finish');
|
||||
expect(result.current.activeFlow).toBe('single-location');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Entity, EntityName } from '@backstage/catalog-model';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useReducer } from 'react';
|
||||
import { AnalyzeResult } from '../api';
|
||||
|
||||
// the configuration of the stepper
|
||||
@@ -25,16 +25,9 @@ export type ImportFlows =
|
||||
| 'multiple-locations'
|
||||
| 'no-location';
|
||||
|
||||
// the available states of the stepper
|
||||
type ImportStateTypes = 'analyze' | 'prepare' | 'review' | 'finish';
|
||||
|
||||
// a list that shows the order of states
|
||||
const importStates: ImportStateTypes[] = [
|
||||
'analyze',
|
||||
'prepare',
|
||||
'review',
|
||||
'finish',
|
||||
];
|
||||
|
||||
// result of the prepare state
|
||||
export type PrepareResult =
|
||||
| {
|
||||
@@ -79,32 +72,41 @@ export type ReviewResult =
|
||||
}>;
|
||||
};
|
||||
|
||||
// function type for the 'analysis' -> 'prepare'/'review' transition
|
||||
type onAnalysisFn = (
|
||||
flow: ImportFlows,
|
||||
url: string,
|
||||
result: AnalyzeResult,
|
||||
opts?: { prepareResult?: PrepareResult },
|
||||
) => void;
|
||||
|
||||
// function type for the 'prepare' -> 'review' transition
|
||||
type onPrepareFn = (
|
||||
result: PrepareResult,
|
||||
opts?: { notRepeatable?: boolean },
|
||||
) => void;
|
||||
|
||||
// function type for the 'review' -> 'finish' transition
|
||||
type onReviewFn = (result: ReviewResult) => void;
|
||||
|
||||
// the type interfaces that are available in each state. every state provides
|
||||
// already known information and means to go to the next, or the previous step.
|
||||
type State =
|
||||
| {
|
||||
activeState: 'analyze';
|
||||
onAnalysis: (
|
||||
flow: ImportFlows,
|
||||
url: string,
|
||||
result: AnalyzeResult,
|
||||
opts?: { prepareResult?: PrepareResult },
|
||||
) => void;
|
||||
onAnalysis: onAnalysisFn;
|
||||
}
|
||||
| {
|
||||
activeState: 'prepare';
|
||||
analyzeResult: AnalyzeResult;
|
||||
prepareResult?: PrepareResult;
|
||||
onPrepare: (
|
||||
result: PrepareResult,
|
||||
opts?: { notRepeatable?: boolean },
|
||||
) => void;
|
||||
onPrepare: onPrepareFn;
|
||||
}
|
||||
| {
|
||||
activeState: 'review';
|
||||
analyzeResult: AnalyzeResult;
|
||||
prepareResult: PrepareResult;
|
||||
onReview: (result: ReviewResult) => void;
|
||||
onReview: onReviewFn;
|
||||
}
|
||||
| {
|
||||
activeState: 'finish';
|
||||
@@ -122,6 +124,119 @@ export type ImportState = State & {
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
type ReducerActions =
|
||||
| { type: 'onAnalysis'; args: Parameters<onAnalysisFn> }
|
||||
| { type: 'onPrepare'; args: Parameters<onPrepareFn> }
|
||||
| { type: 'onReview'; args: Parameters<onReviewFn> }
|
||||
| { type: 'onGoBack' }
|
||||
| { type: 'onReset'; initialUrl?: string };
|
||||
|
||||
type ReducerState = {
|
||||
activeFlow: ImportFlows;
|
||||
activeState: ImportStateTypes;
|
||||
analysisUrl?: string;
|
||||
analyzeResult?: AnalyzeResult;
|
||||
prepareResult?: PrepareResult;
|
||||
reviewResult?: ReviewResult;
|
||||
|
||||
previousStates: ImportStateTypes[];
|
||||
};
|
||||
|
||||
function init(initialUrl?: string): ReducerState {
|
||||
return {
|
||||
activeFlow: 'unknown',
|
||||
activeState: 'analyze',
|
||||
analysisUrl: initialUrl,
|
||||
previousStates: [],
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(state: ReducerState, action: ReducerActions): ReducerState {
|
||||
switch (action.type) {
|
||||
case 'onAnalysis': {
|
||||
if (state.activeState !== 'analyze') {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { activeState, previousStates } = state;
|
||||
const [activeFlow, analysisUrl, analyzeResult, opts] = action.args;
|
||||
|
||||
return {
|
||||
...state,
|
||||
analysisUrl,
|
||||
activeFlow,
|
||||
analyzeResult,
|
||||
prepareResult: opts?.prepareResult,
|
||||
|
||||
activeState: opts?.prepareResult === undefined ? 'prepare' : 'review',
|
||||
previousStates: previousStates.concat(activeState),
|
||||
};
|
||||
}
|
||||
|
||||
case 'onPrepare': {
|
||||
if (state.activeState !== 'prepare') {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { activeState, previousStates } = state;
|
||||
const [prepareResult, opts] = action.args;
|
||||
|
||||
return {
|
||||
...state,
|
||||
prepareResult,
|
||||
|
||||
activeState: 'review',
|
||||
previousStates: opts?.notRepeatable
|
||||
? []
|
||||
: previousStates.concat(activeState),
|
||||
};
|
||||
}
|
||||
|
||||
case 'onReview': {
|
||||
if (state.activeState !== 'review') {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { activeState, previousStates } = state;
|
||||
const [reviewResult] = action.args;
|
||||
|
||||
return {
|
||||
...state,
|
||||
reviewResult,
|
||||
|
||||
activeState: 'finish',
|
||||
previousStates: previousStates.concat(activeState),
|
||||
};
|
||||
}
|
||||
|
||||
case 'onGoBack': {
|
||||
const { activeState, previousStates } = state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
activeState:
|
||||
previousStates.length > 0
|
||||
? previousStates[previousStates.length - 1]
|
||||
: activeState,
|
||||
previousStates: previousStates.slice(0, previousStates.length - 1),
|
||||
};
|
||||
}
|
||||
|
||||
case 'onReset':
|
||||
return {
|
||||
...init(action.initialUrl),
|
||||
|
||||
// we keep the old reviewResult since the form is animated and an
|
||||
// undefined value might crash the last step.
|
||||
reviewResult: state.reviewResult,
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that manages the state machine of the form. It handles different flows
|
||||
* which each can implement up to four states:
|
||||
@@ -133,130 +248,41 @@ export type ImportState = State & {
|
||||
* @param opts options
|
||||
*/
|
||||
export const useImportState = (opts?: { initialUrl?: string }): ImportState => {
|
||||
// state management for the results of the individual states
|
||||
const [results, setResults] = useState<{
|
||||
activeFlow: ImportFlows;
|
||||
activeState: ImportStateTypes;
|
||||
analysisUrl?: string;
|
||||
analyzeResult?: AnalyzeResult;
|
||||
prepareResult?: PrepareResult;
|
||||
reviewResult?: ReviewResult;
|
||||
}>({
|
||||
activeFlow: 'unknown',
|
||||
activeState: importStates[0],
|
||||
analysisUrl: opts?.initialUrl,
|
||||
});
|
||||
const [state, dispatch] = useReducer(reducer, opts?.initialUrl, init);
|
||||
|
||||
// for going back
|
||||
const [previewsSteps, setPreviousSteps] = useState<ImportStateTypes[]>([]);
|
||||
|
||||
// compute the next state to use
|
||||
const prepareNextStep = useCallback(
|
||||
(
|
||||
fromState: ImportStateTypes,
|
||||
opts?: { skip?: boolean; clearHistory?: boolean },
|
||||
) => {
|
||||
if (opts?.clearHistory) {
|
||||
setPreviousSteps([]);
|
||||
} else {
|
||||
setPreviousSteps(s => s.concat(fromState));
|
||||
}
|
||||
return importStates[
|
||||
importStates.indexOf(fromState) + (opts?.skip ? 2 : 1)
|
||||
];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onGoBack = useCallback(() => {
|
||||
if (previewsSteps.length > 0) {
|
||||
setResults(old => ({
|
||||
...old,
|
||||
activeState: previewsSteps[previewsSteps.length - 1],
|
||||
}));
|
||||
setPreviousSteps(previewsSteps.slice(0, previewsSteps.length - 1));
|
||||
}
|
||||
}, [previewsSteps]);
|
||||
|
||||
const onAnalysis = (
|
||||
flow: ImportFlows,
|
||||
url: string,
|
||||
result: AnalyzeResult,
|
||||
opts?: { prepareResult?: PrepareResult },
|
||||
) => {
|
||||
if (results.activeState !== 'analyze') {
|
||||
throw new Error("Can't analyze!");
|
||||
}
|
||||
|
||||
setResults(old => ({
|
||||
...old,
|
||||
analysisUrl: url,
|
||||
activeFlow: flow,
|
||||
activeState: prepareNextStep('analyze', {
|
||||
skip: opts?.prepareResult !== undefined,
|
||||
}),
|
||||
analyzeResult: result,
|
||||
prepareResult: opts?.prepareResult,
|
||||
}));
|
||||
};
|
||||
|
||||
const onPrepare = (
|
||||
result: PrepareResult,
|
||||
opts?: { notRepeatable?: boolean },
|
||||
) => {
|
||||
if (results.activeState !== 'prepare') {
|
||||
throw new Error("Can't prepare!");
|
||||
}
|
||||
|
||||
setResults(old => ({
|
||||
...old,
|
||||
prepareResult: result,
|
||||
activeState: prepareNextStep('prepare', {
|
||||
clearHistory: opts?.notRepeatable,
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const onReview = (result: ReviewResult) => {
|
||||
if (results.activeState !== 'review') {
|
||||
throw new Error("Can't prepare!");
|
||||
}
|
||||
|
||||
setResults(old => ({
|
||||
...old,
|
||||
reviewResult: result,
|
||||
activeState: prepareNextStep('review'),
|
||||
}));
|
||||
};
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
setResults(old => ({
|
||||
activeFlow: 'unknown',
|
||||
activeState: importStates[0],
|
||||
analysisUrl: opts?.initialUrl,
|
||||
|
||||
// we keep the old reviewResult since the form is animated and an
|
||||
// undefined value might crash the last step.
|
||||
reviewResult: old.reviewResult,
|
||||
}));
|
||||
setPreviousSteps([]);
|
||||
}, [opts?.initialUrl]);
|
||||
const { activeFlow, activeState, analysisUrl, previousStates } = state;
|
||||
|
||||
return {
|
||||
activeFlow: results.activeFlow,
|
||||
activeStepNumber: importStates.indexOf(results.activeState),
|
||||
analysisUrl: results.analysisUrl,
|
||||
activeFlow,
|
||||
activeState,
|
||||
activeStepNumber: ['analyze', 'prepare', 'review', 'finish'].indexOf(
|
||||
activeState,
|
||||
),
|
||||
analysisUrl: analysisUrl,
|
||||
|
||||
activeState: results.activeState,
|
||||
analyzeResult: results.analyzeResult!,
|
||||
prepareResult: results.prepareResult!,
|
||||
reviewResult: results.reviewResult!,
|
||||
analyzeResult: state.analyzeResult!,
|
||||
prepareResult: state.prepareResult!,
|
||||
reviewResult: state.reviewResult!,
|
||||
|
||||
onAnalysis,
|
||||
onPrepare,
|
||||
onReview,
|
||||
onAnalysis: (flow, url, result, opts) =>
|
||||
dispatch({
|
||||
type: 'onAnalysis',
|
||||
args: [flow, url, result, opts],
|
||||
}),
|
||||
|
||||
onGoBack: previewsSteps.length > 0 ? onGoBack : undefined,
|
||||
onReset,
|
||||
onPrepare: (result, opts) =>
|
||||
dispatch({
|
||||
type: 'onPrepare',
|
||||
args: [result, opts],
|
||||
}),
|
||||
|
||||
onReview: result => dispatch({ type: 'onReview', args: [result] }),
|
||||
|
||||
onGoBack:
|
||||
previousStates.length > 0
|
||||
? () => dispatch({ type: 'onGoBack' })
|
||||
: undefined,
|
||||
|
||||
onReset: () => dispatch({ type: 'onReset', initialUrl: opts?.initialUrl }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: T[P] extends (infer U)[]
|
||||
? RecursivePartial<U>[]
|
||||
: T[P] extends object
|
||||
? RecursivePartial<T[P]>
|
||||
: T[P];
|
||||
};
|
||||
|
||||
export type PartialEntity = RecursivePartial<Entity>;
|
||||
Reference in New Issue
Block a user