From d326058eb9676c869f7679ea06b46d871412a364 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 29 Jul 2022 15:21:13 +0200 Subject: [PATCH 01/11] chore: added a simple case for async validation and testing delay with a component Signed-off-by: blam --- packages/app/src/App.tsx | 7 ++-- .../scaffolder/customScaffolderExtensions.tsx | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3f748813f5..a0390d96e8 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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 = ( } > - + } /> diff --git a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx index 61407e2918..2a4dedf080 100644 --- a/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx +++ b/packages/app/src/components/scaffolder/customScaffolderExtensions.tsx @@ -62,3 +62,37 @@ export const LowerCaseValuePickerFieldExtension = scaffolderPlugin.provide( }, }), ); + +const MockDelayComponent = ( + props: FieldExtensionComponentProps<{ test?: string }>, +) => { + const { onChange, formData, rawErrors } = props; + return ( + 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'); + } + }, + }), +); From 6c8c1a41402caa8aa500b29ec4ac9e3acad2dcf7 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 29 Jul 2022 15:22:11 +0200 Subject: [PATCH 02/11] chore: starting to add async validation to the next scaffolder Signed-off-by: blam --- plugins/scaffolder/src/extensions/types.ts | 2 +- .../TemplateWizardPage/Stepper/Stepper.tsx | 27 +++++- .../Stepper/createAsyncValidators.ts | 83 +++++++++++++++++++ .../Stepper/useTemplateSchema.ts | 3 + 4 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts diff --git a/plugins/scaffolder/src/extensions/types.ts b/plugins/scaffolder/src/extensions/types.ts index be8dc14b0a..9b42ba478b 100644 --- a/plugins/scaffolder/src/extensions/types.ts +++ b/plugins/scaffolder/src/extensions/types.ts @@ -25,7 +25,7 @@ export type CustomFieldValidator = ( data: TFieldReturnValue, field: FieldValidation, context: { apiHolder: ApiHolder }, -) => void; +) => void | Promise; /** * Type for the Custom Field Extension with the diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx index 19335d4993..240139ae5c 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -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, FormValidation, 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 { createAsyncValidator } from './createAsyncValidators'; import { useTemplateSchema } from './useTemplateSchema'; const useStyles = makeStyles(theme => ({ @@ -51,6 +53,7 @@ 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 styles = useStyles(); @@ -61,11 +64,31 @@ export const Stepper = (props: StepperProps) => { ); }, [props.extensions]); + const validators = useMemo(() => { + return Object.fromEntries( + props.extensions.map(({ name, validation }) => [name, validation]), + ); + }, [props.extensions]); + + const validator = useMemo(() => { + return createAsyncValidator(steps[activeStep].originalSchema, validators, { + apiHolder, + }); + }, [steps, activeStep, validators, apiHolder]); + const handleBack = () => { setActiveStep(prevActiveStep => prevActiveStep - 1); }; - const handleNext = ({ formData }: { formData: JsonObject }) => { + const handleNext = async ({ formData }: { formData: JsonObject }) => { + console.log('running validation'); + + const errors = await validator(formData, { + addError: () => {}, + __errors: [], + } as any); + + console.log('errors'); setActiveStep(prevActiveStep => prevActiveStep + 1); setFormState(current => ({ ...current, ...formData })); }; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts new file mode 100644 index 0000000000..613deb57b3 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts @@ -0,0 +1,83 @@ +/* + * 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 { FormValidation } from '@rjsf/core'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { ApiHolder } from '@backstage/core-plugin-api'; +import { CustomFieldValidator } from '../../../extensions'; + +function isObject(obj: unknown): obj is JsonObject { + return typeof obj === 'object' && obj !== null && !Array.isArray(obj); +} + +export const createAsyncValidator = ( + rootSchema: JsonObject, + validators: Record>, + context: { + apiHolder: ApiHolder; + }, +) => { + async function validate( + schema: JsonObject, + formData: JsonObject, + errors: FormValidation, + ) { + const schemaProps = schema.properties; + const customObject = schema.type === 'object' && schemaProps === undefined; + + if (!isObject(schemaProps) && !customObject) { + return; + } + + if (schemaProps) { + for (const [key, propData] of Object.entries(formData)) { + const propValidation = errors[key]; + + if (isObject(propData)) { + const propSchemaProps = schemaProps[key]; + if (isObject(propSchemaProps)) { + await validate( + propSchemaProps, + propData as JsonObject, + propValidation as FormValidation, + ); + } + } else { + const propSchema = schemaProps[key]; + const fieldName = + isObject(propSchema) && (propSchema['ui:field'] as string); + if (fieldName && typeof validators[fieldName] === 'function') { + await validators[fieldName]!( + propData as JsonValue, + propValidation, + context, + ); + } + } + } + } else if (customObject) { + const fieldName = schema['ui:field'] as string; + if (fieldName && typeof validators[fieldName] === 'function') { + await validators[fieldName]!(formData, errors, context); + } + } + } + + return async (formData: JsonObject, errors: FormValidation) => { + await validate(rootSchema, formData, errors); + return errors; + }; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts index d5fe244a25..dac2c7b61c 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts @@ -24,6 +24,7 @@ export const useTemplateSchema = ( ): { steps: { uiSchema: UiSchema; + originalSchema: JsonObject; schema: JsonObject; title: string; description?: string; @@ -33,6 +34,7 @@ export const useTemplateSchema = ( const steps = manifest.steps.map(({ title, description, schema }) => ({ title, description, + originalSchema: schema, ...extractSchemaFromStep(schema), })); @@ -45,6 +47,7 @@ export const useTemplateSchema = ( // Then filter out the properties that are not enabled with feature flag .map(step => ({ ...step, + schema: { ...step.schema, // Title is rendered at the top of the page, so let's ignore this from jsonschemaform From 7b186350d893e848a99379d097f16fc67fa7cdc2 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 2 Aug 2022 15:58:44 +0200 Subject: [PATCH 03/11] chore: rendering the errors for the async validation Signed-off-by: blam --- .../TemplateWizardPage/Stepper/Stepper.tsx | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx index 240139ae5c..bb37e3143c 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -22,7 +22,7 @@ import { Button, makeStyles, } from '@material-ui/core'; -import { FieldValidation, FormValidation, withTheme } from '@rjsf/core'; +import { FormValidation, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useMemo, useState } from 'react'; import { FieldExtensionOptions } from '../../../extensions'; @@ -56,6 +56,7 @@ export const Stepper = (props: StepperProps) => { const apiHolder = useApiHolder(); const [activeStep, setActiveStep] = useState(0); const [formState, setFormState] = useState({}); + const [errors, setErrors] = useState(); const styles = useStyles(); const extensions = useMemo(() => { @@ -81,15 +82,37 @@ export const Stepper = (props: StepperProps) => { }; const handleNext = async ({ formData }: { formData: JsonObject }) => { - console.log('running validation'); + setErrors(undefined); - const errors = await validator(formData, { - addError: () => {}, - __errors: [], - } as any); + const errorContext: any = {}; + for (const [key] of Object.entries(formData)) { + const localFieldContext = { + __errors: [] as string[], + addError: (message: string) => { + localFieldContext.__errors.push(message); + }, + }; + errorContext[key] = localFieldContext; + } - console.log('errors'); - setActiveStep(prevActiveStep => prevActiveStep + 1); + const returnedValidation = await validator( + formData, + errorContext as FormValidation, + ); + + const hasErrors = Object.values(returnedValidation).some(i => { + if ('__errors' in i) { + return i.__errors.length > 0; + } + return false; + }); + + if (hasErrors) { + setErrors(returnedValidation); + } else { + setErrors(undefined); + setActiveStep(prevActiveStep => prevActiveStep + 1); + } setFormState(current => ({ ...current, ...formData })); }; @@ -104,6 +127,7 @@ export const Stepper = (props: StepperProps) => {
Date: Wed, 3 Aug 2022 14:56:39 +0200 Subject: [PATCH 04/11] chore: added a library to work with the json schema form and make it easier to take into account all the different versions of jsonschema Signed-off-by: blam --- plugins/scaffolder/package.json | 1 + .../Stepper/createAsyncValidators.test.ts | 154 ++++++++++++++++++ .../Stepper/createAsyncValidators.ts | 71 +++----- yarn.lock | 40 +++++ 4 files changed, 221 insertions(+), 45 deletions(-) create mode 100644 plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 626a20d73e..52be3be8a4 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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", diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts new file mode 100644 index 0000000000..f101ecf0f0 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts @@ -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 { createAsyncValidator } 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 = createAsyncValidator(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 = (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 = createAsyncValidator( + schema, + { + NameField: NameField as CustomFieldValidator, + AddressField: AddressField as CustomFieldValidator, + }, + { + 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!'], + }), + }); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts index 613deb57b3..26a51bf376 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import { FormValidation } from '@rjsf/core'; -import { JsonObject, JsonValue } from '@backstage/types'; +import { FieldValidation, FormValidation } 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'; function isObject(obj: unknown): obj is JsonObject { return typeof obj === 'object' && obj !== null && !Array.isArray(obj); @@ -30,54 +31,34 @@ export const createAsyncValidator = ( apiHolder: ApiHolder; }, ) => { - async function validate( - schema: JsonObject, - formData: JsonObject, - errors: FormValidation, - ) { - const schemaProps = schema.properties; - const customObject = schema.type === 'object' && schemaProps === undefined; + async function validate(formData: JsonObject, pathPrefix: string = '#') { + const parsedSchema = new JSONSchema(rootSchema); + const formValidation: Record = {}; + for (const [key, value] of Object.entries(formData)) { + const definitionInSchema = parsedSchema.getSchema( + `${pathPrefix}/${key}`, + formData, + ); - if (!isObject(schemaProps) && !customObject) { - return; - } - - if (schemaProps) { - for (const [key, propData] of Object.entries(formData)) { - const propValidation = errors[key]; - - if (isObject(propData)) { - const propSchemaProps = schemaProps[key]; - if (isObject(propSchemaProps)) { - await validate( - propSchemaProps, - propData as JsonObject, - propValidation as FormValidation, - ); - } - } else { - const propSchema = schemaProps[key]; - const fieldName = - isObject(propSchema) && (propSchema['ui:field'] as string); - if (fieldName && typeof validators[fieldName] === 'function') { - await validators[fieldName]!( - propData as JsonValue, - propValidation, - context, - ); - } + if (definitionInSchema && 'ui:field' in definitionInSchema) { + const validator = validators[definitionInSchema['ui:field']]; + if (validator) { + const fieldValidation = { + __errors: [] as string[], + addError: (message: string) => { + fieldValidation.__errors.push(message); + }, + }; + await validator(value, fieldValidation, context); + formValidation[key] = fieldValidation; } } - } else if (customObject) { - const fieldName = schema['ui:field'] as string; - if (fieldName && typeof validators[fieldName] === 'function') { - await validators[fieldName]!(formData, errors, context); - } } + + return formValidation; } - return async (formData: JsonObject, errors: FormValidation) => { - await validate(rootSchema, formData, errors); - return errors; + return async (formData: JsonObject) => { + return await validate(formData); }; }; diff --git a/yarn.lock b/yarn.lock index e697cffa41..b95e1bfe8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From bc6a0c248a5191a75450a4ce3753b750a2ed3598 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Aug 2022 15:46:33 +0200 Subject: [PATCH 05/11] chore: fixing typescript Signed-off-by: blam --- .../TemplateWizardPage/Stepper/Stepper.tsx | 28 +++++++------------ .../Stepper/createAsyncValidators.ts | 14 ++-------- .../next/TemplateWizardPage/Stepper/schema.ts | 18 +++++++++++- .../Stepper/useTemplateSchema.ts | 4 +-- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx index bb37e3143c..013147dc90 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -22,12 +22,13 @@ import { Button, makeStyles, } from '@material-ui/core'; -import { FormValidation, 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 { createAsyncValidator } from './createAsyncValidators'; +import { createFieldValidation } from './schema'; import { useTemplateSchema } from './useTemplateSchema'; const useStyles = makeStyles(theme => ({ @@ -56,7 +57,9 @@ export const Stepper = (props: StepperProps) => { const apiHolder = useApiHolder(); const [activeStep, setActiveStep] = useState(0); const [formState, setFormState] = useState({}); - const [errors, setErrors] = useState(); + const [errors, setErrors] = useState< + undefined | Record + >(); const styles = useStyles(); const extensions = useMemo(() => { @@ -72,7 +75,8 @@ export const Stepper = (props: StepperProps) => { }, [props.extensions]); const validator = useMemo(() => { - return createAsyncValidator(steps[activeStep].originalSchema, validators, { + const { mergedSchema } = steps[activeStep]; + return createAsyncValidator(mergedSchema, validators, { apiHolder, }); }, [steps, activeStep, validators, apiHolder]); @@ -86,25 +90,13 @@ export const Stepper = (props: StepperProps) => { const errorContext: any = {}; for (const [key] of Object.entries(formData)) { - const localFieldContext = { - __errors: [] as string[], - addError: (message: string) => { - localFieldContext.__errors.push(message); - }, - }; - errorContext[key] = localFieldContext; + errorContext[key] = createFieldValidation(); } - const returnedValidation = await validator( - formData, - errorContext as FormValidation, - ); + const returnedValidation = await validator(formData); const hasErrors = Object.values(returnedValidation).some(i => { - if ('__errors' in i) { - return i.__errors.length > 0; - } - return false; + return i.__errors.length > 0; }); if (hasErrors) { diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts index 26a51bf376..e2f625892a 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts @@ -14,15 +14,12 @@ * limitations under the License. */ -import { FieldValidation, FormValidation } from '@rjsf/core'; +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'; - -function isObject(obj: unknown): obj is JsonObject { - return typeof obj === 'object' && obj !== null && !Array.isArray(obj); -} +import { createFieldValidation } from './schema'; export const createAsyncValidator = ( rootSchema: JsonObject, @@ -43,12 +40,7 @@ export const createAsyncValidator = ( if (definitionInSchema && 'ui:field' in definitionInSchema) { const validator = validators[definitionInSchema['ui:field']]; if (validator) { - const fieldValidation = { - __errors: [] as string[], - addError: (message: string) => { - fieldValidation.__errors.push(message); - }, - }; + const fieldValidation = createFieldValidation(); await validator(value, fieldValidation, context); formValidation[key] = fieldValidation; } diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts index 5a33a622bb..d353820004 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts @@ -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,19 @@ export const extractSchemaFromStep = ( extractUiSchema(returnSchema, uiSchema); return { uiSchema, schema: returnSchema }; }; + +/** + * @alpha + * Creates a field validation object for use in react jsonschema form + * @returns {FieldValidation} A field validation object that can be used to validate a field + */ +export const createFieldValidation = (): FieldValidation => { + const fieldValidation: FieldValidation = { + __errors: [] as string[], + addError: (message: string) => { + fieldValidation.__errors.push(message); + }, + }; + + return fieldValidation; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts index dac2c7b61c..926fb8d56e 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts @@ -24,7 +24,7 @@ export const useTemplateSchema = ( ): { steps: { uiSchema: UiSchema; - originalSchema: JsonObject; + mergedSchema: JsonObject; schema: JsonObject; title: string; description?: string; @@ -34,7 +34,7 @@ export const useTemplateSchema = ( const steps = manifest.steps.map(({ title, description, schema }) => ({ title, description, - originalSchema: schema, + mergedSchema: schema, ...extractSchemaFromStep(schema), })); From 3424a8075dad7569f4692c0123645e4fbf54253a Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Aug 2022 16:05:52 +0200 Subject: [PATCH 06/11] chore: added changeset Signed-off-by: blam --- .changeset/sharp-swans-suffer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-swans-suffer.md diff --git a/.changeset/sharp-swans-suffer.md b/.changeset/sharp-swans-suffer.md new file mode 100644 index 0000000000..841d746634 --- /dev/null +++ b/.changeset/sharp-swans-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Added support for `async` validation for the `next` version of the plugin From ac43121a1ee5eed673c257d421479e3daac17f66 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Aug 2022 16:16:09 +0200 Subject: [PATCH 07/11] chore: updating api-report Signed-off-by: blam --- plugins/scaffolder/api-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 24ff8e210d..891b76906e 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -44,7 +44,7 @@ export type CustomFieldValidator = ( context: { apiHolder: ApiHolder; }, -) => void; +) => void | Promise; // @public export const EntityNamePickerFieldExtension: FieldExtensionComponent< From ac002fe3f062ff47d0faadfc6933a18cb1fcdeb2 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Aug 2022 16:24:46 +0200 Subject: [PATCH 08/11] chore: removing the error context, this isn't used anymore Signed-off-by: blam --- .../src/next/TemplateWizardPage/Stepper/Stepper.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx index 013147dc90..5178318978 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -88,11 +88,6 @@ export const Stepper = (props: StepperProps) => { const handleNext = async ({ formData }: { formData: JsonObject }) => { setErrors(undefined); - const errorContext: any = {}; - for (const [key] of Object.entries(formData)) { - errorContext[key] = createFieldValidation(); - } - const returnedValidation = await validator(formData); const hasErrors = Object.values(returnedValidation).some(i => { From 5d2b0b8fc890ade38fccd2a1ddd0940501e6cdcc Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Aug 2022 16:26:13 +0200 Subject: [PATCH 09/11] chore: remove some whitespace Signed-off-by: blam --- .../src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts index 926fb8d56e..0482d89503 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts @@ -47,7 +47,6 @@ export const useTemplateSchema = ( // Then filter out the properties that are not enabled with feature flag .map(step => ({ ...step, - schema: { ...step.schema, // Title is rendered at the top of the page, so let's ignore this from jsonschemaform From ddb0557e56d86fd4e43765b4d15bff1ac65aa733 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 18 Aug 2022 16:47:28 +0200 Subject: [PATCH 10/11] chore: code-review comments Signed-off-by: blam --- .../src/next/TemplateWizardPage/Stepper/Stepper.tsx | 11 ++++++----- .../Stepper/createAsyncValidators.test.ts | 6 +++--- .../Stepper/createAsyncValidators.ts | 8 ++++++-- .../src/next/TemplateWizardPage/Stepper/schema.ts | 1 - 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx index 5178318978..b85a1e1f5d 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -27,8 +27,7 @@ import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useMemo, useState } from 'react'; import { FieldExtensionOptions } from '../../../extensions'; import { TemplateParameterSchema } from '../../../types'; -import { createAsyncValidator } from './createAsyncValidators'; -import { createFieldValidation } from './schema'; +import { createAsyncValidators } from './createAsyncValidators'; import { useTemplateSchema } from './useTemplateSchema'; const useStyles = makeStyles(theme => ({ @@ -74,9 +73,9 @@ export const Stepper = (props: StepperProps) => { ); }, [props.extensions]); - const validator = useMemo(() => { + const validation = useMemo(() => { const { mergedSchema } = steps[activeStep]; - return createAsyncValidator(mergedSchema, validators, { + return createAsyncValidators(mergedSchema, validators, { apiHolder, }); }, [steps, activeStep, validators, apiHolder]); @@ -86,9 +85,11 @@ export const Stepper = (props: StepperProps) => { }; 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 validator(formData); + const returnedValidation = await validation(formData); const hasErrors = Object.values(returnedValidation).some(i => { return i.__errors.length > 0; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts index f101ecf0f0..ed887fb56d 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.test.ts @@ -15,7 +15,7 @@ */ import { JsonObject } from '@backstage/types'; import { CustomFieldValidator } from '../../../extensions'; -import { createAsyncValidator } from './createAsyncValidators'; +import { createAsyncValidators } from './createAsyncValidators'; describe('createAsyncValidators', () => { it('should call the correct functions for validation', async () => { @@ -43,7 +43,7 @@ describe('createAsyncValidators', () => { const validators = { NameField: jest.fn(), AddressField: jest.fn() }; - const validate = createAsyncValidator(schema, validators, { + const validate = createAsyncValidators(schema, validators, { apiHolder: { get: jest.fn() }, }); @@ -98,7 +98,7 @@ describe('createAsyncValidators', () => { } }; - const validate = createAsyncValidator( + const validate = createAsyncValidators( schema, { NameField: NameField as CustomFieldValidator, diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts index e2f625892a..d89f0d4e5f 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/createAsyncValidators.ts @@ -21,7 +21,7 @@ import { CustomFieldValidator } from '../../../extensions'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { createFieldValidation } from './schema'; -export const createAsyncValidator = ( +export const createAsyncValidators = ( rootSchema: JsonObject, validators: Record>, context: { @@ -41,7 +41,11 @@ export const createAsyncValidator = ( const validator = validators[definitionInSchema['ui:field']]; if (validator) { const fieldValidation = createFieldValidation(); - await validator(value, fieldValidation, context); + try { + await validator(value, fieldValidation, context); + } catch (ex) { + fieldValidation.addError(ex.message); + } formValidation[key] = fieldValidation; } } diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts index d353820004..e2d5e89932 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts @@ -114,7 +114,6 @@ export const extractSchemaFromStep = ( /** * @alpha * Creates a field validation object for use in react jsonschema form - * @returns {FieldValidation} A field validation object that can be used to validate a field */ export const createFieldValidation = (): FieldValidation => { const fieldValidation: FieldValidation = { From 64658427a0d54890831d31ff773124368b76ecab Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 19 Aug 2022 10:21:53 +0200 Subject: [PATCH 11/11] chore: fixing tests by wrapping in act Signed-off-by: blam --- .../TemplateWizardPage/Stepper/Stepper.test.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx index 2b091883ae..90901f22c5 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx @@ -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',