chore: remove the legacy exports from alpha
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -15,4 +15,3 @@
|
||||
*/
|
||||
|
||||
export * from './next';
|
||||
export * from './legacy';
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
LegacyCustomFieldValidator,
|
||||
LegacyFieldExtensionOptions,
|
||||
LegacyFieldExtensionComponentProps,
|
||||
} from './types';
|
||||
import { Extension, attachComponentData } from '@backstage/core-plugin-api';
|
||||
import { FIELD_EXTENSION_KEY } from '../../extensions/keys';
|
||||
import { FieldExtensionComponent } from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
/**
|
||||
* Method for creating field extensions that can be used in the scaffolder
|
||||
* frontend form.
|
||||
* @alpha
|
||||
*/
|
||||
export function createLegacyScaffolderFieldExtension<
|
||||
TReturnValue = unknown,
|
||||
TInputProps = unknown,
|
||||
>(
|
||||
options: LegacyFieldExtensionOptions<TReturnValue, TInputProps>,
|
||||
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>> {
|
||||
return {
|
||||
expose() {
|
||||
const FieldExtensionDataHolder: any = () => null;
|
||||
|
||||
attachComponentData(
|
||||
FieldExtensionDataHolder,
|
||||
FIELD_EXTENSION_KEY,
|
||||
options,
|
||||
);
|
||||
|
||||
return FieldExtensionDataHolder;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type {
|
||||
LegacyCustomFieldValidator,
|
||||
LegacyFieldExtensionOptions,
|
||||
LegacyFieldExtensionComponentProps,
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { FieldValidation } from '@rjsf/utils';
|
||||
import {
|
||||
CustomFieldExtensionSchema,
|
||||
ScaffolderRJSFFieldProps,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
/**
|
||||
* Field validation type for Custom Field Extensions.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type LegacyCustomFieldValidator<TFieldReturnValue> = (
|
||||
data: TFieldReturnValue,
|
||||
field: FieldValidation,
|
||||
context: { apiHolder: ApiHolder },
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Type for the Custom Field Extension with the
|
||||
* name and components and validation function.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type LegacyFieldExtensionOptions<
|
||||
TFieldReturnValue = unknown,
|
||||
TInputProps = unknown,
|
||||
> = {
|
||||
name: string;
|
||||
component: (
|
||||
props: LegacyFieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
|
||||
) => JSX.Element | null;
|
||||
validation?: LegacyCustomFieldValidator<TFieldReturnValue>;
|
||||
schema?: CustomFieldExtensionSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for field extensions and being able to type
|
||||
* incoming props easier.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface LegacyFieldExtensionComponentProps<
|
||||
TFieldReturnValue,
|
||||
TUiOptions = unknown,
|
||||
> extends ScaffolderRJSFFieldProps<TFieldReturnValue> {
|
||||
uiSchema: ScaffolderRJSFFieldProps['uiSchema'] & {
|
||||
'ui:options'?: TUiOptions;
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './extensions';
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { scaffolderApiRef, ScaffolderClient } from '../src';
|
||||
import { ScaffolderPage, LegacyScaffolderPage } from '../src/plugin';
|
||||
import { ScaffolderPage } from '../src/plugin';
|
||||
import {
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
@@ -68,11 +68,6 @@ createDevApp()
|
||||
title: 'Create',
|
||||
element: <ScaffolderPage />,
|
||||
})
|
||||
.addPage({
|
||||
path: '/legacy-create',
|
||||
title: 'Create (next)',
|
||||
element: <LegacyScaffolderPage />,
|
||||
})
|
||||
.addPage({
|
||||
path: '/create-groups',
|
||||
title: 'Groups',
|
||||
@@ -91,22 +86,4 @@ createDevApp()
|
||||
/>
|
||||
),
|
||||
})
|
||||
.addPage({
|
||||
path: '/legacy-create-groups',
|
||||
title: 'Groups (next)',
|
||||
element: (
|
||||
<LegacyScaffolderPage
|
||||
groups={[
|
||||
{
|
||||
filter: e => e.metadata.tags?.includes('techdocs') || false,
|
||||
title: 'Techdocs',
|
||||
},
|
||||
{
|
||||
filter: e => e.metadata.tags?.includes('react') || false,
|
||||
title: 'React',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
})
|
||||
.render();
|
||||
|
||||
@@ -22,5 +22,3 @@ export type { RouterProps } from './Router';
|
||||
export { OngoingTask as TaskPage } from './OngoingTask';
|
||||
|
||||
export type { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
export type { TaskPageProps } from '../legacy/TaskPage';
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
@@ -212,3 +212,13 @@ export type LayoutTemplate = LayoutTemplateTemp;
|
||||
* @deprecated use import from {@link @backstage/plugin-scaffolder-react#LayoutOptions} instead as this has now been moved.
|
||||
*/
|
||||
export type LayoutOptions = LayoutOptionsTemp;
|
||||
|
||||
/**
|
||||
* TaskPageProps for constructing a TaskPage
|
||||
* @param loadingText - Optional loading text shown before a task begins executing.
|
||||
* @public
|
||||
* @deprecated - this is a useless type that is no longer used.
|
||||
*/
|
||||
export type TaskPageProps = {
|
||||
loadingText?: string;
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MarkdownContent } from '@backstage/core-components';
|
||||
import { FieldProps } from '@rjsf/utils';
|
||||
|
||||
export const DescriptionField = ({ description }: FieldProps) =>
|
||||
description && <MarkdownContent content={description} linkTarget="_blank" />;
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { DescriptionField } from './DescriptionField';
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getReviewData, getUiSchemasFromSteps } from './ReviewStep';
|
||||
|
||||
describe('MultistepJsonForm', () => {
|
||||
const formDataMock = {
|
||||
password: 'password',
|
||||
masked: 'Some info to mask',
|
||||
open: 'Some open info',
|
||||
hidden: 'Some info to hide',
|
||||
'other-open': 'Other open info',
|
||||
};
|
||||
|
||||
const stepsMock = [
|
||||
{
|
||||
title: 'The test template',
|
||||
schema: {
|
||||
title: 'The test template',
|
||||
properties: {
|
||||
password: {
|
||||
title: 'Password',
|
||||
type: 'string',
|
||||
'ui:widget': 'password',
|
||||
},
|
||||
masked: {
|
||||
title: 'Masked',
|
||||
type: 'string',
|
||||
'ui:backstage': {
|
||||
review: {
|
||||
mask: '******',
|
||||
},
|
||||
},
|
||||
},
|
||||
open: {
|
||||
title: 'Open info',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Other fields',
|
||||
schema: {
|
||||
title: 'Other fields',
|
||||
properties: {
|
||||
hidden: {
|
||||
title: 'Hidden',
|
||||
type: 'string',
|
||||
'ui:backstage': {
|
||||
review: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
'other-open': {
|
||||
title: 'Other Open Info',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('Fields are defined to be hidden or masked', () => {
|
||||
const schemas = getUiSchemasFromSteps(stepsMock);
|
||||
const reviewData = getReviewData(formDataMock, schemas);
|
||||
|
||||
expect(reviewData.password).toBe('******');
|
||||
expect(reviewData.masked).toBe('******');
|
||||
expect(reviewData.open).toBe('Some open info');
|
||||
expect(reviewData.hidden).toBeUndefined();
|
||||
expect(reviewData['other-open']).toBe('Other open info');
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
Button,
|
||||
Step as StepUI,
|
||||
StepContent,
|
||||
StepLabel,
|
||||
Stepper,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import {
|
||||
errorApiRef,
|
||||
featureFlagsApiRef,
|
||||
useAnalytics,
|
||||
useRouteRefParams,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { FormProps, IChangeEvent, withTheme } from '@rjsf/core';
|
||||
import { Theme as MuiTheme } from '@rjsf/material-ui';
|
||||
import React, { ComponentType, useState } from 'react';
|
||||
import { transformSchemaToProps } from './schema';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import * as fieldOverrides from './FieldOverrides';
|
||||
import { ReviewStep } from './ReviewStep';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { extractSchemaFromStep } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { selectedTemplateRouteRef } from '../../routes';
|
||||
import {
|
||||
LayoutOptions,
|
||||
ReviewStepProps,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
const Form = withTheme(MuiTheme);
|
||||
|
||||
type Step = {
|
||||
schema: JsonObject;
|
||||
title: string;
|
||||
} & Partial<Omit<FormProps<any>, 'schema'>>;
|
||||
|
||||
/**
|
||||
* The props for a dynamic form of a scaffolder template.
|
||||
*/
|
||||
export type MultistepJsonFormProps = {
|
||||
/**
|
||||
* Steps for the form, each contains title and form schema
|
||||
*/
|
||||
steps: Step[];
|
||||
formData: Record<string, any>;
|
||||
onChange: (e: IChangeEvent) => void;
|
||||
onReset: () => void;
|
||||
onFinish?: () => Promise<void>;
|
||||
widgets?: FormProps<any>['widgets'];
|
||||
fields?: FormProps<any>['fields'];
|
||||
finishButtonLabel?: string;
|
||||
layouts: LayoutOptions[];
|
||||
ReviewStepComponent?: ComponentType<ReviewStepProps>;
|
||||
};
|
||||
|
||||
export function getSchemasFromSteps(steps: Step[]) {
|
||||
return steps.map(({ schema }) => ({
|
||||
mergedSchema: schema,
|
||||
...extractSchemaFromStep(schema),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the dynamic form for a scaffolder template.
|
||||
*/
|
||||
export const MultistepJsonForm = (props: MultistepJsonFormProps) => {
|
||||
const {
|
||||
formData,
|
||||
onChange,
|
||||
onReset,
|
||||
onFinish,
|
||||
fields,
|
||||
widgets,
|
||||
layouts,
|
||||
ReviewStepComponent,
|
||||
} = props;
|
||||
const { templateName } = useRouteRefParams(selectedTemplateRouteRef);
|
||||
const analytics = useAnalytics();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [disableButtons, setDisableButtons] = useState(false);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const featureFlagApi = useApi(featureFlagsApiRef);
|
||||
const featureFlagKey = 'backstage:featureFlag';
|
||||
const filterOutProperties = (step: Step): Step => {
|
||||
const filteredStep = cloneDeep(step);
|
||||
const removedPropertyKeys: Array<string> = [];
|
||||
if (filteredStep.schema.properties) {
|
||||
filteredStep.schema.properties = Object.fromEntries(
|
||||
Object.entries(filteredStep.schema.properties).filter(
|
||||
([key, value]) => {
|
||||
if (value[featureFlagKey]) {
|
||||
if (featureFlagApi.isActive(value[featureFlagKey])) {
|
||||
return true;
|
||||
}
|
||||
removedPropertyKeys.push(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// remove the feature flag property key from required if they are not active
|
||||
filteredStep.schema.required = Array.isArray(filteredStep.schema.required)
|
||||
? filteredStep.schema.required?.filter(
|
||||
r => !removedPropertyKeys.includes(r as string),
|
||||
)
|
||||
: filteredStep.schema.required;
|
||||
}
|
||||
return filteredStep;
|
||||
};
|
||||
|
||||
const steps = props.steps
|
||||
.filter(step => {
|
||||
const featureFlag = step.schema[featureFlagKey];
|
||||
return (
|
||||
typeof featureFlag !== 'string' || featureFlagApi.isActive(featureFlag)
|
||||
);
|
||||
})
|
||||
.map(filterOutProperties);
|
||||
|
||||
const handleReset = () => {
|
||||
setActiveStep(0);
|
||||
onReset();
|
||||
};
|
||||
const handleNext = () => {
|
||||
const stepNum = Math.min(activeStep + 1, steps.length);
|
||||
setActiveStep(stepNum);
|
||||
analytics.captureEvent('click', `Next Step (${stepNum})`);
|
||||
};
|
||||
const handleBack = () => setActiveStep(Math.max(activeStep - 1, 0));
|
||||
const handleCreate = async () => {
|
||||
if (!onFinish) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisableButtons(true);
|
||||
try {
|
||||
await onFinish();
|
||||
analytics.captureEvent('create', formData.name || `new ${templateName}`);
|
||||
} catch (err) {
|
||||
errorApi.post(err);
|
||||
} finally {
|
||||
setDisableButtons(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ReviewStepElement = ReviewStepComponent ?? ReviewStep;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stepper activeStep={activeStep} orientation="vertical">
|
||||
{steps.map(({ title, schema, ...formProps }, index) => {
|
||||
return (
|
||||
<StepUI key={title}>
|
||||
<StepLabel
|
||||
aria-label={`Step ${index + 1} ${title}`}
|
||||
aria-disabled="false"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Typography variant="h6" component="h2">
|
||||
{title}
|
||||
</Typography>
|
||||
</StepLabel>
|
||||
<StepContent key={title}>
|
||||
<Form
|
||||
validator={validator}
|
||||
showErrorList={false}
|
||||
fields={{ ...fieldOverrides, ...fields }}
|
||||
widgets={widgets}
|
||||
noHtml5Validate
|
||||
formData={formData}
|
||||
formContext={{ formData }}
|
||||
onChange={onChange}
|
||||
onSubmit={(e: IChangeEvent<any>) => {
|
||||
if (e.errors.length === 0) handleNext();
|
||||
}}
|
||||
experimental_defaultFormStateBehavior={{
|
||||
allOf: 'populateDefaults',
|
||||
}}
|
||||
{...formProps}
|
||||
{...transformSchemaToProps(schema, layouts)}
|
||||
>
|
||||
<Button disabled={activeStep === 0} onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" color="primary" type="submit">
|
||||
Next step
|
||||
</Button>
|
||||
</Form>
|
||||
</StepContent>
|
||||
</StepUI>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
{activeStep === steps.length && (
|
||||
<ReviewStepElement
|
||||
disableButtons={disableButtons}
|
||||
handleBack={handleBack}
|
||||
handleCreate={handleCreate}
|
||||
handleReset={handleReset}
|
||||
formData={formData}
|
||||
steps={getSchemasFromSteps(steps)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Box, Button, Paper, Typography } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { Content, StructuredMetadataTable } from '@backstage/core-components';
|
||||
import { UiSchema } from '@rjsf/utils';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
export function getReviewData(
|
||||
formData: Record<string, any>,
|
||||
uiSchemas: UiSchema[],
|
||||
) {
|
||||
const reviewData: Record<string, any> = {};
|
||||
const orderedReviewProperties = new Set(
|
||||
uiSchemas.map(us => us.name).concat(Object.getOwnPropertyNames(formData)),
|
||||
);
|
||||
|
||||
for (const key of orderedReviewProperties) {
|
||||
const uiSchema = uiSchemas.find(us => us.name === key);
|
||||
|
||||
if (!uiSchema) {
|
||||
reviewData[key] = formData[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (uiSchema['ui:widget'] === 'password') {
|
||||
reviewData[key] = '******';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!uiSchema['ui:backstage'] || !uiSchema['ui:backstage'].review) {
|
||||
reviewData[key] = formData[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
const review = uiSchema['ui:backstage'].review as JsonObject;
|
||||
if (review.mask) {
|
||||
reviewData[key] = review.mask;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!review.show) {
|
||||
continue;
|
||||
}
|
||||
reviewData[key] = formData[key];
|
||||
}
|
||||
|
||||
return reviewData;
|
||||
}
|
||||
|
||||
export function getUiSchemasFromSteps(
|
||||
steps: {
|
||||
schema: JsonObject;
|
||||
}[],
|
||||
): UiSchema[] {
|
||||
const uiSchemas: Array<UiSchema> = [];
|
||||
steps.forEach(step => {
|
||||
const schemaProps = step.schema.properties as JsonObject;
|
||||
for (const key in schemaProps) {
|
||||
if (schemaProps.hasOwnProperty(key)) {
|
||||
const uiSchema = schemaProps[key] as UiSchema;
|
||||
uiSchema.name = key;
|
||||
uiSchemas.push(uiSchema);
|
||||
}
|
||||
}
|
||||
});
|
||||
return uiSchemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* The component displaying the Last Step in scaffolder template form.
|
||||
* Which represents the summary of the input provided by the end user.
|
||||
*/
|
||||
export const ReviewStep = (props: ReviewStepProps) => {
|
||||
const {
|
||||
disableButtons,
|
||||
formData,
|
||||
handleBack,
|
||||
handleCreate,
|
||||
handleReset,
|
||||
steps,
|
||||
} = props;
|
||||
return (
|
||||
<Content>
|
||||
<Paper square elevation={0}>
|
||||
<Typography variant="h6">Review and create</Typography>
|
||||
<StructuredMetadataTable
|
||||
dense
|
||||
metadata={getReviewData(
|
||||
formData,
|
||||
getUiSchemasFromSteps(
|
||||
steps.map(({ mergedSchema }) => ({ schema: mergedSchema })),
|
||||
),
|
||||
)}
|
||||
/>
|
||||
<Box mb={4} />
|
||||
<Button onClick={handleBack} disabled={disableButtons}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleReset} disabled={disableButtons}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={disableButtons}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Paper>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { MultistepJsonForm } from './MultistepJsonForm';
|
||||
@@ -1,535 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { transformSchemaToProps } from './schema';
|
||||
|
||||
describe('transformSchemaToProps', () => {
|
||||
it('transforms deep schema', () => {
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
'ui:welp': 'warp',
|
||||
properties: {
|
||||
field1: {
|
||||
type: 'string',
|
||||
'ui:derp': 'herp',
|
||||
},
|
||||
field2: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fieldX: {
|
||||
type: 'string',
|
||||
'ui:derp': 'xerp',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field1: {
|
||||
type: 'string',
|
||||
},
|
||||
field2: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
fieldX: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedUiSchema = {
|
||||
'ui:welp': 'warp',
|
||||
field1: {
|
||||
'ui:derp': 'herp',
|
||||
},
|
||||
field2: {
|
||||
fieldX: {
|
||||
'ui:derp': 'xerp',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformSchemaToProps(inputSchema)).toEqual({
|
||||
schema: expectedSchema,
|
||||
uiSchema: expectedUiSchema,
|
||||
});
|
||||
});
|
||||
|
||||
it('transforms schema with anyOf fields', () => {
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
anyOf: [
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 2',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
field4: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
allOf: [
|
||||
{
|
||||
properties: {
|
||||
field5: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
field1: {
|
||||
type: 'object',
|
||||
anyOf: [
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 2',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
field4: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
allOf: [
|
||||
{
|
||||
properties: {
|
||||
field5: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
field2: {
|
||||
type: 'string',
|
||||
'ui:derp': 'xerp',
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedSchema = {
|
||||
type: 'object',
|
||||
anyOf: [
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
field4: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
allOf: [
|
||||
{
|
||||
properties: {
|
||||
field5: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
field1: {
|
||||
type: 'object',
|
||||
anyOf: [
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
field3: {
|
||||
type: 'string',
|
||||
default: 'Value 2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
field4: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
allOf: [
|
||||
{
|
||||
properties: {
|
||||
field5: {
|
||||
type: 'string',
|
||||
default: 'Value 1',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
field2: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedUiSchema = {
|
||||
field3: {
|
||||
'ui:readonly': true,
|
||||
},
|
||||
field4: {
|
||||
'ui:readonly': true,
|
||||
},
|
||||
field5: {
|
||||
'ui:readonly': true,
|
||||
},
|
||||
field1: {
|
||||
field3: {
|
||||
'ui:readonly': true,
|
||||
},
|
||||
field4: {
|
||||
'ui:readonly': true,
|
||||
},
|
||||
field5: {
|
||||
'ui:readonly': true,
|
||||
},
|
||||
},
|
||||
field2: {
|
||||
'ui:derp': 'xerp',
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformSchemaToProps(inputSchema)).toEqual({
|
||||
schema: expectedSchema,
|
||||
uiSchema: expectedUiSchema,
|
||||
});
|
||||
});
|
||||
|
||||
it('transforms schema with dependencies', () => {
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
credit_card: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
dependencies: {
|
||||
credit_card: {
|
||||
properties: {
|
||||
billing_address: {
|
||||
type: 'string',
|
||||
'ui:widget': 'textarea',
|
||||
},
|
||||
},
|
||||
required: ['billing_address'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
credit_card: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
dependencies: {
|
||||
credit_card: {
|
||||
properties: {
|
||||
billing_address: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['billing_address'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedUiSchema = {
|
||||
billing_address: {
|
||||
'ui:widget': 'textarea',
|
||||
},
|
||||
credit_card: {},
|
||||
name: {},
|
||||
};
|
||||
|
||||
expect(transformSchemaToProps(inputSchema)).toEqual({
|
||||
schema: expectedSchema,
|
||||
uiSchema: expectedUiSchema,
|
||||
});
|
||||
});
|
||||
|
||||
it('transforms schema with complex dependencies', () => {
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
conditional: {
|
||||
title: 'Person',
|
||||
type: 'object',
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
type: 'string',
|
||||
enum: ['No', 'Yes: One', 'Yes: More than one'],
|
||||
default: 'No',
|
||||
'ui:widget': 'radio',
|
||||
},
|
||||
},
|
||||
required: ['Do you have any pets?'],
|
||||
dependencies: {
|
||||
'Do you have any pets?': {
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
enum: ['No'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
enum: ['Yes: One'],
|
||||
},
|
||||
'How old is your pet?': {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['How old is your pet?'],
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
enum: ['Yes: More than one'],
|
||||
},
|
||||
'Do you want to get rid of any?': {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['Do you want to get rid of any?'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
conditional: {
|
||||
title: 'Person',
|
||||
type: 'object',
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
type: 'string',
|
||||
enum: ['No', 'Yes: One', 'Yes: More than one'],
|
||||
default: 'No',
|
||||
},
|
||||
},
|
||||
required: ['Do you have any pets?'],
|
||||
dependencies: {
|
||||
'Do you have any pets?': {
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
enum: ['No'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
enum: ['Yes: One'],
|
||||
},
|
||||
'How old is your pet?': {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['How old is your pet?'],
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
'Do you have any pets?': {
|
||||
enum: ['Yes: More than one'],
|
||||
},
|
||||
'Do you want to get rid of any?': {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['Do you want to get rid of any?'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedUiSchema = {
|
||||
conditional: {
|
||||
'Do you have any pets?': {
|
||||
'ui:widget': 'radio',
|
||||
},
|
||||
'Do you want to get rid of any?': {},
|
||||
'How old is your pet?': {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformSchemaToProps(inputSchema)).toEqual({
|
||||
schema: expectedSchema,
|
||||
uiSchema: expectedUiSchema,
|
||||
});
|
||||
});
|
||||
|
||||
it('transforms schema with array items', () => {
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
person: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
address: {
|
||||
type: 'string',
|
||||
'ui:widget': 'textarea',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountNumber: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
person: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
address: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountNumber: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedUiSchema = {
|
||||
accountNumber: {},
|
||||
person: {
|
||||
items: {
|
||||
name: {},
|
||||
address: {
|
||||
'ui:widget': 'textarea',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(transformSchemaToProps(inputSchema)).toEqual({
|
||||
schema: expectedSchema,
|
||||
uiSchema: expectedUiSchema,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { UiSchema } from '@rjsf/utils';
|
||||
import type { LayoutOptions } from '@backstage/plugin-scaffolder-react';
|
||||
import { FormProps } from '@rjsf/core';
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) {
|
||||
if (!isObject(schema)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { properties, items, anyOf, oneOf, allOf, dependencies } = schema;
|
||||
|
||||
for (const propName in schema) {
|
||||
if (!schema.hasOwnProperty(propName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (propName.startsWith('ui:')) {
|
||||
uiSchema[propName] = schema[propName];
|
||||
delete schema[propName];
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(properties)) {
|
||||
for (const propName in properties) {
|
||||
if (!properties.hasOwnProperty(propName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const schemaNode = properties[propName];
|
||||
if (!isObject(schemaNode)) {
|
||||
continue;
|
||||
}
|
||||
const innerUiSchema = {};
|
||||
uiSchema[propName] = uiSchema[propName] || innerUiSchema;
|
||||
extractUiSchema(schemaNode, innerUiSchema);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(items)) {
|
||||
const innerUiSchema = {};
|
||||
uiSchema.items = innerUiSchema;
|
||||
extractUiSchema(items, innerUiSchema);
|
||||
}
|
||||
|
||||
if (Array.isArray(anyOf)) {
|
||||
for (const schemaNode of anyOf) {
|
||||
if (!isObject(schemaNode)) {
|
||||
continue;
|
||||
}
|
||||
extractUiSchema(schemaNode, uiSchema);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(oneOf)) {
|
||||
for (const schemaNode of oneOf) {
|
||||
if (!isObject(schemaNode)) {
|
||||
continue;
|
||||
}
|
||||
extractUiSchema(schemaNode, uiSchema);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(allOf)) {
|
||||
for (const schemaNode of allOf) {
|
||||
if (!isObject(schemaNode)) {
|
||||
continue;
|
||||
}
|
||||
extractUiSchema(schemaNode, uiSchema);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(dependencies)) {
|
||||
for (const depName of Object.keys(dependencies)) {
|
||||
const schemaNode = dependencies[depName];
|
||||
if (!isObject(schemaNode)) {
|
||||
continue;
|
||||
}
|
||||
extractUiSchema(schemaNode, uiSchema);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function transformSchemaToProps(
|
||||
inputSchema: JsonObject,
|
||||
layouts: LayoutOptions[] = [],
|
||||
): {
|
||||
schema: FormProps<any>['schema'];
|
||||
uiSchema: FormProps<any>['uiSchema'];
|
||||
} {
|
||||
const customLayoutName = inputSchema['ui:ObjectFieldTemplate'];
|
||||
inputSchema.type = inputSchema.type || 'object';
|
||||
const schema = JSON.parse(JSON.stringify(inputSchema));
|
||||
delete schema.title; // Rendered separately
|
||||
const uiSchema: UiSchema = {};
|
||||
extractUiSchema(schema, uiSchema);
|
||||
|
||||
if (customLayoutName) {
|
||||
const Layout = layouts.find(
|
||||
layout => layout.name === customLayoutName,
|
||||
)?.component;
|
||||
|
||||
if (Layout) {
|
||||
uiSchema['ui:ObjectFieldTemplate'] = Layout;
|
||||
}
|
||||
}
|
||||
|
||||
return { schema, uiSchema };
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType, useEffect, PropsWithChildren } from 'react';
|
||||
import { Navigate, Route, Routes, useOutlet } from 'react-router-dom';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { ScaffolderPage } from './ScaffolderPage';
|
||||
import { TemplatePage } from './TemplatePage';
|
||||
import { TaskPage } from './TaskPage';
|
||||
import { ActionsPage } from '../components/ActionsPage';
|
||||
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../extensions/default';
|
||||
import { useRouteRef, useRouteRefParams } from '@backstage/core-plugin-api';
|
||||
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import {
|
||||
ReviewStepProps,
|
||||
SecretsContextProvider,
|
||||
useCustomFieldExtensions,
|
||||
useCustomLayouts,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { ListTasksPage } from '../components/ListTasksPage';
|
||||
import {
|
||||
actionsRouteRef,
|
||||
editRouteRef,
|
||||
legacySelectedTemplateRouteRef,
|
||||
scaffolderListTaskRouteRef,
|
||||
scaffolderTaskRouteRef,
|
||||
selectedTemplateRouteRef,
|
||||
} from '../routes';
|
||||
import { TemplateEditorPage } from './TemplateEditorPage';
|
||||
|
||||
/**
|
||||
* The props for the entrypoint `ScaffolderPage` component the plugin.
|
||||
* @alpha
|
||||
*/
|
||||
export type LegacyRouterProps = {
|
||||
components?: {
|
||||
ReviewStepComponent?: ComponentType<ReviewStepProps>;
|
||||
TemplateCardComponent?:
|
||||
| ComponentType<{ template: TemplateEntityV1beta3 }>
|
||||
| undefined;
|
||||
TaskPageComponent?: ComponentType<PropsWithChildren<{}>>;
|
||||
};
|
||||
groups?: Array<{
|
||||
title?: React.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
}>;
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
defaultPreviewTemplate?: string;
|
||||
headerOptions?: {
|
||||
pageTitleOverride?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
/**
|
||||
* Options for the context menu on the scaffolder page.
|
||||
*/
|
||||
contextMenu?: {
|
||||
/** Whether to show a link to the template editor */
|
||||
editor?: boolean;
|
||||
/** Whether to show a link to the actions documentation */
|
||||
actions?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The legacy router
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const LegacyRouter = (props: LegacyRouterProps) => {
|
||||
const {
|
||||
groups,
|
||||
templateFilter,
|
||||
components = {},
|
||||
defaultPreviewTemplate,
|
||||
} = props;
|
||||
|
||||
const { ReviewStepComponent, TemplateCardComponent, TaskPageComponent } =
|
||||
components;
|
||||
|
||||
const outlet = useOutlet();
|
||||
const TaskPageElement = TaskPageComponent ?? TaskPage;
|
||||
|
||||
const customFieldExtensions =
|
||||
useCustomFieldExtensions<LegacyFieldExtensionOptions>(outlet);
|
||||
|
||||
const fieldExtensions = [
|
||||
...customFieldExtensions,
|
||||
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
|
||||
({ name }) =>
|
||||
!customFieldExtensions.some(
|
||||
customFieldExtension => customFieldExtension.name === name,
|
||||
),
|
||||
),
|
||||
] as LegacyFieldExtensionOptions[];
|
||||
|
||||
const customLayouts = useCustomLayouts(outlet);
|
||||
|
||||
/**
|
||||
* This component can be deleted once the older routes have been deprecated.
|
||||
*/
|
||||
const RedirectingComponent = () => {
|
||||
const { templateName } = useRouteRefParams(legacySelectedTemplateRouteRef);
|
||||
const newLink = useRouteRef(selectedTemplateRouteRef);
|
||||
useEffect(
|
||||
() =>
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'The route /template/:templateName is deprecated, please use the new /template/:namespace/:templateName route instead',
|
||||
),
|
||||
[],
|
||||
);
|
||||
return <Navigate to={newLink({ namespace: 'default', templateName })} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ScaffolderPage
|
||||
groups={groups}
|
||||
templateFilter={templateFilter}
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
contextMenu={props.contextMenu}
|
||||
headerOptions={props.headerOptions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={legacySelectedTemplateRouteRef.path}
|
||||
element={<RedirectingComponent />}
|
||||
/>
|
||||
<Route
|
||||
path={selectedTemplateRouteRef.path}
|
||||
element={
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage
|
||||
ReviewStepComponent={ReviewStepComponent}
|
||||
customFieldExtensions={fieldExtensions}
|
||||
layouts={customLayouts}
|
||||
headerOptions={props.headerOptions}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={scaffolderListTaskRouteRef.path}
|
||||
element={<ListTasksPage />}
|
||||
/>
|
||||
<Route path={scaffolderTaskRouteRef.path} element={<TaskPageElement />} />
|
||||
<Route path={actionsRouteRef.path} element={<ActionsPage />} />
|
||||
<Route
|
||||
path={editRouteRef.path}
|
||||
element={
|
||||
<SecretsContextProvider>
|
||||
<TemplateEditorPage
|
||||
defaultPreviewTemplate={defaultPreviewTemplate}
|
||||
customFieldExtensions={fieldExtensions}
|
||||
layouts={customLayouts}
|
||||
/>
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="preview" element={<Navigate to="../edit" />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
CreateButton,
|
||||
Header,
|
||||
Page,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
CatalogFilterLayout,
|
||||
EntityKindPicker,
|
||||
EntityListProvider,
|
||||
EntitySearchBar,
|
||||
EntityTagPicker,
|
||||
UserListPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { TemplateList } from '../TemplateList';
|
||||
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { ScaffolderPageContextMenu } from './ScaffolderPageContextMenu';
|
||||
import { registerComponentRouteRef } from '../../routes';
|
||||
import { TemplateTypePicker } from '../../components';
|
||||
|
||||
export type ScaffolderPageProps = {
|
||||
TemplateCardComponent?:
|
||||
| ComponentType<{ template: TemplateEntityV1beta3 }>
|
||||
| undefined;
|
||||
groups?: Array<{
|
||||
title?: React.ReactNode;
|
||||
filter: (entity: TemplateEntityV1beta3) => boolean;
|
||||
}>;
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
contextMenu?: {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
tasks?: boolean;
|
||||
};
|
||||
headerOptions?: {
|
||||
pageTitleOverride?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const ScaffolderPageContents = ({
|
||||
TemplateCardComponent,
|
||||
groups,
|
||||
templateFilter,
|
||||
contextMenu,
|
||||
headerOptions,
|
||||
}: ScaffolderPageProps) => {
|
||||
const registerComponentLink = useRouteRef(registerComponentRouteRef);
|
||||
const otherTemplatesGroup = {
|
||||
title: groups ? 'Other Templates' : 'Templates',
|
||||
filter: (entity: TemplateEntityV1beta3) => {
|
||||
const filtered = (groups ?? []).map(group => group.filter(entity));
|
||||
return !filtered.some(result => result === true);
|
||||
},
|
||||
};
|
||||
|
||||
const { allowed } = usePermission({
|
||||
permission: catalogEntityCreatePermission,
|
||||
});
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride="Create a New Component"
|
||||
title="Create a New Component"
|
||||
subtitle="Create new software components using standard templates"
|
||||
{...headerOptions}
|
||||
>
|
||||
<ScaffolderPageContextMenu {...contextMenu} />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Available Templates">
|
||||
{allowed && (
|
||||
<CreateButton
|
||||
title="Register Existing Component"
|
||||
to={registerComponentLink && registerComponentLink()}
|
||||
/>
|
||||
)}
|
||||
<SupportButton>
|
||||
Create new software components using standard templates. Different
|
||||
templates create different kinds of components (services, websites,
|
||||
documentation, ...).
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
|
||||
<CatalogFilterLayout>
|
||||
<CatalogFilterLayout.Filters>
|
||||
<EntitySearchBar />
|
||||
<EntityKindPicker initialFilter="template" hidden />
|
||||
<UserListPicker
|
||||
initialFilter="all"
|
||||
availableFilters={['all', 'starred']}
|
||||
/>
|
||||
<TemplateTypePicker />
|
||||
<EntityTagPicker />
|
||||
</CatalogFilterLayout.Filters>
|
||||
<CatalogFilterLayout.Content>
|
||||
{groups &&
|
||||
groups.map((group, index) => (
|
||||
<TemplateList
|
||||
key={index}
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
group={group}
|
||||
templateFilter={templateFilter}
|
||||
/>
|
||||
))}
|
||||
<TemplateList
|
||||
key="other"
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
templateFilter={templateFilter}
|
||||
group={otherTemplatesGroup}
|
||||
/>
|
||||
</CatalogFilterLayout.Content>
|
||||
</CatalogFilterLayout>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScaffolderPage = ({
|
||||
TemplateCardComponent,
|
||||
groups,
|
||||
templateFilter,
|
||||
contextMenu,
|
||||
headerOptions,
|
||||
}: ScaffolderPageProps) => (
|
||||
<EntityListProvider>
|
||||
<ScaffolderPageContents
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
groups={groups}
|
||||
templateFilter={templateFilter}
|
||||
contextMenu={contextMenu}
|
||||
headerOptions={headerOptions}
|
||||
/>
|
||||
</EntityListProvider>
|
||||
);
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ScaffolderPageContextMenu } from './ScaffolderPageContextMenu';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
|
||||
describe('ScaffolderPageContextMenu', () => {
|
||||
it('does not render anything if fully disabled', async () => {
|
||||
await renderInTestApp(
|
||||
<div data-testid="container">
|
||||
<ScaffolderPageContextMenu editor={false} actions={false} />
|
||||
</div>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('container')).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the editor option', async () => {
|
||||
await renderInTestApp(
|
||||
<div data-testid="container">
|
||||
<ScaffolderPageContextMenu actions={false} />
|
||||
</div>,
|
||||
{
|
||||
mountedRoutes: { '/': rootRouteRef },
|
||||
},
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('container').firstElementChild!);
|
||||
|
||||
expect(screen.getByText('Template Editor')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Installed Actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the actions option', async () => {
|
||||
await renderInTestApp(
|
||||
<div data-testid="container">
|
||||
<ScaffolderPageContextMenu actions editor={false} />
|
||||
</div>,
|
||||
{
|
||||
mountedRoutes: { '/': rootRouteRef },
|
||||
},
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('container').firstElementChild!);
|
||||
|
||||
expect(screen.queryByText('Template Editor')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Installed Actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all options', async () => {
|
||||
await renderInTestApp(
|
||||
<div data-testid="container">
|
||||
<ScaffolderPageContextMenu />
|
||||
</div>,
|
||||
{
|
||||
mountedRoutes: { '/': rootRouteRef },
|
||||
},
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('container').firstElementChild!);
|
||||
|
||||
expect(screen.getByText('Template Editor')).toBeInTheDocument();
|
||||
expect(screen.getByText('Installed Actions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import MenuList from '@material-ui/core/MenuList';
|
||||
import Popover from '@material-ui/core/Popover';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Description from '@material-ui/icons/Description';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import List from '@material-ui/icons/List';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
actionsRouteRef,
|
||||
editRouteRef,
|
||||
scaffolderListTaskRouteRef,
|
||||
} from '../../routes';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
button: {
|
||||
color: theme.page.fontColor,
|
||||
},
|
||||
}));
|
||||
|
||||
export type ScaffolderPageContextMenuProps = {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
tasks?: boolean;
|
||||
};
|
||||
|
||||
export function ScaffolderPageContextMenu(
|
||||
props: ScaffolderPageContextMenuProps,
|
||||
) {
|
||||
const classes = useStyles();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
const editLink = useRouteRef(editRouteRef);
|
||||
const actionsLink = useRouteRef(actionsRouteRef);
|
||||
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showEditor = props.editor !== false;
|
||||
const showActions = props.actions !== false;
|
||||
const showTasks = props.tasks !== false;
|
||||
|
||||
if (!showEditor && !showActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onOpen = (event: React.SyntheticEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setAnchorEl(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
id="long-menu"
|
||||
aria-label="more"
|
||||
aria-controls="long-menu"
|
||||
aria-expanded={!!anchorEl}
|
||||
aria-haspopup="true"
|
||||
role="button"
|
||||
onClick={onOpen}
|
||||
data-testid="menu-button"
|
||||
color="inherit"
|
||||
className={classes.button}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
<Popover
|
||||
aria-labelledby="long-menu"
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={onClose}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuList>
|
||||
{showEditor && (
|
||||
<MenuItem onClick={() => navigate(editLink())}>
|
||||
<ListItemIcon>
|
||||
<Edit fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Template Editor" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{showActions && (
|
||||
<MenuItem onClick={() => navigate(actionsLink())}>
|
||||
<ListItemIcon>
|
||||
<Description fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Installed Actions" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{showTasks && (
|
||||
<MenuItem onClick={() => navigate(tasksLink())}>
|
||||
<ListItemIcon>
|
||||
<List fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Task List" />
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { ScaffolderPage } from './ScaffolderPage';
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import CloudIcon from '@material-ui/icons/Cloud';
|
||||
import React from 'react';
|
||||
import { IconLink } from './IconLink';
|
||||
|
||||
describe('IconLink', () => {
|
||||
it('should render an icon link', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<IconLink href="https://example.com" text="I am Link" Icon={CloudIcon} />,
|
||||
);
|
||||
|
||||
expect(rendered.getByText('I am Link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Box } from '@material-ui/core';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DismissableBanner } from '@backstage/core-components';
|
||||
|
||||
type TaskErrorsProps = {
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export const TaskErrors = ({ error }: TaskErrorsProps) => {
|
||||
const id = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
id.current = String(Math.random());
|
||||
}, [error]);
|
||||
return error ? (
|
||||
<Box>
|
||||
<DismissableBanner
|
||||
id={id.current}
|
||||
variant="warning"
|
||||
message={error.message}
|
||||
/>
|
||||
</Box>
|
||||
) : null;
|
||||
};
|
||||
@@ -1,393 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
Content,
|
||||
ErrorPage,
|
||||
Header,
|
||||
LogViewer,
|
||||
Page,
|
||||
Progress,
|
||||
} from '@backstage/core-components';
|
||||
import {
|
||||
useApi,
|
||||
useRouteRef,
|
||||
useRouteRefParams,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
StepButton,
|
||||
StepIconProps,
|
||||
} from '@material-ui/core';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import Step from '@material-ui/core/Step';
|
||||
import StepLabel from '@material-ui/core/StepLabel';
|
||||
import Stepper from '@material-ui/core/Stepper';
|
||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Cancel from '@material-ui/icons/Cancel';
|
||||
import Check from '@material-ui/icons/Check';
|
||||
import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord';
|
||||
import classNames from 'classnames';
|
||||
import { DateTime, Interval } from 'luxon';
|
||||
import qs from 'qs';
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useInterval from 'react-use/lib/useInterval';
|
||||
import {
|
||||
ScaffolderTaskStatus,
|
||||
ScaffolderTaskOutput,
|
||||
useTaskEventStream,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { TaskErrors } from './TaskErrors';
|
||||
import { TaskPageLinks } from './TaskPageLinks';
|
||||
import {
|
||||
rootRouteRef,
|
||||
scaffolderTaskRouteRef,
|
||||
selectedTemplateRouteRef,
|
||||
} from '../../routes';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
width: '100%',
|
||||
},
|
||||
button: {
|
||||
marginBottom: theme.spacing(2),
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
actionsContainer: {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
resetContainer: {
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
labelWrapper: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
stepWrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
type TaskStep = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ScaffolderTaskStatus;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
};
|
||||
|
||||
const StepTimeTicker = ({ step }: { step: TaskStep }) => {
|
||||
const [time, setTime] = useState('');
|
||||
|
||||
useInterval(() => {
|
||||
if (!step.startedAt) {
|
||||
setTime('');
|
||||
return;
|
||||
}
|
||||
|
||||
const end = step.endedAt
|
||||
? DateTime.fromISO(step.endedAt)
|
||||
: DateTime.local();
|
||||
|
||||
const startedAt = DateTime.fromISO(step.startedAt);
|
||||
const formatted = Interval.fromDateTimes(startedAt, end)
|
||||
.toDuration()
|
||||
.valueOf();
|
||||
|
||||
setTime(humanizeDuration(formatted, { round: true }));
|
||||
}, 1000);
|
||||
|
||||
return <Typography variant="caption">{time}</Typography>;
|
||||
};
|
||||
|
||||
const useStepIconStyles = makeStyles(theme =>
|
||||
createStyles({
|
||||
root: {
|
||||
color: theme.palette.text.disabled,
|
||||
display: 'flex',
|
||||
height: 22,
|
||||
alignItems: 'center',
|
||||
},
|
||||
completed: {
|
||||
color: theme.palette.status.ok,
|
||||
},
|
||||
error: {
|
||||
color: theme.palette.status.error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
function TaskStepIconComponent(props: StepIconProps) {
|
||||
const classes = useStepIconStyles();
|
||||
const { active, completed, error } = props;
|
||||
|
||||
const getMiddle = () => {
|
||||
if (active) {
|
||||
return <CircularProgress size="24px" />;
|
||||
}
|
||||
if (completed) {
|
||||
return <Check />;
|
||||
}
|
||||
if (error) {
|
||||
return <Cancel />;
|
||||
}
|
||||
return <FiberManualRecordIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.root, {
|
||||
[classes.completed]: completed,
|
||||
[classes.error]: error,
|
||||
})}
|
||||
>
|
||||
{getMiddle()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TaskStatusStepper = memo(
|
||||
(props: {
|
||||
steps: TaskStep[];
|
||||
currentStepId: string | undefined;
|
||||
onUserStepChange: (id: string) => void;
|
||||
classes?: {
|
||||
root?: string;
|
||||
};
|
||||
}) => {
|
||||
const { steps, currentStepId, onUserStepChange } = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Stepper
|
||||
activeStep={steps.findIndex(s => s.id === currentStepId)}
|
||||
orientation="vertical"
|
||||
nonLinear
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isCancelled = step.status === 'cancelled';
|
||||
const isActive = step.status === 'processing';
|
||||
const isCompleted = step.status === 'completed';
|
||||
const isFailed = step.status === 'failed';
|
||||
const isSkipped = step.status === 'skipped';
|
||||
|
||||
return (
|
||||
<Step key={String(index)} expanded>
|
||||
<StepButton onClick={() => onUserStepChange(step.id)}>
|
||||
<StepLabel
|
||||
StepIconProps={{
|
||||
completed: isCompleted,
|
||||
error: isFailed || isCancelled,
|
||||
active: isActive,
|
||||
}}
|
||||
StepIconComponent={TaskStepIconComponent}
|
||||
className={classes.stepWrapper}
|
||||
>
|
||||
<div className={classes.labelWrapper}>
|
||||
<Typography variant="subtitle2">{step.name}</Typography>
|
||||
{isSkipped ? (
|
||||
<Typography variant="caption">Skipped</Typography>
|
||||
) : (
|
||||
<StepTimeTicker step={step} />
|
||||
)}
|
||||
</div>
|
||||
</StepLabel>
|
||||
</StepButton>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const hasLinks = ({ links = [] }: ScaffolderTaskOutput): boolean =>
|
||||
links.length > 0;
|
||||
|
||||
/**
|
||||
* TaskPageProps for constructing a TaskPage
|
||||
* @param loadingText - Optional loading text shown before a task begins executing.
|
||||
* @public
|
||||
* @deprecated - this is a useless type that is no longer used.
|
||||
*/
|
||||
export type TaskPageProps = {
|
||||
loadingText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* TaskPage for showing the status of the taskId provided as a param
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const TaskPage = (props: TaskPageProps) => {
|
||||
const { loadingText } = props;
|
||||
const classes = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const rootPath = useRouteRef(rootRouteRef);
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const templateRoute = useRouteRef(selectedTemplateRouteRef);
|
||||
const [userSelectedStepId, setUserSelectedStepId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [clickedToCancel, setClickedToCancel] = useState<boolean>(false);
|
||||
const [lastActiveStepId, setLastActiveStepId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const { taskId } = useRouteRefParams(scaffolderTaskRouteRef);
|
||||
const taskStream = useTaskEventStream(taskId);
|
||||
const completed = taskStream.completed;
|
||||
const taskCancelled = taskStream.cancelled;
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
taskStream.task?.spec.steps.map(step => ({
|
||||
...step,
|
||||
...taskStream?.steps?.[step.id],
|
||||
})) ?? [],
|
||||
[taskStream],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mostRecentFailedOrActiveStep = steps.find(step =>
|
||||
['failed', 'processing'].includes(step.status),
|
||||
);
|
||||
if (completed && !mostRecentFailedOrActiveStep) {
|
||||
setLastActiveStepId(steps[steps.length - 1]?.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setLastActiveStepId(mostRecentFailedOrActiveStep?.id);
|
||||
}, [steps, completed]);
|
||||
|
||||
const currentStepId = userSelectedStepId ?? lastActiveStepId;
|
||||
|
||||
const logAsString = useMemo(() => {
|
||||
if (!currentStepId) {
|
||||
return loadingText ? loadingText : 'Loading...';
|
||||
}
|
||||
const log = taskStream.stepLogs[currentStepId];
|
||||
|
||||
if (!log?.length) {
|
||||
return 'Waiting for logs...';
|
||||
}
|
||||
return log.join('\n');
|
||||
}, [taskStream.stepLogs, currentStepId, loadingText]);
|
||||
|
||||
const taskNotFound =
|
||||
taskStream.completed && !taskStream.loading && !taskStream.task;
|
||||
|
||||
const { output } = taskStream;
|
||||
|
||||
const handleStartOver = () => {
|
||||
if (!taskStream.task || !taskStream.task?.spec.templateInfo?.entityRef) {
|
||||
navigate(rootPath());
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = taskStream.task!.spec.parameters;
|
||||
|
||||
const { name, namespace } = parseEntityRef(
|
||||
taskStream.task!.spec.templateInfo?.entityRef,
|
||||
);
|
||||
|
||||
navigate(
|
||||
`${templateRoute({ templateName: name, namespace })}?${qs.stringify({
|
||||
formData: JSON.stringify(formData),
|
||||
})}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setClickedToCancel(true);
|
||||
await scaffolderApi.cancelTask(taskId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride={`Task ${taskId}`}
|
||||
title="Task Activity"
|
||||
subtitle={`Activity for task: ${taskId}`}
|
||||
/>
|
||||
<Content>
|
||||
{taskNotFound ? (
|
||||
<ErrorPage
|
||||
status="404"
|
||||
statusMessage="Task not found"
|
||||
additionalInfo="No task found with this ID"
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Grid container>
|
||||
<Grid item xs={3}>
|
||||
<Paper>
|
||||
<TaskStatusStepper
|
||||
steps={steps}
|
||||
currentStepId={currentStepId}
|
||||
onUserStepChange={setUserSelectedStepId}
|
||||
/>
|
||||
{output && hasLinks(output) && (
|
||||
<TaskPageLinks output={output} />
|
||||
)}
|
||||
<Button
|
||||
className={classes.button}
|
||||
onClick={handleStartOver}
|
||||
disabled={!completed}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
Start Over
|
||||
</Button>
|
||||
<Button
|
||||
className={classes.button}
|
||||
onClick={handleCancel}
|
||||
disabled={completed || taskCancelled || clickedToCancel}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
>
|
||||
{(taskCancelled || clickedToCancel) && !completed
|
||||
? 'Cancelling...'
|
||||
: 'Cancel'}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={9}>
|
||||
{!currentStepId && <Progress />}
|
||||
|
||||
<div style={{ height: '80vh' }}>
|
||||
<TaskErrors error={taskStream.error} />
|
||||
<LogViewer text={logAsString} />
|
||||
</div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { TaskPageLinks } from './TaskPageLinks';
|
||||
|
||||
describe('TaskPageLinks', () => {
|
||||
beforeEach(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('renders further links', async () => {
|
||||
const output = {
|
||||
links: [
|
||||
{ url: 'https://first.url', title: 'Cool link 1' },
|
||||
{ url: 'https://second.url', title: 'Cool link 2' },
|
||||
{ entityRef: 'Component:default/my-app', title: 'Open in catalog' },
|
||||
{ title: 'Skipped' },
|
||||
],
|
||||
};
|
||||
const { findByText, queryByText } = await renderInTestApp(
|
||||
<TaskPageLinks output={output} />,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let element = await findByText('Cool link 1');
|
||||
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element).toHaveAttribute('href', 'https://first.url');
|
||||
|
||||
element = await findByText('Cool link 2');
|
||||
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element).toHaveAttribute('href', 'https://second.url');
|
||||
|
||||
element = await findByText('Open in catalog');
|
||||
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/Component/my-app',
|
||||
);
|
||||
|
||||
expect(queryByText('Skipped')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TaskPage } from './TaskPage';
|
||||
export type { TaskPageProps } from './TaskPage';
|
||||
@@ -1,316 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
Entity,
|
||||
EntityLink,
|
||||
parseEntityRef,
|
||||
RELATION_OWNED_BY,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
ItemCardHeader,
|
||||
Link,
|
||||
LinkButton,
|
||||
MarkdownContent,
|
||||
} from '@backstage/core-components';
|
||||
import {
|
||||
IconComponent,
|
||||
useApi,
|
||||
useApp,
|
||||
useRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
ScmIntegrationIcon,
|
||||
scmIntegrationsApiRef,
|
||||
} from '@backstage/integration-react';
|
||||
import {
|
||||
EntityRefLinks,
|
||||
FavoriteEntity,
|
||||
getEntityRelations,
|
||||
getEntitySourceLocation,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@material-ui/core';
|
||||
import LanguageIcon from '@material-ui/icons/Language';
|
||||
import WarningIcon from '@material-ui/icons/Warning';
|
||||
import React from 'react';
|
||||
import { selectedTemplateRouteRef, viewTechDocRouteRef } from '../../routes';
|
||||
|
||||
const useStyles = makeStyles<
|
||||
Theme,
|
||||
{ fontColor: string; backgroundImage: string }
|
||||
>(theme => ({
|
||||
cardHeader: {
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
backgroundImage: ({ backgroundImage }) => backgroundImage,
|
||||
color: ({ fontColor }) => fontColor,
|
||||
},
|
||||
box: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
'-webkit-line-clamp': 10,
|
||||
'-webkit-box-orient': 'vertical',
|
||||
},
|
||||
label: {
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 0.5,
|
||||
lineHeight: 1,
|
||||
paddingBottom: '0.2rem',
|
||||
},
|
||||
linksLabel: {
|
||||
padding: '0 16px',
|
||||
},
|
||||
description: {
|
||||
'& p': {
|
||||
margin: '0px',
|
||||
},
|
||||
},
|
||||
leftButton: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
starButton: {
|
||||
position: 'absolute',
|
||||
top: theme.spacing(0.5),
|
||||
right: theme.spacing(0.5),
|
||||
padding: '0.25rem',
|
||||
color: ({ fontColor }) => fontColor,
|
||||
},
|
||||
}));
|
||||
|
||||
const MuiIcon = ({ icon: Icon }: { icon: IconComponent }) => <Icon />;
|
||||
|
||||
const useDeprecationStyles = makeStyles(theme => ({
|
||||
deprecationIcon: {
|
||||
position: 'absolute',
|
||||
top: theme.spacing(0.5),
|
||||
right: theme.spacing(3.5),
|
||||
padding: '0.25rem',
|
||||
},
|
||||
link: {
|
||||
color: theme.palette.warning.light,
|
||||
},
|
||||
}));
|
||||
|
||||
export type TemplateCardProps = {
|
||||
template: TemplateEntityV1beta3;
|
||||
deprecated?: boolean;
|
||||
};
|
||||
|
||||
type TemplateProps = {
|
||||
description: string;
|
||||
tags: string[];
|
||||
title: string;
|
||||
type: string;
|
||||
name: string;
|
||||
links: EntityLink[];
|
||||
};
|
||||
|
||||
const getTemplateCardProps = (
|
||||
template: TemplateEntityV1beta3,
|
||||
): TemplateProps & { 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[]) ?? [],
|
||||
links: template.metadata.links ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
const DeprecationWarning = () => {
|
||||
const styles = useDeprecationStyles();
|
||||
|
||||
const Title = (
|
||||
<Typography style={{ padding: 10, maxWidth: 300 }}>
|
||||
This template uses a syntax that has been deprecated, and should be
|
||||
migrated to a newer syntax. Click for more info.
|
||||
</Typography>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.deprecationIcon}>
|
||||
<Tooltip title={Title}>
|
||||
<Link
|
||||
to="https://backstage.io/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3"
|
||||
className={styles.link}
|
||||
>
|
||||
<WarningIcon />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TemplateCard = ({ template, deprecated }: TemplateCardProps) => {
|
||||
const app = useApp();
|
||||
const backstageTheme = useTheme();
|
||||
const templateRoute = useRouteRef(selectedTemplateRouteRef);
|
||||
const templateProps = getTemplateCardProps(template);
|
||||
const ownedByRelations = getEntityRelations(
|
||||
template as Entity,
|
||||
RELATION_OWNED_BY,
|
||||
);
|
||||
const themeId = backstageTheme.getPageTheme({ themeId: templateProps.type })
|
||||
? templateProps.type
|
||||
: 'other';
|
||||
const theme = backstageTheme.getPageTheme({ themeId });
|
||||
const classes = useStyles({
|
||||
fontColor: theme.fontColor,
|
||||
backgroundImage: theme.backgroundImage,
|
||||
});
|
||||
const { name, namespace } = parseEntityRef(stringifyEntityRef(template));
|
||||
const href = templateRoute({ templateName: name, namespace });
|
||||
|
||||
// TechDocs Link
|
||||
const viewTechDoc = useRouteRef(viewTechDocRouteRef);
|
||||
const viewTechDocsAnnotation =
|
||||
template.metadata.annotations?.['backstage.io/techdocs-ref'];
|
||||
const viewTechDocsLink =
|
||||
!!viewTechDocsAnnotation &&
|
||||
!!viewTechDoc &&
|
||||
viewTechDoc({
|
||||
namespace: template.metadata.namespace || DEFAULT_NAMESPACE,
|
||||
kind: template.kind,
|
||||
name: template.metadata.name,
|
||||
});
|
||||
|
||||
const iconResolver = (key?: string): IconComponent =>
|
||||
key ? app.getSystemIcon(key) ?? LanguageIcon : LanguageIcon;
|
||||
|
||||
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
|
||||
const sourceLocation = getEntitySourceLocation(template, scmIntegrationsApi);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardMedia className={classes.cardHeader}>
|
||||
<FavoriteEntity className={classes.starButton} entity={template} />
|
||||
{deprecated && <DeprecationWarning />}
|
||||
<ItemCardHeader
|
||||
title={templateProps.title}
|
||||
subtitle={templateProps.type}
|
||||
classes={{ root: classes.title }}
|
||||
/>
|
||||
</CardMedia>
|
||||
<CardContent
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}
|
||||
>
|
||||
<Box className={classes.box}>
|
||||
<Typography variant="body2" className={classes.label}>
|
||||
Description
|
||||
</Typography>
|
||||
<MarkdownContent
|
||||
className={classes.description}
|
||||
content={templateProps.description}
|
||||
/>
|
||||
</Box>
|
||||
<Box className={classes.box}>
|
||||
<Typography variant="body2" className={classes.label}>
|
||||
Owner
|
||||
</Typography>
|
||||
<EntityRefLinks
|
||||
entityRefs={ownedByRelations}
|
||||
defaultKind="Group"
|
||||
hideIcons
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
style={{ marginBottom: '4px' }}
|
||||
variant="body2"
|
||||
className={classes.label}
|
||||
>
|
||||
Tags
|
||||
</Typography>
|
||||
{templateProps.tags?.map(tag => (
|
||||
<Chip size="small" label={tag} key={tag} />
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
<Typography
|
||||
variant="body2"
|
||||
className={[classes.label, classes.linksLabel].join(' ')}
|
||||
>
|
||||
Links
|
||||
</Typography>
|
||||
<CardActions>
|
||||
<div className={classes.leftButton}>
|
||||
{sourceLocation && (
|
||||
<Tooltip
|
||||
title={
|
||||
sourceLocation.integrationType ||
|
||||
sourceLocation.locationTargetUrl
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
className={classes.leftButton}
|
||||
href={sourceLocation.locationTargetUrl}
|
||||
>
|
||||
<ScmIntegrationIcon type={sourceLocation.integrationType} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{viewTechDocsLink && (
|
||||
<Tooltip title="View TechDocs">
|
||||
<IconButton
|
||||
className={classes.leftButton}
|
||||
href={viewTechDocsLink}
|
||||
>
|
||||
<MuiIcon icon={iconResolver('docs')} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{templateProps.links?.map((link, i) => (
|
||||
<Tooltip key={`${link.url}_${i}`} title={link.title || link.url}>
|
||||
<IconButton size="medium" href={link.url}>
|
||||
<MuiIcon icon={iconResolver(link.icon)} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<LinkButton
|
||||
color="primary"
|
||||
to={href}
|
||||
aria-label={`Choose ${templateProps.title}`}
|
||||
>
|
||||
Choose
|
||||
</LinkButton>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplateCard } from './TemplateCard';
|
||||
export type { TemplateCardProps } from './TemplateCard';
|
||||
@@ -1,205 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { yaml as yamlSupport } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import yaml from 'yaml';
|
||||
import { Form } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { TemplateEditorForm } from './TemplateEditorForm';
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
gridArea: 'pageContent',
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"controls controls"
|
||||
"fieldForm preview"
|
||||
`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
},
|
||||
controls: {
|
||||
gridArea: 'controls',
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
fieldForm: {
|
||||
gridArea: 'fieldForm',
|
||||
},
|
||||
preview: {
|
||||
gridArea: 'preview',
|
||||
},
|
||||
}));
|
||||
|
||||
export const CustomFieldExplorer = ({
|
||||
customFieldExtensions = [],
|
||||
onClose,
|
||||
}: {
|
||||
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const fieldOptions = customFieldExtensions.filter(field => !!field.schema);
|
||||
const [selectedField, setSelectedField] = useState(fieldOptions[0]);
|
||||
const [fieldFormState, setFieldFormState] = useState({});
|
||||
const [refreshKey, setRefreshKey] = useState(Date.now());
|
||||
const [formState, setFormState] = useState({});
|
||||
const sampleFieldTemplate = useMemo(
|
||||
() =>
|
||||
yaml.stringify({
|
||||
parameters: [
|
||||
{
|
||||
title: `${selectedField.name} Example`,
|
||||
properties: {
|
||||
[selectedField.name]: {
|
||||
type: selectedField.schema?.returnValue?.type,
|
||||
'ui:field': selectedField.name,
|
||||
'ui:options': fieldFormState,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[fieldFormState, selectedField],
|
||||
);
|
||||
|
||||
const fieldComponents = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
customFieldExtensions.map(({ name, component }) => [name, component]),
|
||||
);
|
||||
}, [customFieldExtensions]);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(selection: LegacyFieldExtensionOptions) => {
|
||||
setSelectedField(selection);
|
||||
setFieldFormState({});
|
||||
setFormState({});
|
||||
},
|
||||
[setFieldFormState, setSelectedField, setFormState],
|
||||
);
|
||||
|
||||
const handleFieldConfigChange = useCallback(
|
||||
(state: {}) => {
|
||||
setFieldFormState(state);
|
||||
// Force TemplateEditorForm to re-render since some fields
|
||||
// may not be responsive to ui:option changes
|
||||
setRefreshKey(Date.now());
|
||||
},
|
||||
[setFieldFormState, setRefreshKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className={classes.root}>
|
||||
<div className={classes.controls}>
|
||||
<FormControl variant="outlined" size="small" fullWidth>
|
||||
<InputLabel id="select-field-label">
|
||||
Choose Custom Field Extension
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedField}
|
||||
label="Choose Custom Field Extension"
|
||||
labelId="select-field-label"
|
||||
onChange={e =>
|
||||
handleSelectionChange(
|
||||
e.target.value as LegacyFieldExtensionOptions,
|
||||
)
|
||||
}
|
||||
>
|
||||
{fieldOptions.map((option, idx) => (
|
||||
<MenuItem key={idx} value={option as any}>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton size="medium" onClick={onClose} aria-label="Close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={classes.fieldForm}>
|
||||
<Card>
|
||||
<CardHeader title="Field Options" />
|
||||
<CardContent>
|
||||
<Form
|
||||
showErrorList={false}
|
||||
fields={{ ...fieldComponents }}
|
||||
noHtml5Validate
|
||||
formData={fieldFormState}
|
||||
formContext={{ fieldFormState }}
|
||||
onSubmit={e => handleFieldConfigChange(e.formData)}
|
||||
validator={validator}
|
||||
schema={selectedField.schema?.uiOptions || {}}
|
||||
experimental_defaultFormStateBehavior={{
|
||||
allOf: 'populateDefaults',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={!selectedField.schema?.uiOptions}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className={classes.preview}>
|
||||
<Card>
|
||||
<CardHeader title="Example Template Spec" />
|
||||
<CardContent>
|
||||
<CodeMirror
|
||||
readOnly
|
||||
theme="dark"
|
||||
height="100%"
|
||||
extensions={[StreamLanguage.define(yamlSupport)]}
|
||||
value={sampleFieldTemplate}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TemplateEditorForm
|
||||
data={formState}
|
||||
onUpdate={setFormState}
|
||||
key={refreshKey}
|
||||
content={sampleFieldTemplate}
|
||||
contentIsSpec
|
||||
fieldExtensions={customFieldExtensions}
|
||||
setErrorText={() => null}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { TemplateDirectoryAccess } from '../../lib/filesystem';
|
||||
import { LayoutOptions } from '@backstage/plugin-scaffolder-react';
|
||||
import { DirectoryEditorProvider } from '../../next/TemplateEditorPage/DirectoryEditorContext';
|
||||
import { DryRunProvider } from '../../next/TemplateEditorPage/DryRunContext';
|
||||
import { TemplateEditorBrowser } from '../../next/TemplateEditorPage/TemplateEditorBrowser';
|
||||
import { TemplateEditorTextArea } from '../../next/TemplateEditorPage/TemplateEditorTextArea';
|
||||
import { TemplateEditorForm } from './TemplateEditorForm';
|
||||
import { DryRunResults } from '../../next/TemplateEditorPage/DryRunResults';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
// Reset and fix sizing to make sure scrolling behaves correctly
|
||||
root: {
|
||||
gridArea: 'pageContent',
|
||||
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"browser editor preview"
|
||||
"results results results"
|
||||
`,
|
||||
gridTemplateColumns: '1fr 3fr 2fr',
|
||||
gridTemplateRows: '1fr auto',
|
||||
},
|
||||
browser: {
|
||||
gridArea: 'browser',
|
||||
overflow: 'auto',
|
||||
},
|
||||
editor: {
|
||||
gridArea: 'editor',
|
||||
overflow: 'auto',
|
||||
},
|
||||
preview: {
|
||||
gridArea: 'preview',
|
||||
overflow: 'auto',
|
||||
},
|
||||
results: {
|
||||
gridArea: 'results',
|
||||
},
|
||||
});
|
||||
|
||||
export const TemplateEditor = (props: {
|
||||
directory: TemplateDirectoryAccess;
|
||||
fieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
return (
|
||||
<DirectoryEditorProvider directory={props.directory}>
|
||||
<DryRunProvider>
|
||||
<main className={classes.root}>
|
||||
<section className={classes.browser}>
|
||||
<TemplateEditorBrowser onClose={props.onClose} />
|
||||
</section>
|
||||
<section className={classes.editor}>
|
||||
<TemplateEditorTextArea.DirectoryEditor errorText={errorText} />
|
||||
</section>
|
||||
<section className={classes.preview}>
|
||||
<TemplateEditorForm.DirectoryEditorDryRun
|
||||
setErrorText={setErrorText}
|
||||
fieldExtensions={props.fieldExtensions}
|
||||
layouts={props.layouts}
|
||||
/>
|
||||
</section>
|
||||
<section className={classes.results}>
|
||||
<DryRunResults />
|
||||
</section>
|
||||
</main>
|
||||
</DryRunProvider>
|
||||
</DirectoryEditorProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,261 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useApiHolder } from '@backstage/core-plugin-api';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React, { Component, ReactNode, useMemo, useState } from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import yaml from 'yaml';
|
||||
import {
|
||||
LayoutOptions,
|
||||
TemplateParameterSchema,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
import { useDryRun } from '../../next/TemplateEditorPage/DryRunContext';
|
||||
import { useDirectoryEditor } from '../../next/TemplateEditorPage/DirectoryEditorContext';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { createValidator } from '../TemplatePage';
|
||||
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
containerWrapper: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
overflow: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
invalidator: unknown;
|
||||
setErrorText(errorText: string | undefined): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
shouldRender: boolean;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state = {
|
||||
shouldRender: true,
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: { invalidator: unknown }) {
|
||||
if (prevProps.invalidator !== this.props.invalidator) {
|
||||
this.setState({ shouldRender: true });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.props.setErrorText(error.message);
|
||||
this.setState({ shouldRender: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.shouldRender ? this.props.children : null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TemplateEditorFormProps {
|
||||
content?: string;
|
||||
/** Setting this to true will cause the content to be parsed as if it is the template entity spec */
|
||||
contentIsSpec?: boolean;
|
||||
data: JsonObject;
|
||||
onUpdate: (data: JsonObject) => void;
|
||||
setErrorText: (errorText?: string) => void;
|
||||
|
||||
onDryRun?: (data: JsonObject) => Promise<void>;
|
||||
fieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
}
|
||||
|
||||
function isJsonObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Shows the a template form that is parsed from the provided content */
|
||||
export function TemplateEditorForm(props: TemplateEditorFormProps) {
|
||||
const {
|
||||
content,
|
||||
contentIsSpec,
|
||||
data,
|
||||
onUpdate,
|
||||
onDryRun,
|
||||
setErrorText,
|
||||
fieldExtensions = [],
|
||||
layouts = [],
|
||||
} = props;
|
||||
const classes = useStyles();
|
||||
const apiHolder = useApiHolder();
|
||||
|
||||
const [steps, setSteps] = useState<TemplateParameterSchema['steps']>();
|
||||
|
||||
const fields = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
fieldExtensions.map(({ name, component }) => [name, component]),
|
||||
);
|
||||
}, [fieldExtensions]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
try {
|
||||
if (!content) {
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed: JsonValue = yaml
|
||||
.parseAllDocuments(content)
|
||||
.filter(c => c)
|
||||
.map(c => c.toJSON())[0];
|
||||
|
||||
if (!isJsonObject(parsed)) {
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let rootObj = parsed;
|
||||
if (!contentIsSpec) {
|
||||
const isTemplate =
|
||||
String(parsed.kind).toLocaleLowerCase('en-US') === 'template';
|
||||
if (!isTemplate) {
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
rootObj = isJsonObject(parsed.spec) ? parsed.spec : {};
|
||||
}
|
||||
|
||||
const { parameters } = rootObj;
|
||||
|
||||
if (!Array.isArray(parameters)) {
|
||||
setErrorText('Template parameters must be an array');
|
||||
setSteps(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldValidators = Object.fromEntries(
|
||||
fieldExtensions.map(({ name, validation }) => [name, validation]),
|
||||
);
|
||||
|
||||
setErrorText();
|
||||
setSteps(
|
||||
parameters.flatMap(param =>
|
||||
isJsonObject(param)
|
||||
? [
|
||||
{
|
||||
title: String(param.title),
|
||||
schema: param,
|
||||
validate: createValidator(param, fieldValidators, {
|
||||
apiHolder,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setErrorText(e.message);
|
||||
}
|
||||
},
|
||||
250,
|
||||
[contentIsSpec, content, apiHolder],
|
||||
);
|
||||
|
||||
if (!steps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.containerWrapper}>
|
||||
<div className={classes.container}>
|
||||
<ErrorBoundary invalidator={steps} setErrorText={setErrorText}>
|
||||
<MultistepJsonForm
|
||||
steps={steps}
|
||||
fields={fields as any}
|
||||
formData={data}
|
||||
onChange={e => onUpdate(e.formData)}
|
||||
onReset={() => onUpdate({})}
|
||||
finishButtonLabel={onDryRun && 'Try It'}
|
||||
onFinish={onDryRun && (() => onDryRun(data))}
|
||||
layouts={layouts}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */
|
||||
export function TemplateEditorFormDirectoryEditorDryRun(
|
||||
props: Pick<
|
||||
TemplateEditorFormProps,
|
||||
'setErrorText' | 'fieldExtensions' | 'layouts'
|
||||
>,
|
||||
) {
|
||||
const { setErrorText, fieldExtensions = [], layouts } = props;
|
||||
const dryRun = useDryRun();
|
||||
|
||||
const directoryEditor = useDirectoryEditor();
|
||||
const { selectedFile } = directoryEditor;
|
||||
|
||||
const [data, setData] = useState<JsonObject>({});
|
||||
|
||||
const handleDryRun = async () => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dryRun.execute({
|
||||
templateContent: selectedFile.content,
|
||||
values: data,
|
||||
files: directoryEditor.files,
|
||||
});
|
||||
setErrorText();
|
||||
} catch (e) {
|
||||
setErrorText(String(e.cause || e));
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const content =
|
||||
selectedFile && selectedFile.path.match(/\.ya?ml$/)
|
||||
? selectedFile.content
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TemplateEditorForm
|
||||
onDryRun={handleDryRun}
|
||||
fieldExtensions={fieldExtensions}
|
||||
setErrorText={setErrorText}
|
||||
content={content}
|
||||
data={data}
|
||||
onUpdate={setData}
|
||||
layouts={layouts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TemplateEditorForm.DirectoryEditorDryRun =
|
||||
TemplateEditorFormDirectoryEditorDryRun;
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Content, Header, Page } from '@backstage/core-components';
|
||||
import {
|
||||
TemplateDirectoryAccess,
|
||||
WebFileSystemAccess,
|
||||
} from '../../lib/filesystem';
|
||||
import { type LayoutOptions } from '@backstage/plugin-scaffolder-react';
|
||||
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { TemplateEditorIntro } from '../../next/TemplateEditorPage/TemplateEditorIntro';
|
||||
import { TemplateEditor } from './TemplateEditor';
|
||||
import { TemplateFormPreviewer } from './TemplateFormPreviewer';
|
||||
import { CustomFieldExplorer } from './CustomFieldExplorer';
|
||||
|
||||
type Selection =
|
||||
| {
|
||||
type: 'local';
|
||||
directory: TemplateDirectoryAccess;
|
||||
}
|
||||
| {
|
||||
type: 'form';
|
||||
}
|
||||
| {
|
||||
type: 'field-explorer';
|
||||
};
|
||||
|
||||
interface TemplateEditorPageProps {
|
||||
defaultPreviewTemplate?: string;
|
||||
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
}
|
||||
|
||||
export function TemplateEditorPage(props: TemplateEditorPageProps) {
|
||||
const [selection, setSelection] = useState<Selection>();
|
||||
|
||||
let content: JSX.Element | null = null;
|
||||
if (selection?.type === 'local') {
|
||||
content = (
|
||||
<TemplateEditor
|
||||
directory={selection.directory}
|
||||
fieldExtensions={props.customFieldExtensions}
|
||||
onClose={() => setSelection(undefined)}
|
||||
layouts={props.layouts}
|
||||
/>
|
||||
);
|
||||
} else if (selection?.type === 'form') {
|
||||
content = (
|
||||
<TemplateFormPreviewer
|
||||
defaultPreviewTemplate={props.defaultPreviewTemplate}
|
||||
customFieldExtensions={props.customFieldExtensions}
|
||||
onClose={() => setSelection(undefined)}
|
||||
layouts={props.layouts}
|
||||
/>
|
||||
);
|
||||
} else if (selection?.type === 'field-explorer') {
|
||||
content = (
|
||||
<CustomFieldExplorer
|
||||
customFieldExtensions={props.customFieldExtensions}
|
||||
onClose={() => setSelection(undefined)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Content>
|
||||
<TemplateEditorIntro
|
||||
onSelect={option => {
|
||||
if (option === 'local') {
|
||||
WebFileSystemAccess.requestDirectoryAccess()
|
||||
.then(directory => setSelection({ type: 'local', directory }))
|
||||
.catch(() => {});
|
||||
} else if (option === 'form') {
|
||||
setSelection({ type: 'form' });
|
||||
} else if (option === 'field-explorer') {
|
||||
setSelection({ type: 'field-explorer' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
title="Template Editor"
|
||||
subtitle="Edit, preview, and try out templates and template forms"
|
||||
/>
|
||||
{content}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
catalogApiRef,
|
||||
humanizeEntityRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
LinearProgress,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
Select,
|
||||
} from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import yaml from 'yaml';
|
||||
import { LayoutOptions } from '@backstage/plugin-scaffolder-react';
|
||||
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { TemplateEditorTextArea } from '../../next/TemplateEditorPage/TemplateEditorTextArea';
|
||||
import { TemplateEditorForm } from './TemplateEditorForm';
|
||||
|
||||
const EXAMPLE_TEMPLATE_PARAMS_YAML = `# Edit the template parameters below to see how they will render in the scaffolder form UI
|
||||
parameters:
|
||||
- title: Fill in some steps
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
title: Name
|
||||
type: string
|
||||
description: Unique name of the component
|
||||
owner:
|
||||
title: Owner
|
||||
type: string
|
||||
description: Owner of the component
|
||||
ui:field: OwnerPicker
|
||||
ui:options:
|
||||
catalogFilter:
|
||||
kind: Group
|
||||
- title: Choose a location
|
||||
required:
|
||||
- repoUrl
|
||||
properties:
|
||||
repoUrl:
|
||||
title: Repository Location
|
||||
type: string
|
||||
ui:field: RepoUrlPicker
|
||||
ui:options:
|
||||
allowedHosts:
|
||||
- github.com
|
||||
steps:
|
||||
- id: fetch-base
|
||||
name: Fetch Base
|
||||
action: fetch:template
|
||||
input:
|
||||
url: ./template
|
||||
values:
|
||||
name: \${{parameters.name}}
|
||||
`;
|
||||
|
||||
type TemplateOption = {
|
||||
label: string;
|
||||
value: Entity;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
gridArea: 'pageContent',
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"controls controls"
|
||||
"textArea preview"
|
||||
`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
},
|
||||
controls: {
|
||||
gridArea: 'controls',
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
textArea: {
|
||||
gridArea: 'textArea',
|
||||
},
|
||||
preview: {
|
||||
gridArea: 'preview',
|
||||
},
|
||||
}));
|
||||
|
||||
export const TemplateFormPreviewer = ({
|
||||
defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML,
|
||||
customFieldExtensions = [],
|
||||
onClose,
|
||||
layouts = [],
|
||||
}: {
|
||||
defaultPreviewTemplate?: string;
|
||||
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
|
||||
onClose?: () => void;
|
||||
layouts?: LayoutOptions[];
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState('');
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
const [templateOptions, setTemplateOptions] = useState<TemplateOption[]>([]);
|
||||
const [templateYaml, setTemplateYaml] = useState(defaultPreviewTemplate);
|
||||
const [formState, setFormState] = useState({});
|
||||
|
||||
const { loading } = useAsync(
|
||||
() =>
|
||||
catalogApi
|
||||
.getEntities({
|
||||
filter: { kind: 'template' },
|
||||
fields: [
|
||||
'kind',
|
||||
'metadata.namespace',
|
||||
'metadata.name',
|
||||
'metadata.title',
|
||||
'spec.parameters',
|
||||
'spec.steps',
|
||||
'spec.output',
|
||||
],
|
||||
})
|
||||
.then(({ items }) =>
|
||||
setTemplateOptions(
|
||||
items.map(template => ({
|
||||
label:
|
||||
template.metadata.title ??
|
||||
humanizeEntityRef(template, { defaultKind: 'template' }),
|
||||
value: template,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(e =>
|
||||
alertApi.post({
|
||||
message: `Error loading exisiting templates: ${e.message}`,
|
||||
severity: 'error',
|
||||
}),
|
||||
),
|
||||
[catalogApi],
|
||||
);
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
// TODO(Rugvip): Afaik this should be Entity, but didn't want to make runtime changes while fixing types
|
||||
(selected: any) => {
|
||||
setSelectedTemplate(selected);
|
||||
setTemplateYaml(yaml.stringify(selected.spec));
|
||||
},
|
||||
[setTemplateYaml],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <LinearProgress />}
|
||||
<main className={classes.root}>
|
||||
<div className={classes.controls}>
|
||||
<FormControl variant="outlined" size="small" fullWidth>
|
||||
<InputLabel id="select-template-label">
|
||||
Load Existing Template
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={selectedTemplate}
|
||||
label="Load Existing Template"
|
||||
labelId="select-template-label"
|
||||
onChange={e => handleSelectChange(e.target.value)}
|
||||
>
|
||||
{templateOptions.map((option, idx) => (
|
||||
<MenuItem key={idx} value={option.value as any}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton size="medium" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={classes.textArea}>
|
||||
<TemplateEditorTextArea
|
||||
content={templateYaml}
|
||||
onUpdate={setTemplateYaml}
|
||||
errorText={errorText}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.preview}>
|
||||
<TemplateEditorForm
|
||||
data={formState}
|
||||
onUpdate={setFormState}
|
||||
content={templateYaml}
|
||||
contentIsSpec
|
||||
fieldExtensions={customFieldExtensions}
|
||||
setErrorText={setErrorText}
|
||||
layouts={layouts}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplateEditorPage } from './TemplateEditorPage';
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { TemplateList } from './TemplateList';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import {
|
||||
ScmIntegrationsApi,
|
||||
scmIntegrationsApiRef,
|
||||
} from '@backstage/integration-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => ({
|
||||
useEntityList: jest.fn().mockReturnValue({
|
||||
loading: false,
|
||||
entities: [
|
||||
{
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 't1',
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
{
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 't2',
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
getEntityRelations: jest.fn().mockImplementation(() => []),
|
||||
getEntitySourceLocation: jest.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
describe('TemplateList', () => {
|
||||
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
|
||||
byHost: () => ({ type: 'github' }),
|
||||
};
|
||||
|
||||
it('should filter out templates based on provided filter condition', async () => {
|
||||
const TemplateCardComponent = ({
|
||||
template,
|
||||
}: {
|
||||
template: TemplateEntityV1beta3;
|
||||
}) => (
|
||||
<div data-testid={template.metadata.name}>{template.metadata.name}</div>
|
||||
);
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[scmIntegrationsApiRef, mockIntegrationsApi]]}>
|
||||
<div data-testid="container">
|
||||
<TemplateList
|
||||
templateFilter={e => e.metadata.name === 't1'}
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
/>
|
||||
</div>
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
|
||||
expect(() => screen.getByTestId('t2')).toThrow();
|
||||
expect(screen.getByTestId('t1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
isTemplateEntityV1beta3,
|
||||
TemplateEntityV1beta3,
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
ItemCardGrid,
|
||||
Link,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { useEntityList } from '@backstage/plugin-catalog-react';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { TemplateCard } from '../TemplateCard';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type TemplateListProps = {
|
||||
TemplateCardComponent?:
|
||||
| ComponentType<{ template: TemplateEntityV1beta3 }>
|
||||
| undefined;
|
||||
group?: {
|
||||
title?: React.ReactNode;
|
||||
filter: (entity: TemplateEntityV1beta3) => boolean;
|
||||
};
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const TemplateList = ({
|
||||
TemplateCardComponent,
|
||||
group,
|
||||
templateFilter,
|
||||
}: TemplateListProps) => {
|
||||
const { loading, error, entities } = useEntityList();
|
||||
const Card = TemplateCardComponent || TemplateCard;
|
||||
const templateEntities = entities.filter(isTemplateEntityV1beta3);
|
||||
const maybeFilteredEntities = (
|
||||
group ? templateEntities.filter(group.filter) : templateEntities
|
||||
).filter(e => (templateFilter ? templateFilter(e) : true));
|
||||
|
||||
const titleComponent: React.ReactNode = (() => {
|
||||
if (group && group.title) {
|
||||
if (typeof group.title === 'string') {
|
||||
return <ContentHeader title={group.title} />;
|
||||
}
|
||||
return group.title;
|
||||
}
|
||||
|
||||
return <ContentHeader title="Other Templates" />;
|
||||
})();
|
||||
|
||||
if (group && maybeFilteredEntities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{loading && <Progress />}
|
||||
|
||||
{error && (
|
||||
<WarningPanel title="Oops! Something went wrong loading the templates">
|
||||
{error.message}
|
||||
</WarningPanel>
|
||||
)}
|
||||
|
||||
{!error && !loading && !entities.length && (
|
||||
<Typography variant="body2">
|
||||
No templates found that match your filter. Learn more about{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-templates/adding-templates">
|
||||
adding templates
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Content>
|
||||
{titleComponent}
|
||||
<ItemCardGrid>
|
||||
{maybeFilteredEntities &&
|
||||
maybeFilteredEntities?.length > 0 &&
|
||||
maybeFilteredEntities.map((template: Entity) => (
|
||||
<Card
|
||||
key={stringifyEntityRef(template)}
|
||||
template={template as TemplateEntityV1beta3}
|
||||
deprecated={template.apiVersion === 'backstage.io/v1beta2'}
|
||||
/>
|
||||
))}
|
||||
</ItemCardGrid>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplateList } from './TemplateList';
|
||||
export type { TemplateListProps } from './TemplateList';
|
||||
@@ -1,366 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
MockAnalyticsApi,
|
||||
renderInTestApp,
|
||||
TestApiRegistry,
|
||||
} from '@backstage/test-utils';
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
scaffolderApiRef,
|
||||
ScaffolderApi,
|
||||
SecretsContextProvider,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { TemplatePage } from './TemplatePage';
|
||||
import {
|
||||
featureFlagsApiRef,
|
||||
FeatureFlagsApi,
|
||||
analyticsApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
return {
|
||||
...(jest.requireActual('react-router-dom') as any),
|
||||
useParams: () => ({
|
||||
templateName: 'test',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
|
||||
cancelTask: jest.fn(),
|
||||
scaffold: jest.fn(),
|
||||
getTemplateParameterSchema: jest.fn(),
|
||||
getIntegrationsList: jest.fn(),
|
||||
getTask: jest.fn(),
|
||||
streamLogs: jest.fn(),
|
||||
listActions: jest.fn(),
|
||||
listTasks: jest.fn(),
|
||||
};
|
||||
|
||||
const featureFlagsApiMock: jest.Mocked<FeatureFlagsApi> = {
|
||||
isActive: jest.fn(),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
|
||||
|
||||
const analyticsMock = new MockAnalyticsApi();
|
||||
|
||||
const schemaMockValue = {
|
||||
title: 'my-schema',
|
||||
steps: [
|
||||
{
|
||||
title: 'Fill in some steps',
|
||||
schema: {
|
||||
title: 'Fill in some steps',
|
||||
'backstage:featureFlag': 'experimental-feature',
|
||||
properties: {
|
||||
name: {
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
'backstage:featureFlag': 'should-show-some-stuff-first-option',
|
||||
},
|
||||
description: {
|
||||
title: 'Description',
|
||||
type: 'string',
|
||||
description: 'A description for the component',
|
||||
},
|
||||
owner: {
|
||||
title: 'Owner',
|
||||
type: 'string',
|
||||
description: 'Owner of the component',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Send data',
|
||||
schema: {
|
||||
title: 'Send data',
|
||||
properties: {
|
||||
user: {
|
||||
title: 'User',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const apis = TestApiRegistry.from(
|
||||
[scaffolderApiRef, scaffolderApiMock],
|
||||
[errorApiRef, errorApiMock],
|
||||
[featureFlagsApiRef, featureFlagsApiMock],
|
||||
[analyticsApiRef, analyticsMock],
|
||||
);
|
||||
|
||||
describe('TemplatePage', () => {
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
it('renders correctly', async () => {
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
|
||||
title: 'React SSR Template',
|
||||
steps: [],
|
||||
});
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage />
|
||||
</SecretsContextProvider>
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.getByText('Create a New Component')).toBeInTheDocument();
|
||||
expect(rendered.getByText('React SSR Template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders spinner while loading', async () => {
|
||||
let resolve: Function;
|
||||
const promise = new Promise<any>(res => {
|
||||
resolve = res;
|
||||
});
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockReturnValueOnce(promise);
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage />
|
||||
</SecretsContextProvider>
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.getByText('Create a New Component')).toBeInTheDocument();
|
||||
expect(rendered.getByTestId('loading-progress')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
resolve!({
|
||||
title: 'React SSR Template',
|
||||
steps: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('captures expected analytics events', async () => {
|
||||
scaffolderApiMock.scaffold.mockResolvedValue({ taskId: 'xyz' });
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
|
||||
title: 'schema-4-analytics',
|
||||
steps: [
|
||||
{
|
||||
title: 'Fill in some steps',
|
||||
schema: {
|
||||
properties: {
|
||||
name: {
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { findByLabelText, findByText } = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage />
|
||||
</SecretsContextProvider>
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Fill out the name field
|
||||
expect(await findByText('Fill in some steps')).toBeInTheDocument();
|
||||
fireEvent.change(await findByLabelText('Name', { exact: false }), {
|
||||
target: { value: 'expected-name' },
|
||||
});
|
||||
|
||||
// Go to the final page
|
||||
fireEvent.click(await findByText('Next step'));
|
||||
expect(await findByText('Reset')).toBeInTheDocument();
|
||||
|
||||
// Create the software
|
||||
await act(async () => {
|
||||
fireEvent.click(await findByText('Create'));
|
||||
});
|
||||
|
||||
// The "Next Step" button should have fired an event
|
||||
expect(analyticsMock.getEvents()[0]).toMatchObject({
|
||||
action: 'click',
|
||||
subject: 'Next Step (1)',
|
||||
context: { entityRef: 'template:default/test' },
|
||||
});
|
||||
|
||||
// And the "Create" button should have fired an event
|
||||
expect(analyticsMock.getEvents()[1]).toMatchObject({
|
||||
action: 'create',
|
||||
subject: 'expected-name',
|
||||
context: { entityRef: 'template:default/test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates away if no template was loaded', async () => {
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/create/test"
|
||||
element={
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage />
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
<Route path="/create" element={<>This is root</>} />
|
||||
</Routes>
|
||||
</ApiProvider>,
|
||||
{
|
||||
routeEntries: ['/create'],
|
||||
mountedRoutes: { '/create': rootRouteRef },
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
rendered.queryByText('Create a New Component'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(rendered.getByText('This is root')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('display template with oneOf', async () => {
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
|
||||
title: 'my-schema',
|
||||
steps: [
|
||||
{
|
||||
title: 'Fill in some steps',
|
||||
schema: {
|
||||
oneOf: [
|
||||
{
|
||||
title: 'First',
|
||||
properties: {
|
||||
name: {
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
{
|
||||
title: 'Second',
|
||||
properties: {
|
||||
something: {
|
||||
title: 'Something',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['something'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { findByText, findByLabelText, findAllByRole, findByRole } =
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage />
|
||||
</SecretsContextProvider>
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create/actions': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(await findByText('Fill in some steps')).toBeInTheDocument();
|
||||
|
||||
// Fill the first option
|
||||
fireEvent.change(await findByLabelText('Name', { exact: false }), {
|
||||
target: { value: 'my-name' },
|
||||
});
|
||||
|
||||
// Switch to second option
|
||||
fireEvent.mouseDown((await findAllByRole('button'))[0]);
|
||||
const listbox = within(await findByRole('listbox'));
|
||||
fireEvent.click(listbox.getByText(/Second/i));
|
||||
|
||||
// Fill the second option
|
||||
fireEvent.change(await findByLabelText('Something', { exact: false }), {
|
||||
target: { value: 'my-something' },
|
||||
});
|
||||
|
||||
// Go to the final page
|
||||
fireEvent.click(await findByText('Next step'));
|
||||
expect(await findByText('Reset')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a section or property based on a feature flag', async () => {
|
||||
featureFlagsApiMock.isActive.mockImplementation(flag => {
|
||||
return flag === 'experimental-feature';
|
||||
});
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue(
|
||||
schemaMockValue,
|
||||
);
|
||||
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SecretsContextProvider>
|
||||
<TemplatePage />
|
||||
</SecretsContextProvider>
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create/actions': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Name')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Send data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { LinearProgress } from '@material-ui/core';
|
||||
import { IChangeEvent } from '@rjsf/core';
|
||||
import qs from 'qs';
|
||||
import React, { ComponentType, useCallback, useState } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import {
|
||||
type LayoutOptions,
|
||||
scaffolderApiRef,
|
||||
useTemplateSecrets,
|
||||
ReviewStepProps,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { MultistepJsonForm } from '../MultistepJsonForm';
|
||||
import { createValidator } from './createValidator';
|
||||
|
||||
import { Content, Header, InfoCard, Page } from '@backstage/core-components';
|
||||
import {
|
||||
AnalyticsContext,
|
||||
errorApiRef,
|
||||
useApi,
|
||||
useApiHolder,
|
||||
useRouteRef,
|
||||
useRouteRefParams,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
rootRouteRef,
|
||||
scaffolderTaskRouteRef,
|
||||
selectedTemplateRouteRef,
|
||||
} from '../../routes';
|
||||
import { LegacyFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
|
||||
const useTemplateParameterSchema = (templateRef: string) => {
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const { value, loading, error } = useAsync(
|
||||
() => scaffolderApi.getTemplateParameterSchema(templateRef),
|
||||
[scaffolderApi, templateRef],
|
||||
);
|
||||
return { schema: value, loading, error };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ReviewStepComponent?: ComponentType<ReviewStepProps>;
|
||||
customFieldExtensions?: LegacyFieldExtensionOptions<any, any>[];
|
||||
layouts?: LayoutOptions[];
|
||||
headerOptions?: {
|
||||
pageTitleOverride?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const TemplatePage = ({
|
||||
ReviewStepComponent,
|
||||
customFieldExtensions = [],
|
||||
layouts = [],
|
||||
headerOptions,
|
||||
}: Props) => {
|
||||
const apiHolder = useApiHolder();
|
||||
const secretsContext = useTemplateSecrets();
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const { templateName, namespace } = useRouteRefParams(
|
||||
selectedTemplateRouteRef,
|
||||
);
|
||||
const templateRef = stringifyEntityRef({
|
||||
name: templateName,
|
||||
kind: 'template',
|
||||
namespace,
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const scaffolderTaskRoute = useRouteRef(scaffolderTaskRouteRef);
|
||||
const rootRoute = useRouteRef(rootRouteRef);
|
||||
const { schema, loading, error } = useTemplateParameterSchema(templateRef);
|
||||
const [formState, setFormState] = useState<Record<string, any>>(() => {
|
||||
const query = qs.parse(window.location.search, {
|
||||
ignoreQueryPrefix: true,
|
||||
});
|
||||
|
||||
try {
|
||||
return JSON.parse(query.formData as string);
|
||||
} catch (e) {
|
||||
return query.formData ?? {};
|
||||
}
|
||||
});
|
||||
const handleFormReset = () => setFormState({});
|
||||
const handleChange = useCallback(
|
||||
(e: IChangeEvent) => setFormState(e.formData),
|
||||
[setFormState],
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const { taskId } = await scaffolderApi.scaffold({
|
||||
templateRef,
|
||||
values: formState,
|
||||
secrets: secretsContext?.secrets,
|
||||
});
|
||||
|
||||
const formParams = qs.stringify(
|
||||
{ formData: formState },
|
||||
{ addQueryPrefix: true },
|
||||
);
|
||||
const newUrl = `${window.location.pathname}${formParams}`;
|
||||
// We use direct history manipulation since useSearchParams and
|
||||
// useNavigate in react-router-dom cause unnecessary extra rerenders.
|
||||
// Also make sure to replace the state rather than pushing to avoid
|
||||
// extra back/forward slots.
|
||||
window.history?.replaceState(null, document.title, newUrl);
|
||||
|
||||
navigate(scaffolderTaskRoute({ taskId }));
|
||||
};
|
||||
|
||||
if (error) {
|
||||
errorApi.post(new Error(`Failed to load template, ${error}`));
|
||||
return <Navigate to={rootRoute()} />;
|
||||
}
|
||||
if (!loading && !schema) {
|
||||
errorApi.post(new Error('Template was not found.'));
|
||||
return <Navigate to={rootRoute()} />;
|
||||
}
|
||||
|
||||
const customFieldComponents = Object.fromEntries(
|
||||
customFieldExtensions.map(({ name, component }) => [name, component]),
|
||||
);
|
||||
|
||||
const customFieldValidators = Object.fromEntries(
|
||||
customFieldExtensions.map(({ name, validation }) => [name, validation]),
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsContext attributes={{ entityRef: templateRef }}>
|
||||
<Page themeId="home">
|
||||
<Header
|
||||
pageTitleOverride="Create a New Component"
|
||||
title="Create a New Component"
|
||||
subtitle="Create new software components using standard templates"
|
||||
{...headerOptions}
|
||||
/>
|
||||
<Content>
|
||||
{loading && <LinearProgress data-testid="loading-progress" />}
|
||||
{schema && (
|
||||
<InfoCard
|
||||
title={schema.title}
|
||||
noPadding
|
||||
titleTypographyProps={{ component: 'h2' }}
|
||||
>
|
||||
<MultistepJsonForm
|
||||
ReviewStepComponent={ReviewStepComponent}
|
||||
formData={formState}
|
||||
fields={customFieldComponents as any}
|
||||
onChange={handleChange}
|
||||
onReset={handleFormReset}
|
||||
onFinish={handleCreate}
|
||||
layouts={layouts}
|
||||
steps={schema.steps.map(step => {
|
||||
return {
|
||||
...step,
|
||||
validate: createValidator(
|
||||
step.schema,
|
||||
customFieldValidators,
|
||||
{ apiHolder },
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</InfoCard>
|
||||
)}
|
||||
</Content>
|
||||
</Page>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createValidator } from './createValidator';
|
||||
import { LegacyCustomFieldValidator } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { FieldValidation, FormValidation } from '@rjsf/utils';
|
||||
|
||||
type CustomLinkType = {
|
||||
url: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
describe('createValidator', () => {
|
||||
const validators: Record<
|
||||
string,
|
||||
undefined | LegacyCustomFieldValidator<unknown>
|
||||
> = {
|
||||
CustomPicker: (
|
||||
value: unknown,
|
||||
fieldValidation: FieldValidation,
|
||||
_context: { apiHolder: ApiHolder },
|
||||
) => {
|
||||
if (!value || !(value as { value?: unknown }).value) {
|
||||
fieldValidation.addError('Error !');
|
||||
}
|
||||
},
|
||||
CustomLink: (
|
||||
values: unknown,
|
||||
fieldValidation: FieldValidation,
|
||||
_context: { apiHolder: ApiHolder },
|
||||
) => {
|
||||
const input = values as CustomLinkType[];
|
||||
for (const item of input) {
|
||||
const validGitlabUrlRegex =
|
||||
/gitlab\.(?:stg\.)?spotify\.com\?owner=.*&repo=.*/;
|
||||
|
||||
if (!item || !validGitlabUrlRegex.test(item.url)) {
|
||||
fieldValidation.addError(
|
||||
`Make sure to put in a valid gitlab clone url.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
TagPicker: (
|
||||
values: unknown,
|
||||
fieldValidation: FieldValidation,
|
||||
_context: { apiHolder: ApiHolder },
|
||||
) => {
|
||||
const input = values as string[];
|
||||
for (const item of input) {
|
||||
if (!/^[a-z0-9-]+$/.test(item)) {
|
||||
fieldValidation.addError(
|
||||
'A tag name can only contain lowercase letters, numeric characters or dashes',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const apiHolderMock: jest.Mocked<ApiHolder> = {
|
||||
get: jest.fn().mockImplementation(() => {
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const context = {
|
||||
apiHolder: apiHolderMock,
|
||||
};
|
||||
|
||||
it('should call validator for object property from a custom field extension', () => {
|
||||
/* GIVEN */
|
||||
const rootSchema = {
|
||||
title: 'Title',
|
||||
properties: {
|
||||
p1: {
|
||||
title: 'PropertyOn',
|
||||
type: 'object',
|
||||
'ui:field': 'CustomPicker',
|
||||
},
|
||||
},
|
||||
};
|
||||
const validator = createValidator(rootSchema, validators, context);
|
||||
|
||||
const formData = {
|
||||
p1: {},
|
||||
};
|
||||
const errors = {
|
||||
addError: jest.fn(),
|
||||
p1: {
|
||||
addError: jest.fn(),
|
||||
} as unknown as FormValidation,
|
||||
} as unknown as FormValidation;
|
||||
|
||||
/* WHEN */
|
||||
const result = validator(formData, errors);
|
||||
|
||||
/* THEN */
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.p1?.addError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call validator for array property from a custom field extension', () => {
|
||||
/* GIVEN */
|
||||
const rootSchema = {
|
||||
title: 'My form',
|
||||
properties: {
|
||||
tags: {
|
||||
title: 'Tags',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
'ui:field': 'TagPicker',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const validator = createValidator(rootSchema, validators, context);
|
||||
|
||||
const formData = {
|
||||
tags: ['invalid-tag$$'],
|
||||
};
|
||||
const errors = {
|
||||
addError: jest.fn(),
|
||||
tags: {
|
||||
addError: jest.fn(),
|
||||
} as unknown as FormValidation,
|
||||
} as unknown as FormValidation;
|
||||
|
||||
/* WHEN */
|
||||
const result = validator(formData, errors);
|
||||
|
||||
/* THEN */
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.tags?.addError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call validator for array object property from a custom field extension', () => {
|
||||
/* GIVEN */
|
||||
const rootSchema = {
|
||||
title: 'My links',
|
||||
properties: {
|
||||
links: {
|
||||
title: 'Links',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['url', 'title', 'icon'],
|
||||
properties: {
|
||||
url: {
|
||||
title: 'url',
|
||||
description: 'url',
|
||||
type: 'object',
|
||||
'ui:field': 'CustomLink',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const validator = createValidator(rootSchema, validators, context);
|
||||
|
||||
const formData = {
|
||||
links: [
|
||||
{
|
||||
url: 'http://gitlab.spotify.nl/owener=me&repo=test',
|
||||
icon: 'subject',
|
||||
title: 'My repository for testing features',
|
||||
} as CustomLinkType,
|
||||
],
|
||||
};
|
||||
const errors = {
|
||||
addError: jest.fn(),
|
||||
links: {
|
||||
addError: jest.fn(),
|
||||
} as unknown as FormValidation,
|
||||
} as unknown as FormValidation;
|
||||
|
||||
/* WHEN */
|
||||
const result = validator(formData, errors);
|
||||
|
||||
/* THEN */
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.links?.addError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LegacyCustomFieldValidator } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { FieldValidation, FormValidation } from '@rjsf/utils';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
|
||||
function isObject(obj: unknown): obj is JsonObject {
|
||||
return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
|
||||
}
|
||||
|
||||
function isArray(obj: unknown): obj is JsonObject {
|
||||
return typeof obj === 'object' && obj !== null && Array.isArray(obj);
|
||||
}
|
||||
|
||||
export const createValidator = (
|
||||
rootSchema: JsonObject,
|
||||
validators: Record<string, undefined | LegacyCustomFieldValidator<unknown>>,
|
||||
context: {
|
||||
apiHolder: ApiHolder;
|
||||
},
|
||||
) => {
|
||||
function validate(
|
||||
schema: JsonObject,
|
||||
formData: JsonObject,
|
||||
errors: FormValidation,
|
||||
) {
|
||||
const schemaProps = schema.properties;
|
||||
const customObject = schema.type === 'object' && schemaProps === undefined;
|
||||
|
||||
if (!isObject(schemaProps) && !customObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (schemaProps) {
|
||||
for (const [key, propData] of Object.entries(formData)) {
|
||||
const propValidation = errors[key];
|
||||
|
||||
const doValidate = (item: JsonValue | undefined) => {
|
||||
if (item && isObject(item)) {
|
||||
const fieldName = item['ui:field'] as string;
|
||||
if (fieldName && typeof validators[fieldName] === 'function') {
|
||||
validators[fieldName]!(
|
||||
propData as JsonObject[],
|
||||
propValidation as FieldValidation,
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const propSchemaProps = schemaProps[key];
|
||||
if (isObject(propData) && isObject(propSchemaProps)) {
|
||||
validate(
|
||||
propSchemaProps,
|
||||
propData as JsonObject,
|
||||
propValidation as FormValidation,
|
||||
);
|
||||
} else if (isArray(propData)) {
|
||||
if (isObject(propSchemaProps)) {
|
||||
const { items } = propSchemaProps;
|
||||
if (isObject(items)) {
|
||||
if (items.type === 'object') {
|
||||
const properties = (items?.properties ?? []) as JsonObject[];
|
||||
for (const [, value] of Object.entries(properties)) {
|
||||
doValidate(value);
|
||||
}
|
||||
} else {
|
||||
doValidate(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
doValidate(propSchemaProps);
|
||||
}
|
||||
}
|
||||
} else if (customObject) {
|
||||
const fieldName = schema['ui:field'] as string;
|
||||
if (fieldName && typeof validators[fieldName] === 'function') {
|
||||
validators[fieldName]!(formData, errors, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (formData: JsonObject, errors: FormValidation) => {
|
||||
validate(rootSchema, formData, errors);
|
||||
return errors;
|
||||
};
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { TemplatePage } from './TemplatePage';
|
||||
export { createValidator } from './createValidator';
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { LegacyRouter, type LegacyRouterProps } from './Router';
|
||||
export { LegacyScaffolderPage } from '../plugin';
|
||||
@@ -27,8 +27,8 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useDryRun } from '../DryRunContext';
|
||||
import { DryRunResultsSplitView } from './DryRunResultsSplitView';
|
||||
import { FileBrowser } from '../../../components/FileBrowser';
|
||||
import { TaskPageLinks } from '../../../legacy/TaskPage/TaskPageLinks';
|
||||
import { TaskStatusStepper } from '../../../legacy/TaskPage/TaskPage';
|
||||
import { TaskPageLinks } from './TaskPageLinks';
|
||||
import { TaskStatusStepper } from './TaskStatusStepper';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
|
||||
+1
-2
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -13,7 +13,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Grid, LinkProps, makeStyles, Typography } from '@material-ui/core';
|
||||
import LanguageIcon from '@material-ui/icons/Language';
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { memo } from 'react';
|
||||
import Step from '@material-ui/core/Step';
|
||||
import StepLabel from '@material-ui/core/StepLabel';
|
||||
import Stepper from '@material-ui/core/Stepper';
|
||||
import { ScaffolderTaskStatus } from '@backstage/plugin-scaffolder-react';
|
||||
import {
|
||||
StepButton,
|
||||
StepIconProps,
|
||||
Theme,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
CircularProgress,
|
||||
} from '@material-ui/core';
|
||||
import Cancel from '@material-ui/icons/Cancel';
|
||||
import Check from '@material-ui/icons/Check';
|
||||
import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { DateTime, Interval } from 'luxon';
|
||||
import useInterval from 'react-use/lib/useInterval';
|
||||
import humanizeDuration from 'humanize-duration';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
width: '100%',
|
||||
},
|
||||
button: {
|
||||
marginBottom: theme.spacing(2),
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
actionsContainer: {
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
resetContainer: {
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
labelWrapper: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
stepWrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
type TaskStep = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ScaffolderTaskStatus;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
};
|
||||
|
||||
const useStepIconStyles = makeStyles(theme =>
|
||||
createStyles({
|
||||
root: {
|
||||
color: theme.palette.text.disabled,
|
||||
display: 'flex',
|
||||
height: 22,
|
||||
alignItems: 'center',
|
||||
},
|
||||
completed: {
|
||||
color: theme.palette.status.ok,
|
||||
},
|
||||
error: {
|
||||
color: theme.palette.status.error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const StepTimeTicker = ({ step }: { step: TaskStep }) => {
|
||||
const [time, setTime] = useState('');
|
||||
|
||||
useInterval(() => {
|
||||
if (!step.startedAt) {
|
||||
setTime('');
|
||||
return;
|
||||
}
|
||||
|
||||
const end = step.endedAt
|
||||
? DateTime.fromISO(step.endedAt)
|
||||
: DateTime.local();
|
||||
|
||||
const startedAt = DateTime.fromISO(step.startedAt);
|
||||
const formatted = Interval.fromDateTimes(startedAt, end)
|
||||
.toDuration()
|
||||
.valueOf();
|
||||
|
||||
setTime(humanizeDuration(formatted, { round: true }));
|
||||
}, 1000);
|
||||
|
||||
return <Typography variant="caption">{time}</Typography>;
|
||||
};
|
||||
|
||||
function TaskStepIconComponent(props: StepIconProps) {
|
||||
const classes = useStepIconStyles();
|
||||
const { active, completed, error } = props;
|
||||
|
||||
const getMiddle = () => {
|
||||
if (active) {
|
||||
return <CircularProgress size="24px" />;
|
||||
}
|
||||
if (completed) {
|
||||
return <Check />;
|
||||
}
|
||||
if (error) {
|
||||
return <Cancel />;
|
||||
}
|
||||
return <FiberManualRecordIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.root, {
|
||||
[classes.completed]: completed,
|
||||
[classes.error]: error,
|
||||
})}
|
||||
>
|
||||
{getMiddle()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TaskStatusStepper = memo(
|
||||
(props: {
|
||||
steps: TaskStep[];
|
||||
currentStepId: string | undefined;
|
||||
onUserStepChange: (id: string) => void;
|
||||
classes?: {
|
||||
root?: string;
|
||||
};
|
||||
}) => {
|
||||
const { steps, currentStepId, onUserStepChange } = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Stepper
|
||||
activeStep={steps.findIndex(s => s.id === currentStepId)}
|
||||
orientation="vertical"
|
||||
nonLinear
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isCancelled = step.status === 'cancelled';
|
||||
const isActive = step.status === 'processing';
|
||||
const isCompleted = step.status === 'completed';
|
||||
const isFailed = step.status === 'failed';
|
||||
const isSkipped = step.status === 'skipped';
|
||||
|
||||
return (
|
||||
<Step key={String(index)} expanded>
|
||||
<StepButton onClick={() => onUserStepChange(step.id)}>
|
||||
<StepLabel
|
||||
StepIconProps={{
|
||||
completed: isCompleted,
|
||||
error: isFailed || isCancelled,
|
||||
active: isActive,
|
||||
}}
|
||||
StepIconComponent={TaskStepIconComponent}
|
||||
className={classes.stepWrapper}
|
||||
>
|
||||
<div className={classes.labelWrapper}>
|
||||
<Typography variant="subtitle2">{step.name}</Typography>
|
||||
{isSkipped ? (
|
||||
<Typography variant="caption">Skipped</Typography>
|
||||
) : (
|
||||
<StepTimeTicker step={step} />
|
||||
)}
|
||||
</div>
|
||||
</StepLabel>
|
||||
</StepButton>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -212,15 +212,3 @@ export const EntityTagsPickerFieldExtension = scaffolderPlugin.provide(
|
||||
schema: EntityTagsPickerSchema,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* The Router and main entrypoint to the Alpha Scaffolder plugin.
|
||||
*/
|
||||
export const LegacyScaffolderPage = scaffolderPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'LegacyScaffolderPage',
|
||||
component: () => import('./legacy/Router').then(m => m.LegacyRouter),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user