diff --git a/.changeset/shy-buses-mix.md b/.changeset/shy-buses-mix.md new file mode 100644 index 0000000000..b7db90d03c --- /dev/null +++ b/.changeset/shy-buses-mix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': minor +--- + +refactor `createAsyncValidators` to be recursive to ensure validators are called in nested schemas. diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index c97d31c487..9242ef0086 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -23,11 +23,14 @@ import { makeStyles, } from '@material-ui/core'; import { type IChangeEvent, withTheme } from '@rjsf/core-v5'; -import { ErrorSchema, FieldValidation } from '@rjsf/utils'; +import { ErrorSchema } from '@rjsf/utils'; import React, { useCallback, useMemo, useState, type ReactNode } from 'react'; import { NextFieldExtensionOptions } from '../../extensions'; import { TemplateParameterSchema } from '../../../types'; -import { createAsyncValidators } from './createAsyncValidators'; +import { + createAsyncValidators, + type FormValidation, +} from './createAsyncValidators'; import { ReviewState, type ReviewStateProps } from '../ReviewState'; import { useTemplateSchema } from '../../hooks/useTemplateSchema'; import validator from '@rjsf/validator-ajv8'; @@ -35,6 +38,7 @@ import { useFormDataFromQuery } from '../../hooks'; import { FormProps } from '../../types'; import { LayoutOptions } from '../../../layouts'; import { useTransformSchemaToProps } from '../../hooks/useTransformSchemaToProps'; +import { hasErrors } from './utils'; const useStyles = makeStyles(theme => ({ backButton: { @@ -92,9 +96,7 @@ export const Stepper = (stepperProps: StepperProps) => { const [activeStep, setActiveStep] = useState(0); const [formState, setFormState] = useFormDataFromQuery(props.initialState); - const [errors, setErrors] = useState< - undefined | Record - >(); + const [errors, setErrors] = useState(); const styles = useStyles(); const extensions = useMemo(() => { @@ -136,11 +138,7 @@ export const Stepper = (stepperProps: StepperProps) => { const returnedValidation = await validation(formData); - const hasErrors = Object.values(returnedValidation).some( - i => i.__errors?.length, - ); - - if (hasErrors) { + if (hasErrors(returnedValidation)) { setErrors(returnedValidation); } else { setErrors(undefined); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts index 6ad14e3cc4..15fa944b0f 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.test.ts @@ -154,4 +154,89 @@ describe('createAsyncValidators', () => { }), }); }); + + it('should run validation on complex nested schemas', async () => { + const schema: JsonObject = { + title: 'Select component', + properties: { + actionType: { + title: 'action type', + type: 'string', + description: 'Select the action type', + enum: ['newThing', 'existingThing'], + enumNames: ['New thing', 'Existing thing'], + default: 'newThing', + }, + }, + required: ['actionType'], + dependencies: { + actionType: { + oneOf: [ + { + properties: { + actionType: { + enum: ['newThing'], + }, + general: { + title: 'General', + type: 'object', + properties: { + name: { + title: 'Name', + type: 'string', + 'ui:field': 'NameField', + }, + }, + }, + }, + }, + { + properties: { + actionType: { + enum: ['existingThing'], + }, + thingId: { + title: 'Thing id', + type: 'string', + description: 'Enter thing id', + }, + }, + }, + ], + }, + }, + }; + + const NameField: NextCustomFieldValidator = ( + value, + { addError }, + ) => { + if (!value) { + addError('something is broken here!'); + } + }; + + const validators = { + NameField: NameField as NextCustomFieldValidator, + }; + + const validate = createAsyncValidators(schema, validators, { + apiHolder: { get: jest.fn() }, + }); + + await expect( + validate({ + actionType: 'newThing', + general: { + name: undefined, + }, + }), + ).resolves.toEqual({ + general: { + name: expect.objectContaining({ + __errors: ['something is broken here!'], + }), + }, + }); + }); }); diff --git a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts index 79466ee602..86b7619b34 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts +++ b/plugins/scaffolder-react/src/next/components/Stepper/createAsyncValidators.ts @@ -15,11 +15,19 @@ */ import { FieldValidation } from '@rjsf/utils'; -import { JsonObject } from '@backstage/types'; +import type { JsonObject } from '@backstage/types'; import { ApiHolder } from '@backstage/core-plugin-api'; import { Draft07 as JSONSchema } from 'json-schema-library'; import { createFieldValidation } from '../../lib'; import { NextCustomFieldValidator } from '../../extensions'; +import { isObject } from './utils'; + +/** + * @internal + */ +export type FormValidation = { + [name: string]: FieldValidation | FormValidation; +}; export const createAsyncValidators = ( rootSchema: JsonObject, @@ -28,14 +36,17 @@ export const createAsyncValidators = ( apiHolder: ApiHolder; }, ) => { - async function validate(formData: JsonObject, pathPrefix: string = '#') { + async function validate( + formData: JsonObject, + pathPrefix: string = '#', + current: JsonObject = formData, + ): Promise { const parsedSchema = new JSONSchema(rootSchema); - const formValidation: Record = {}; - for (const [key, value] of Object.entries(formData)) { - const definitionInSchema = parsedSchema.getSchema( - `${pathPrefix}/${key}`, - formData, - ); + const formValidation: FormValidation = {}; + + for (const [key, value] of Object.entries(current)) { + const path = `${pathPrefix}/${key}`; + const definitionInSchema = parsedSchema.getSchema(path, formData); if (definitionInSchema && 'ui:field' in definitionInSchema) { const validator = validators[definitionInSchema['ui:field']]; @@ -48,6 +59,8 @@ export const createAsyncValidators = ( } formValidation[key] = fieldValidation; } + } else if (isObject(value)) { + formValidation[key] = await validate(formData, path, value); } } diff --git a/plugins/scaffolder-react/src/next/components/Stepper/utils.ts b/plugins/scaffolder-react/src/next/components/Stepper/utils.ts new file mode 100644 index 0000000000..e4da5882e5 --- /dev/null +++ b/plugins/scaffolder-react/src/next/components/Stepper/utils.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2023 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 type { JsonObject, JsonValue } from '@backstage/types'; +import type { FieldValidation } from '@rjsf/utils'; +import { FormValidation } from './createAsyncValidators'; + +function isFieldValidation(error: any): error is FieldValidation { + return !!error && '__errors' in error; +} + +export function hasErrors(errors?: FormValidation): boolean { + if (!errors) { + return false; + } + + for (const error of Object.values(errors)) { + if (isFieldValidation(error)) { + return (error.__errors ?? []).length > 0; + } + + return hasErrors(error); + } + + return false; +} + +export function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}