diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c8f194f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup NodeJS 14 + uses: actions/setup-node@v2 + with: + node-version: 14 + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + + - uses: actions/cache@v3 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install yarn + run: npm install -g yarn + + - name: Install dependencies + run: yarn install + + - name: Clean dist + run: yarn clean + + - name: Run unit test + run: yarn test diff --git a/.gitignore b/.gitignore index c9b1519..13c0af0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules .DS_Store -dist -dist-example -dist-ssr -*.local \ No newline at end of file +dist* +*.local +coverage +.idea +docs/.vitepress/cache diff --git a/.husky/pre-commit b/.husky/pre-commit index 36af219..99301e3 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx lint-staged +yarn format +yarn test diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index d927c2e..0000000 --- a/.prettierrc +++ /dev/null @@ -1,17 +0,0 @@ -{ - "arrowParens": "avoid", - "bracketSpacing": true, - "htmlWhitespaceSensitivity": "ignore", - "insertPragma": false, - "jsxBracketSameLine": true, - "jsxSingleQuote": false, - "printWidth": 80, - "proseWrap": "preserve", - "quoteProps": "as-needed", - "requirePragma": false, - "semi": true, - "singleQuote": true, - "tabWidth": 2, - "trailingComma": "all", - "useTabs": false -} \ No newline at end of file diff --git a/.size-limit.json b/.size-limit.json deleted file mode 100644 index 026054c..0000000 --- a/.size-limit.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "path": "dist/vue-tiny-validate.es.js", - "limit": "2.5KB", - "gzip": false - } -] \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2726f22 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +ledzanh@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5527372 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Anh Le + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a0a641 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +## `vue-tiny-validate` + +Tiny Vue Validate Composition + +## Motivation + +During the time of refactoring our project, we have coped with so many challenges, one of which was to minimize bundle +size of the external libraries. We looked for a solution in the Vue community, and we have seen so many great validation +tools, namely [Vuelidate](https://github.com/vuelidate/vuelidate) or +[vee-validate](https://github.com/logaretm/vee-validate). They were all great, but they weren't the best fit for our +problem at hand. + +Most, or maybe all of them, are over **10KB** minified. This was way too heavy for our goal of keeping our validation library +**robust**, **fully-supported**, and most importantly, **minimal**. + +That's why `vue-tiny-validate` was born. + +## Features + +- Easy. Come with familiar API and coherent documentation. +- Tiny. Only **3.4KB** minified. **1.4KB** gzipped. +- Flexible. Full control over everything. +- Fully functional. Sync validation, async validation, etc supported. +- Compatible. Works with both Vue 2.6 and Vue 3. +- Nearly 100% unit test coverage. + +## Usage + +- See [docs](https://vue-tiny-validate.netlify.app) for more detail. + +## About + +- This library is heavily inspired by [Vuelidate](https://github.com/vuelidate/vuelidate). +- Created by [Anh Le](https://github.com/culee). + +## LICENSE + +- [MIT](https://github.com/FrontLabsOfficial/vue-tiny-validate/blob/master/LICENSE) diff --git a/config/example.vue3.ts b/config/example.vue3.ts new file mode 100644 index 0000000..2a81db3 --- /dev/null +++ b/config/example.vue3.ts @@ -0,0 +1,48 @@ +import { resolve } from 'path'; +import { + presetAttributify, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss'; +import Unocss from 'unocss/vite'; +import type { ConfigEnv } from 'vite'; +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { LIBRARY_NAME } from './shared'; + +const settings = { + plugins: [ + vue(), + Unocss({ + presets: [presetAttributify({ strict: true }), presetUno()], + transformers: [transformerVariantGroup(), transformerDirectives()], + }), + ], + root: resolve(__dirname, '../example/vue3'), + resolve: { + alias: { + [LIBRARY_NAME]: resolve(__dirname, '../src'), + }, + }, + optimizeDeps: { + exclude: ['vue-demi'], + }, +}; + +const dev = { + ...settings, + server: { + port: 3456, + }, +}; + +const build = { + ...settings, + build: { + outDir: resolve(__dirname, '../dist-example'), + }, +}; + +export default ({ command }: ConfigEnv) => + defineConfig(command === 'serve' ? dev : build); diff --git a/config/library.ts b/config/library.ts new file mode 100644 index 0000000..1e9b079 --- /dev/null +++ b/config/library.ts @@ -0,0 +1,26 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { LIBRARY_NAME } from './shared'; + +export default defineConfig({ + plugins: [vue()], + build: { + lib: { + entry: resolve(__dirname, '../src/index.ts'), + name: LIBRARY_NAME, + fileName: 'index', + formats: ['es', 'cjs', 'umd'], + }, + rollupOptions: { + external: ['vue', 'vue-demi'], + output: { + globals: { + vue: 'Vue', + 'vue-demi': 'VueDemi', + }, + }, + }, + outDir: resolve(__dirname, '../dist'), + }, +}); diff --git a/config/shared.ts b/config/shared.ts new file mode 100644 index 0000000..d99ff76 --- /dev/null +++ b/config/shared.ts @@ -0,0 +1 @@ +export const LIBRARY_NAME = 'vue-tiny-validate'; diff --git a/config/test.ts b/config/test.ts new file mode 100644 index 0000000..d2e521c --- /dev/null +++ b/config/test.ts @@ -0,0 +1,20 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + setupFiles: [resolve(__dirname, '../test/setup.ts')], + environment: 'happy-dom', + reporters: 'verbose', + deps: { + inline: ['vue2', '@vue/composition-api', 'vue-demi'], + }, + coverage: { + include: ['src/*'], + clean: true, + lines: 99, + statements: 99, + }, + }, +}); diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js new file mode 100644 index 0000000..8848f53 --- /dev/null +++ b/docs/.vitepress/config.js @@ -0,0 +1,80 @@ +export default { + title: 'vue-tiny-validate', + description: 'Tiny Vue Validate Composition', + lastUpdated: true, + head: [ + ['meta', { property: 'og:title', content: 'vue-tiny-validate' }], + ['meta', { property: 'twitter:title', content: 'vue-tiny-validate' }], + [ + 'meta', + { + property: 'og:description', + content: 'Tiny Vue Validate Composition', + }, + ], + [ + 'meta', + { + property: 'twitter:description', + content: 'Tiny Vue validate Composition', + }, + ], + [ + 'meta', + { + property: 'og:image', + content: 'https://vue-tiny-validate.js.org/og_.jpeg', + }, + ], + [ + 'meta', + { + property: 'twitter:image', + content: 'https://vue-tiny-validate.js.org/og_.jpeg', + }, + ], + ], + themeConfig: { + outline: 'deep', + prevLinks: true, + nextLinks: true, + siteTitle: 'vue-tiny-validate', + socialLinks: [ + { + icon: 'github', + link: 'https://github.com/FrontLabsOfficial/vue-tiny-validate', + }, + ], + nav: [ + { + text: 'Example', + link: 'https://vue-tiny-validate-example.netlify.app/', + }, + ], + sidebar: [ + { + text: 'Guide', + collapsible: true, + items: [ + { text: 'Introduction', link: '/' }, + { text: 'Getting Started', link: '/getting-started' }, + { text: 'Usage', link: '/usage' }, + ], + }, + { + text: 'Others', + collapsible: true, + items: [ + { text: 'API', link: '/api' }, + { text: 'FAQ', link: '/faq' }, + { text: 'Changelog', link: '/change-log' }, + ], + }, + ], + algolia: { + appId: 'IPJD2UD9OR', + apiKey: '9dd8e988e93000e13b11a56c9acc649c', + indexName: 'vue-tiny-validate-js', + }, + }, +}; diff --git a/docs/.vitepress/theme/analytics.js b/docs/.vitepress/theme/analytics.js new file mode 100644 index 0000000..1c044c5 --- /dev/null +++ b/docs/.vitepress/theme/analytics.js @@ -0,0 +1,46 @@ +import { getAnalytics, isSupported } from '@firebase/analytics'; +import { initializeApp } from '@firebase/app'; + +const GOOGLE_ANALYTICS_CONFIG = { + apiKey: 'AIzaSyBY-7TmR3rfgye-KDG3QCx2F73tM4BQo-A', + authDomain: 'vue-tiny-validate.firebaseapp.com', + projectId: 'vue-tiny-validate', + storageBucket: 'vue-tiny-validate.appspot.com', + messagingSenderId: '202088318374', + appId: '1:202088318374:web:5a4d59d9b6d5f78bd83b67', + measurementId: 'G-G6Y36531LR', +}; + +const SELF_DEPLOYED_ANALYTICS_CONFIG = { + 'data-website-id': 'e40a6e9f-6d6a-4f57-969b-085c8e22a276', + src: 'https://analytics.duyanh.dev/umami.js', + defer: '', +}; + +const setAttributes = (el, attrs) => { + for (const key in attrs) { + el.setAttribute(key, attrs[key]); + } +}; + +const AnalyticsPlugin = { + async install() { + if (import.meta.env.SSR || import.meta.env.DEV) return; + // google analytics + const isEnvSupported = await isSupported(); + + if (isEnvSupported) { + const firebaseApp = initializeApp(GOOGLE_ANALYTICS_CONFIG); + getAnalytics(firebaseApp); + console.info('Init-ed FireBase analytics'); + } + + // self-deployed analytics + const scriptElement = document.createElement('script'); + setAttributes(scriptElement, SELF_DEPLOYED_ANALYTICS_CONFIG); + document.head.appendChild(scriptElement); + console.info('Init-ed self-deployed analytics'); + }, +}; + +export default AnalyticsPlugin; diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 0000000..93edde7 --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,12 @@ +import DefaultTheme from 'vitepress/theme'; +import Analytics from './analytics'; +import './style.css'; + +const Theme = { + ...DefaultTheme, + enhanceApp({ app }) { + app.use(Analytics); + }, +}; + +export default Theme; diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 0000000..b32d3ea --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -0,0 +1,8 @@ +:root { + --c-brand: #2563eb; + --c-brand-light: #3b82f6; +} + +.sidebar > .sidebar-links > .sidebar-link + .sidebar-link { + padding-top: 0; +} diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..c5687b8 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,179 @@ +## Parameters + +Data: + +```ts +type Data = Record; + +type DataParam = UnwrapRef | Ref | ComputedRef; +``` + +Rules: + +```ts +interface Rule { + test: ((value: any) => boolean) | ((value: any) => Promise); + message?: string | ((value: any) => string); + name: string; +} + +interface Rules { + [key: string]: Array | Rule | Rules; +} + +type RulesParam = UnwrapRef | Ref | ComputedRef; +``` + +`Data` and `Rules` should have the same structure, and `Data` must always have all the properties that `Rules` has. + +They all must be **reactive object**. More exactly, they can only be **Ref**, **Reactive** or **Computed**. + +## Result + +```ts +interface Result { + $invalid: boolean; + $errors: Array; + $messages: Array; + $dirty: boolean; + + // method properties... +} +``` + +### $invalid + +- Type: `boolean` +- Default: `false` + +Validation state. It's **true** whenever properties have **errors**. + +### $errors + +```ts +interface Error { + name: string; + message?: string | null; +} +``` + +- Type: `Array` +- Default: `[]` + +All the error objects go here. Each of them contains **name** and **message**. + +### $messages + +- Type: `Array` +- Default: `[]` + +All the error messages go here. + +### $dirty + +- Type: `boolean` +- Default: `false` + +State to check whether property has been **touched** or **dirtied**. It's **true** whenever property's value has been +**changed** or property is **touched** using `touch` method. + +### $pending + +- Type: `boolean` +- Default: `false` + +It's **true** whenever the `$test` method is performing **async validation**. + +## Methods + +```ts +interface Result { + // result properties... + + $test: (() => void) | (() => Promise); + $reset: () => void; + $touch: () => void; +} +``` + +### $test + +- Type: `async function | function` +- Default: `(value: any, data?: Data, rules?: Rules, option?: Option) => void` + +The `$test` method loops through the array of rules of each property and executes `test` function of each rule item. + +### $reset + +- Type: `function` +- Default: `() => void` + +The `$reset` method sets the result of the property to its default value. + +### $touch + +- Type: `function` +- Default: `() => void` + +The `$touch` method sets `dirty` result of the property to **true**. + +## Options + +```ts +interface Option { + autoTouch?: boolean; + autoTest?: boolean; + lazy?: boolean; + firstError?: boolean; + touchOnTest?: boolean; +} +``` + +### autoTest + +- Type: `boolean` +- Default: `false` + +Normally, the `result` object is only updated whenever `$test` method is called. Setting this option to **true** will +have the `$test` method executed on every property change. + +### autoTouch + +- Type: `boolean` +- Default: `false` + +Same as the option right above. Setting this option to **true** will have `$touch` method executed on every property +change. + +### lazy + +- Type: `boolean` +- Default: `false` + +As said above, `$test` method will execute all rule items of each property. It's gonna be **redudant** if the `$test` +method tests **undirtied** or **untouched** properties, as they haven't been updated. Setting this option to **true** +will make the `$test` method skip **undirtied** or **untouched** properties. + +### firstError + +- Type: `boolean` +- Default: `false` + +In some cases, to minimize effort, you only need to validate through the first error. Setting this option to **true** +will make the `$test` method stop the validation process after getting its **first error**. + +### touchOnTest + +- Type: `boolean` +- Default: `false` + +By default, when executing the `$test` method, only changed properties will be considered as **dirtied** or **touched**. +Setting this option to **true** will have the `$touch` method executed along with the `$test` method. + +### transform + +- Type: `function` +- Default: `(value: any, data?: Data, rules?: Rules, option?: Option) => any` + +In some cases, you might want to modify or attach a value to the `result` value. That's when `transform` comes. Use this +option to transform the `result` object to anything that fits your needs. diff --git a/docs/change-log.md b/docs/change-log.md new file mode 100644 index 0000000..ebeeff4 --- /dev/null +++ b/docs/change-log.md @@ -0,0 +1,47 @@ +## 0.2.4 + +- Fix `result` value is touched / dirtied before finishing test. + +## 0.2.3 + +- This version is un-published due to some build problems. + +## 0.2.2 + +- Fix wrong `result` when `data` has multiple nested properties. +- Async `$test` method. + +```js +await result.$test(); +console.log('Tested'); +``` + +- Cancel async validation on resetting. +- Add more parameters (`data`, `rules`, `options`) to `test` function. Also, update `rule` properties. + +```js +const rules = reactive({ + name: { + // rule now has (name, test, message) properties instead of ($key, $test, $message) properties + name: 'required', + test: (value, data, rules, options) => { + // you can access data, rules, options here + return Boolean(value); + }, + message: 'Name must not be empty.', + }, +}); +``` + +- Support Vue 2.6. +- Add `transform` option. + +```js +// add some additional value to result object +const transform = value => ({ ...value, addition: 'some value' }); + +const options = reactive({ transform }); + +const { result } = useValidate(data, rules, options); +``` + diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..eea1826 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,3 @@ +### We are updating... + +Visit [here](https://github.com/FrontLabsOfficial/vue-tiny-validate/tree/master/example) for a real-world example. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..f955a87 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,19 @@ +### FAQ + +**Q: Why is there no built-in validator ?** + +**A**: Frankly saying, it's because we are **super lazy**. Moreover, we believe that there is no pre-built validator +that can satisfy all of your needs, so it's **redundant** to have any in the first place. Instead, just build your own +validator. + +**Q: Why should all parameters be reactive objects?** + +**A**: In short, to take advantage of Vue's `watch` method to track their changes effortlessly. + +**Q: Why does re-assigning any `rules` or `options` values also cause the validation results to reset??** + +**A**: Since the library depends on `watch`'s **shallow comparison**, re-assigning any reactive object will trigger +`watch`'s **callback function**. In this case, it will **re-initialize** the validation. + +**Have a question? Feel free to create an [issue](https://github.com/FrontLabsOfficial/vue-tiny-validate/issues). Thank +you.** diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..faccd80 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,54 @@ +## Installation + +with **npm**: + +```bash +npm install vue-tiny-validate +``` + +or with **Yarn**: + +```bash +yarn add vue-tiny-validate +``` + +## Quickstart + +Now that you've installed the library, let's get started with a basic usage guide below. + +```js + + + +``` + +As you can see above, the `useValidate` composition requires 2 parameters `data` and `rules`. + +The `result` value has everything you need to **get** and **set** the validation. In this example, we use the +`$test` method to validate and the `$invalid` property to get validation state. + +Head to **[Usage](/usage)** to see more detailed instructions. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d516e96 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,33 @@ +**vue-tiny-validate** \ +:100: Tiny Vue Validate Composition + +## Motivation + +During the time of refactoring our project, we have coped with so many challenges, one of which was to minimize bundle +size of the external libraries. We looked for a solution in the Vue community, and we have seen so many great validation +tools, namely [Vuelidate](https://github.com/vuelidate/vuelidate) or +[vee-validate](https://github.com/logaretm/vee-validate). They were all great, but they weren't the best fit for our +problem at hand. + +Most, or maybe all of them, are over **10KB** minified. This was way too heavy for our goal of keeping our validation library +**robust**, **fully-supported**, and most importantly, **minimal**. + +That's why `vue-tiny-validate` was born. + +## Features + +- Easy. Come with familiar API and coherent documentation. +- Tiny. Only **3.4KB** minified. **1.4KB** gzipped. +- Flexible. Full control over everything. +- Fully functional. Sync validation, async validation, etc supported. +- Compatible. Works with both Vue 2.6 and Vue 3. +- Nearly 100% unit test coverage. + +## About + +- Inspired by [Vuelidate](https://github.com/vuelidate/vuelidate). +- Created mainly for usage of the `Front` team at [ShopBase :vietnam:](https://shopbase.com) by [Anh Le](https://github.com/anh-ld). + + + Deploys by Netlify + diff --git a/docs/public/og.png b/docs/public/og.png new file mode 100644 index 0000000..ace7493 Binary files /dev/null and b/docs/public/og.png differ diff --git a/docs/public/og_.jpeg b/docs/public/og_.jpeg new file mode 100644 index 0000000..a1c0e9d Binary files /dev/null and b/docs/public/og_.jpeg differ diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..953ee8a --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,383 @@ +## Basic + +```js + + + +``` + +The `useValidate` composition requires 3 parameters: `data`, `rules` and `options`. `data` and `rules` are **mandatory**. +They must have the **same properties** at every level. The other one `options` is optional. + +These 3 parameters must all be **reactive object**. To be exact, they can only be `Ref`, `Reactive` or `Computed`. + +Start validating your data by calling the `$test` method. All the validation states will be +stored in the `result` object. + +::: warning +Re-assigning any **rules** or **options** values will also reset the **validation results**. +::: + +### Nested data + +Data can also be nested. Again, remember to assign the **same properties** for `data` and `rules` at every level. + +```js +const data = reactive({ + name: 'Evelyn', + add: { + street: 'St Louis', + }, +}); + +const rules = reactive({ + name: { + name: 'required', + test: value => Boolean(value), + message: 'Name must not be empty.', + }, + add: { + street: { + name: 'required', + test: value => Boolean(value), + message: 'Street must not be empty.', + }, + }, +}); +``` + +### Array data + +Data can also be an array, even though this library is written for **object structured data**. + +```js +const data = reactive({ + name: 'Evelyn', + add: ['St Louis'], +}); + +const rules = reactive({ + name: { + name: 'required', + test: value => Boolean(value), + message: 'Name must not be empty.', + }, + add: { + 0: { + name: 'required', + test: value => Boolean(value), + message: 'Address must not be empty.', + }, + }, +}); +``` + +or + +```js +const data = ref(['St Louis']); + +const rules = ref({ + 0: { + name: 'required', + test: value => Boolean(value), + message: 'Name must not be empty.', + } +}); +``` + +## Rules + +Each property has its own rule. Rule must be **an object** (validator). + +```js +const rules = reactive({ + name: { + name: 'required', + test: value => Boolean(value), + }, +}); +``` + +Each validator has 3 properties: `name`, `test` and `message`. The first two items are **mandatory** and the other one +`message` is **optional**. + +`name` is an unique key that is used to identify which error the property has after being validated. + +`test` is a validate function that returns a **boolean** value. All the validate logic goes here. + +Whenever the `test` function returns false value, a `message` string is also returned. + +### Multiple + +When the property has more than one rules, these rules must be presented in an **array** of validators. + +```js +const rules = reactive({ + name: [ + { name: 'required', test: value => Boolean(value) }, + { + name: 'maxLength10', + test: value => value.length <= 10, + message: 'Excess max length', + }, + ], +}); +``` + +### Async + +**Async validation** is supported by default. Simply assign an **async function** or a function that returns a +**Promise** to `test`. + +```js +const rules = reactive({ + name: { + name: 'required', + test: (value) => new Promise(resolve => { + resolve(true); + }); + } +}) +``` + +```js +const rules = reactive({ + name: { + name: 'required', + test: async (value) => { + const r = await new Promise(resolve => { + resolve(true); + }); + + return r; + }; + } +}) +``` + +### Extra parameters + +In some cases, the `test` method depends on other parameters. In other words, you need to provide a **dynamic validator**, which +can simply be done by creating a **higher order function** that wraps your normal validator. + +```js +const rgxCheck = rgx => value => rgx.test(value); + +const rules = reactive({ + name: { + name: 'checkZipCode', + test: rgxCheck(/^[0-9]{5}(?:-[0-9]{4})?$/), + }, +}); +``` + +## Messages + +As said above, `message` is basically a string that is returned when `test` returns **false**. + +```js +const rules = reactive({ + name: { + name: 'required', + test: value => Boolean(value), + message: 'This property is required.', + }, +}); +``` + +### With data value + +If you need to include the **data** value in your message, simply assign a function that returns a **string** to +`message`. + +```js +const rules = reactive({ + name: { + name: 'required', + test: value => Boolean(value), + message: value => `Your "${value}" is not allowed.`, + }, +}); +``` + +### Extra parameters + +Same as the above. If you need to provide a **dynamic message**. Simply create a **higher order function** that wraps +your normal `$message` method. + +```js +const messageWith = extra => value => + `The word "${extra}" will be rejected. Your ${value} is not allow.`; + +const rules = reactive({ + name: { + name: 'required', + test: value => Boolean(value), + message: messageWith('hello'), + }, +}); +``` + +## Test + +Validate your data by calling the `$test` method. Technically, it will run through all properties and execute each of +their validators. + +::: warning +`$test` is either function or async function. +::: + +```js +const { result } = useValidate(data, rules); + +// This function is called to test all properties +const testAll = () => { + result.value.$test(); +}; + +// This function is called to only test the 'name' property +const testName = () => { + result.value.name.$test(); +}; +``` + +::: warning +Be careful. The `$test` method is different from the `test` function in validator. +::: + +### Lazy + +To **minimize** the processing time, you would sometimes want to **skip** validating **unchanged** (or undirtied, untouched) +properties. In this case, you could use the `lazy` option. + +```js +const options = reactive({ lazy: true }); + +const { result } = useValidate(data, rules, options); +``` + +### Return first error + +With the same purpose of **minimizing** effort, you could **stop** the validation process after you have detected the +**first error**. This can be done by using the `firstError` option. + +If you want to have only one error at most at each validation, use the `firstError` option. + +```js +const options = reactive({ firstError: true }); + +const { result } = useValidate(data, rules, options); +``` + +### Auto test + +If you want the `$test` method to be called whenever any properties have changes, use the `autoTest` option. + +```js +const options = reactive({ autoTest: true }); + +const { result } = useValidate(data, rules, options); +``` + +## Touch + +Normally, only changed properties are considered as **touched** or **dirtied**. You can intentionally cause the property +to be **touched** or **dirtied** by calling the `$touch` method. + +```js +const { result } = useValidate(data, rules); + +// This function is called to touch all properties +const touchAll = () => { + result.$touch(); +}; + +// This function is called to only touch the 'name' property +const touchName = () => { + result.name.$touch(); +}; +``` + +### Auto touch + +If you want the `$touch` method to be called whenever any properties have changed, use the `autoTouch` option. + +```js +const options = reactive({ autoTouch: true }); + +const { result } = useValidate(data, rules, options); +``` + +### Touch on test + +If you want the `$touch` method to be called along with the `$test` method, use the `touchOnTest` option. + +```js +const options = reactive({ touchOnTest: true }); + +const { result } = useValidate(data, rules, options); +``` + +## Transform + +If you want to transform the `result` value, use the `transform` option. + +```js +// add some additional value to result object +const transform = value => ({ ...value, addition: 'some value' }); + +const options = reactive({ transform }); + +const { result } = useValidate(data, rules, options); +``` + +## Reset + +Calling the `$reset` method will set the `errors`, `messages`, `invalid` and `pending` value of the property to its +default value. + +```js +const { result } = useValidate(data, rules); + +// This function is called to reset all properties +const resetAll = () => { + result.$reset(); +}; + +// This function is called to only reset the 'name' property +const resetName = () => { + result.name.$reset(); +}; +``` diff --git a/example/App.vue b/example/App.vue deleted file mode 100644 index 9a717d9..0000000 --- a/example/App.vue +++ /dev/null @@ -1,185 +0,0 @@ - - - - diff --git a/example/vue3/README.md b/example/vue3/README.md new file mode 100644 index 0000000..9757665 --- /dev/null +++ b/example/vue3/README.md @@ -0,0 +1,3 @@ +[vue-tiny-validate-example.netlify.app](https://vue-tiny-validate-example.netlify.app) + +** Switch from vue3 to vue2 will cause ide to find errors in this directory, just ignore it ** diff --git a/example/index.html b/example/vue3/index.html similarity index 66% rename from example/index.html rename to example/vue3/index.html index de74a86..e8a66b1 100644 --- a/example/index.html +++ b/example/vue3/index.html @@ -3,10 +3,10 @@ - Vue Tiny Validate + Vue Tiny Validate Example: Vue 3
- + diff --git a/example/vue3/src/App.vue b/example/vue3/src/App.vue new file mode 100644 index 0000000..7a7538a --- /dev/null +++ b/example/vue3/src/App.vue @@ -0,0 +1,529 @@ + + + diff --git a/example/main.ts b/example/vue3/src/main.ts similarity index 54% rename from example/main.ts rename to example/vue3/src/main.ts index 684d042..cf7986f 100644 --- a/example/main.ts +++ b/example/vue3/src/main.ts @@ -1,4 +1,7 @@ +import 'uno.css'; import { createApp } from 'vue'; +import '@unocss/reset/tailwind.css'; +import '../style.css'; import App from './App.vue'; createApp(App).mount('#app'); diff --git a/example/shims-vue.d.ts b/example/vue3/src/shims-vue.d.ts similarity index 69% rename from example/shims-vue.d.ts rename to example/vue3/src/shims-vue.d.ts index 506bf2e..64c3fd9 100644 --- a/example/shims-vue.d.ts +++ b/example/vue3/src/shims-vue.d.ts @@ -1,5 +1,5 @@ declare module '*.vue' { - import { DefineComponent } from 'vue'; + import type { DefineComponent } from 'vue'; const component: DefineComponent<{}, {}, any>; export default component; } diff --git a/example/vite.env.d.ts b/example/vue3/src/vite.env.d.ts similarity index 100% rename from example/vite.env.d.ts rename to example/vue3/src/vite.env.d.ts diff --git a/example/vue3/style.css b/example/vue3/style.css new file mode 100644 index 0000000..9b3515e --- /dev/null +++ b/example/vue3/style.css @@ -0,0 +1,117 @@ +@keyframes moveGradient { + 50% { + background-position: 100% 50%; + } +} + +body { + background: linear-gradient( + -60deg, + #5f86f2, + #a65ff2, + #f25fd0, + #f25f61, + #f2cb5f, + #abf25f, + #5ff281, + #5ff2f0 + ) + 0 50%; + background-size: 300% 300%; + animation: moveGradient 10s alternate infinite; +} + +input[type='text'], +select { + @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; + z-index: 2; +} + +select { + 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"); + background-position: right 0.5rem center; + background-size: 1.5em 1.5em; + + @apply bg-no-repeat pr-10 appearance-none; +} + +.base-button { + @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; +} + +.form-item { + @apply relative; +} + +.form-item--message { + @apply text-xs absolute mt-0.5 block text-red-600; +} + +.form-input__error[type='text'], +select.form-input__error { + @apply border-red-500; +} + +.form-input__success[type='text'], +select.form-input__success { + @apply border-green-500; +} + +.form-item__loading > input { + border: none; + position: relative; + top: 1px; +} + +.form-item__loading::after { + content: ''; + width: calc(100% + 2px); + height: 38px; + z-index: 1; + background: linear-gradient( + 60deg, + #5f86f2, + #a65ff2, + #f25fd0, + #f25f61, + #f2cb5f, + #abf25f, + #5ff281, + #5ff2f0 + ) + 0 50%; + background-size: 300% 300%; + animation: moveGradient 4s alternate infinite; + + @apply -bottom-0.5 lg:bottom-0 absolute -left-px rounded-md; +} + +.tree * { + @apply justify-start text-base; +} + +.tree .json-view-item:not(.root-item) { + @apply ml-8; +} + +.tree .data-key, +.tree .value-key { + @apply text-gray-700 outline-none ml-0 pl-0; +} + +.tree .data-key:focus, +.tree .value-key:focus { + @apply outline-none; +} + +.tree .data-key:hover { + @apply bg-transparent; +} + +.tree .chevron-arrow { + @apply hidden; +} + +.tree .properties { + @apply text-xs rounded bg-blue-700 text-white px-2 py-0.5; +} diff --git a/package.json b/package.json index f8245ac..88d6ae9 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "vue-tiny-validate", - "version": "0.2.0", - "description": "Tiny (1KB gzipped) Vue 3 Validate Composition", - "main": "dist/vue-tiny-validate.cjs.js", - "module": "dist/vue-tiny-validate.es.js", + "version": "0.2.4", + "description": "Tiny Vue Validate Composition", + "main": "dist/index.umd.js", + "module": "dist/index.es.js", "sideEffects": false, "author": "Anh Le", "license": "MIT", @@ -11,51 +11,121 @@ "vue", "validate", "vue-validate", - "vue-tiny-validate" + "vue-tiny-validate", + "validate", + "async-validate" ], "repository": { "type": "git", "url": "git+https://github.com/FrontLabsOfficial/vue-tiny-validate.git" }, "files": [ - "dist/*.js", - "dist/vue-tiny-validate.d.ts" + "dist", + "*.md" ], - "types": "dist/vue-tiny-validate.d.ts", + "types": "dist/index.d.ts", "bugs": { "url": "https://github.com/FrontLabsOfficial/vue-tiny-validate/issues" }, "homepage": "https://github.com/FrontLabsOfficial/vue-tiny-validate/tree/master#readme", "scripts": { - "type": "tsc src/*.ts --declaration --emitDeclarationOnly --esModuleInterop --skipLibCheck --outfile dist/vue-tiny-validate.d.ts", - "clean": "rm -rf dist dist-example", - "pretty": "prettier --write '**/*.{ts,css,md,vue,html}'", - "dev": "vite", - "build": "yarn clean && yarn pretty && vite build --mode library && yarn type && yarn size", - "build:example": "yarn clean && yarn pretty && vite build --mode example", - "prepare": "husky install", - "size": "size-limit", - "release": "np" + "postinstall": "husky install", + "clean": "rimraf dist* docs/.vitepress/dist docs/.vitepress/.temp coverage", + "format": "eslint --fix --ext=ts,js,vue,html .", + "type": "tsc --outdir dist", + "test": "yarn test:vue3 && yarn test:vue2", + "test:vue2": "vue-demi-switch 2 vue2 && vitest run --config ./config/test.ts --coverage", + "test:vue3": "vue-demi-switch 3 && vitest run --config ./config/test.ts --coverage", + "dev": "vite --config config/example.vue3.ts", + "dev:docs": "vue-demi-switch 3 && vitepress dev docs --port 4000", + "build:library": "vue-demi-switch 3 && yarn clean && vite build --config config/library.ts && yarn type", + "build:docs": "vue-demi-switch 3 && yarn clean && vitepress build docs", + "build:example": "vue-demi-switch 3 && yarn clean && vite build --config config/example.vue3.ts", + "release": "yarn build:library && np" + }, + "dependencies": { + "vue-demi": "^0.13.11" }, "devDependencies": { - "@size-limit/preset-small-lib": "^4.11.0", - "@vitejs/plugin-vue": "^1.2.3", - "@vue/compiler-sfc": "^3.0.5", - "husky": ">=6", - "json-tree-view-vue3": "^0.1.15", - "lint-staged": ">=10", - "np": "^7.5.0", - "prettier": "^2.3.0", - "sass": "^1.34.1", - "size-limit": "^4.11.0", - "typescript": "^4.3.2", - "vite": "^2.3.5", - "vue": "^3.0.11" + "@antfu/eslint-config": "^0.26.2", + "@firebase/analytics": "^0.8.0", + "@firebase/app": "^0.7.31", + "@trivago/prettier-plugin-sort-imports": "^3.3.0", + "@types/jest": "^29.0.0", + "@types/lodash": "^4.14.184", + "@types/node": "^18.7.14", + "@vitejs/plugin-vue": "^3.0.3", + "@vitest/coverage-c8": "^0.22.1", + "@vue/compiler-sfc": "^3.2.37", + "@vue/composition-api": "^1.7.0", + "c8": "^7.12.0", + "eslint": "^8.23.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-prettier": "^4.2.1", + "happy-dom": "^6.0.4", + "husky": "^8.0.1", + "json-tree-view-vue3": "^0.1.16", + "lodash": "^4.17.21", + "np": "^7.6.2", + "prettier": "^2.7.1", + "rimraf": "^3.0.2", + "sass": "^1.54.6", + "typescript": "^4.8.2", + "unocss": "^0.45.13", + "vite": "^3.0.9", + "vitepress": "^1.0.0-rc.24", + "vitest": "^0.22.1", + "vue": "^3.2.37", + "vue2": "npm:vue@2.6" }, "peerDependencies": { - "vue": "^3.0.11" + "@vue/composition-api": "^1.0.0-rc.1", + "vue": ">= 2.6 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + }, + "prettier": { + "arrowParens": "avoid", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "ignore", + "insertPragma": false, + "jsxSingleQuote": false, + "printWidth": 80, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "requirePragma": false, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false, + "importOrder": [ + "^@(.*)/(.*)$", + "^[./]" + ] }, - "lint-staged": { - "**/*.{ts,css,md,vue,html}": "prettier --write" + "eslintConfig": { + "root": true, + "env": { + "browser": true, + "node": true + }, + "plugins": [ + "prettier" + ], + "extends": [ + "@antfu/eslint-config", + "prettier" + ], + "rules": { + "no-console": "off", + "@typescript-eslint/no-use-before-define": "off", + "prettier/prettier": "error", + "import/export": "off", + "antfu/if-newline": "off" + } } } diff --git a/src/helpers.ts b/src/helpers.ts index f477c68..5a074b9 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,17 +1,41 @@ -export const TEST_FUNCTION = (): boolean => true; +import { Vue2, isRef, isVue2, reactive } from 'vue-demi'; +import type { UnknownObject } from './types'; -export const ERROR_MESSAGE: string = ''; +/* Coverage ignored since it only works with matched Vue version */ -export const RESULT = { - $invalid: false, - $errors: [], - $messages: [], - $test: () => {}, - $reset: () => {}, +/* c8 ignore start */ +export const setReactiveValue = (obj: any, key: string, value: any) => { + if (isVue2) { + Vue2.set(obj, key, value); + } else { + obj[key] = value; + } }; +/* c8 ignore stop */ -export const hasOwn = (obj: { [key: string]: any }, key: string): boolean => +export const hasOwn = (obj: UnknownObject, key: string): boolean => typeof obj[key] !== 'undefined'; -export const isObject = (obj: { [key: string]: any }): boolean => +export const isObject = (obj: UnknownObject): boolean => Object.prototype.toString.call(obj) === '[object Object]'; + +export const unwrap = (obj: UnknownObject): UnknownObject => + (isRef(obj) ? obj.value : obj) as UnknownObject; + +export const NOOP = () => {}; + +export const ENTRY_PARAM = { + $invalid: false, + $errors: [], + $messages: [], + $pending: false, +}; + +export const OPTION = reactive({ + autoTest: false, + autoTouch: false, + lazy: false, + firstError: false, + touchOnTest: false, + transform: (value: any) => value, +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..12a630a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,286 @@ +import type { ComputedRef, Ref, UnwrapRef } from 'vue-demi'; +import { computed, reactive, watch } from 'vue-demi'; +import { + ENTRY_PARAM, + NOOP, + OPTION, + hasOwn, + isObject, + setReactiveValue, + unwrap, +} from './helpers'; +import type { + ArgsObject, + Data, + Dirt, + Entries, + Entry, + Error, + Fns, + GetDataFn, + Option, + Result, + Rule, + Rules, + UnknownObject, + UseValidate, +} from './types'; + +const useValidate = ( + _data: UnwrapRef | Ref | ComputedRef, + _rules: UnwrapRef | Ref | ComputedRef, + _option: UnwrapRef