├── .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 · [](https://github.com/beizhedenglong/vue-hooks-form/blob/master/LICENSE)  [](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 |
22 |
31 |
32 |
33 |
60 | ```
61 | ## Live Demo
62 | [](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 |
2 |
10 |
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 |
2 |
8 |
9 |
10 |
41 |
--------------------------------------------------------------------------------
/docs/src/.vuepress/demos/basic.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
40 |
--------------------------------------------------------------------------------
/docs/src/.vuepress/demos/displaying-errors.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
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 |
2 |
16 |
17 |
18 |
45 |
--------------------------------------------------------------------------------
/docs/src/.vuepress/demos/nested-data.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
36 |
--------------------------------------------------------------------------------
/docs/src/.vuepress/demos/sync-validation.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
41 |
--------------------------------------------------------------------------------
/docs/src/.vuepress/demos/validation-mode.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
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 |
2 |
3 |
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 |
2 |
14 |
15 |
16 |
43 |
--------------------------------------------------------------------------------
/example/components/Input.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
$emit('update:modelValue', e.target.value)"
10 | v-bind="$attrs"
11 | />
12 |
13 |
14 |
15 |
{{errorMessage}}
16 |
17 |
18 |
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
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 |
--------------------------------------------------------------------------------
]