Merge pull request #14132 from acierto/scaffolder-result-content

Make scaffolder last step (summary overview) configurable by the user
This commit is contained in:
Ben Lambert
2022-11-22 11:46:18 +01:00
committed by GitHub
10 changed files with 241 additions and 94 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-scaffolder': patch
---
Enabling the customization of the last step in the scaffolder template.
To override the content you have to do the next:
```typescript jsx
<TemplatePage ReviewStepComponent={YourCustomComponent} />
```
+15
View File
@@ -388,12 +388,27 @@ export const RepoUrlPickerFieldSchema: FieldSchema<
export type RepoUrlPickerUiOptions =
typeof RepoUrlPickerFieldSchema.uiOptionsType;
// @public
export type ReviewStepProps = {
disableButtons: boolean;
formData: JsonObject;
handleBack: () => void;
handleReset: () => void;
handleCreate: () => void;
steps: {
uiSchema: UiSchema;
mergedSchema: JsonObject;
schema: JsonObject;
}[];
};
// @public (undocumented)
export const rootRouteRef: RouteRef<undefined>;
// @public
export type RouterProps = {
components?: {
ReviewStepComponent?: ComponentType<ReviewStepProps>;
TemplateCardComponent?:
| ComponentType<{
template: TemplateEntityV1beta3;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { getReviewData } from './MultistepJsonForm';
import { getReviewData, getUiSchemasFromSteps } from './ReviewStep';
describe('MultistepJsonForm', () => {
const formDataMock = {
@@ -76,7 +76,8 @@ describe('MultistepJsonForm', () => {
];
test('Fields are defined to be hidden or masked', () => {
const reviewData = getReviewData(formDataMock, stepsMock);
const schemas = getUiSchemasFromSteps(stepsMock);
const reviewData = getReviewData(formDataMock, schemas);
expect(reviewData.password).toBe('******');
expect(reviewData.masked).toBe('******');
@@ -13,41 +13,46 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
import {
Box,
Button,
Paper,
Step as StepUI,
StepContent,
StepLabel,
Stepper,
Typography,
} from '@material-ui/core';
import { JsonObject } from '@backstage/types';
import {
errorApiRef,
useApi,
featureFlagsApiRef,
useAnalytics,
useRouteRefParams,
useApi,
} from '@backstage/core-plugin-api';
import { FormProps, IChangeEvent, UiSchema, withTheme } from '@rjsf/core';
import { FormProps, IChangeEvent, withTheme } from '@rjsf/core';
import { UiSchema } from '@rjsf/utils';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import React, { useState } from 'react';
import React, { ComponentType, useState } from 'react';
import { transformSchemaToProps } from './schema';
import { Content, StructuredMetadataTable } from '@backstage/core-components';
import cloneDeep from 'lodash/cloneDeep';
import * as fieldOverrides from './FieldOverrides';
import { LayoutOptions } from '../../layouts';
import { ReviewStepProps } from '../types';
import { ReviewStep } from './ReviewStep';
import { selectedTemplateRouteRef } from '../../routes';
import { extractSchemaFromStep } from '../../next/TemplateWizardPage/Stepper/schema';
const Form = withTheme(MuiTheme);
type Step = {
schema: JsonObject;
title: string;
} & Partial<Omit<FormProps<any>, 'schema'>>;
type Props = {
/**
* The props for a dynamic form of a scaffolder template.
*/
export type MultistepJsonFormProps = {
/**
* Steps for the form, each contains title and form schema
*/
@@ -60,62 +65,24 @@ type Props = {
fields?: FormProps<any>['fields'];
finishButtonLabel?: string;
layouts: LayoutOptions[];
ReviewStepComponent?: ComponentType<ReviewStepProps>;
};
export function getUiSchemasFromSteps(steps: Step[]): 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;
export function getSchemasFromSteps(steps: Step[]): {
uiSchema: UiSchema;
mergedSchema: JsonObject;
schema: JsonObject;
}[] {
return steps.map(({ schema }) => ({
mergedSchema: schema,
...extractSchemaFromStep(schema),
}));
}
export function getReviewData(formData: Record<string, any>, steps: Step[]) {
const uiSchemas = getUiSchemasFromSteps(steps);
const reviewData: Record<string, any> = {};
for (const key in formData) {
if (formData.hasOwnProperty(key)) {
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 const MultistepJsonForm = (props: Props) => {
/**
* Creates the dynamic form for a scaffolder template.
*/
export const MultistepJsonForm = (props: MultistepJsonFormProps) => {
const {
formData,
onChange,
@@ -123,8 +90,8 @@ export const MultistepJsonForm = (props: Props) => {
onFinish,
fields,
widgets,
finishButtonLabel,
layouts,
ReviewStepComponent,
} = props;
const { templateName } = useRouteRefParams(selectedTemplateRouteRef);
const analytics = useAnalytics();
@@ -197,6 +164,8 @@ export const MultistepJsonForm = (props: Props) => {
}
};
const ReviewStepElement = ReviewStepComponent ?? ReviewStep;
return (
<>
<Stepper activeStep={activeStep} orientation="vertical">
@@ -240,30 +209,14 @@ export const MultistepJsonForm = (props: Props) => {
})}
</Stepper>
{activeStep === steps.length && (
<Content>
<Paper square elevation={0}>
<Typography variant="h6">Review and create</Typography>
<StructuredMetadataTable
dense
metadata={getReviewData(formData, steps)}
/>
<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={!onFinish || disableButtons}
>
{finishButtonLabel ?? 'Create'}
</Button>
</Paper>
</Content>
<ReviewStepElement
disableButtons={disableButtons}
handleBack={handleBack}
handleCreate={handleCreate}
handleReset={handleReset}
formData={formData}
steps={getSchemasFromSteps(steps)}
/>
)}
</>
);
@@ -0,0 +1,121 @@
/*
* 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/core';
import { JsonObject } from '@backstage/types';
import { ReviewStepProps } from '../types';
export function getReviewData(
formData: Record<string, any>,
uiSchemas: UiSchema[],
) {
const reviewData: Record<string, any> = {};
for (const key in formData) {
if (formData.hasOwnProperty(key)) {
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))}
/>
<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>
);
};
+9 -5
View File
@@ -15,7 +15,7 @@
*/
import React, { ComponentType, useEffect } from 'react';
import { Routes, Route, useOutlet, Navigate } from 'react-router';
import { Navigate, Route, Routes, useOutlet } from 'react-router';
import { Entity } from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { ScaffolderPage } from './ScaffolderPage';
@@ -26,10 +26,10 @@ import { SecretsContextProvider } from './secrets/SecretsContext';
import { TemplateEditorPage } from './TemplateEditorPage';
import {
FieldExtensionOptions,
FIELD_EXTENSION_WRAPPER_KEY,
FIELD_EXTENSION_KEY,
DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS,
FIELD_EXTENSION_KEY,
FIELD_EXTENSION_WRAPPER_KEY,
FieldExtensionOptions,
} from '../extensions';
import {
useElementFilter,
@@ -46,6 +46,7 @@ import {
} from '../routes';
import { ListTasksPage } from './ListTasksPage';
import { LayoutOptions, LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from '../layouts';
import { ReviewStepProps } from './types';
/**
* The props for the entrypoint `ScaffolderPage` component the plugin.
@@ -53,6 +54,7 @@ import { LayoutOptions, LAYOUTS_KEY, LAYOUTS_WRAPPER_KEY } from '../layouts';
*/
export type RouterProps = {
components?: {
ReviewStepComponent?: ComponentType<ReviewStepProps>;
TemplateCardComponent?:
| ComponentType<{ template: TemplateEntityV1beta3 }>
| undefined;
@@ -87,7 +89,8 @@ export type RouterProps = {
export const Router = (props: RouterProps) => {
const { groups, components = {}, defaultPreviewTemplate } = props;
const { TemplateCardComponent, TaskPageComponent } = components;
const { ReviewStepComponent, TemplateCardComponent, TaskPageComponent } =
components;
const outlet = useOutlet();
const TaskPageElement = TaskPageComponent ?? TaskPage;
@@ -161,6 +164,7 @@ export const Router = (props: RouterProps) => {
element={
<SecretsContextProvider>
<TemplatePage
ReviewStepComponent={ReviewStepComponent}
customFieldExtensions={fieldExtensions}
layouts={customLayouts}
headerOptions={props.headerOptions}
@@ -16,7 +16,7 @@
import { LinearProgress } from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import qs from 'qs';
import React, { useCallback, useContext, useState } from 'react';
import React, { ComponentType, useCallback, useContext, useState } from 'react';
import { Navigate, useNavigate } from 'react-router';
import useAsync from 'react-use/lib/useAsync';
import { scaffolderApiRef } from '../../api';
@@ -41,6 +41,7 @@ import {
} from '@backstage/core-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { LayoutOptions } from '../../layouts';
import { ReviewStepProps } from '../types';
const useTemplateParameterSchema = (templateRef: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
@@ -52,6 +53,7 @@ const useTemplateParameterSchema = (templateRef: string) => {
};
type Props = {
ReviewStepComponent?: ComponentType<ReviewStepProps>;
customFieldExtensions?: FieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
headerOptions?: {
@@ -62,6 +64,7 @@ type Props = {
};
export const TemplatePage = ({
ReviewStepComponent,
customFieldExtensions = [],
layouts = [],
headerOptions,
@@ -155,6 +158,7 @@ export const TemplatePage = ({
titleTypographyProps={{ component: 'h2' }}
>
<MultistepJsonForm
ReviewStepComponent={ReviewStepComponent}
formData={formState}
fields={customFieldComponents}
onChange={handleChange}
@@ -19,3 +19,4 @@ export { TemplateTypePicker } from './TemplateTypePicker';
export * from './secrets';
export { TaskPage } from './TaskPage';
export type { RouterProps } from './Router';
export type { ReviewStepProps } from './types';
@@ -0,0 +1,37 @@
/*
* 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 { UiSchema } from '@rjsf/utils';
import { JsonObject } from '@backstage/types';
/**
* The props for the Last Step in scaffolder template form.
* Which represents the summary of the input provided by the end user.
*
* @public
*/
export type ReviewStepProps = {
disableButtons: boolean;
formData: JsonObject;
handleBack: () => void;
handleReset: () => void;
handleCreate: () => void;
steps: {
uiSchema: UiSchema;
mergedSchema: JsonObject;
schema: JsonObject;
}[];
};
@@ -120,7 +120,7 @@ export const EntityNamePickerFieldExtension = scaffolderPlugin.provide(
/**
* The field extension which provides the ability to select a RepositoryUrl.
* Currently this is an encoded URL that looks something like the following `github.com?repo=myRepoName&owner=backstage`.
* Currently, this is an encoded URL that looks something like the following `github.com?repo=myRepoName&owner=backstage`.
*
* @public
*/