diff --git a/README.md b/README.md index fd8652c..380501d 100644 --- a/README.md +++ b/README.md @@ -18,4 +18,43 @@ For example you have an `initialData` like this: ``` When giving an object like this to the `useFormData` hook, the `onChange` method will be callable like this: `onChange("details")("name")("newValue")`. Which will change the `formData` from the hook with the updated value in the details.name field. For the moment this method can only go one level 2 level deep so any complex objects will have to be update entirely when calling this method. -We use this hook to simplify our forms. We can then structure our form to represent each section of the data structure. Then each section will receive the underlying section. We can then give them `formData.details` as value and `onChange("details")` as onChange method. Giving us maximum flexibility to add/remove/change fields of the data structure without impacting the props we give to each components. \ No newline at end of file +We use this hook to simplify our forms. We can then structure our form to represent each section of the data structure. Then each section will receive the underlying section. We can then give them `formData.details` as value and `onChange("details")` as onChange method. Giving us maximum flexibility to add/remove/change fields of the data structure without impacting the props we give to each components. + + +### Form Validation +This library exposes a FormValidator to validate data received from the `useFormData` hook. It is possible to configure a FormValidator by extending the class, and passing the interface of your form data structure as template. You can then configure a set of rules for each property of the object. For exemple: + +```typescript +interface CreateAccountFormData { + name: string; + email: string; + password: string; + confirmedPassword: string; +} + +class CreateAccountFormValidator + extends FormValidator { + + constructor(formData: CreateAccountFormData) { + super(formData); + this.rules = ({ + name: [isEmpty], + email: [isEmpty, isEmail], + password: [isEmpty, isPassword], + confirmedPassword: [isEmpty, isSame(formData.password)], + }); + } +} +``` + +A set of rules is given with the library but you can define your own rules by respecting the same signature. All you have to do after is to give your form data to your validator and call the `validate` method. Which will update the validator's error property. + +```typescript +const submit = (): void => { + const validator = new CreateAccountFormValidator(account); + validator.validate(); + if (validator.hasError()) { + setErrors(validator.getMessages()); + } +}; +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cdd4da7..1bd948d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -761,6 +761,15 @@ "@types/lodash": "*" } }, + "@types/lodash.isfinite": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/lodash.isfinite/-/lodash.isfinite-3.3.6.tgz", + "integrity": "sha512-BOUL9F8i72Ds1mHphd2YBGjQzdTTwN31DgiKAxLm8MS1IcNE577QtRxi90A6qGYX6zRb4S3RbqShB+6lgr7q2A==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/node": { "version": "14.14.22", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz", @@ -3094,6 +3103,11 @@ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" }, + "lodash.isfinite": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz", + "integrity": "sha1-+4m2WpqAKBgz8LdHizpRBPiY67M=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/package.json b/package.json index 6078d20..bbb34e0 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "devDependencies": { "@testing-library/react-hooks": "^5.0.3", "@types/jest": "^26.0.20", + "@types/lodash.isfinite": "^3.3.6", "@types/react": "17.0.0", "jest": "^26.6.3", "react": "16.9.x", @@ -37,6 +38,7 @@ "homepage": "https://github.com/Nexapp/nexapp-react-forms#readme", "dependencies": { "@types/lodash.isequal": "^4.5.5", - "lodash.isequal": "^4.5.0" + "lodash.isequal": "^4.5.0", + "lodash.isfinite": "^3.3.2" } } diff --git a/src/domain/form/validation/FormValidations.ts b/src/domain/form/validation/FormValidations.ts new file mode 100644 index 0000000..8cff66e --- /dev/null +++ b/src/domain/form/validation/FormValidations.ts @@ -0,0 +1,88 @@ +import isFinite from "lodash.isfinite"; +import { emailRegex, passwordRegex } from "./inputRegex"; +import InvalidFieldError from "./InvalidFieldError"; + +export const isEmpty = (field: string, value: unknown): InvalidFieldError | undefined => { + if (typeof value !== "string") { + throw new Error("isEmpty must be called on string"); + } + + if (!value) { + return { + field, + error: `${field}_isEmpty`, + }; + } +}; + +export const isEmail = (field: string, value: unknown): InvalidFieldError | undefined => { + if (typeof value !== "string") { + throw new Error("isEmail must be called on string"); + } + + if (value && !(emailRegex.test(value))) { + return { + field, + error: `${field}_isEmail`, + }; + } +}; + +export const isPassword = (field: string, value: unknown): InvalidFieldError | undefined => { + if (typeof value !== "string") { + throw new Error("isPassword must be called on string"); + } + + if (value && !(passwordRegex.test(value))) { + return { + field, + error: `${field}_isPassword`, + }; + } +}; + +export const isSame = (toCompare: unknown) => (field: string, value: unknown): InvalidFieldError | undefined => { + if (value !== toCompare) { + return { + field, + error: `${field}_isSame_${field}`, + }; + } +}; + +export const isNumber = (field: string, value: unknown): InvalidFieldError | undefined => { + if (value && !isFinite(Number(value))) { + return { + field, + error: `${field}_isNumber`, + }; + } +}; + +export const hasLength = (length: number) => + (field: string, value: unknown): InvalidFieldError | undefined => { + if (typeof value !== "string") { + throw new Error("hasLength must be called on string"); + } + + if (value.length !== length) { + return { + field, + error: `${field}_hasLength:${length}`, + }; + } + }; + +export const hasMinLength = (length: number) => + (field: string, value: unknown): InvalidFieldError | undefined => { + if (typeof value !== "string") { + throw new Error("hasMinLength must be called on string"); + } + + if (value.length < length) { + return { + field, + error: `${field}_hasMinLength:${length}`, + }; + } + }; diff --git a/src/domain/form/validation/FormValidator.ts b/src/domain/form/validation/FormValidator.ts new file mode 100644 index 0000000..d1f6d05 --- /dev/null +++ b/src/domain/form/validation/FormValidator.ts @@ -0,0 +1,30 @@ +import InvalidFieldError from "./InvalidFieldError"; +import ValidatorRules from "./ValidationRules"; + +abstract class FormValidator { + protected formData: Fields; + public rules: ValidatorRules; + public errors: InvalidFieldError[]; + + constructor(formData: Fields) { + this.formData = formData; + this.errors = []; + this.rules = {}; + } + + public validate = (): void => { + this.errors = []; + Object.entries(this.formData).map(([field, value]) => { + this.rules[field].map((fn) => { + const error = fn(field, value); + if (error) { + this.errors.push(error); + } + }); + }); + }; + + public hasError = (): boolean => this.errors.length > 0; +} + +export default FormValidator; diff --git a/src/domain/form/validation/InvalidFieldError.ts b/src/domain/form/validation/InvalidFieldError.ts new file mode 100644 index 0000000..3a9b891 --- /dev/null +++ b/src/domain/form/validation/InvalidFieldError.ts @@ -0,0 +1,6 @@ +interface InvalidFieldError { + error: string; + field: Fields; +} + +export default InvalidFieldError; diff --git a/src/domain/form/validation/ValidationRules.ts b/src/domain/form/validation/ValidationRules.ts new file mode 100644 index 0000000..7ba8b0a --- /dev/null +++ b/src/domain/form/validation/ValidationRules.ts @@ -0,0 +1,5 @@ +import InvalidFieldError from "./InvalidFieldError"; + +export default interface ValidatorRules { + [key: string]: ((field: string, value: unknown) => InvalidFieldError | undefined)[]; +} diff --git a/src/domain/form/validation/__tests__/FormValidations.test.ts b/src/domain/form/validation/__tests__/FormValidations.test.ts new file mode 100644 index 0000000..b201dc9 --- /dev/null +++ b/src/domain/form/validation/__tests__/FormValidations.test.ts @@ -0,0 +1,164 @@ +import { + isEmpty, isEmail, isNumber, hasLength, hasMinLength, +} from "../FormValidations"; +import "jest"; + +describe("FormValidations", () => { + const FIELD_NAME = "FIELD_NAME"; + describe("isEmpty", () => { + describe("given a non string value", () => { + it("should throw", () => { + const value = {}; + expect(() => isEmpty(FIELD_NAME, value as unknown as string)).toThrow(); + }); + }); + + describe("given an empty value", () => { + it("should return an error", () => { + const value = ""; + expect(isEmpty(FIELD_NAME, value)).toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_isEmpty` }); + }); + }); + + describe("given a valid value", () => { + it("should not return no error", () => { + const value = "value"; + expect(isEmpty(FIELD_NAME, value)).toBe(undefined); + }); + }); + }); + + describe("isEmail", () => { + describe("given a non string value", () => { + it("should throw", () => { + const value = {}; + expect(() => isEmail(FIELD_NAME, value as unknown as string)).toThrow(); + }); + }); + + describe("given an empty value", () => { + it("should return no error", () => { + const value = ""; + expect(isEmail(FIELD_NAME, value)).toBe(undefined); + }); + }); + + describe("given a valid email", () => { + it("should return no error", () => { + const value = "email@exemple.ca"; + expect(isEmail(FIELD_NAME, value)).toBe(undefined); + }); + }); + + describe("given a custom email doamin", () => { + it("should return no error", () => { + const value = "email+something@nexapp.ca"; + expect(isEmail(FIELD_NAME, value)).toBe(undefined); + }); + }); + + describe("given an invalid email", () => { + it("should return an error", () => { + const value = "notAnEmail"; + expect(isEmail(FIELD_NAME, value)).toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_isEmail` }); + }); + }); + }); + + describe("isNumber", () => { + describe("given a string containing only numbers", () => { + it("should return no error", () => { + const value = "123"; + expect(isNumber(FIELD_NAME, value)).toBe(undefined); + }); + }); + + describe("given an string containing letters", () => { + it("should return an error", () => { + const value = "notANumber"; + expect(isNumber(FIELD_NAME, value)).toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_isNumber` }); + }); + }); + }); + + describe("hasLength", () => { + const LENGTH = 10; + describe("given a non string or array value", () => { + it("should throw", () => { + const value = {}; + expect(() => hasLength(LENGTH)(FIELD_NAME, value as unknown as string)).toThrow(); + }); + }); + + describe("given an empty value", () => { + it("should return an error", () => { + const value = ""; + expect(hasLength(LENGTH)(FIELD_NAME, value)) + .toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_hasLength:${LENGTH}` }); + }); + }); + + describe("given a value equal to given lenght", () => { + it("should return no error", () => { + const value = "1234567890"; + expect(hasLength(LENGTH)(FIELD_NAME, value)).toBe(undefined); + }); + }); + + describe("given a value longer than length", () => { + it("should return an error", () => { + const value = "1234567890123456789"; + expect(hasLength(LENGTH)(FIELD_NAME, value)) + .toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_hasLength:${LENGTH}` }); + }); + }); + + describe("given a value shorter than length", () => { + it("should return an error", () => { + const value = "123"; + expect(hasLength(LENGTH)(FIELD_NAME, value)) + .toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_hasLength:${LENGTH}` }); + }); + }); + }); + + describe("hasMinLength", () => { + const LENGTH = 10; + describe("given a non string or array value", () => { + it("should throw", () => { + const value = {}; + expect(() => hasMinLength(LENGTH)(FIELD_NAME, value as unknown as string)).toThrow(); + }); + }); + + describe("given an empty value", () => { + it("should return an error", () => { + const value = ""; + expect(hasMinLength(LENGTH)(FIELD_NAME, value)) + .toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_hasMinLength:${LENGTH}` }); + }); + }); + + describe("given a value equal to given length", () => { + it("should return no error", () => { + const value = "1234567890"; + expect(hasMinLength(LENGTH)(FIELD_NAME, value)).toBe(undefined); + }); + }); + + describe("given a value longer to given length", () => { + it("should return no error", () => { + const value = "1234567890123456789"; + expect(hasMinLength(LENGTH)(FIELD_NAME, value)).toBe(undefined); + }); + }); + + describe("given a value shorter than minimum length", () => { + it("should return an error", () => { + const value = "123"; + expect(hasMinLength(LENGTH)(FIELD_NAME, value)) + .toEqual({ field: FIELD_NAME, error: `${FIELD_NAME}_hasMinLength:${LENGTH}` }); + }); + }); + }); +}); diff --git a/src/domain/form/validation/__tests__/FormValidator.test.ts b/src/domain/form/validation/__tests__/FormValidator.test.ts new file mode 100644 index 0000000..2bbecac --- /dev/null +++ b/src/domain/form/validation/__tests__/FormValidator.test.ts @@ -0,0 +1,89 @@ +/* eslint-disable max-classes-per-file */ +import FormValidator from "../FormValidator"; +import { isEmpty, isNumber } from "../FormValidations"; + +describe("FormValidator", () => { + let formValidator: FormValidator; + + describe("given a first field", () => { + describe("and only isEmpty rule", () => { + beforeEach(() => { + const formData: FormDataTest ={ + field1: "", + field2: "123", + }; + formValidator = new FormValidatorTest(formData); + formValidator.validate(); + }); + + describe("when the value is empty", () => { + it("should have errors", () => { + expect(formValidator.hasError()).toBeTruthy(); + }); + }); + }); + }); + + describe("given a second field", () => { + describe("and isEmpty and isNumber rules", () => { + beforeEach(() => { + const formData: FormDataTest = { + field1: "something", + field2: "", + }; + formValidator = new FormValidatorTest(formData); + formValidator.validate(); + }); + describe("when the value is empty", () => { + it("should have errors", () => { + expect(formValidator.hasError()).toBeTruthy(); + }); + }); + }); + + describe("when the value is an invalid number", () => { + beforeEach(() => { + const formData: FormDataTest = { + field1: "something", + field2: "a", + }; + formValidator = new FormValidatorTest(formData); + formValidator.validate(); + }); + + it("should have errors", () => { + expect(formValidator.hasError()).toBeTruthy(); + }); + }); + }); + + describe("given valid data", () => { + beforeEach(() => { + const formData: FormDataTest = { + field1: "something", + field2: "123", + }; + formValidator = new FormValidatorTest(formData); + formValidator.validate(); + }); + + it("should not have any errors", () => { + expect(formValidator.hasError()).toBeFalsy(); + }); + }); +}); + +class FormValidatorTest extends FormValidator { + constructor(formData: any) { + super(formData); + this.rules = ({ + field1: [isEmpty], + field2: [isEmpty, isNumber], + }); + } +} + +interface FormDataTest { + field1: string; + field2: string; +} diff --git a/src/domain/form/validation/__tests__/inputRegex.test.ts b/src/domain/form/validation/__tests__/inputRegex.test.ts new file mode 100644 index 0000000..9e48edd --- /dev/null +++ b/src/domain/form/validation/__tests__/inputRegex.test.ts @@ -0,0 +1,85 @@ +import { passwordRegex } from "../inputRegex"; + +describe("inputRegex", () => { + + beforeEach(() => { + jest.clearAllMocks().resetModules(); + }); + + describe("passwordRegex", () => { + it("should be invalid given a password with only a special character", () => { + const invalidPassword = "."; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password with only special characters and a length of 8+ characters", () => { + const invalidPassword = "^$*.[]{}()?-!@#%&/\,><':;|_~"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password with only an alphanumeric character", () => { + const invalidPassword = "c"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password with only alphanumeric characters and a length of 8+ characters", () => { + const invalidPassword = "Abcd1234"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password with only a caps character", () => { + const invalidPassword = "K"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password with only caps characters and a length of 8+ characters", () => { + const invalidPassword = "ABCDEFGHI"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password with only a number", () => { + const invalidPassword = "42"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password with only caps numbers and a length of 8+ characters", () => { + const invalidPassword = "87654321"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password without a special character", () => { + const invalidPassword = "Harry4242"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password without a caps character", () => { + const invalidPassword = "harry6742!"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be invalid given a password without a length of 8+ characters", () => { + const invalidPassword = "Harry4@"; + + expect(passwordRegex.test(invalidPassword)).toBeFalsy(); + }); + + it("should be valid given a password with at least a caps character, a number, a special character" + + "and a length of 8+ characters", () => { + const validPassword = "H42.arry"; + + expect(passwordRegex.test(validPassword)).toBeTruthy(); + }); + + }); + +}); diff --git a/src/domain/form/validation/index.ts b/src/domain/form/validation/index.ts new file mode 100644 index 0000000..6912437 --- /dev/null +++ b/src/domain/form/validation/index.ts @@ -0,0 +1,4 @@ +export * as FormValidator from "./FormValidator"; +export * as InvalidFieldError from "./InvalidFieldError"; +export * as ValidationRules from "./ValidationRules"; +export * as FormValidations from "./FormValidations"; \ No newline at end of file diff --git a/src/domain/form/validation/inputRegex.ts b/src/domain/form/validation/inputRegex.ts new file mode 100644 index 0000000..5d74765 --- /dev/null +++ b/src/domain/form/validation/inputRegex.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[\^\$\*\.\[\]\{\}\(\)\?\-\"\!\@\#\%\&\/\\\,\>\<\'\:\;\|\_\~\`])(?=.{8,})/; + +export { emailRegex, passwordRegex }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..dca68b1 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * as useFormData from "./useFormData"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 9d7a21f..0000000 --- a/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useFormData from "./hooks/useFormData"; - -export { - useFormData -} \ No newline at end of file