fix(scaffolder): big cleanup

This commit is contained in:
Ivan Shmidt
2020-07-01 20:36:16 +02:00
parent 227a4af561
commit 6de13cb54e
19 changed files with 318 additions and 178 deletions
+11 -3
View File
@@ -34,10 +34,18 @@ export interface CatalogApi {
getEntityByName(
compoundName: EntityCompoundName,
): Promise<Entity | undefined>;
getEntities(filter?: Record<string, string | string[]>): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getEntities<EntityType extends Entity = Entity>(
filter?: Record<string, string | string[]>,
): Promise<EntityType[]>;
addLocation<EntityType extends Entity = Entity>(
type: string,
target: string,
): Promise<AddLocationResponse<EntityType>>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
removeEntityByUid(uid: string): Promise<void>;
}
export type AddLocationResponse = { location: Location; entities: Entity[] };
export type AddLocationResponse<EntityType extends Entity = Entity> = {
location: Location;
entities: EntityType[];
};
+4 -2
View File
@@ -25,15 +25,17 @@
"@backstage/core": "^0.1.1-alpha.12",
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@backstage/catalog-model": "^0.1.1-alpha.12",
"@backstage/plugin-catalog": "^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.27.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"swr": "^0.2.2"
@@ -1 +0,0 @@
export { CreatePage } from './CreatePage';
@@ -13,21 +13,24 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState, Suspense } from 'react';
import {
Box,
ExpansionPanel,
ExpansionPanelSummary,
Typography,
ExpansionPanelDetails,
ExpansionPanelSummary,
LinearProgress,
Typography,
} from '@material-ui/core';
import moment from 'moment';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { makeStyles } from '@material-ui/core/styles';
import { Job } from './types';
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,
@@ -37,37 +40,10 @@ const useStyles = makeStyles(theme => ({
marginRight: 0,
marginLeft: '-20px',
},
neutral: {},
failed: {
position: 'relative',
'&:after': {
pointerEvents: 'none',
content: '""',
position: 'absolute',
top: 0,
right: 0,
left: 0,
bottom: 0,
boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`,
},
},
running: {
position: 'relative',
'&:after': {
pointerEvents: 'none',
content: '""',
position: 'absolute',
top: 0,
right: 0,
left: 0,
bottom: 0,
boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`,
},
},
cardContent: {
backgroundColor: theme.palette.background.default,
},
success: {
expansionPanel: {
position: 'relative',
'&:after': {
pointerEvents: 'none',
@@ -77,6 +53,21 @@ const useStyles = makeStyles(theme => ({
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}`,
},
},
@@ -90,13 +81,14 @@ type Props = {
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 === 'FAILED', setExpanded]);
}, [status, setExpanded]);
const timeElapsed =
status !== 'PENDING'
@@ -108,15 +100,10 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
return (
<ExpansionPanel
TransitionProps={{ unmountOnExit: true }}
className={
status === 'STARTED'
? classes.running
: status === 'FAILED'
? classes.failed
: status === 'COMPLETED'
? classes.success
: classes.neutral
}
className={cn(
classes.expansionPanel,
classes[status.toLowerCase()] ?? classes.neutral,
)}
expanded={expanded}
onChange={(_, newState) => setExpanded(newState)}
>
@@ -134,11 +121,11 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
</ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.expansionPanelDetails}>
{log.length === 0 ? (
'Nothing here...'
<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} enableSearch />
<LazyLog text={log.join('\n')} extraLines={1} />
</div>
</Suspense>
)}
@@ -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';
@@ -1,3 +1,18 @@
/*
* 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,
@@ -6,13 +21,14 @@ import {
DialogContent,
DialogActions,
} from '@material-ui/core';
import { JobStage } from './JobStage';
import { JobStage } from '../JobStage/JobStage';
import { useJobPolling } from './useJobPolling';
import { Job } from './types';
import { Job } from '../../types';
import { ComponentEntityV1alpha1 } 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;
@@ -29,8 +45,9 @@ export const JobStatusModal = ({
const job = useJobPolling(jobId);
useEffect(() => {
if (job?.status === 'COMPLETED') onComplete(job as Job);
}, [job]);
if (job?.status === 'COMPLETED') onComplete(job);
}, [job, onComplete]);
return (
<Dialog open onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { Job } from './types';
import { Job } from '../../types';
import { useApi } from '@backstage/core';
import { scaffolderApiRef } from '../../api';
@@ -42,6 +42,7 @@ export const useJobPolling = (
useEffect(() => {
if (!jobId) return () => {};
const stopPolling = poll(async () => {
const nextJobState = await scaffolderApi.getJob(jobId);
if (
@@ -13,34 +13,39 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import { withTheme, IChangeEvent, FormProps } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import { JSONSchema } from '@backstage/catalog-model';
import { Content, StructuredMetadataTable } from '@backstage/core';
import {
Stepper,
Step,
StepLabel,
StepContent,
Box,
Button,
Paper,
Step,
StepContent,
StepLabel,
Stepper,
Typography,
Box,
} from '@material-ui/core';
import { Content, StructuredMetadataTable } from '@backstage/core';
import { JSONSchema } from '@backstage/catalog-model';
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<FormProps>;
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,
@@ -57,10 +62,11 @@ export const MultistepJsonForm = ({
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 }) => (
{steps.map(({ label, schema, ...formProps }) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
<StepContent>
@@ -72,17 +78,14 @@ export const MultistepJsonForm = ({
onSubmit={e => {
if (e.errors.length === 0) handleNext();
}}
{...formProps}
>
<div>
<div>
<Button disabled={activeStep === 0} onClick={handleBack}>
Back
</Button>
<Button variant="contained" color="primary" type="submit">
{activeStep === steps.length - 1 ? 'Finish' : 'Next'}
</Button>
</div>
</div>
<Button disabled={activeStep === 0} onClick={handleBack}>
Back
</Button>
<Button variant="contained" color="primary" type="submit">
Next step
</Button>
</Form>
</StepContent>
</Step>
@@ -14,31 +14,43 @@
* 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 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,16 +108,7 @@ export const ScaffolderPage: React.FC<{}> = () => {
templates.map(template => {
return (
<Grid item xs={12} sm={6} md={3}>
<TemplateCard
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[]) ?? []}
/>
<TemplateCard {...getTemplateCardProps(template)} />
</Grid>
);
})}
@@ -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,11 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 { Button } from '@backstage/core';
import { Card, Chip, Typography, makeStyles } from '@material-ui/core';
import { createTemplateRoute } from '../../routes';
import { templateRoute } from '../../routes';
const useStyles = makeStyles(theme => ({
header: {
@@ -40,14 +40,15 @@ const useStyles = makeStyles(theme => ({
},
}));
type TemplateCardProps = {
export type TemplateCardProps = {
description: string;
tags: string[];
title: string;
type: string;
name: string;
};
const TemplateCard = ({
export const TemplateCard = ({
description,
tags,
title,
@@ -55,7 +56,7 @@ const TemplateCard = ({
name,
}: TemplateCardProps) => {
const classes = useStyles();
const href = generatePath(createTemplateRoute.path, { templateName: name });
const href = generatePath(templateRoute.path, { templateName: name });
return (
<Card>
@@ -79,5 +80,3 @@ const TemplateCard = ({
</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';
@@ -13,45 +13,76 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import useStaleWhileRevalidate from 'swr';
import { useParams } from 'react-router-dom';
import { LinearProgress } from '@material-ui/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import {
useApi,
Page,
Content,
Header,
Lifecycle,
InfoCard,
errorApiRef,
} from '@backstage/core';
import {
TemplateEntityV1alpha1,
ComponentEntityV1alpha1,
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 { JobStatusModal } from '../JobStatusModal';
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 { Job } from '../JobStatusModal/types';
export const CreatePage = () => {
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<TemplateEntityV1alpha1>({
kind: 'Template',
'metadata.name': templateName,
}),
);
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 {
data: [template] = [] as TemplateEntityV1alpha1[],
isValidating,
} = useStaleWhileRevalidate(
`templates/${templateName}`,
async () =>
(catalogApi.getEntities({
kind: 'Template',
'metadata.name': templateName,
}) as any) as Promise<TemplateEntityV1alpha1[]>,
);
const { template, loading } = useTemplate(templateName, catalogApi);
const [formState, setFormState] = useState({});
const handleFormReset = () => setFormState({});
@@ -62,22 +93,24 @@ export const CreatePage = () => {
const handleClose = () => setJobId(null);
const handleCreate = async () => {
const job = await scaffolderApi.scaffold(template, formState);
const job = await scaffolderApi.scaffold(template!, formState);
setJobId(job);
};
const [entity, setEntity] = React.useState<ComponentEntityV1alpha1 | 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}`,
`Failed to find component-info.yaml file in ${job.metadata.remoteUrl}.`,
),
);
return;
@@ -85,13 +118,27 @@ export const CreatePage = () => {
const {
entities: [createdEntity],
} = await catalogApi.addLocation('github', componentYaml);
} = await catalogApi.addLocation<ComponentEntityV1alpha1>(
'github',
componentYaml,
);
setEntity(createdEntity as ComponentEntityV1alpha1);
setEntity(createdEntity);
};
if (!template && isValidating) return <LinearProgress />;
if (!template || !template?.spec?.schema) return null;
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>
@@ -105,6 +152,7 @@ export const CreatePage = () => {
subtitle="Create new software components using standard templates"
/>
<Content>
{loading && <LinearProgress />}
{jobId && (
<JobStatusModal
onComplete={handleCreateComplete}
@@ -113,39 +161,27 @@ export const CreatePage = () => {
entity={entity}
/>
)}
<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: {
$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',
},
},
{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,
},
},
]}
/>
</InfoCard>
{
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';
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { plugin } from './plugin';
export { rootRoute, createTemplateRoute } from './routes';
export { rootRoute, templateRoute } from './routes';
+3 -3
View File
@@ -16,13 +16,13 @@
import { createPlugin } from '@backstage/core';
import { ScaffolderPage } from './components/ScaffolderPage';
import { CreatePage } from './components/CreatePage';
import { rootRoute, createTemplateRoute } from './routes';
import { TemplatePage } from './components/TemplatePage';
import { rootRoute, templateRoute } from './routes';
export const plugin = createPlugin({
id: 'scaffolder',
register({ router }) {
router.addRoute(rootRoute, ScaffolderPage);
router.addRoute(createTemplateRoute, CreatePage);
router.addRoute(templateRoute, TemplatePage);
},
});
+16 -1
View File
@@ -1,3 +1,18 @@
/*
* 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({
@@ -5,7 +20,7 @@ export const rootRoute = createRouteRef({
path: '/create',
title: 'Create new entity',
});
export const createTemplateRoute = createRouteRef({
export const templateRoute = createRouteRef({
icon: () => null,
path: '/create/:templateName',
title: 'Entity creation',
+6 -1
View File
@@ -13030,6 +13030,11 @@ moment@2.26.0, moment@^2.25.3, moment@^2.26.0:
resolved "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a"
integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw==
moment@^2.27.0:
version "2.27.0"
resolved "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d"
integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==
monaco-editor@^0.20.0:
version "0.20.0"
resolved "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz#5d5009343a550124426cb4d965a4d27a348b4dea"
@@ -15697,7 +15702,7 @@ react-router-dom@^5.2.0:
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
react-router@5.2.0:
react-router@5.2.0, react-router@^5.2.0:
version "5.2.0"
resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==