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 8deccf8..0000000 --- a/.size-limit.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "path": "dist/index.umd.js", - "limit": "3KB", - "gzip": false - } -] \ No newline at end of file diff --git a/README.md b/README.md index 6876037..7a0a641 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,11 @@ That's why `vue-tiny-validate` was born. ## Features - Easy. Come with familiar API and coherent documentation. -- Tiny. No dependencies. Only **3KB** minified. **1.3KB** gzipped. +- Tiny. Only **3.4KB** minified. **1.4KB** gzipped. - Flexible. Full control over everything. -- Fully functional. Sync validation, async validation, etc supported.\ +- Fully functional. Sync validation, async validation, etc supported. - Compatible. Works with both Vue 2.6 and Vue 3. +- Nearly 100% unit test coverage. ## Usage diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts deleted file mode 100644 index dc02fe5..0000000 --- a/__tests__/index.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { ref, reactive } from 'vue'; -import { shallowMount } from '@vue/test-utils'; -// @ts-ignore -import useValidate from 'vue-tiny-validate'; - -// set up Vue component -export const createComponent = (_data: any, _rules: any, _options: any) => ({ - template: `
`, - setup() { - const data = reactive(_data); - const rules = reactive(_rules); - const options = reactive(_options); - const { result } = useValidate(data, rules, options); - - return { result, data }; - }, -}); - -// test stories -describe('data', () => { - const options = {}; - - test('simple data', () => { - const data = { number: 0 }; - const rules = { - number: { name: 'test_01', test: (value: any) => value > 0 }, - }; - - const { vm } = shallowMount(createComponent(data, rules, options)); - - expect(vm.result.$invalid).toBe(false); - expect(vm.result.number.$invalid).toBe(false); - - vm.result.$test(); - - expect(vm.result.$invalid).toBe(true); - expect(vm.result.number.$invalid).toBe(true); - }); - - test('nested data', () => { - const data = { number: 0, another: { number: 5 } }; - const rules = { - number: { name: 'test_01', test: (value: any) => value > 0 }, - another: { - number: { name: 'test_02', test: (value: any) => value > 0 }, - }, - }; - - const { vm } = shallowMount(createComponent(data, rules, options)); - - expect(vm.result.$invalid).toBe(false); - expect(vm.result.number.$invalid).toBe(false); - expect(vm.result.another.number.$invalid).toBe(false); - - vm.result.$test(); - - expect(vm.result.$invalid).toBe(true); - expect(vm.result.number.$invalid).toBe(true); - expect(vm.result.another.number.$invalid).toBe(false); - }); -}); - -describe('rules', () => { - const options = {}; - - test('multiple rules', () => { - const data = { number: 5 }; - const rules = { - number: [ - { name: 'test_01', test: (value: any) => value !== 5 }, - { name: 'test_02', test: (value: any) => value % 2 === 0 }, - ], - }; - - const { vm } = shallowMount(createComponent(data, rules, options)); - - expect(vm.result.$errors.length).toBe(0); - expect(vm.result.number.$errors.length).toBe(0); - - vm.result.$test(); - - expect(vm.result.$errors.length).toBe(2); - expect(vm.result.number.$errors.length).toBe(2); - }); - - test('higher order function test rule', () => { - const test = (cNumber: any) => (value: any) => cNumber === value; - - const data = { number: 5 }; - const rules = { - number: { name: 'test_01', test: test(20) }, - }; - - const { vm } = shallowMount(createComponent(data, rules, options)); - - expect(vm.result.$errors.length).toBe(0); - expect(vm.result.number.$errors.length).toBe(0); - - vm.result.$test(); - - expect(vm.result.$errors.length).toBe(1); - expect(vm.result.number.$errors.length).toBe(1); - }); - - test('rule with message', () => { - const test = (cNumber: any) => (value: any) => cNumber === value; - - const data = { number: 5 }; - const rules = { - number: { - name: 'test_01', - test: (value: any) => value !== 5, - message: 'Can not be 5!!!', - }, - }; - - const { vm } = shallowMount(createComponent(data, rules, options)); - - expect(vm.result.$messages.length).toBe(0); - expect(vm.result.number.$messages.length).toBe(0); - - vm.result.$test(); - - expect(vm.result.$messages.length).toBe(1); - expect(vm.result.number.$messages.length).toBe(1); - }); - - test('rule with message function', () => { - const test = (cNumber: any) => (value: any) => cNumber === value; - - const data = { number: 5 }; - const rules = { - number: { - name: 'test_01', - test: (value: any) => value !== 5, - message: (value: any) => `Got ${value}. Can not be 5!!!`, - }, - }; - - const { vm } = shallowMount(createComponent(data, rules, options)); - - expect(vm.result.$messages.length).toBe(0); - expect(vm.result.number.$messages.length).toBe(0); - - vm.result.$test(); - - expect(vm.result.$messages.length).toBe(1); - expect(vm.result.number.$messages.length).toBe(1); - }); - - test('async rule', async () => { - const test = async (value: any) => - new Promise(resolve => - setTimeout(() => { - resolve(false); - }, 2000), - ); - - const data = { number: 5 }; - const rules = { - number: { name: 'test_01', test: test }, - }; - - const { vm } = shallowMount(createComponent(data, rules, options)); - - expect(vm.result.$pending).toBe(false); - expect(vm.result.$errors.length).toBe(0); - expect(vm.result.number.$pending).toBe(false); - expect(vm.result.number.$errors.length).toBe(0); - - vm.result.$test(); - - expect(vm.result.$pending).toBe(true); - expect(vm.result.number.$pending).toBe(true); - - // workaround to wait 2s - await new Promise(resolve => setTimeout(resolve, 2100)); - - expect(vm.result.$pending).toBe(false); - expect(vm.result.$errors.length).toBe(1); - expect(vm.result.number.$pending).toBe(false); - expect(vm.result.number.$errors.length).toBe(1); - }); -}); - -describe('option', () => { - // skip autoTouch / autoTest / lazy because i haven't found good workaround to update field data without touching dom - - test('firstError', () => { - const data = { number: 5 }; - const rules = { - number: [ - { name: 'test_01', test: (value: any) => value !== 5 }, - { name: 'test_02', test: (value: any) => value % 2 === 0 }, - ], - }; - - const { vm } = shallowMount( - createComponent(data, rules, { firstError: true }), - ); - - expect(vm.result.$errors.length).toBe(0); - expect(vm.result.number.$errors.length).toBe(0); - - vm.result.$test(); - - expect(vm.result.$errors.length).toBe(1); - expect(vm.result.number.$errors.length).toBe(1); - }); - - test('touch on testing', () => { - const data = { number: 5 }; - const rules = { - number: { name: 'test_01', test: (value: any) => value !== 5 }, - }; - - const { vm } = shallowMount( - createComponent(data, rules, { touchOnTest: true }), - ); - - expect(vm.result.$dirty).toBe(false); - expect(vm.result.number.$dirty).toBe(false); - - vm.result.$test(); - - expect(vm.result.$dirty).toBe(true); - expect(vm.result.number.$dirty).toBe(true); - }); -}); 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 index 68d05a1..8848f53 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -1,35 +1,80 @@ -module.exports = { +export default { title: 'vue-tiny-validate', - description: 'Tiny (1KB gzipped) Vue 3 Validate Composition', + 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 (1KB gzipped) Vue 3 Validate Composition', + 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: { - repo: 'FrontLabsOfficial/vue-tiny-validate', - docsDir: 'docs', - docsBranch: 'main', + 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://github.com/FrontLabsOfficial/vue-tiny-validate/tree/master/example', + link: 'https://vue-tiny-validate-example.netlify.app/', }, ], sidebar: [ - { text: 'Introduction', link: '/' }, - { text: 'Getting Started', link: '/getting-started' }, - { text: 'Usage', link: '/usage' }, - { text: 'API', link: '/api' }, - { text: 'FAQ', link: '/faq' }, - { text: 'Changelog', link: '/change-log' }, + { + 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 index 7850521..93edde7 100644 --- a/docs/.vitepress/theme/index.js +++ b/docs/.vitepress/theme/index.js @@ -1,4 +1,12 @@ -import Theme from 'vitepress/theme'; +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/api.md b/docs/api.md index a770f8c..c5687b8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3,25 +3,25 @@ Data: ```ts -type Data = { [key: string]: any }; +type Data = Record