├── .all-contributorsrc ├── .babelrc ├── .github ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── draft-release.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── _config.yml ├── example ├── .gitignore ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── index.tsx │ ├── input.js │ ├── react-app-env.d.ts │ └── submit.js ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── form.tsx ├── index.ts ├── rules.ts ├── types.d.ts ├── use-field.ts ├── use-submit.ts └── validate.ts ├── tests ├── form.test.js └── helpers │ ├── form.tsx │ ├── input.tsx │ └── submit.tsx ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-validify", 3 | "projectOwner": "navjobs", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": true, 9 | "contributors": [ 10 | { 11 | "login": "zackify", 12 | "name": "Zach Silveira", 13 | "avatar_url": "https://avatars0.githubusercontent.com/u/449136?v=4", 14 | "profile": "https://zach.codes", 15 | "contributions": [] 16 | }, 17 | { 18 | "login": "audiolion", 19 | "name": "Ryan Castner", 20 | "avatar_url": "https://avatars1.githubusercontent.com/u/2430381?v=4", 21 | "profile": "http://audiolion.github.io", 22 | "contributions": [] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "production": { 4 | "presets": ["@babel/preset-react", "@babel/preset-typescript"] 5 | }, 6 | "test": { 7 | "presets": [ 8 | ["@babel/preset-env", { "targets": { "node": "current" } }], 9 | "@babel/preset-typescript", 10 | "@babel/preset-react" 11 | ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | - Bump the version in package.json (minor bump if adding features, major if breaking changes) 2 | - Ensure tests are passing (these are ran automatically when you commit) 3 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | - master 3 | name-template: '$NEXT_PATCH_VERSION' 4 | tag-template: '$NEXT_PATCH_VERSION' 5 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 6 | template: | 7 | ## Changes 8 | $CHANGES 9 | -------------------------------------------------------------------------------- /.github/workflows/draft-release.yml: -------------------------------------------------------------------------------- 1 | name: Draft Package Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | update_release_draft: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: release-drafter/release-drafter@v5 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | registry-url: https://registry.npmjs.org/ 16 | - name: Cache NPM dependencies 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: ${{ runner.OS }}-npm-cache-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | ${{ runner.OS }}-npm-cache- 23 | - run: yarn --frozen-lockfile 24 | - run: yarn build 25 | - run: yarn export-types 26 | - run: yarn publish --new-version ${GITHUB_REF:10} --no-git-tag-version 27 | env: 28 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [10.x, 12.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Cache NPM dependencies 20 | uses: actions/cache@v1 21 | with: 22 | path: node_modules 23 | key: ${{ runner.OS }}-npm-cache-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.OS }}-npm-cache- 26 | - name: yarn install, build, and test 27 | run: | 28 | yarn --frozen-lockfile 29 | yarn build 30 | yarn test --coverage 31 | yarn check-types 32 | env: 33 | CI: true 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | dist 3 | coverage 4 | node_modules 5 | yarn-error.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | .babelrc 3 | src 4 | .all-contributorsrc 5 | README.md 6 | coverage 7 | tests 8 | .github 9 | tsconfig.json 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "prisma.prisma", 5 | "golang.go", 6 | "ms-azuretools.vscode-docker", 7 | "s3gf4ult.monokai-vibrant", 8 | "svelte.svelte-vscode", 9 | "bradlc.vscode-tailwindcss", 10 | "zxh404.vscode-proto3" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorTheme": "Monokai Vibrant", 3 | "explorer.confirmDelete": false, 4 | "explorer.confirmDragAndDrop": false, 5 | "terminal.integrated.shell.osx": "/bin/zsh", 6 | "tailwindCSS.includeLanguages": { 7 | "plaintext": "html", 8 | "typescript": "javascript", 9 | "typescriptreact": "javascript" 10 | }, 11 | "editor.fontFamily": "Cascadia Code", 12 | "window.zoomLevel": 0, 13 | "typescript.updateImportsOnFileMove.enabled": "always", 14 | "[typescript]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[json]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "javascript.updateImportsOnFileMove.enabled": "always", 21 | "[typescriptreact]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "[javascript]": { 25 | "editor.defaultFormatter": "esbenp.prettier-vscode" 26 | }, 27 | "[html]": { 28 | "editor.defaultFormatter": "esbenp.prettier-vscode" 29 | }, 30 | "editor.tabSize": 2, 31 | "editor.insertSpaces": true, 32 | "[jsonc]": { 33 | "editor.defaultFormatter": "esbenp.prettier-vscode" 34 | }, 35 | "go.useLanguageServer": true, 36 | "editor.formatOnSave": true, 37 | "workbench.editorAssociations": [ 38 | { 39 | "viewType": "jupyter.notebook.ipynb", 40 | "filenamePattern": "*.ipynb" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 NavJobs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React Validify 2 | 3 | single dependency, simplest way to validate and manage form state with hooks in React + React Native! With full test coverage and TS support. 4 | 5 | ## Contents 6 | 7 | - [Install](#install) 8 | - [Getting Started](#getting-started) 9 | - [TypeScript Support](#typescript-support) 10 | - [Contributors](#contributors) 11 | 12 | ## Install 13 | 14 | ``` 15 | npm install react-validify lodash 16 | ``` 17 | 18 | ## Getting Started 19 | 20 | This api has been carefully thought out over the past year. It's been in use on multiple React websites and React Native mobile applications. Using the library is simple. Include the `Form` component, and wrap your `input`'s and `submit` buttons. 21 | 22 | ```js 23 | import Input from "./input"; 24 | import Submit from "./submit"; 25 | import { Form, rules } from "react-validify"; 26 | 27 | const { required, email } = rules; 28 | 29 | const App = () => { 30 | let [values, setValues] = React.useState({ 31 | email: "test", 32 | nested: { test: "this is nested" }, 33 | }); 34 | 35 | return ( 36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | ``` 47 | 48 | Add `useField` to your own inputs inside the Form wrapper. This allows you to use the library with any type of input field. 49 | It just needs to support a `handleChange` `handleBlur` and `value` prop. This is the `Input` component you see in the first example. Don't forget to pass the field `name` to the hook. 50 | 51 | ```js 52 | import React from "react"; 53 | import { useField, FieldProps } from "react-validify"; 54 | 55 | type Props = { placeholder: string } & FieldProps; 56 | 57 | const Input = ({ name, rules, placeholder }: Props) => { 58 | let { handleChange, handleBlur, value, errors } = useField({ name, rules }); 59 | 60 | return ( 61 |
62 | {errors ?

{errors[0]}

: null} 63 | handleChange(event.target.value)} 69 | /> 70 |
71 | ); 72 | }; 73 | ``` 74 | 75 | Add `useSubmit` to trigger submitting or validating: 76 | 77 | ```js 78 | import React from "react"; 79 | import { useSubmit } from "react-validify"; 80 | 81 | const Submit = (props) => { 82 | let { canSubmit, handleSubmit } = useSubmit(); 83 | 84 | return ( 85 |
handleSubmit((values) => console.log("submit!", values))} 87 | style={{ opacity: canSubmit ? 1 : 0.5 }} 88 | > 89 | Submit Form 90 |
91 | ); 92 | }; 93 | export default Submit; 94 | ``` 95 | 96 | The callback passed to `handleSubmit` will only be triggered if validation is passing. 97 | 98 | Create rules: 99 | 100 | ```js 101 | const testRule: RuleFn = (value, values) => 102 | value.length > values.date2.length ? "Date can't be longer" : null; 103 | ``` 104 | 105 | Rules get a `value` and `values` arguments. This means you can validate an input, or validate it against other form values. 106 | 107 | Rules are guaranteed to run on a field after the first time the field is blurred, and then any time an error is present, they will run onChange. 108 | 109 | ## TypeScript Support 110 | 111 | With TS enabled, you can create a type for your form values, like so: 112 | 113 | ```tsx 114 | type Values = { 115 | email: string; 116 | date1?: string; 117 | name?: string; 118 | }; 119 | ``` 120 | 121 | Now when we use the form, it looks like this: 122 | 123 | ```tsx 124 | let [values, setValues] = useState({ 125 | email: 'test', 126 | }); 127 | 128 | return ( 129 |
133 | 134 |
135 | ) 136 | } 137 | ``` 138 | 139 | ## Contributors 140 | 141 | Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)): 142 | 143 | 144 | 145 | | [
Zach Silveira](https://zach.codes)
| [
Ryan Castner](http://audiolion.github.io)
| 146 | | :---------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------: | 147 | 148 | 149 | 150 | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! 151 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/node": "^14.14.32", 7 | "@types/react": "^17.0.3", 8 | "@types/react-dom": "^17.0.2", 9 | "react": "^17.0.1", 10 | "react-dom": "^17.0.1", 11 | "react-scripts": "4.0.3", 12 | "react-validify": "6.0.3", 13 | "typescript": "4.2.3" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackify/validify/7ae44c557524d88b7d0878abd30ce33467dd68c1/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackify/validify/7ae44c557524d88b7d0878abd30ce33467dd68c1/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackify/validify/7ae44c557524d88b7d0878abd30ce33467dd68c1/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Input from "./input"; 4 | import Submit from "./submit"; 5 | import { required, email, RuleFn } from "react-validify/lib/rules"; 6 | import { Form } from "react-validify"; 7 | 8 | const greaterThanDate2: RuleFn = (value, values) => { 9 | if (!values.date2) return false; 10 | 11 | if (value.length < values.date2.length) 12 | return "Must be longer date than date 2"; 13 | }; 14 | 15 | type TestValues = { 16 | email: string; 17 | date1?: string; 18 | name?: string; 19 | }; 20 | 21 | const App = () => { 22 | let [values, setValues] = React.useState({ email: "test" }); 23 | 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /example/src/input.js: -------------------------------------------------------------------------------- 1 | import { useField } from "react-validify"; 2 | 3 | const Input = (props) => { 4 | let { handleChange, handleBlur, value, errors } = useField(props); 5 | return ( 6 |
7 | {errors ?

{errors[0]}

: null} 8 | handleChange(event.target.value)} 14 | /> 15 |
16 | ); 17 | }; 18 | export default Input; 19 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/submit.js: -------------------------------------------------------------------------------- 1 | import { useSubmit } from "react-validify"; 2 | 3 | const Submit = (props) => { 4 | let { canSubmit, handleSubmit } = useSubmit(); 5 | 6 | return ( 7 |
{ 9 | if (canSubmit) 10 | return handleSubmit((values) => console.log("submit!", values)); 11 | }} 12 | style={{ opacity: canSubmit ? 1 : 0.5 }} 13 | > 14 | Submit Form 15 |
16 | ); 17 | }; 18 | export default Submit; 19 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-validify", 3 | "version": "6.1.3", 4 | "description": "Form validation made easy", 5 | "main": "dist/index.js", 6 | "module": "lib/index.js", 7 | "directories": { 8 | "example": "example" 9 | }, 10 | "jest": { 11 | "testMatch": [ 12 | "/tests/**/*.test.js" 13 | ], 14 | "transform": { 15 | ".*": "/node_modules/babel-jest" 16 | }, 17 | "moduleFileExtensions": [ 18 | "js", 19 | "ts", 20 | "tsx" 21 | ], 22 | "collectCoverageFrom": [ 23 | "src/**" 24 | ], 25 | "setupFilesAfterEnv": [ 26 | "@testing-library/jest-dom/extend-expect" 27 | ], 28 | "collectCoverage": false 29 | }, 30 | "scripts": { 31 | "build": "NODE_ENV=production babel src --out-dir lib --extensions \".ts,.tsx,.js\" && NODE_ENV=production npx babel --plugins @babel/plugin-transform-modules-commonjs src --out-dir dist --extensions \".ts,.tsx,.js\"", 32 | "test": "jest", 33 | "check-types": "tsc --noEmit", 34 | "export-types": "tsc --emitDeclarationOnly --outDir dist && tsc --emitDeclarationOnly --outDir lib" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/navjobs/validify.git" 39 | }, 40 | "keywords": [ 41 | "form", 42 | "validation" 43 | ], 44 | "author": "Zach Silveira", 45 | "license": "ISC", 46 | "bugs": { 47 | "url": "https://github.com/navjobs/validify/issues" 48 | }, 49 | "homepage": "https://github.com/navjobs/validify#readme", 50 | "dependencies": {}, 51 | "peerDependencies": { 52 | "lodash": ">=4.0", 53 | "react": ">=16.8" 54 | }, 55 | "devDependencies": { 56 | "@babel/cli": "^7.13.10", 57 | "@babel/core": "^7.13.10", 58 | "@babel/plugin-transform-modules-commonjs": "^7.13.8", 59 | "@babel/preset-env": "^7.13.10", 60 | "@babel/preset-react": "^7.12.13", 61 | "@babel/preset-typescript": "^7.13.0", 62 | "@testing-library/jest-dom": "^5.11.9", 63 | "@testing-library/react": "^11.2.5", 64 | "@types/react": "^17.0.3", 65 | "babel-jest": "^26.6.3", 66 | "jest": "^26.6.3", 67 | "lodash": "^4.17.21", 68 | "react": "^17.0.1", 69 | "react-dom": "^17.0.1", 70 | "react-test-renderer": "^17.0.1", 71 | "typescript": "^4.2.3" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/form.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | Dispatch, 4 | SetStateAction, 5 | ReactNode, 6 | MutableRefObject, 7 | useRef, 8 | } from "react"; 9 | import { RuleFn } from "./rules"; 10 | import set from "lodash/set"; 11 | 12 | export type ValuesBlurred = { [key: string]: boolean }; 13 | export type RulesRef = { [key: string]: RuleFn[] }; 14 | 15 | export type Error = { 16 | name: string; 17 | message: string; 18 | }; 19 | 20 | type Context = { 21 | rules: MutableRefObject; 22 | values: any; 23 | errors: Error[]; 24 | valuesBlurred: ValuesBlurred; 25 | hasBlurred: (name: string) => any; 26 | setErrors: Dispatch>; 27 | updateValue: (name: string, value: string) => any; 28 | setValuesBlurred: Dispatch>; 29 | }; 30 | 31 | const FormContext = React.createContext({} as Context); 32 | 33 | export type FormProps = { 34 | values: Values; 35 | children: ReactNode; 36 | onValues: Dispatch>; 37 | }; 38 | 39 | function Form({ children, onValues, values }: FormProps) { 40 | let rules = useRef({}); 41 | let [errors, setErrors] = useState([]); 42 | let [valuesBlurred, setValuesBlurred] = useState({}); 43 | 44 | return ( 45 | { 54 | if (valuesBlurred[name]) return; 55 | //Store list of values that have been touched, so we can run validation on them now 56 | setValuesBlurred({ 57 | ...valuesBlurred, 58 | [name]: true, 59 | }); 60 | }, 61 | updateValue: (name, value) => { 62 | let newValues = { ...values }; 63 | set(newValues, name, value); 64 | onValues(newValues); 65 | }, 66 | }} 67 | > 68 | {children} 69 | 70 | ); 71 | } 72 | 73 | const FormContextConsumer = FormContext.Consumer; 74 | 75 | export { FormContext, Form, FormContextConsumer }; 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./form"; 2 | export * from "./use-field"; 3 | export * from "./use-submit"; 4 | import * as rules from "./rules"; 5 | 6 | export { rules }; 7 | -------------------------------------------------------------------------------- /src/rules.ts: -------------------------------------------------------------------------------- 1 | export type RuleFn = ( 2 | value: any, 3 | values: { [key: string]: string } 4 | ) => string | boolean | undefined | null; 5 | 6 | export type RuleFns = RuleFn | RuleFn[]; 7 | 8 | // Taken from Stackoverflow 9 | export const email: RuleFn = (value) => { 10 | var re = 11 | /^(([^<>()\[\]\\.,;:\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,}))$/; 12 | if (!re.test(String(value).toLowerCase())) return "Email address is invalid"; 13 | }; 14 | 15 | export const required: RuleFn = (value) => 16 | !value || !value.toString().length ? "This field is required" : null; 17 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'lodash/get'; 2 | declare module 'lodash/set'; 3 | -------------------------------------------------------------------------------- /src/use-field.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import get from "lodash/get"; 3 | import set from "lodash/set"; 4 | import validate from "./validate"; 5 | import { FormContext } from "./form"; 6 | import { RuleFns } from "./rules"; 7 | 8 | export type FieldProps = { 9 | name: string; 10 | rules?: RuleFns; 11 | }; 12 | 13 | export const useField = ({ name, rules: fieldRules }: FieldProps) => { 14 | const { 15 | errors, 16 | values, 17 | setErrors, 18 | hasBlurred, 19 | updateValue, 20 | valuesBlurred, 21 | rules, 22 | } = React.useContext(FormContext); 23 | 24 | //set the rules when they change, clear when unmounted 25 | useEffect(() => { 26 | //This check lets us pass in a single rule without making an array, or an array of rules 27 | if (Array.isArray(fieldRules)) { 28 | rules.current[name] = fieldRules; 29 | } else { 30 | rules.current[name] = fieldRules ? [fieldRules] : []; 31 | } 32 | 33 | return () => { 34 | rules.current[name] = []; 35 | }; 36 | }, [fieldRules]); 37 | 38 | let value = get(values, name); 39 | // Pulling out just this field's errors 40 | let fieldErrors = errors 41 | .filter((error) => error.name === name) 42 | .map((error) => error.message); 43 | 44 | //Args needed for validation 45 | let validationProps = { 46 | values, 47 | rules, 48 | valuesBlurred, 49 | setErrors, 50 | errors, 51 | }; 52 | 53 | const handleChange = (value: any, field?: string) => { 54 | /* 55 | If this field 56 | - has errors 57 | - blurred before 58 | - has dependant rules 59 | check validation on change 60 | */ 61 | if (fieldErrors.length || valuesBlurred[field || name]) { 62 | let newValues = { ...values }; 63 | set(newValues, field || name, value); 64 | 65 | validate({ 66 | ...validationProps, 67 | values: newValues, 68 | valuesBlurred: { ...valuesBlurred, [field || name]: true }, 69 | }); 70 | } 71 | 72 | updateValue(field || name, value); 73 | }; 74 | 75 | /* 76 | When blurring for the first time, 77 | lets send hasBlurred, and validate the field 78 | */ 79 | const handleBlur = () => { 80 | hasBlurred(name); 81 | validate({ 82 | ...validationProps, 83 | valuesBlurred: { ...valuesBlurred, [name]: true }, 84 | }); 85 | }; 86 | 87 | return { 88 | handleBlur, 89 | handleChange, 90 | value, 91 | values, 92 | errors: fieldErrors.length ? fieldErrors : null, 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /src/use-submit.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import validate from "./validate"; 3 | import { FormContext } from "./form"; 4 | 5 | export const useSubmit = () => { 6 | const { 7 | rules, 8 | values, 9 | errors, 10 | setErrors, 11 | setValuesBlurred, 12 | } = React.useContext(FormContext); 13 | 14 | const handleSubmit = (callback: (values: any) => any) => { 15 | let errors = validate({ 16 | values, 17 | rules, 18 | setErrors, 19 | }); 20 | if (!errors.length) return callback(values); 21 | 22 | // if there are errors, mark all values as blurred, 23 | // so validation runs on change after hitting submit 24 | setValuesBlurred( 25 | Object.keys(rules.current).reduce( 26 | (acc, name) => ({ ...acc, [name]: true }), 27 | {} 28 | ) 29 | ); 30 | }; 31 | 32 | return { 33 | values, 34 | errors, 35 | handleSubmit, 36 | canSubmit: !errors.length, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import get from "lodash/get"; 2 | import { Dispatch, MutableRefObject, SetStateAction } from "react"; 3 | import { Error, RulesRef } from "./form"; 4 | 5 | type Props = { 6 | values: any; 7 | rules: MutableRefObject; 8 | setErrors: Dispatch>; 9 | valuesBlurred?: { [key: string]: boolean }; 10 | }; 11 | 12 | export default ({ values, rules, setErrors, valuesBlurred }: Props) => { 13 | let newErrors = Object.keys(rules.current) 14 | .filter((field) => { 15 | if (valuesBlurred) return valuesBlurred[field]; 16 | return true; 17 | }) 18 | .map((field) => 19 | rules.current[field].map((rule) => { 20 | let error = rule(get(values, field), values); 21 | 22 | if (!error) return false; 23 | 24 | return { 25 | name: field, 26 | message: error, 27 | }; 28 | }) 29 | ) 30 | .reduce((acc, row) => [...acc, ...row], []) 31 | .filter(Boolean) as Error[]; 32 | 33 | setErrors(newErrors); 34 | return newErrors; 35 | }; 36 | -------------------------------------------------------------------------------- /tests/form.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, fireEvent, wait, act } from "@testing-library/react"; 3 | import { 4 | TestForm, 5 | TestFormWithRemovedField, 6 | TestFormWithSingleRule, 7 | } from "./helpers/form"; 8 | 9 | test("Checks dependent rule", async () => { 10 | let errorMessage = "Must be longer value than date 2 field"; 11 | let { queryByPlaceholderText, queryByText, getByText } = render(); 12 | 13 | // put a 2 character string in the date 2 field 14 | const date2 = queryByPlaceholderText("date2"); 15 | fireEvent.change(date2, { target: { value: 22 } }); 16 | 17 | // confirm the error message doesn't show yet 18 | const date1 = queryByPlaceholderText("date1"); 19 | expect(queryByText(errorMessage)).toBeNull(); 20 | 21 | // make the date1 field a single character, and blur it, which should trigger 22 | // the error message 23 | fireEvent.change(date1, { target: { value: 2 } }); 24 | fireEvent.blur(date1); 25 | 26 | // confirm the error message is displayed 27 | expect(getByText(errorMessage)).toBeInTheDocument(); 28 | }); 29 | 30 | test(`Validates fields that aren't changed, but their dependent fields changed`, async () => { 31 | let errorMessage = "Must be longer value than date 2 field"; 32 | let { queryByPlaceholderText, queryByText, getByText } = render(); 33 | 34 | const date1 = queryByPlaceholderText("date1"); 35 | const date2 = queryByPlaceholderText("date2"); 36 | 37 | //fill in both fields correctly 38 | fireEvent.change(date2, { target: { value: "short" } }); 39 | fireEvent.change(date1, { target: { value: "longer" } }); 40 | 41 | // blur them both to confirm errors should show immediately now 42 | fireEvent.blur(date1); 43 | fireEvent.blur(date2); 44 | 45 | // confirm no error yet 46 | expect(queryByText(errorMessage)).toBeNull(); 47 | 48 | // change date 2 field, which has no validation itself, and make sure 49 | // that date1 is validated on change 50 | fireEvent.change(date2, { target: { value: "longer than date1" } }); 51 | expect(getByText(errorMessage)).toBeInTheDocument(); 52 | }); 53 | 54 | test("Validation runs after blur", async () => { 55 | let { queryByPlaceholderText, queryByText } = render(); 56 | 57 | //blur the field 58 | const name = queryByPlaceholderText("name"); 59 | fireEvent.blur(name); 60 | 61 | //ensure the validation shows up 62 | expect(queryByText("This field is required")).toBeInTheDocument(); 63 | }); 64 | 65 | test("Validation runs on change after initial blur", async () => { 66 | let { queryByPlaceholderText, queryByText } = render(); 67 | 68 | const name = queryByPlaceholderText("name"); 69 | 70 | // blur out of the field 71 | fireEvent.blur(name); 72 | //blur twice, make sure nothing happens since we only care about the first time 73 | fireEvent.blur(name); 74 | 75 | // make sure the validation error shows up 76 | expect(queryByText("This field is required")).toBeInTheDocument(); 77 | 78 | //fill in the field with anything 79 | fireEvent.change(name, { target: { value: "filled" } }); 80 | 81 | //ensure the validation goes away 82 | expect(queryByText("This field is required")).toBeNull(); 83 | }); 84 | 85 | test("Validation runs after submit", async () => { 86 | let { queryByText } = render(); 87 | const submit = queryByText("Submit Form"); 88 | 89 | //ensure the validation isn't showing 90 | expect(queryByText("This field is required")).toBeNull(); 91 | 92 | //press the submit button 93 | submit.click(); 94 | 95 | //see if the validation is now showing for fields 96 | expect(queryByText("This field is required")).toBeInTheDocument(); 97 | expect(queryByText("Email address is invalid")).toBeInTheDocument(); 98 | }); 99 | 100 | test("Submit calls onSubmit if validation passes", async () => { 101 | const spy = jest.fn(); 102 | let { queryByPlaceholderText, queryByText } = render( 103 | 104 | ); 105 | const submit = queryByText("Submit Form"); 106 | 107 | //press the submit button 108 | submit.click(); 109 | 110 | //ensure onSubmit wasn't called, because validation failed 111 | expect(spy.mock.calls.length).toEqual(0); 112 | 113 | //fill in required fields, so validation will pass 114 | fireEvent.change(queryByPlaceholderText("name"), { 115 | target: { value: "test" }, 116 | }); 117 | fireEvent.change(queryByPlaceholderText("email"), { 118 | target: { value: "test@test.com" }, 119 | }); 120 | 121 | //press the submit button with passing validation 122 | submit.click(); 123 | 124 | //ensure onSubmit was called this time 125 | expect(spy.mock.calls.length).toEqual(1); 126 | }); 127 | 128 | test("Form works without rules object passed", async () => { 129 | let { queryByPlaceholderText } = render(); 130 | 131 | //blur the field 132 | const name = queryByPlaceholderText("name"); 133 | fireEvent.blur(name); 134 | fireEvent.change(name, { target: { value: "testing" } }); 135 | 136 | //ensure the validation shows up 137 | expect(name.value).toEqual("testing"); 138 | }); 139 | 140 | test("Empty input value gets passed undefined to rule fn", async () => { 141 | const spy = jest.fn(); 142 | let { queryByText } = render(); 143 | const submit = queryByText("Submit Form"); 144 | 145 | //press the submit button 146 | submit.click(); 147 | 148 | //ensure that the value given to the rule is an empty string if it wasnt touched 149 | expect(spy.mock.calls[0][0]).toEqual(undefined); 150 | expect(spy.mock.calls[0][1]).toEqual({ email: "test" }); 151 | }); 152 | 153 | test("Field validation shows errors on submit even without touching any fields", async () => { 154 | let { getByText } = render(); 155 | 156 | //trigger submit 157 | getByText("Submit Form").click(); 158 | 159 | //ensure the validation shows up 160 | expect(getByText("This field is required")).toBeInTheDocument(); 161 | }); 162 | 163 | test("Field validation runs on change, after submitting", async () => { 164 | let { queryByPlaceholderText, getByText } = render(); 165 | const name = queryByPlaceholderText("name"); 166 | 167 | //trigger submit 168 | getByText("Submit Form").click(); 169 | 170 | //change name to something valid 171 | fireEvent.change(name, { target: { value: "testing" } }); 172 | 173 | // change it back to invalid, and make sure the validation is shown 174 | fireEvent.change(name, { target: { value: "" } }); 175 | 176 | //ensure the validation shows up 177 | expect(getByText("This field is required")).toBeInTheDocument(); 178 | }); 179 | 180 | test(`Untouched fields shouldn't validate unless submitted first`, () => { 181 | let { queryByPlaceholderText, queryByText } = render(); 182 | const email = queryByPlaceholderText("email"); 183 | 184 | fireEvent.focus(email); 185 | fireEvent.change(email, { target: { value: "test@test.com" } }); 186 | fireEvent.blur(email); 187 | 188 | expect(queryByText("This field is required")).toBeNull(); 189 | }); 190 | 191 | test(`Doesnt check rule after component is unmounted`, async () => { 192 | const spy = jest.fn(); 193 | let { getByText, queryByPlaceholderText } = render( 194 | 195 | ); 196 | fireEvent.change(queryByPlaceholderText("email"), { 197 | target: { value: "" }, 198 | }); 199 | await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))); 200 | expect(queryByPlaceholderText("email")).toEqual(null); 201 | 202 | fireEvent.change(queryByPlaceholderText("name"), { 203 | target: { value: "testing" }, 204 | }); 205 | fireEvent.change(queryByPlaceholderText("date1"), { 206 | target: { value: "testing" }, 207 | }); 208 | fireEvent.change(queryByPlaceholderText("date2"), { 209 | target: { value: "test" }, 210 | }); 211 | 212 | getByText("Submit Form").click(); 213 | 214 | //it should call submit even though email is empty, because it was unmounted 215 | expect(spy.mock.calls[0][0].email).toEqual(""); 216 | }); 217 | 218 | test(`Passing in single rule without array works correctly`, () => { 219 | let { queryByPlaceholderText, queryByText } = render( 220 | 221 | ); 222 | const email = queryByPlaceholderText("email"); 223 | 224 | fireEvent.focus(email); 225 | fireEvent.change(email, { target: { value: "testtesst.com" } }); 226 | fireEvent.blur(email); 227 | 228 | expect(queryByText("Email address is invalid")).toBeTruthy(); 229 | fireEvent.change(email, { target: { value: "test@tesst.com" } }); 230 | fireEvent.blur(email); 231 | expect(queryByText("Email address is invalid")).toBeNull(); 232 | }); 233 | -------------------------------------------------------------------------------- /tests/helpers/form.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Form } from "../../src/form"; 3 | import { useState } from "react"; 4 | import { required, email, RuleFn } from "../../src/rules"; 5 | import Input from "./input"; 6 | import Submit from "./submit"; 7 | 8 | const greaterThanDate2 = (value, values) => { 9 | if (!values.date2) return false; 10 | 11 | if (value.length < values.date2.length) 12 | return "Must be longer value than date 2 field"; 13 | }; 14 | 15 | type Props = { 16 | noRules?: boolean; 17 | nameRule?: RuleFn; 18 | onSubmit?: (values: any) => any; 19 | unmountEmail?: boolean; 20 | }; 21 | 22 | type TestValues = { 23 | email: string; 24 | date1?: string; 25 | name?: string; 26 | }; 27 | 28 | export const TestForm = ({ 29 | onSubmit, 30 | noRules, 31 | nameRule, 32 | unmountEmail, 33 | }: Props) => { 34 | let [values, setValues] = useState({ email: "test" }); 35 | 36 | return ( 37 |
38 | {unmountEmail ? null : ( 39 | 40 | )} 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export const TestFormWithRemovedField = (props: Props) => { 50 | let [unmountEmail, setUnmount] = useState(false); 51 | 52 | useEffect(() => { 53 | setTimeout(() => setUnmount(true), 0); 54 | }, []); 55 | 56 | return ; 57 | }; 58 | 59 | export const TestFormWithSingleRule = ({ 60 | onSubmit, 61 | noRules, 62 | nameRule, 63 | unmountEmail, 64 | }: Props) => { 65 | let [values, setValues] = useState({ email: "test" }); 66 | 67 | return ( 68 |
69 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /tests/helpers/input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useField, FieldProps } from "../../src/use-field"; 3 | 4 | const Input = ({ name, rules }: FieldProps) => { 5 | let { handleChange, handleBlur, value, errors } = useField({ 6 | name, 7 | rules, 8 | }); 9 | return ( 10 |
11 | {errors ?

{errors[0]}

: null} 12 | handleChange(event.target.value)} 17 | /> 18 |
19 | ); 20 | }; 21 | export default Input; 22 | -------------------------------------------------------------------------------- /tests/helpers/submit.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSubmit } from "../../src/use-submit"; 3 | 4 | type Props = { 5 | onSubmit?: (values: any) => any; 6 | }; 7 | 8 | const Submit = ({ onSubmit }: Props) => { 9 | let { canSubmit, handleSubmit } = useSubmit(); 10 | 11 | return ( 12 |
{ 15 | if (canSubmit) { 16 | handleSubmit(onSubmit); 17 | } 18 | }} 19 | style={{ opacity: canSubmit ? 1 : 0.5 }} 20 | > 21 | Submit Form 22 |
23 | ); 24 | }; 25 | export default Submit; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "moduleResolution": "node", 10 | "strict": true, 11 | "target": "esnext", 12 | "baseUrl": "./src", 13 | "declaration": true 14 | }, 15 | "include": ["src"], 16 | "exclude": ["node_modules"] 17 | } 18 | --------------------------------------------------------------------------------