Merge pull request #29 from cmath10/0.6.0-experimental
v0.6.0 Form state management refactoring, release automation
This commit is contained in:
commit
b5961c52eb
3
.commitlintrc.json
Normal file
3
.commitlintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@commitlint/config-conventional"]
|
||||||
|
}
|
@ -22,7 +22,9 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/ban-ts-ignore': 'off', // @TODO
|
'@typescript-eslint/camelcase': ['error', {
|
||||||
|
allow: ['^__Formulario'],
|
||||||
|
}],
|
||||||
'@typescript-eslint/no-empty-function': 'off',
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off', // @TODO
|
'@typescript-eslint/no-explicit-any': 'off', // @TODO
|
||||||
'@typescript-eslint/no-unused-vars': ['error'], // @TODO
|
'@typescript-eslint/no-unused-vars': ['error'], // @TODO
|
||||||
|
16
.github/workflows/github-actions.yml
vendored
Normal file
16
.github/workflows/github-actions.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "Using branch ${{ github.ref }} for repository ${{ github.repository }}."
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- run: echo "The ${{ github.repository }} repository has been cloned to the runner."
|
||||||
|
- run: chmod 777 .
|
||||||
|
- run: docker-compose pull
|
||||||
|
- run: docker-compose run --rm node yarn install
|
||||||
|
- run: docker-compose run --rm node yarn test
|
@ -1,5 +1,9 @@
|
|||||||
|
.commitlintrc.json
|
||||||
|
.editorconfig
|
||||||
.github
|
.github
|
||||||
.gitignore
|
.gitignore
|
||||||
|
.travis.yml
|
||||||
|
.versionrc.json
|
||||||
/storybook
|
/storybook
|
||||||
/node_modules
|
/node_modules
|
||||||
/build
|
/build
|
||||||
|
12
.versionrc.json
Normal file
12
.versionrc.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"types": [
|
||||||
|
{"type": "feat", "section": "Features"},
|
||||||
|
{"type": "fix", "section": "Fixes"},
|
||||||
|
{"type": "chore", "hidden": true},
|
||||||
|
{"type": "docs", "hidden": true},
|
||||||
|
{"type": "style", "hidden": true},
|
||||||
|
{"type": "refactor", "hidden": true},
|
||||||
|
{"type": "perf", "hidden": true},
|
||||||
|
{"type": "test", "hidden": true}
|
||||||
|
]
|
||||||
|
}
|
24
README.md
24
README.md
@ -1,11 +1,11 @@
|
|||||||
## What is Vue Formulario?
|
## What is Vue Formulario?
|
||||||
|
|
||||||
Vue Formulario is a library, based on <a href="https://vueformulate.com">Vue Formulate</a>, that handles the core logic
|
Vue Formulario is a library, inspired by <a href="https://vueformulate.com">Vue Formulate</a>, that handles the core logic
|
||||||
for working with forms and gives full control on the form presentation.
|
for working with forms and gives full control on the form presentation.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Every form control have to rendered inside FormularioInput component. This component provides `id` and `context` in
|
Every form control have to rendered inside FormularioField component. This component provides `id` and `context` in
|
||||||
v-slot props. Control should use `context.model` as v-model and `context.runValidation` as handler for `blur` event
|
v-slot props. Control should use `context.model` as v-model and `context.runValidation` as handler for `blur` event
|
||||||
(it is necessary for validation when property `validationBehavior` is `demand`). Errors list for a field can be
|
(it is necessary for validation when property `validationBehavior` is `demand`). Errors list for a field can be
|
||||||
accessed through `context.allErrors`.
|
accessed through `context.allErrors`.
|
||||||
@ -27,7 +27,7 @@ The example below creates the authorization form from data:
|
|||||||
v-model="formData"
|
v-model="formData"
|
||||||
name="formName"
|
name="formName"
|
||||||
>
|
>
|
||||||
<FormularioInput
|
<FormularioField
|
||||||
v-slot="{ context }"
|
v-slot="{ context }"
|
||||||
name="username"
|
name="username"
|
||||||
validation="required|email"
|
validation="required|email"
|
||||||
@ -46,9 +46,9 @@ The example below creates the authorization form from data:
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
|
|
||||||
<FormularioInput
|
<FormularioField
|
||||||
v-slot="{ context }"
|
v-slot="{ context }"
|
||||||
name="password"
|
name="password"
|
||||||
validation="required|min:4,length"
|
validation="required|min:4,length"
|
||||||
@ -57,10 +57,10 @@ The example below creates the authorization form from data:
|
|||||||
v-model="context.model"
|
v-model="context.model"
|
||||||
type="password"
|
type="password"
|
||||||
>
|
>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
|
|
||||||
<FormularioGrouping name="options">
|
<FormularioFieldGroup name="options">
|
||||||
<FormularioInput
|
<FormularioField
|
||||||
v-slot="{ context }"
|
v-slot="{ context }"
|
||||||
name="anonymous"
|
name="anonymous"
|
||||||
>
|
>
|
||||||
@ -72,10 +72,10 @@ The example below creates the authorization form from data:
|
|||||||
>
|
>
|
||||||
<label for="options-anonymous">As anonymous</label>
|
<label for="options-anonymous">As anonymous</label>
|
||||||
</div>
|
</div>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
</FormularioGrouping>
|
</FormularioFieldGroup>
|
||||||
|
|
||||||
<FormularioInput
|
<FormularioField
|
||||||
v-slot="{ context }"
|
v-slot="{ context }"
|
||||||
name="options.tags[0]"
|
name="options.tags[0]"
|
||||||
>
|
>
|
||||||
@ -83,7 +83,7 @@ The example below creates the authorization form from data:
|
|||||||
v-model="context.model"
|
v-model="context.model"
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
</FormularioForm>
|
</FormularioForm>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import alias from '@rollup/plugin-alias'
|
import alias from '@rollup/plugin-alias'
|
||||||
import autoExternal from 'rollup-plugin-auto-external'
|
import autoExternal from 'rollup-plugin-auto-external'
|
||||||
import commonjs from '@rollup/plugin-commonjs'
|
import commonjs from '@rollup/plugin-commonjs'
|
||||||
import { terser } from 'rollup-plugin-terser'
|
|
||||||
import typescript from 'rollup-plugin-typescript2'
|
import typescript from 'rollup-plugin-typescript2'
|
||||||
import vue from 'rollup-plugin-vue'
|
import vue from 'rollup-plugin-vue'
|
||||||
|
|
||||||
@ -14,19 +13,17 @@ export default {
|
|||||||
globals: {
|
globals: {
|
||||||
'is-plain-object': 'isPlainObject',
|
'is-plain-object': 'isPlainObject',
|
||||||
'is-url': 'isUrl',
|
'is-url': 'isUrl',
|
||||||
'nanoid/non-secure': 'nanoid',
|
|
||||||
vue: 'Vue',
|
vue: 'Vue',
|
||||||
'vue-property-decorator': 'vuePropertyDecorator',
|
'vue-property-decorator': 'vuePropertyDecorator',
|
||||||
},
|
},
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
}],
|
}],
|
||||||
external: ['nanoid/non-secure', 'vue', 'vue-property-decorator'],
|
external: ['vue', 'vue-property-decorator'],
|
||||||
plugins: [
|
plugins: [
|
||||||
typescript({ check: false, sourceMap: false }),
|
typescript({ sourceMap: false }),
|
||||||
vue({ css: true, compileTemplate: true }),
|
vue({ css: true, compileTemplate: true }),
|
||||||
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
|
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
autoExternal(),
|
autoExternal(),
|
||||||
// terser(),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,6 @@ export default {
|
|||||||
globals: {
|
globals: {
|
||||||
'is-plain-object': 'isPlainObject',
|
'is-plain-object': 'isPlainObject',
|
||||||
'is-url': 'isUrl',
|
'is-url': 'isUrl',
|
||||||
'nanoid/non-secure': 'nanoid',
|
|
||||||
vue: 'Vue',
|
vue: 'Vue',
|
||||||
'vue-property-decorator': 'vuePropertyDecorator',
|
'vue-property-decorator': 'vuePropertyDecorator',
|
||||||
},
|
},
|
||||||
@ -27,11 +26,11 @@ export default {
|
|||||||
browser: true,
|
browser: true,
|
||||||
preferBuiltins: false,
|
preferBuiltins: false,
|
||||||
}),
|
}),
|
||||||
typescript({ check: false, sourceMap: false }),
|
typescript({ sourceMap: false }),
|
||||||
vue({ css: true, compileTemplate: true }),
|
vue({ css: true, compileTemplate: true }),
|
||||||
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
|
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
internal(['is-plain-object', 'nanoid/non-secure', 'is-url', 'vue-property-decorator']),
|
internal(['is-plain-object', 'is-url', 'vue-property-decorator']),
|
||||||
terser(),
|
terser(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
version: '3.6'
|
version: '3.6'
|
||||||
services:
|
services:
|
||||||
node:
|
node:
|
||||||
image: library/node:12
|
image: node:12-alpine
|
||||||
user: node
|
user: node
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/vue-formulario
|
- ./:/var/www/vue-formulario
|
||||||
- "$SSH_AUTH_SOCK:/ssh-auth.sock"
|
|
||||||
working_dir: /var/www/vue-formulario
|
working_dir: /var/www/vue-formulario
|
||||||
|
43
package.json
43
package.json
@ -1,20 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@retailcrm/vue-formulario",
|
"name": "@retailcrm/vue-formulario",
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
|
"license": "MIT",
|
||||||
"author": "RetailDriverLLC <integration@retailcrm.ru>",
|
"author": "RetailDriverLLC <integration@retailcrm.ru>",
|
||||||
"scripts": {
|
|
||||||
"build": "npm run build:esm & npm run build:umd & npm run build:iife & wait && echo \"Build complete:\nesm: $(gzip -c dist/formulario.esm.js | wc -c)b gzip\numd: $(gzip -c dist/formulario.umd.js | wc -c)b gzip\nmin: $(gzip -c dist/formulario.min.js | wc -c)b gzip\"",
|
|
||||||
"build:esm": "rollup --config build/rollup.config.js --format es --file dist/formulario.esm.js",
|
|
||||||
"build:iife": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulario.min.js",
|
|
||||||
"build:size": "gzip -c dist/formulario.esm.js | wc -c",
|
|
||||||
"build:umd": "rollup --config build/rollup.config.js --format umd --file dist/formulario.umd.js",
|
|
||||||
"lint": "vue-cli-service lint",
|
|
||||||
"storybook:build": "vue-cli-service storybook:build -c storybook/config",
|
|
||||||
"storybook:serve": "vue-cli-service storybook:serve -p 6006 -c storybook/config",
|
|
||||||
"test": "NODE_ENV=test jest --config test/jest.conf.js",
|
|
||||||
"test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage",
|
|
||||||
"test:watch": "NODE_ENV=test jest --config test/jest.conf.js --watch"
|
|
||||||
},
|
|
||||||
"main": "dist/formulario.umd.js",
|
"main": "dist/formulario.umd.js",
|
||||||
"module": "dist/formulario.esm.js",
|
"module": "dist/formulario.esm.js",
|
||||||
"browser": {
|
"browser": {
|
||||||
@ -24,14 +12,35 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-plain-object": "^3.0.0",
|
"is-plain-object": "^3.0.0",
|
||||||
"is-url": "^1.2.4",
|
"is-url": "^1.2.4",
|
||||||
"nanoid": "^2.1.11",
|
|
||||||
"vue-class-component": "^7.2.3",
|
"vue-class-component": "^7.2.3",
|
||||||
"vue-property-decorator": "^8.4.2"
|
"vue-property-decorator": "^8.4.2"
|
||||||
},
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/retailcrm/vue-formulario/issues"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:esm & npm run build:umd & npm run build:iife & wait && echo \"Build complete:\nesm: $(gzip -c dist/formulario.esm.js | wc -c)b gzip\numd: $(gzip -c dist/formulario.umd.js | wc -c)b gzip\nmin: $(gzip -c dist/formulario.min.js | wc -c)b gzip\"",
|
||||||
|
"build:esm": "rollup --config build/rollup.config.js --format es --file dist/formulario.esm.js",
|
||||||
|
"build:iife": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulario.min.js",
|
||||||
|
"build:size": "gzip -c dist/formulario.esm.js | wc -c",
|
||||||
|
"build:umd": "rollup --config build/rollup.config.js --format umd --file dist/formulario.umd.js",
|
||||||
|
"lint": "vue-cli-service lint",
|
||||||
|
"release": "standard-version",
|
||||||
|
"release:minor": "standard-version --release-as minor",
|
||||||
|
"release:patch": "standard-version --release-as patch",
|
||||||
|
"release:major": "standard-version --release-as major",
|
||||||
|
"storybook:build": "vue-cli-service storybook:build -c storybook/config",
|
||||||
|
"storybook:serve": "vue-cli-service storybook:serve -p 6006 -c storybook/config",
|
||||||
|
"test": "NODE_ENV=test jest --config test/jest.conf.js",
|
||||||
|
"test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage",
|
||||||
|
"test:watch": "NODE_ENV=test jest --config test/jest.conf.js --watch"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.9.6",
|
"@babel/core": "^7.9.6",
|
||||||
"@babel/plugin-transform-modules-commonjs": "^7.9.6",
|
"@babel/plugin-transform-modules-commonjs": "^7.9.6",
|
||||||
"@babel/preset-env": "^7.9.6",
|
"@babel/preset-env": "^7.9.6",
|
||||||
|
"@commitlint/cli": "^12.1.4",
|
||||||
|
"@commitlint/config-conventional": "^12.1.4",
|
||||||
"@rollup/plugin-alias": "^3.1.1",
|
"@rollup/plugin-alias": "^3.1.1",
|
||||||
"@rollup/plugin-buble": "^0.21.3",
|
"@rollup/plugin-buble": "^0.21.3",
|
||||||
"@rollup/plugin-commonjs": "^11.1.0",
|
"@rollup/plugin-commonjs": "^11.1.0",
|
||||||
@ -43,7 +52,6 @@
|
|||||||
"@storybook/vue": "^6.0.26",
|
"@storybook/vue": "^6.0.26",
|
||||||
"@types/is-url": "^1.2.28",
|
"@types/is-url": "^1.2.28",
|
||||||
"@types/jest": "^26.0.14",
|
"@types/jest": "^26.0.14",
|
||||||
"@types/nanoid": "^2.1.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^2.26.0",
|
"@typescript-eslint/eslint-plugin": "^2.26.0",
|
||||||
"@typescript-eslint/parser": "^2.26.0",
|
"@typescript-eslint/parser": "^2.26.0",
|
||||||
"@vue/cli-plugin-babel": "^4.3.1",
|
"@vue/cli-plugin-babel": "^4.3.1",
|
||||||
@ -78,6 +86,7 @@
|
|||||||
"rollup-plugin-typescript2": "^0.27.3",
|
"rollup-plugin-typescript2": "^0.27.3",
|
||||||
"rollup-plugin-vue": "^5.1.7",
|
"rollup-plugin-vue": "^5.1.7",
|
||||||
"sass-loader": "^10.0.3",
|
"sass-loader": "^10.0.3",
|
||||||
|
"standard-version": "^9.3.0",
|
||||||
"ts-jest": "^26.4.1",
|
"ts-jest": "^26.4.1",
|
||||||
"typescript": "~3.9.3",
|
"typescript": "~3.9.3",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
@ -88,9 +97,6 @@
|
|||||||
"vue-template-es2015-compiler": "^1.9.1",
|
"vue-template-es2015-compiler": "^1.9.1",
|
||||||
"watch": "^1.0.2"
|
"watch": "^1.0.2"
|
||||||
},
|
},
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/retailcrm/vue-formulario/issues"
|
|
||||||
},
|
|
||||||
"contributors": [
|
"contributors": [
|
||||||
"Justin Schroeder <justin@wearebraid.com>"
|
"Justin Schroeder <justin@wearebraid.com>"
|
||||||
],
|
],
|
||||||
@ -101,7 +107,6 @@
|
|||||||
"validation",
|
"validation",
|
||||||
"validate"
|
"validate"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
|
@ -7,8 +7,11 @@ import {
|
|||||||
ValidationRuleFn,
|
ValidationRuleFn,
|
||||||
ValidationMessageFn,
|
ValidationMessageFn,
|
||||||
ValidationMessageI18NFn,
|
ValidationMessageI18NFn,
|
||||||
|
Violation,
|
||||||
} from '@/validation/validator'
|
} from '@/validation/validator'
|
||||||
|
|
||||||
|
import { FormularioForm } from '@/types'
|
||||||
|
|
||||||
export interface FormularioOptions {
|
export interface FormularioOptions {
|
||||||
validationRules?: Record<string, ValidationRuleFn>;
|
validationRules?: Record<string, ValidationRuleFn>;
|
||||||
validationMessages?: Record<string, ValidationMessageI18NFn|string>;
|
validationMessages?: Record<string, ValidationMessageI18NFn|string>;
|
||||||
@ -21,7 +24,11 @@ export default class Formulario {
|
|||||||
public validationRules: Record<string, ValidationRuleFn> = {}
|
public validationRules: Record<string, ValidationRuleFn> = {}
|
||||||
public validationMessages: Record<string, ValidationMessageI18NFn|string> = {}
|
public validationMessages: Record<string, ValidationMessageI18NFn|string> = {}
|
||||||
|
|
||||||
constructor (options?: FormularioOptions) {
|
private readonly registry: Map<string, FormularioForm>
|
||||||
|
|
||||||
|
public constructor (options?: FormularioOptions) {
|
||||||
|
this.registry = new Map()
|
||||||
|
|
||||||
this.validationRules = validationRules
|
this.validationRules = validationRules
|
||||||
this.validationMessages = validationMessages
|
this.validationMessages = validationMessages
|
||||||
|
|
||||||
@ -31,26 +38,70 @@ export default class Formulario {
|
|||||||
/**
|
/**
|
||||||
* Given a set of options, apply them to the pre-existing options.
|
* Given a set of options, apply them to the pre-existing options.
|
||||||
*/
|
*/
|
||||||
extend (extendWith: FormularioOptions): Formulario {
|
public extend (extendWith: FormularioOptions): Formulario {
|
||||||
if (typeof extendWith === 'object') {
|
if (typeof extendWith === 'object') {
|
||||||
this.validationRules = merge(this.validationRules, extendWith.validationRules || {})
|
this.validationRules = merge(this.validationRules, extendWith.validationRules || {})
|
||||||
this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {})
|
this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {})
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
throw new Error(`[Formulario]: Formulario.extend() should be passed an object (was ${typeof extendWith})`)
|
throw new Error(`[Formulario]: Formulario.extend(): should be passed an object (was ${typeof extendWith})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
public runValidation (id: string): Promise<Record<string, Violation[]>> {
|
||||||
|
if (!this.registry.has(id)) {
|
||||||
|
throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = this.registry.get(id) as FormularioForm
|
||||||
|
|
||||||
|
return form.runValidation()
|
||||||
|
}
|
||||||
|
|
||||||
|
public resetValidation (id: string): void {
|
||||||
|
if (!this.registry.has(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = this.registry.get(id) as FormularioForm
|
||||||
|
|
||||||
|
form.resetValidation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by forms instances to add themselves into a registry
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public register (id: string, form: FormularioForm): void {
|
||||||
|
if (this.registry.has(id)) {
|
||||||
|
throw new Error(`[Formulario]: Formulario.register(): id "${id}" is already in use`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registry.set(id, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by forms instances to remove themselves from a registry
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public unregister (id: string): void {
|
||||||
|
if (this.registry.has(id)) {
|
||||||
|
this.registry.delete(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get validation rules by merging any passed in with global rules.
|
* Get validation rules by merging any passed in with global rules.
|
||||||
|
* @internal
|
||||||
*/
|
*/
|
||||||
getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
|
public getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
|
||||||
return merge(this.validationRules, extendWith)
|
return merge(this.validationRules, extendWith)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get validation messages by merging any passed in with global messages.
|
* Get validation messages by merging any passed in with global messages.
|
||||||
|
* @internal
|
||||||
*/
|
*/
|
||||||
getMessages (vm: Vue, extendWith: Record<string, ValidationMessageI18NFn|string>): Record<string, ValidationMessageFn> {
|
public getMessages (vm: Vue, extendWith: Record<string, ValidationMessageI18NFn|string>): Record<string, ValidationMessageFn> {
|
||||||
const raw = merge(this.validationMessages || {}, extendWith)
|
const raw = merge(this.validationMessages || {}, extendWith)
|
||||||
const messages: Record<string, ValidationMessageFn> = {}
|
const messages: Record<string, ValidationMessageFn> = {}
|
||||||
|
|
||||||
|
225
src/FormularioField.vue
Normal file
225
src/FormularioField.vue
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<div v-bind="$attrs">
|
||||||
|
<slot :context="context" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
Model,
|
||||||
|
Prop,
|
||||||
|
Watch,
|
||||||
|
} from 'vue-property-decorator'
|
||||||
|
import { deepEquals, has, snakeToCamel } from './utils'
|
||||||
|
import {
|
||||||
|
processConstraints,
|
||||||
|
validate,
|
||||||
|
ValidationRuleFn,
|
||||||
|
ValidationMessageI18NFn,
|
||||||
|
Violation,
|
||||||
|
} from '@/validation/validator'
|
||||||
|
|
||||||
|
import {
|
||||||
|
FormularioFieldContext,
|
||||||
|
FormularioFieldModelGetConverter as ModelGetConverter,
|
||||||
|
FormularioFieldModelSetConverter as ModelSetConverter,
|
||||||
|
Empty,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
const VALIDATION_BEHAVIOR = {
|
||||||
|
DEMAND: 'demand',
|
||||||
|
LIVE: 'live',
|
||||||
|
SUBMIT: 'submit',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({ name: 'FormularioField', inheritAttrs: false })
|
||||||
|
export default class FormularioField extends Vue {
|
||||||
|
@Inject({ default: '' }) __Formulario_path!: string
|
||||||
|
@Inject({ default: undefined }) __FormularioForm_set!: Function|undefined
|
||||||
|
@Inject({ default: () => (): void => {} }) __FormularioForm_emitInput!: Function
|
||||||
|
@Inject({ default: () => (): void => {} }) __FormularioForm_emitValidation!: Function
|
||||||
|
@Inject({ default: undefined }) __FormularioForm_register!: Function|undefined
|
||||||
|
@Inject({ default: undefined }) __FormularioForm_unregister!: Function|undefined
|
||||||
|
|
||||||
|
@Inject({ default: () => (): Record<string, unknown> => ({}) })
|
||||||
|
__FormularioForm_getState!: () => Record<string, unknown>
|
||||||
|
|
||||||
|
@Model('input', { default: '' }) value!: unknown
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
required: true,
|
||||||
|
validator: (name: unknown): boolean => typeof name === 'string' && name.length > 0,
|
||||||
|
}) name!: string
|
||||||
|
|
||||||
|
@Prop({ default: '' }) validation!: string|any[]
|
||||||
|
@Prop({ default: () => ({}) }) validationRules!: Record<string, ValidationRuleFn>
|
||||||
|
@Prop({ default: () => ({}) }) validationMessages!: Record<string, ValidationMessageI18NFn|string>
|
||||||
|
@Prop({
|
||||||
|
default: VALIDATION_BEHAVIOR.DEMAND,
|
||||||
|
validator: behavior => Object.values(VALIDATION_BEHAVIOR).includes(behavior)
|
||||||
|
}) validationBehavior!: string
|
||||||
|
|
||||||
|
// Affects only setting of local errors
|
||||||
|
@Prop({ default: false }) errorsDisabled!: boolean
|
||||||
|
|
||||||
|
@Prop({ default: () => <U, T>(value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter
|
||||||
|
@Prop({ default: () => <T, U>(value: U|T): U|T => value }) modelSetConverter!: ModelSetConverter
|
||||||
|
|
||||||
|
public proxy: unknown = this.hasModel ? this.value : ''
|
||||||
|
|
||||||
|
private localErrors: string[] = []
|
||||||
|
|
||||||
|
private violations: Violation[] = []
|
||||||
|
|
||||||
|
private validationRun: Promise<Violation[]> = Promise.resolve([])
|
||||||
|
|
||||||
|
public get fullPath (): string {
|
||||||
|
return this.__Formulario_path !== '' ? `${this.__Formulario_path}.${this.name}` : this.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if this formulario element is v-modeled or not.
|
||||||
|
*/
|
||||||
|
public get hasModel (): boolean {
|
||||||
|
return has(this.$options.propsData || {}, 'value')
|
||||||
|
}
|
||||||
|
|
||||||
|
private get context (): FormularioFieldContext<unknown> {
|
||||||
|
return Object.defineProperty({
|
||||||
|
name: this.fullPath,
|
||||||
|
path: this.fullPath,
|
||||||
|
runValidation: this.runValidation.bind(this),
|
||||||
|
violations: this.violations,
|
||||||
|
errors: this.localErrors,
|
||||||
|
allErrors: [...this.localErrors, ...this.violations.map(v => v.message)],
|
||||||
|
}, 'model', {
|
||||||
|
get: () => this.modelGetConverter(this.proxy),
|
||||||
|
set: (value: unknown): void => {
|
||||||
|
this.syncProxy(this.modelSetConverter(value, this.proxy))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private get normalizedValidationRules (): Record<string, ValidationRuleFn> {
|
||||||
|
const rules: Record<string, ValidationRuleFn> = {}
|
||||||
|
Object.keys(this.validationRules).forEach(key => {
|
||||||
|
rules[snakeToCamel(key)] = this.validationRules[key]
|
||||||
|
})
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
private get normalizedValidationMessages (): Record<string, ValidationMessageI18NFn|string> {
|
||||||
|
const messages: Record<string, ValidationMessageI18NFn|string> = {}
|
||||||
|
Object.keys(this.validationMessages).forEach(key => {
|
||||||
|
messages[snakeToCamel(key)] = this.validationMessages[key]
|
||||||
|
})
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch('value')
|
||||||
|
private onValueChange (): void {
|
||||||
|
this.syncProxy(this.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch('proxy')
|
||||||
|
private onProxyChange (): void {
|
||||||
|
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
|
||||||
|
this.runValidation()
|
||||||
|
} else {
|
||||||
|
this.resetValidation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public created (): void {
|
||||||
|
if (typeof this.__FormularioForm_register === 'function') {
|
||||||
|
this.__FormularioForm_register(this.fullPath, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
|
||||||
|
this.runValidation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public beforeDestroy (): void {
|
||||||
|
if (typeof this.__FormularioForm_unregister === 'function') {
|
||||||
|
this.__FormularioForm_unregister(this.fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncProxy (value: unknown): void {
|
||||||
|
if (!deepEquals(value, this.proxy)) {
|
||||||
|
this.proxy = value
|
||||||
|
this.$emit('input', value)
|
||||||
|
|
||||||
|
if (typeof this.__FormularioForm_set === 'function') {
|
||||||
|
this.__FormularioForm_set(this.fullPath, value)
|
||||||
|
this.__FormularioForm_emitInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public runValidation (): Promise<Violation[]> {
|
||||||
|
this.validationRun = this.validate().then(violations => {
|
||||||
|
this.violations = violations
|
||||||
|
this.emitValidation(this.fullPath, violations)
|
||||||
|
|
||||||
|
return this.violations
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.validationRun
|
||||||
|
}
|
||||||
|
|
||||||
|
private validate (): Promise<Violation[]> {
|
||||||
|
return validate(processConstraints(
|
||||||
|
this.validation,
|
||||||
|
this.$formulario.getRules(this.normalizedValidationRules),
|
||||||
|
this.$formulario.getMessages(this, this.normalizedValidationMessages),
|
||||||
|
), {
|
||||||
|
value: this.proxy,
|
||||||
|
name: this.fullPath,
|
||||||
|
formValues: this.__FormularioForm_getState(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitValidation (path: string, violations: Violation[]): void {
|
||||||
|
this.$emit('validation', { path, violations })
|
||||||
|
if (typeof this.__FormularioForm_emitValidation === 'function') {
|
||||||
|
this.__FormularioForm_emitValidation(path, violations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasValidationErrors (): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.validationRun.then(() => resolve(this.violations.length > 0))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public setErrors (errors: string[]): void {
|
||||||
|
if (!this.errorsDisabled) {
|
||||||
|
this.localErrors = errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
public resetValidation (): void {
|
||||||
|
this.localErrors = []
|
||||||
|
this.violations = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
38
src/FormularioFieldGroup.vue
Normal file
38
src/FormularioFieldGroup.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Vue from 'vue'
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
Prop,
|
||||||
|
Provide,
|
||||||
|
} from 'vue-property-decorator'
|
||||||
|
|
||||||
|
@Component({ name: 'FormularioFieldGroup' })
|
||||||
|
export default class FormularioFieldGroup extends Vue {
|
||||||
|
@Inject({ default: '' }) __Formulario_path!: string
|
||||||
|
|
||||||
|
@Prop({ required: true })
|
||||||
|
readonly name!: string
|
||||||
|
|
||||||
|
@Provide('__Formulario_path')
|
||||||
|
get fullPath (): string {
|
||||||
|
const path = `${this.name}`
|
||||||
|
|
||||||
|
if (parseInt(path).toString() === path) {
|
||||||
|
return `${this.__Formulario_path}[${path}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.__Formulario_path === '') {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.__Formulario_path}.${path}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,209 +1,208 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="onFormSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
<slot :errors="mergedFormErrors" />
|
<slot :errors="formErrorsComputed" />
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { Component, Model, Prop, Provide, Watch } from 'vue-property-decorator'
|
|
||||||
import { clone, getNested, has, merge, setNested, shallowEqualObjects } from '@/utils'
|
|
||||||
import Registry from '@/form/registry'
|
|
||||||
import FormularioInput from '@/FormularioInput.vue'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ErrorHandler,
|
Component,
|
||||||
ErrorObserver,
|
Model,
|
||||||
ErrorObserverRegistry,
|
Prop,
|
||||||
} from '@/validation/ErrorObserver'
|
Provide,
|
||||||
|
Watch,
|
||||||
|
} from 'vue-property-decorator'
|
||||||
|
import {
|
||||||
|
id,
|
||||||
|
clone,
|
||||||
|
deepEquals,
|
||||||
|
get,
|
||||||
|
has,
|
||||||
|
merge,
|
||||||
|
set,
|
||||||
|
unset,
|
||||||
|
} from '@/utils'
|
||||||
|
|
||||||
|
import { FormularioField } from '@/types'
|
||||||
import { Violation } from '@/validation/validator'
|
import { Violation } from '@/validation/validator'
|
||||||
|
|
||||||
|
const update = (state: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return unset(state, path) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
return set(state, path, value) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
@Component({ name: 'FormularioForm' })
|
@Component({ name: 'FormularioForm' })
|
||||||
export default class FormularioForm extends Vue {
|
export default class FormularioForm extends Vue {
|
||||||
@Model('input', { default: () => ({}) })
|
@Model('input', { default: () => ({}) })
|
||||||
public readonly formularioValue!: Record<string, any>
|
public readonly state!: Record<string, unknown>
|
||||||
|
|
||||||
// Errors record, describing state validation errors of whole form
|
@Prop({ default: () => id('formulario-form') })
|
||||||
@Prop({ default: () => ({}) }) readonly errors!: Record<string, any>
|
public readonly id!: string
|
||||||
// Form errors only used on FormularioForm default slot
|
|
||||||
|
// Describes validation errors of whole form
|
||||||
|
@Prop({ default: () => ({}) }) readonly fieldsErrors!: Record<string, string[]>
|
||||||
|
// Only used on FormularioForm default slot
|
||||||
@Prop({ default: () => ([]) }) readonly formErrors!: string[]
|
@Prop({ default: () => ([]) }) readonly formErrors!: string[]
|
||||||
|
|
||||||
@Provide()
|
private proxy: Record<string, unknown> = {}
|
||||||
public path = ''
|
private registry: Map<string, FormularioField> = new Map()
|
||||||
|
|
||||||
public proxy: Record<string, any> = {}
|
|
||||||
|
|
||||||
private registry: Registry = new Registry(this)
|
|
||||||
|
|
||||||
private errorObserverRegistry = new ErrorObserverRegistry()
|
|
||||||
// Local error messages are temporal, they wiped each resetValidation call
|
// Local error messages are temporal, they wiped each resetValidation call
|
||||||
|
private localFieldsErrors: Record<string, string[]> = {}
|
||||||
private localFormErrors: string[] = []
|
private localFormErrors: string[] = []
|
||||||
private localFieldErrors: Record<string, string[]> = {}
|
|
||||||
|
|
||||||
get initialValues (): Record<string, any> {
|
private get fieldsErrorsComputed (): Record<string, string[]> {
|
||||||
if (this.hasModel && typeof this.formularioValue === 'object') {
|
return merge(this.fieldsErrors || {}, this.localFieldsErrors)
|
||||||
// If there is a v-model on the form/group, use those values as first priority
|
|
||||||
return { ...this.formularioValue } // @todo - use a deep clone to detach reference types
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get mergedFormErrors (): string[] {
|
private get formErrorsComputed (): string[] {
|
||||||
return [...this.formErrors, ...this.localFormErrors]
|
return [...this.formErrors, ...this.localFormErrors]
|
||||||
}
|
}
|
||||||
|
|
||||||
get mergedFieldErrors (): Record<string, string[]> {
|
@Provide('__FormularioForm_register')
|
||||||
return merge(this.errors || {}, this.localFieldErrors)
|
private register (path: string, field: FormularioField): void {
|
||||||
}
|
if (!this.registry.has(path)) {
|
||||||
|
this.registry.set(path, field)
|
||||||
|
}
|
||||||
|
|
||||||
get hasModel (): boolean {
|
const value = get(this.proxy, path)
|
||||||
return has(this.$options.propsData || {}, 'formularioValue')
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasInitialValue (): boolean {
|
if (!field.hasModel) {
|
||||||
return this.formularioValue && typeof this.formularioValue === 'object'
|
if (value !== undefined) {
|
||||||
}
|
field.proxy = value
|
||||||
|
} else {
|
||||||
|
this.setFieldValue(path, null)
|
||||||
|
this.emitInput()
|
||||||
|
}
|
||||||
|
} else if (!deepEquals(field.proxy, value)) {
|
||||||
|
this.setFieldValue(path, field.proxy)
|
||||||
|
this.emitInput()
|
||||||
|
}
|
||||||
|
|
||||||
@Watch('formularioValue', { deep: true })
|
if (has(this.fieldsErrorsComputed, path)) {
|
||||||
onFormularioValueChanged (values: Record<string, any>): void {
|
field.setErrors(this.fieldsErrorsComputed[path])
|
||||||
if (this.hasModel && values && typeof values === 'object') {
|
|
||||||
this.setValues(values)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch('mergedFormErrors')
|
@Provide('__FormularioForm_unregister')
|
||||||
onMergedFormErrorsChanged (errors: string[]): void {
|
private unregister (path: string): void {
|
||||||
this.errorObserverRegistry.filter(o => o.type === 'form').observe(errors)
|
if (this.registry.has(path)) {
|
||||||
|
this.registry.delete(path)
|
||||||
|
this.proxy = unset(this.proxy, path) as Record<string, unknown>
|
||||||
|
this.emitInput()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Watch('mergedFieldErrors', { deep: true, immediate: true })
|
@Provide('__FormularioForm_getState')
|
||||||
onMergedFieldErrorsChanged (errors: Record<string, string[]>): void {
|
private getState (): Record<string, unknown> {
|
||||||
this.errorObserverRegistry.filter(o => o.type === 'input').observe(errors)
|
|
||||||
}
|
|
||||||
|
|
||||||
created (): void {
|
|
||||||
this.initProxy()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provide()
|
|
||||||
getFormValues (): Record<string, any> {
|
|
||||||
return this.proxy
|
return this.proxy
|
||||||
}
|
}
|
||||||
|
|
||||||
onFormSubmit (): Promise<void> {
|
@Provide('__FormularioForm_set')
|
||||||
return this.hasValidationErrors()
|
private setFieldValue (path: string, value: unknown): void {
|
||||||
.then(hasErrors => hasErrors ? undefined : clone(this.proxy))
|
this.proxy = update(this.proxy, path, value)
|
||||||
.then(data => {
|
|
||||||
if (typeof data !== 'undefined') {
|
|
||||||
this.$emit('submit', data)
|
|
||||||
} else {
|
|
||||||
this.$emit('error')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provide()
|
@Provide('__FormularioForm_emitInput')
|
||||||
onFormularioFieldValidation (payload: { name: string; violations: Violation[]}): void {
|
private emitInput (): void {
|
||||||
this.$emit('validation', payload)
|
this.$emit('input', clone(this.proxy))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provide()
|
@Provide('__FormularioForm_emitValidation')
|
||||||
addErrorObserver (observer: ErrorObserver): void {
|
private emitValidation (path: string, violations: Violation[]): void {
|
||||||
this.errorObserverRegistry.add(observer)
|
this.$emit('validation', { path, violations })
|
||||||
if (observer.type === 'form') {
|
|
||||||
observer.callback(this.mergedFormErrors)
|
|
||||||
} else if (observer.field && has(this.mergedFieldErrors, observer.field)) {
|
|
||||||
observer.callback(this.mergedFieldErrors[observer.field])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provide()
|
@Watch('state', { deep: true })
|
||||||
removeErrorObserver (observer: ErrorHandler): void {
|
private onStateChange (newState: Record<string, unknown>): void {
|
||||||
this.errorObserverRegistry.remove(observer)
|
const newProxy = clone(newState)
|
||||||
}
|
const oldProxy = this.proxy
|
||||||
|
|
||||||
@Provide('formularioRegister')
|
|
||||||
register (field: string, component: FormularioInput): void {
|
|
||||||
this.registry.add(field, component)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provide('formularioDeregister')
|
|
||||||
deregister (field: string): void {
|
|
||||||
this.registry.remove(field)
|
|
||||||
}
|
|
||||||
|
|
||||||
initProxy (): void {
|
|
||||||
if (this.hasInitialValue) {
|
|
||||||
this.proxy = this.initialValues
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setValues (values: Record<string, any>): void {
|
|
||||||
const keys = Array.from(new Set([...Object.keys(values), ...Object.keys(this.proxy)]))
|
|
||||||
let proxyHasChanges = false
|
let proxyHasChanges = false
|
||||||
keys.forEach(field => {
|
|
||||||
if (!this.registry.hasNested(field)) {
|
this.registry.forEach((field, path) => {
|
||||||
return
|
const newValue = get(newState, path, null)
|
||||||
|
const oldValue = get(oldProxy, path, null)
|
||||||
|
|
||||||
|
field.proxy = newValue
|
||||||
|
|
||||||
|
if (!deepEquals(newValue, oldValue)) {
|
||||||
|
field.$emit('input', newValue)
|
||||||
|
update(newProxy, path, newValue)
|
||||||
|
proxyHasChanges = true
|
||||||
}
|
}
|
||||||
|
|
||||||
this.registry.getNested(field).forEach((registryField, registryKey) => {
|
|
||||||
const $input = this.registry.get(registryKey) as FormularioInput
|
|
||||||
const oldValue = getNested(this.proxy, registryKey)
|
|
||||||
const newValue = getNested(values, registryKey)
|
|
||||||
|
|
||||||
if (!shallowEqualObjects(newValue, oldValue)) {
|
|
||||||
this.setFieldValue(registryKey, newValue)
|
|
||||||
proxyHasChanges = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shallowEqualObjects(newValue, $input.proxy)) {
|
|
||||||
$input.context.model = newValue
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.initProxy()
|
this.proxy = newProxy
|
||||||
|
|
||||||
if (proxyHasChanges) {
|
if (proxyHasChanges) {
|
||||||
this.$emit('input', { ...this.proxy })
|
this.emitInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFieldValue (field: string, value: any): void {
|
@Watch('fieldsErrorsComputed', { deep: true, immediate: true })
|
||||||
if (value === undefined) {
|
private onFieldsErrorsChange (fieldsErrors: Record<string, string[]>): void {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
this.registry.forEach((field, path) => {
|
||||||
const { [field]: value, ...proxy } = this.proxy
|
field.setErrors(fieldsErrors[path] || [])
|
||||||
this.proxy = proxy
|
})
|
||||||
} else {
|
}
|
||||||
setNested(this.proxy, field, value)
|
|
||||||
|
public created (): void {
|
||||||
|
this.$formulario.register(this.id, this)
|
||||||
|
if (typeof this.state === 'object') {
|
||||||
|
this.proxy = clone(this.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provide('formularioSetter')
|
public beforeDestroy (): void {
|
||||||
setFieldValueAndEmit (field: string, value: any): void {
|
this.$formulario.unregister(this.id)
|
||||||
this.setFieldValue(field, value)
|
|
||||||
this.$emit('input', { ...this.proxy })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors ({ formErrors, inputErrors }: { formErrors?: string[]; inputErrors?: Record<string, string[]> }): void {
|
public runValidation (): Promise<Record<string, Violation[]>> {
|
||||||
|
const runs: Promise<void>[] = []
|
||||||
|
const violations: Record<string, Violation[]> = {}
|
||||||
|
|
||||||
|
this.registry.forEach((field, path) => {
|
||||||
|
runs.push(field.runValidation().then(v => { violations[path] = v }))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.all(runs).then(() => violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasValidationErrors (): Promise<boolean> {
|
||||||
|
return this.runValidation().then(violations => {
|
||||||
|
return Object.keys(violations).some(path => violations[path].length > 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public setErrors ({ fieldsErrors, formErrors }: {
|
||||||
|
fieldsErrors?: Record<string, string[]>;
|
||||||
|
formErrors?: string[];
|
||||||
|
}): void {
|
||||||
|
this.localFieldsErrors = fieldsErrors || {}
|
||||||
this.localFormErrors = formErrors || []
|
this.localFormErrors = formErrors || []
|
||||||
this.localFieldErrors = inputErrors || {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasValidationErrors (): Promise<boolean> {
|
public resetValidation (): void {
|
||||||
return Promise.all(this.registry.reduce((resolvers: Promise<boolean>[], input: FormularioInput) => {
|
this.localFieldsErrors = {}
|
||||||
resolvers.push(input.runValidation() && input.hasValidationErrors())
|
|
||||||
return resolvers
|
|
||||||
}, [])).then(results => results.some(hasErrors => hasErrors))
|
|
||||||
}
|
|
||||||
|
|
||||||
resetValidation (): void {
|
|
||||||
this.localFormErrors = []
|
this.localFormErrors = []
|
||||||
this.localFieldErrors = {}
|
this.registry.forEach((field: FormularioField) => {
|
||||||
this.registry.forEach((input: FormularioInput) => {
|
field.resetValidation()
|
||||||
input.resetValidation()
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSubmit (): Promise<void> {
|
||||||
|
return this.runValidation().then(violations => {
|
||||||
|
const hasErrors = Object.keys(violations).some(path => violations[path].length > 0)
|
||||||
|
|
||||||
|
if (!hasErrors) {
|
||||||
|
this.$emit('submit', clone(this.proxy))
|
||||||
|
} else {
|
||||||
|
this.$emit('error', violations)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import Vue from 'vue'
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
Inject,
|
|
||||||
Prop,
|
|
||||||
Provide,
|
|
||||||
} from 'vue-property-decorator'
|
|
||||||
|
|
||||||
@Component({ name: 'FormularioGrouping' })
|
|
||||||
export default class FormularioGrouping extends Vue {
|
|
||||||
@Inject({ default: '' }) path!: string
|
|
||||||
|
|
||||||
@Prop({ required: true })
|
|
||||||
readonly name!: string
|
|
||||||
|
|
||||||
@Prop({ default: false })
|
|
||||||
readonly isArrayItem!: boolean
|
|
||||||
|
|
||||||
@Provide('path') get groupPath (): string {
|
|
||||||
if (this.isArrayItem) {
|
|
||||||
return `${this.path}[${this.name}]`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.path === '') {
|
|
||||||
return this.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${this.path}.${this.name}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,243 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="formulario-input">
|
|
||||||
<slot :context="context" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import Vue from 'vue'
|
|
||||||
import {
|
|
||||||
Component,
|
|
||||||
Inject,
|
|
||||||
Model,
|
|
||||||
Prop,
|
|
||||||
Watch,
|
|
||||||
} from 'vue-property-decorator'
|
|
||||||
import { arrayify, has, shallowEqualObjects, snakeToCamel } from './utils'
|
|
||||||
import {
|
|
||||||
ValidationRuleFn,
|
|
||||||
ValidationMessageI18NFn,
|
|
||||||
processConstraints,
|
|
||||||
validate,
|
|
||||||
Violation,
|
|
||||||
} from '@/validation/validator'
|
|
||||||
|
|
||||||
const VALIDATION_BEHAVIOR = {
|
|
||||||
DEMAND: 'demand',
|
|
||||||
LIVE: 'live',
|
|
||||||
SUBMIT: 'submit',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelGetConverter {
|
|
||||||
<U, T>(value: U|Empty): U|T|Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelSetConverter {
|
|
||||||
<T, U>(curr: U|T, prev: U|Empty): U|T;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Empty = null | undefined
|
|
||||||
|
|
||||||
@Component({ name: 'FormularioInput', inheritAttrs: false })
|
|
||||||
export default class FormularioInput extends Vue {
|
|
||||||
@Inject({ default: undefined }) formularioSetter!: Function|undefined
|
|
||||||
@Inject({ default: () => (): void => {} }) onFormularioFieldValidation!: Function
|
|
||||||
@Inject({ default: undefined }) formularioRegister!: Function|undefined
|
|
||||||
@Inject({ default: undefined }) formularioDeregister!: Function|undefined
|
|
||||||
@Inject({ default: () => (): Record<string, any> => ({}) }) getFormValues!: Function
|
|
||||||
@Inject({ default: undefined }) addErrorObserver!: Function|undefined
|
|
||||||
@Inject({ default: undefined }) removeErrorObserver!: Function|undefined
|
|
||||||
@Inject({ default: '' }) path!: string
|
|
||||||
|
|
||||||
@Model('input', { default: '' }) value!: any
|
|
||||||
|
|
||||||
@Prop({
|
|
||||||
required: true,
|
|
||||||
validator: (name: any): boolean => typeof name === 'string' && name.length > 0,
|
|
||||||
}) name!: string
|
|
||||||
|
|
||||||
@Prop({ default: '' }) validation!: string|any[]
|
|
||||||
@Prop({ default: () => ({}) }) validationRules!: Record<string, ValidationRuleFn>
|
|
||||||
@Prop({ default: () => ({}) }) validationMessages!: Record<string, ValidationMessageI18NFn|string>
|
|
||||||
@Prop({
|
|
||||||
default: VALIDATION_BEHAVIOR.DEMAND,
|
|
||||||
validator: behavior => Object.values(VALIDATION_BEHAVIOR).includes(behavior)
|
|
||||||
}) validationBehavior!: string
|
|
||||||
|
|
||||||
// Affects only observing & setting of local errors
|
|
||||||
@Prop({ default: false }) errorsDisabled!: boolean
|
|
||||||
|
|
||||||
@Prop({ default: () => <U, T>(value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter
|
|
||||||
@Prop({ default: () => <T, U>(value: U|T): U|T => value }) modelSetConverter!: ModelSetConverter
|
|
||||||
|
|
||||||
public proxy: any = this.getInitialValue()
|
|
||||||
|
|
||||||
private localErrors: string[] = []
|
|
||||||
private violations: Violation[] = []
|
|
||||||
private validationRun: Promise<any> = Promise.resolve()
|
|
||||||
|
|
||||||
get fullQualifiedName (): string {
|
|
||||||
return this.path !== '' ? `${this.path}.${this.name}` : this.name
|
|
||||||
}
|
|
||||||
|
|
||||||
get model (): any {
|
|
||||||
const model = this.hasModel ? 'value' : 'proxy'
|
|
||||||
return this.modelGetConverter(this[model])
|
|
||||||
}
|
|
||||||
|
|
||||||
set model (value: any) {
|
|
||||||
value = this.modelSetConverter(value, this.proxy)
|
|
||||||
|
|
||||||
if (!shallowEqualObjects(value, this.proxy)) {
|
|
||||||
this.proxy = value
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit('input', value)
|
|
||||||
|
|
||||||
if (typeof this.formularioSetter === 'function') {
|
|
||||||
this.formularioSetter(this.context.name, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get context (): Record<string, any> {
|
|
||||||
return Object.defineProperty({
|
|
||||||
name: this.fullQualifiedName,
|
|
||||||
runValidation: this.runValidation.bind(this),
|
|
||||||
violations: this.violations,
|
|
||||||
errors: this.localErrors,
|
|
||||||
allErrors: [...this.localErrors, ...this.violations.map(v => v.message)],
|
|
||||||
}, 'model', {
|
|
||||||
get: () => this.model,
|
|
||||||
set: (value: any) => {
|
|
||||||
this.model = value
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
get normalizedValidationRules (): Record<string, ValidationRuleFn> {
|
|
||||||
const rules: Record<string, ValidationRuleFn> = {}
|
|
||||||
Object.keys(this.validationRules).forEach(key => {
|
|
||||||
rules[snakeToCamel(key)] = this.validationRules[key]
|
|
||||||
})
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
|
|
||||||
get normalizedValidationMessages (): Record<string, ValidationMessageI18NFn|string> {
|
|
||||||
const messages: Record<string, ValidationMessageI18NFn|string> = {}
|
|
||||||
Object.keys(this.validationMessages).forEach(key => {
|
|
||||||
messages[snakeToCamel(key)] = this.validationMessages[key]
|
|
||||||
})
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if this formulario element is v-modeled or not.
|
|
||||||
*/
|
|
||||||
get hasModel (): boolean {
|
|
||||||
return has(this.$options.propsData || {}, 'value')
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('proxy')
|
|
||||||
onProxyChanged (newValue: any, oldValue: any): void {
|
|
||||||
if (!this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
|
|
||||||
this.context.model = newValue
|
|
||||||
}
|
|
||||||
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
|
|
||||||
this.runValidation()
|
|
||||||
} else {
|
|
||||||
this.violations = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('value')
|
|
||||||
onValueChanged (newValue: any, oldValue: any): void {
|
|
||||||
if (this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
|
|
||||||
this.context.model = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
created (): void {
|
|
||||||
this.initProxy()
|
|
||||||
if (typeof this.formularioRegister === 'function') {
|
|
||||||
this.formularioRegister(this.fullQualifiedName, this)
|
|
||||||
}
|
|
||||||
if (typeof this.addErrorObserver === 'function' && !this.errorsDisabled) {
|
|
||||||
this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.fullQualifiedName })
|
|
||||||
}
|
|
||||||
if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
|
|
||||||
this.runValidation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noinspection JSUnusedGlobalSymbols
|
|
||||||
beforeDestroy (): void {
|
|
||||||
if (!this.errorsDisabled && typeof this.removeErrorObserver === 'function') {
|
|
||||||
this.removeErrorObserver(this.setErrors)
|
|
||||||
}
|
|
||||||
if (typeof this.formularioDeregister === 'function') {
|
|
||||||
this.formularioDeregister(this.fullQualifiedName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getInitialValue (): any {
|
|
||||||
return has(this.$options.propsData || {}, 'value') ? this.value : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
initProxy (): void {
|
|
||||||
// This should only be run immediately on created and ensures that the
|
|
||||||
// proxy and the model are both the same before any additional registration.
|
|
||||||
if (!shallowEqualObjects(this.context.model, this.proxy)) {
|
|
||||||
this.context.model = this.proxy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runValidation (): Promise<void> {
|
|
||||||
this.validationRun = this.validate().then(violations => {
|
|
||||||
const validationChanged = !shallowEqualObjects(violations, this.violations)
|
|
||||||
this.violations = violations
|
|
||||||
if (validationChanged) {
|
|
||||||
const payload = {
|
|
||||||
name: this.context.name,
|
|
||||||
violations: this.violations,
|
|
||||||
}
|
|
||||||
this.$emit('validation', payload)
|
|
||||||
if (typeof this.onFormularioFieldValidation === 'function') {
|
|
||||||
this.onFormularioFieldValidation(payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.violations
|
|
||||||
})
|
|
||||||
return this.validationRun
|
|
||||||
}
|
|
||||||
|
|
||||||
validate (): Promise<Violation[]> {
|
|
||||||
return validate(processConstraints(
|
|
||||||
this.validation,
|
|
||||||
this.$formulario.getRules(this.normalizedValidationRules),
|
|
||||||
this.$formulario.getMessages(this, this.normalizedValidationMessages),
|
|
||||||
), {
|
|
||||||
value: this.context.model,
|
|
||||||
name: this.context.name,
|
|
||||||
formValues: this.getFormValues(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
hasValidationErrors (): Promise<boolean> {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.validationRun.then(() => resolve(this.violations.length > 0))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors (errors: string[]): void {
|
|
||||||
this.localErrors = arrayify(errors)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetValidation (): void {
|
|
||||||
this.localErrors = []
|
|
||||||
this.violations = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -1,141 +0,0 @@
|
|||||||
import { shallowEqualObjects, has, getNested } from '@/utils'
|
|
||||||
import FormularioForm from '@/FormularioForm.vue'
|
|
||||||
import FormularioInput from '@/FormularioInput.vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component registry with inherent depth to handle complex nesting. This is
|
|
||||||
* important for features such as grouped fields.
|
|
||||||
*/
|
|
||||||
export default class Registry {
|
|
||||||
private ctx: FormularioForm
|
|
||||||
private registry: Map<string, FormularioInput>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new registry of components.
|
|
||||||
* @param {FormularioForm} ctx The host vm context of the registry.
|
|
||||||
*/
|
|
||||||
constructor (ctx: FormularioForm) {
|
|
||||||
this.registry = new Map()
|
|
||||||
this.ctx = ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fully register a component.
|
|
||||||
* @param {string} field name of the field.
|
|
||||||
* @param {FormularioForm} component the actual component instance.
|
|
||||||
*/
|
|
||||||
add (field: string, component: FormularioInput): void {
|
|
||||||
if (this.registry.has(field)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registry.set(field, component)
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const value = getNested(this.ctx.initialValues, field)
|
|
||||||
const hasModel = has(component.$options.propsData || {}, 'value')
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
if (!hasModel && this.ctx.hasInitialValue && value !== undefined) {
|
|
||||||
// In the case that the form is carrying an initial value and the
|
|
||||||
// element is not, set it directly.
|
|
||||||
// @ts-ignore
|
|
||||||
component.context.model = value
|
|
||||||
// @ts-ignore
|
|
||||||
} else if (hasModel && !shallowEqualObjects(component.proxy, value)) {
|
|
||||||
// In this case, the field is v-modeled or has an initial value and the
|
|
||||||
// form has no value or a different value, so use the field value
|
|
||||||
// @ts-ignore
|
|
||||||
this.ctx.setFieldValueAndEmit(field, component.proxy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an item from the registry.
|
|
||||||
*/
|
|
||||||
remove (name: string): void {
|
|
||||||
this.registry.delete(name)
|
|
||||||
// @ts-ignore
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { [name]: value, ...newProxy } = this.ctx.proxy
|
|
||||||
// @ts-ignore
|
|
||||||
this.ctx.proxy = newProxy
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the registry has the given key.
|
|
||||||
*/
|
|
||||||
has (key: string): boolean {
|
|
||||||
return this.registry.has(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the registry has elements, that equals or nested given key
|
|
||||||
*/
|
|
||||||
hasNested (key: string): boolean {
|
|
||||||
for (const i of this.registry.keys()) {
|
|
||||||
if (i === key || i.includes(key + '.')) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a particular registry value.
|
|
||||||
*/
|
|
||||||
get (key: string): FormularioInput | undefined {
|
|
||||||
return this.registry.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get registry value for key or nested to given key
|
|
||||||
*/
|
|
||||||
getNested (key: string): Map<string, FormularioInput> {
|
|
||||||
const result = new Map()
|
|
||||||
|
|
||||||
for (const i of this.registry.keys()) {
|
|
||||||
const objectKey = key + '.'
|
|
||||||
const arrayKey = key + '['
|
|
||||||
|
|
||||||
if (
|
|
||||||
i === key ||
|
|
||||||
i.substring(0, objectKey.length) === objectKey ||
|
|
||||||
i.substring(0, arrayKey.length) === arrayKey
|
|
||||||
) {
|
|
||||||
result.set(i, this.registry.get(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map over the registry (recursively).
|
|
||||||
*/
|
|
||||||
forEach (callback: Function): void {
|
|
||||||
this.registry.forEach((component, field) => {
|
|
||||||
callback(component, field)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the keys of the registry.
|
|
||||||
*/
|
|
||||||
keys (): string[] {
|
|
||||||
return Array.from(this.registry.keys())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduce the registry.
|
|
||||||
* @param {function} callback
|
|
||||||
* @param accumulator
|
|
||||||
*/
|
|
||||||
reduce<U> (callback: Function, accumulator: U): U {
|
|
||||||
this.registry.forEach((component, field) => {
|
|
||||||
accumulator = callback(accumulator, component, field)
|
|
||||||
})
|
|
||||||
return accumulator
|
|
||||||
}
|
|
||||||
}
|
|
18
src/index.ts
18
src/index.ts
@ -1,14 +1,22 @@
|
|||||||
import Formulario, { FormularioOptions } from '@/Formulario.ts'
|
|
||||||
import { VueConstructor } from 'vue'
|
import { VueConstructor } from 'vue'
|
||||||
|
|
||||||
|
import Formulario, { FormularioOptions } from '@/Formulario.ts'
|
||||||
|
|
||||||
|
import FormularioField from '@/FormularioField.vue'
|
||||||
|
import FormularioFieldGroup from '@/FormularioFieldGroup.vue'
|
||||||
import FormularioForm from '@/FormularioForm.vue'
|
import FormularioForm from '@/FormularioForm.vue'
|
||||||
import FormularioGrouping from '@/FormularioGrouping.vue'
|
|
||||||
import FormularioInput from '@/FormularioInput.vue'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
Formulario,
|
||||||
install (Vue: VueConstructor, options?: FormularioOptions): void {
|
install (Vue: VueConstructor, options?: FormularioOptions): void {
|
||||||
|
Vue.component('FormularioField', FormularioField)
|
||||||
|
Vue.component('FormularioFieldGroup', FormularioFieldGroup)
|
||||||
Vue.component('FormularioForm', FormularioForm)
|
Vue.component('FormularioForm', FormularioForm)
|
||||||
Vue.component('FormularioGrouping', FormularioGrouping)
|
|
||||||
Vue.component('FormularioInput', FormularioInput)
|
// @deprecated Use FormularioField instead
|
||||||
|
Vue.component('FormularioInput', FormularioField)
|
||||||
|
// @deprecated Use FormularioFieldGroup instead
|
||||||
|
Vue.component('FormularioGrouping', FormularioFieldGroup)
|
||||||
|
|
||||||
Vue.mixin({
|
Vue.mixin({
|
||||||
beforeCreate () {
|
beforeCreate () {
|
||||||
|
105
src/types.ts
Normal file
105
src/types.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { Violation } from '@/validation/validator'
|
||||||
|
|
||||||
|
export interface FormularioForm extends Vue {
|
||||||
|
runValidation(): Promise<Record<string, Violation[]>>;
|
||||||
|
resetValidation(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormularioField extends Vue {
|
||||||
|
hasModel: boolean;
|
||||||
|
proxy: unknown;
|
||||||
|
setErrors(errors: string[]): void;
|
||||||
|
runValidation(): Promise<Violation[]>;
|
||||||
|
resetValidation(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormularioFieldContext<T> = {
|
||||||
|
model: T;
|
||||||
|
name: string;
|
||||||
|
runValidation(): Promise<Violation[]>;
|
||||||
|
violations: Violation[];
|
||||||
|
errors: string[];
|
||||||
|
allErrors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormularioFieldModelGetConverter {
|
||||||
|
<U, T>(value: U|Empty): U|T|Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormularioFieldModelSetConverter {
|
||||||
|
<T, U>(curr: U|T, prev: U|Empty): U|T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Empty = undefined | null
|
||||||
|
|
||||||
|
export enum TYPE {
|
||||||
|
ARRAY = 'ARRAY',
|
||||||
|
BIGINT = 'BIGINT',
|
||||||
|
BOOLEAN = 'BOOLEAN',
|
||||||
|
DATE = 'DATE',
|
||||||
|
FUNCTION = 'FUNCTION',
|
||||||
|
NUMBER = 'NUMBER',
|
||||||
|
RECORD = 'RECORD',
|
||||||
|
STRING = 'STRING',
|
||||||
|
SYMBOL = 'SYMBOL',
|
||||||
|
UNDEFINED = 'UNDEFINED',
|
||||||
|
NULL = 'NULL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeOf (value: unknown): string {
|
||||||
|
switch (typeof value) {
|
||||||
|
case 'bigint':
|
||||||
|
return TYPE.BIGINT
|
||||||
|
case 'boolean':
|
||||||
|
return TYPE.BOOLEAN
|
||||||
|
case 'function':
|
||||||
|
return TYPE.FUNCTION
|
||||||
|
case 'number':
|
||||||
|
return TYPE.NUMBER
|
||||||
|
case 'string':
|
||||||
|
return TYPE.STRING
|
||||||
|
case 'symbol':
|
||||||
|
return TYPE.SYMBOL
|
||||||
|
case 'undefined':
|
||||||
|
return TYPE.UNDEFINED
|
||||||
|
case 'object':
|
||||||
|
if (value === null) {
|
||||||
|
return TYPE.NULL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return TYPE.DATE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return TYPE.ARRAY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.constructor.name === 'Object') {
|
||||||
|
return TYPE.RECORD
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'InstanceOf<' + value.constructor.name + '>'
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecordLike (value: unknown): boolean {
|
||||||
|
return typeof value === 'object' && value !== null && ['Array', 'Object'].includes(value.constructor.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isScalar (value: unknown): boolean {
|
||||||
|
switch (typeof value) {
|
||||||
|
case 'bigint':
|
||||||
|
case 'boolean':
|
||||||
|
case 'number':
|
||||||
|
case 'string':
|
||||||
|
case 'symbol':
|
||||||
|
case 'undefined':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return value === null
|
||||||
|
}
|
||||||
|
}
|
122
src/utils/access.ts
Normal file
122
src/utils/access.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import has from './has'
|
||||||
|
import { isRecordLike, isScalar } from '@/types'
|
||||||
|
|
||||||
|
const extractIntOrNaN = (value: string): number => {
|
||||||
|
const numeric = parseInt(value)
|
||||||
|
|
||||||
|
return numeric.toString() === value ? numeric : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractPath = (raw: string): string[] => {
|
||||||
|
const path = [] as string[]
|
||||||
|
|
||||||
|
raw.split('.').forEach(key => {
|
||||||
|
if (/(.*)\[(\d+)]$/.test(key)) {
|
||||||
|
path.push(...key.substr(0, key.length - 1).split('[').filter(k => k.length))
|
||||||
|
} else {
|
||||||
|
path.push(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get (state: unknown, rawOrPath: string|string[], fallback: unknown = undefined): unknown {
|
||||||
|
const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
|
||||||
|
|
||||||
|
if (isScalar(state) || path.length === 0) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = path.shift() as string
|
||||||
|
const index = extractIntOrNaN(key)
|
||||||
|
|
||||||
|
if (!isNaN(index)) {
|
||||||
|
if (Array.isArray(state) && index >= 0 && index < state.length) {
|
||||||
|
return path.length === 0 ? state[index] : get(state[index], path, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has(state as Record<string, unknown>, key)) {
|
||||||
|
const values = state as Record<string, unknown>
|
||||||
|
|
||||||
|
return path.length === 0 ? values[key] : get(values[key], path, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set (state: unknown, rawOrPath: string|string[], value: unknown): unknown {
|
||||||
|
const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = path.shift() as string
|
||||||
|
const index = extractIntOrNaN(key)
|
||||||
|
|
||||||
|
if (!isRecordLike(state)) {
|
||||||
|
return set(!isNaN(index) ? [] : {}, [key, ...path], value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNaN(index) && Array.isArray(state)) {
|
||||||
|
const slice = [...state as unknown[]]
|
||||||
|
|
||||||
|
slice[index] = path.length === 0 ? value : set(slice[index], path, value)
|
||||||
|
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = { ...state as Record<string, unknown> }
|
||||||
|
|
||||||
|
slice[key] = path.length === 0 ? value : set(slice[key], path, value)
|
||||||
|
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsetInRecord = (record: Record<string, unknown>, prop: string): Record<string, unknown> => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { [prop]: _, ...copy } = record
|
||||||
|
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unset (state: unknown, rawOrPath: string|string[]): unknown {
|
||||||
|
if (!isRecordLike(state)) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = path.shift() as string
|
||||||
|
const index = extractIntOrNaN(key)
|
||||||
|
|
||||||
|
if (!isNaN(index) && Array.isArray(state) && index >= 0 && index < state.length) {
|
||||||
|
const slice = [...state as unknown[]]
|
||||||
|
|
||||||
|
if (path.length === 0) {
|
||||||
|
slice.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
slice[index] = unset(slice[index], path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has(state as Record<string, unknown>, key)) {
|
||||||
|
const slice = { ...state as Record<string, unknown> }
|
||||||
|
|
||||||
|
return path.length === 0
|
||||||
|
? unsetInRecord(slice, key)
|
||||||
|
: { ...slice, [key]: unset(slice[key], path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Converts to array.
|
|
||||||
* If given parameter is not string, object ot array, result will be an empty array.
|
|
||||||
* @param {*} item
|
|
||||||
*/
|
|
||||||
export default function arrayify (item: any): any[] {
|
|
||||||
if (!item) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
return [item]
|
|
||||||
}
|
|
||||||
if (Array.isArray(item)) {
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
if (typeof item === 'object') {
|
|
||||||
return Object.values(item)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
@ -1,28 +1,34 @@
|
|||||||
import isScalar from '@/utils/isScalar'
|
import { isRecordLike, isScalar } from '@/types'
|
||||||
import has from '@/utils/has'
|
|
||||||
|
const cloneInstance = <T>(original: T): T => {
|
||||||
|
return Object.assign(Object.create(Object.getPrototypeOf(original)), original)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple (somewhat non-comprehensive) clone function, valid for our use
|
* A simple (somewhat non-comprehensive) clone function, valid for our use
|
||||||
* case of needing to unbind reactive watchers.
|
* case of needing to unbind reactive watchers.
|
||||||
*/
|
*/
|
||||||
export default function clone (value: any): any {
|
export default function clone<T = unknown> (value: T): T {
|
||||||
if (typeof value !== 'object') {
|
if (isScalar(value)) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const copy: any | Record<string, any> = Array.isArray(value) ? [] : {}
|
if (value instanceof Date) {
|
||||||
|
return new Date(value) as unknown as T
|
||||||
for (const key in value) {
|
|
||||||
if (has(value, key)) {
|
|
||||||
if (isScalar(value[key])) {
|
|
||||||
copy[key] = value[key]
|
|
||||||
} else if (value instanceof Date) {
|
|
||||||
copy[key] = new Date(copy[key])
|
|
||||||
} else {
|
|
||||||
copy[key] = clone(value[key])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return copy
|
if (!isRecordLike(value)) {
|
||||||
|
return cloneInstance(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.slice().map(clone) as unknown as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const source: Record<string, unknown> = value as Record<string, unknown>
|
||||||
|
|
||||||
|
return Object.keys(source).reduce((copy, key) => ({
|
||||||
|
...copy,
|
||||||
|
[key]: clone(source[key])
|
||||||
|
}), {}) as unknown as T
|
||||||
}
|
}
|
||||||
|
82
src/utils/compare.ts
Normal file
82
src/utils/compare.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import has from './has'
|
||||||
|
import { typeOf, TYPE } from '@/types'
|
||||||
|
|
||||||
|
export interface EqualPredicate {
|
||||||
|
(a: unknown, b: unknown): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function datesEquals (a: Date, b: Date): boolean {
|
||||||
|
return a.getTime() === b.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arraysEquals (
|
||||||
|
a: unknown[],
|
||||||
|
b: unknown[],
|
||||||
|
predicate: EqualPredicate
|
||||||
|
): boolean {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (!predicate(a[i], b[i])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordsEquals (
|
||||||
|
a: Record<string, unknown>,
|
||||||
|
b: Record<string, unknown>,
|
||||||
|
predicate: EqualPredicate
|
||||||
|
): boolean {
|
||||||
|
if (Object.keys(a).length !== Object.keys(b).length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prop in a as object) {
|
||||||
|
if (!has(b, prop) || !predicate(a[prop], b[prop])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strictEquals (a: unknown, b: unknown): boolean {
|
||||||
|
return a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
export function equals (a: unknown, b: unknown, predicate: EqualPredicate): boolean {
|
||||||
|
const typeOfA = typeOf(a)
|
||||||
|
const typeOfB = typeOf(b)
|
||||||
|
|
||||||
|
return typeOfA === typeOfB && (
|
||||||
|
(typeOfA === TYPE.ARRAY && arraysEquals(
|
||||||
|
a as unknown[],
|
||||||
|
b as unknown[],
|
||||||
|
predicate
|
||||||
|
)) ||
|
||||||
|
(typeOfA === TYPE.DATE && datesEquals(a as Date, b as Date)) ||
|
||||||
|
(typeOfA === TYPE.RECORD && recordsEquals(
|
||||||
|
a as Record<string, unknown>,
|
||||||
|
b as Record<string, unknown>,
|
||||||
|
predicate
|
||||||
|
)) ||
|
||||||
|
(typeOfA.includes('InstanceOf') && equals(
|
||||||
|
Object.entries(a as object),
|
||||||
|
Object.entries(b as object),
|
||||||
|
predicate,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deepEquals (a: unknown, b: unknown): boolean {
|
||||||
|
return a === b || equals(a, b, deepEquals)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shallowEquals (a: unknown, b: unknown): boolean {
|
||||||
|
return a === b || equals(a, b, strictEquals)
|
||||||
|
}
|
10
src/utils/id.ts
Normal file
10
src/utils/id.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const registry: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
export default (prefix: string): string => {
|
||||||
|
const current = registry.get(prefix) || 0
|
||||||
|
const next = current + 1
|
||||||
|
|
||||||
|
registry.set(prefix, next)
|
||||||
|
|
||||||
|
return `${prefix}-${next}`
|
||||||
|
}
|
@ -1,71 +1,8 @@
|
|||||||
export { default as arrayify } from './arrayify'
|
export { default as id } from './id'
|
||||||
export { default as clone } from './clone'
|
export { default as clone } from './clone'
|
||||||
export { default as has } from './has'
|
export { default as has } from './has'
|
||||||
export { default as isScalar } from './isScalar'
|
|
||||||
export { default as merge } from './merge'
|
export { default as merge } from './merge'
|
||||||
|
export { get, set, unset } from './access'
|
||||||
export { default as regexForFormat } from './regexForFormat'
|
export { default as regexForFormat } from './regexForFormat'
|
||||||
export { default as shallowEqualObjects } from './shallowEqualObjects'
|
export { deepEquals, shallowEquals } from './compare'
|
||||||
export { default as snakeToCamel } from './snakeToCamel'
|
export { default as snakeToCamel } from './snakeToCamel'
|
||||||
|
|
||||||
export function getNested (obj: Record<string, any>, field: string): any {
|
|
||||||
const fieldParts = field.split('.')
|
|
||||||
|
|
||||||
let result: Record<string, any> = obj
|
|
||||||
|
|
||||||
for (const key in fieldParts) {
|
|
||||||
const matches = fieldParts[key].match(/(.+)\[(\d+)\]$/)
|
|
||||||
if (result === undefined) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (matches) {
|
|
||||||
result = result[matches[1]]
|
|
||||||
|
|
||||||
if (result === undefined) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
result = result[matches[2]]
|
|
||||||
} else {
|
|
||||||
result = result[fieldParts[key]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setNested (obj: Record<string, any>, field: string, value: any): void {
|
|
||||||
const fieldParts = field.split('.')
|
|
||||||
|
|
||||||
let subProxy: Record<string, any> = obj
|
|
||||||
for (let i = 0; i < fieldParts.length; i++) {
|
|
||||||
const fieldPart = fieldParts[i]
|
|
||||||
const matches = fieldPart.match(/(.+)\[(\d+)\]$/)
|
|
||||||
|
|
||||||
if (subProxy === undefined) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches) {
|
|
||||||
if (subProxy[matches[1]] === undefined) {
|
|
||||||
subProxy[matches[1]] = []
|
|
||||||
}
|
|
||||||
subProxy = subProxy[matches[1]]
|
|
||||||
|
|
||||||
if (i === fieldParts.length - 1) {
|
|
||||||
subProxy[matches[2]] = value
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
subProxy = subProxy[matches[2]]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (i === fieldParts.length - 1) {
|
|
||||||
subProxy[fieldPart] = value
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line max-depth
|
|
||||||
if (subProxy[fieldPart] === undefined) {
|
|
||||||
subProxy[fieldPart] = {}
|
|
||||||
}
|
|
||||||
subProxy = subProxy[fieldPart]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
export default function isScalar (data: any): boolean {
|
|
||||||
switch (typeof data) {
|
|
||||||
case 'symbol':
|
|
||||||
case 'number':
|
|
||||||
case 'string':
|
|
||||||
case 'boolean':
|
|
||||||
case 'undefined':
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return data === null
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
export default function shallowEqualObjects (objA: Record<string, any>, objB: Record<string, any>): boolean {
|
|
||||||
if (objA === objB) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!objA || !objB) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const aKeys = Object.keys(objA)
|
|
||||||
const bKeys = Object.keys(objB)
|
|
||||||
|
|
||||||
if (bKeys.length !== aKeys.length) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (objA instanceof Date && objB instanceof Date) {
|
|
||||||
return objA.getTime() === objB.getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aKeys.length === 0) {
|
|
||||||
return objA === objB
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < aKeys.length; i++) {
|
|
||||||
const key = aKeys[i]
|
|
||||||
|
|
||||||
if (objA[key] !== objB[key]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
import { has } from '@/utils'
|
|
||||||
|
|
||||||
export interface ErrorHandler {
|
|
||||||
(errors: Record<string, any> | any[]): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorObserver {
|
|
||||||
callback: ErrorHandler;
|
|
||||||
type: 'form' | 'input';
|
|
||||||
field?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorObserverPredicate {
|
|
||||||
(value: ErrorObserver, index: number, array: ErrorObserver[]): unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ErrorObserverRegistry {
|
|
||||||
private observers: ErrorObserver[] = []
|
|
||||||
|
|
||||||
constructor (observers: ErrorObserver[] = []) {
|
|
||||||
this.observers = observers
|
|
||||||
}
|
|
||||||
|
|
||||||
public add (observer: ErrorObserver): void {
|
|
||||||
if (!this.observers.some(o => o.callback === observer.callback)) {
|
|
||||||
this.observers.push(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public remove (handler: ErrorHandler): void {
|
|
||||||
this.observers = this.observers.filter(o => o.callback !== handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
public filter (predicate: ErrorObserverPredicate): ErrorObserverRegistry {
|
|
||||||
return new ErrorObserverRegistry(this.observers.filter(predicate))
|
|
||||||
}
|
|
||||||
|
|
||||||
public some (predicate: ErrorObserverPredicate): boolean {
|
|
||||||
return this.observers.some(predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
public observe (errors: Record<string, string[]>|string[]): void {
|
|
||||||
this.observers.forEach(observer => {
|
|
||||||
if (observer.type === 'form') {
|
|
||||||
observer.callback(errors)
|
|
||||||
} else if (
|
|
||||||
observer.field &&
|
|
||||||
!Array.isArray(errors)
|
|
||||||
) {
|
|
||||||
if (has(errors, observer.field)) {
|
|
||||||
observer.callback(errors[observer.field])
|
|
||||||
} else {
|
|
||||||
observer.callback([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
import isUrl from 'is-url'
|
import isUrl from 'is-url'
|
||||||
import { has, regexForFormat, shallowEqualObjects } from '@/utils'
|
import { has, regexForFormat, shallowEquals } from '@/utils'
|
||||||
import {
|
import {
|
||||||
ValidationContext,
|
ValidationContext,
|
||||||
ValidationRuleFn,
|
ValidationRuleFn,
|
||||||
@ -130,7 +130,7 @@ const rules: Record<string, ValidationRuleFn> = {
|
|||||||
* Rule: Value is in an array (stack).
|
* Rule: Value is in an array (stack).
|
||||||
*/
|
*/
|
||||||
in ({ value }: ValidationContext, ...stack: any[]): boolean {
|
in ({ value }: ValidationContext, ...stack: any[]): boolean {
|
||||||
return stack.some(item => typeof item === 'object' ? shallowEqualObjects(item, value) : item === value)
|
return stack.some(item => shallowEquals(item, value))
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -198,7 +198,7 @@ const rules: Record<string, ValidationRuleFn> = {
|
|||||||
* Rule: Value is not in stack.
|
* Rule: Value is not in stack.
|
||||||
*/
|
*/
|
||||||
not ({ value }: ValidationContext, ...stack: any[]): boolean {
|
not ({ value }: ValidationContext, ...stack: any[]): boolean {
|
||||||
return !stack.some(item => typeof item === 'object' ? shallowEqualObjects(item, value) : item === value)
|
return !stack.some(item => shallowEquals(item, value))
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -5,10 +5,10 @@ export interface Validator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Violation {
|
export interface Violation {
|
||||||
|
message: string;
|
||||||
rule: string|null;
|
rule: string|null;
|
||||||
args: any[];
|
args: any[];
|
||||||
context: ValidationContext|null;
|
context: ValidationContext|null;
|
||||||
message: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationRuleFn {
|
export interface ValidationRuleFn {
|
||||||
|
@ -1,26 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<FormularioForm v-model="values">
|
<FormularioForm v-model="values">
|
||||||
<h1>Address list</h1>
|
<h1>Delivery</h1>
|
||||||
|
|
||||||
<FormularioInput
|
<h3>Customer</h3>
|
||||||
|
|
||||||
|
<FormularioFieldGroup
|
||||||
|
name="customer"
|
||||||
|
class="row mx-n2"
|
||||||
|
>
|
||||||
|
<FormularioField
|
||||||
|
v-slot="{ context }"
|
||||||
|
name="name"
|
||||||
|
class="col col-auto px-2 mb-3"
|
||||||
|
>
|
||||||
|
<label for="customer-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="customer-name"
|
||||||
|
v-model="context.model"
|
||||||
|
class="field form-control"
|
||||||
|
type="text"
|
||||||
|
@blur="context.runValidation"
|
||||||
|
>
|
||||||
|
</FormularioField>
|
||||||
|
</FormularioFieldGroup>
|
||||||
|
|
||||||
|
<h3>Address list</h3>
|
||||||
|
|
||||||
|
<FormularioField
|
||||||
v-slot="addressList"
|
v-slot="addressList"
|
||||||
name="addressList"
|
name="addressList"
|
||||||
>
|
>
|
||||||
<FormularioGrouping name="addressList">
|
<FormularioFieldGroup name="addressList">
|
||||||
<FormularioGrouping
|
<FormularioFieldGroup
|
||||||
v-for="(address, addressIndex) in addressList.context.model"
|
v-for="(address, addressIndex) in addressList.context.model"
|
||||||
:key="'address-' + addressIndex"
|
:key="'address-' + addressIndex"
|
||||||
:name="addressIndex"
|
:name="addressIndex"
|
||||||
:is-array-item="true"
|
|
||||||
class="row mx-n2"
|
class="row mx-n2"
|
||||||
>
|
>
|
||||||
<FormularioInput
|
<FormularioField
|
||||||
v-slot="{ context }"
|
v-slot="{ context }"
|
||||||
class="col col-auto px-2 mb-3"
|
class="col col-auto px-2 mb-3"
|
||||||
name="street"
|
name="street"
|
||||||
validation="required"
|
validation="required"
|
||||||
>
|
>
|
||||||
<label for="address-street">Street <span class="text-danger">*</span></label>
|
<label for="address-street">Street</label>
|
||||||
<input
|
<input
|
||||||
id="address-street"
|
id="address-street"
|
||||||
v-model="context.model"
|
v-model="context.model"
|
||||||
@ -36,15 +59,15 @@
|
|||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
|
|
||||||
<FormularioInput
|
<FormularioField
|
||||||
v-slot="{ context }"
|
v-slot="{ context }"
|
||||||
class="col col-auto px-2 mb-3"
|
class="col col-auto px-2 mb-3"
|
||||||
name="building"
|
name="building"
|
||||||
validation="^required|number"
|
validation="^required|alphanumeric"
|
||||||
>
|
>
|
||||||
<label for="address-building">Building <span class="text-danger">*</span></label>
|
<label for="address-building">Building</label>
|
||||||
<input
|
<input
|
||||||
id="address-building"
|
id="address-building"
|
||||||
v-model="context.model"
|
v-model="context.model"
|
||||||
@ -60,9 +83,19 @@
|
|||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
</FormularioGrouping>
|
|
||||||
</FormularioGrouping>
|
<div class="remove-btn-wrapper">
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
type="button"
|
||||||
|
@click="removeAddress(addressIndex)"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormularioFieldGroup>
|
||||||
|
</FormularioFieldGroup>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@ -71,34 +104,38 @@
|
|||||||
>
|
>
|
||||||
Add address
|
Add address
|
||||||
</button>
|
</button>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
</FormularioForm>
|
</FormularioForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import FormularioField from '@/FormularioField'
|
||||||
|
import FormularioFieldGroup from '@/FormularioFieldGroup'
|
||||||
import FormularioForm from '@/FormularioForm'
|
import FormularioForm from '@/FormularioForm'
|
||||||
import FormularioGrouping from '@/FormularioGrouping'
|
|
||||||
import FormularioInput from '@/FormularioInput'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ExampleAddressListTale',
|
name: 'ExampleAddressListTale',
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
|
FormularioField,
|
||||||
|
FormularioFieldGroup,
|
||||||
FormularioForm,
|
FormularioForm,
|
||||||
FormularioGrouping,
|
|
||||||
FormularioInput,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
data: () => ({
|
data: () => ({
|
||||||
values: {
|
values: {
|
||||||
addressList: [],
|
addressList: [{
|
||||||
|
street: 'Baker Street',
|
||||||
|
building: '221b',
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
created () {
|
created () {
|
||||||
this.$formulario.extend({
|
this.$formulario.extend({
|
||||||
validationMessages: {
|
validationMessages: {
|
||||||
number: () => 'Value is not a number',
|
alphanumeric: () => 'Value must be alphanumeric',
|
||||||
|
number: () => 'Value must be a number',
|
||||||
required: () => 'Value is required',
|
required: () => 'Value is required',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -111,6 +148,10 @@ export default {
|
|||||||
building: '',
|
building: '',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeAddress (index) {
|
||||||
|
this.values.addressList.splice(index, 1)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -119,4 +160,8 @@ export default {
|
|||||||
.field {
|
.field {
|
||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remove-btn-wrapper {
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,106 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
v-for="(item, groupIndex) in groups"
|
|
||||||
:key="groupIndex"
|
|
||||||
>
|
|
||||||
<FormularioGrouping :name="`groups[${groupIndex}]`">
|
|
||||||
<FormularioInput
|
|
||||||
v-slot="{ context }"
|
|
||||||
class="mb-3"
|
|
||||||
name="text"
|
|
||||||
validation="number|required"
|
|
||||||
validation-behavior="live"
|
|
||||||
>
|
|
||||||
<label for="text-field">Text field (number|required)</label>
|
|
||||||
<input
|
|
||||||
id="text-field"
|
|
||||||
v-model="context.model"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
style="max-width: 250px;"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(error, index) in context.allErrors"
|
|
||||||
:key="index"
|
|
||||||
class="text-danger"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
</FormularioInput>
|
|
||||||
|
|
||||||
<FormularioInput
|
|
||||||
v-slot="{ context }"
|
|
||||||
:validation-messages="{ in: 'The value was different than expected' }"
|
|
||||||
class="mb-3"
|
|
||||||
name="abcdef-field"
|
|
||||||
validation="in:abcdef"
|
|
||||||
validation-behavior="live"
|
|
||||||
>
|
|
||||||
<label for="abcdef-field">Text field (in:abcdef)</label>
|
|
||||||
<input
|
|
||||||
id="abcdef-field"
|
|
||||||
v-model="context.model"
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
style="max-width: 250px;"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(error, index) in context.allErrors"
|
|
||||||
:key="index"
|
|
||||||
class="text-danger"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioGrouping>
|
|
||||||
|
|
||||||
<button @click="onRemoveGroup(groupIndex)">
|
|
||||||
Remove Group
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button @click="onAddGroup">
|
|
||||||
Add Group
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import FormularioGrouping from '@/FormularioGrouping'
|
|
||||||
import FormularioInput from '@/FormularioInput'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'FormularioGroupingGroupTale',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
FormularioGrouping,
|
|
||||||
FormularioInput,
|
|
||||||
},
|
|
||||||
|
|
||||||
model: {
|
|
||||||
prop: 'groups',
|
|
||||||
event: 'change'
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
groups: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onAddGroup () {
|
|
||||||
this.$emit('change', this.groups.concat([{}]))
|
|
||||||
},
|
|
||||||
onRemoveGroup (removedIndex) {
|
|
||||||
this.$emit('change', this.groups.filter((item, index) => {
|
|
||||||
return index !== removedIndex
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -3,10 +3,9 @@ import './bootstrap.scss'
|
|||||||
import { storiesOf } from '@storybook/vue'
|
import { storiesOf } from '@storybook/vue'
|
||||||
|
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import VueFormulario from '../../dist/formulario.esm'
|
import VueFormulario from '@/index.ts'
|
||||||
|
|
||||||
import ExampleAddressList from './ExampleAddressList.tale'
|
import ExampleAddressList from './ExampleAddressList.tale'
|
||||||
import FormularioGrouping from './FormularioGrouping.tale'
|
|
||||||
|
|
||||||
Vue.mixin({
|
Vue.mixin({
|
||||||
methods: {
|
methods: {
|
||||||
@ -22,4 +21,3 @@ Vue.use(VueFormulario)
|
|||||||
|
|
||||||
storiesOf('Examples', module)
|
storiesOf('Examples', module)
|
||||||
.add('Address list', () => ExampleAddressList)
|
.add('Address list', () => ExampleAddressList)
|
||||||
.add('FormularioGrouping', () => FormularioGrouping)
|
|
||||||
|
@ -3,60 +3,54 @@ import Formulario from '@/Formulario.ts'
|
|||||||
import plugin from '@/index.ts'
|
import plugin from '@/index.ts'
|
||||||
|
|
||||||
describe('Formulario', () => {
|
describe('Formulario', () => {
|
||||||
it('Installs on vue instance', () => {
|
it('installs on vue instance', () => {
|
||||||
const localVue = createLocalVue()
|
const localVue = createLocalVue()
|
||||||
|
|
||||||
localVue.use(plugin)
|
localVue.use(plugin)
|
||||||
|
|
||||||
|
expect(localVue.component('FormularioField')).toBeTruthy()
|
||||||
|
expect(localVue.component('FormularioFieldGroup')).toBeTruthy()
|
||||||
expect(localVue.component('FormularioForm')).toBeTruthy()
|
expect(localVue.component('FormularioForm')).toBeTruthy()
|
||||||
expect(localVue.component('FormularioGrouping')).toBeTruthy()
|
|
||||||
expect(localVue.component('FormularioInput')).toBeTruthy()
|
|
||||||
|
|
||||||
const wrapper = mount({ template: '<div />', }, { localVue })
|
const wrapper = mount({ render: h => h('div'), }, { localVue })
|
||||||
|
|
||||||
expect(wrapper.vm.$formulario).toBeInstanceOf(Formulario)
|
expect(wrapper.vm.$formulario).toBeInstanceOf(Formulario)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('Pushes Formulario instance to child a component', () => {
|
it ('pushes Formulario instance to child a component', () => {
|
||||||
const localVue = createLocalVue()
|
const localVue = createLocalVue()
|
||||||
|
|
||||||
localVue.use(plugin)
|
localVue.use(plugin)
|
||||||
localVue.component('TestComponent', {
|
|
||||||
render (h) {
|
const ChildComponent = localVue.component('ChildComponent', {
|
||||||
return h('div')
|
render: h => h('div'),
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount({
|
const parent = mount({
|
||||||
render (h) {
|
render: h => h('div', [h('ChildComponent')]),
|
||||||
return h('div', [h('TestComponent', { ref: 'test' })])
|
|
||||||
},
|
|
||||||
}, { localVue })
|
}, { localVue })
|
||||||
|
|
||||||
expect(wrapper.vm.$formulario === wrapper.vm.$refs.test.$formulario).toBe(true)
|
const child = parent.findComponent(ChildComponent)
|
||||||
|
|
||||||
|
expect(parent.vm.$formulario === child.vm.$formulario).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('Does not pushes Formulario instance to a child component, if it has its own', () => {
|
it ('does not push Formulario instance to a child component, if it has its own', () => {
|
||||||
const localVue = createLocalVue()
|
const localVue = createLocalVue()
|
||||||
|
|
||||||
localVue.use(plugin)
|
localVue.use(plugin)
|
||||||
// noinspection JSCheckFunctionSignatures
|
// noinspection JSCheckFunctionSignatures
|
||||||
localVue.component('TestComponent', {
|
const ChildComponent = localVue.component('ChildComponent', {
|
||||||
formulario () {
|
formulario: () => new Formulario(),
|
||||||
return new Formulario()
|
render: h => h('div'),
|
||||||
},
|
|
||||||
|
|
||||||
render (h) {
|
|
||||||
return h('div')
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount({
|
const parent = mount({
|
||||||
render (h) {
|
render: h => h('div', [h('ChildComponent')]),
|
||||||
return h('div', [h('TestComponent', { ref: 'test' })])
|
|
||||||
},
|
|
||||||
}, { localVue })
|
}, { localVue })
|
||||||
|
|
||||||
expect(wrapper.vm.$formulario === wrapper.vm.$refs.test.$formulario).toBe(false)
|
const child = parent.findComponent(ChildComponent)
|
||||||
|
|
||||||
|
expect(parent.vm.$formulario === child.vm.$formulario).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
289
test/unit/FormularioField.test.js
Normal file
289
test/unit/FormularioField.test.js
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import flushPromises from 'flush-promises'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import Formulario from '@/index.ts'
|
||||||
|
import FormularioField from '@/FormularioField.vue'
|
||||||
|
import FormularioForm from '@/FormularioForm.vue'
|
||||||
|
|
||||||
|
const globalRule = jest.fn(() => false)
|
||||||
|
|
||||||
|
Vue.use(Formulario, {
|
||||||
|
validationRules: { globalRule },
|
||||||
|
validationMessages: {
|
||||||
|
required: () => 'required',
|
||||||
|
'in': () => 'in',
|
||||||
|
min: () => 'min',
|
||||||
|
globalRule: () => 'globalRule',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FormularioField', () => {
|
||||||
|
const createWrapper = (props = {}) => mount(FormularioField, {
|
||||||
|
propsData: {
|
||||||
|
name: 'field',
|
||||||
|
value: 'initial',
|
||||||
|
validation: 'required|in:abcdef',
|
||||||
|
validationMessages: { in: 'the value was different than expected' },
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
scopedSlots: {
|
||||||
|
default: `
|
||||||
|
<div>
|
||||||
|
<input type="text" v-model="props.context.model">
|
||||||
|
<span v-for="(violation, index) in props.context.violations" :key="index" data-violation>
|
||||||
|
{{ violation.message }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows custom field-rule level validation strings', async () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
validationBehavior: 'live',
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-violation]').text()).toBe(
|
||||||
|
'the value was different than expected'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['demand'],
|
||||||
|
['submit'],
|
||||||
|
])('no validation when validationBehavior is not "live"', async validationBehavior => {
|
||||||
|
const wrapper = createWrapper({ validationBehavior })
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-violation]').exists()).toBe(false)
|
||||||
|
|
||||||
|
wrapper.find('input').element['value'] = 'updated'
|
||||||
|
wrapper.find('input').trigger('change')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('input').element['value']).toBe('updated')
|
||||||
|
expect(wrapper.find('[data-violation]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows custom validation rule message', async () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
value: 'other value',
|
||||||
|
validationMessages: { in: ({ value }) => `the string "${value}" is not correct` },
|
||||||
|
validationBehavior: 'live',
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-violation]').text()).toBe(
|
||||||
|
'the string "other value" is not correct'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['bar', ({ value }) => value === 'foo'],
|
||||||
|
['bar', ({ value }) => Promise.resolve(value === 'foo')],
|
||||||
|
])('uses local custom validation rules', async (value, rule) => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
value,
|
||||||
|
validation: 'required|custom',
|
||||||
|
validationRules: { custom: rule },
|
||||||
|
validationMessages: { custom: 'failed the custom rule check' },
|
||||||
|
validationBehavior: 'live',
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-violation]').text()).toBe('failed the custom rule check')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses global custom validation rules', async () => {
|
||||||
|
mount(FormularioField, {
|
||||||
|
propsData: {
|
||||||
|
name: 'test',
|
||||||
|
value: 'bar',
|
||||||
|
validation: 'required|globalRule',
|
||||||
|
validationBehavior: 'live',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(globalRule.mock.calls.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('emits correct validation event', async () => {
|
||||||
|
const wrapper = mount(FormularioField, {
|
||||||
|
propsData: {
|
||||||
|
name: 'field',
|
||||||
|
value: '',
|
||||||
|
validation: 'required',
|
||||||
|
validationBehavior: 'live',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('validation')).toEqual([[{
|
||||||
|
path: 'field',
|
||||||
|
violations: [{
|
||||||
|
rule: 'required',
|
||||||
|
args: expect.any(Array),
|
||||||
|
context: {
|
||||||
|
name: 'field',
|
||||||
|
value: '',
|
||||||
|
formValues: {},
|
||||||
|
},
|
||||||
|
message: expect.any(String),
|
||||||
|
}],
|
||||||
|
}]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['bail|required|in:xyz', 1],
|
||||||
|
['^required|in:xyz|min:10,length', 1],
|
||||||
|
['required|^in:xyz|min:10,length', 2],
|
||||||
|
['required|in:xyz|bail', 2],
|
||||||
|
])('prevents further validation if not passed a rule with bail modifier', async (
|
||||||
|
validation,
|
||||||
|
expectedViolationsCount
|
||||||
|
) => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
value: '',
|
||||||
|
validation,
|
||||||
|
validationBehavior: 'live',
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-violation]').length).toBe(expectedViolationsCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('proceeds validation if passed a rule with bail modifier', async () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
value: '123',
|
||||||
|
validation: '^required|in:xyz|min:10,length',
|
||||||
|
validationBehavior: 'live',
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-violation]').length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displays errors when validation-behavior is submit and form is submitted', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<FormularioField
|
||||||
|
v-slot="{ context }"
|
||||||
|
name="field"
|
||||||
|
validation="required"
|
||||||
|
validation-behavior="submit"
|
||||||
|
>
|
||||||
|
<span v-for="(violation, index) in context.violations" :key="index" data-violation>
|
||||||
|
{{ violation.message }}
|
||||||
|
</span>
|
||||||
|
</FormularioField>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-violation]').exists()).toBe(false)
|
||||||
|
|
||||||
|
wrapper.trigger('submit')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-violation]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('model getter for input', async () => {
|
||||||
|
const wrapper = mount({
|
||||||
|
data: () => ({ state: { date: 'not a date' } }),
|
||||||
|
template: `
|
||||||
|
<FormularioForm v-model="state">
|
||||||
|
<FormularioField
|
||||||
|
v-slot="{ context }"
|
||||||
|
:model-get-converter="onGet"
|
||||||
|
name="date"
|
||||||
|
>
|
||||||
|
<span data-output>{{ context.model }}</span>
|
||||||
|
</FormularioField>
|
||||||
|
</FormularioForm>
|
||||||
|
`,
|
||||||
|
methods: {
|
||||||
|
onGet (source) {
|
||||||
|
return source instanceof Date ? source.getDate() : 'invalid date'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-output]').text()).toBe('invalid date')
|
||||||
|
|
||||||
|
wrapper.vm.state = { date: new Date('1995-12-17') }
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-output]').text()).toBe('17')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('model setter for input', async () => {
|
||||||
|
const wrapper = mount({
|
||||||
|
data: () => ({ state: { date: 'not a date' } }),
|
||||||
|
template: `
|
||||||
|
<FormularioForm v-model="state">
|
||||||
|
<FormularioField
|
||||||
|
v-slot="{ context }"
|
||||||
|
:model-get-converter="onGet"
|
||||||
|
:model-set-converter="onSet"
|
||||||
|
name="date"
|
||||||
|
>
|
||||||
|
<input type="text" v-model="context.model">
|
||||||
|
</FormularioField>
|
||||||
|
</FormularioForm>
|
||||||
|
`,
|
||||||
|
methods: {
|
||||||
|
onGet (source) {
|
||||||
|
return source instanceof Date ? source.getDate() : source
|
||||||
|
},
|
||||||
|
|
||||||
|
onSet (source) {
|
||||||
|
if (source instanceof Date) {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(source)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Date('2001-05-01')
|
||||||
|
result.setDate(source)
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
wrapper.find('input').setValue('12')
|
||||||
|
wrapper.find('input').trigger('input')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const form = wrapper.findComponent(FormularioForm)
|
||||||
|
|
||||||
|
expect(form.emitted('input')).toEqual([
|
||||||
|
[{ date: new Date('2001-05-12') }],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
98
test/unit/FormularioFieldGroup.test.js
Normal file
98
test/unit/FormularioFieldGroup.test.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import flushPromises from 'flush-promises'
|
||||||
|
|
||||||
|
import Formulario from '@/index.ts'
|
||||||
|
import FormularioFieldGroup from '@/FormularioFieldGroup.vue'
|
||||||
|
import FormularioForm from '@/FormularioForm.vue'
|
||||||
|
|
||||||
|
Vue.use(Formulario)
|
||||||
|
|
||||||
|
describe('FormularioFieldGroup', () => {
|
||||||
|
test('grouped fields to be set', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<FormularioFieldGroup name="group">
|
||||||
|
<FormularioField name="text" v-slot="{ context }">
|
||||||
|
<input type="text" v-model="context.model">
|
||||||
|
</FormularioField>
|
||||||
|
</FormularioFieldGroup>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapper.find('input').setValue('test')
|
||||||
|
wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('submit')).toEqual([
|
||||||
|
[{ group: { text: 'test' } }],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('grouped fields to be got', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
propsData: {
|
||||||
|
state: {
|
||||||
|
group: { text: 'Group text' },
|
||||||
|
text: 'Text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<FormularioFieldGroup name="group">
|
||||||
|
<FormularioField name="text" v-slot="{ context }">
|
||||||
|
<input type="text" v-model="context.model">
|
||||||
|
</FormularioField>
|
||||||
|
</FormularioFieldGroup>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('input').element['value']).toBe('Group text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('data reactive with grouped fields', async () => {
|
||||||
|
const wrapper = mount({
|
||||||
|
data: () => ({ values: {} }),
|
||||||
|
template: `
|
||||||
|
<FormularioForm v-model="values">
|
||||||
|
<FormularioFieldGroup name="group">
|
||||||
|
<FormularioField name="text" v-slot="{ context }">
|
||||||
|
<input type="text" v-model="context.model">
|
||||||
|
<span>{{ values.group.text }}</span>
|
||||||
|
</FormularioField>
|
||||||
|
</FormularioFieldGroup>
|
||||||
|
</FormularioForm>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('span').text()).toBe('')
|
||||||
|
|
||||||
|
wrapper.find('input').setValue('test')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('span').text()).toBe('test')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('errors are set for grouped fields', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
propsData: { fieldsErrors: { 'address.street': ['Test error'] } },
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<FormularioFieldGroup name="address">
|
||||||
|
<FormularioField ref="input" name="street" v-slot="{ context }">
|
||||||
|
<span v-for="error in context.errors">{{ error }}</span>
|
||||||
|
</FormularioField>
|
||||||
|
</FormularioFieldGroup>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.findAll('span').length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
@ -1,6 +1,8 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import flushPromises from 'flush-promises'
|
import flushPromises from 'flush-promises'
|
||||||
|
|
||||||
import Formulario from '@/index.ts'
|
import Formulario from '@/index.ts'
|
||||||
import FormularioForm from '@/FormularioForm.vue'
|
import FormularioForm from '@/FormularioForm.vue'
|
||||||
|
|
||||||
@ -13,442 +15,452 @@ Vue.use(Formulario, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('FormularioForm', () => {
|
describe('FormularioForm', () => {
|
||||||
it('render a form DOM element', () => {
|
test('renders a form DOM element', () => {
|
||||||
const wrapper = mount(FormularioForm)
|
const wrapper = mount(FormularioForm)
|
||||||
expect(wrapper.find('form').exists()).toBe(true)
|
expect(wrapper.find('form').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a default slot', () => {
|
test('accepts a default slot', () => {
|
||||||
const wrapper = mount(FormularioForm, {
|
const wrapper = mount(FormularioForm, {
|
||||||
slots: {
|
slots: {
|
||||||
default: '<div class="default-slot-item" />'
|
default: '<div data-default />'
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
expect(wrapper.find('form div.default-slot-item').exists()).toBe(true)
|
|
||||||
|
expect(wrapper.find('form [data-default]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Intercepts submit event', () => {
|
test('can set a field’s initial value', async () => {
|
||||||
const wrapper = mount(FormularioForm, {
|
const wrapper = mount(FormularioForm, {
|
||||||
slots: {
|
propsData: { state: { test: 'Has initial value' } },
|
||||||
default: '<button type="submit" />'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const spy = jest.spyOn(wrapper.vm, 'onFormSubmit')
|
|
||||||
wrapper.find('form').trigger('submit')
|
|
||||||
expect(spy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Adds subcomponents to the registry', () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: { formularioValue: {} },
|
|
||||||
slots: {
|
slots: {
|
||||||
default: `
|
default: `
|
||||||
<FormularioInput name="sub1" />
|
<FormularioField v-slot="{ context }" validation="required|in:bar" name="test" >
|
||||||
<FormularioInput name="sub2" />
|
|
||||||
`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
expect(wrapper.vm['registry'].keys()).toEqual(['sub1', 'sub2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Removes subcomponents from the registry', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ active: true }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm>
|
|
||||||
<FormularioInput v-if="active" name="sub1" />
|
|
||||||
<FormularioInput name="sub2" />
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub1', 'sub2'])
|
|
||||||
wrapper.setData({ active: false })
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Getting nested fields from registry', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ active: true, nested: { groups: { value: 'value' } }, groups: [{ name: 'group1' }, { name: 'group2' }] }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm>
|
|
||||||
<FormularioInput name="sub1" />
|
|
||||||
<FormularioInput name="sub2" />
|
|
||||||
<FormularioInput name="nested.groups.value" />
|
|
||||||
<FormularioInput name="groups">
|
|
||||||
<FormularioGrouping :name="'groups[' + index + ']'" v-for="(item, index) in groups" :key="index">
|
|
||||||
<FormularioInput name="name" />
|
|
||||||
</FormularioGrouping>
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('sub1').keys())).toEqual(['sub1'])
|
|
||||||
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys()))
|
|
||||||
.toEqual(['groups', 'groups[0].name', 'groups[1].name'])
|
|
||||||
|
|
||||||
wrapper.setData({ active: true, groups: [{ name: 'group1' }] })
|
|
||||||
await flushPromises()
|
|
||||||
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys()))
|
|
||||||
.toEqual(['groups', 'groups[0].name'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can set a field’s initial value', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: { formularioValue: { test: 'Has initial value' } },
|
|
||||||
slots: {
|
|
||||||
default: `
|
|
||||||
<FormularioInput v-slot="{ context }" validation="required|in:bar" name="test" >
|
|
||||||
<input v-model="context.model" type="text">
|
<input v-model="context.model" type="text">
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('input').element['value']).toBe('Has initial value')
|
expect(wrapper.find('input').element['value']).toBe('Has initial value')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Lets individual fields override form initial value', () => {
|
describe('emits input event', () => {
|
||||||
|
test('when individual fields contain a populated value', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
propsData: { state: { field: 'initial' } },
|
||||||
|
slots: {
|
||||||
|
default: '<FormularioField name="field" value="populated" />'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await Vue.nextTick()
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('input')
|
||||||
|
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
expect(emitted[emitted.length - 1]).toEqual([{ field: 'populated' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('when individual fields are edited', () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
propsData: { state: { field: 'initial' } },
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<FormularioField v-slot="{ context }" name="field" >
|
||||||
|
<input v-model="context.model" type="text">
|
||||||
|
</FormularioField>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapper.find('input').setValue('updated')
|
||||||
|
|
||||||
|
const emitted = wrapper.emitted('input')
|
||||||
|
|
||||||
|
expect(emitted).toBeTruthy()
|
||||||
|
expect(emitted[emitted.length - 1]).toEqual([{ field: 'updated' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('updates a field when the form v-model is modified', async () => {
|
||||||
const wrapper = mount(FormularioForm, {
|
const wrapper = mount(FormularioForm, {
|
||||||
propsData: { formularioValue: { test: 'has initial value' } },
|
propsData: { state: { field: 'initial' } },
|
||||||
slots: {
|
slots: {
|
||||||
default: `
|
default: `
|
||||||
<FormularioInput v-slot="{ context }" name="test" value="123">
|
<FormularioField v-slot="{ context }" name="field">
|
||||||
<input v-model="context.model" type="text">
|
<input v-model="context.model" type="text">
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
`
|
`
|
||||||
}
|
|
||||||
})
|
|
||||||
expect(wrapper.find('input').element['value']).toBe('123')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Lets fields set form initial value with value prop', () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ values: {} }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm v-model="values">
|
|
||||||
<FormularioInput name="test" value="123" />
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
expect(wrapper.vm['values']).toEqual({ test: '123' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Receives updates to form model when individual fields are edited', () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ values: { test: '' } }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm v-model="values">
|
|
||||||
<FormularioInput v-slot="{ context }" name="test" >
|
|
||||||
<input v-model="context.model" type="text">
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
wrapper.find('input').setValue('Edited value')
|
|
||||||
expect(wrapper.vm['values']).toEqual({ test: 'Edited value' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Field data updates when it is type of date', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ formValues: { date: new Date(123) } }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm v-model="formValues" ref="form">
|
|
||||||
<FormularioInput v-slot="{ context }" name="date" >
|
|
||||||
<span v-if="context.model">{{ context.model.getTime() }}</span>
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.find('span').text()).toBe('123')
|
|
||||||
|
|
||||||
wrapper.setData({ formValues: { date: new Date(234) } })
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('span').text()).toBe('234')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Updates initial form values when input contains a populated v-model', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({
|
|
||||||
formValues: { test: '' },
|
|
||||||
fieldValue: '123',
|
|
||||||
}),
|
|
||||||
template: `
|
|
||||||
<FormularioForm v-model="formValues">
|
|
||||||
<FormularioInput name="test" v-model="fieldValue" />
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.vm['formValues']).toEqual({ test: '123' })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Replacement test for the above test - not quite as good of a test.
|
|
||||||
it('Updates calls setFieldValue on form when a field contains a populated v-model on registration', () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: {
|
|
||||||
formularioValue: { test: 'Initial' }
|
|
||||||
},
|
|
||||||
slots: {
|
|
||||||
default: '<FormularioInput name="test" value="Overrides" />'
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitted = wrapper.emitted('input')
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
expect(emitted).toBeTruthy()
|
|
||||||
expect(emitted[emitted.length - 1]).toEqual([{ test: 'Overrides' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates an inputs value when the form v-model is modified', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ values: { test: 'abcd' } }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm v-model="values">
|
|
||||||
<FormularioInput v-slot="{ context }" name="test" >
|
|
||||||
<input v-model="context.model" type="text">
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
|
|
||||||
wrapper.vm.values = { test: '1234' }
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
const input = wrapper.find('input[type="text"]')
|
|
||||||
|
|
||||||
expect(input).toBeTruthy()
|
expect(input).toBeTruthy()
|
||||||
expect(input.element['value']).toBe('1234')
|
expect(input.element['value']).toBe('initial')
|
||||||
|
|
||||||
|
wrapper.setProps({ state: { field: 'updated' } })
|
||||||
|
|
||||||
|
await Vue.nextTick()
|
||||||
|
|
||||||
|
expect(input.element['value']).toBe('updated')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Resolves hasValidationErrors to true', async () => {
|
test('updates a field when it is an instance of Date', async () => {
|
||||||
const wrapper = mount(FormularioForm, {
|
const dateA = new Date('1970-01-01')
|
||||||
slots: { default: '<FormularioInput name="fieldName" validation="required" />' }
|
const dateB = new Date()
|
||||||
|
|
||||||
|
const wrapper = mount(FormularioForm,{
|
||||||
|
propsData: { state: { date: dateA } },
|
||||||
|
scopedSlots: {
|
||||||
|
default: `
|
||||||
|
<FormularioField v-slot="{ context }" name="date">
|
||||||
|
<span v-if="context.model">{{ context.model.toISOString() }}</span>
|
||||||
|
</FormularioField>
|
||||||
|
`,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('span').text()).toBe(dateA.toISOString())
|
||||||
|
|
||||||
|
wrapper.setProps({ state: { date: dateB } })
|
||||||
|
|
||||||
|
await Vue.nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('span').text()).toBe(dateB.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves submitted form values to an object', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
slots: {
|
||||||
|
default: '<FormularioField name="name" validation="required" value="Justin" />'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
wrapper.find('form').trigger('submit')
|
wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
const emitted = wrapper.emitted()
|
|
||||||
|
|
||||||
expect(emitted['error']).toBeTruthy()
|
|
||||||
expect(emitted['error'].length).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Resolves submitted form values to an object', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
slots: { default: '<FormularioInput name="fieldName" validation="required" value="Justin" />' }
|
|
||||||
})
|
|
||||||
wrapper.find('form').trigger('submit')
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
const emitted = wrapper.emitted()
|
|
||||||
|
|
||||||
expect(emitted['submit']).toBeTruthy()
|
|
||||||
expect(emitted['submit'].length).toBe(1)
|
|
||||||
expect(emitted['submit'][0]).toEqual([{ fieldName: 'Justin' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Receives a form-errors prop and displays it', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: { formErrors: ['first', 'second'] },
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.vm.mergedFormErrors.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Aggregates form-errors prop with form-named errors', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: { formErrors: ['first', 'second'] }
|
|
||||||
})
|
|
||||||
wrapper.vm.setErrors({ formErrors: ['third'] })
|
|
||||||
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(Object.keys(wrapper.vm.mergedFormErrors).length).toBe(3)
|
expect(wrapper.emitted('submit')).toEqual([
|
||||||
|
[{ name: 'Justin' }],
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('displays field errors on inputs with errors prop', async () => {
|
test('resolves runValidation', async () => {
|
||||||
const wrapper = mount(FormularioForm, {
|
const wrapper = mount(FormularioForm, {
|
||||||
propsData: { errors: { fieldWithErrors: ['This field has an error'] }},
|
|
||||||
slots: {
|
slots: {
|
||||||
default: `
|
default: `
|
||||||
<FormularioInput v-slot="{ context }" name="fieldWithErrors">
|
<div>
|
||||||
<span v-for="error in context.errors">{{ error }}</span>
|
<FormularioField name="address.street" validation="required" />
|
||||||
</FormularioInput>
|
<FormularioField name="address.building" validation="required" />
|
||||||
`
|
</div>
|
||||||
}
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const violations = await wrapper.vm.runValidation()
|
||||||
|
const state = {
|
||||||
|
address: {
|
||||||
|
street: null,
|
||||||
|
building: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(violations).toEqual({
|
||||||
|
'address.street': [{
|
||||||
|
message: expect.any(String),
|
||||||
|
rule: 'required',
|
||||||
|
args: [],
|
||||||
|
context: {
|
||||||
|
name: 'address.street',
|
||||||
|
value: '',
|
||||||
|
formValues: state,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
'address.building': [{
|
||||||
|
message: expect.any(String),
|
||||||
|
rule: 'required',
|
||||||
|
args: [],
|
||||||
|
context: {
|
||||||
|
name: 'address.building',
|
||||||
|
value: '',
|
||||||
|
formValues: state,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves runValidation via $formulario', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
propsData: {
|
||||||
|
id: 'address',
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<div>
|
||||||
|
<FormularioField name="address.street" validation="required" />
|
||||||
|
<FormularioField name="address.building" validation="required" />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const violations = await wrapper.vm.$formulario.runValidation('address')
|
||||||
|
const state = {
|
||||||
|
address: {
|
||||||
|
street: null,
|
||||||
|
building: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(violations).toEqual({
|
||||||
|
'address.street': [{
|
||||||
|
message: expect.any(String),
|
||||||
|
rule: 'required',
|
||||||
|
args: [],
|
||||||
|
context: {
|
||||||
|
name: 'address.street',
|
||||||
|
value: '',
|
||||||
|
formValues: state,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
'address.building': [{
|
||||||
|
message: expect.any(String),
|
||||||
|
rule: 'required',
|
||||||
|
args: [],
|
||||||
|
context: {
|
||||||
|
name: 'address.building',
|
||||||
|
value: '',
|
||||||
|
formValues: state,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves hasValidationErrors to true', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
slots: {
|
||||||
|
default: '<FormularioField name="fieldName" validation="required" />',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('error')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('error').length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('allows setting fields errors', () => {
|
||||||
|
/**
|
||||||
|
* @param props
|
||||||
|
* @return {Wrapper<FormularioForm>}
|
||||||
|
*/
|
||||||
|
const createWrapper = (props = {}) => mount(FormularioForm, {
|
||||||
|
propsData: props,
|
||||||
|
scopedSlots: {
|
||||||
|
default: '<div><div v-for="error in props.errors" data-error /></div>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
test('via prop', async () => {
|
||||||
|
const wrapper = createWrapper({ formErrors: ['first', 'second'] })
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-error]').length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('manually with setErrors()', async () => {
|
||||||
|
const wrapper = createWrapper({ formErrors: ['first', 'second'] })
|
||||||
|
|
||||||
|
wrapper.vm.setErrors({ formErrors: ['third'] })
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-error]').length).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displays field errors on inputs with errors prop', async () => {
|
||||||
|
const wrapper = mount(FormularioForm, {
|
||||||
|
propsData: { fieldsErrors: { field: ['This field has an error'] }},
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<FormularioField v-slot="{ context }" name="field">
|
||||||
|
<span v-for="error in context.errors">{{ error }}</span>
|
||||||
|
</FormularioField>
|
||||||
|
`,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
expect(wrapper.find('span').exists()).toBe(true)
|
expect(wrapper.find('span').exists()).toBe(true)
|
||||||
expect(wrapper.find('span').text()).toEqual('This field has an error')
|
expect(wrapper.find('span').text()).toEqual('This field has an error')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Is able to display multiple errors on multiple elements', async () => {
|
describe('allows setting fields errors', () => {
|
||||||
const errors = { inputA: ['first'], inputB: ['first', 'second']}
|
/**
|
||||||
const wrapper = mount(FormularioForm, { propsData: { errors } })
|
* @param props
|
||||||
|
* @return {Wrapper<FormularioForm>}
|
||||||
await wrapper.vm.$nextTick()
|
*/
|
||||||
|
const createWrapper = (props = {}) => mount(FormularioForm, {
|
||||||
expect(Object.keys(wrapper.vm.mergedFieldErrors).length).toBe(2)
|
propsData: props,
|
||||||
expect(wrapper.vm.mergedFieldErrors.inputA.length).toBe(1)
|
|
||||||
expect(wrapper.vm.mergedFieldErrors.inputB.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can set multiple field errors with setErrors()', async () => {
|
|
||||||
const wrapper = mount(FormularioForm)
|
|
||||||
|
|
||||||
expect(Object.keys(wrapper.vm.mergedFieldErrors).length).toBe(0)
|
|
||||||
|
|
||||||
wrapper.vm.setErrors({
|
|
||||||
inputErrors: {
|
|
||||||
inputA: ['first'],
|
|
||||||
inputB: ['first', 'second'],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(Object.keys(wrapper.vm.mergedFieldErrors).length).toBe(2)
|
|
||||||
expect(wrapper.vm.mergedFieldErrors.inputA.length).toBe(1)
|
|
||||||
expect(wrapper.vm.mergedFieldErrors.inputB.length).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Emits correct validation event when no errors', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
slots: {
|
slots: {
|
||||||
default: `
|
default: `
|
||||||
<FormularioInput v-slot="{ context }" name="foo" validation="required|in:foo">
|
<div>
|
||||||
<input v-model="context.model" type="text" @blur="context.runValidation()">
|
<FormularioField v-slot="{ context }" name="fieldA">
|
||||||
</FormularioInput>
|
<div v-for="error in context.errors" data-error-a>{{ error }}</div>
|
||||||
<FormularioInput name="bar" validation="required" />
|
</FormularioField>
|
||||||
|
|
||||||
|
<FormularioField v-slot="{ context }" name="fieldB">
|
||||||
|
<div v-for="error in context.errors" data-error-b>{{ error }}</div>
|
||||||
|
</FormularioField>
|
||||||
|
</div>
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
wrapper.find('input[type="text"]').setValue('foo')
|
|
||||||
wrapper.find('input[type="text"]').trigger('blur')
|
|
||||||
|
|
||||||
await flushPromises()
|
test('via prop', async () => {
|
||||||
|
const wrapper = createWrapper({
|
||||||
|
fieldsErrors: { fieldA: ['first'], fieldB: ['first', 'second']},
|
||||||
|
})
|
||||||
|
|
||||||
expect(wrapper.emitted('validation')).toBeTruthy()
|
expect(wrapper.findAll('[data-error-a]').length).toBe(1)
|
||||||
expect(wrapper.emitted('validation').length).toBe(1)
|
expect(wrapper.findAll('[data-error-b]').length).toBe(2)
|
||||||
expect(wrapper.emitted('validation')[0][0]).toEqual({
|
})
|
||||||
name: 'foo',
|
|
||||||
violations: [],
|
test('manually with setErrors()', async () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-error-a]').length).toBe(0)
|
||||||
|
expect(wrapper.findAll('[data-error-b]').length).toBe(0)
|
||||||
|
|
||||||
|
wrapper.vm.setErrors({ fieldsErrors: { fieldA: ['first'], fieldB: ['first', 'second'] } })
|
||||||
|
|
||||||
|
await Vue.nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-error-a]').length).toBe(1)
|
||||||
|
expect(wrapper.findAll('[data-error-b]').length).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Emits correct validation event on entry', async () => {
|
describe('emits correct validation event', () => {
|
||||||
|
/**
|
||||||
|
* @return {Wrapper<FormularioForm>}
|
||||||
|
*/
|
||||||
|
const createWrapper = () => mount(FormularioForm, {
|
||||||
|
slots: {
|
||||||
|
default: `
|
||||||
|
<FormularioField v-slot="{ context }" name="foo" validation="required|in:foo">
|
||||||
|
<input v-model="context.model" type="text" @blur="context.runValidation()">
|
||||||
|
</FormularioField>
|
||||||
|
<FormularioField name="bar" validation="required" />
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
test('when no errors', async () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
|
||||||
|
wrapper.find('input[type="text"]').setValue('foo')
|
||||||
|
wrapper.find('input[type="text"]').trigger('blur')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('validation')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('validation')).toEqual([[{
|
||||||
|
path: 'foo',
|
||||||
|
violations: [],
|
||||||
|
}]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('on entry', async () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
|
||||||
|
wrapper.find('input[type="text"]').setValue('bar')
|
||||||
|
wrapper.find('input[type="text"]').trigger('blur')
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('validation')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('validation')).toEqual([[ {
|
||||||
|
path: 'foo',
|
||||||
|
violations: [ {
|
||||||
|
rule: expect.any(String),
|
||||||
|
args: ['foo'],
|
||||||
|
context: {
|
||||||
|
value: 'bar',
|
||||||
|
formValues: expect.any(Object),
|
||||||
|
name: 'foo',
|
||||||
|
},
|
||||||
|
message: expect.any(String),
|
||||||
|
} ],
|
||||||
|
} ]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows resetting form validation', async () => {
|
||||||
const wrapper = mount(FormularioForm, {
|
const wrapper = mount(FormularioForm, {
|
||||||
slots: { default: `
|
slots: {
|
||||||
<FormularioInput
|
default: `
|
||||||
v-slot="{ context }"
|
<div>
|
||||||
name="firstField"
|
<FormularioField v-slot="{ context }" name="username" validation="required">
|
||||||
validation="required|in:foo"
|
<input v-model="context.model" type="text">
|
||||||
>
|
<div v-for="error in context.allErrors" data-username-error />
|
||||||
<input
|
</FormularioField>
|
||||||
v-model="context.model"
|
|
||||||
type="text"
|
|
||||||
@blur="context.runValidation()"
|
|
||||||
>
|
|
||||||
</FormularioInput>
|
|
||||||
<FormularioInput
|
|
||||||
name="secondField"
|
|
||||||
validation="required"
|
|
||||||
/>
|
|
||||||
` }
|
|
||||||
})
|
|
||||||
wrapper.find('input[type="text"]').setValue('bar')
|
|
||||||
wrapper.find('input[type="text"]').trigger('blur')
|
|
||||||
|
|
||||||
await flushPromises()
|
<FormularioField v-slot="{ context }" name="password" validation="required|min:4,length">
|
||||||
|
<input v-model="context.model" type="password" @blur="context.runValidation()">
|
||||||
expect(wrapper.emitted('validation')).toBeTruthy()
|
<div v-for="error in context.allErrors" data-password-error />
|
||||||
expect(wrapper.emitted('validation').length).toBe(1)
|
</FormularioField>
|
||||||
expect(wrapper.emitted('validation')[0][0]).toEqual({
|
</div>
|
||||||
name: 'firstField',
|
`,
|
||||||
violations: [ {
|
},
|
||||||
rule: expect.any(String),
|
|
||||||
args: ['foo'],
|
|
||||||
context: {
|
|
||||||
value: 'bar',
|
|
||||||
formValues: expect.any(Object),
|
|
||||||
name: 'firstField',
|
|
||||||
},
|
|
||||||
message: expect.any(String),
|
|
||||||
} ],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows resetting a form, wiping validation.', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ values: {} }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm
|
|
||||||
v-model="values"
|
|
||||||
name="login"
|
|
||||||
ref="form"
|
|
||||||
>
|
|
||||||
<FormularioInput v-slot="{ context }" name="username" validation="required">
|
|
||||||
<input v-model="context.model" type="text">
|
|
||||||
</FormularioInput>
|
|
||||||
<FormularioInput v-slot="{ context }" name="password" validation="required|min:4,length">
|
|
||||||
<input v-model="context.model" type="password">
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioForm>
|
|
||||||
`,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const password = wrapper.find('input[type="password"]')
|
const password = wrapper.find('input[type="password"]')
|
||||||
|
|
||||||
password.setValue('foo')
|
password.setValue('foo')
|
||||||
|
password.trigger('input')
|
||||||
password.trigger('blur')
|
password.trigger('blur')
|
||||||
|
|
||||||
wrapper.find('form').trigger('submit')
|
|
||||||
wrapper.vm.$refs.form.setErrors({ inputErrors: { username: ['Failed'] } })
|
|
||||||
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
// First make sure we caught the errors
|
wrapper.vm.setErrors({ fieldsErrors: { username: ['required'] } })
|
||||||
expect(Object.keys(wrapper.vm.$refs.form.mergedFieldErrors).length).toBe(1)
|
|
||||||
wrapper.vm.$refs.form.resetValidation()
|
|
||||||
|
|
||||||
await flushPromises()
|
await Vue.nextTick()
|
||||||
|
|
||||||
expect(Object.keys(wrapper.vm.$refs.form.mergedFieldErrors).length).toBe(0)
|
expect(wrapper.findAll('[data-username-error]').length).toBe(1)
|
||||||
|
expect(wrapper.findAll('[data-password-error]').length).toBe(1)
|
||||||
|
|
||||||
|
wrapper.vm.resetValidation()
|
||||||
|
|
||||||
|
await Vue.nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('[data-username-error]').length).toBe(0)
|
||||||
|
expect(wrapper.findAll('[data-password-error]').length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Local errors resetted when errors prop cleared', async () => {
|
test('local errors are reset when errors prop cleared', async () => {
|
||||||
const wrapper = mount({
|
const wrapper = mount(FormularioForm, {
|
||||||
data: () => ({ values: {}, errors: { input: ['failure'] } }),
|
propsData: { fieldsErrors: { input: ['failure'] } },
|
||||||
template: `
|
slots: {
|
||||||
<FormularioForm
|
default: `
|
||||||
v-model="values"
|
<FormularioField v-slot="{ context }" name="input">
|
||||||
:errors="errors"
|
|
||||||
ref="form"
|
|
||||||
>
|
|
||||||
<FormularioInput
|
|
||||||
v-slot="{ context }"
|
|
||||||
name="input"
|
|
||||||
ref="form"
|
|
||||||
>
|
|
||||||
<span v-for="error in context.allErrors">{{ error.message }}</span>
|
<span v-for="error in context.allErrors">{{ error.message }}</span>
|
||||||
</FormularioInput>
|
</FormularioField>
|
||||||
</FormularioForm>
|
`,
|
||||||
`
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').exists()).toBe(true)
|
expect(wrapper.find('span').exists()).toBe(true)
|
||||||
|
|
||||||
wrapper.vm.errors = {}
|
wrapper.setProps({ fieldsErrors: {} })
|
||||||
await flushPromises()
|
|
||||||
|
await Vue.nextTick()
|
||||||
|
|
||||||
expect(wrapper.find('span').exists()).toBe(false)
|
expect(wrapper.find('span').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,97 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import flushPromises from 'flush-promises'
|
|
||||||
import Formulario from '@/index.ts'
|
|
||||||
import FormularioForm from '@/FormularioForm.vue'
|
|
||||||
import FormularioGrouping from '@/FormularioGrouping.vue'
|
|
||||||
|
|
||||||
Vue.use(Formulario)
|
|
||||||
|
|
||||||
describe('FormularioGrouping', () => {
|
|
||||||
it('Grouped fields to be set', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
slots: {
|
|
||||||
default: `
|
|
||||||
<FormularioGrouping name="group">
|
|
||||||
<FormularioInput name="text" v-slot="{ context }">
|
|
||||||
<input type="text" v-model="context.model">
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioGrouping>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.findAll('input[type="text"]').length).toBe(1)
|
|
||||||
|
|
||||||
wrapper.find('input[type="text"]').setValue('test')
|
|
||||||
wrapper.find('form').trigger('submit')
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
const emitted = wrapper.emitted()
|
|
||||||
|
|
||||||
expect(emitted['submit']).toBeTruthy()
|
|
||||||
expect(emitted['submit'].length).toBe(1)
|
|
||||||
expect(emitted['submit'][0]).toEqual([{ group: { text: 'test' } }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Grouped fields to be got', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: {
|
|
||||||
formularioValue: {
|
|
||||||
group: { text: 'Group text' },
|
|
||||||
text: 'Text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
slots: {
|
|
||||||
default: `
|
|
||||||
<FormularioGrouping name="group">
|
|
||||||
<FormularioInput name="text" v-slot="{ context }">
|
|
||||||
<input type="text" v-model="context.model">
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioGrouping>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
expect(wrapper.find('input[type="text"]').element['value']).toBe('Group text')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Data reactive with grouped fields', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ values: {} }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm name="form" v-model="values">
|
|
||||||
<FormularioGrouping name="group">
|
|
||||||
<FormularioInput name="text" v-slot="{ context }">
|
|
||||||
<input type="text" v-model="context.model">
|
|
||||||
<span>{{ values.group.text }}</span>
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioGrouping>
|
|
||||||
</FormularioForm>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
expect(wrapper.find('span').text()).toBe('')
|
|
||||||
wrapper.find('input[type="text"]').setValue('test')
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').text()).toBe('test')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Errors are set for grouped fields', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: {
|
|
||||||
formularioValue: {},
|
|
||||||
errors: { 'group.text': 'Test error' },
|
|
||||||
},
|
|
||||||
slots: {
|
|
||||||
default: `
|
|
||||||
<FormularioGrouping name="group">
|
|
||||||
<FormularioInput ref="input" name="text" v-slot="{ context }">
|
|
||||||
<span v-for="error in context.errors">{{ error }}</span>
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioGrouping>
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(wrapper.findAll('span').length).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,350 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import flushPromises from 'flush-promises'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
|
|
||||||
import Formulario from '@/index.ts'
|
|
||||||
import FormularioForm from '@/FormularioForm.vue'
|
|
||||||
import FormularioInput from '@/FormularioInput.vue'
|
|
||||||
|
|
||||||
const globalRule = jest.fn(() => { return false })
|
|
||||||
|
|
||||||
Vue.use(Formulario, {
|
|
||||||
validationRules: { globalRule },
|
|
||||||
validationMessages: {
|
|
||||||
required: () => 'required',
|
|
||||||
'in': () => 'in',
|
|
||||||
min: () => 'min',
|
|
||||||
globalRule: () => 'globalRule',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('FormularioInput', () => {
|
|
||||||
it('Allows custom field-rule level validation strings', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
value: 'other value',
|
|
||||||
validation: 'required|in:abcdef',
|
|
||||||
validationMessages: { in: 'the value was different than expected' },
|
|
||||||
validationBehavior: 'live',
|
|
||||||
},
|
|
||||||
scopedSlots: {
|
|
||||||
default: `<div><span v-for="violation in props.context.violations">{{ violation.message }}</span></div>`
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').text()).toBe('the value was different than expected')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('No validation on created when validationBehavior is not live', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'required|in:abcdef',
|
|
||||||
validationMessages: {in: 'the value was different than expected'},
|
|
||||||
value: 'other value'
|
|
||||||
},
|
|
||||||
scopedSlots: {
|
|
||||||
default: `<div><span v-for="error in props.context.violations">{{ error.message }}</span></div>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('No validation on value change when validationBehavior is "submit"', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'required|in:abcdef',
|
|
||||||
validationMessages: {in: 'the value was different than expected'},
|
|
||||||
validationBehavior: 'submit',
|
|
||||||
value: 'Initial'
|
|
||||||
},
|
|
||||||
scopedSlots: {
|
|
||||||
default: `<div>
|
|
||||||
<input type="text" v-model="props.context.model">
|
|
||||||
<span v-for="error in props.context.violations">{{ error.message }}</span>
|
|
||||||
</div>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('span').exists()).toBe(false)
|
|
||||||
|
|
||||||
wrapper.find('input[type="text"]').element['value'] = 'Test'
|
|
||||||
wrapper.find('input[type="text"]').trigger('change')
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('input[type="text"]').element['value']).toBe('Test')
|
|
||||||
expect(wrapper.find('span').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows custom field-rule level validation functions', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'required|in:abcdef',
|
|
||||||
validationMessages: { in: ({ value }) => `The string ${value} is not correct.` },
|
|
||||||
validationBehavior: 'live',
|
|
||||||
value: 'other value'
|
|
||||||
},
|
|
||||||
scopedSlots: {
|
|
||||||
default: `<div><span v-for="error in props.context.violations">{{ error.message }}</span></div>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').text()).toBe('The string other value is not correct.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('No validation on created when validationBehavior is default', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'required|in:abcdef',
|
|
||||||
validationMessages: { in: 'the value was different than expected' },
|
|
||||||
value: 'other value'
|
|
||||||
},
|
|
||||||
scopedSlots: {
|
|
||||||
default: `<div><span v-for="error in props.context.violations">{{ error.message }}</span></div>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').exists()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Uses custom async validation rules on defined on the field', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'required|foobar',
|
|
||||||
validationRules: { foobar: async ({ value }) => value === 'foo' },
|
|
||||||
validationMessages: { foobar: 'failed the foobar check' },
|
|
||||||
validationBehavior: 'live',
|
|
||||||
value: 'bar'
|
|
||||||
},
|
|
||||||
scopedSlots: {
|
|
||||||
default: `<div><span v-for="error in props.context.violations">{{ error.message }}</span></div>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').text()).toBe('failed the foobar check')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Uses custom sync validation rules on defined on the field', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
value: 'bar',
|
|
||||||
validation: 'required|foobar',
|
|
||||||
validationRules: { foobar: ({ value }) => value === 'foo' },
|
|
||||||
validationMessages: { foobar: 'failed the foobar check' },
|
|
||||||
validationBehavior: 'live',
|
|
||||||
},
|
|
||||||
scopedSlots: {
|
|
||||||
default: `<div><span v-for="error in props.context.violations">{{ error.message }}</span></div>`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').text()).toBe('failed the foobar check')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Uses global custom validation rules', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
value: 'bar',
|
|
||||||
validation: 'required|globalRule',
|
|
||||||
validationBehavior: 'live',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
expect(globalRule.mock.calls.length).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Emits correct validation event', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'fieldName',
|
|
||||||
value: '',
|
|
||||||
validation: 'required',
|
|
||||||
validationBehavior: 'live',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.emitted('validation')).toBeTruthy()
|
|
||||||
expect(wrapper.emitted('validation')[0][0]).toEqual({
|
|
||||||
name: 'fieldName',
|
|
||||||
violations: [{
|
|
||||||
rule: expect.stringContaining('required'),
|
|
||||||
args: expect.any(Array),
|
|
||||||
context: expect.any(Object),
|
|
||||||
message: expect.any(String),
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can bail on validation when encountering the bail rule', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'bail|required|in:xyz',
|
|
||||||
validationBehavior: 'live',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.vm.context.violations.length).toBe(1);
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can show multiple validation errors if they occur before the bail rule', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'required|in:xyz|bail',
|
|
||||||
validationBehavior: 'live',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.vm.context.violations.length).toBe(2);
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Can avoid bail behavior by using modifier', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
value: '123',
|
|
||||||
validation: '^required|in:xyz|min:10,length',
|
|
||||||
validationBehavior: 'live',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.vm.context.violations.length).toBe(2);
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Prevents later error messages when modified rule fails', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: '^required|in:xyz|min:10,length',
|
|
||||||
validationBehavior: 'live',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.vm.context.violations.length).toBe(1);
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can bail in the middle of the rule set with a modifier', async () => {
|
|
||||||
const wrapper = mount(FormularioInput, {
|
|
||||||
propsData: {
|
|
||||||
name: 'test',
|
|
||||||
validation: 'required|^in:xyz|min:10,length',
|
|
||||||
validationBehavior: 'live',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.vm.context.violations.length).toBe(2);
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Displays errors when validation-behavior is submit and form is submitted', async () => {
|
|
||||||
const wrapper = mount(FormularioForm, {
|
|
||||||
propsData: { name: 'test' },
|
|
||||||
slots: {
|
|
||||||
default: `
|
|
||||||
<FormularioInput
|
|
||||||
v-slot="{ context }"
|
|
||||||
name="testinput"
|
|
||||||
validation="required"
|
|
||||||
validation-behavior="submit"
|
|
||||||
>
|
|
||||||
<span v-for="error in context.violations">{{ error.message }}</span>
|
|
||||||
</FormularioInput>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('span').exists()).toBe(false)
|
|
||||||
|
|
||||||
wrapper.trigger('submit')
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('span').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Model getter for input', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ values: { test: 'abcd' } }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm v-model="values">
|
|
||||||
<FormularioInput v-slot="{ context }" :model-get-converter="onGet" name="test" >
|
|
||||||
<span>{{ context.model }}</span>
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioForm>
|
|
||||||
`,
|
|
||||||
methods: {
|
|
||||||
onGet(source) {
|
|
||||||
if (!(source instanceof Date)) {
|
|
||||||
return 'invalid date'
|
|
||||||
}
|
|
||||||
|
|
||||||
return source.getDate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').text()).toBe('invalid date')
|
|
||||||
|
|
||||||
wrapper.vm.values = { test: new Date('1995-12-17') }
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.find('span').text()).toBe('17')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Model setter for input', async () => {
|
|
||||||
const wrapper = mount({
|
|
||||||
data: () => ({ values: { test: 'abcd' } }),
|
|
||||||
template: `
|
|
||||||
<FormularioForm v-model="values">
|
|
||||||
<FormularioInput v-slot="{ context }" :model-get-converter="onGet" :model-set-converter="onSet" name="test" >
|
|
||||||
<input type="text" v-model="context.model">
|
|
||||||
</FormularioInput>
|
|
||||||
</FormularioForm>
|
|
||||||
`,
|
|
||||||
methods: {
|
|
||||||
onGet(source) {
|
|
||||||
if (!(source instanceof Date)) {
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
return source.getDate()
|
|
||||||
},
|
|
||||||
onSet(source) {
|
|
||||||
if (source instanceof Date) {
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
if (isNaN(source)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = new Date('2001-05-01')
|
|
||||||
result.setDate(source)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.vm.values.test).toBe(undefined)
|
|
||||||
|
|
||||||
wrapper.find('input[type="text"]').element['value'] = '12'
|
|
||||||
wrapper.find('input[type="text"]').trigger('input')
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.vm.values.test.toISOString()).toBe((new Date('2001-05-12')).toISOString())
|
|
||||||
})
|
|
||||||
})
|
|
49
test/unit/types.test.js
Normal file
49
test/unit/types.test.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
isRecordLike,
|
||||||
|
isScalar,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
describe('types', () => {
|
||||||
|
const scalars = [
|
||||||
|
['booleans', false],
|
||||||
|
['numbers', 123],
|
||||||
|
['strings', 'hello'],
|
||||||
|
['symbols', Symbol(123)],
|
||||||
|
['undefined', undefined],
|
||||||
|
['null', null],
|
||||||
|
]
|
||||||
|
|
||||||
|
const records = [
|
||||||
|
[{}],
|
||||||
|
[{ a: 'a', b: ['b'] }],
|
||||||
|
[[]],
|
||||||
|
[['b', 'c']],
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('isRecordLike', () => {
|
||||||
|
test.each(records)('passes on records', record => {
|
||||||
|
expect(isRecordLike(record)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each(scalars)('fails on $type', (type, scalar) => {
|
||||||
|
expect(isRecordLike(scalar)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['class instance', new class {} ()],
|
||||||
|
['builtin Date instance', new Date()],
|
||||||
|
])('fails on $type', (type, instance) => {
|
||||||
|
expect(isRecordLike(instance)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isScalar', () => {
|
||||||
|
test.each(scalars)('passes on $type', (type, scalar) => {
|
||||||
|
expect(isScalar(scalar)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each(records)('fails on records & arrays', record => {
|
||||||
|
expect(isScalar(record)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
99
test/unit/utils/access.test.js
Normal file
99
test/unit/utils/access.test.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { get, set, unset } from '@/utils/access'
|
||||||
|
|
||||||
|
class Sample {
|
||||||
|
constructor() {
|
||||||
|
this.fieldA = 'fieldA'
|
||||||
|
this.fieldB = 'fieldB'
|
||||||
|
}
|
||||||
|
|
||||||
|
doSomething () {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('access', () => {
|
||||||
|
describe('get', () => {
|
||||||
|
test.each([
|
||||||
|
[{ a: { b: { c: 1 } } }, 'a', { b: { c: 1 } }],
|
||||||
|
[{ a: { b: { c: 1 } }, d: 1 }, 'a', { b: { c: 1 } }],
|
||||||
|
[{ a: { b: { c: 1 } } }, 'a.b.c', 1],
|
||||||
|
[{ a: { b: [1] } }, 'a.b[0]', 1],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[0]', 1],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[1]', 2],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[2]', 3],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[3]', undefined],
|
||||||
|
[{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[0].c', 1],
|
||||||
|
[{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[1].c', undefined],
|
||||||
|
[[{ c: 1 }, 2, 3], '[0].c', 1],
|
||||||
|
[[{ c: 2 }, 2, 3], '[0].c', 2],
|
||||||
|
[new Sample(), 'fieldA', 'fieldA'],
|
||||||
|
])('gets by path', (state, path, expected) => {
|
||||||
|
expect(get(state, path)).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('set', () => {
|
||||||
|
test.each([
|
||||||
|
[{}, 'a', 1, { a: 1 }],
|
||||||
|
[null, 'a', 1, { a: 1 }],
|
||||||
|
['', 'a', 1, { a: 1 }],
|
||||||
|
['lorem', 'a', 1, { a: 1 }],
|
||||||
|
[true, 'a', 1, { a: 1 }],
|
||||||
|
[{}, 'a.b', 1, { a: { b: 1 } }],
|
||||||
|
[{ a: { b: null } }, 'a.b', 1, { a: { b: 1 } }],
|
||||||
|
[{ a: false }, 'a.b', 1, { a: { b: 1 } }],
|
||||||
|
[{}, 'a[0]', 1, { a: [1] }],
|
||||||
|
[{ a: false }, 'a[0]', 1, { a: [1] }],
|
||||||
|
[{}, 'a[0].b', 1, { a: [{ b: 1 }] }],
|
||||||
|
[{ a: false }, 'a[0].b', 1, { a: [{ b: 1 }] }],
|
||||||
|
[{}, 'a[0].b.c', 1, { a: [{ b: { c: 1 } }] }],
|
||||||
|
[{}, 'a[0].b[0].c', 1, { a: [{ b: [{ c: 1 }] }] }],
|
||||||
|
[{ a: false }, 'a[0].b[0].c', 1, { a: [{ b: [{ c: 1 }] }] }],
|
||||||
|
[{ a: [{ b: false }] }, 'a[0].b[0].c', 1, { a: [{ b: [{ c: 1 }] }] }],
|
||||||
|
[{ a: { b: false } }, 'a[0].b[0].c', 1, { a: { 0: { b: [{ c: 1 }] }, b: false } }],
|
||||||
|
])('sets by path', (state, path, value, expected) => {
|
||||||
|
const processed = set(state, path, value)
|
||||||
|
|
||||||
|
expect(processed).toEqual(expected)
|
||||||
|
expect(processed === state).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unset', () => {
|
||||||
|
test.each([
|
||||||
|
[{ a: { b: { c: 1 } } }, 'a', {}],
|
||||||
|
[{ a: { b: { c: 1 } }, d: 1 }, 'a', { d: 1 }],
|
||||||
|
[{ a: { b: { c: 1 } } }, 'a.b.c', { a: { b: {} } }],
|
||||||
|
[{ a: { b: [1] } }, 'a.b[0]', { a: { b: [] } }],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[0]', { a: { b: [2, 3] } }],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[1]', { a: { b: [1, 3] } }],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[2]', { a: { b: [1, 2] } }],
|
||||||
|
[{ a: { b: [1, 2, 3] } }, 'a.b[3]', { a: { b: [1, 2, 3] } }],
|
||||||
|
[{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[0].c', { a: { b: [{}, 2, 3] } }],
|
||||||
|
[{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[1].c', { a: { b: [{ c: 1 }, 2, 3] } }],
|
||||||
|
[[{ c: 1 }, 2, 3], '[0].c', [{}, 2, 3]],
|
||||||
|
])('unsets by path', (state, path, expected) => {
|
||||||
|
const processed = unset(state, path)
|
||||||
|
|
||||||
|
expect(processed).toEqual(expected)
|
||||||
|
expect(processed === state).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each`
|
||||||
|
type | scalar
|
||||||
|
${'booleans'} | ${false}
|
||||||
|
${'numbers'} | ${123}
|
||||||
|
${'strings'} | ${'hello'}
|
||||||
|
${'symbols'} | ${Symbol(123)}
|
||||||
|
${'undefined'} | ${undefined}
|
||||||
|
${'null'} | ${null}
|
||||||
|
`('not unsets for $type', ({ scalar }) => {
|
||||||
|
expect(unset(scalar, 'key')).toStrictEqual(scalar)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('not unsets for class instance', () => {
|
||||||
|
const sample = new Sample()
|
||||||
|
const processed = unset(sample, 'fieldA')
|
||||||
|
|
||||||
|
expect(processed.fieldA).toStrictEqual('fieldA')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -1,28 +1,79 @@
|
|||||||
import clone from '@/utils/clone'
|
import clone from '@/utils/clone'
|
||||||
|
|
||||||
|
class Sample {
|
||||||
|
constructor() {
|
||||||
|
this.fieldA = 'fieldA'
|
||||||
|
this.fieldB = 'fieldB'
|
||||||
|
}
|
||||||
|
|
||||||
|
doSomething () {}
|
||||||
|
}
|
||||||
|
|
||||||
describe('clone', () => {
|
describe('clone', () => {
|
||||||
it('Basic objects stay the same', () => {
|
test.each([
|
||||||
const obj = { a: 123, b: 'hello' }
|
[{ a: 123, b: 'hello' }],
|
||||||
expect(clone(obj)).toEqual(obj)
|
[{ a: 123, b: { c: 'hello-world' } }],
|
||||||
|
[{
|
||||||
|
id: 123,
|
||||||
|
addresses: [{
|
||||||
|
street: 'Baker Street',
|
||||||
|
building: '221b',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
])('recreates object, preserving its structure', state => {
|
||||||
|
expect(clone(state)).toEqual(state)
|
||||||
|
expect(clone({ ref: state }).ref === state).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Basic nested objects stay the same', () => {
|
test('retains array structures inside of a pojo', () => {
|
||||||
const obj = { a: 123, b: { c: 'hello-world' } }
|
const obj = { a: 'abc', d: ['first', 'second'] }
|
||||||
expect(clone(obj)).toEqual(obj)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Simple pojo reference types are re-created', () => {
|
|
||||||
const c = { c: 'hello-world' }
|
|
||||||
expect(clone({ a: 123, b: c }).b === c).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Retains array structures inside of a pojo', () => {
|
|
||||||
const obj = { a: 'abcd', d: ['first', 'second'] }
|
|
||||||
expect(Array.isArray(clone(obj).d)).toBe(true)
|
expect(Array.isArray(clone(obj).d)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Removes references inside array structures', () => {
|
test('removes references inside array structures', () => {
|
||||||
const obj = { a: 'abcd', d: ['first', { foo: 'bar' }] }
|
const obj = { a: 'abc', d: ['first', { foo: 'bar' }] }
|
||||||
expect(clone(obj).d[1] === obj.d[1]).toBe(false)
|
expect(clone(obj).d[1] === obj.d[1]).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('creates a copy of a date', () => {
|
||||||
|
const date = new Date()
|
||||||
|
const copy = clone(date)
|
||||||
|
|
||||||
|
expect(date === copy).toBeFalsy()
|
||||||
|
expect(copy.toISOString()).toStrictEqual(date.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a copy of a nested date', () => {
|
||||||
|
const date = new Date()
|
||||||
|
const copy = clone({ date })
|
||||||
|
|
||||||
|
expect(date === copy.date).toBeFalsy()
|
||||||
|
expect(copy.date.toISOString()).toStrictEqual(date.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a copy of a class instance', () => {
|
||||||
|
const sample = new Sample()
|
||||||
|
const copy = clone(sample)
|
||||||
|
|
||||||
|
expect(sample === copy).toBeFalsy()
|
||||||
|
|
||||||
|
expect(copy).toBeInstanceOf(Sample)
|
||||||
|
expect(copy.fieldA).toEqual('fieldA')
|
||||||
|
expect(copy.fieldB).toEqual('fieldB')
|
||||||
|
expect(copy.doSomething).toBeTruthy()
|
||||||
|
expect(copy.doSomething).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates a copy of a nested class instance', () => {
|
||||||
|
const sample = new Sample()
|
||||||
|
const copy = clone({ sample })
|
||||||
|
|
||||||
|
expect(sample === copy.sample).toBeFalsy()
|
||||||
|
|
||||||
|
expect(copy.sample).toBeInstanceOf(Sample)
|
||||||
|
expect(copy.sample.fieldA).toEqual('fieldA')
|
||||||
|
expect(copy.sample.fieldB).toEqual('fieldB')
|
||||||
|
expect(copy.sample.doSomething).toBeTruthy()
|
||||||
|
expect(copy.sample.doSomething).not.toThrow()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
125
test/unit/utils/compare.test.js
Normal file
125
test/unit/utils/compare.test.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { deepEquals, shallowEquals } from '@/utils/compare'
|
||||||
|
|
||||||
|
class Sample {
|
||||||
|
constructor() {
|
||||||
|
this.fieldA = 'fieldA'
|
||||||
|
this.fieldB = 'fieldB'
|
||||||
|
}
|
||||||
|
|
||||||
|
doSomething () {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compare', () => {
|
||||||
|
describe('deepEquals', () => {
|
||||||
|
test.each`
|
||||||
|
type | a
|
||||||
|
${'booleans'} | ${false}
|
||||||
|
${'numbers'} | ${123}
|
||||||
|
${'strings'} | ${'hello'}
|
||||||
|
${'symbols'} | ${Symbol(123)}
|
||||||
|
${'undefined'} | ${undefined}
|
||||||
|
${'null'} | ${null}
|
||||||
|
${'array'} | ${[1, 2, 3]}
|
||||||
|
${'pojo'} | ${{ a: 1, b: 2 }}
|
||||||
|
${'empty array'} | ${[]}
|
||||||
|
${'empty pojo'} | ${{}}
|
||||||
|
${'date'} | ${new Date()}
|
||||||
|
`('A=A check on $type', ({ a }) => {
|
||||||
|
expect(deepEquals(a, a)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each`
|
||||||
|
a | b | expected
|
||||||
|
${[]} | ${[]} | ${true}
|
||||||
|
${[1, 2, 3]} | ${[1, 2, 3]} | ${true}
|
||||||
|
${[1, 2, { a: 1 }]} | ${[1, 2, { a: 1 }]} | ${true}
|
||||||
|
${[1, 2, { a: 1 }]} | ${[1, 2, { a: 2 }]} | ${false}
|
||||||
|
${[1, 2, 3]} | ${[1, 2, 4]} | ${false}
|
||||||
|
${[1, 2, 3]} | ${[1, 2]} | ${false}
|
||||||
|
${[]} | ${[1, 2]} | ${false}
|
||||||
|
${{}} | ${{}} | ${true}
|
||||||
|
${{ a: 1, b: 2 }} | ${{ a: 1, b: 2 }} | ${true}
|
||||||
|
${{ a: 1, b: 2 }} | ${{ a: 1 }} | ${false}
|
||||||
|
${{ a: {} }} | ${{ a: {} }} | ${true}
|
||||||
|
${{ a: { b: 1 } }} | ${{ a: { b: 1 } }} | ${true}
|
||||||
|
${{ a: { b: 1 } }} | ${{ a: { b: 2 } }} | ${false}
|
||||||
|
${new Date()} | ${new Date()} | ${true}
|
||||||
|
`('A=B & B=A check: A=$a, B=$b, expected: $expected', ({ a, b, expected }) => {
|
||||||
|
expect(deepEquals(a, b)).toBe(expected)
|
||||||
|
expect(deepEquals(b, a)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A=B & B=A check for instances', () => {
|
||||||
|
const a = new Sample()
|
||||||
|
const b = new Sample()
|
||||||
|
|
||||||
|
expect(deepEquals(a, b)).toBe(true)
|
||||||
|
expect(deepEquals(b, a)).toBe(true)
|
||||||
|
|
||||||
|
b.fieldA += '~'
|
||||||
|
|
||||||
|
expect(deepEquals(a, b)).toBe(false)
|
||||||
|
expect(deepEquals(b, a)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A=B & B=A check for instances with nesting', () => {
|
||||||
|
const a = new Sample()
|
||||||
|
const b = new Sample()
|
||||||
|
|
||||||
|
a.fieldA = new Sample()
|
||||||
|
b.fieldA = new Sample()
|
||||||
|
|
||||||
|
expect(deepEquals(a, b)).toBe(true)
|
||||||
|
expect(deepEquals(b, a)).toBe(true)
|
||||||
|
|
||||||
|
b.fieldA.fieldA += '~'
|
||||||
|
|
||||||
|
expect(deepEquals(a, b)).toBe(false)
|
||||||
|
expect(deepEquals(b, a)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shallowEquals', () => {
|
||||||
|
test.each`
|
||||||
|
type | a
|
||||||
|
${'booleans'} | ${false}
|
||||||
|
${'numbers'} | ${123}
|
||||||
|
${'strings'} | ${'hello'}
|
||||||
|
${'symbols'} | ${Symbol(123)}
|
||||||
|
${'undefined'} | ${undefined}
|
||||||
|
${'null'} | ${null}
|
||||||
|
${'array'} | ${[1, 2, 3]}
|
||||||
|
${'pojo'} | ${{ a: 1, b: 2 }}
|
||||||
|
${'empty array'} | ${[]}
|
||||||
|
${'empty pojo'} | ${{}}
|
||||||
|
${'date'} | ${new Date()}
|
||||||
|
`('A=A check on $type', ({ a }) => {
|
||||||
|
expect(shallowEquals(a, a)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each`
|
||||||
|
a | b | expected
|
||||||
|
${[]} | ${[]} | ${true}
|
||||||
|
${[1, 2, 3]} | ${[1, 2, 3]} | ${true}
|
||||||
|
${[1, 2, 3]} | ${[1, 2, 4]} | ${false}
|
||||||
|
${[1, 2, 3]} | ${[1, 2]} | ${false}
|
||||||
|
${[]} | ${[1, 2]} | ${false}
|
||||||
|
${{}} | ${{}} | ${true}
|
||||||
|
${{ a: 1, b: 2 }} | ${{ a: 1, b: 2 }} | ${true}
|
||||||
|
${{ a: 1, b: 2 }} | ${{ a: 1 }} | ${false}
|
||||||
|
${{ a: {} }} | ${{ a: {} }} | ${false}
|
||||||
|
${new Date()} | ${new Date()} | ${true}
|
||||||
|
`('A=B & B=A check: A=$a, B=$b, expected: $expected', ({ a, b, expected }) => {
|
||||||
|
expect(shallowEquals(a, b)).toBe(expected)
|
||||||
|
expect(shallowEquals(b, a)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A=B & B=A check for instances', () => {
|
||||||
|
const a = new Sample()
|
||||||
|
const b = new Sample()
|
||||||
|
|
||||||
|
expect(shallowEquals(a, b)).toBe(false)
|
||||||
|
expect(shallowEquals(b, a)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -1,17 +0,0 @@
|
|||||||
import isScalar from '@/utils/isScalar'
|
|
||||||
|
|
||||||
describe('isScalar', () => {
|
|
||||||
it('Passes on strings', () => expect(isScalar('hello')).toBe(true))
|
|
||||||
|
|
||||||
it('Passes on numbers', () => expect(isScalar(123)).toBe(true))
|
|
||||||
|
|
||||||
it('Passes on booleans', () => expect(isScalar(false)).toBe(true))
|
|
||||||
|
|
||||||
it('Passes on symbols', () => expect(isScalar(Symbol(123))).toBe(true))
|
|
||||||
|
|
||||||
it('Passes on null', () => expect(isScalar(null)).toBe(true))
|
|
||||||
|
|
||||||
it('Passes on undefined', () => expect(isScalar(undefined)).toBe(true))
|
|
||||||
|
|
||||||
it('Fails on pojo', () => expect(isScalar({})).toBe(false))
|
|
||||||
})
|
|
@ -1,79 +1,73 @@
|
|||||||
import regexForFormat from '@/utils/regexForFormat'
|
import regexForFormat from '@/utils/regexForFormat'
|
||||||
|
|
||||||
describe('regexForFormat', () => {
|
describe('regexForFormat', () => {
|
||||||
it('Allows MM format with other characters', () => {
|
test('allows MM format with other characters', () => {
|
||||||
expect(regexForFormat('abc/MM').test('abc/01')).toBe(true)
|
expect(regexForFormat('abc/MM').test('abc/01')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Fails MM format with single digit', () => {
|
test('fails MM format with single digit', () => {
|
||||||
expect(regexForFormat('abc/MM').test('abc/1')).toBe(false)
|
expect(regexForFormat('abc/MM').test('abc/1')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Allows M format with single digit', () => {
|
test('allows M format with single digit', () => {
|
||||||
expect(regexForFormat('M/abc').test('1/abc')).toBe(true)
|
expect(regexForFormat('M/abc').test('1/abc')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Fails MM format when out of range', () => {
|
test.each([
|
||||||
expect(regexForFormat('M/abc').test('13/abc')).toBe(false)
|
['13/abc'],
|
||||||
})
|
['55/abc'],
|
||||||
|
])('fails M format when out of range', (string) => {
|
||||||
|
expect(regexForFormat('M/abc').test(string)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('Fails M format when out of range', () => {
|
test('replaces double digits before singles', () => {
|
||||||
expect(regexForFormat('M/abc').test('55/abc')).toBe(false)
|
expect(regexForFormat('MMM').test('313131')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Replaces double digits before singles', () => {
|
test('allows DD format with zero digit', () => {
|
||||||
expect(regexForFormat('MMM').test('313131')).toBe(false)
|
const regex = regexForFormat('xyz/DD')
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows DD format with zero digit', () => {
|
expect(regex.test('xyz/01')).toBe(true)
|
||||||
expect(regexForFormat('xyz/DD').test('xyz/01')).toBe(true)
|
expect(regex.test('xyz/9')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Fails DD format with single digit', () => {
|
test('allows D format with single digit', () => {
|
||||||
expect(regexForFormat('xyz/DD').test('xyz/9')).toBe(false)
|
expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Allows D format with single digit', () => {
|
test.each([
|
||||||
expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true)
|
['xyz/92'],
|
||||||
})
|
['xyz/32'],
|
||||||
|
])('fails D format with out of range digit', string => {
|
||||||
|
expect(regexForFormat('xyz/D').test(string)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('Fails D format with out of range digit', () => {
|
test.each([
|
||||||
expect(regexForFormat('xyz/D').test('xyz/92')).toBe(false)
|
['00', true],
|
||||||
})
|
['0000', false],
|
||||||
|
])('allows YY format', (string, matches) => {
|
||||||
|
expect(regexForFormat('YY').test(string)).toBe(matches)
|
||||||
|
})
|
||||||
|
|
||||||
it('Fails DD format with out of range digit', () => {
|
test('allows YYYY format with four zeros', () => {
|
||||||
expect(regexForFormat('xyz/D').test('xyz/32')).toBe(false)
|
expect(regexForFormat('YYYY').test('0000')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Allows YY format with double zeros', () => {
|
test.each([
|
||||||
expect(regexForFormat('YY').test('00')).toBe(true)
|
['MD-YY', '12-00'],
|
||||||
})
|
['DM-YY', '12-00'],
|
||||||
|
])('allows $format', (format, string) => {
|
||||||
|
expect(regexForFormat(format).test(string)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('Fails YY format with four zeros', () => {
|
test.each([
|
||||||
expect(regexForFormat('YY').test('0000')).toBe(false)
|
['MM/DD/YYYY', '12/18/1987'],
|
||||||
})
|
['YYYY-MM-DD', '1987-01-31']
|
||||||
|
])('$date matches $format', (format, date) => {
|
||||||
|
expect(regexForFormat(format).test(date)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('Allows YYYY format with four zeros', () => {
|
test('Fails date like YYYY-MM-DD with out of bounds day', () => {
|
||||||
expect(regexForFormat('YYYY').test('0000')).toBe(true)
|
expect(regexForFormat('YYYY-MM-DD').test('1987-01-32')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Allows MD-YY', () => {
|
|
||||||
expect(regexForFormat('MD-YY').test('12-00')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows DM-YY', () => {
|
|
||||||
expect(regexForFormat('DM-YY').test('12-00')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows date like MM/DD/YYYY', () => {
|
|
||||||
expect(regexForFormat('MM/DD/YYYY').test('12/18/1987')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Allows date like YYYY-MM-DD', () => {
|
|
||||||
expect(regexForFormat('YYYY-MM-DD').test('1987-01-31')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Fails date like YYYY-MM-DD with out of bounds day', () => {
|
|
||||||
expect(regexForFormat('YYYY-MM-DD').test('1987-01-32')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
@ -1,27 +1,22 @@
|
|||||||
import snakeToCamel from '@/utils/snakeToCamel'
|
import snakeToCamel from '@/utils/snakeToCamel'
|
||||||
|
|
||||||
describe('snakeToCamel', () => {
|
describe('snakeToCamel', () => {
|
||||||
it('Converts underscore separated words to camelCase', () => {
|
test.each([
|
||||||
expect(snakeToCamel('this_is_snake_case')).toBe('thisIsSnakeCase')
|
['this_is_snake_case', 'thisIsSnakeCase'],
|
||||||
|
['this_is_snake_case_2nd_example', 'thisIsSnakeCase2ndExample'],
|
||||||
|
])('converts snake_case to camelCase', (raw, expected) => {
|
||||||
|
expect(snakeToCamel(raw)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Converts underscore separated words to camelCase even if they start with a number', () => {
|
test('Does not capitalize the first word or strip first underscore if a phrase starts with an underscore', () => {
|
||||||
expect(snakeToCamel('this_is_snake_case_2nd_example')).toBe('thisIsSnakeCase2ndExample')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Has no effect on already camelCase words', () => {
|
|
||||||
expect(snakeToCamel('thisIsCamelCase')).toBe('thisIsCamelCase')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Does not capitalize the first word or strip first underscore if a phrase starts with an underscore', () => {
|
|
||||||
expect(snakeToCamel('_this_starts_with_an_underscore')).toBe('_thisStartsWithAnUnderscore')
|
expect(snakeToCamel('_this_starts_with_an_underscore')).toBe('_thisStartsWithAnUnderscore')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Ignores double underscores anywhere in a word', () => {
|
test.each([
|
||||||
expect(snakeToCamel('__unlikely__thing__')).toBe('__unlikely__thing__')
|
['thisIsCamelCase'],
|
||||||
})
|
['__double__underscores__'],
|
||||||
|
['this-is-kebab-case'],
|
||||||
it('Has no effect hyphenated words', () => {
|
])('has no effect', (raw) => {
|
||||||
expect(snakeToCamel('not-a-good-name')).toBe('not-a-good-name')
|
expect(snakeToCamel(raw)).toBe(raw)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user