Merge pull request #26363 from jboeijenga/feat/support-omitExtraData

Add support for omitExtraData and liveOmit properties in scaffolder.
This commit is contained in:
Ben Lambert
2024-09-05 16:09:09 +02:00
committed by GitHub
10 changed files with 231 additions and 111 deletions
+6 -1
View File
@@ -128,7 +128,12 @@ export interface FieldExtensionUiSchema<TFieldReturnValue, TUiOptions>
// @public
export type FormProps = Pick<
FormProps_2,
'transformErrors' | 'noHtml5Validate' | 'uiSchema' | 'formContext'
| 'transformErrors'
| 'noHtml5Validate'
| 'uiSchema'
| 'formContext'
| 'omitExtraData'
| 'liveOmit'
>;
// @public
@@ -32,7 +32,12 @@ export type TemplateGroupFilter = {
*/
export type FormProps = Pick<
SchemaFormProps,
'transformErrors' | 'noHtml5Validate' | 'uiSchema' | 'formContext'
| 'transformErrors'
| 'noHtml5Validate'
| 'uiSchema'
| 'formContext'
| 'omitExtraData'
| 'liveOmit'
>;
/**
@@ -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', () => {
</SecretsContextProvider>,
);
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', () => {
</SecretsContextProvider>,
);
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>) => (
<input
aria-label="repo"
type="text"
onChange={e => 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(
<SecretsContextProvider>
<Stepper
manifest={manifest}
onCreate={onCreate}
extensions={[]}
formProps={{ omitExtraData: true, liveOmit: true }}
/>
</SecretsContextProvider>,
);
const Owner = ({
onChange,
}: FieldExtensionComponentProps<{ owner: string }, any>) => (
<input
aria-label="owner"
type="text"
onChange={e => 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<string, JsonValue>) => {
expect(values).toEqual({
first: { repository: 'Repo' },
second: { owner: 'Owner' },
});
});
const onCreate = jest.fn();
const { getByRole } = await renderInTestApp(
<SecretsContextProvider>
<Stepper
manifest={manifest}
onCreate={onCreate}
extensions={[
{ name: 'Repo', component: Repo },
{ name: 'Owner', component: Owner },
]}
/>
<Stepper manifest={manifest} onCreate={onCreate} extensions={[]} />
</SecretsContextProvider>,
);
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', () => {
/>
</SecretsContextProvider>,
);
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', () => {
</SecretsContextProvider>,
);
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', () => {
</SecretsContextProvider>,
);
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' }));
});
});
@@ -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<string, JsonValue>;
}>();
const [errors, setErrors] = useState<undefined | FormValidation>();
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 && <LinearProgress variant="indeterminate" />}
@@ -237,8 +262,8 @@ export const Stepper = (stepperProps: StepperProps) => {
<Form
validator={validator}
extraErrors={errors as unknown as ErrorSchema}
formData={formState}
formContext={{ ...propFormContext, formData: formState }}
formData={mergedState}
formContext={{ ...propFormContext, formData: mergedState }}
schema={currentStep.schema}
uiSchema={mergedUiSchema}
onSubmit={handleNext}
@@ -274,7 +299,7 @@ export const Stepper = (stepperProps: StepperProps) => {
ReviewStepComponent ? (
<ReviewStepComponent
disableButtons={isValidating}
formData={formState}
formData={mergedState}
handleBack={handleBack}
handleReset={() => {}}
steps={steps}
@@ -282,7 +307,7 @@ export const Stepper = (stepperProps: StepperProps) => {
/>
) : (
<>
<ReviewStateComponent formState={formState} schemas={steps} />
<ReviewStateComponent formState={mergedState} schemas={steps} />
<div className={styles.footer}>
<Button
onClick={handleBack}