diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d89da1c8..bb3c6335c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/validator-ajv8 - Remove alias for ajv -> ajv8 in package.json. This fixes [#3215](https://github.com/rjsf-team/react-jsonschema-form/issues/3215). +- Updated `AJV8Validator#transformRJSFValidationErrors` to return more human readable error messages. The ajv8 `ErrorObject` message is enhanced by replacing the error message field with either the `uiSchema`'s `ui:title` field if one exists or the `parentSchema` title if one exists. Fixes [#3246](https://github.com/rjsf-team/react-jsonschema-form/issues/3246) # 5.0.0-beta-16 diff --git a/packages/validator-ajv8/package.json b/packages/validator-ajv8/package.json index 6869a24326..fa633d6725 100644 --- a/packages/validator-ajv8/package.json +++ b/packages/validator-ajv8/package.json @@ -33,7 +33,7 @@ "lodash-es": "^4.17.15" }, "peerDependencies": { - "@rjsf/utils": "^5.0.0-beta.12" + "@rjsf/utils": "^5.0.0-beta.16" }, "devDependencies": { "@babel/core": "^7.20.12", diff --git a/packages/validator-ajv8/src/createAjvInstance.ts b/packages/validator-ajv8/src/createAjvInstance.ts index b1f8c01876..f56f1b5e8b 100644 --- a/packages/validator-ajv8/src/createAjvInstance.ts +++ b/packages/validator-ajv8/src/createAjvInstance.ts @@ -12,6 +12,7 @@ export const AJV_CONFIG: Options = { allErrors: true, multipleOfPrecision: 8, strict: false, + verbose: true, } as const; export const COLOR_FORMAT_REGEX = /^(#?([0-9A-Fa-f]{3}){1,2}\b|aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow|(rgb\(\s*\b([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\b\s*,\s*\b([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\b\s*,\s*\b([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\b\s*\))|(rgb\(\s*(\d?\d%|100%)+\s*,\s*(\d?\d%|100%)+\s*,\s*(\d?\d%|100%)+\s*\)))$/; diff --git a/packages/validator-ajv8/src/validator.ts b/packages/validator-ajv8/src/validator.ts index 829a5de38a..873dad9834 100644 --- a/packages/validator-ajv8/src/validator.ts +++ b/packages/validator-ajv8/src/validator.ts @@ -21,7 +21,10 @@ import { UiSchema, ValidationData, ValidatorType, + PROPERTIES_KEY, + getUiOptions, } from "@rjsf/utils"; +import get from "lodash/get"; import { CustomValidatorOptionsType, Localizer } from "./types"; import createAjvInstance from "./createAjvInstance"; @@ -199,20 +202,63 @@ export default class AJV8Validator< * At some point, components should be updated to support ajv. * * @param errors - The list of AJV errors to convert to `RJSFValidationErrors` - * @private + * @protected */ - private transformRJSFValidationErrors( - errors: ErrorObject[] = [] + protected transformRJSFValidationErrors( + errors: ErrorObject[] = [], + uiSchema?: UiSchema ): RJSFValidationError[] { return errors.map((e: ErrorObject) => { - const { instancePath, keyword, message, params, schemaPath } = e; + const { + instancePath, + keyword, + params, + schemaPath, + parentSchema, + ...rest + } = e; + let { message = "" } = rest; let property = instancePath.replace(/\//g, "."); let stack = `${property} ${message}`.trim(); + if ("missingProperty" in params) { property = property ? `${property}.${params.missingProperty}` : params.missingProperty; - stack = message!; + const currentProperty: string = params.missingProperty; + const uiSchemaTitle = getUiOptions( + get(uiSchema, `${property.replace(/^\./, "")}`) + ).title; + + if (uiSchemaTitle) { + message = message.replace(currentProperty, uiSchemaTitle); + } else { + const parentSchemaTitle = get(parentSchema, [ + PROPERTIES_KEY, + currentProperty, + "title", + ]); + + if (parentSchemaTitle) { + message = message.replace(currentProperty, parentSchemaTitle); + } + } + + stack = message; + } else { + const uiSchemaTitle = getUiOptions( + get(uiSchema, `${property.replace(/^\./, "")}`) + ).title; + + if (uiSchemaTitle) { + stack = `'${uiSchemaTitle}' ${message}`.trim(); + } else { + const parentSchemaTitle = parentSchema?.title; + + if (parentSchemaTitle) { + stack = `'${parentSchemaTitle}' ${message}`.trim(); + } + } } // put data in expected format @@ -288,7 +334,7 @@ export default class AJV8Validator< ): ValidationData { const rawErrors = this.rawValidation(schema, formData); const { validationError: invalidSchemaError } = rawErrors; - let errors = this.transformRJSFValidationErrors(rawErrors.errors); + let errors = this.transformRJSFValidationErrors(rawErrors.errors, uiSchema); if (invalidSchemaError) { errors = [...errors, { stack: invalidSchemaError!.message }]; diff --git a/packages/validator-ajv8/test/validator.test.ts b/packages/validator-ajv8/test/validator.test.ts index 95ba74e207..5ce32c0c75 100644 --- a/packages/validator-ajv8/test/validator.test.ts +++ b/packages/validator-ajv8/test/validator.test.ts @@ -1,3 +1,4 @@ +import { ErrorObject } from "ajv"; import Ajv2019 from "ajv/dist/2019"; import Ajv2020 from "ajv/dist/2020"; import { @@ -17,6 +18,13 @@ class TestValidator extends AJV8Validator { withIdRefPrefix(schemaNode: RJSFSchema): RJSFSchema { return super.withIdRefPrefix(schemaNode); } + + transformRJSFValidationErrors( + errors: ErrorObject[] = [], + uiSchema?: UiSchema + ): RJSFValidationError[] { + return super.transformRJSFValidationErrors(errors, uiSchema); + } } const illFormedKey = "bar`'()=+*&^%$#@!"; @@ -1382,6 +1390,240 @@ describe("AJV8Validator", () => { ); }); }); + describe("title is in validation messages at the top level", () => { + beforeAll(() => { + const schema: RJSFSchema = { + type: "object", + required: ["firstName", "lastName"], + properties: { + firstName: { title: "First Name", type: "string" }, + lastName: { title: "Last Name", type: "string" }, + numberOfChildren: { + title: "Number of children", + type: "string", + pattern: "\\d+", + }, + }, + }; + + const formData = { firstName: "a", numberOfChildren: "aa" }; + const result = validator.validateFormData(formData, schema); + errors = result.errors; + errorSchema = result.errorSchema; + }); + it("should return an error list", () => { + expect(errors).toHaveLength(2); + + const stack = errors.map((e) => e.stack); + + expect(stack).toEqual([ + "must have required property 'Last Name'", + "'Number of children' must match pattern \"\\d+\"", + ]); + }); + it("should return an errorSchema", () => { + expect(errorSchema.lastName!.__errors).toHaveLength(1); + expect(errorSchema.lastName!.__errors![0]).toEqual( + "must have required property 'Last Name'" + ); + + expect(errorSchema.numberOfChildren!.__errors).toHaveLength(1); + expect(errorSchema.numberOfChildren!.__errors![0]).toEqual( + 'must match pattern "\\d+"' + ); + }); + }); + describe("title is in validation message with a nested child", () => { + beforeAll(() => { + const schema: RJSFSchema = { + type: "object", + properties: { + nested: { + type: "object", + required: ["firstName", "lastName"], + properties: { + firstName: { type: "string", title: "First Name" }, + lastName: { type: "string", title: "Last Name" }, + numberOfChildren: { + title: "Number of children", + type: "string", + pattern: "\\d+", + }, + }, + }, + }, + }; + + const formData = { + nested: { firstName: "a", numberOfChildren: "aa" }, + }; + const result = validator.validateFormData(formData, schema); + errors = result.errors; + errorSchema = result.errorSchema; + }); + it("should return an error list", () => { + expect(errors).toHaveLength(2); + const stack = errors.map((e) => e.stack); + + expect(stack).toEqual([ + "must have required property 'Last Name'", + "'Number of children' must match pattern \"\\d+\"", + ]); + }); + it("should return an errorSchema", () => { + expect(errorSchema.nested!.lastName!.__errors).toHaveLength(1); + expect(errorSchema.nested!.lastName!.__errors![0]).toEqual( + "must have required property 'Last Name'" + ); + + expect(errorSchema.nested!.numberOfChildren!.__errors).toHaveLength( + 1 + ); + expect(errorSchema.nested!.numberOfChildren!.__errors![0]).toEqual( + 'must match pattern "\\d+"' + ); + }); + }); + describe("title is in validation message when it is in the uiSchema ui:title field", () => { + beforeAll(() => { + const schema: RJSFSchema = { + type: "object", + required: ["firstName", "lastName"], + properties: { + firstName: { title: "First Name", type: "string" }, + lastName: { title: "Last Name", type: "string" }, + numberOfChildren: { + title: "Number of children", + type: "string", + pattern: "\\d+", + }, + }, + }; + const uiSchema: UiSchema = { + lastName: { + "ui:title": "uiSchema Last Name", + }, + numberOfChildren: { + "ui:title": "uiSchema Number of children", + }, + }; + + const formData = { firstName: "a", numberOfChildren: "aa" }; + const result = validator.validateFormData( + formData, + schema, + undefined, + undefined, + uiSchema + ); + errors = result.errors; + errorSchema = result.errorSchema; + }); + it("should return an error list", () => { + expect(errors).toHaveLength(2); + + const stack = errors.map((e) => e.stack); + + expect(stack).toEqual([ + "must have required property 'uiSchema Last Name'", + "'uiSchema Number of children' must match pattern \"\\d+\"", + ]); + }); + it("should return an errorSchema", () => { + expect(errorSchema.lastName!.__errors).toHaveLength(1); + expect(errorSchema.lastName!.__errors![0]).toEqual( + "must have required property 'uiSchema Last Name'" + ); + + expect(errorSchema.numberOfChildren!.__errors).toHaveLength(1); + expect(errorSchema.numberOfChildren!.__errors![0]).toEqual( + 'must match pattern "\\d+"' + ); + }); + }); + describe("uiSchema title in validation when defined in nested field", () => { + beforeAll(() => { + const schema: RJSFSchema = { + type: "object", + properties: { + nested: { + type: "object", + required: ["firstName", "lastName"], + properties: { + firstName: { type: "string", title: "First Name" }, + lastName: { type: "string", title: "Last Name" }, + numberOfChildren: { + title: "Number of children", + type: "string", + pattern: "\\d+", + }, + }, + }, + }, + }; + const uiSchema: UiSchema = { + nested: { + lastName: { + "ui:title": "uiSchema Last Name", + }, + numberOfChildren: { + "ui:title": "uiSchema Number of children", + }, + }, + }; + + const formData = { + nested: { firstName: "a", numberOfChildren: "aa" }, + }; + const result = validator.validateFormData( + formData, + schema, + undefined, + undefined, + uiSchema + ); + errors = result.errors; + errorSchema = result.errorSchema; + }); + it("should return an error list", () => { + expect(errors).toHaveLength(2); + const stack = errors.map((e) => e.stack); + + expect(stack).toEqual([ + "must have required property 'uiSchema Last Name'", + "'uiSchema Number of children' must match pattern \"\\d+\"", + ]); + }); + it("should return an errorSchema", () => { + expect(errorSchema.nested!.lastName!.__errors).toHaveLength(1); + expect(errorSchema.nested!.lastName!.__errors![0]).toEqual( + "must have required property 'uiSchema Last Name'" + ); + + expect(errorSchema.nested!.numberOfChildren!.__errors).toHaveLength( + 1 + ); + expect(errorSchema.nested!.numberOfChildren!.__errors![0]).toEqual( + 'must match pattern "\\d+"' + ); + }); + }); + }); + describe("passing optional error fields to transformRJSFValidationErrors", () => { + it("should transform errors without an error message or parentSchema field", () => { + const error = { + instancePath: "/numberOfChildren", + schemaPath: "#/properties/numberOfChildren/pattern", + keyword: "pattern", + params: { pattern: "\\d+" }, + schema: "\\d+", + data: "aa", + }; + + const errors = validator.transformRJSFValidationErrors([error]); + + expect(errors).toHaveLength(1); + }); }); describe("No custom validate function, single additionalProperties value", () => { let errors: RJSFValidationError[]; @@ -1684,10 +1926,16 @@ describe("AJV8Validator", () => { it("localizer was called with the errors", () => { expect(localizer).toHaveBeenCalledWith([ { + data: "some kind of text", instancePath: "/datasetId", keyword: "pattern", message: 'must match pattern "\\d+"', params: { pattern: "\\d+" }, + parentSchema: { + pattern: "\\d+", + type: "string", + }, + schema: "\\d+", schemaPath: "#/definitions/Dataset/properties/datasetId/pattern", }, ]); @@ -1851,10 +2099,16 @@ describe("AJV8Validator", () => { it("localizer was called with the errors", () => { expect(localizer).toHaveBeenCalledWith([ { + data: "some kind of text", instancePath: "/datasetId", keyword: "pattern", message: 'must match pattern "\\d+"', params: { pattern: "\\d+" }, + parentSchema: { + pattern: "\\d+", + type: "string", + }, + schema: "\\d+", schemaPath: "#/definitions/Dataset/properties/datasetId/pattern", }, ]); @@ -2019,10 +2273,16 @@ describe("AJV8Validator", () => { it("localizer was called with the errors", () => { expect(localizer).toHaveBeenCalledWith([ { + data: "some kind of text", instancePath: "/datasetId", keyword: "pattern", message: 'must match pattern "\\d+"', params: { pattern: "\\d+" }, + parentSchema: { + pattern: "\\d+", + type: "string", + }, + schema: "\\d+", schemaPath: "#/definitions/Dataset/properties/datasetId/pattern", }, ]);