diff --git a/.changeset/angry-trees-relax.md b/.changeset/angry-trees-relax.md new file mode 100644 index 0000000000..64dd50366c --- /dev/null +++ b/.changeset/angry-trees-relax.md @@ -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 + +``` diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 33a5f8c2fe..39e58a7a42 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -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; // @public export type RouterProps = { components?: { + ReviewStepComponent?: ComponentType; TemplateCardComponent?: | ComponentType<{ template: TemplateEntityV1beta3; diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.test.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.test.tsx index 21540a4895..3f8e153b65 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.test.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.test.tsx @@ -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('******'); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 7afbeb5a97..1fc63b2b61 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -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, '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['fields']; finishButtonLabel?: string; layouts: LayoutOptions[]; + ReviewStepComponent?: ComponentType; }; -export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] { - const uiSchemas: Array = []; - 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, steps: Step[]) { - const uiSchemas = getUiSchemasFromSteps(steps); - const reviewData: Record = {}; - 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 ( <> @@ -240,30 +209,14 @@ export const MultistepJsonForm = (props: Props) => { })} {activeStep === steps.length && ( - - - Review and create - - - - - - - + )} ); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/ReviewStep.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/ReviewStep.tsx new file mode 100644 index 0000000000..d78d726869 --- /dev/null +++ b/plugins/scaffolder/src/components/MultistepJsonForm/ReviewStep.tsx @@ -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, + uiSchemas: UiSchema[], +) { + const reviewData: Record = {}; + 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 = []; + 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 ( + + + Review and create + + + + + + + + ); +}; diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 9f46b427a0..d35bcbc67a 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -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; 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={ { const scaffolderApi = useApi(scaffolderApiRef); @@ -52,6 +53,7 @@ const useTemplateParameterSchema = (templateRef: string) => { }; type Props = { + ReviewStepComponent?: ComponentType; customFieldExtensions?: FieldExtensionOptions[]; 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' }} > void; + handleReset: () => void; + handleCreate: () => void; + steps: { + uiSchema: UiSchema; + mergedSchema: JsonObject; + schema: JsonObject; + }[]; +}; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.tsx similarity index 97% rename from plugins/scaffolder/src/plugin.ts rename to plugins/scaffolder/src/plugin.tsx index a34105745f..cfa7af8128 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.tsx @@ -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 */