chore: remove the legacy exports from alpha

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2024-01-17 14:13:36 +01:00
parent 22bf996b08
commit 029295cd94
49 changed files with 236 additions and 4860 deletions
-1
View File
@@ -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';
+1 -24
View File
@@ -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.
*/
+10
View File
@@ -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 };
}
-183
View File
@@ -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';
-17
View File
@@ -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,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>
);
},
);
-12
View File
@@ -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,
}),
);