├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── cypress.json ├── docs ├── .gitignore ├── package.json ├── src │ ├── .vuepress │ │ ├── components │ │ │ ├── demo.vue │ │ │ ├── index.html │ │ │ ├── main.js │ │ │ ├── package.json.js │ │ │ └── style.css │ │ ├── config.js │ │ ├── demos │ │ │ ├── async-validation.vue │ │ │ ├── basic.vue │ │ │ ├── displaying-errors.vue │ │ │ ├── main.js │ │ │ ├── manually-validation.vue │ │ │ ├── nested-data.vue │ │ │ ├── sync-validation.vue │ │ │ └── validation-mode.vue │ │ ├── enhanceApp.js │ │ └── styles │ │ │ ├── index.styl │ │ │ └── palette.styl │ ├── api │ │ └── README.md │ ├── guide │ │ ├── README.md │ │ ├── Validation.md │ │ ├── errors.md │ │ ├── get-started.md │ │ └── nested-data.md │ └── index.md └── yarn.lock ├── example ├── App.vue ├── assets │ └── logo.png ├── components │ ├── Demo.vue │ └── Input.vue └── main.ts ├── jest.config.js ├── package.json ├── public ├── favicon.ico └── index.html ├── rollup.config.js ├── shims-vue.d.ts ├── src ├── deepValidator.ts ├── index.ts ├── useForm.ts └── utils.ts ├── tests ├── a.test.ts ├── unit │ ├── deepValidator.spec.ts │ └── useForm.spec.tsx └── utils.ts ├── tsconfig.json ├── vue.config.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !/docs/src/.vuepress -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | '@vue/airbnb', 9 | '@vue/typescript/recommended', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020, 13 | }, 14 | rules: { 15 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 17 | semi: ["error", "never"], 18 | "@typescript-eslint/ban-ts-ignore": "off", 19 | "@typescript-eslint/no-explicit-any": "off", 20 | "@typescript-eslint/no-var-requires": "off", 21 | "max-len": "warn", 22 | "global-require": "off", 23 | "import/no-webpack-loader-syntax": "off" 24 | }, 25 | overrides: [ 26 | { 27 | files: [ 28 | '**/__tests__/*.{j,t}s?(x)', 29 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 30 | ], 31 | env: { 32 | jest: true, 33 | }, 34 | }, 35 | ], 36 | }; 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [beizhedenglong] 2 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master, dev ] 9 | pull_request: 10 | branches: [ master, dev ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x, 14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: yarn install, build 28 | run: | 29 | yarn 30 | yarn build 31 | 32 | - name: yarn test 33 | uses: mattallty/jest-github-action@v1.0.3 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | with: 37 | test-command: "yarn test" 38 | 39 | - name: Coveralls 40 | uses: coverallsapp/github-action@master 41 | with: 42 | github-token: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | 9 | # local env files 10 | .env.local 11 | .env.*.local 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | pnpm-debug.log* 18 | 19 | # Editor directories and files 20 | .idea 21 | .vscode 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | coverage 29 | 30 | exampleDist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Victor 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 | # Vue Hooks Form · [![license](https://img.shields.io/github/license/beizhedenglong/vue-hooks-form)](https://github.com/beizhedenglong/vue-hooks-form/blob/master/LICENSE) ![build status](https://github.com/beizhedenglong/vue-hooks-form/workflows/Node.js%20CI/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/beizhedenglong/vue-hooks-form/badge.svg?branch=master)](https://coveralls.io/github/beizhedenglong/vue-hooks-form?branch=master) 2 | Building forms with Vue composition API. 3 | >The API is not stable and might change in the future. 4 | 5 | ## Docs 6 | Visit https://beizhedenglong.github.io/vue-hooks-form/. 7 | 8 | ## Installation 9 | 10 | ``` 11 | yarn add vue-hooks-form 12 | ``` 13 | ## Features 14 | - UI decoupling: Since It does not contain any UI code, It can be easily integrated with other UI libraries. 15 | - Easy to adoptable: Since form state is inherently local and ephemeral, it can be easily adopted. 16 | - Easy to use: No fancy stuffs, just reactive values/errors. 17 | - TypeScript support. 18 | 19 | ## Quickstart 20 | ```vue 21 | 32 | 33 | 60 | ``` 61 | ## Live Demo 62 | [![Edit Vue Hooks Form Demo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/vue-hooks-form-demo-lqtp0?fontsize=14&hidenavigation=1&theme=dark) 63 | 64 | 65 | ## API(TODO) 66 | 67 | ### `useForm` 68 | ```js 69 | const { 70 | values, 71 | getFieldValues, 72 | errors, 73 | validateFields, 74 | validateField, 75 | get, 76 | set, 77 | useField, 78 | handleSubmit 79 | } = useForm({ 80 | defaultValues: {}, 81 | shouldUnregister: true, 82 | validateMode: 'change', 83 | }) 84 | ``` 85 | 86 | ### `useField` 87 | ```js 88 | const { 89 | ref, 90 | value, 91 | error, 92 | validate 93 | } = useField(path, options) 94 | ``` 95 | 96 | 97 | ## Credits 98 | This project was inspired by [react-hook-form](https://react-hook-form.com/), [formik](https://formik.org), and many other form libraries. -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | pids 2 | logs 3 | node_modules 4 | npm-debug.log 5 | coverage/ 6 | run 7 | dist 8 | .DS_Store 9 | .nyc_output 10 | .basement 11 | config.local.js 12 | basement_dist 13 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-hooks-form", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "authors": { 7 | "name": "", 8 | "email": "" 9 | }, 10 | "repository": "/vue-hooks-form", 11 | "scripts": { 12 | "dev": "vuepress dev src", 13 | "build": "vuepress build src" 14 | }, 15 | "license": "MIT", 16 | "devDependencies": { 17 | "vuepress": "^1.5.3" 18 | }, 19 | "dependencies": { 20 | "codesandbox": "^2.2.1", 21 | "raw-loader": "^4.0.2", 22 | "vue-hooks-form": "^0.1.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/demo.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 86 | 87 | 89 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%= htmlWebpackPlugin.options.title %> 11 | 12 | 13 | 14 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import "./style.css" 4 | 5 | createApp(App).mount('#app') 6 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/package.json.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'Demo', 3 | version: '0.1.0', 4 | private: true, 5 | scripts: { 6 | serve: 'vue-cli-service serve', 7 | build: 'vue-cli-service build', 8 | }, 9 | dependencies: { 10 | 'core-js': '^3.6.5', 11 | vue: '^3.0.0-0', 12 | 'vue-hooks-form': '>=0.2.2', 13 | }, 14 | devDependencies: { 15 | '@vue/cli-plugin-babel': '~4.5.0', 16 | '@vue/cli-service': '~4.5.0', 17 | '@vue/compiler-sfc': '^3.0.0-0', 18 | }, 19 | browserslist: ['> 1%', 'last 2 versions', 'not dead'], 20 | keywords: [], 21 | description: '', 22 | } 23 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: rgb(21, 21, 21); 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | } 7 | 8 | form { 9 | max-width: 500px; 10 | margin: 0 auto; 11 | } 12 | 13 | h1 { 14 | font-weight: 100; 15 | color: white; 16 | text-align: center; 17 | padding-bottom: 10px; 18 | border-bottom: 1px solid rgb(79, 98, 148); 19 | } 20 | 21 | .form { 22 | background: #0e101c; 23 | max-width: 400px; 24 | margin: 0 auto; 25 | } 26 | 27 | p { 28 | color: #DA5961; 29 | } 30 | 31 | p::before { 32 | display: inline; 33 | content: "⚠ "; 34 | } 35 | 36 | input { 37 | display: block; 38 | box-sizing: border-box; 39 | width: 100%; 40 | border-radius: 4px; 41 | border: 1px solid white; 42 | padding: 10px 15px; 43 | margin-bottom: 10px; 44 | font-size: 14px; 45 | outline: none; 46 | } 47 | 48 | label { 49 | line-height: 2; 50 | text-align: left; 51 | display: block; 52 | margin-bottom: 13px; 53 | margin-top: 20px; 54 | color: white; 55 | font-size: 14px; 56 | font-weight: 200; 57 | } 58 | 59 | button[type="submit"], 60 | input[type="submit"] { 61 | background: #3eaf7c; 62 | color: white; 63 | text-transform: uppercase; 64 | border: none; 65 | margin-top: 40px; 66 | padding: 20px; 67 | font-size: 16px; 68 | font-weight: 100; 69 | letter-spacing: 10px; 70 | width: 100%; 71 | } 72 | 73 | button[type="submit"]:hover, 74 | input[type="submit"]:hover { 75 | background: #4abf8a; 76 | } 77 | 78 | button[type="submit"]:active, 79 | input[type="button"]:active, 80 | input[type="submit"]:active { 81 | transition: 0.3s all; 82 | transform: translateY(3px); 83 | border: 1px solid transparent; 84 | opacity: 0.8; 85 | } 86 | 87 | input:disabled { 88 | opacity: 0.4; 89 | } 90 | 91 | input[type="button"]:hover { 92 | transition: 0.3s all; 93 | } 94 | 95 | button[type="submit"], 96 | input[type="button"], 97 | input[type="submit"] { 98 | -webkit-appearance: none; 99 | } 100 | 101 | .App { 102 | max-width: 600px; 103 | margin: 0 auto; 104 | } 105 | 106 | button[type="button"] { 107 | display: block; 108 | appearance: none; 109 | background: #333; 110 | color: white; 111 | border: none; 112 | text-transform: uppercase; 113 | padding: 10px 20px; 114 | border-radius: 4px; 115 | } 116 | 117 | hr { 118 | margin-top: 30px; 119 | } 120 | 121 | button { 122 | display: block; 123 | appearance: none; 124 | margin-top: 40px; 125 | border: 1px solid #333; 126 | margin-bottom: 20px; 127 | text-transform: uppercase; 128 | padding: 10px 8px; 129 | border-radius: 4px; 130 | outline: none; 131 | } 132 | -------------------------------------------------------------------------------- /docs/src/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | const { description } = require('../../package') 2 | 3 | module.exports = { 4 | /** 5 | * Ref:https://v1.vuepress.vuejs.org/config/#title 6 | */ 7 | title: 'Vue Hooks Form', 8 | /** 9 | * Ref:https://v1.vuepress.vuejs.org/config/#description 10 | */ 11 | description: 'Building forms with vue composition API.', 12 | base: process.env.NODE_ENV === 'production' 13 | ? '/vue-hooks-form/' 14 | : '/', 15 | 16 | /** 17 | * Extra tags to be injected to the page HTML `` 18 | * 19 | * ref:https://v1.vuepress.vuejs.org/config/#head 20 | */ 21 | head: [ 22 | ['meta', { name: 'theme-color', content: '#3eaf7c' }], 23 | ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], 24 | ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], 25 | ], 26 | 27 | /** 28 | * Theme configuration, here is the default theme configuration for VuePress. 29 | * 30 | * ref:https://v1.vuepress.vuejs.org/theme/default-theme-config.html 31 | */ 32 | themeConfig: { 33 | repo: '', 34 | editLinks: false, 35 | docsDir: '', 36 | editLinkText: '', 37 | lastUpdated: false, 38 | nav: [ 39 | { 40 | text: 'Guide', 41 | link: '/guide/', 42 | }, 43 | { 44 | text: 'API', 45 | link: '/api/', 46 | }, 47 | { 48 | text: 'Github', 49 | link: 'https://github.com/beizhedenglong/vue-hooks-form.git', 50 | }, 51 | ], 52 | sidebar: { 53 | '/guide/': [ 54 | { 55 | title: 'Guide', 56 | collapsable: false, 57 | children: [ 58 | '', 59 | 'get-started', 60 | 'validation', 61 | 'errors', 62 | 'nested-data', 63 | ], 64 | }, 65 | ], 66 | '/api/': [ 67 | { 68 | title: 'API', 69 | collapsable: false, 70 | children: [ 71 | '', 72 | ], 73 | }, 74 | ], 75 | }, 76 | }, 77 | 78 | /** 79 | * Apply plugins,ref:https://v1.vuepress.vuejs.org/zh/plugin/ 80 | */ 81 | plugins: [ 82 | '@vuepress/plugin-back-to-top', 83 | '@vuepress/plugin-medium-zoom', 84 | ], 85 | } 86 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/async-validation.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 41 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/basic.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 40 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/displaying-errors.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 38 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import basic from './basic.vue' 3 | 4 | createApp(basic).mount('#app') 5 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/manually-validation.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 45 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/nested-data.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/sync-validation.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 41 | -------------------------------------------------------------------------------- /docs/src/.vuepress/demos/validation-mode.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 39 | -------------------------------------------------------------------------------- /docs/src/.vuepress/enhanceApp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Client app enhancement file. 3 | * 4 | * https://v1.vuepress.vuejs.org/guide/basic-config.html#app-level-enhancements 5 | */ 6 | 7 | export default ({ 8 | Vue, // the version of Vue being used in the VuePress app 9 | options, // the options for the root Vue instance 10 | router, // the router instance for the app 11 | siteData, // site metadata 12 | }) => { 13 | // ...apply enhancements for the site. 14 | } 15 | -------------------------------------------------------------------------------- /docs/src/.vuepress/styles/index.styl: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom Styles here. 3 | * 4 | * ref:https://v1.vuepress.vuejs.org/config/#index-styl 5 | */ 6 | 7 | .home .hero img 8 | max-width 450px!important 9 | -------------------------------------------------------------------------------- /docs/src/.vuepress/styles/palette.styl: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom palette here. 3 | * 4 | * ref:https://v1.vuepress.vuejs.org/zh/config/#palette-styl 5 | */ 6 | 7 | $accentColor = #3eaf7c 8 | $textColor = #2c3e50 9 | $borderColor = #eaecef 10 | $codeBgColor = #282c34 11 | $contentWidth = 70% 12 | -------------------------------------------------------------------------------- /docs/src/api/README.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /docs/src/guide/README.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install Vue Hooks Form via your favorite JavaScript package manager. 4 | 5 | ## NPM 6 | ```sh 7 | npm install vue-hooks-form --save 8 | ``` 9 | 10 | ## Yarn 11 | 12 | ```sh 13 | yarn add vue-hooks-form 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/src/guide/Validation.md: -------------------------------------------------------------------------------- 1 | # Validation 2 | Vue Hooks From makes form validation easy. It supports synchronous and asynchronous form-level and field-level validation. 3 | 4 | List of validation rules supported: 5 | - Type: string/number/boolean/method/regexp/array/... 6 | - Required 7 | - Pattern 8 | - Range 9 | - Length 10 | - Enumerable 11 | - Validator/Async Validator 12 | - Messages 13 | - ... 14 | 15 | Vue Hooks Form use `async-validator` to do validation, click [here](https://github.com/yiminghe/async-validator) for more information。 16 | 17 | ## Synchronous validation 18 | 19 | 20 | 21 | ## Asynchronous validation 22 | 23 | 24 | 25 | ## Validation mode 26 | You can control when forms runs validation by change the value of `validateMode: 'change' | 'focusout' | 'submit' = 'change'`. 27 | 28 | 29 | 30 | ## Manually triggering validation 31 | You can manually trigger form-level validation by using `validateFields()` and field-level validation by using `validateField(path)`/`useField(path).validate()`. 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/src/guide/errors.md: -------------------------------------------------------------------------------- 1 | # Errors 2 | Vue Hooks From supports both form-level and field-level errors. 3 | 4 | ## Displaying Error Messages 5 | Your can use form-level `errors` or field-level `useField(path).error`. 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/src/guide/get-started.md: -------------------------------------------------------------------------------- 1 | # Get Started 2 | 3 | ## Basic example 4 | The following code demonstrates a basic usage example: 5 | 6 | -------------------------------------------------------------------------------- /docs/src/guide/nested-data.md: -------------------------------------------------------------------------------- 1 | # Nested Data 2 | 3 | Vue Hooks Form support deeply nested data out of box. The `path` you passed in `useField(path)` support both dot and bracket syntax, for example: 4 | 5 | - `useField("user.friends[0].name")` 6 | - `useField("user.friends.0.name")` 7 | 8 | -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroImage: https://v1.vuepress.vuejs.org/hero.png 4 | tagline: Building forms with vue composition API. 5 | actionText: Quick Start → 6 | actionLink: /guide/ 7 | features: 8 | - title: UI decoupling 9 | details: Since It does not contain any UI code, It can be easily integrated with other UI libraries. 10 | 11 | - title: Easy to adoptable 12 | details: Form state is inherently local and ephemeral, it can be easily adopted. 13 | - title: Simple to use 14 | details: No fancy stuffs, just reactive values/errors. 15 | footer: Made by with ❤️ 16 | --- 17 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /example/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /example/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beizhedenglong/vue-hooks-form/219419c9a4be1660e261a019d9ebf6cf3f89144a/example/assets/logo.png -------------------------------------------------------------------------------- /example/components/Demo.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 43 | -------------------------------------------------------------------------------- /example/components/Input.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 35 | -------------------------------------------------------------------------------- /example/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | createApp(App).mount('#app') 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | transform: { 4 | '^.+\\.vue$': 'vue-jest', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-hooks-form", 3 | "version": "0.3.1", 4 | "source": "src/index.ts", 5 | "main": "dist/index.js", 6 | "types": "dist/src/index.d.ts", 7 | "scripts": { 8 | "start": "vue-cli-service serve example/main.ts", 9 | "build": "rollup -c", 10 | "dev": "microbundle watch", 11 | "build:docs": "cd docs && yarn build", 12 | "test": "jest --coverage", 13 | "gh-pages": "yarn build:docs && gh-pages -d docs/src/.vuepress/dist" 14 | }, 15 | "dependencies": { 16 | "async-validator": "^3.4.0", 17 | "lodash.get": "^4.4.2", 18 | "lodash.merge": "^4.6.2", 19 | "lodash.set": "^4.3.2", 20 | "lodash.setwith": "^4.3.2", 21 | "lodash.topath": "^4.5.2", 22 | "vue-demi": "^0.13.11" 23 | }, 24 | "peerDependencies": { 25 | "@vue/composition-api": "^1.0.0-rc.1", 26 | "vue": "^2.0.0 || >=3.0.0" 27 | }, 28 | "peerDependenciesMeta": { 29 | "@vue/composition-api": { 30 | "optional": true 31 | } 32 | }, 33 | "devDependencies": { 34 | "@rollup/plugin-commonjs": "^15.1.0", 35 | "@rollup/plugin-node-resolve": "^9.0.0", 36 | "@rollup/plugin-typescript": "^6.0.0", 37 | "@types/jest": "^24.0.19", 38 | "@types/lodash.get": "^4.4.6", 39 | "@types/lodash.merge": "^4.6.6", 40 | "@types/lodash.set": "^4.3.6", 41 | "@types/lodash.setwith": "^4.3.6", 42 | "@types/lodash.topath": "^4.5.6", 43 | "@typescript-eslint/eslint-plugin": "^2.33.0", 44 | "@typescript-eslint/parser": "^2.33.0", 45 | "@vue/cli-plugin-babel": "~4.5.0", 46 | "@vue/cli-plugin-e2e-cypress": "~4.5.0", 47 | "@vue/cli-plugin-eslint": "~4.5.0", 48 | "@vue/cli-plugin-typescript": "~4.5.0", 49 | "@vue/cli-plugin-unit-jest": "~4.5.0", 50 | "@vue/cli-service": "~4.5.0", 51 | "@vue/compiler-sfc": "^3.0.0-0", 52 | "@vue/eslint-config-airbnb": "^5.0.2", 53 | "@vue/eslint-config-typescript": "^5.0.2", 54 | "@vue/test-utils": "^2.0.0-0", 55 | "eslint": "^6.7.2", 56 | "eslint-plugin-import": "^2.20.2", 57 | "eslint-plugin-vue": "^7.0.0-0", 58 | "gh-pages": "^3.1.0", 59 | "materialize-css": "^1.0.0-rc.2", 60 | "rollup": "^2.32.0", 61 | "tslib": "^2.0.3", 62 | "typescript": "^4.0.3", 63 | "vue": "^3.1.5", 64 | "vue-jest": "^5.0.0-0", 65 | "yarn": "^1.22.10" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beizhedenglong/vue-hooks-form/219419c9a4be1660e261a019d9ebf6cf3f89144a/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= htmlWebpackPlugin.options.title %> 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // rollup.config.js 2 | import typescript from '@rollup/plugin-typescript' 3 | import { nodeResolve } from '@rollup/plugin-node-resolve' 4 | import commonjs from '@rollup/plugin-commonjs' 5 | 6 | export default { 7 | input: 'src/index.ts', 8 | output: { 9 | dir: 'dist', 10 | format: 'cjs', 11 | sourcemap: true, 12 | }, 13 | plugins: [typescript({ 14 | tsconfig: './tsconfig.json', 15 | exclude: './example', 16 | }), nodeResolve(), commonjs()], 17 | external: ['vue'], 18 | } 19 | -------------------------------------------------------------------------------- /shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { defineComponent } from 'vue' 3 | 4 | const component: ReturnType 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /src/deepValidator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eqeqeq */ 2 | import Validator, { RuleItem, Rules, ValidateSource } from 'async-validator' 3 | import { 4 | toPath, setWith, get, set, 5 | } from './utils' 6 | 7 | const getRulePath = (path: any) => toPath(path).join('.fields.') 8 | 9 | const getRule = (rules: Rules, path: any) => { 10 | const rulePath = getRulePath(path) 11 | return get(rules, rulePath) as RuleItem 12 | } 13 | 14 | const setRule = (rules: Rules, path: any, rule: RuleItem | undefined) => { 15 | const rulePath = getRulePath(path) 16 | const pathArr = toPath(path) 17 | let index = 0 18 | return setWith(rules, rulePath, rule, (pathValue, key) => { 19 | if (key !== 'fields') { 20 | index += 1 21 | const type = /^\d$/.test(pathArr[index]) ? 'array' : 'object' 22 | return pathValue || ({ 23 | type, 24 | } as RuleItem) 25 | } 26 | return pathValue 27 | }) 28 | } 29 | 30 | export type Errors = { 31 | [key: string]: { 32 | message: string; 33 | field: string; 34 | }; 35 | } 36 | 37 | const DeepValidator = (rules: Rules = {}) => { 38 | const registerRule = (path: any, rule: RuleItem) => { 39 | setRule(rules, path, rule) 40 | } 41 | const removeRule = (path: any) => { 42 | setRule(rules, path, {}) 43 | } 44 | const validate = async (data: ValidateSource) => { 45 | try { 46 | await new Validator(rules).validate(data) 47 | return undefined 48 | } catch ({ errors, fields }) { 49 | const errorObject: Errors = Object.keys(fields).reduce((acc, key) => { 50 | acc[key] = get(fields, [key, 0]) 51 | return acc 52 | }, {} as Errors) 53 | throw errorObject 54 | } 55 | } 56 | return { 57 | getRules: () => rules, 58 | registerRule, 59 | removeRule, 60 | validate, 61 | validateField: async (path: any, value: any) => { 62 | const fieldRule = setRule({}, path, getRule(rules, path)) 63 | try { 64 | await new Validator(fieldRule).validate( 65 | set({}, path, value), 66 | ) 67 | return undefined 68 | } catch ({ fields }) { 69 | throw get(fields, [path, 0]) 70 | } 71 | }, 72 | } 73 | } 74 | 75 | export default DeepValidator 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useForm' 2 | -------------------------------------------------------------------------------- /src/useForm.ts: -------------------------------------------------------------------------------- 1 | import { 2 | reactive, computed, ref, Ref, watch, 3 | } from 'vue-demi' 4 | import { RuleItem } from 'async-validator' 5 | import DeepValidator from './deepValidator' 6 | import { 7 | isAllUnmounted, get, set, toPathString, 8 | getDOMNode, FieldNode, 9 | } from './utils' 10 | 11 | export type ValidateMode = 'change' | 'focusout' | 'submit' 12 | 13 | export type FormOptions = { 14 | defaultValues?: Values; 15 | shouldUnregister?: boolean; 16 | validateMode?: ValidateMode; 17 | } 18 | 19 | export type FieldOptions = { 20 | rule?: RuleItem; 21 | } 22 | export type Error = { 23 | message: string; 24 | field: string; 25 | } 26 | 27 | export type Errors = { 28 | [field: string]: Error[] | undefined; 29 | } 30 | 31 | export const useForm = (options: FormOptions = {}) => { 32 | const { 33 | defaultValues = {} as T, 34 | shouldUnregister = true, 35 | validateMode = 'change', 36 | } = options 37 | const validator = DeepValidator({}) 38 | const fieldsRef = ref<{ [key: string]: Set> }>({}) 39 | const fieldValues = reactive(defaultValues) as any 40 | 41 | const errors = reactive({} as Errors) 42 | 43 | // make errors is reactive 44 | const clearErrors = () => { 45 | Object.keys(errors).forEach((key) => { 46 | delete errors[key] 47 | }) 48 | } 49 | const setErrors = (newErrors: Errors) => { 50 | clearErrors() 51 | Object.keys(newErrors).forEach((key) => { 52 | errors[key] = newErrors[key] 53 | }) 54 | } 55 | 56 | const getFieldValues = () => Object.keys(fieldsRef.value).reduce((acc, path) => { 57 | // only return fields that exit on page 58 | const value = get(fieldValues, path) 59 | if (!shouldUnregister) { 60 | set(acc, path, value) 61 | return acc 62 | } 63 | if (!isAllUnmounted(fieldsRef.value[path])) { 64 | set(acc, path, value) 65 | return acc 66 | } 67 | return acc 68 | }, {} as Partial) 69 | const validateFields = async () => { 70 | try { 71 | const noErrors = await validator.validate(getFieldValues()) 72 | clearErrors() 73 | return noErrors 74 | } catch (error) { 75 | setErrors(error) 76 | return error 77 | } 78 | } 79 | 80 | const validateField = async (path: any) => { 81 | try { 82 | const noError = await validator.validateField(path, get(fieldValues, path)) 83 | delete errors[path] 84 | return noError 85 | } catch (error) { 86 | errors[path] = error 87 | return error 88 | } 89 | } 90 | // eslint-disable-next-line no-shadow 91 | const useField = (path: string | (string | number)[], options: FieldOptions = {}) => { 92 | const pathStr = toPathString(path) 93 | const fieldRef = ref(null) 94 | const { rule } = options 95 | const validateWithoutError = async () => { 96 | await validateField(pathStr) 97 | } 98 | if (rule) { 99 | validator.registerRule(pathStr, rule) 100 | } 101 | const value = computed({ 102 | get: () => get(fieldValues, pathStr), 103 | set: (newValue) => { 104 | set(fieldValues, pathStr, newValue) 105 | }, 106 | }) 107 | const listener = ref((e: Event) => { 108 | validateWithoutError() 109 | }) 110 | const getRef = (nodeRef: FieldNode): void => { 111 | const domNode = getDOMNode(nodeRef) 112 | if (domNode !== null) { 113 | if (validateMode === 'focusout') { 114 | domNode.addEventListener('focusout', listener.value) 115 | } 116 | } else { 117 | const prevDomNode = getDOMNode(fieldRef.value) 118 | if (prevDomNode !== null) { 119 | if (validateMode === 'focusout') { 120 | prevDomNode.removeEventListener('focusout', listener.value) 121 | } 122 | } 123 | } 124 | fieldRef.value = nodeRef 125 | const nodeSet = fieldsRef.value[pathStr] || new Set() 126 | nodeSet.add(fieldRef) 127 | fieldsRef.value[pathStr] = nodeSet 128 | if (shouldUnregister && isAllUnmounted(nodeSet)) { 129 | validator.removeRule(pathStr) 130 | } 131 | } 132 | watch(value, async () => { 133 | if (validateMode === 'change') { 134 | validateWithoutError() 135 | } 136 | }) 137 | // can't watch the change of fieldRef 138 | // watch(fieldRef, () => { 139 | // console.log('watch', fieldRef) 140 | // }) 141 | return reactive({ 142 | ref: getRef, 143 | value, 144 | error: computed(() => errors[pathStr]), 145 | validate: () => validateField(pathStr), 146 | }) 147 | } 148 | const handleSubmit = (onSubmit: (fieldValues: Partial) => any) => async (e?: Event) => { 149 | if (e) { 150 | e.preventDefault() 151 | } 152 | 153 | const error = await validateFields() 154 | if (!error) { 155 | onSubmit(getFieldValues()) 156 | } 157 | } 158 | return reactive({ 159 | values: fieldValues as T, 160 | useField, 161 | get: (path: string, defaultValue?: any) => get(fieldValues, path, defaultValue), 162 | set: (path: string, value: any) => set(fieldValues, path, value), 163 | getFieldValues, 164 | validateFields, 165 | validateField, 166 | errors, 167 | handleSubmit, 168 | }) 169 | } 170 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Ref, ComponentPublicInstance } from 'vue-demi' 2 | import _toPath from 'lodash.topath' 3 | import _get from 'lodash.get' 4 | import _set from 'lodash.set' 5 | 6 | export { default as merge } from 'lodash.merge' 7 | export { default as setWith } from 'lodash.setwith' 8 | 9 | export const isUnmounted = (fieldRef: Ref) => fieldRef.value === null 10 | 11 | export const isAllUnmounted = (fieldRefs?: Set) => { 12 | if (fieldRefs === undefined) { 13 | return true 14 | } 15 | return [...fieldRefs].every(isUnmounted) 16 | } 17 | 18 | export const toPath = _toPath 19 | 20 | export const toPathString = (path: any) => toPath(path).join('.') 21 | 22 | export const set = _set 23 | 24 | export const get = _get 25 | 26 | export type FieldNode = Element | null | ComponentPublicInstance 27 | export const getDOMNode = (value: FieldNode) => { 28 | if (value === null || value instanceof Element) { 29 | return value 30 | } 31 | return value.$el as Element 32 | } 33 | -------------------------------------------------------------------------------- /tests/a.test.ts: -------------------------------------------------------------------------------- 1 | test('1+1=2', () => { 2 | expect(1 + 1).toBe(3) 3 | }) 4 | -------------------------------------------------------------------------------- /tests/unit/deepValidator.spec.ts: -------------------------------------------------------------------------------- 1 | import { RuleItem, Rules } from 'async-validator' 2 | import DeepValidator from '../../src/deepValidator' 3 | 4 | describe('DeepValidator', () => { 5 | const rules: Rules = { 6 | name: { 7 | type: 'string', 8 | required: true, 9 | validator: (rule: RuleItem, value: string) => value === 'muji', 10 | }, 11 | age: { 12 | type: 'number', 13 | asyncValidator: (rule: RuleItem, value: number) => new Promise((resolve, reject) => { 14 | if (value < 18) { 15 | // eslint-disable-next-line prefer-promise-reject-errors 16 | reject('too young') // reject with error message 17 | } else { 18 | resolve() 19 | } 20 | }), 21 | }, 22 | } 23 | const validator = DeepValidator(rules) 24 | test('registerRule', async (done) => { 25 | validator.registerRule('email', { 26 | type: 'email', 27 | }) 28 | 29 | validator.validateField('email', { email: 'xxx' }).catch((errors) => { 30 | expect(errors).toBeDefined() 31 | done() 32 | }) 33 | }) 34 | test('register deep rule', async (done) => { 35 | const friendsValidator = (rule: RuleItem, value: any, cb: any) => { 36 | if (value.length < 1) { 37 | cb('must have a friend') 38 | } 39 | } 40 | validator.registerRule('person.friends', { 41 | type: 'array', 42 | validator: friendsValidator, 43 | }) 44 | expect(validator.getRules().person).toEqual({ 45 | type: 'object', 46 | fields: { 47 | friends: { 48 | type: 'array', 49 | validator: friendsValidator, 50 | }, 51 | }, 52 | } as RuleItem) 53 | validator.validate({ 54 | person: { 55 | friends: [], 56 | }, 57 | }).catch((errors) => { 58 | expect(errors['person.friends']).toBeDefined() 59 | done() 60 | }) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /tests/unit/useForm.spec.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import { mount } from '@vue/test-utils' 3 | import { renderHook, waitForNextUpdate } from '../utils' 4 | import { useForm } from '../../src' 5 | 6 | describe('useFrom', () => { 7 | test('basic', async (done) => { 8 | const defaultValues = { 9 | name: 'wang', 10 | age: 1, 11 | info: { 12 | email: 'hello@example.com', 13 | city: 'beijing', 14 | }, 15 | notInclude: 'notInclude', 16 | } 17 | const { result } = renderHook(() => useForm({ 18 | defaultValues, 19 | })) 20 | if (result !== undefined) { 21 | const nameField = result.useField('name', { 22 | rule: { required: true }, 23 | }) 24 | expect(result.values).toEqual(defaultValues) 25 | expect(result.errors).toEqual({}) 26 | expect(nameField.value).toBe('wang') 27 | nameField.value = 'victor' 28 | expect(nameField.value).toBe('victor') 29 | await nameField.validate() 30 | expect(result.errors.name).toBe(undefined) 31 | nameField.value = undefined 32 | await waitForNextUpdate() 33 | expect(nameField.error).toBeDefined() 34 | expect(result.errors.name).toBeDefined() 35 | done() 36 | } else { 37 | throw Error 38 | } 39 | }) 40 | test('nested', async (done) => { 41 | const { result } = renderHook(() => useForm()) 42 | if (result !== undefined) { 43 | expect(result.values).toEqual({}) 44 | const nestedField = result.useField('a.b.c.d', { 45 | rule: { required: true }, 46 | }) 47 | expect(nestedField.value).toBeUndefined() 48 | expect(nestedField.error).toBeUndefined() 49 | nestedField.value = 1 50 | expect(result.get('a.b.c.d')).toBe(1) 51 | await waitForNextUpdate() 52 | expect(nestedField.error).toBeDefined() 53 | } else { 54 | throw Error 55 | } 56 | done() 57 | }) 58 | test('with component', async (done) => { 59 | const mockFn = jest.fn() 60 | const Form = defineComponent({ 61 | setup() { 62 | const { 63 | useField, handleSubmit, values, errors, set, 64 | } = useForm({ 65 | defaultValues: {}, 66 | }) 67 | const nameField = useField('name', { 68 | rule: { required: true }, 69 | }) 70 | return { 71 | values, 72 | nameField, 73 | onSubmit: handleSubmit(mockFn), 74 | errors, 75 | set, 76 | } 77 | }, 78 | render() { 79 | return
80 | { this.nameField.value = e.target.value }}/> 81 |
82 | }, 83 | }) 84 | const wrapper = mount(Form) 85 | expect(wrapper.vm.values).toEqual({}) 86 | const nameInput = wrapper.find('input') 87 | await wrapper.vm.onSubmit() 88 | expect(mockFn).toBeCalledTimes(0) 89 | expect(wrapper.vm.errors.name).toBeDefined() 90 | await nameInput.setValue('victor') 91 | expect(wrapper.vm.values).toEqual({ name: 'victor' }) 92 | await wrapper.vm.onSubmit() 93 | expect(mockFn).toBeCalledTimes(1) 94 | expect(mockFn).toBeCalledWith({ name: 'victor' }) 95 | expect(wrapper.vm.errors.name).toBeUndefined() 96 | wrapper.vm.set('name', 'wang') 97 | expect(wrapper.vm.values).toEqual({ name: 'wang' }) 98 | await waitForNextUpdate() 99 | expect(nameInput.element.value).toBe('wang') 100 | done() 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import { mount } from '@vue/test-utils' 4 | import { defineComponent } from 'vue-demi' 5 | 6 | export type RenderHookResult = { 7 | result?: T; 8 | } 9 | export const renderHook = ( 10 | callback: () => Result | void = () => { }, 11 | ): RenderHookResult => { 12 | let result 13 | const Container = defineComponent({ 14 | setup() { 15 | result = callback() 16 | return result 17 | }, 18 | render() { 19 | return null 20 | }, 21 | }) 22 | mount(Container) 23 | 24 | return { 25 | result, 26 | } 27 | } 28 | 29 | export const waitForNextUpdate = (timeout?: number) => new Promise((resolve) => { 30 | setTimeout(() => { 31 | resolve(undefined) 32 | }, timeout) 33 | }) 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "dist", 5 | "target": "esnext", 6 | "module": "esnext", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "importHelpers": true, 10 | "moduleResolution": "node", 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env", 18 | "jest" 19 | ], 20 | "paths": { 21 | "@/*": [ 22 | "src/*" 23 | ] 24 | }, 25 | "lib": [ 26 | "esnext", 27 | "dom", 28 | "dom.iterable", 29 | "scripthost" 30 | ] 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "src/**/*.tsx", 35 | "src/**/*.vue", 36 | "tests/**/*.ts", 37 | "tests/**/*.tsx", 38 | "example/**/*.vue", 39 | "example/**/*.ts", 40 | "example/**/*.tsx", 41 | ], 42 | "exclude": [ 43 | "node_modules", 44 | "example" 45 | ] 46 | } -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: process.env.NODE_ENV === 'production' 3 | ? '/vue-hooks-form/' 4 | : '/', 5 | } 6 | --------------------------------------------------------------------------------