Merge branch 'master' of github.com:spotify/backstage into mob/techdocs-sanitize-html

This commit is contained in:
Sebastian Qvarfordt
2020-07-07 10:45:50 +02:00
41 changed files with 1447 additions and 81 deletions
+4 -1
View File
@@ -40,4 +40,7 @@ export interface CatalogApi {
removeEntityByUid(uid: string): Promise<void>;
}
export type AddLocationResponse = { location: Location; entities: Entity[] };
export type AddLocationResponse = {
location: Location;
entities: Entity[];
};
@@ -11,3 +11,17 @@ spec:
processor: cookiecutter
type: website
path: '.'
schema:
required:
- component_id
- description
properties:
component_id:
title: Name
type: string
description: Unique name of the component
description:
title: Description
type: string
description: Description of the component
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: {{cookiecutter.component_id}}
description: {{cookiecutter.description}}
spec:
type: website
lifecycle: experimental
owner: {{cookiecutter.owner}}
@@ -11,3 +11,16 @@ spec:
processor: cookiecutter
type: service
path: '.'
schema:
required:
- component_id
- description
properties:
component_id:
title: Name
type: string
description: Unique name of the component
description:
title: Description
type: string
description: Description of the component
@@ -39,6 +39,22 @@ describe('JobProcessor', () => {
spec: {
type: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
@@ -98,6 +98,7 @@ export class JobProcessor implements Processor {
try {
// Run the handler with the context created for the Job and some
// Additional logging helpers.
stage.status = 'STARTED';
const handlerResponse = await stage.handler({
...job.context,
logger,
@@ -49,6 +49,22 @@ describe('GitHubPreparer', () => {
spec: {
type: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
});
@@ -41,6 +41,22 @@ describe('Helpers', () => {
spec: {
type: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
@@ -72,6 +88,22 @@ describe('Helpers', () => {
spec: {
type: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
@@ -101,6 +133,22 @@ describe('Helpers', () => {
spec: {
type: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
@@ -131,6 +179,22 @@ describe('Helpers', () => {
spec: {
type: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
@@ -159,6 +223,22 @@ describe('Helpers', () => {
spec: {
type: 'cookiecutter',
path: './template',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
@@ -37,6 +37,22 @@ describe('Preparers', () => {
spec: {
type: 'cookiecutter',
path: '.',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
it('should throw an error when the preparer for the source location is not registered', () => {
@@ -74,6 +90,22 @@ describe('Preparers', () => {
spec: {
type: 'cookiecutter',
path: '.',
schema: {
$schema: 'http://json-schema.org/draft-07/schema#',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
},
},
};
@@ -14,22 +14,21 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import Router from 'express-promise-router';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
import { Octokit } from '@octokit/rest';
import Docker from 'dockerode';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
PreparerBuilder,
TemplaterBase,
GithubPublisher,
JobProcessor,
PreparerBuilder,
RequiredTemplateValues,
StageContext,
GithubPublisher,
TemplaterBase,
} from '../scaffolder';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import Docker from 'dockerode';
import {} from '@backstage/backend-common';
import { Octokit } from '@octokit/rest';
import { JsonValue } from '@backstage/config';
export interface RouterOptions {
preparers: PreparerBuilder;
@@ -107,7 +106,7 @@ export async function createRouter(
{
name: 'Run the templater',
handler: async (ctx: StageContext<{ skeletonDir: string }>) => {
const resultDir = await templater.run({
const { resultDir } = await templater.run({
directory: ctx.skeletonDir,
dockerClient,
logStream: ctx.logStream,
+8 -1
View File
@@ -22,14 +22,20 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.12",
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/core": "^0.1.1-alpha.12",
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@rjsf/core": "^2.1.0",
"@rjsf/material-ui": "^2.1.0",
"classnames": "^2.2.6",
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"swr": "^0.2.2"
@@ -37,6 +43,7 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.12",
"@backstage/dev-utils": "^0.1.1-alpha.12",
"@backstage/test-utils": "^0.1.1-alpha.12",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+73
View File
@@ -0,0 +1,73 @@
/*
* 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 { createApiRef } from '@backstage/core';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
id: 'plugin.scaffolder.service',
description: 'Used to make requests towards the scaffolder backend',
});
export class ScaffolderApi {
private apiOrigin: string;
private basePath: string;
constructor({
apiOrigin,
basePath,
}: {
apiOrigin: string;
basePath: string;
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
}
/**
*
* @param template Template entity for the scaffolder to use. New project is going to be created out of this template.
* @param values Parameters for the template, e.g. name, description
*/
async scaffold(
template: TemplateEntityV1alpha1,
values: Record<string, any>,
) {
const url = `${this.apiOrigin}${this.basePath}/jobs`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// TODO(shmidt-i): when repo picker is implemented, take isOrg from it
body: JSON.stringify({ template, values: { ...values, isOrg: true } }),
});
if (response.status !== 201) {
throw new Error(await response.text());
}
const { id } = await response.json();
return id;
}
async getJob(jobId: string) {
const url = `${this.apiOrigin}${this.basePath}/job/${encodeURIComponent(
jobId,
)}`;
return fetch(url).then(x => x.json());
}
}
@@ -0,0 +1,136 @@
/*
* 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 {
Box,
ExpansionPanel,
ExpansionPanelDetails,
ExpansionPanelSummary,
LinearProgress,
Typography,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import cn from 'classnames';
import moment from 'moment';
import React, { Suspense, useEffect, useState } from 'react';
import { Job } from '../../types';
const LazyLog = React.lazy(() => import('react-lazylog/build/LazyLog'));
moment.relativeTimeThreshold('ss', 0);
const useStyles = makeStyles(theme => ({
expansionPanelDetails: {
padding: 0,
},
button: {
order: -1,
marginRight: 0,
marginLeft: '-20px',
},
cardContent: {
backgroundColor: theme.palette.background.default,
},
expansionPanel: {
position: 'relative',
'&:after': {
pointerEvents: 'none',
content: '""',
position: 'absolute',
top: 0,
right: 0,
left: 0,
bottom: 0,
},
},
neutral: {},
failed: {
'&:after': {
boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`,
},
},
started: {
'&:after': {
boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`,
},
},
completed: {
'&:after': {
boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`,
},
},
}));
type Props = {
name: string;
className?: string;
log: string[];
startedAt: string;
endedAt?: string;
status: Job['status'];
};
export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
const classes = useStyles();
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (status === 'FAILED') setExpanded(true);
}, [status, setExpanded]);
const timeElapsed =
status !== 'PENDING'
? moment
.duration(moment(endedAt ?? moment()).diff(moment(startedAt)))
.humanize()
: null;
return (
<ExpansionPanel
TransitionProps={{ unmountOnExit: true }}
className={cn(
classes.expansionPanel,
classes[status.toLowerCase() as keyof ReturnType<typeof useStyles>] ??
classes.neutral,
)}
expanded={expanded}
onChange={(_, newState) => setExpanded(newState)}
>
<ExpansionPanelSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`panel-${name}-content`}
id={`panel-${name}-header`}
IconButtonProps={{
className: classes.button,
}}
>
<Typography variant="button">
{name} {timeElapsed && `(${timeElapsed})`}
</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.expansionPanelDetails}>
{log.length === 0 ? (
<Box px={4}>No logs available for this step</Box>
) : (
<Suspense fallback={<LinearProgress />}>
<div style={{ height: '20vh', width: '100%' }}>
<LazyLog text={log.join('\n')} extraLines={1} />
</div>
</Suspense>
)}
</ExpansionPanelDetails>
</ExpansionPanel>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { JobStage } from './JobStage';
@@ -0,0 +1,91 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect } from 'react';
import {
Dialog,
LinearProgress,
DialogTitle,
DialogContent,
DialogActions,
} from '@material-ui/core';
import { JobStage } from '../JobStage/JobStage';
import { useJobPolling } from './useJobPolling';
import { Job } from '../../types';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Button } from '@backstage/core';
import { entityRoute } from '@backstage/plugin-catalog';
import { generatePath } from 'react-router-dom';
type Props = {
onClose: () => void;
onComplete: (job: Job) => void;
jobId: string;
entity: TemplateEntityV1alpha1 | null;
};
export const JobStatusModal = ({
onClose,
jobId,
onComplete,
entity,
}: Props) => {
const job = useJobPolling(jobId);
useEffect(() => {
if (job?.status === 'COMPLETED') onComplete(job);
}, [job, onComplete]);
return (
<Dialog open onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">
Creating component...
</DialogTitle>
<DialogContent>
{!job ? (
<LinearProgress />
) : (
(job?.stages ?? []).map(step => (
<JobStage
log={step.log}
name={step.name}
key={step.name}
startedAt={step.startedAt}
endedAt={step.endedAt}
status={step.status}
/>
))
)}
</DialogContent>
{entity && (
<DialogActions>
<Button
to={generatePath(entityRoute.path, {
kind: entity.kind,
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
})}
>
View in catalog
</Button>
</DialogActions>
)}
</Dialog>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { JobStatusModal } from './JobStatusModal';
@@ -0,0 +1,62 @@
/*
* 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 { useState, useEffect } from 'react';
import { Job } from '../../types';
import { useApi } from '@backstage/core';
import { scaffolderApiRef } from '../../api';
const DEFAULT_POLLING_INTERVAL = 1000;
const poll = (thunk: () => Promise<void>, ms: number) => {
let shouldStop = false;
(async () => {
while (!shouldStop) {
await thunk();
await new Promise(res => setTimeout(res, ms));
}
})();
return () => {
shouldStop = true;
};
};
export const useJobPolling = (
jobId: string | null,
pollingInterval = DEFAULT_POLLING_INTERVAL,
) => {
const scaffolderApi = useApi(scaffolderApiRef);
const [job, setJob] = useState<Job | null>(null);
useEffect(() => {
if (!jobId) return () => {};
const stopPolling = poll(async () => {
const nextJobState = await scaffolderApi.getJob(jobId);
if (
nextJobState.status === 'FAILED' ||
nextJobState.status === 'COMPLETED'
) {
stopPolling();
}
setJob(nextJobState);
}, pollingInterval);
return () => {
stopPolling();
};
}, [jobId, setJob, scaffolderApi, pollingInterval]);
return job;
};
@@ -0,0 +1,110 @@
/*
* 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 { JSONSchema } from '@backstage/catalog-model';
import { Content, StructuredMetadataTable } from '@backstage/core';
import {
Box,
Button,
Paper,
Step,
StepContent,
StepLabel,
Stepper,
Typography,
} from '@material-ui/core';
import { FormProps, IChangeEvent, withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import React, { useState } from 'react';
const Form = withTheme(MuiTheme);
type Step = {
schema: JSONSchema;
label: string;
} & Partial<Omit<FormProps<any>, 'schema'>>;
type Props = {
/**
* Steps for the form, each contains label and form schema
*/
steps: Step[];
formData: Record<string, any>;
onChange: (e: IChangeEvent) => void;
onReset: () => void;
onFinish: () => void;
};
export const MultistepJsonForm = ({
steps,
formData,
onChange,
onReset,
onFinish,
}: Props) => {
const [activeStep, setActiveStep] = useState(0);
const handleReset = () => {
setActiveStep(0);
onReset();
};
const handleNext = () =>
setActiveStep(Math.min(activeStep + 1, steps.length));
const handleBack = () => setActiveStep(Math.max(activeStep - 1, 0));
return (
<>
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map(({ label, schema, ...formProps }) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
<StepContent>
<Form
noHtml5Validate
formData={formData}
onChange={onChange}
schema={schema as FormProps<any>['schema']}
onSubmit={e => {
if (e.errors.length === 0) handleNext();
}}
{...formProps}
>
<Button disabled={activeStep === 0} onClick={handleBack}>
Back
</Button>
<Button variant="contained" color="primary" type="submit">
Next step
</Button>
</Form>
</StepContent>
</Step>
))}
</Stepper>
{activeStep === steps.length && (
<Content>
<Paper square elevation={0}>
<Typography variant="h6">Review and create</Typography>
<StructuredMetadataTable dense metadata={formData} />
<Box mb={4} />
<Button onClick={handleBack}>Back</Button>
<Button onClick={handleReset}>Reset</Button>
<Button variant="contained" color="primary" onClick={onFinish}>
Create
</Button>
</Paper>
</Content>
)}
</>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { MultistepJsonForm } from './MultistepJsonForm';
@@ -14,32 +14,44 @@
* limitations under the License.
*/
import React, { useEffect } from 'react';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
Lifecycle,
Content,
ContentHeader,
errorApiRef,
Header,
SupportButton,
Lifecycle,
Page,
pageTheme,
SupportButton,
useApi,
errorApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import {
Typography,
Link,
Button,
Grid,
LinearProgress,
Link,
Typography,
} from '@material-ui/core';
import React, { useEffect } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import TemplateCard from '../TemplateCard';
import useStaleWhileRevalidate from 'swr';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { TemplateCard, TemplateCardProps } from '../TemplateCard';
const ScaffolderPage: React.FC<{}> = () => {
const getTemplateCardProps = (
template: TemplateEntityV1alpha1,
): TemplateCardProps & { key: string } => {
return {
key: template.metadata.uid!,
name: template.metadata.name,
title: `${(template.metadata.title || template.metadata.name) ?? ''}`,
type: template.spec.type ?? '',
description: template.metadata.description ?? '-',
tags: (template.metadata?.tags as string[]) ?? [],
};
};
export const ScaffolderPage: React.FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const errorApi = useApi(errorApiRef);
@@ -96,15 +108,7 @@ const ScaffolderPage: React.FC<{}> = () => {
templates.map(template => {
return (
<Grid item xs={12} sm={6} md={3}>
<TemplateCard
key={template.metadata.uid}
title={`${
(template.metadata.title || template.metadata.name) ?? ''
}`}
type={template.spec.type ?? ''}
description={template.metadata.description ?? '-'}
tags={(template.metadata?.tags as string[]) ?? []}
/>
<TemplateCard {...getTemplateCardProps(template)} />
</Grid>
);
})}
@@ -113,5 +117,3 @@ const ScaffolderPage: React.FC<{}> = () => {
</Page>
);
};
export default ScaffolderPage;
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { ScaffolderPage } from './ScaffolderPage';
@@ -13,8 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core';
import { Button } from '@backstage/core';
import { Card, Chip, makeStyles, Typography } from '@material-ui/core';
import React from 'react';
import { generatePath } from 'react-router-dom';
import { templateRoute } from '../../routes';
const useStyles = makeStyles(theme => ({
header: {
@@ -37,19 +40,23 @@ const useStyles = makeStyles(theme => ({
},
}));
type TemplateCardProps = {
export type TemplateCardProps = {
description: string;
tags: string[];
title: string;
type: string;
name: string;
};
const TemplateCard: FC<TemplateCardProps> = ({
export const TemplateCard = ({
description,
tags,
title,
type,
}) => {
name,
}: TemplateCardProps) => {
const classes = useStyles();
const href = generatePath(templateRoute.path, { templateName: name });
return (
<Card>
@@ -65,11 +72,11 @@ const TemplateCard: FC<TemplateCardProps> = ({
{description}
</Typography>
<div className={classes.footer}>
<Button color="primary">Choose</Button>
<Button color="primary" variant="contained" to={href}>
Choose
</Button>
</div>
</div>
</Card>
);
};
export default TemplateCard;
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { TemplateCard } from './TemplateCard';
export type { TemplateCardProps } from './TemplateCard';
@@ -0,0 +1,154 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TemplatePage } from './TemplatePage';
import { wrapInTestApp, renderWithEffects } from '@backstage/test-utils';
import { ApiRegistry, errorApiRef, ApiProvider } from '@backstage/core';
import { scaffolderApiRef, ScaffolderApi } from '../../api';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
import { mutate } from 'swr';
import { act } from 'react-dom/test-utils';
import { Route, MemoryRouter } from 'react-router';
import { rootRoute } from '../../routes';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
const templateMock = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'file:/something/sample-templates/react-ssr-template/template.yaml',
},
name: 'react-ssr-template',
title: 'React SSR Template',
description:
'Next.js application skeleton for creating isomorphic web applications.',
tags: ['Recommended', 'React'],
uid: '55efc748-4a2b-460f-9e47-3f4fd23b46f7',
etag: 'MTM3YThjY2QtYTc1MS00MTFkLTk3YTAtNzgyMDg3MDVmZTVm',
generation: 1,
},
spec: {
processor: 'cookiecutter',
type: 'website',
path: '.',
schema: {
required: ['component_id', 'description'],
properties: {
component_id: {
title: 'Name',
type: 'string',
description: 'Unique name of the component',
},
description: {
title: 'Description',
type: 'string',
description: 'Description of the component',
},
},
},
},
};
jest.mock('react-router-dom', () => {
return {
...(jest.requireActual('react-router-dom') as any),
useParams: () => ({
templateName: 'test',
}),
};
});
const scaffolderApiMock: Partial<ScaffolderApi> = {
scaffold: jest.fn(),
};
const catalogApiMock = {
getEntities: jest.fn() as jest.MockedFunction<CatalogApi['getEntities']>,
};
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
const apis = ApiRegistry.from([
[scaffolderApiRef, scaffolderApiMock],
[errorApiRef, errorApiMock],
[catalogApiRef, catalogApiMock],
]);
describe('TemplatePage', () => {
afterEach(async () => {
// Cleaning up swr's cache
await act(async () => {
await mutate('templates/test');
});
});
it('renders correctly', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce([templateMock]);
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
),
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByText('React SSR Template')).toBeInTheDocument();
// await act(async () => await mutate('templates/test'));
});
it('renders spinner while loading', async () => {
let resolve: Function;
const promise = new Promise<any>(res => {
resolve = res;
});
catalogApiMock.getEntities.mockResolvedValueOnce(promise);
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
),
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument();
// Need to cleanup the promise or will timeout
resolve!();
});
it('navigates away if no template was loaded', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce([]);
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter initialEntries={['/create/test']}>
<Route path="/create/test">
<TemplatePage />
</Route>
<Route path={rootRoute.path} element={<>This is root</>} />
</MemoryRouter>
</ThemeProvider>
</ApiProvider>,
);
expect(
rendered.queryByText('Create a new component'),
).not.toBeInTheDocument();
expect(rendered.queryByText('This is root')).toBeInTheDocument();
});
});
@@ -0,0 +1,182 @@
/*
* 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 { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
InfoCard,
Lifecycle,
Page,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { LinearProgress } from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import useStaleWhileRevalidate from 'swr';
import { scaffolderApiRef } from '../../api';
import { JobStatusModal } from '../JobStatusModal';
import { Job } from '../../types';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { Navigate } from 'react-router';
import { rootRoute } from '../../routes';
const useTemplate = (
templateName: string,
catalogApi: typeof catalogApiRef.T,
) => {
const { data, error } = useStaleWhileRevalidate(
`templates/${templateName}`,
async () =>
catalogApi.getEntities({
kind: 'Template',
'metadata.name': templateName,
}) as Promise<TemplateEntityV1alpha1[]>,
);
return { template: data?.[0], loading: !error && !data, error };
};
const OWNER_REPO_SCHEMA = {
$schema: 'http://json-schema.org/draft-07/schema#' as const,
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string' as const,
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
format: 'GitHub user or org / Repo name',
type: 'string' as const,
title: 'Store path',
description: 'GitHub store path in org/repo format',
},
},
};
const REPO_FORMAT = {
'GitHub user or org / Repo name': /[^\/]*\/[^\/]*/,
};
export const TemplatePage = () => {
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const scaffolderApi = useApi(scaffolderApiRef);
const { templateName } = useParams();
const { template, loading } = useTemplate(templateName, catalogApi);
const [formState, setFormState] = useState({});
const handleFormReset = () => setFormState({});
const handleChange = (e: IChangeEvent) =>
setFormState({ ...formState, ...e.formData });
const [jobId, setJobId] = useState<string | null>(null);
const handleClose = () => setJobId(null);
const handleCreate = async () => {
const job = await scaffolderApi.scaffold(template!, formState);
setJobId(job);
};
const [entity, setEntity] = React.useState<TemplateEntityV1alpha1 | null>(
null,
);
const handleCreateComplete = async (job: Job) => {
const componentYaml = job.metadata.remoteUrl?.replace(
/\.git$/,
'/blob/master/component-info.yaml',
);
if (!componentYaml) {
errorApi.post(
new Error(
`Failed to find component-info.yaml file in ${job.metadata.remoteUrl}.`,
),
);
return;
}
const {
entities: [createdEntity],
} = await catalogApi.addLocation('github', componentYaml);
setEntity((createdEntity as any) as TemplateEntityV1alpha1);
};
if (!loading && !template) {
errorApi.post(new Error('Template was not found.'));
return <Navigate to={rootRoute.path} />;
}
if (template && !template?.spec?.schema) {
errorApi.post(
new Error(
'Template schema is corrupted, please check the template.yaml file.',
),
);
return <Navigate to={rootRoute.path} />;
}
return (
<Page>
<Header
pageTitleOverride="Create a new component"
title={
<>
Create a new component <Lifecycle alpha shorthand />
</>
}
subtitle="Create new software components using standard templates"
/>
<Content>
{loading && <LinearProgress data-testid="loading-progress" />}
{jobId && (
<JobStatusModal
onComplete={handleCreateComplete}
jobId={jobId}
onClose={handleClose}
entity={entity}
/>
)}
{template && (
<InfoCard title={template.metadata.title as string} noPadding>
<MultistepJsonForm
formData={formState}
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
steps={[
{
label: 'Fill in template parameters',
schema: template.spec.schema,
},
{
label: 'Choose owner and repo',
schema: OWNER_REPO_SCHEMA,
customFormats: REPO_FORMAT,
},
]}
/>
</InfoCard>
)}
</Content>
</Page>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { TemplatePage } from './TemplatePage';
+3 -1
View File
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { plugin, rootRoute } from './plugin';
export { plugin } from './plugin';
export { ScaffolderApi, scaffolderApiRef } from './api';
export { rootRoute, templateRoute } from './routes';
+5 -8
View File
@@ -14,18 +14,15 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import ScaffolderPage from './components/ScaffolderPage';
export const rootRoute = createRouteRef({
icon: () => null,
path: '/create',
title: 'Create entity',
});
import { createPlugin } from '@backstage/core';
import { ScaffolderPage } from './components/ScaffolderPage';
import { TemplatePage } from './components/TemplatePage';
import { rootRoute, templateRoute } from './routes';
export const plugin = createPlugin({
id: 'scaffolder',
register({ router }) {
router.addRoute(rootRoute, ScaffolderPage);
router.addRoute(templateRoute, TemplatePage);
},
});
+27
View File
@@ -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 { createRouteRef } from '@backstage/core';
export const rootRoute = createRouteRef({
icon: () => null,
path: '/create',
title: 'Create new entity',
});
export const templateRoute = createRouteRef({
icon: () => null,
path: '/create/:templateName',
title: 'Entity creation',
});
+34
View File
@@ -0,0 +1,34 @@
/*
* 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.
*/
export type Job = {
id: string;
metadata: {
entity: any;
values: any;
remoteUrl?: string;
};
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
stages: Stage[];
error?: Error;
};
export type Stage = {
name: string;
log: string[];
status: 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
startedAt: string;
endedAt?: string;
};