├── .husky ├── .gitignore └── pre-commit ├── config ├── shared.ts ├── test.ts ├── library.ts └── example.vue3.ts ├── example └── vue3 │ ├── src │ ├── vite.env.d.ts │ ├── shims-vue.d.ts │ ├── main.ts │ └── App.vue │ ├── README.md │ ├── index.html │ └── style.css ├── docs ├── public │ ├── og.png │ └── og_.jpeg ├── examples.md ├── .vitepress │ ├── theme │ │ ├── style.css │ │ ├── index.js │ │ └── analytics.js │ └── config.js ├── faq.md ├── change-log.md ├── getting-started.md ├── index.md ├── api.md └── usage.md ├── .gitignore ├── tsconfig.json ├── test ├── setup.ts ├── shared.ts ├── method.spec.ts ├── mix.spec.ts ├── option.spec.ts ├── data.spec.ts └── rules.spec.ts ├── LICENSE ├── src ├── helpers.ts ├── types.ts └── index.ts ├── .github └── workflows │ └── ci.yaml ├── README.md ├── package.json └── CODE_OF_CONDUCT.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /config/shared.ts: -------------------------------------------------------------------------------- 1 | export const LIBRARY_NAME = 'vue-tiny-validate'; 2 | -------------------------------------------------------------------------------- /example/vue3/src/vite.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn format 5 | yarn test 6 | -------------------------------------------------------------------------------- /docs/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrontLabsOfficial/vue-tiny-validate/HEAD/docs/public/og.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist* 4 | *.local 5 | coverage 6 | .idea 7 | docs/.vitepress/cache 8 | -------------------------------------------------------------------------------- /docs/public/og_.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FrontLabsOfficial/vue-tiny-validate/HEAD/docs/public/og_.jpeg -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | ### We are updating... 2 | 3 | Visit [here](https://github.com/FrontLabsOfficial/vue-tiny-validate/tree/master/example) for a real-world example. 4 | -------------------------------------------------------------------------------- /example/vue3/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue'; 3 | const component: DefineComponent<{}, {}, any>; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --c-brand: #2563eb; 3 | --c-brand-light: #3b82f6; 4 | } 5 | 6 | .sidebar > .sidebar-links > .sidebar-link + .sidebar-link { 7 | padding-top: 0; 8 | } 9 | -------------------------------------------------------------------------------- /example/vue3/README.md: -------------------------------------------------------------------------------- 1 | [vue-tiny-validate-example.netlify.app](https://vue-tiny-validate-example.netlify.app) 2 | 3 | ** Switch from vue3 to vue2 will cause ide to find errors in this directory, just ignore it ** 4 | -------------------------------------------------------------------------------- /example/vue3/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'uno.css'; 2 | import { createApp } from 'vue'; 3 | import '@unocss/reset/tailwind.css'; 4 | import '../style.css'; 5 | import App from './App.vue'; 6 | 7 | createApp(App).mount('#app'); 8 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme'; 2 | import Analytics from './analytics'; 3 | import './style.css'; 4 | 5 | const Theme = { 6 | ...DefaultTheme, 7 | enhanceApp({ app }) { 8 | app.use(Analytics); 9 | }, 10 | }; 11 | 12 | export default Theme; 13 | -------------------------------------------------------------------------------- /example/vue3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vue Tiny Validate Example: Vue 3 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /config/test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | setupFiles: [resolve(__dirname, '../test/setup.ts')], 8 | environment: 'happy-dom', 9 | reporters: 'verbose', 10 | deps: { 11 | inline: ['vue2', '@vue/composition-api', 'vue-demi'], 12 | }, 13 | coverage: { 14 | include: ['src/*'], 15 | clean: true, 16 | lines: 99, 17 | statements: 99, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "sourceMap": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "lib": ["esnext", "dom"], 12 | "types": ["vite/client" ,"vitest", "vitest/globals"], 13 | "skipLibCheck": true, 14 | "declaration": true, 15 | "emitDeclarationOnly": false, 16 | "paths": { 17 | "vue-tiny-validate": ["./src"] 18 | } 19 | }, 20 | "include": ["src/**/*.ts", "src/**/*.d.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /config/library.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import vue from '@vitejs/plugin-vue'; 4 | import { LIBRARY_NAME } from './shared'; 5 | 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | build: { 9 | lib: { 10 | entry: resolve(__dirname, '../src/index.ts'), 11 | name: LIBRARY_NAME, 12 | fileName: 'index', 13 | formats: ['es', 'cjs', 'umd'], 14 | }, 15 | rollupOptions: { 16 | external: ['vue', 'vue-demi'], 17 | output: { 18 | globals: { 19 | vue: 'Vue', 20 | 'vue-demi': 'VueDemi', 21 | }, 22 | }, 23 | }, 24 | outDir: resolve(__dirname, '../dist'), 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import isEqual from 'lodash/isEqual'; 2 | import { expect } from 'vitest'; 3 | import { Vue2, install, isVue2 } from 'vue-demi'; 4 | 5 | const setup = () => { 6 | if (isVue2) { 7 | Vue2.config.productionTip = false; 8 | Vue2.config.devtools = false; 9 | install(Vue2); 10 | } 11 | }; 12 | 13 | expect.extend({ 14 | deepEqual(received, value) { 15 | const pass = isEqual(received, value); 16 | 17 | return { 18 | pass, 19 | message: () => 20 | pass 21 | ? 'Passed' 22 | : `Expected ${JSON.stringify( 23 | received, 24 | null, 25 | 2, 26 | )} to be ${JSON.stringify(value, null, 2)}`, 27 | }; 28 | }, 29 | }); 30 | 31 | setup(); 32 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | ### FAQ 2 | 3 | **Q: Why is there no built-in validator ?** 4 | 5 | **A**: Frankly saying, it's because we are **super lazy**. Moreover, we believe that there is no pre-built validator 6 | that can satisfy all of your needs, so it's **redundant** to have any in the first place. Instead, just build your own 7 | validator. 8 | 9 | **Q: Why should all parameters be reactive objects?** 10 | 11 | **A**: In short, to take advantage of Vue's `watch` method to track their changes effortlessly. 12 | 13 | **Q: Why does re-assigning any `rules` or `options` values also cause the validation results to reset??** 14 | 15 | **A**: Since the library depends on `watch`'s **shallow comparison**, re-assigning any reactive object will trigger 16 | `watch`'s **callback function**. In this case, it will **re-initialize** the validation. 17 | 18 | **Have a question? Feel free to create an [issue](https://github.com/FrontLabsOfficial/vue-tiny-validate/issues). Thank 19 | you.** 20 | -------------------------------------------------------------------------------- /test/shared.ts: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick'; 2 | import type { Result } from '../src/types'; 3 | 4 | type CompareValue = Omit; 5 | 6 | const pickKeysFromValue = (value: CompareValue) => 7 | pick(value, ['$invalid', '$pending', '$dirty', '$errors', '$messages']); 8 | 9 | export const initialExpect = (value: CompareValue): void => { 10 | const compareValue = pickKeysFromValue(value); 11 | (expect(compareValue) as any).deepEqual(baseState); 12 | }; 13 | 14 | export const valueExpect = ( 15 | value: CompareValue, 16 | expectValue: CompareValue, 17 | ): void => { 18 | const compareValue = pickKeysFromValue(value); 19 | 20 | (expect(compareValue) as any).deepEqual(expectValue); 21 | }; 22 | 23 | export const baseState: CompareValue = { 24 | $invalid: false, 25 | $errors: [], 26 | $messages: [], 27 | $pending: false, 28 | $dirty: false, 29 | }; 30 | 31 | export const delay = (ms: number) => 32 | new Promise(resolve => setTimeout(resolve, ms)); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Anh Le 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 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Vue2, isRef, isVue2, reactive } from 'vue-demi'; 2 | import type { UnknownObject } from './types'; 3 | 4 | /* Coverage ignored since it only works with matched Vue version */ 5 | 6 | /* c8 ignore start */ 7 | export const setReactiveValue = (obj: any, key: string, value: any) => { 8 | if (isVue2) { 9 | Vue2.set(obj, key, value); 10 | } else { 11 | obj[key] = value; 12 | } 13 | }; 14 | /* c8 ignore stop */ 15 | 16 | export const hasOwn = (obj: UnknownObject, key: string): boolean => 17 | typeof obj[key] !== 'undefined'; 18 | 19 | export const isObject = (obj: UnknownObject): boolean => 20 | Object.prototype.toString.call(obj) === '[object Object]'; 21 | 22 | export const unwrap = (obj: UnknownObject): UnknownObject => 23 | (isRef(obj) ? obj.value : obj) as UnknownObject; 24 | 25 | export const NOOP = () => {}; 26 | 27 | export const ENTRY_PARAM = { 28 | $invalid: false, 29 | $errors: [], 30 | $messages: [], 31 | $pending: false, 32 | }; 33 | 34 | export const OPTION = reactive({ 35 | autoTest: false, 36 | autoTouch: false, 37 | lazy: false, 38 | firstError: false, 39 | touchOnTest: false, 40 | transform: (value: any) => value, 41 | }); 42 | -------------------------------------------------------------------------------- /config/example.vue3.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { 3 | presetAttributify, 4 | presetUno, 5 | transformerDirectives, 6 | transformerVariantGroup, 7 | } from 'unocss'; 8 | import Unocss from 'unocss/vite'; 9 | import type { ConfigEnv } from 'vite'; 10 | import { defineConfig } from 'vite'; 11 | import vue from '@vitejs/plugin-vue'; 12 | import { LIBRARY_NAME } from './shared'; 13 | 14 | const settings = { 15 | plugins: [ 16 | vue(), 17 | Unocss({ 18 | presets: [presetAttributify({ strict: true }), presetUno()], 19 | transformers: [transformerVariantGroup(), transformerDirectives()], 20 | }), 21 | ], 22 | root: resolve(__dirname, '../example/vue3'), 23 | resolve: { 24 | alias: { 25 | [LIBRARY_NAME]: resolve(__dirname, '../src'), 26 | }, 27 | }, 28 | optimizeDeps: { 29 | exclude: ['vue-demi'], 30 | }, 31 | }; 32 | 33 | const dev = { 34 | ...settings, 35 | server: { 36 | port: 3456, 37 | }, 38 | }; 39 | 40 | const build = { 41 | ...settings, 42 | build: { 43 | outDir: resolve(__dirname, '../dist-example'), 44 | }, 45 | }; 46 | 47 | export default ({ command }: ConfigEnv) => 48 | defineConfig(command === 'serve' ? dev : build); 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup NodeJS 14 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 14 23 | 24 | - name: Get yarn cache directory path 25 | id: yarn-cache-dir-path 26 | run: echo "::set-output name=dir::$(yarn cache dir)" 27 | 28 | - uses: actions/cache@v3 29 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 30 | with: 31 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 32 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-yarn- 35 | 36 | - name: Install yarn 37 | run: npm install -g yarn 38 | 39 | - name: Install dependencies 40 | run: yarn install 41 | 42 | - name: Clean dist 43 | run: yarn clean 44 | 45 | - name: Run unit test 46 | run: yarn test 47 | -------------------------------------------------------------------------------- /docs/change-log.md: -------------------------------------------------------------------------------- 1 | ## 0.2.4 2 | 3 | - Fix `result` value is touched / dirtied before finishing test. 4 | 5 | ## 0.2.3 6 | 7 | - This version is un-published due to some build problems. 8 | 9 | ## 0.2.2 10 | 11 | - Fix wrong `result` when `data` has multiple nested properties. 12 | - Async `$test` method. 13 | 14 | ```js 15 | await result.$test(); 16 | console.log('Tested'); 17 | ``` 18 | 19 | - Cancel async validation on resetting. 20 | - Add more parameters (`data`, `rules`, `options`) to `test` function. Also, update `rule` properties. 21 | 22 | ```js 23 | const rules = reactive({ 24 | name: { 25 | // rule now has (name, test, message) properties instead of ($key, $test, $message) properties 26 | name: 'required', 27 | test: (value, data, rules, options) => { 28 | // you can access data, rules, options here 29 | return Boolean(value); 30 | }, 31 | message: 'Name must not be empty.', 32 | }, 33 | }); 34 | ``` 35 | 36 | - Support Vue 2.6. 37 | - Add `transform` option. 38 | 39 | ```js 40 | // add some additional value to result object 41 | const transform = value => ({ ...value, addition: 'some value' }); 42 | 43 | const options = reactive({ transform }); 44 | 45 | const { result } = useValidate(data, rules, options); 46 | ``` 47 | 48 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | with **npm**: 4 | 5 | ```bash 6 | npm install vue-tiny-validate 7 | ``` 8 | 9 | or with **Yarn**: 10 | 11 | ```bash 12 | yarn add vue-tiny-validate 13 | ``` 14 | 15 | ## Quickstart 16 | 17 | Now that you've installed the library, let's get started with a basic usage guide below. 18 | 19 | ```js 20 | 25 | 26 | 47 | ``` 48 | 49 | As you can see above, the `useValidate` composition requires 2 parameters `data` and `rules`. 50 | 51 | The `result` value has everything you need to **get** and **set** the validation. In this example, we use the 52 | `$test` method to validate and the `$invalid` property to get validation state. 53 | 54 | Head to **[Usage](/usage)** to see more detailed instructions. 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## `vue-tiny-validate` 2 | 3 | Tiny Vue Validate Composition 4 | 5 | ## Motivation 6 | 7 | During the time of refactoring our project, we have coped with so many challenges, one of which was to minimize bundle 8 | size of the external libraries. We looked for a solution in the Vue community, and we have seen so many great validation 9 | tools, namely [Vuelidate](https://github.com/vuelidate/vuelidate) or 10 | [vee-validate](https://github.com/logaretm/vee-validate). They were all great, but they weren't the best fit for our 11 | problem at hand. 12 | 13 | Most, or maybe all of them, are over **10KB** minified. This was way too heavy for our goal of keeping our validation library 14 | **robust**, **fully-supported**, and most importantly, **minimal**. 15 | 16 | That's why `vue-tiny-validate` was born. 17 | 18 | ## Features 19 | 20 | - Easy. Come with familiar API and coherent documentation. 21 | - Tiny. Only **3.4KB** minified. **1.4KB** gzipped. 22 | - Flexible. Full control over everything. 23 | - Fully functional. Sync validation, async validation, etc supported. 24 | - Compatible. Works with both Vue 2.6 and Vue 3. 25 | - Nearly 100% unit test coverage. 26 | 27 | ## Usage 28 | 29 | - See [docs](https://vue-tiny-validate.netlify.app) for more detail. 30 | 31 | ## About 32 | 33 | - This library is heavily inspired by [Vuelidate](https://github.com/vuelidate/vuelidate). 34 | - Created by [Anh Le](https://github.com/culee). 35 | 36 | ## LICENSE 37 | 38 | - [MIT](https://github.com/FrontLabsOfficial/vue-tiny-validate/blob/master/LICENSE) 39 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | **vue-tiny-validate** \ 2 | :100: Tiny Vue Validate Composition 3 | 4 | ## Motivation 5 | 6 | During the time of refactoring our project, we have coped with so many challenges, one of which was to minimize bundle 7 | size of the external libraries. We looked for a solution in the Vue community, and we have seen so many great validation 8 | tools, namely [Vuelidate](https://github.com/vuelidate/vuelidate) or 9 | [vee-validate](https://github.com/logaretm/vee-validate). They were all great, but they weren't the best fit for our 10 | problem at hand. 11 | 12 | Most, or maybe all of them, are over **10KB** minified. This was way too heavy for our goal of keeping our validation library 13 | **robust**, **fully-supported**, and most importantly, **minimal**. 14 | 15 | That's why `vue-tiny-validate` was born. 16 | 17 | ## Features 18 | 19 | - Easy. Come with familiar API and coherent documentation. 20 | - Tiny. Only **3.4KB** minified. **1.4KB** gzipped. 21 | - Flexible. Full control over everything. 22 | - Fully functional. Sync validation, async validation, etc supported. 23 | - Compatible. Works with both Vue 2.6 and Vue 3. 24 | - Nearly 100% unit test coverage. 25 | 26 | ## About 27 | 28 | - Inspired by [Vuelidate](https://github.com/vuelidate/vuelidate). 29 | - Created mainly for usage of the `Front` team at [ShopBase :vietnam:](https://shopbase.com) by [Anh Le](https://github.com/anh-ld). 30 | 31 | 32 | Deploys by Netlify 33 | 34 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/analytics.js: -------------------------------------------------------------------------------- 1 | import { getAnalytics, isSupported } from '@firebase/analytics'; 2 | import { initializeApp } from '@firebase/app'; 3 | 4 | const GOOGLE_ANALYTICS_CONFIG = { 5 | apiKey: 'AIzaSyBY-7TmR3rfgye-KDG3QCx2F73tM4BQo-A', 6 | authDomain: 'vue-tiny-validate.firebaseapp.com', 7 | projectId: 'vue-tiny-validate', 8 | storageBucket: 'vue-tiny-validate.appspot.com', 9 | messagingSenderId: '202088318374', 10 | appId: '1:202088318374:web:5a4d59d9b6d5f78bd83b67', 11 | measurementId: 'G-G6Y36531LR', 12 | }; 13 | 14 | const SELF_DEPLOYED_ANALYTICS_CONFIG = { 15 | 'data-website-id': 'e40a6e9f-6d6a-4f57-969b-085c8e22a276', 16 | src: 'https://analytics.duyanh.dev/umami.js', 17 | defer: '', 18 | }; 19 | 20 | const setAttributes = (el, attrs) => { 21 | for (const key in attrs) { 22 | el.setAttribute(key, attrs[key]); 23 | } 24 | }; 25 | 26 | const AnalyticsPlugin = { 27 | async install() { 28 | if (import.meta.env.SSR || import.meta.env.DEV) return; 29 | // google analytics 30 | const isEnvSupported = await isSupported(); 31 | 32 | if (isEnvSupported) { 33 | const firebaseApp = initializeApp(GOOGLE_ANALYTICS_CONFIG); 34 | getAnalytics(firebaseApp); 35 | console.info('Init-ed FireBase analytics'); 36 | } 37 | 38 | // self-deployed analytics 39 | const scriptElement = document.createElement('script'); 40 | setAttributes(scriptElement, SELF_DEPLOYED_ANALYTICS_CONFIG); 41 | document.head.appendChild(scriptElement); 42 | console.info('Init-ed self-deployed analytics'); 43 | }, 44 | }; 45 | 46 | export default AnalyticsPlugin; 47 | -------------------------------------------------------------------------------- /docs/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'vue-tiny-validate', 3 | description: 'Tiny Vue Validate Composition', 4 | lastUpdated: true, 5 | head: [ 6 | ['meta', { property: 'og:title', content: 'vue-tiny-validate' }], 7 | ['meta', { property: 'twitter:title', content: 'vue-tiny-validate' }], 8 | [ 9 | 'meta', 10 | { 11 | property: 'og:description', 12 | content: 'Tiny Vue Validate Composition', 13 | }, 14 | ], 15 | [ 16 | 'meta', 17 | { 18 | property: 'twitter:description', 19 | content: 'Tiny Vue validate Composition', 20 | }, 21 | ], 22 | [ 23 | 'meta', 24 | { 25 | property: 'og:image', 26 | content: 'https://vue-tiny-validate.js.org/og_.jpeg', 27 | }, 28 | ], 29 | [ 30 | 'meta', 31 | { 32 | property: 'twitter:image', 33 | content: 'https://vue-tiny-validate.js.org/og_.jpeg', 34 | }, 35 | ], 36 | ], 37 | themeConfig: { 38 | outline: 'deep', 39 | prevLinks: true, 40 | nextLinks: true, 41 | siteTitle: 'vue-tiny-validate', 42 | socialLinks: [ 43 | { 44 | icon: 'github', 45 | link: 'https://github.com/FrontLabsOfficial/vue-tiny-validate', 46 | }, 47 | ], 48 | nav: [ 49 | { 50 | text: 'Example', 51 | link: 'https://vue-tiny-validate-example.netlify.app/', 52 | }, 53 | ], 54 | sidebar: [ 55 | { 56 | text: 'Guide', 57 | collapsible: true, 58 | items: [ 59 | { text: 'Introduction', link: '/' }, 60 | { text: 'Getting Started', link: '/getting-started' }, 61 | { text: 'Usage', link: '/usage' }, 62 | ], 63 | }, 64 | { 65 | text: 'Others', 66 | collapsible: true, 67 | items: [ 68 | { text: 'API', link: '/api' }, 69 | { text: 'FAQ', link: '/faq' }, 70 | { text: 'Changelog', link: '/change-log' }, 71 | ], 72 | }, 73 | ], 74 | algolia: { 75 | appId: 'IPJD2UD9OR', 76 | apiKey: '9dd8e988e93000e13b11a56c9acc649c', 77 | indexName: 'vue-tiny-validate-js', 78 | }, 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /test/method.spec.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue-demi'; 2 | import useValidate from '../src'; 3 | import { baseState, valueExpect } from './shared'; 4 | 5 | describe('method', () => { 6 | it.concurrent('should work with $test', () => { 7 | const data = reactive({ 8 | name: '', 9 | age: 0, 10 | }); 11 | 12 | const rules = reactive({ 13 | name: { 14 | name: 'nameRequired', 15 | test: (v: any) => Boolean(v), 16 | message: 'Name is required', 17 | }, 18 | age: { 19 | name: 'ageRequired', 20 | test: (v: any) => Boolean(v), 21 | message: 'Age is required', 22 | }, 23 | }); 24 | 25 | const { result } = useValidate(data, rules); 26 | 27 | result.value.age.$test(); 28 | 29 | valueExpect(result.value, { 30 | ...baseState, 31 | $invalid: true, 32 | $errors: [{ name: 'ageRequired', message: 'Age is required' }], 33 | $messages: ['Age is required'], 34 | }); 35 | 36 | result.value.$test(); 37 | 38 | valueExpect(result.value, { 39 | ...baseState, 40 | $invalid: true, 41 | $errors: [ 42 | { name: 'nameRequired', message: 'Name is required' }, 43 | { name: 'ageRequired', message: 'Age is required' }, 44 | ], 45 | $messages: ['Name is required', 'Age is required'], 46 | }); 47 | }); 48 | 49 | it.concurrent('should work with $reset', () => { 50 | const data = reactive({ age: 0 }); 51 | const rules = reactive({ 52 | age: { 53 | name: 'ageRequired', 54 | test: (v: any) => Boolean(v), 55 | }, 56 | }); 57 | 58 | const { result } = useValidate(data, rules); 59 | 60 | result.value.$test(); 61 | 62 | result.value.$reset(); 63 | 64 | valueExpect(result.value, baseState); 65 | valueExpect(result.value.age, baseState); 66 | }); 67 | 68 | it.concurrent('should work with $touch', () => { 69 | const data = reactive({ age: 0 }); 70 | const rules = reactive({ 71 | age: { 72 | name: 'ageRequired', 73 | test: (v: any) => Boolean(v), 74 | }, 75 | }); 76 | 77 | const { result } = useValidate(data, rules); 78 | 79 | result.value.$touch(); 80 | 81 | valueExpect(result.value, { 82 | ...baseState, 83 | $dirty: true, 84 | }); 85 | 86 | valueExpect(result.value.age, { 87 | ...baseState, 88 | $dirty: true, 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/mix.spec.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive, ref } from 'vue-demi'; 2 | import useValidate from '../src'; 3 | import { baseState, initialExpect, valueExpect } from './shared'; 4 | 5 | describe('data and rules', () => { 6 | it.concurrent('should together work with ref and reactive', () => { 7 | const data = ref({ country: '' }); 8 | const rules = reactive({ 9 | country: { name: 'required', test: (v: any) => Boolean(v) }, 10 | }); 11 | 12 | const { result } = useValidate(data, rules); 13 | 14 | initialExpect(result.value.country); 15 | 16 | result.value.$test(); 17 | 18 | valueExpect(result.value.country, { 19 | ...baseState, 20 | $invalid: true, 21 | $errors: [{ name: 'required', message: null }], 22 | }); 23 | 24 | data.value.country = 'Vietnam'; 25 | result.value.$test(); 26 | 27 | valueExpect(result.value.country, { 28 | ...baseState, 29 | $dirty: true, 30 | }); 31 | }); 32 | 33 | it.concurrent('should together work with reactive and computed', () => { 34 | const data = reactive({ country: '' }); 35 | const rules = computed(() => ({ 36 | country: { name: 'required', test: (v: any) => Boolean(v) }, 37 | })); 38 | 39 | const { result } = useValidate(data, rules); 40 | 41 | initialExpect(result.value.country); 42 | 43 | result.value.$test(); 44 | 45 | valueExpect(result.value.country, { 46 | ...baseState, 47 | $invalid: true, 48 | $errors: [{ name: 'required', message: null }], 49 | }); 50 | 51 | data.country = 'Vietnam'; 52 | result.value.$test(); 53 | 54 | valueExpect(result.value.country, { 55 | ...baseState, 56 | $dirty: true, 57 | }); 58 | }); 59 | 60 | it.concurrent('should together work with and computed and ref', () => { 61 | const origin = ref(''); 62 | 63 | const data = computed(() => ({ country: origin.value })); 64 | const rules = ref({ 65 | country: { name: 'required', test: (v: any) => Boolean(v) }, 66 | }); 67 | 68 | const { result } = useValidate(data, rules); 69 | 70 | initialExpect(result.value.country); 71 | 72 | result.value.$test(); 73 | 74 | valueExpect(result.value.country, { 75 | ...baseState, 76 | $invalid: true, 77 | $errors: [{ name: 'required', message: null }], 78 | }); 79 | 80 | origin.value = 'Vietnam'; 81 | result.value.$test(); 82 | 83 | valueExpect(result.value.country, { 84 | ...baseState, 85 | $dirty: true, 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef } from 'vue-demi'; 2 | 3 | export type UnknownObject = Record; 4 | 5 | export type Fns = Record>; 6 | 7 | export interface Option { 8 | autoTouch?: boolean; 9 | autoTest?: boolean; 10 | lazy?: boolean; 11 | firstError?: boolean; 12 | touchOnTest?: boolean; 13 | transform?: 14 | | (( 15 | value: any, 16 | data?: Data, 17 | rules?: Rules, 18 | option?: Option, 19 | ) => Result | any) 20 | | (( 21 | value: any, 22 | data?: Data, 23 | rules?: Rules, 24 | option?: Option, 25 | ) => Promise); 26 | } 27 | 28 | export type Data = UnknownObject; 29 | 30 | export interface Rule { 31 | test: 32 | | ((value: any, data?: Data, rules?: Rules, option?: Option) => boolean) 33 | | (( 34 | value: any, 35 | data?: Data, 36 | rules?: Rules, 37 | option?: Option, 38 | ) => Promise); 39 | message?: string | ((value: any) => string); 40 | name: string; 41 | } 42 | 43 | export interface Rules { 44 | [key: string]: Array | Rule | Rules; 45 | } 46 | 47 | export interface Dirt { 48 | [key: string]: boolean | Dirt; 49 | } 50 | 51 | export type FnsMapItem = Record>; 52 | 53 | export type FnsMap = Array; 54 | 55 | export interface Error { 56 | name: string; 57 | message?: string | null; 58 | } 59 | 60 | export interface Entry { 61 | $invalid: boolean; 62 | $errors: Array; 63 | $messages: Array; 64 | $pending: boolean; 65 | $test: (() => void) | (() => Promise); 66 | $reset: () => void; 67 | $touch: () => void; 68 | $uw?: () => void; 69 | } 70 | 71 | export interface Entries { 72 | [key: string]: Entry | Entries; 73 | } 74 | 75 | export type GetDataFn = () => Data; 76 | 77 | export type Args = [GetDataFn, Rules, Dirt, UnknownObject, Entries]; 78 | 79 | export interface ArgsObject { 80 | data: GetDataFn; 81 | rules: Rules; 82 | dirt: Dirt; 83 | rawData: UnknownObject; 84 | entries: Entries; 85 | } 86 | 87 | export interface Result { 88 | $invalid: boolean; 89 | $errors: Array; 90 | $messages: Array; 91 | $test: (() => void) | (() => Promise); 92 | $reset: () => void; 93 | $touch: () => void; 94 | $dirty: boolean; 95 | 96 | // currently there's no good implementation to well support circular reference, so left it any 97 | [key: string]: any; 98 | } 99 | 100 | export interface UseValidate { 101 | result: ComputedRef; 102 | } 103 | -------------------------------------------------------------------------------- /example/vue3/style.css: -------------------------------------------------------------------------------- 1 | @keyframes moveGradient { 2 | 50% { 3 | background-position: 100% 50%; 4 | } 5 | } 6 | 7 | body { 8 | background: linear-gradient( 9 | -60deg, 10 | #5f86f2, 11 | #a65ff2, 12 | #f25fd0, 13 | #f25f61, 14 | #f2cb5f, 15 | #abf25f, 16 | #5ff281, 17 | #5ff2f0 18 | ) 19 | 0 50%; 20 | background-size: 300% 300%; 21 | animation: moveGradient 10s alternate infinite; 22 | } 23 | 24 | input[type='text'], 25 | select { 26 | @apply relative bg-white border px-3 py-2 leading-5 mt-1 block w-full shadow-sm border-gray-300 rounded-md sm:text-sm sm:leading-5 focus:ring-blue-500 focus:border-blue-500; 27 | z-index: 2; 28 | } 29 | 30 | select { 31 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 32 | background-position: right 0.5rem center; 33 | background-size: 1.5em 1.5em; 34 | 35 | @apply bg-no-repeat pr-10 appearance-none; 36 | } 37 | 38 | .base-button { 39 | @apply inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2; 40 | } 41 | 42 | .form-item { 43 | @apply relative; 44 | } 45 | 46 | .form-item--message { 47 | @apply text-xs absolute mt-0.5 block text-red-600; 48 | } 49 | 50 | .form-input__error[type='text'], 51 | select.form-input__error { 52 | @apply border-red-500; 53 | } 54 | 55 | .form-input__success[type='text'], 56 | select.form-input__success { 57 | @apply border-green-500; 58 | } 59 | 60 | .form-item__loading > input { 61 | border: none; 62 | position: relative; 63 | top: 1px; 64 | } 65 | 66 | .form-item__loading::after { 67 | content: ''; 68 | width: calc(100% + 2px); 69 | height: 38px; 70 | z-index: 1; 71 | background: linear-gradient( 72 | 60deg, 73 | #5f86f2, 74 | #a65ff2, 75 | #f25fd0, 76 | #f25f61, 77 | #f2cb5f, 78 | #abf25f, 79 | #5ff281, 80 | #5ff2f0 81 | ) 82 | 0 50%; 83 | background-size: 300% 300%; 84 | animation: moveGradient 4s alternate infinite; 85 | 86 | @apply -bottom-0.5 lg:bottom-0 absolute -left-px rounded-md; 87 | } 88 | 89 | .tree * { 90 | @apply justify-start text-base; 91 | } 92 | 93 | .tree .json-view-item:not(.root-item) { 94 | @apply ml-8; 95 | } 96 | 97 | .tree .data-key, 98 | .tree .value-key { 99 | @apply text-gray-700 outline-none ml-0 pl-0; 100 | } 101 | 102 | .tree .data-key:focus, 103 | .tree .value-key:focus { 104 | @apply outline-none; 105 | } 106 | 107 | .tree .data-key:hover { 108 | @apply bg-transparent; 109 | } 110 | 111 | .tree .chevron-arrow { 112 | @apply hidden; 113 | } 114 | 115 | .tree .properties { 116 | @apply text-xs rounded bg-blue-700 text-white px-2 py-0.5; 117 | } 118 | -------------------------------------------------------------------------------- /test/option.spec.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue-demi'; 2 | import useValidate from '../src'; 3 | import { baseState, delay, valueExpect } from './shared'; 4 | 5 | describe('option', () => { 6 | it.concurrent('should work with autoTest', async () => { 7 | const data = reactive({ city: '' }); 8 | const rules = reactive({ 9 | city: { 10 | name: 'blank', 11 | test: (v: any) => v === '', 12 | }, 13 | }); 14 | 15 | const option = reactive({ autoTest: true }); 16 | 17 | const { result } = useValidate(data, rules, option); 18 | 19 | data.city = 'Hanoi'; 20 | 21 | // since $test is async function, delay to wait until it's done 22 | await delay(100); 23 | 24 | valueExpect(result.value, { 25 | ...baseState, 26 | $invalid: true, 27 | $errors: [{ name: 'blank', message: null }], 28 | $dirty: true, 29 | }); 30 | }); 31 | 32 | it.concurrent('should work with autoTouch', async () => { 33 | const data = reactive({ city: '' }); 34 | const rules = reactive({ 35 | city: { 36 | name: 'blank', 37 | test: (v: any) => v === '', 38 | }, 39 | }); 40 | 41 | const option = reactive({ autoTouch: true }); 42 | 43 | const { result } = useValidate(data, rules, option); 44 | 45 | data.city = 'Hanoi'; 46 | 47 | await delay(100); 48 | 49 | valueExpect(result.value, { 50 | ...baseState, 51 | $dirty: true, 52 | }); 53 | }); 54 | 55 | it.concurrent('should work with lazy', () => { 56 | const data = reactive({ city: 'Hanoi' }); 57 | const rules = reactive({ 58 | city: { 59 | name: 'blank', 60 | test: (v: any) => v === '', 61 | }, 62 | }); 63 | 64 | const option = reactive({ lazy: true }); 65 | 66 | const { result } = useValidate(data, rules, option); 67 | 68 | result.value.$test(); 69 | 70 | valueExpect(result.value, baseState); 71 | }); 72 | 73 | it.concurrent('should work with firstError', () => { 74 | const data = reactive({ city: '' }); 75 | const rules = reactive({ 76 | city: [ 77 | { name: 'notBlank', test: (v: any) => v !== '' }, 78 | { name: 'has5chars', test: (v: any) => v.length === 5 }, 79 | ], 80 | }); 81 | 82 | const option = reactive({ firstError: true }); 83 | 84 | const { result } = useValidate(data, rules, option); 85 | 86 | result.value.$test(); 87 | 88 | valueExpect(result.value, { 89 | ...baseState, 90 | $invalid: true, 91 | $errors: [{ name: 'notBlank', message: null }], 92 | }); 93 | }); 94 | 95 | it.concurrent('should work with touchOnTest', () => { 96 | const data = reactive({ city: 'Hanoi' }); 97 | const rules = reactive({ 98 | city: [ 99 | { name: 'notBlank', test: (v: any) => v !== '' }, 100 | { name: 'has5chars', test: (v: any) => v.length === 5 }, 101 | ], 102 | }); 103 | 104 | const option = reactive({ touchOnTest: true }); 105 | 106 | const { result } = useValidate(data, rules, option); 107 | 108 | result.value.$test(); 109 | 110 | valueExpect(result.value, { 111 | ...baseState, 112 | $dirty: true, 113 | }); 114 | }); 115 | 116 | it.concurrent('should work with transform', () => { 117 | const data = reactive({ city: 'Hanoi' }); 118 | const rules = reactive({ 119 | city: [ 120 | { name: 'notBlank', test: (v: any) => v !== '' }, 121 | { name: 'has5chars', test: (v: any) => v.length === 5 }, 122 | ], 123 | }); 124 | 125 | const option = reactive({ 126 | transform: (r: any) => { 127 | const { $test, $reset, $touch } = r; 128 | return { $test, $reset, $touch, custom: 1 }; 129 | }, 130 | }); 131 | 132 | const { result } = useValidate(data, rules, option); 133 | 134 | result.value.$test(); 135 | 136 | expect(result.value.custom).toBe(1); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-tiny-validate", 3 | "version": "0.2.4", 4 | "description": "Tiny Vue Validate Composition", 5 | "main": "dist/index.umd.js", 6 | "module": "dist/index.es.js", 7 | "sideEffects": false, 8 | "author": "Anh Le", 9 | "license": "MIT", 10 | "keywords": [ 11 | "vue", 12 | "validate", 13 | "vue-validate", 14 | "vue-tiny-validate", 15 | "validate", 16 | "async-validate" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/FrontLabsOfficial/vue-tiny-validate.git" 21 | }, 22 | "files": [ 23 | "dist", 24 | "*.md" 25 | ], 26 | "types": "dist/index.d.ts", 27 | "bugs": { 28 | "url": "https://github.com/FrontLabsOfficial/vue-tiny-validate/issues" 29 | }, 30 | "homepage": "https://github.com/FrontLabsOfficial/vue-tiny-validate/tree/master#readme", 31 | "scripts": { 32 | "postinstall": "husky install", 33 | "clean": "rimraf dist* docs/.vitepress/dist docs/.vitepress/.temp coverage", 34 | "format": "eslint --fix --ext=ts,js,vue,html .", 35 | "type": "tsc --outdir dist", 36 | "test": "yarn test:vue3 && yarn test:vue2", 37 | "test:vue2": "vue-demi-switch 2 vue2 && vitest run --config ./config/test.ts --coverage", 38 | "test:vue3": "vue-demi-switch 3 && vitest run --config ./config/test.ts --coverage", 39 | "dev": "vite --config config/example.vue3.ts", 40 | "dev:docs": "vue-demi-switch 3 && vitepress dev docs --port 4000", 41 | "build:library": "vue-demi-switch 3 && yarn clean && vite build --config config/library.ts && yarn type", 42 | "build:docs": "vue-demi-switch 3 && yarn clean && vitepress build docs", 43 | "build:example": "vue-demi-switch 3 && yarn clean && vite build --config config/example.vue3.ts", 44 | "release": "yarn build:library && np" 45 | }, 46 | "dependencies": { 47 | "vue-demi": "^0.13.11" 48 | }, 49 | "devDependencies": { 50 | "@antfu/eslint-config": "^0.26.2", 51 | "@firebase/analytics": "^0.8.0", 52 | "@firebase/app": "^0.7.31", 53 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 54 | "@types/jest": "^29.0.0", 55 | "@types/lodash": "^4.14.184", 56 | "@types/node": "^18.7.14", 57 | "@vitejs/plugin-vue": "^3.0.3", 58 | "@vitest/coverage-c8": "^0.22.1", 59 | "@vue/compiler-sfc": "^3.2.37", 60 | "@vue/composition-api": "^1.7.0", 61 | "c8": "^7.12.0", 62 | "eslint": "^8.23.0", 63 | "eslint-config-prettier": "^8.5.0", 64 | "eslint-plugin-prettier": "^4.2.1", 65 | "happy-dom": "^6.0.4", 66 | "husky": "^8.0.1", 67 | "json-tree-view-vue3": "^0.1.16", 68 | "lodash": "^4.17.21", 69 | "np": "^7.6.2", 70 | "prettier": "^2.7.1", 71 | "rimraf": "^3.0.2", 72 | "sass": "^1.54.6", 73 | "typescript": "^4.8.2", 74 | "unocss": "^0.45.13", 75 | "vite": "^3.0.9", 76 | "vitepress": "^1.0.0-rc.24", 77 | "vitest": "^0.22.1", 78 | "vue": "^3.2.37", 79 | "vue2": "npm:vue@2.6" 80 | }, 81 | "peerDependencies": { 82 | "@vue/composition-api": "^1.0.0-rc.1", 83 | "vue": ">= 2.6 || >=3.0.0" 84 | }, 85 | "peerDependenciesMeta": { 86 | "@vue/composition-api": { 87 | "optional": true 88 | } 89 | }, 90 | "prettier": { 91 | "arrowParens": "avoid", 92 | "bracketSpacing": true, 93 | "htmlWhitespaceSensitivity": "ignore", 94 | "insertPragma": false, 95 | "jsxSingleQuote": false, 96 | "printWidth": 80, 97 | "proseWrap": "preserve", 98 | "quoteProps": "as-needed", 99 | "requirePragma": false, 100 | "semi": true, 101 | "singleQuote": true, 102 | "tabWidth": 2, 103 | "trailingComma": "all", 104 | "useTabs": false, 105 | "importOrder": [ 106 | "^@(.*)/(.*)$", 107 | "^[./]" 108 | ] 109 | }, 110 | "eslintConfig": { 111 | "root": true, 112 | "env": { 113 | "browser": true, 114 | "node": true 115 | }, 116 | "plugins": [ 117 | "prettier" 118 | ], 119 | "extends": [ 120 | "@antfu/eslint-config", 121 | "prettier" 122 | ], 123 | "rules": { 124 | "no-console": "off", 125 | "@typescript-eslint/no-use-before-define": "off", 126 | "prettier/prettier": "error", 127 | "import/export": "off", 128 | "antfu/if-newline": "off" 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/data.spec.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive, ref } from 'vue-demi'; 2 | import useValidate from '../src'; 3 | import { baseState, initialExpect, valueExpect } from './shared'; 4 | 5 | describe('data', () => { 6 | it.concurrent('should work with ref', () => { 7 | const data = ref({ name: '' }); 8 | const rules = ref({ 9 | name: { name: 'required', test: (value: any) => Boolean(value) }, 10 | }); 11 | 12 | const { result } = useValidate(data, rules); 13 | 14 | initialExpect(result.value.name); 15 | 16 | result.value.$test(); 17 | 18 | valueExpect(result.value.name, { 19 | ...baseState, 20 | $invalid: true, 21 | $errors: [{ name: 'required', message: null }], 22 | }); 23 | 24 | data.value = { name: 'Anh Le' }; 25 | result.value.$test(); 26 | 27 | valueExpect(result.value.name, { 28 | ...baseState, 29 | $dirty: true, 30 | }); 31 | }); 32 | 33 | it.concurrent('should work with reactive', () => { 34 | const data = reactive({ age: 0 }); 35 | const rules = reactive({ 36 | age: { name: 'positive', test: (value: any) => value > 0 }, 37 | }); 38 | 39 | const { result } = useValidate(data, rules); 40 | 41 | initialExpect(result.value.age); 42 | 43 | result.value.$test(); 44 | 45 | valueExpect(result.value.age, { 46 | ...baseState, 47 | $invalid: true, 48 | $errors: [{ name: 'positive', message: null }], 49 | }); 50 | 51 | data.age = 25; 52 | result.value.$test(); 53 | 54 | valueExpect(result.value.age, { 55 | ...baseState, 56 | $dirty: true, 57 | }); 58 | }); 59 | 60 | it.concurrent('should work with computed', () => { 61 | const origin = ref(false); 62 | 63 | const data = computed(() => ({ agree: origin.value })); 64 | const rules = computed(() => ({ 65 | agree: { name: 'isNotFalse', test: (value: any) => Boolean(value) }, 66 | })); 67 | 68 | const { result } = useValidate(data, rules); 69 | 70 | initialExpect(result.value.agree); 71 | 72 | result.value.$test(); 73 | 74 | valueExpect(result.value.agree, { 75 | ...baseState, 76 | $invalid: true, 77 | $errors: [{ name: 'isNotFalse', message: null }], 78 | }); 79 | 80 | origin.value = true; 81 | result.value.$test(); 82 | 83 | valueExpect(result.value.agree, { 84 | ...baseState, 85 | $dirty: true, 86 | }); 87 | }); 88 | 89 | it.concurrent('should work with multiple value', () => { 90 | const data = reactive({ name: '', age: 0 }); 91 | const rules = reactive({ 92 | name: { name: 'required', test: (value: any) => Boolean(value) }, 93 | age: { name: 'positive', test: (value: any) => value > 0 }, 94 | }); 95 | 96 | const { result } = useValidate(data, rules); 97 | 98 | initialExpect(result.value); 99 | initialExpect(result.value.name); 100 | initialExpect(result.value.age); 101 | 102 | result.value.$test(); 103 | 104 | valueExpect(result.value, { 105 | ...baseState, 106 | $invalid: true, 107 | $errors: [ 108 | { name: 'required', message: null }, 109 | { name: 'positive', message: null }, 110 | ], 111 | }); 112 | 113 | valueExpect(result.value.name, { 114 | ...baseState, 115 | $invalid: true, 116 | $errors: [{ name: 'required', message: null }], 117 | }); 118 | 119 | valueExpect(result.value.age, { 120 | ...baseState, 121 | $invalid: true, 122 | $errors: [{ name: 'positive', message: null }], 123 | }); 124 | 125 | data.name = 'Anh Le'; 126 | data.age = 25; 127 | result.value.$test(); 128 | 129 | valueExpect(result.value, { 130 | ...baseState, 131 | $dirty: true, 132 | }); 133 | 134 | valueExpect(result.value.name, { 135 | ...baseState, 136 | $dirty: true, 137 | }); 138 | 139 | valueExpect(result.value.age, { 140 | ...baseState, 141 | $dirty: true, 142 | }); 143 | }); 144 | 145 | it.concurrent('should work with nested value', () => { 146 | const data = reactive({ info: { age: 0 } }); 147 | const rules = reactive({ 148 | info: { 149 | age: { name: 'positive', test: (value: any) => value > 0 }, 150 | }, 151 | }); 152 | 153 | const { result } = useValidate(data, rules); 154 | 155 | initialExpect(result.value.info.age); 156 | 157 | result.value.$test(); 158 | 159 | valueExpect(result.value.info.age, { 160 | ...baseState, 161 | $invalid: true, 162 | $errors: [{ name: 'positive', message: null }], 163 | }); 164 | 165 | data.info.age = 25; 166 | result.value.$test(); 167 | 168 | valueExpect(result.value.info.age, { 169 | ...baseState, 170 | $dirty: true, 171 | }); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | ## Parameters 2 | 3 | Data: 4 | 5 | ```ts 6 | type Data = Record; 7 | 8 | type DataParam = UnwrapRef | Ref | ComputedRef; 9 | ``` 10 | 11 | Rules: 12 | 13 | ```ts 14 | interface Rule { 15 | test: ((value: any) => boolean) | ((value: any) => Promise); 16 | message?: string | ((value: any) => string); 17 | name: string; 18 | } 19 | 20 | interface Rules { 21 | [key: string]: Array | Rule | Rules; 22 | } 23 | 24 | type RulesParam = UnwrapRef | Ref | ComputedRef; 25 | ``` 26 | 27 | `Data` and `Rules` should have the same structure, and `Data` must always have all the properties that `Rules` has. 28 | 29 | They all must be **reactive object**. More exactly, they can only be **Ref**, **Reactive** or **Computed**. 30 | 31 | ## Result 32 | 33 | ```ts 34 | interface Result { 35 | $invalid: boolean; 36 | $errors: Array; 37 | $messages: Array; 38 | $dirty: boolean; 39 | 40 | // method properties... 41 | } 42 | ``` 43 | 44 | ### $invalid 45 | 46 | - Type: `boolean` 47 | - Default: `false` 48 | 49 | Validation state. It's **true** whenever properties have **errors**. 50 | 51 | ### $errors 52 | 53 | ```ts 54 | interface Error { 55 | name: string; 56 | message?: string | null; 57 | } 58 | ``` 59 | 60 | - Type: `Array` 61 | - Default: `[]` 62 | 63 | All the error objects go here. Each of them contains **name** and **message**. 64 | 65 | ### $messages 66 | 67 | - Type: `Array` 68 | - Default: `[]` 69 | 70 | All the error messages go here. 71 | 72 | ### $dirty 73 | 74 | - Type: `boolean` 75 | - Default: `false` 76 | 77 | State to check whether property has been **touched** or **dirtied**. It's **true** whenever property's value has been 78 | **changed** or property is **touched** using `touch` method. 79 | 80 | ### $pending 81 | 82 | - Type: `boolean` 83 | - Default: `false` 84 | 85 | It's **true** whenever the `$test` method is performing **async validation**. 86 | 87 | ## Methods 88 | 89 | ```ts 90 | interface Result { 91 | // result properties... 92 | 93 | $test: (() => void) | (() => Promise); 94 | $reset: () => void; 95 | $touch: () => void; 96 | } 97 | ``` 98 | 99 | ### $test 100 | 101 | - Type: `async function | function` 102 | - Default: `(value: any, data?: Data, rules?: Rules, option?: Option) => void` 103 | 104 | The `$test` method loops through the array of rules of each property and executes `test` function of each rule item. 105 | 106 | ### $reset 107 | 108 | - Type: `function` 109 | - Default: `() => void` 110 | 111 | The `$reset` method sets the result of the property to its default value. 112 | 113 | ### $touch 114 | 115 | - Type: `function` 116 | - Default: `() => void` 117 | 118 | The `$touch` method sets `dirty` result of the property to **true**. 119 | 120 | ## Options 121 | 122 | ```ts 123 | interface Option { 124 | autoTouch?: boolean; 125 | autoTest?: boolean; 126 | lazy?: boolean; 127 | firstError?: boolean; 128 | touchOnTest?: boolean; 129 | } 130 | ``` 131 | 132 | ### autoTest 133 | 134 | - Type: `boolean` 135 | - Default: `false` 136 | 137 | Normally, the `result` object is only updated whenever `$test` method is called. Setting this option to **true** will 138 | have the `$test` method executed on every property change. 139 | 140 | ### autoTouch 141 | 142 | - Type: `boolean` 143 | - Default: `false` 144 | 145 | Same as the option right above. Setting this option to **true** will have `$touch` method executed on every property 146 | change. 147 | 148 | ### lazy 149 | 150 | - Type: `boolean` 151 | - Default: `false` 152 | 153 | As said above, `$test` method will execute all rule items of each property. It's gonna be **redudant** if the `$test` 154 | method tests **undirtied** or **untouched** properties, as they haven't been updated. Setting this option to **true** 155 | will make the `$test` method skip **undirtied** or **untouched** properties. 156 | 157 | ### firstError 158 | 159 | - Type: `boolean` 160 | - Default: `false` 161 | 162 | In some cases, to minimize effort, you only need to validate through the first error. Setting this option to **true** 163 | will make the `$test` method stop the validation process after getting its **first error**. 164 | 165 | ### touchOnTest 166 | 167 | - Type: `boolean` 168 | - Default: `false` 169 | 170 | By default, when executing the `$test` method, only changed properties will be considered as **dirtied** or **touched**. 171 | Setting this option to **true** will have the `$touch` method executed along with the `$test` method. 172 | 173 | ### transform 174 | 175 | - Type: `function` 176 | - Default: `(value: any, data?: Data, rules?: Rules, option?: Option) => any` 177 | 178 | In some cases, you might want to modify or attach a value to the `result` value. That's when `transform` comes. Use this 179 | option to transform the `result` object to anything that fits your needs. 180 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | ledzanh@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /test/rules.spec.ts: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue-demi'; 2 | import useValidate from '../src'; 3 | import { baseState, valueExpect } from './shared'; 4 | 5 | describe('rules', () => { 6 | it.concurrent('should work with multiple value', () => { 7 | const data = reactive({ year: 2020 }); 8 | const rules = reactive({ 9 | year: [ 10 | { 11 | name: 'in20th', 12 | test: (v: any) => v < 2000 && v >= 1900, 13 | }, 14 | { 15 | name: 'odd', 16 | test: (v: any) => v % 2 === 1, 17 | }, 18 | ], 19 | }); 20 | 21 | const { result } = useValidate(data, rules); 22 | 23 | result.value.$test(); 24 | 25 | valueExpect(result.value.year, { 26 | ...baseState, 27 | $invalid: true, 28 | $errors: [ 29 | { name: 'in20th', message: null }, 30 | { name: 'odd', message: null }, 31 | ], 32 | }); 33 | }); 34 | 35 | it.concurrent('should work after mutation', () => { 36 | const data = reactive({ year: 1970 }); 37 | const rules = reactive({ 38 | year: { 39 | name: 'in20th', 40 | test: (v: any) => v < 2000 && v >= 1900, 41 | }, 42 | }); 43 | 44 | const { result } = useValidate(data, rules); 45 | 46 | result.value.$test(); 47 | 48 | valueExpect(result.value.year, baseState); 49 | }); 50 | 51 | it.concurrent('should work with resolved async validator', () => { 52 | const data = reactive({ year: 1970 }); 53 | const rules = reactive({ 54 | year: { 55 | name: 'in20th', 56 | test: (): Promise => { 57 | return new Promise(resolve => setTimeout(() => resolve(false), 2000)); 58 | }, 59 | }, 60 | }); 61 | 62 | const { result } = useValidate(data, rules); 63 | 64 | result.value.$test().then(() => { 65 | valueExpect(result.value.year, { 66 | ...baseState, 67 | $invalid: true, 68 | $errors: [{ name: 'in20th', message: null }], 69 | }); 70 | }); 71 | 72 | expect(result.value.year.$pending).toBe(true); 73 | }); 74 | 75 | it.concurrent('should work with rejected async validator', () => { 76 | const data = reactive({ year: 1970 }); 77 | const rules = reactive({ 78 | year: { 79 | name: 'in20th', 80 | test: (): Promise => { 81 | // eslint-disable-next-line promise/param-names 82 | return new Promise((_, reject) => reject(new Error('Rejected'))); 83 | }, 84 | }, 85 | }); 86 | 87 | const { result } = useValidate(data, rules); 88 | 89 | result.value.$test().then(() => { 90 | valueExpect(result.value.year, { 91 | ...baseState, 92 | $invalid: true, 93 | $errors: [{ name: 'in20th', message: null }], 94 | }); 95 | }); 96 | 97 | expect(result.value.year.$pending).toBe(true); 98 | }); 99 | 100 | it.concurrent('should work with high order validator', () => { 101 | const data = reactive({ zip: 33 }); 102 | 103 | const rgxCheck = 104 | (rgx: RegExp) => 105 | (value: string): boolean => 106 | rgx.test(value); 107 | 108 | const rules = reactive({ 109 | zip: { 110 | test: rgxCheck(/^[0-9]{5}(?:-[0-9]{4})?$/), 111 | name: 'zip', 112 | }, 113 | }); 114 | 115 | const { result } = useValidate(data, rules); 116 | 117 | result.value.$test(); 118 | 119 | valueExpect(result.value.zip, { 120 | ...baseState, 121 | $invalid: true, 122 | $errors: [{ name: 'zip', message: null }], 123 | }); 124 | }); 125 | 126 | it.concurrent('should work with extra param in validator', () => { 127 | const data = reactive({ password: 'well666', rePassword: '' }); 128 | const rules = reactive({ 129 | rePassword: { 130 | test: (v: any, d: any) => v === d.password, 131 | name: 'same', 132 | }, 133 | }); 134 | 135 | const { result } = useValidate(data, rules); 136 | 137 | result.value.$test(); 138 | 139 | valueExpect(result.value.rePassword, { 140 | ...baseState, 141 | $invalid: true, 142 | $errors: [{ name: 'same', message: null }], 143 | }); 144 | }); 145 | 146 | it.concurrent('should work with message', () => { 147 | const data = reactive({ year: 2020 }); 148 | const rules = reactive({ 149 | year: { 150 | name: 'in20th', 151 | test: (v: any) => v < 2000 && v >= 1900, 152 | message: 'Should be in the 20th', 153 | }, 154 | }); 155 | 156 | const { result } = useValidate(data, rules); 157 | 158 | result.value.$test(); 159 | 160 | valueExpect(result.value.year, { 161 | ...baseState, 162 | $invalid: true, 163 | $errors: [{ name: 'in20th', message: 'Should be in the 20th' }], 164 | $messages: ['Should be in the 20th'], 165 | }); 166 | }); 167 | 168 | it.concurrent('should work with data value in message', () => { 169 | const data = reactive({ year: 2020 }); 170 | const rules = reactive({ 171 | year: { 172 | name: 'in20th', 173 | test: (v: any) => v < 2000 && v >= 1900, 174 | message: (v: any) => `${v} is not in the 20th`, 175 | }, 176 | }); 177 | 178 | const { result } = useValidate(data, rules); 179 | 180 | result.value.$test(); 181 | 182 | valueExpect(result.value.year, { 183 | ...baseState, 184 | $invalid: true, 185 | $errors: [{ name: 'in20th', message: '2020 is not in the 20th' }], 186 | $messages: ['2020 is not in the 20th'], 187 | }); 188 | }); 189 | 190 | it.concurrent('should work with high order message function', () => { 191 | const messageFn = (endMessage: any) => (v: any) => 192 | `${v} is not in the 20th. ${endMessage}`; 193 | 194 | const data = reactive({ year: 2020 }); 195 | const rules = reactive({ 196 | year: { 197 | name: 'in20th', 198 | test: (v: any) => v < 2000 && v >= 1900, 199 | message: messageFn('Try another year.'), 200 | }, 201 | }); 202 | 203 | const { result } = useValidate(data, rules); 204 | 205 | result.value.$test(); 206 | 207 | valueExpect(result.value.year, { 208 | ...baseState, 209 | $invalid: true, 210 | $errors: [ 211 | { 212 | name: 'in20th', 213 | message: '2020 is not in the 20th. Try another year.', 214 | }, 215 | ], 216 | $messages: ['2020 is not in the 20th. Try another year.'], 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, Ref, UnwrapRef } from 'vue-demi'; 2 | import { computed, reactive, watch } from 'vue-demi'; 3 | import { 4 | ENTRY_PARAM, 5 | NOOP, 6 | OPTION, 7 | hasOwn, 8 | isObject, 9 | setReactiveValue, 10 | unwrap, 11 | } from './helpers'; 12 | import type { 13 | ArgsObject, 14 | Data, 15 | Dirt, 16 | Entries, 17 | Entry, 18 | Error, 19 | Fns, 20 | GetDataFn, 21 | Option, 22 | Result, 23 | Rule, 24 | Rules, 25 | UnknownObject, 26 | UseValidate, 27 | } from './types'; 28 | 29 | const useValidate = ( 30 | _data: UnwrapRef | Ref | ComputedRef, 31 | _rules: UnwrapRef | Ref | ComputedRef, 32 | _option: UnwrapRef