diff --git a/.changeset/funny-rocks-pull.md b/.changeset/funny-rocks-pull.md new file mode 100644 index 0000000000..9145464137 --- /dev/null +++ b/.changeset/funny-rocks-pull.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': minor +'@backstage/plugin-scaffolder': minor +--- + +Added support for `omitExtraData` and `liveOmit` for rjsf in the scaffolder diff --git a/plugins/scaffolder-react/api-report.md b/plugins/scaffolder-react/api-report.md index a19a656d17..f73365f0ac 100644 --- a/plugins/scaffolder-react/api-report.md +++ b/plugins/scaffolder-react/api-report.md @@ -128,7 +128,12 @@ export interface FieldExtensionUiSchema // @public export type FormProps = Pick< FormProps_2, - 'transformErrors' | 'noHtml5Validate' | 'uiSchema' | 'formContext' + | 'transformErrors' + | 'noHtml5Validate' + | 'uiSchema' + | 'formContext' + | 'omitExtraData' + | 'liveOmit' >; // @public diff --git a/plugins/scaffolder-react/src/components/types.ts b/plugins/scaffolder-react/src/components/types.ts index d5d317015d..3df6209cad 100644 --- a/plugins/scaffolder-react/src/components/types.ts +++ b/plugins/scaffolder-react/src/components/types.ts @@ -32,7 +32,12 @@ export type TemplateGroupFilter = { */ export type FormProps = Pick< SchemaFormProps, - 'transformErrors' | 'noHtml5Validate' | 'uiSchema' | 'formContext' + | 'transformErrors' + | 'noHtml5Validate' + | 'uiSchema' + | 'formContext' + | 'omitExtraData' + | 'liveOmit' >; /** diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx index 78aef3428f..b2ec74c41a 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.test.tsx @@ -13,16 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { renderInTestApp } from '@backstage/test-utils'; +import { JsonValue } from '@backstage/types'; +import { act, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; + +import { LayoutTemplate } from '../../../layouts'; +import { SecretsContextProvider } from '../../../secrets'; import { TemplateParameterSchema } from '../../../types'; import { Stepper } from './Stepper'; -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 { FieldExtensionComponentProps } from '../../../extensions'; -import { SecretsContextProvider } from '../../../secrets'; -import { LayoutTemplate } from '../../../layouts'; describe('Stepper', () => { it('should render the step titles for each step of the manifest', async () => { @@ -63,7 +64,7 @@ describe('Stepper', () => { expect(getByRole('button', { name: 'Next' })).toBeInTheDocument(); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Next' })); + fireEvent.click(getByRole('button', { name: 'Next' })); }); expect(getByRole('button', { name: 'Review' })).toBeInTheDocument(); @@ -102,16 +103,15 @@ describe('Stepper', () => { , ); - await fireEvent.change(getByRole('textbox', { name: 'name' }), { - target: { value: 'im a test value' }, + await act(async () => { + fireEvent.change(getByRole('textbox', { name: 'name' }), { + target: { value: 'im a test value' }, + }); + fireEvent.click(getByRole('button', { name: 'Next' })); }); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Next' })); - }); - - await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Back' })); + fireEvent.click(getByRole('button', { name: 'Back' })); }); expect(getByRole('textbox', { name: 'name' })).toHaveValue( @@ -152,16 +152,15 @@ describe('Stepper', () => { , ); - await fireEvent.change(getByRole('textbox', { name: 'name' }), { - target: { value: 'im a test value' }, + await act(async () => { + fireEvent.change(getByRole('textbox', { name: 'name' }), { + target: { value: 'im a test value' }, + }); + fireEvent.click(getByRole('button', { name: 'Next' })); }); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Next' })); - }); - - await act(async () => { - await fireEvent.click(getByLabelText('Step 1')); + fireEvent.click(getByLabelText('Step 1')); }); expect(getByRole('textbox', { name: 'name' })).toHaveValue( @@ -169,38 +168,107 @@ describe('Stepper', () => { ); }); - it('should merge nested formData correctly in multiple steps', async () => { - const Repo = ({ - onChange, - }: FieldExtensionComponentProps<{ repository: string }, any>) => ( - onChange({ repository: e.target.value })} - defaultValue="" - /> + it('should omit properties that are no longer pertinent to the current step', async () => { + const manifest: TemplateParameterSchema = { + title: 'Conditional Input Form', + steps: [ + { + title: 'Conditional Input step', + schema: { + type: 'object', + properties: { + moreInfo: { + type: 'boolean', + title: 'More info', + }, + }, + dependencies: { + moreInfo: { + oneOf: [ + { + properties: { + moreInfo: { + const: true, + }, + description: { + type: 'string', + title: 'Description', + }, + }, + required: ['description'], + }, + { + properties: { + moreInfo: { + not: { + const: true, + }, + }, + }, + }, + ], + }, + }, + }, + }, + ], + }; + + const onCreate = jest.fn(); + + const { getByRole, queryByRole } = await renderInTestApp( + + + , ); - const Owner = ({ - onChange, - }: FieldExtensionComponentProps<{ owner: string }, any>) => ( - onChange({ owner: e.target.value })} - defaultValue="" - /> - ); + await act(async () => { + fireEvent.click(getByRole('checkbox', { name: 'More info' })); + fireEvent.change(getByRole('textbox', { name: 'Description' }), { + target: { value: 'My Test Description' }, + }); + fireEvent.click(getByRole('button', { name: 'Review' })); + }); + expect( + getByRole('cell', { name: 'My Test Description' }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(getByRole('button', { name: 'Back' })); + }); + + await act(async () => { + fireEvent.click(getByRole('checkbox', { name: 'More info' })); + fireEvent.click(getByRole('button', { name: 'Review' })); + }); + + expect( + queryByRole('cell', { name: 'My Test Description' }), + ).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.click(getByRole('button', { name: 'Create' })); + }); + + expect(onCreate).toHaveBeenCalledWith({ moreInfo: false }); + }); + + it('should merge and overwrite nested formData correctly', async () => { const manifest: TemplateParameterSchema = { steps: [ { title: 'Step 1', schema: { properties: { - first: { - type: 'object', - 'ui:field': 'Repo', + foo: { + type: 'string', + title: 'Foo - 1', }, }, }, @@ -209,9 +277,13 @@ describe('Stepper', () => { title: 'Step 2', schema: { properties: { - second: { - type: 'object', - 'ui:field': 'Owner', + foo: { + type: 'string', + title: 'Foo - 2', + }, + bar: { + type: 'string', + title: 'Bar - 2', }, }, }, @@ -220,47 +292,39 @@ describe('Stepper', () => { title: 'React JSON Schema Form Test', }; - const onCreate = jest.fn(async (values: Record) => { - expect(values).toEqual({ - first: { repository: 'Repo' }, - second: { owner: 'Owner' }, - }); - }); + const onCreate = jest.fn(); const { getByRole } = await renderInTestApp( - + , ); - await fireEvent.change(getByRole('textbox', { name: 'repo' }), { - target: { value: 'Repo' }, + await act(async () => { + fireEvent.change(getByRole('textbox', { name: 'Foo - 1' }), { + target: { value: 'value 1' }, + }); + fireEvent.click(getByRole('button', { name: 'Next' })); }); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Next' })); - }); - - await fireEvent.change(getByRole('textbox', { name: 'owner' }), { - target: { value: 'Owner' }, + fireEvent.change(getByRole('textbox', { name: 'Foo - 2' }), { + target: { value: 'value 2' }, + }); + fireEvent.change(getByRole('textbox', { name: 'Bar - 2' }), { + target: { value: 'value 2' }, + }); + fireEvent.click(getByRole('button', { name: 'Review' })); }); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Review' })); + fireEvent.click(getByRole('button', { name: 'Create' })); }); - await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Create' })); + expect(onCreate).toHaveBeenCalledWith({ + foo: 'value 2', + bar: 'value 2', }); - - expect(onCreate).toHaveBeenCalled(); }); it('should render custom field extensions properly', async () => { @@ -335,13 +399,11 @@ describe('Stepper', () => { /> , ); - - act(() => { - fireEvent.click(getByRole('button', { name: 'Review' })); + fireEvent.click(getByRole('button', { name: 'Review' })); + await waitFor(() => { + expect(getByRole('progressbar')).toBeInTheDocument(); // Check if progress bar is rendered + expect(getByRole('button', { name: 'Review' })).toBeDisabled(); // Check if the button is disabled }); - - expect(getByRole('progressbar')).toBeInTheDocument(); - expect(getByRole('button', { name: 'Review' })).toBeDisabled(); }); it('should transform default error message', async () => { @@ -381,12 +443,11 @@ describe('Stepper', () => { , ); - await fireEvent.change(getByRole('textbox', { name: 'postcode' }), { - target: { value: 'invalid' }, - }); - await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Review' })); + fireEvent.change(getByRole('textbox', { name: 'postcode' }), { + target: { value: 'invalid' }, + }); + fireEvent.click(getByRole('button', { name: 'Review' })); }); expect(getByText('invalid postcode')).toBeInTheDocument(); @@ -421,12 +482,12 @@ describe('Stepper', () => { , ); - await fireEvent.change(getByRole('textbox', { name: 'postcode' }), { - target: { value: 'invalid' }, - }); - await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Review' })); + fireEvent.change(getByRole('textbox', { name: 'postcode' }), { + target: { value: 'invalid' }, + }); + + fireEvent.click(getByRole('button', { name: 'Review' })); }); expect(getByText('invalid postcode')).toBeInTheDocument(); @@ -494,13 +555,13 @@ describe('Stepper', () => { ); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Review' })); + fireEvent.click(getByRole('button', { name: 'Review' })); }); expect(getByRole('button', { name: 'Create' })).toBeInTheDocument(); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Create' })); + fireEvent.click(getByRole('button', { name: 'Create' })); }); // flush promises @@ -539,13 +600,13 @@ describe('Stepper', () => { ); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Inspect' })); + fireEvent.click(getByRole('button', { name: 'Inspect' })); }); expect(getByRole('button', { name: 'Make' })).toBeInTheDocument(); await act(async () => { - await fireEvent.click(getByRole('button', { name: 'Make' })); + fireEvent.click(getByRole('button', { name: 'Make' })); }); }); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 3a8858144b..a1b31d0089 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -115,11 +115,16 @@ export const Stepper = (stepperProps: StepperProps) => { const apiHolder = useApiHolder(); const [activeStep, setActiveStep] = useState(0); const [isValidating, setIsValidating] = useState(false); - const [formState, setFormState] = useFormDataFromQuery(props.initialState); + const [initialState] = useFormDataFromQuery(props.initialState); + const [formState, setFormState] = useState<{ + [step: string]: Record; + }>(); const [errors, setErrors] = useState(); const styles = useStyles(); + const makeStepKey = (step: string | number) => `step-${step}`; + const backLabel = presentation?.buttonLabels?.backButtonText ?? backButtonText; const createLabel = @@ -155,16 +160,15 @@ export const Stepper = (stepperProps: StepperProps) => { }; const handleChange = useCallback( - (e: IChangeEvent) => - setFormState(current => ({ ...current, ...e.formData })), - [setFormState], + (e: IChangeEvent) => { + setFormState(current => ({ + ...current, + [makeStepKey(activeStep)]: e.formData, + })); + }, + [setFormState, activeStep], ); - const handleCreate = useCallback(() => { - props.onCreate(formState); - analytics.captureEvent('click', `${createLabel}`); - }, [props, formState, analytics, createLabel]); - const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts }); const handleNext = async ({ @@ -191,7 +195,10 @@ export const Stepper = (stepperProps: StepperProps) => { return stepNum; }); } - setFormState(current => ({ ...current, ...formData })); + setFormState(current => ({ + ...current, + [makeStepKey(activeStep)]: formData, + })); }; const { @@ -202,6 +209,24 @@ export const Stepper = (stepperProps: StepperProps) => { const mergedUiSchema = merge({}, propUiSchema, currentStep?.uiSchema); + const mergedState = useMemo(() => { + if (!formState) { + return initialState; + } + const { [makeStepKey(activeStep)]: activeState, ...historicalState } = + formState; + const chronologicalState = { + ...historicalState, + [makeStepKey(activeStep)]: activeState, + }; + return merge({}, ...Object.values(chronologicalState)); + }, [formState, activeStep, initialState]); + + const handleCreate = useCallback(() => { + props.onCreate(mergedState); + analytics.captureEvent('click', `${createLabel}`); + }, [props, mergedState, analytics, createLabel]); + return ( <> {isValidating && } @@ -237,8 +262,8 @@ export const Stepper = (stepperProps: StepperProps) => {
{ ReviewStepComponent ? ( {}} steps={steps} @@ -282,7 +307,7 @@ export const Stepper = (stepperProps: StepperProps) => { /> ) : ( <> - +
@@ -206,7 +209,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { export function TemplateEditorFormDirectoryEditorDryRun( props: Pick< TemplateEditorFormProps, - 'setErrorText' | 'fieldExtensions' | 'layouts' + 'setErrorText' | 'fieldExtensions' | 'layouts' | 'formProps' >, ) { const { setErrorText, fieldExtensions = [], layouts } = props; @@ -245,6 +248,7 @@ export function TemplateEditorFormDirectoryEditorDryRun( setErrorText={setErrorText} content={content} layouts={layouts} + formProps={props.formProps} /> ); } diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx index 6e7c921e91..03d656c98d 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateEditorPage.tsx @@ -24,6 +24,7 @@ import { TemplateEditor } from './TemplateEditor'; import { TemplateFormPreviewer } from './TemplateFormPreviewer'; import { FieldExtensionOptions, + FormProps, type LayoutOptions, } from '@backstage/plugin-scaffolder-react'; import { TemplateEditorIntro } from './TemplateEditorIntro'; @@ -54,6 +55,7 @@ interface TemplateEditorPageProps { defaultPreviewTemplate?: string; customFieldExtensions?: FieldExtensionOptions[]; layouts?: LayoutOptions[]; + formProps?: FormProps; } export function TemplateEditorPage(props: TemplateEditorPageProps) { @@ -79,6 +81,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { fieldExtensions={props.customFieldExtensions} onClose={() => setSelection(undefined)} layouts={props.layouts} + formProps={props.formProps} /> ); } else if (selection?.type === 'form') { @@ -88,6 +91,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { customFieldExtensions={props.customFieldExtensions} onClose={() => setSelection(undefined)} layouts={props.layouts} + formProps={props.formProps} /> ); } else if (selection?.type === 'field-explorer') { diff --git a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx index 07f2fd7f07..610d2a22f0 100644 --- a/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/next/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -33,6 +33,7 @@ import yaml from 'yaml'; import { LayoutOptions, FieldExtensionOptions, + FormProps, } from '@backstage/plugin-scaffolder-react'; import { TemplateEditorForm } from './TemplateEditorForm'; import { TemplateEditorTextArea } from './TemplateEditorTextArea'; @@ -114,11 +115,13 @@ export const TemplateFormPreviewer = ({ customFieldExtensions = [], onClose, layouts = [], + formProps, }: { defaultPreviewTemplate?: string; customFieldExtensions?: FieldExtensionOptions[]; onClose?: () => void; layouts?: LayoutOptions[]; + formProps?: FormProps; }) => { const classes = useStyles(); const { t } = useTranslationRef(scaffolderTranslationRef); @@ -213,6 +216,7 @@ export const TemplateFormPreviewer = ({ fieldExtensions={customFieldExtensions} setErrorText={setErrorText} layouts={layouts} + formProps={formProps} />