From bfd463fcd9761ed71bf691407b307f2a0e52489d Mon Sep 17 00:00:00 2001 From: foxhound87 Date: Sun, 28 Jul 2024 11:19:51 +0200 Subject: [PATCH] feat: introduced JOI validator --- CHANGELOG.md | 5 ++ package-lock.json | 99 ++++++++++++++++++++++++++++++ package.json | 3 +- src/Validator.ts | 4 +- src/models/ValidatorInterface.ts | 7 +++ src/validators/JOI.ts | 73 ++++++++++++++++++++++ tests/data/_.nested.ts | 4 ++ tests/data/forms/nested/form.z3.ts | 76 +++++++++++++++++++++++ tests/data/forms/nested/form.z4.ts | 80 ++++++++++++++++++++++++ 9 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/validators/JOI.ts create mode 100644 tests/data/forms/nested/form.z3.ts create mode 100644 tests/data/forms/nested/form.z4.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e499d04d..c2ee5a95 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 6.10.0 (master) + +- Introduced `JOI` validation plugin and driver. +- Added `ValidatorConstructor` inferface + # 6.9.4 (master) - Fix: #636 diff --git a/package-lock.json b/package-lock.json index ba5af5c6..269d70ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "eslint": "^8.35.0", "eslint-plugin-import": "^2.27.5", "husky": "0.13.1", + "joi": "^17.13.3", "json-loader": "0.5.4", "lodash-webpack-plugin": "^0.11.6", "mobx": "^6.3.3", @@ -710,6 +711,21 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -1612,6 +1628,27 @@ "semantic-release": ">=18.0.0-beta.1" } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -6655,6 +6692,19 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-sdsl": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", @@ -14921,6 +14971,21 @@ "integrity": "sha512-JXdzbRiWclLVoD8sNUjR443VVlYqiYmDVT6rGUEIEHU5YJW0gaVZwV2xgM7D4arkvASqD0IlLUVjHiFuxaftRw==", "dev": true }, + "@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -15597,6 +15662,27 @@ "read-pkg-up": "^7.0.0" } }, + "@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -19346,6 +19432,19 @@ } } }, + "joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "js-sdsl": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", diff --git a/package.json b/package.json index 8b2eb7bf..8d36283e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "license": "MIT", "version": "0.0.0-development", "author": "Claudio Savino (https://twitter.com/foxhound87)", - "description": "Automagically manage React forms state and automatic validation with MobX.", + "description": "Reactive MobX Form State Management", "homepage": "https://github.com/foxhound87/mobx-react-form#readme", "main": "./lib/index.js", "types": "./lib/index.d.ts", @@ -95,6 +95,7 @@ "eslint": "^8.35.0", "eslint-plugin-import": "^2.27.5", "husky": "0.13.1", + "joi": "^17.13.3", "json-loader": "0.5.4", "lodash-webpack-plugin": "^0.11.6", "mobx": "^6.3.3", diff --git a/src/Validator.ts b/src/Validator.ts index 595e9c28..cea76ef5 100755 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -7,6 +7,7 @@ import ValidatorInterface, { ValidationPlugin, ValidationPluginInterface, ValidationPlugins, + ValidatorConstructor, } from "./models/ValidatorInterface"; import { FormInterface } from "./models/FormInterface"; import { FieldInterface } from "./models/FieldInterface"; @@ -25,11 +26,12 @@ export default class Validator implements ValidatorInterface { svk: undefined, yup: undefined, zod: undefined, + joi: undefined, }; error: string | null = null; - constructor(obj: any = {}) { + constructor(obj: ValidatorConstructor) { makeObservable(this, { error: observable, validate: action, diff --git a/src/models/ValidatorInterface.ts b/src/models/ValidatorInterface.ts index 4a2732ae..9f057277 100644 --- a/src/models/ValidatorInterface.ts +++ b/src/models/ValidatorInterface.ts @@ -4,6 +4,11 @@ import {FieldInterface} from "./FieldInterface"; import {FormInterface} from "./FormInterface"; import {StateInterface} from "./StateInterface"; +export interface ValidatorConstructor { + form: FormInterface; + plugins: ValidationPlugins; +} + export interface ValidateOptionsInterface { showErrors?: boolean, related?: boolean, @@ -36,6 +41,7 @@ export interface ValidationPlugins { svk?: ValidationPlugin; yup?: ValidationPlugin; zod?: ValidationPlugin; + joi?: ValidationPlugin; } export type ValidationPackage = any; @@ -75,4 +81,5 @@ export enum ValidationHooks { onError = 'onError', } + export default ValidatorInterface; \ No newline at end of file diff --git a/src/validators/JOI.ts b/src/validators/JOI.ts new file mode 100644 index 00000000..cd164494 --- /dev/null +++ b/src/validators/JOI.ts @@ -0,0 +1,73 @@ +import _ from "lodash"; +import { + ValidationPlugin, + ValidationPluginConfig, + ValidationPluginConstructor, + ValidationPluginInterface, +} from "../models/ValidatorInterface"; + +class JOI implements ValidationPluginInterface { + promises = []; + + config = null; + + state = null; + + extend = null; + + validator = null; + + schema = null; + + constructor({ + config, + state = null, + promises = [], + }: ValidationPluginConstructor) { + this.state = state; + this.promises = promises; + this.extend = config?.extend; + this.validator = config.package; + this.schema = config.schema; + this.extendValidator(); + } + + extendValidator(): void { + // extend using "extend" callback + if (typeof this.extend === "function") { + this.extend({ + validator: this.validator, + form: this.state.form, + }); + } + } + + validate(field): void { + const { error } = this.schema.validate(field.state.form.validatedValues, { abortEarly: false }); + if (!error) return; + + const fieldPathArray = field.path.split('.'); + + const fieldErrors = error.details + .filter(detail => { + const errorPathString = detail.path.join('.'); + const fieldPathString = fieldPathArray.join('.'); + return errorPathString === fieldPathString || errorPathString.startsWith(`${fieldPathString}.`); + }) + .map(detail => { + // Replace the path in the error message with the custom label + const label = detail.context?.label || detail.path.join('.'); + const message = detail.message.replace(`${detail.path.join('.')}`, label); + return message; + }); + + if (fieldErrors.length) { + field.validationErrorStack = fieldErrors; + } + } +} + +export default (config?: ValidationPluginConfig): ValidationPlugin => ({ + class: JOI, + config, +}); diff --git a/tests/data/_.nested.ts b/tests/data/_.nested.ts index 6e76194d..0d574528 100755 --- a/tests/data/_.nested.ts +++ b/tests/data/_.nested.ts @@ -33,6 +33,8 @@ import $V4 from "./forms/nested/form.v4"; import $Z from "./forms/nested/form.z"; import $Z1 from "./forms/nested/form.z1"; import $Z2 from "./forms/nested/form.z2"; +import $Z3 from "./forms/nested/form.z3"; +import $Z4 from "./forms/nested/form.z4"; import $X from "./forms/nested/form.x"; export default { @@ -68,5 +70,7 @@ export default { $Z, $Z1, $Z2, + $Z3, + $Z4, $X, }; diff --git a/tests/data/forms/nested/form.z3.ts b/tests/data/forms/nested/form.z3.ts new file mode 100644 index 00000000..5234ec7b --- /dev/null +++ b/tests/data/forms/nested/form.z3.ts @@ -0,0 +1,76 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from "chai"; +import j from "joi"; +import FormInterface from "../../../../src/models/FormInterface"; +import OptionsModel from "../../../../src/models/OptionsModel"; +import { Form } from "../../../../src"; +import joi from "../../../../src/validators/JOI"; +import { ValidationPlugins } from "../../../../src/models/ValidatorInterface"; + + +const fields = [ + "user.username", + "user.email", + "user.password", + "user.passwordConfirm", +]; + +const values = { + user: { + username: 'a', + email: 'notAValidEmail@', + password: 'x', + passwordConfirm: 'mysecretpassword', + } +} + +const schema = j.object({ + user: j.object({ + username: j.string().min(3).required().label('Username'), + email: j.string().email().required(), + password: j.string().min(6).max(25).required(), + passwordConfirm: j.string().min(6).max(25).valid(j.ref('password')).required().messages({ + 'any.only': 'Passwords do not match', + 'any.required': 'Password confirmation is required', + }), + }).required() +}); + +const plugins: ValidationPlugins = { + joi: joi({ + package: j, + schema, + }), +}; + +const options: OptionsModel = { + validateOnInit: true, + showErrorsOnInit: true, +}; + +export default new Form({ + fields, + values, +}, { + plugins, + options, + name: "Nested-Z3", + hooks: { + onInit(form: FormInterface) { + describe("Check joi validation flag", () => { + it('user.username hasError should be true', () => expect(form.$('user.username').hasError).to.be.true); + it('user.email hasError should be true', () => expect(form.$('user.email').hasError).to.be.true); + it('user.password hasError should be true', () => expect(form.$('user.password').hasError).to.be.true); + it('user.passwordConfirm hasError should be true', () => expect(form.$('user.passwordConfirm').hasError).to.be.true); + }); + + describe("Check joi validation errors", () => { + it('user.username error should equal joi error', () => expect(form.$('user.username').error).to.be.equal('"Username" length must be at least 3 characters long')); + it('user.email error should equal joi error', () => expect(form.$('user.email').error).to.be.equal('"user.email" must be a valid email')); + it('user.password error should equal joi error', () => expect(form.$('user.password').error).to.be.equal('"user.password" length must be at least 6 characters long')); + it('user.passwordConfirm error should equal joi error', () => expect(form.$('user.passwordConfirm').error).to.be.equal('Passwords do not match')); + }); + + } + } +}); diff --git a/tests/data/forms/nested/form.z4.ts b/tests/data/forms/nested/form.z4.ts new file mode 100644 index 00000000..ab063219 --- /dev/null +++ b/tests/data/forms/nested/form.z4.ts @@ -0,0 +1,80 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect } from "chai"; +import j from "joi"; +import FormInterface from "../../../../src/models/FormInterface"; +import OptionsModel from "../../../../src/models/OptionsModel"; +import { Form } from "../../../../src"; +import joi from "../../../../src/validators/JOI"; +import { ValidationPlugins } from "../../../../src/models/ValidatorInterface"; + + +const fields = [ + "products[].name", + "products[].qty", + "products[].amount", +]; + +const values = { + products: [{ + name: 'a', + qty: -1, + amount: -1, + }] +} + +// const schema = z.object({ +// products: z.array( +// z.object({ +// name: z.string().min(3), +// qty: z.number().min(0), +// amount: z.number().min(0), +// })) +// .optional(), +// }) + +const schema = j.object({ + products: j.array().items( + j.object({ + name: j.string().min(3).required(), + qty: j.number().integer().min(0).required(), + amount: j.number().min(0).required(), + }).optional() + ) + }); + +const plugins: ValidationPlugins = { + joi: joi({ + package: j, + schema, + }), +}; + +const options: OptionsModel = { + validateOnInit: true, + showErrorsOnInit: true, +}; + +export default new Form({ + fields, + values, +}, { + plugins, + options, + name: "Nested-Z4", + hooks: { + onInit(form: FormInterface) { + describe("Check joi validation flag", () => { + it('products[0].name hasError should be true', () => expect(form.$('products[0].name').hasError).to.be.true); + it('products[0].qty hasError should be true', () => expect(form.$('products[0].qty').hasError).to.be.true); + it('products[0].amount hasError should be true', () => expect(form.$('products[0].amount').hasError).to.be.true); + }); + + describe("Check joi validation errors", () => { + it('products[0].name error should equal joi error', () => expect(form.$('products[0].name').error).to.be.equal('"products[0].name" length must be at least 3 characters long')); + it('products[0].qty error should equal joi error', () => expect(form.$('products[0].qty').error).to.be.equal('"products[0].qty" must be greater than or equal to 0')); + it('products[0].amount error should equal joi error', () => expect(form.$('products[0].amount').error).to.be.equal('"products[0].amount" must be greater than or equal to 0')); + }); + + } + } +});