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: {
|
||||
'@typescript-eslint/ban-ts-ignore': 'off', // @TODO
|
||||
'@typescript-eslint/camelcase': ['error', {
|
||||
allow: ['^__Formulario'],
|
||||
}],
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off', // @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
|
||||
.gitignore
|
||||
.travis.yml
|
||||
.versionrc.json
|
||||
/storybook
|
||||
/node_modules
|
||||
/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?
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
(it is necessary for validation when property `validationBehavior` is `demand`). Errors list for a field can be
|
||||
accessed through `context.allErrors`.
|
||||
@ -27,7 +27,7 @@ The example below creates the authorization form from data:
|
||||
v-model="formData"
|
||||
name="formName"
|
||||
>
|
||||
<FormularioInput
|
||||
<FormularioField
|
||||
v-slot="{ context }"
|
||||
name="username"
|
||||
validation="required|email"
|
||||
@ -46,9 +46,9 @@ The example below creates the authorization form from data:
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</FormularioInput>
|
||||
</FormularioField>
|
||||
|
||||
<FormularioInput
|
||||
<FormularioField
|
||||
v-slot="{ context }"
|
||||
name="password"
|
||||
validation="required|min:4,length"
|
||||
@ -57,10 +57,10 @@ The example below creates the authorization form from data:
|
||||
v-model="context.model"
|
||||
type="password"
|
||||
>
|
||||
</FormularioInput>
|
||||
</FormularioField>
|
||||
|
||||
<FormularioGrouping name="options">
|
||||
<FormularioInput
|
||||
<FormularioFieldGroup name="options">
|
||||
<FormularioField
|
||||
v-slot="{ context }"
|
||||
name="anonymous"
|
||||
>
|
||||
@ -72,10 +72,10 @@ The example below creates the authorization form from data:
|
||||
>
|
||||
<label for="options-anonymous">As anonymous</label>
|
||||
</div>
|
||||
</FormularioInput>
|
||||
</FormularioGrouping>
|
||||
</FormularioField>
|
||||
</FormularioFieldGroup>
|
||||
|
||||
<FormularioInput
|
||||
<FormularioField
|
||||
v-slot="{ context }"
|
||||
name="options.tags[0]"
|
||||
>
|
||||
@ -83,7 +83,7 @@ The example below creates the authorization form from data:
|
||||
v-model="context.model"
|
||||
type="text"
|
||||
>
|
||||
</FormularioInput>
|
||||
</FormularioField>
|
||||
</FormularioForm>
|
||||
```
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
import alias from '@rollup/plugin-alias'
|
||||
import autoExternal from 'rollup-plugin-auto-external'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import { terser } from 'rollup-plugin-terser'
|
||||
import typescript from 'rollup-plugin-typescript2'
|
||||
import vue from 'rollup-plugin-vue'
|
||||
|
||||
@ -14,19 +13,17 @@ export default {
|
||||
globals: {
|
||||
'is-plain-object': 'isPlainObject',
|
||||
'is-url': 'isUrl',
|
||||
'nanoid/non-secure': 'nanoid',
|
||||
vue: 'Vue',
|
||||
'vue-property-decorator': 'vuePropertyDecorator',
|
||||
},
|
||||
sourcemap: false,
|
||||
}],
|
||||
external: ['nanoid/non-secure', 'vue', 'vue-property-decorator'],
|
||||
external: ['vue', 'vue-property-decorator'],
|
||||
plugins: [
|
||||
typescript({ check: false, sourceMap: false }),
|
||||
typescript({ sourceMap: false }),
|
||||
vue({ css: true, compileTemplate: true }),
|
||||
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
|
||||
commonjs(),
|
||||
autoExternal(),
|
||||
// terser(),
|
||||
]
|
||||
}
|
||||
|
@ -16,7 +16,6 @@ export default {
|
||||
globals: {
|
||||
'is-plain-object': 'isPlainObject',
|
||||
'is-url': 'isUrl',
|
||||
'nanoid/non-secure': 'nanoid',
|
||||
vue: 'Vue',
|
||||
'vue-property-decorator': 'vuePropertyDecorator',
|
||||
},
|
||||
@ -27,11 +26,11 @@ export default {
|
||||
browser: true,
|
||||
preferBuiltins: false,
|
||||
}),
|
||||
typescript({ check: false, sourceMap: false }),
|
||||
typescript({ sourceMap: false }),
|
||||
vue({ css: true, compileTemplate: true }),
|
||||
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
|
||||
commonjs(),
|
||||
internal(['is-plain-object', 'nanoid/non-secure', 'is-url', 'vue-property-decorator']),
|
||||
internal(['is-plain-object', 'is-url', 'vue-property-decorator']),
|
||||
terser(),
|
||||
]
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
version: '3.6'
|
||||
services:
|
||||
node:
|
||||
image: library/node:12
|
||||
image: node:12-alpine
|
||||
user: node
|
||||
volumes:
|
||||
- ./:/var/www/vue-formulario
|
||||
- "$SSH_AUTH_SOCK:/ssh-auth.sock"
|
||||
working_dir: /var/www/vue-formulario
|
||||
|
43
package.json
43
package.json
@ -1,20 +1,8 @@
|
||||
{
|
||||
"name": "@retailcrm/vue-formulario",
|
||||
"version": "0.5.1",
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"module": "dist/formulario.esm.js",
|
||||
"browser": {
|
||||
@ -24,14 +12,35 @@
|
||||
"dependencies": {
|
||||
"is-plain-object": "^3.0.0",
|
||||
"is-url": "^1.2.4",
|
||||
"nanoid": "^2.1.11",
|
||||
"vue-class-component": "^7.2.3",
|
||||
"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": {
|
||||
"@babel/core": "^7.9.6",
|
||||
"@babel/plugin-transform-modules-commonjs": "^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-buble": "^0.21.3",
|
||||
"@rollup/plugin-commonjs": "^11.1.0",
|
||||
@ -43,7 +52,6 @@
|
||||
"@storybook/vue": "^6.0.26",
|
||||
"@types/is-url": "^1.2.28",
|
||||
"@types/jest": "^26.0.14",
|
||||
"@types/nanoid": "^2.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^2.26.0",
|
||||
"@typescript-eslint/parser": "^2.26.0",
|
||||
"@vue/cli-plugin-babel": "^4.3.1",
|
||||
@ -78,6 +86,7 @@
|
||||
"rollup-plugin-typescript2": "^0.27.3",
|
||||
"rollup-plugin-vue": "^5.1.7",
|
||||
"sass-loader": "^10.0.3",
|
||||
"standard-version": "^9.3.0",
|
||||
"ts-jest": "^26.4.1",
|
||||
"typescript": "~3.9.3",
|
||||
"vue": "^2.6.11",
|
||||
@ -88,9 +97,6 @@
|
||||
"vue-template-es2015-compiler": "^1.9.1",
|
||||
"watch": "^1.0.2"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/retailcrm/vue-formulario/issues"
|
||||
},
|
||||
"contributors": [
|
||||
"Justin Schroeder <justin@wearebraid.com>"
|
||||
],
|
||||
@ -101,7 +107,6 @@
|
||||
"validation",
|
||||
"validate"
|
||||
],
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
@ -7,8 +7,11 @@ import {
|
||||
ValidationRuleFn,
|
||||
ValidationMessageFn,
|
||||
ValidationMessageI18NFn,
|
||||
Violation,
|
||||
} from '@/validation/validator'
|
||||
|
||||
import { FormularioForm } from '@/types'
|
||||
|
||||
export interface FormularioOptions {
|
||||
validationRules?: Record<string, ValidationRuleFn>;
|
||||
validationMessages?: Record<string, ValidationMessageI18NFn|string>;
|
||||
@ -21,7 +24,11 @@ export default class Formulario {
|
||||
public validationRules: Record<string, ValidationRuleFn> = {}
|
||||
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.validationMessages = validationMessages
|
||||
|
||||
@ -31,26 +38,70 @@ export default class Formulario {
|
||||
/**
|
||||
* 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') {
|
||||
this.validationRules = merge(this.validationRules, extendWith.validationRules || {})
|
||||
this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {})
|
||||
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.
|
||||
* @internal
|
||||
*/
|
||||
getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
|
||||
public getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
|
||||
return merge(this.validationRules, extendWith)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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>
|
||||
<form @submit.prevent="onFormSubmit">
|
||||
<slot :errors="mergedFormErrors" />
|
||||
<form @submit.prevent="onSubmit">
|
||||
<slot :errors="formErrorsComputed" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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 {
|
||||
ErrorHandler,
|
||||
ErrorObserver,
|
||||
ErrorObserverRegistry,
|
||||
} from '@/validation/ErrorObserver'
|
||||
Component,
|
||||
Model,
|
||||
Prop,
|
||||
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'
|
||||
|
||||
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' })
|
||||
export default class FormularioForm extends Vue {
|
||||
@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: () => ({}) }) readonly errors!: Record<string, any>
|
||||
// Form errors only used on FormularioForm default slot
|
||||
@Prop({ default: () => id('formulario-form') })
|
||||
public readonly id!: string
|
||||
|
||||
// Describes validation errors of whole form
|
||||
@Prop({ default: () => ({}) }) readonly fieldsErrors!: Record<string, string[]>
|
||||
// Only used on FormularioForm default slot
|
||||
@Prop({ default: () => ([]) }) readonly formErrors!: string[]
|
||||
|
||||
@Provide()
|
||||
public path = ''
|
||||
|
||||
public proxy: Record<string, any> = {}
|
||||
|
||||
private registry: Registry = new Registry(this)
|
||||
|
||||
private errorObserverRegistry = new ErrorObserverRegistry()
|
||||
private proxy: Record<string, unknown> = {}
|
||||
private registry: Map<string, FormularioField> = new Map()
|
||||
// Local error messages are temporal, they wiped each resetValidation call
|
||||
private localFieldsErrors: Record<string, string[]> = {}
|
||||
private localFormErrors: string[] = []
|
||||
private localFieldErrors: Record<string, string[]> = {}
|
||||
|
||||
get initialValues (): Record<string, any> {
|
||||
if (this.hasModel && typeof this.formularioValue === 'object') {
|
||||
// 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 {}
|
||||
private get fieldsErrorsComputed (): Record<string, string[]> {
|
||||
return merge(this.fieldsErrors || {}, this.localFieldsErrors)
|
||||
}
|
||||
|
||||
get mergedFormErrors (): string[] {
|
||||
private get formErrorsComputed (): string[] {
|
||||
return [...this.formErrors, ...this.localFormErrors]
|
||||
}
|
||||
|
||||
get mergedFieldErrors (): Record<string, string[]> {
|
||||
return merge(this.errors || {}, this.localFieldErrors)
|
||||
}
|
||||
@Provide('__FormularioForm_register')
|
||||
private register (path: string, field: FormularioField): void {
|
||||
if (!this.registry.has(path)) {
|
||||
this.registry.set(path, field)
|
||||
}
|
||||
|
||||
get hasModel (): boolean {
|
||||
return has(this.$options.propsData || {}, 'formularioValue')
|
||||
}
|
||||
const value = get(this.proxy, path)
|
||||
|
||||
get hasInitialValue (): boolean {
|
||||
return this.formularioValue && typeof this.formularioValue === 'object'
|
||||
}
|
||||
if (!field.hasModel) {
|
||||
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 })
|
||||
onFormularioValueChanged (values: Record<string, any>): void {
|
||||
if (this.hasModel && values && typeof values === 'object') {
|
||||
this.setValues(values)
|
||||
if (has(this.fieldsErrorsComputed, path)) {
|
||||
field.setErrors(this.fieldsErrorsComputed[path])
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('mergedFormErrors')
|
||||
onMergedFormErrorsChanged (errors: string[]): void {
|
||||
this.errorObserverRegistry.filter(o => o.type === 'form').observe(errors)
|
||||
@Provide('__FormularioForm_unregister')
|
||||
private unregister (path: string): void {
|
||||
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 })
|
||||
onMergedFieldErrorsChanged (errors: Record<string, string[]>): void {
|
||||
this.errorObserverRegistry.filter(o => o.type === 'input').observe(errors)
|
||||
}
|
||||
|
||||
created (): void {
|
||||
this.initProxy()
|
||||
}
|
||||
|
||||
@Provide()
|
||||
getFormValues (): Record<string, any> {
|
||||
@Provide('__FormularioForm_getState')
|
||||
private getState (): Record<string, unknown> {
|
||||
return this.proxy
|
||||
}
|
||||
|
||||
onFormSubmit (): Promise<void> {
|
||||
return this.hasValidationErrors()
|
||||
.then(hasErrors => hasErrors ? undefined : clone(this.proxy))
|
||||
.then(data => {
|
||||
if (typeof data !== 'undefined') {
|
||||
this.$emit('submit', data)
|
||||
} else {
|
||||
this.$emit('error')
|
||||
}
|
||||
})
|
||||
@Provide('__FormularioForm_set')
|
||||
private setFieldValue (path: string, value: unknown): void {
|
||||
this.proxy = update(this.proxy, path, value)
|
||||
}
|
||||
|
||||
@Provide()
|
||||
onFormularioFieldValidation (payload: { name: string; violations: Violation[]}): void {
|
||||
this.$emit('validation', payload)
|
||||
@Provide('__FormularioForm_emitInput')
|
||||
private emitInput (): void {
|
||||
this.$emit('input', clone(this.proxy))
|
||||
}
|
||||
|
||||
@Provide()
|
||||
addErrorObserver (observer: ErrorObserver): void {
|
||||
this.errorObserverRegistry.add(observer)
|
||||
if (observer.type === 'form') {
|
||||
observer.callback(this.mergedFormErrors)
|
||||
} else if (observer.field && has(this.mergedFieldErrors, observer.field)) {
|
||||
observer.callback(this.mergedFieldErrors[observer.field])
|
||||
}
|
||||
@Provide('__FormularioForm_emitValidation')
|
||||
private emitValidation (path: string, violations: Violation[]): void {
|
||||
this.$emit('validation', { path, violations })
|
||||
}
|
||||
|
||||
@Provide()
|
||||
removeErrorObserver (observer: ErrorHandler): void {
|
||||
this.errorObserverRegistry.remove(observer)
|
||||
}
|
||||
@Watch('state', { deep: true })
|
||||
private onStateChange (newState: Record<string, unknown>): void {
|
||||
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
|
||||
keys.forEach(field => {
|
||||
if (!this.registry.hasNested(field)) {
|
||||
return
|
||||
|
||||
this.registry.forEach((field, path) => {
|
||||
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) {
|
||||
this.$emit('input', { ...this.proxy })
|
||||
this.emitInput()
|
||||
}
|
||||
}
|
||||
|
||||
setFieldValue (field: string, value: any): void {
|
||||
if (value === undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { [field]: value, ...proxy } = this.proxy
|
||||
this.proxy = proxy
|
||||
} else {
|
||||
setNested(this.proxy, field, value)
|
||||
@Watch('fieldsErrorsComputed', { deep: true, immediate: true })
|
||||
private onFieldsErrorsChange (fieldsErrors: Record<string, string[]>): void {
|
||||
this.registry.forEach((field, path) => {
|
||||
field.setErrors(fieldsErrors[path] || [])
|
||||
})
|
||||
}
|
||||
|
||||
public created (): void {
|
||||
this.$formulario.register(this.id, this)
|
||||
if (typeof this.state === 'object') {
|
||||
this.proxy = clone(this.state)
|
||||
}
|
||||
}
|
||||
|
||||
@Provide('formularioSetter')
|
||||
setFieldValueAndEmit (field: string, value: any): void {
|
||||
this.setFieldValue(field, value)
|
||||
this.$emit('input', { ...this.proxy })
|
||||
public beforeDestroy (): void {
|
||||
this.$formulario.unregister(this.id)
|
||||
}
|
||||
|
||||
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.localFieldErrors = inputErrors || {}
|
||||
}
|
||||
|
||||
hasValidationErrors (): Promise<boolean> {
|
||||
return Promise.all(this.registry.reduce((resolvers: Promise<boolean>[], input: FormularioInput) => {
|
||||
resolvers.push(input.runValidation() && input.hasValidationErrors())
|
||||
return resolvers
|
||||
}, [])).then(results => results.some(hasErrors => hasErrors))
|
||||
}
|
||||
|
||||
resetValidation (): void {
|
||||
public resetValidation (): void {
|
||||
this.localFieldsErrors = {}
|
||||
this.localFormErrors = []
|
||||
this.localFieldErrors = {}
|
||||
this.registry.forEach((input: FormularioInput) => {
|
||||
input.resetValidation()
|
||||
this.registry.forEach((field: FormularioField) => {
|
||||
field.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 Formulario, { FormularioOptions } from '@/Formulario.ts'
|
||||
|
||||
import FormularioField from '@/FormularioField.vue'
|
||||
import FormularioFieldGroup from '@/FormularioFieldGroup.vue'
|
||||
import FormularioForm from '@/FormularioForm.vue'
|
||||
import FormularioGrouping from '@/FormularioGrouping.vue'
|
||||
import FormularioInput from '@/FormularioInput.vue'
|
||||
|
||||
export default {
|
||||
Formulario,
|
||||
install (Vue: VueConstructor, options?: FormularioOptions): void {
|
||||
Vue.component('FormularioField', FormularioField)
|
||||
Vue.component('FormularioFieldGroup', FormularioFieldGroup)
|
||||
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({
|
||||
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 has from '@/utils/has'
|
||||
import { isRecordLike, isScalar } from '@/types'
|
||||
|
||||
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
|
||||
* case of needing to unbind reactive watchers.
|
||||
*/
|
||||
export default function clone (value: any): any {
|
||||
if (typeof value !== 'object') {
|
||||
export default function clone<T = unknown> (value: T): T {
|
||||
if (isScalar(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
const copy: any | Record<string, any> = Array.isArray(value) ? [] : {}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return new Date(value) as unknown as T
|
||||
}
|
||||
|
||||
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 has } from './has'
|
||||
export { default as isScalar } from './isScalar'
|
||||
export { default as merge } from './merge'
|
||||
export { get, set, unset } from './access'
|
||||
export { default as regexForFormat } from './regexForFormat'
|
||||
export { default as shallowEqualObjects } from './shallowEqualObjects'
|
||||
export { deepEquals, shallowEquals } from './compare'
|
||||
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 { has, regexForFormat, shallowEqualObjects } from '@/utils'
|
||||
import { has, regexForFormat, shallowEquals } from '@/utils'
|
||||
import {
|
||||
ValidationContext,
|
||||
ValidationRuleFn,
|
||||
@ -130,7 +130,7 @@ const rules: Record<string, ValidationRuleFn> = {
|
||||
* Rule: Value is in an array (stack).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 {
|
||||
message: string;
|
||||
rule: string|null;
|
||||
args: any[];
|
||||
context: ValidationContext|null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationRuleFn {
|
||||
|
@ -1,26 +1,49 @@
|
||||
<template>
|
||||
<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"
|
||||
name="addressList"
|
||||
>
|
||||
<FormularioGrouping name="addressList">
|
||||
<FormularioGrouping
|
||||
<FormularioFieldGroup name="addressList">
|
||||
<FormularioFieldGroup
|
||||
v-for="(address, addressIndex) in addressList.context.model"
|
||||
:key="'address-' + addressIndex"
|
||||
:name="addressIndex"
|
||||
:is-array-item="true"
|
||||
class="row mx-n2"
|
||||
>
|
||||
<FormularioInput
|
||||
<FormularioField
|
||||
v-slot="{ context }"
|
||||
class="col col-auto px-2 mb-3"
|
||||
name="street"
|
||||
validation="required"
|
||||
>
|
||||
<label for="address-street">Street <span class="text-danger">*</span></label>
|
||||
<label for="address-street">Street</label>
|
||||
<input
|
||||
id="address-street"
|
||||
v-model="context.model"
|
||||
@ -36,15 +59,15 @@
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
</FormularioInput>
|
||||
</FormularioField>
|
||||
|
||||
<FormularioInput
|
||||
<FormularioField
|
||||
v-slot="{ context }"
|
||||
class="col col-auto px-2 mb-3"
|
||||
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
|
||||
id="address-building"
|
||||
v-model="context.model"
|
||||
@ -60,9 +83,19 @@
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
</FormularioInput>
|
||||
</FormularioGrouping>
|
||||
</FormularioGrouping>
|
||||
</FormularioField>
|
||||
|
||||
<div class="remove-btn-wrapper">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
type="button"
|
||||
@click="removeAddress(addressIndex)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</FormularioFieldGroup>
|
||||
</FormularioFieldGroup>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@ -71,34 +104,38 @@
|
||||
>
|
||||
Add address
|
||||
</button>
|
||||
</FormularioInput>
|
||||
</FormularioField>
|
||||
</FormularioForm>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormularioField from '@/FormularioField'
|
||||
import FormularioFieldGroup from '@/FormularioFieldGroup'
|
||||
import FormularioForm from '@/FormularioForm'
|
||||
import FormularioGrouping from '@/FormularioGrouping'
|
||||
import FormularioInput from '@/FormularioInput'
|
||||
|
||||
export default {
|
||||
name: 'ExampleAddressListTale',
|
||||
|
||||
components: {
|
||||
FormularioField,
|
||||
FormularioFieldGroup,
|
||||
FormularioForm,
|
||||
FormularioGrouping,
|
||||
FormularioInput,
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
values: {
|
||||
addressList: [],
|
||||
addressList: [{
|
||||
street: 'Baker Street',
|
||||
building: '221b',
|
||||
}],
|
||||
},
|
||||
}),
|
||||
|
||||
created () {
|
||||
this.$formulario.extend({
|
||||
validationMessages: {
|
||||
number: () => 'Value is not a number',
|
||||
alphanumeric: () => 'Value must be alphanumeric',
|
||||
number: () => 'Value must be a number',
|
||||
required: () => 'Value is required',
|
||||
},
|
||||
})
|
||||
@ -111,6 +148,10 @@ export default {
|
||||
building: '',
|
||||
})
|
||||
},
|
||||
|
||||
removeAddress (index) {
|
||||
this.values.addressList.splice(index, 1)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -119,4 +160,8 @@ export default {
|
||||
.field {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.remove-btn-wrapper {
|
||||
padding-top: 32px;
|
||||
}
|
||||
</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 Vue from 'vue'
|
||||
import VueFormulario from '../../dist/formulario.esm'
|
||||
import VueFormulario from '@/index.ts'
|
||||
|
||||
import ExampleAddressList from './ExampleAddressList.tale'
|
||||
import FormularioGrouping from './FormularioGrouping.tale'
|
||||
|
||||
Vue.mixin({
|
||||
methods: {
|
||||
@ -22,4 +21,3 @@ Vue.use(VueFormulario)
|
||||
|
||||
storiesOf('Examples', module)
|
||||
.add('Address list', () => ExampleAddressList)
|
||||
.add('FormularioGrouping', () => FormularioGrouping)
|
||||
|
@ -3,60 +3,54 @@ import Formulario from '@/Formulario.ts'
|
||||
import plugin from '@/index.ts'
|
||||
|
||||
describe('Formulario', () => {
|
||||
it('Installs on vue instance', () => {
|
||||
it('installs on vue instance', () => {
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(plugin)
|
||||
|
||||
expect(localVue.component('FormularioField')).toBeTruthy()
|
||||
expect(localVue.component('FormularioFieldGroup')).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)
|
||||
})
|
||||
|
||||
it ('Pushes Formulario instance to child a component', () => {
|
||||
it ('pushes Formulario instance to child a component', () => {
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(plugin)
|
||||
localVue.component('TestComponent', {
|
||||
render (h) {
|
||||
return h('div')
|
||||
}
|
||||
|
||||
const ChildComponent = localVue.component('ChildComponent', {
|
||||
render: h => h('div'),
|
||||
})
|
||||
|
||||
const wrapper = mount({
|
||||
render (h) {
|
||||
return h('div', [h('TestComponent', { ref: 'test' })])
|
||||
},
|
||||
const parent = mount({
|
||||
render: h => h('div', [h('ChildComponent')]),
|
||||
}, { 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()
|
||||
|
||||
localVue.use(plugin)
|
||||
// noinspection JSCheckFunctionSignatures
|
||||
localVue.component('TestComponent', {
|
||||
formulario () {
|
||||
return new Formulario()
|
||||
},
|
||||
|
||||
render (h) {
|
||||
return h('div')
|
||||
},
|
||||
const ChildComponent = localVue.component('ChildComponent', {
|
||||
formulario: () => new Formulario(),
|
||||
render: h => h('div'),
|
||||
})
|
||||
|
||||
const wrapper = mount({
|
||||
render (h) {
|
||||
return h('div', [h('TestComponent', { ref: 'test' })])
|
||||
},
|
||||
const parent = mount({
|
||||
render: h => h('div', [h('ChildComponent')]),
|
||||
}, { 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 { mount } from '@vue/test-utils'
|
||||
import flushPromises from 'flush-promises'
|
||||
|
||||
import Formulario from '@/index.ts'
|
||||
import FormularioForm from '@/FormularioForm.vue'
|
||||
|
||||
@ -13,442 +15,452 @@ Vue.use(Formulario, {
|
||||
})
|
||||
|
||||
describe('FormularioForm', () => {
|
||||
it('render a form DOM element', () => {
|
||||
test('renders a form DOM element', () => {
|
||||
const wrapper = mount(FormularioForm)
|
||||
expect(wrapper.find('form').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a default slot', () => {
|
||||
test('accepts a default slot', () => {
|
||||
const wrapper = mount(FormularioForm, {
|
||||
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, {
|
||||
slots: {
|
||||
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: {} },
|
||||
propsData: { state: { test: 'Has initial value' } },
|
||||
slots: {
|
||||
default: `
|
||||
<FormularioInput name="sub1" />
|
||||
<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" >
|
||||
<FormularioField v-slot="{ context }" validation="required|in:bar" name="test" >
|
||||
<input v-model="context.model" type="text">
|
||||
</FormularioInput>
|
||||
</FormularioField>
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
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, {
|
||||
propsData: { formularioValue: { test: 'has initial value' } },
|
||||
propsData: { state: { field: 'initial' } },
|
||||
slots: {
|
||||
default: `
|
||||
<FormularioInput v-slot="{ context }" name="test" value="123">
|
||||
<FormularioField v-slot="{ context }" name="field">
|
||||
<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')
|
||||
|
||||
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"]')
|
||||
const input = wrapper.find('input')
|
||||
|
||||
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 () => {
|
||||
const wrapper = mount(FormularioForm, {
|
||||
slots: { default: '<FormularioInput name="fieldName" validation="required" />' }
|
||||
test('updates a field when it is an instance of Date', async () => {
|
||||
const dateA = new Date('1970-01-01')
|
||||
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')
|
||||
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()
|
||||
|
||||
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, {
|
||||
propsData: { errors: { fieldWithErrors: ['This field has an error'] }},
|
||||
slots: {
|
||||
default: `
|
||||
<FormularioInput v-slot="{ context }" name="fieldWithErrors">
|
||||
<span v-for="error in context.errors">{{ error }}</span>
|
||||
</FormularioInput>
|
||||
`
|
||||
}
|
||||
<div>
|
||||
<FormularioField name="address.street" validation="required" />
|
||||
<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').text()).toEqual('This field has an error')
|
||||
})
|
||||
|
||||
it('Is able to display multiple errors on multiple elements', async () => {
|
||||
const errors = { inputA: ['first'], inputB: ['first', 'second']}
|
||||
const wrapper = mount(FormularioForm, { propsData: { errors } })
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
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('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, {
|
||||
describe('allows setting fields errors', () => {
|
||||
/**
|
||||
* @param props
|
||||
* @return {Wrapper<FormularioForm>}
|
||||
*/
|
||||
const createWrapper = (props = {}) => mount(FormularioForm, {
|
||||
propsData: props,
|
||||
slots: {
|
||||
default: `
|
||||
<FormularioInput v-slot="{ context }" name="foo" validation="required|in:foo">
|
||||
<input v-model="context.model" type="text" @blur="context.runValidation()">
|
||||
</FormularioInput>
|
||||
<FormularioInput name="bar" validation="required" />
|
||||
<div>
|
||||
<FormularioField v-slot="{ context }" name="fieldA">
|
||||
<div v-for="error in context.errors" data-error-a>{{ error }}</div>
|
||||
</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.emitted('validation').length).toBe(1)
|
||||
expect(wrapper.emitted('validation')[0][0]).toEqual({
|
||||
name: 'foo',
|
||||
violations: [],
|
||||
expect(wrapper.findAll('[data-error-a]').length).toBe(1)
|
||||
expect(wrapper.findAll('[data-error-b]').length).toBe(2)
|
||||
})
|
||||
|
||||
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, {
|
||||
slots: { default: `
|
||||
<FormularioInput
|
||||
v-slot="{ context }"
|
||||
name="firstField"
|
||||
validation="required|in:foo"
|
||||
>
|
||||
<input
|
||||
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')
|
||||
slots: {
|
||||
default: `
|
||||
<div>
|
||||
<FormularioField v-slot="{ context }" name="username" validation="required">
|
||||
<input v-model="context.model" type="text">
|
||||
<div v-for="error in context.allErrors" data-username-error />
|
||||
</FormularioField>
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('validation')).toBeTruthy()
|
||||
expect(wrapper.emitted('validation').length).toBe(1)
|
||||
expect(wrapper.emitted('validation')[0][0]).toEqual({
|
||||
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>
|
||||
`,
|
||||
<FormularioField v-slot="{ context }" name="password" validation="required|min:4,length">
|
||||
<input v-model="context.model" type="password" @blur="context.runValidation()">
|
||||
<div v-for="error in context.allErrors" data-password-error />
|
||||
</FormularioField>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
const password = wrapper.find('input[type="password"]')
|
||||
|
||||
password.setValue('foo')
|
||||
password.trigger('input')
|
||||
password.trigger('blur')
|
||||
|
||||
wrapper.find('form').trigger('submit')
|
||||
wrapper.vm.$refs.form.setErrors({ inputErrors: { username: ['Failed'] } })
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// First make sure we caught the errors
|
||||
expect(Object.keys(wrapper.vm.$refs.form.mergedFieldErrors).length).toBe(1)
|
||||
wrapper.vm.$refs.form.resetValidation()
|
||||
wrapper.vm.setErrors({ fieldsErrors: { username: ['required'] } })
|
||||
|
||||
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 () => {
|
||||
const wrapper = mount({
|
||||
data: () => ({ values: {}, errors: { input: ['failure'] } }),
|
||||
template: `
|
||||
<FormularioForm
|
||||
v-model="values"
|
||||
:errors="errors"
|
||||
ref="form"
|
||||
>
|
||||
<FormularioInput
|
||||
v-slot="{ context }"
|
||||
name="input"
|
||||
ref="form"
|
||||
>
|
||||
test('local errors are reset when errors prop cleared', async () => {
|
||||
const wrapper = mount(FormularioForm, {
|
||||
propsData: { fieldsErrors: { input: ['failure'] } },
|
||||
slots: {
|
||||
default: `
|
||||
<FormularioField v-slot="{ context }" name="input">
|
||||
<span v-for="error in context.allErrors">{{ error.message }}</span>
|
||||
</FormularioInput>
|
||||
</FormularioForm>
|
||||
`
|
||||
</FormularioField>
|
||||
`,
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(wrapper.find('span').exists()).toBe(true)
|
||||
|
||||
wrapper.vm.errors = {}
|
||||
await flushPromises()
|
||||
wrapper.setProps({ fieldsErrors: {} })
|
||||
|
||||
await Vue.nextTick()
|
||||
|
||||
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'
|
||||
|
||||
class Sample {
|
||||
constructor() {
|
||||
this.fieldA = 'fieldA'
|
||||
this.fieldB = 'fieldB'
|
||||
}
|
||||
|
||||
doSomething () {}
|
||||
}
|
||||
|
||||
describe('clone', () => {
|
||||
it('Basic objects stay the same', () => {
|
||||
const obj = { a: 123, b: 'hello' }
|
||||
expect(clone(obj)).toEqual(obj)
|
||||
test.each([
|
||||
[{ a: 123, b: 'hello' }],
|
||||
[{ 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', () => {
|
||||
const obj = { a: 123, b: { c: 'hello-world' } }
|
||||
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'] }
|
||||
test('retains array structures inside of a pojo', () => {
|
||||
const obj = { a: 'abc', d: ['first', 'second'] }
|
||||
expect(Array.isArray(clone(obj).d)).toBe(true)
|
||||
})
|
||||
|
||||
it('Removes references inside array structures', () => {
|
||||
const obj = { a: 'abcd', d: ['first', { foo: 'bar' }] }
|
||||
test('removes references inside array structures', () => {
|
||||
const obj = { a: 'abc', d: ['first', { foo: 'bar' }] }
|
||||
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'
|
||||
|
||||
describe('regexForFormat', () => {
|
||||
it('Allows MM format with other characters', () => {
|
||||
expect(regexForFormat('abc/MM').test('abc/01')).toBe(true)
|
||||
})
|
||||
test('allows MM format with other characters', () => {
|
||||
expect(regexForFormat('abc/MM').test('abc/01')).toBe(true)
|
||||
})
|
||||
|
||||
it('Fails MM format with single digit', () => {
|
||||
expect(regexForFormat('abc/MM').test('abc/1')).toBe(false)
|
||||
})
|
||||
test('fails MM format with single digit', () => {
|
||||
expect(regexForFormat('abc/MM').test('abc/1')).toBe(false)
|
||||
})
|
||||
|
||||
it('Allows M format with single digit', () => {
|
||||
expect(regexForFormat('M/abc').test('1/abc')).toBe(true)
|
||||
})
|
||||
test('allows M format with single digit', () => {
|
||||
expect(regexForFormat('M/abc').test('1/abc')).toBe(true)
|
||||
})
|
||||
|
||||
it('Fails MM format when out of range', () => {
|
||||
expect(regexForFormat('M/abc').test('13/abc')).toBe(false)
|
||||
})
|
||||
test.each([
|
||||
['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', () => {
|
||||
expect(regexForFormat('M/abc').test('55/abc')).toBe(false)
|
||||
})
|
||||
test('replaces double digits before singles', () => {
|
||||
expect(regexForFormat('MMM').test('313131')).toBe(false)
|
||||
})
|
||||
|
||||
it('Replaces double digits before singles', () => {
|
||||
expect(regexForFormat('MMM').test('313131')).toBe(false)
|
||||
})
|
||||
test('allows DD format with zero digit', () => {
|
||||
const regex = regexForFormat('xyz/DD')
|
||||
|
||||
it('Allows DD format with zero digit', () => {
|
||||
expect(regexForFormat('xyz/DD').test('xyz/01')).toBe(true)
|
||||
})
|
||||
expect(regex.test('xyz/01')).toBe(true)
|
||||
expect(regex.test('xyz/9')).toBe(false)
|
||||
})
|
||||
|
||||
it('Fails DD format with single digit', () => {
|
||||
expect(regexForFormat('xyz/DD').test('xyz/9')).toBe(false)
|
||||
})
|
||||
test('allows D format with single digit', () => {
|
||||
expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true)
|
||||
})
|
||||
|
||||
it('Allows D format with single digit', () => {
|
||||
expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true)
|
||||
})
|
||||
test.each([
|
||||
['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', () => {
|
||||
expect(regexForFormat('xyz/D').test('xyz/92')).toBe(false)
|
||||
})
|
||||
test.each([
|
||||
['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', () => {
|
||||
expect(regexForFormat('xyz/D').test('xyz/32')).toBe(false)
|
||||
})
|
||||
test('allows YYYY format with four zeros', () => {
|
||||
expect(regexForFormat('YYYY').test('0000')).toBe(true)
|
||||
})
|
||||
|
||||
it('Allows YY format with double zeros', () => {
|
||||
expect(regexForFormat('YY').test('00')).toBe(true)
|
||||
})
|
||||
test.each([
|
||||
['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', () => {
|
||||
expect(regexForFormat('YY').test('0000')).toBe(false)
|
||||
})
|
||||
test.each([
|
||||
['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', () => {
|
||||
expect(regexForFormat('YYYY').test('0000')).toBe(true)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
test('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'
|
||||
|
||||
describe('snakeToCamel', () => {
|
||||
it('Converts underscore separated words to camelCase', () => {
|
||||
expect(snakeToCamel('this_is_snake_case')).toBe('thisIsSnakeCase')
|
||||
test.each([
|
||||
['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', () => {
|
||||
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', () => {
|
||||
test('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')
|
||||
})
|
||||
|
||||
it('Ignores double underscores anywhere in a word', () => {
|
||||
expect(snakeToCamel('__unlikely__thing__')).toBe('__unlikely__thing__')
|
||||
})
|
||||
|
||||
it('Has no effect hyphenated words', () => {
|
||||
expect(snakeToCamel('not-a-good-name')).toBe('not-a-good-name')
|
||||
test.each([
|
||||
['thisIsCamelCase'],
|
||||
['__double__underscores__'],
|
||||
['this-is-kebab-case'],
|
||||
])('has no effect', (raw) => {
|
||||
expect(snakeToCamel(raw)).toBe(raw)
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user