Skip to content

Commit

Permalink
feat: Add form validation
Browse files Browse the repository at this point in the history
  • Loading branch information
gabsima-nexapp committed Feb 16, 2021
1 parent 9de29a5 commit a7090ae
Show file tree
Hide file tree
Showing 14 changed files with 534 additions and 7 deletions.
41 changes: 40 additions & 1 deletion README.md
Expand Up @@ -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.
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<CreateAccountFormData> {

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());
}
};
```
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Expand Up @@ -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",
Expand All @@ -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"
}
}
88 changes: 88 additions & 0 deletions 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<any> | 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<any> | 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<any> | 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<any> | undefined => {
if (value !== toCompare) {
return {
field,
error: `${field}_isSame_${field}`,
};
}
};

export const isNumber = (field: string, value: unknown): InvalidFieldError<any> | undefined => {
if (value && !isFinite(Number(value))) {
return {
field,
error: `${field}_isNumber`,
};
}
};

export const hasLength = (length: number) =>
(field: string, value: unknown): InvalidFieldError<any> | 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<any> | undefined => {
if (typeof value !== "string") {
throw new Error("hasMinLength must be called on string");
}

if (value.length < length) {
return {
field,
error: `${field}_hasMinLength:${length}`,
};
}
};
30 changes: 30 additions & 0 deletions src/domain/form/validation/FormValidator.ts
@@ -0,0 +1,30 @@
import InvalidFieldError from "./InvalidFieldError";
import ValidatorRules from "./ValidationRules";

abstract class FormValidator<Fields> {
protected formData: Fields;
public rules: ValidatorRules;
public errors: InvalidFieldError<keyof Fields>[];

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;
6 changes: 6 additions & 0 deletions src/domain/form/validation/InvalidFieldError.ts
@@ -0,0 +1,6 @@
interface InvalidFieldError<Fields> {
error: string;
field: Fields;
}

export default InvalidFieldError;
5 changes: 5 additions & 0 deletions 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<any> | undefined)[];
}
164 changes: 164 additions & 0 deletions 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}` });
});
});
});
});

0 comments on commit a7090ae

Please sign in to comment.