Merge pull request #12952 from backstage/blam/next-scaffolder/wizard-2
scaffolder(next): Support `async` validation for `CustomFieldExtensions` and run validation for `type: object`
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Added support for `async` validation for the `next` version of the plugin
|
||||
@@ -91,7 +91,10 @@ import { apis } from './apis';
|
||||
import { entityPage } from './components/catalog/EntityPage';
|
||||
import { homePage } from './components/home/HomePage';
|
||||
import { Root } from './components/Root';
|
||||
import { LowerCaseValuePickerFieldExtension } from './components/scaffolder/customScaffolderExtensions';
|
||||
import {
|
||||
DelayingComponentFieldExtension,
|
||||
LowerCaseValuePickerFieldExtension,
|
||||
} from './components/scaffolder/customScaffolderExtensions';
|
||||
import { defaultPreviewTemplate } from './components/scaffolder/defaultPreviewTemplate';
|
||||
import { searchPage } from './components/search/SearchPage';
|
||||
import { providers } from './identityProviders';
|
||||
@@ -228,7 +231,7 @@ const routes = (
|
||||
}
|
||||
>
|
||||
<ScaffolderFieldExtensions>
|
||||
<LowerCaseValuePickerFieldExtension />
|
||||
<DelayingComponentFieldExtension />
|
||||
</ScaffolderFieldExtensions>
|
||||
</Route>
|
||||
<Route path="/explore" element={<ExplorePage />} />
|
||||
|
||||
@@ -62,3 +62,37 @@ export const LowerCaseValuePickerFieldExtension = scaffolderPlugin.provide(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const MockDelayComponent = (
|
||||
props: FieldExtensionComponentProps<{ test?: string }>,
|
||||
) => {
|
||||
const { onChange, formData, rawErrors } = props;
|
||||
return (
|
||||
<TextField
|
||||
label="test"
|
||||
helperText="description"
|
||||
value={formData?.test ?? ''}
|
||||
onChange={({ target: { value } }) => onChange({ test: value })}
|
||||
margin="normal"
|
||||
error={rawErrors?.length > 0 && !formData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DelayingComponentFieldExtension = scaffolderPlugin.provide(
|
||||
createScaffolderFieldExtension({
|
||||
name: 'DelayingComponent',
|
||||
component: MockDelayComponent,
|
||||
validation: async (
|
||||
value: { test?: string },
|
||||
validation: FieldValidation,
|
||||
) => {
|
||||
// delay 2 seconds
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
if (value.test !== 'pass') {
|
||||
validation.addError('value was not equal to pass');
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ export type CustomFieldValidator<TFieldReturnValue> = (
|
||||
context: {
|
||||
apiHolder: ApiHolder;
|
||||
},
|
||||
) => void;
|
||||
) => void | Promise<void>;
|
||||
|
||||
// @public
|
||||
export const EntityNamePickerFieldExtension: FieldExtensionComponent<
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"humanize-duration": "^3.25.1",
|
||||
"immer": "^9.0.1",
|
||||
"json-schema": "^0.4.0",
|
||||
"json-schema-library": "^6.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"qs": "^6.9.4",
|
||||
|
||||
@@ -25,7 +25,7 @@ export type CustomFieldValidator<TFieldReturnValue> = (
|
||||
data: TFieldReturnValue,
|
||||
field: FieldValidation,
|
||||
context: { apiHolder: ApiHolder },
|
||||
) => void;
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Type for the Custom Field Extension with the
|
||||
|
||||
@@ -17,7 +17,7 @@ import React from 'react';
|
||||
import { TemplateParameterSchema } from '../../../types';
|
||||
import { Stepper } from './Stepper';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
|
||||
describe('Stepper', () => {
|
||||
it('should render the step titles for each step of the manifest', async () => {
|
||||
@@ -53,7 +53,9 @@ describe('Stepper', () => {
|
||||
|
||||
expect(getByText('Next')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(getByText('Next'));
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByText('Next'));
|
||||
});
|
||||
|
||||
expect(getByText('Review')).toBeInTheDocument();
|
||||
});
|
||||
@@ -93,9 +95,13 @@ describe('Stepper', () => {
|
||||
target: { value: 'im a test value' },
|
||||
});
|
||||
|
||||
await fireEvent.click(getByText('Next'));
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByText('Next'));
|
||||
});
|
||||
|
||||
await fireEvent.click(getByText('Back'));
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByText('Back'));
|
||||
});
|
||||
|
||||
expect(getByRole('textbox', { name: 'name' })).toHaveValue(
|
||||
'im a test value',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useApiHolder } from '@backstage/core-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import {
|
||||
Stepper as MuiStepper,
|
||||
@@ -21,11 +22,12 @@ import {
|
||||
Button,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { withTheme } from '@rjsf/core';
|
||||
import { FieldValidation, withTheme } from '@rjsf/core';
|
||||
import { Theme as MuiTheme } from '@rjsf/material-ui';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { FieldExtensionOptions } from '../../../extensions';
|
||||
import { TemplateParameterSchema } from '../../../types';
|
||||
import { createAsyncValidators } from './createAsyncValidators';
|
||||
import { useTemplateSchema } from './useTemplateSchema';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -51,8 +53,12 @@ const Form = withTheme(MuiTheme);
|
||||
|
||||
export const Stepper = (props: StepperProps) => {
|
||||
const { steps } = useTemplateSchema(props.manifest);
|
||||
const apiHolder = useApiHolder();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [formState, setFormState] = useState({});
|
||||
const [errors, setErrors] = useState<
|
||||
undefined | Record<string, FieldValidation>
|
||||
>();
|
||||
const styles = useStyles();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
@@ -61,12 +67,40 @@ export const Stepper = (props: StepperProps) => {
|
||||
);
|
||||
}, [props.extensions]);
|
||||
|
||||
const validators = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
props.extensions.map(({ name, validation }) => [name, validation]),
|
||||
);
|
||||
}, [props.extensions]);
|
||||
|
||||
const validation = useMemo(() => {
|
||||
const { mergedSchema } = steps[activeStep];
|
||||
return createAsyncValidators(mergedSchema, validators, {
|
||||
apiHolder,
|
||||
});
|
||||
}, [steps, activeStep, validators, apiHolder]);
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep(prevActiveStep => prevActiveStep - 1);
|
||||
};
|
||||
|
||||
const handleNext = ({ formData }: { formData: JsonObject }) => {
|
||||
setActiveStep(prevActiveStep => prevActiveStep + 1);
|
||||
const handleNext = async ({ formData }: { formData: JsonObject }) => {
|
||||
// TODO(blam): What do we do about loading states, does each field extension get a chance
|
||||
// to display it's own loading? Or should we grey out the entire form.
|
||||
setErrors(undefined);
|
||||
|
||||
const returnedValidation = await validation(formData);
|
||||
|
||||
const hasErrors = Object.values(returnedValidation).some(i => {
|
||||
return i.__errors.length > 0;
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
setErrors(returnedValidation);
|
||||
} else {
|
||||
setErrors(undefined);
|
||||
setActiveStep(prevActiveStep => prevActiveStep + 1);
|
||||
}
|
||||
setFormState(current => ({ ...current, ...formData }));
|
||||
};
|
||||
|
||||
@@ -81,6 +115,7 @@ export const Stepper = (props: StepperProps) => {
|
||||
</MuiStepper>
|
||||
<div className={styles.formWrapper}>
|
||||
<Form
|
||||
extraErrors={errors}
|
||||
formData={formState}
|
||||
schema={steps[activeStep].schema}
|
||||
uiSchema={steps[activeStep].uiSchema}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2022 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 { JsonObject } from '@backstage/types';
|
||||
import { CustomFieldValidator } from '../../../extensions';
|
||||
import { createAsyncValidators } from './createAsyncValidators';
|
||||
|
||||
describe('createAsyncValidators', () => {
|
||||
it('should call the correct functions for validation', async () => {
|
||||
const schema: JsonObject = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'ui:field': 'NameField',
|
||||
},
|
||||
address: {
|
||||
type: 'object',
|
||||
'ui:field': 'AddressField',
|
||||
properties: {
|
||||
street: {
|
||||
type: 'string',
|
||||
},
|
||||
postcode: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validators = { NameField: jest.fn(), AddressField: jest.fn() };
|
||||
|
||||
const validate = createAsyncValidators(schema, validators, {
|
||||
apiHolder: { get: jest.fn() },
|
||||
});
|
||||
|
||||
await validate({
|
||||
name: 'asd',
|
||||
address: { street: 'street', postcode: 'postcode' },
|
||||
});
|
||||
|
||||
expect(validators.NameField).toHaveBeenCalled();
|
||||
expect(validators.AddressField).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the correct errors to the frontend', async () => {
|
||||
const schema: JsonObject = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'ui:field': 'NameField',
|
||||
},
|
||||
address: {
|
||||
type: 'object',
|
||||
'ui:field': 'AddressField',
|
||||
properties: {
|
||||
street: {
|
||||
type: 'string',
|
||||
},
|
||||
postcode: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const NameField: CustomFieldValidator<string> = (value, { addError }) => {
|
||||
if (!value) {
|
||||
addError('something is broken here!');
|
||||
}
|
||||
};
|
||||
|
||||
const AddressField: CustomFieldValidator<{
|
||||
street?: string;
|
||||
postcode?: string;
|
||||
}> = (value, { addError }) => {
|
||||
if (!value.postcode) {
|
||||
addError('postcode is missing!');
|
||||
}
|
||||
|
||||
if (!value.street) {
|
||||
addError('street is missing here!');
|
||||
}
|
||||
};
|
||||
|
||||
const validate = createAsyncValidators(
|
||||
schema,
|
||||
{
|
||||
NameField: NameField as CustomFieldValidator<unknown>,
|
||||
AddressField: AddressField as CustomFieldValidator<unknown>,
|
||||
},
|
||||
{
|
||||
apiHolder: { get: jest.fn() },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
validate({
|
||||
name: 'asd',
|
||||
address: { street: 'street', postcode: 'postcode' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: expect.objectContaining({
|
||||
__errors: [],
|
||||
}),
|
||||
address: expect.objectContaining({
|
||||
__errors: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
validate({
|
||||
name: 'asd',
|
||||
address: { street: '', postcode: 'postcode' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: expect.objectContaining({
|
||||
__errors: [],
|
||||
}),
|
||||
address: expect.objectContaining({
|
||||
__errors: ['street is missing here!'],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
validate({
|
||||
name: '',
|
||||
address: { street: '', postcode: '' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: expect.objectContaining({
|
||||
__errors: ['something is broken here!'],
|
||||
}),
|
||||
address: expect.objectContaining({
|
||||
__errors: ['postcode is missing!', 'street is missing here!'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2022 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 { FieldValidation } from '@rjsf/core';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { CustomFieldValidator } from '../../../extensions';
|
||||
import { Draft07 as JSONSchema } from 'json-schema-library';
|
||||
import { createFieldValidation } from './schema';
|
||||
|
||||
export const createAsyncValidators = (
|
||||
rootSchema: JsonObject,
|
||||
validators: Record<string, undefined | CustomFieldValidator<unknown>>,
|
||||
context: {
|
||||
apiHolder: ApiHolder;
|
||||
},
|
||||
) => {
|
||||
async function validate(formData: JsonObject, pathPrefix: string = '#') {
|
||||
const parsedSchema = new JSONSchema(rootSchema);
|
||||
const formValidation: Record<string, FieldValidation> = {};
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
const definitionInSchema = parsedSchema.getSchema(
|
||||
`${pathPrefix}/${key}`,
|
||||
formData,
|
||||
);
|
||||
|
||||
if (definitionInSchema && 'ui:field' in definitionInSchema) {
|
||||
const validator = validators[definitionInSchema['ui:field']];
|
||||
if (validator) {
|
||||
const fieldValidation = createFieldValidation();
|
||||
try {
|
||||
await validator(value, fieldValidation, context);
|
||||
} catch (ex) {
|
||||
fieldValidation.addError(ex.message);
|
||||
}
|
||||
formValidation[key] = fieldValidation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formValidation;
|
||||
}
|
||||
|
||||
return async (formData: JsonObject) => {
|
||||
return await validate(formData);
|
||||
};
|
||||
};
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { UiSchema } from '@rjsf/core';
|
||||
import { FieldValidation, UiSchema } from '@rjsf/core';
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
@@ -110,3 +110,18 @@ export const extractSchemaFromStep = (
|
||||
extractUiSchema(returnSchema, uiSchema);
|
||||
return { uiSchema, schema: returnSchema };
|
||||
};
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* Creates a field validation object for use in react jsonschema form
|
||||
*/
|
||||
export const createFieldValidation = (): FieldValidation => {
|
||||
const fieldValidation: FieldValidation = {
|
||||
__errors: [] as string[],
|
||||
addError: (message: string) => {
|
||||
fieldValidation.__errors.push(message);
|
||||
},
|
||||
};
|
||||
|
||||
return fieldValidation;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export const useTemplateSchema = (
|
||||
): {
|
||||
steps: {
|
||||
uiSchema: UiSchema;
|
||||
mergedSchema: JsonObject;
|
||||
schema: JsonObject;
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -33,6 +34,7 @@ export const useTemplateSchema = (
|
||||
const steps = manifest.steps.map(({ title, description, schema }) => ({
|
||||
title,
|
||||
description,
|
||||
mergedSchema: schema,
|
||||
...extractSchemaFromStep(schema),
|
||||
}));
|
||||
|
||||
|
||||
@@ -12254,6 +12254,11 @@ easy-table@1.1.0:
|
||||
optionalDependencies:
|
||||
wcwidth ">=1.0.1"
|
||||
|
||||
ebnf@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.npmjs.org/ebnf/-/ebnf-1.9.0.tgz#9c2dd6052f3ed43a69c1f0b07b15bd03cefda764"
|
||||
integrity sha512-LKK899+j758AgPq00ms+y90mo+2P86fMKUWD28sH0zLKUj7aL6iIH2wy4jejAMM9I2BawJ+2kp6C3mMXj+Ii5g==
|
||||
|
||||
ecc-jsbn@~0.1.1:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
|
||||
@@ -14651,6 +14656,25 @@ grpc-docs@^1.0.6, grpc-docs@^1.1.2:
|
||||
rollup-plugin-smart-asset "^2.1.2"
|
||||
styled-components "^5.3.3"
|
||||
|
||||
gson-conform@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.npmjs.org/gson-conform/-/gson-conform-1.0.3.tgz#6f982f98ea84199280bd48b6bfbcd0ae7447f1e2"
|
||||
integrity sha512-gaZQN/5ZbohkLBOs4JKHkg/IdOB9kuuEr5SVLAjHUs+Q+Nl746DRe18GgQy4oxxVXStO//Zga2wg4eWL9Mfshw==
|
||||
|
||||
gson-pointer@4.1.1, gson-pointer@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmjs.org/gson-pointer/-/gson-pointer-4.1.1.tgz#088d6aa38d52753530288d13b5a75b6a43170e9b"
|
||||
integrity sha512-rOS/zCLUI8BT0+/U+p5nzI0RfhIyRmzkpwzyCj8gcLoRl3rHuysk6ci1IQ955AQQZDSNN3mVec26Sx9wRZ0EjA==
|
||||
|
||||
gson-query@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.npmjs.org/gson-query/-/gson-query-5.1.0.tgz#8f34a062849f8c08be0c35eddaa2ad18a4326a49"
|
||||
integrity sha512-xKb/90XfmLLKGgX8Y7LRF4yKnMR/ckV5WOQ/Ip0pRXuDh7jUhdY1UYjPvQnF7/UMbNsAo0168j2j0yt9+4QdaA==
|
||||
dependencies:
|
||||
ebnf "^1.9.0"
|
||||
gson-conform "^1.0.3"
|
||||
gson-pointer "4.1.1"
|
||||
|
||||
gtoken@^6.0.0:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/gtoken/-/gtoken-6.0.1.tgz#1276371d51e93c4eface76e3f30f9e8f1cad3f1a"
|
||||
@@ -16989,6 +17013,17 @@ json-schema-compare@^0.2.2:
|
||||
dependencies:
|
||||
lodash "^4.17.4"
|
||||
|
||||
json-schema-library@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.npmjs.org/json-schema-library/-/json-schema-library-6.0.0.tgz#2a823d850031842c6917f41a8c576af3b603ab48"
|
||||
integrity sha512-1nTpSmyfmgJpaNPIBO1SnHpXiMUh76hZnqSuCrJr7Lcu2N2SB55UPgf5F790r4dy4i4o/Moaw7OdqDhEmxP/Rw==
|
||||
dependencies:
|
||||
deepmerge "^4.2.2"
|
||||
fast-deep-equal "^3.1.3"
|
||||
gson-pointer "^4.1.1"
|
||||
gson-query "^5.1.0"
|
||||
valid-url "^1.0.9"
|
||||
|
||||
json-schema-merge-allof@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.6.0.tgz#64d48820fec26b228db837475ce3338936bf59a5"
|
||||
@@ -25926,6 +25961,11 @@ v8-to-istanbul@^8.1.0:
|
||||
convert-source-map "^1.6.0"
|
||||
source-map "^0.7.3"
|
||||
|
||||
valid-url@^1.0.9:
|
||||
version "1.0.9"
|
||||
resolved "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
|
||||
integrity sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==
|
||||
|
||||
validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
|
||||
|
||||
Reference in New Issue
Block a user