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;
}