diff --git a/.changeset/metal-hotels-deliver.md b/.changeset/metal-hotels-deliver.md
new file mode 100644
index 0000000000..6b7f9c109b
--- /dev/null
+++ b/.changeset/metal-hotels-deliver.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder': patch
+---
+
+add `onChange` handler to`Stepper` component
diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx
index af86abf1c6..2660eed47d 100644
--- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx
+++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx
@@ -20,6 +20,7 @@ import { renderInTestApp } from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import type { RJSFValidationError } from '@rjsf/utils';
import { JsonValue } from '@backstage/types';
+import { NextFieldExtensionComponentProps } from '../../../extensions/types';
describe('Stepper', () => {
it('should render the step titles for each step of the manifest', async () => {
@@ -110,6 +111,98 @@ describe('Stepper', () => {
);
});
+ it('should merge nested formData correctly in multiple steps', async () => {
+ const Repo = ({
+ onChange,
+ }: NextFieldExtensionComponentProps<{ repository: string }, any>) => (
+ onChange({ repository: e.target.value })}
+ defaultValue=""
+ />
+ );
+
+ const Owner = ({
+ onChange,
+ }: NextFieldExtensionComponentProps<{ owner: string }, any>) => (
+ onChange({ owner: e.target.value })}
+ defaultValue=""
+ />
+ );
+
+ const manifest: TemplateParameterSchema = {
+ steps: [
+ {
+ title: 'Step 1',
+ schema: {
+ properties: {
+ first: {
+ type: 'object',
+ 'ui:field': 'Repo',
+ },
+ },
+ },
+ },
+ {
+ title: 'Step 2',
+ schema: {
+ properties: {
+ second: {
+ type: 'object',
+ 'ui:field': 'Owner',
+ },
+ },
+ },
+ },
+ ],
+ title: 'React JSON Schema Form Test',
+ };
+
+ const onComplete = jest.fn(async (values: Record) => {
+ expect(values).toEqual({
+ first: { repository: 'Repo' },
+ second: { owner: 'Owner' },
+ });
+ });
+
+ const { getByRole } = await renderInTestApp(
+ ,
+ );
+
+ await fireEvent.change(getByRole('textbox', { name: 'repo' }), {
+ target: { value: 'Repo' },
+ });
+
+ await act(async () => {
+ await fireEvent.click(getByRole('button', { name: 'Next' }));
+ });
+
+ await fireEvent.change(getByRole('textbox', { name: 'owner' }), {
+ target: { value: 'Owner' },
+ });
+
+ await act(async () => {
+ await fireEvent.click(getByRole('button', { name: 'Review' }));
+ });
+
+ await act(async () => {
+ await fireEvent.click(getByRole('button', { name: 'Create' }));
+ });
+
+ expect(onComplete).toHaveBeenCalled();
+ });
+
it('should render custom field extensions properly', async () => {
const MockComponent = () => {
return im a custom field extension
;
diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx
index 3d5cb1bfd2..d4d57eed22 100644
--- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx
+++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx
@@ -26,9 +26,9 @@ import {
Button,
makeStyles,
} from '@material-ui/core';
-import { withTheme } from '@rjsf/core-v5';
+import { type IChangeEvent, withTheme } from '@rjsf/core-v5';
import { ErrorSchema, FieldValidation } from '@rjsf/utils';
-import React, { useMemo, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import { NextFieldExtensionOptions } from '../../../extensions';
import { TemplateParameterSchema } from '../../../types';
import { createAsyncValidators } from './createAsyncValidators';
@@ -36,7 +36,6 @@ import { useTemplateSchema } from './useTemplateSchema';
import { ReviewState } from './ReviewState';
import validator from '@rjsf/validator-ajv6';
import { selectedTemplateRouteRef } from '../../../routes';
-import { getDefaultFormState } from '@rjsf/utils';
import { useFormData } from './useFormData';
import { FormProps } from '../../types';
@@ -102,6 +101,12 @@ export const Stepper = (props: StepperProps) => {
setActiveStep(prevActiveStep => prevActiveStep - 1);
};
+ const handleChange = useCallback(
+ (e: IChangeEvent) =>
+ setFormState(current => ({ ...current, ...e.formData })),
+ [setFormState],
+ );
+
const handleNext = async ({
formData,
}: {
@@ -111,18 +116,7 @@ export const Stepper = (props: StepperProps) => {
// to display it's own loading? Or should we grey out the entire form.
setErrors(undefined);
- const schema = steps[activeStep]?.schema;
- const rootSchema = steps[activeStep]?.mergedSchema;
-
- const newFormData = getDefaultFormState(
- validator,
- schema,
- formData,
- rootSchema,
- true,
- );
-
- const returnedValidation = await validation(newFormData);
+ const returnedValidation = await validation(formData);
const hasErrors = Object.values(returnedValidation).some(
i => i.__errors?.length,
@@ -138,7 +132,7 @@ export const Stepper = (props: StepperProps) => {
return stepNum;
});
}
- setFormState(current => ({ ...current, ...newFormData }));
+ setFormState(current => ({ ...current, ...formData }));
};
return (
@@ -165,6 +159,7 @@ export const Stepper = (props: StepperProps) => {
onSubmit={handleNext}
fields={extensions}
showErrorList={false}
+ onChange={handleChange}
{...(props.FormProps ?? {})}
>