diff --git a/plugins/scaffolder-frontend-workflow/src/hooks/useAsyncValidation.test.tsx b/plugins/scaffolder-frontend-workflow/src/hooks/useAsyncValidation.test.tsx new file mode 100644 index 0000000000..096063cf12 --- /dev/null +++ b/plugins/scaffolder-frontend-workflow/src/hooks/useAsyncValidation.test.tsx @@ -0,0 +1,492 @@ +import React from 'react'; +import { useAsyncValidation, AsyncValidationProps } from './useAsyncValidation'; +import { TestApiProvider } from '@backstage/test-utils'; +import { renderHook } from '@testing-library/react-hooks'; +import { + scaffolderApiRef, + SecretsContextProvider, +} from '@backstage/plugin-scaffolder-react'; +import { scaffolderApiMock } from '../test.utils'; +import { FieldValidation } from '@rjsf/utils'; + +const createValidation = ({ + schema, + validators, + extensions, +}: AsyncValidationProps) => { + const { result } = renderHook( + () => ({ + hook: useAsyncValidation({ schema, validators, extensions }), + }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + return result.current.hook; +}; + +describe('useAsyncValidation', () => { + /* + Note that we are defining the extensions much like would be configured + when using an RJSF. The two options either no-op or add an error unrelated + to the actual formData that is passed, `value`. This means that the tests + don't really consider the form data, and that the form's schema isn't validated + with the asyncValidation. It is handled within RJSF and the ajv validators. + */ + const extensions = [ + { + name: 'TextExtensionError', + component: () => null, + validation: (value: string, validation: FieldValidation) => { + validation.addError('boop'); + }, + }, + { + name: 'TextExtensionValidates', + component: () => null, + validation: (value: string, validation: FieldValidation) => {}, + }, + ]; + const validators = extensions.reduce((v, c) => { + v[c.name] = c?.validation; + return v; + }, {} as Record); + + it('returns empty validation object with empty schema', async () => { + const schema = { title: 'boop', properties: {}, required: [] }; + const formData = {}; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation(formData); + + expect(returnedValidation).toEqual({}); + }); + + // should this throw on an error? + it.skip('throws errors on invalid schema', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: [ + { + blam: { type: 'bargle' }, + }, + ], + }; + const formData = { bloop: 'boop' }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation(formData); + + expect(returnedValidation).toEqual({}); + }); + + it('validates string component', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + stringField: { + title: 'boop string', + type: 'string', + 'ui:field': 'TextExtensionError', + }, + }, + required: ['stringField'], + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).toEqual({ + stringField: { __errors: ['boop'] }, + }); + }); + + it('validates array of single field', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + stringFields: { + type: 'array', + items: { + type: 'string', + 'ui:field': 'TextExtensionError', + }, + }, + }, + required: ['stringFields'], + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).toEqual({ + stringFields: { __errors: ['boop'] }, + }); + }); + + it('validates array of objects', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + objectFields: { + title: 'boop', + type: 'array', + required: ['name'], + items: { + type: 'object', + properties: { + name: { + type: 'string', + 'ui:field': 'TextExtensionError', + }, + }, + }, + }, + }, + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).toEqual({ + objectFields: { __errors: ['boop'] }, + }); + }); + + it('validates open ended object component', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + stringField: { + title: 'boop object', + type: 'object', + 'ui:field': 'TextExtensionError', + }, + }, + required: ['stringField'], + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).not.toEqual({}); + + expect(returnedValidation?.stringField?.__errors).toEqual(['boop']); + }); + + it('validates defined object component', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + complexField: { + title: 'boop object', + type: 'object', + 'ui:field': 'TextExtensionError', + properties: { + id: { + type: 'integer', + 'ui:field': 'TextExtensionError', + }, + label: { + type: 'string', + 'ui:field': 'TextExtensionError', + }, + required: ['id', 'label'], + }, + }, + }, + required: ['complexField'], + }; + const validation = createValidation({ schema, validators, extensions }); + // the formData matters here as the nested object expects the object passed down + const returnedValidation = await validation({ complexField: {} }); + + expect(returnedValidation).toEqual({ + complexField: { + __errors: ['boop'], + id: { __errors: ['boop'] }, + label: { __errors: ['boop'] }, + }, + }); + }); + + it('validates object with references', async () => { + const schema = { + title: 'boop', + definitions: { + address: { + type: 'object', + 'ui:field': 'TextExtensionError', + properties: { + street_address: { type: 'string' }, + city: { type: 'string' }, + state: { type: 'string' }, + }, + required: ['street_address', 'city', 'state'], + }, + }, + type: 'object', + properties: { + billing_address: { $ref: '#/definitions/address' }, + shipping_address: { $ref: '#/definitions/address' }, + }, + required: ['billing_address', 'shipping_address'], + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).toEqual({ + billing_address: { __errors: ['boop'] }, + shipping_address: { __errors: ['boop'] }, + }); + }); + + it('validates object with unidirectional dependencies', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + name: { type: 'string', 'ui:field': 'TextExtensionError' }, + credit_card: { type: 'number', 'ui:field': 'TextExtensionError' }, + billing_address: { type: 'string', 'ui:field': 'TextExtensionError' }, + }, + required: ['name'], + dependencies: { + credit_card: ['billing_address'], + }, + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + // TODO does that dependency validate? + expect(returnedValidation).toEqual({ + name: { __errors: ['boop'] }, + credit_card: { __errors: ['boop'] }, + billing_address: { __errors: ['boop'] }, + }); + }); + + it('validates object with bidirectional dependencies', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + name: { type: 'string', 'ui:field': 'TextExtensionError' }, + credit_card: { type: 'number', 'ui:field': 'TextExtensionError' }, + billing_address: { type: 'string', 'ui:field': 'TextExtensionError' }, + }, + required: ['name'], + dependencies: { + credit_card: ['billing_address'], + billing_address: ['credit_card'], + }, + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).toEqual({ + name: { __errors: ['boop'] }, + credit_card: { __errors: ['boop'] }, + billing_address: { __errors: ['boop'] }, + }); + }); + + it('validates object with conditional dependencies', async () => { + const schema = { + title: 'boop', + type: 'object', + properties: { + name: { type: 'string', 'ui:field': 'TextExtensionError' }, + credit_card: { type: 'number', 'ui:field': 'TextExtensionError' }, + }, + required: ['name'], + dependencies: { + credit_card: { + properties: { + billing_address: { + type: 'string', + 'ui:field': 'TextExtensionError', + }, + }, + required: ['billing_address'], + }, + }, + }; + const validation = createValidation({ schema, validators, extensions }); + + const returnedValidationBeforeDep = await validation({}); + expect(returnedValidationBeforeDep).toEqual({ + name: { __errors: ['boop'] }, + credit_card: { __errors: ['boop'] }, + }); + + // TODO this should pass when the formData includes the credit_card + // const returnedValidationAfterDep = await validation({ credit_card: 12345 }); + // expect(returnedValidationAfterDep).toEqual({ + // name: { __errors: ['boop'] }, + // credit_card: { __errors: ['boop'] }, + // billing_address: { __errors: ['boop'] }, + // }); + }); + + it('validates ojbect with dynamic dependencies', async () => { + const schema = { + title: 'Person', + type: 'object', + properties: { + 'Any pets?': { + type: 'string', + 'ui:field': 'TextExtensionError', + enum: ['No', 'Yes: One', 'Yes: More than one'], + default: 'No', + }, + }, + required: ['Any pets?'], + dependencies: { + 'Any pets?': { + oneOf: [ + { + properties: { + 'Any pets?': { + enum: ['No'], + 'ui:field': 'TextExtensionError', + }, + }, + }, + { + properties: { + 'Any pets?': { + enum: ['Yes: One'], + 'ui:field': 'TextExtensionError', + }, + 'How old is your pet?': { + type: 'number', + 'ui:field': 'TextExtensionError', + }, + }, + required: ['How old is your pet?'], + }, + { + properties: { + 'Any pets?': { + enum: ['Yes: More than one'], + 'ui:field': 'TextExtensionError', + }, + 'Pet them?': { + type: 'boolean', + 'ui:field': 'TextExtensionError', + }, + }, + required: ['Pet them?'], + }, + ], + }, + }, + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + // TODO does the form data matter? should it? + expect(returnedValidation).toEqual({ + 'Any pets?': { __errors: ['boop'] }, + // credit_card: { __errors: ['boop'] }, + // billing_address: { __errors: ['boop'] }, + }); + }); + + describe.skip('does not support custom widgets for oneOf, anyOf, and allOf', () => { + it('validates oneOf', async () => { + const schema = { + title: 'boop', + type: 'object', + oneOf: [ + { + properties: { + lorem: { + type: 'string', + 'ui:field': 'TextExtensionError', + }, + }, + required: ['lorem'], + }, + { + properties: { + ipsum: { + type: 'string', + 'ui:field': 'TextExtensionError', + }, + }, + required: ['ipsum'], + }, + ], + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + // TODO does the form data matter? should it? + expect(returnedValidation).toEqual({ + // credit_card: { __errors: ['boop'] }, + // billing_address: { __errors: ['boop'] }, + }); + }); + + it('validates anyOf', async () => { + const schema = { + title: 'boop', + type: 'object', + anyOf: [ + { + properties: { + lorem: { + type: 'string', + }, + }, + required: ['lorem'], + }, + { + properties: { + lorem: { + type: 'string', + }, + ipsum: { + type: 'string', + }, + }, + }, + ], + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).toEqual({ + // credit_card: { __errors: ['boop'] }, + // billing_address: { __errors: ['boop'] }, + }); + }); + + it('validates allOf', async () => { + const schema = { + title: 'Field', + allOf: [ + { + type: ['string', 'boolean'], + }, + { + type: 'boolean', + }, + ], + }; + const validation = createValidation({ schema, validators, extensions }); + const returnedValidation = await validation({}); + + expect(returnedValidation).toEqual({ + // credit_card: { __errors: ['boop'] }, + // billing_address: { __errors: ['boop'] }, + }); + }); + }); +}); diff --git a/plugins/scaffolder-frontend-workflow/src/hooks/useAsyncValidation.ts b/plugins/scaffolder-frontend-workflow/src/hooks/useAsyncValidation.ts index 520beb6c85..a3059556c3 100644 --- a/plugins/scaffolder-frontend-workflow/src/hooks/useAsyncValidation.ts +++ b/plugins/scaffolder-frontend-workflow/src/hooks/useAsyncValidation.ts @@ -6,34 +6,32 @@ import { createFieldValidation, extractSchemaFromStep, } from '@backstage/plugin-scaffolder-react/alpha'; -import { Draft07 as JSONSchema } from 'json-schema-library'; +import { Draft07 as JSONSchema, isJSONError } from 'json-schema-library'; import { useApiHolder, ApiHolder } from '@backstage/core-plugin-api'; import { JsonObject, JsonValue } from '@backstage/types'; -import { FieldValidation } from '@rjsf/utils'; +import { ErrorSchemaBuilder } from '@rjsf/utils'; import { Validators } from './useValidators'; -interface Props { +export interface AsyncValidationProps { extensions: NextFieldExtensionOptions[]; + // this is required as it also includes the uiSchema // mergedSchema from useTemplateSchema().steps[activeStep].mergedSchema schema: JsonObject; validators: Validators; } -export function useAsyncValidation(props: Props) { +export function useAsyncValidation(props: AsyncValidationProps) { const apiHolder = useApiHolder(); + const { schema, validators } = props; return useMemo(() => { - return createAsyncValidators(props.schema, props.validators, { + return createAsyncValidators(schema, validators, { apiHolder, }); - }, [props.schema, props.validators, apiHolder]); + }, [schema, validators, apiHolder]); } -export type FormValidation = { - [name: string]: FieldValidation | FormValidation; -}; - function createAsyncValidators( rootSchema: JsonObject, validators: Record< @@ -46,15 +44,16 @@ function createAsyncValidators( ) { async function validate( formData: JsonObject, - pathPrefix: string = '#', - current: JsonObject = formData, - ): Promise { - const parsedSchema = new JSONSchema(rootSchema); - const formValidation: FormValidation = {}; - + pathPrefix: string[] = [], + currentSchema = rootSchema, + errorBuilder: ErrorSchemaBuilder = new ErrorSchemaBuilder< + typeof currentSchema + >(), + ): Promise> { + const parsedSchema = new JSONSchema(currentSchema); const validateForm = async ( validatorName: string, - key: string, + path: string[], value: JsonValue | undefined, schema: JsonObject, uiSchema: NextFieldExtensionUiSchema, @@ -71,15 +70,34 @@ function createAsyncValidators( }); } catch (ex) { // @ts-expect-error 'ex' is of type 'unknown'.ts(18046) - fieldValidation.addError(ex.message); + errorBuilder.addErrors(ex.message, path); } - formValidation[key] = fieldValidation; + if (fieldValidation.__errors) + errorBuilder.addErrors(fieldValidation?.__errors, path); } }; - for (const [key, value] of Object.entries(current)) { - const path = `${pathPrefix}/${key}`; - const definitionInSchema = parsedSchema.getSchema(path, formData); + for (const key of Object.keys(currentSchema?.properties ?? {})) { + const value = formData[key]; + const path = pathPrefix.concat([key]); + // this takes the schema and resolves any references + const definitionInSchema = parsedSchema.step( + key, + currentSchema, + formData, + ); + if (isJSONError(definitionInSchema)) { + // eslint-disable-next-line no-console + console.error( + `${definitionInSchema.name}: ${definitionInSchema.message}`, + ); + // ideally this will only be a dev time error, but if it isn't addressed + // before it reaches production, the user would not be able to address + // the error anyways. + throw new Error( + `Form key ${key} threw an error. Please raise this error with the team.\n${`${definitionInSchema.name}: ${definitionInSchema.message}`}`, + ); + } const { schema, uiSchema } = extractSchemaFromStep(definitionInSchema); const hasItems = definitionInSchema && definitionInSchema.items; @@ -91,7 +109,7 @@ function createAsyncValidators( ) => { await validateForm( propValue['ui:field'] as string, - key, + path, value, itemSchema, itemUiSchema, @@ -106,7 +124,18 @@ function createAsyncValidators( } }; - if (definitionInSchema && 'ui:field' in definitionInSchema) { + if (isObject(value)) { + await validate(value, path, definitionInSchema, errorBuilder); + if ('ui:field' in definitionInSchema) { + await validateForm( + definitionInSchema['ui:field'] as string, + path, + value, + schema, + uiSchema, + ); + } + } else if (definitionInSchema && 'ui:field' in definitionInSchema) { await doValidateItem(definitionInSchema, schema, uiSchema); } else if (hasItems && 'ui:field' in definitionInSchema.items) { await doValidate(definitionInSchema.items); @@ -116,19 +145,18 @@ function createAsyncValidators( for (const [, propValue] of Object.entries(properties)) { await doValidate(propValue); } - } else if (isObject(value)) { - formValidation[key] = await validate(formData, path, value); } } - return formValidation; + return errorBuilder; } return async (formData: JsonObject) => { - return await validate(formData); + const validated = await validate(formData); + return validated.ErrorSchema; }; -}; +} function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); -} \ No newline at end of file +} diff --git a/plugins/scaffolder-frontend-workflow/src/hooks/useStepper.ts b/plugins/scaffolder-frontend-workflow/src/hooks/useStepper.ts index 8614c6a6bf..5e4226fbfb 100644 --- a/plugins/scaffolder-frontend-workflow/src/hooks/useStepper.ts +++ b/plugins/scaffolder-frontend-workflow/src/hooks/useStepper.ts @@ -1,10 +1,14 @@ import { useAnalytics } from '@backstage/core-plugin-api'; import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; -import { NextFieldExtensionOptions, useFormDataFromQuery, useTemplateSchema } from '@backstage/plugin-scaffolder-react/alpha'; -import { JsonValue } from '@backstage/types'; -import { FieldValidation } from '@rjsf/utils'; +import { + NextFieldExtensionOptions, + useFormDataFromQuery, + useTemplateSchema, +} from '@backstage/plugin-scaffolder-react/alpha'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { ErrorSchema, toErrorList } from '@rjsf/utils'; import { useState } from 'react'; -import { FormValidation, useAsyncValidation } from './useAsyncValidation'; +import { useAsyncValidation } from './useAsyncValidation'; import { useValidators } from './useValidators'; interface Props { @@ -21,15 +25,17 @@ export function useStepper({ manifest, initialState, extensions }: Props) { const [formState, setFormState] = useFormDataFromQuery(initialState); const [activeStep, setActiveStep] = useState(0); const [isValidating, setIsValidating] = useState(false); - const [errors, setErrors] = useState(); + const [errors, setErrors] = useState>(); const validators = useValidators({ extensions }); const validation = useAsyncValidation({ extensions, + // this includes both the schema and the uiSchema + // which is required to look up and run async validation schema: steps[activeStep]?.mergedSchema, validators, - }) + }); const handleBack = () => { setActiveStep(prevActiveStep => prevActiveStep - 1); @@ -70,32 +76,11 @@ export function useStepper({ manifest, initialState, extensions }: Props) { currentStep: steps[activeStep], errors, formState, - isValidating + isValidating, }; } -function hasErrors(errors?: FormValidation): boolean { - if (!errors) { - return false; - } - - for (const error of Object.values(errors)) { - if (isFieldValidation(error)) { - if ((error.__errors ?? []).length > 0) { - return true; - } - - continue; - } - - if (hasErrors(error)) { - return true; - } - } - - return false; -} - -function isFieldValidation(error: any): error is FieldValidation { - return !!error && '__errors' in error; +function hasErrors(errors?: ErrorSchema): boolean { + const errorList = toErrorList(errors); + return errorList.length > 0; }