1
0
mirror of synced 2024-11-24 22:36:02 +03:00

Merge pull request #29 from cmath10/0.6.0-experimental

v0.6.0 Form state management refactoring, release automation
This commit is contained in:
Kruglov Kirill 2021-06-17 17:12:23 +03:00 committed by GitHub
commit b5961c52eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 2964 additions and 1936 deletions

3
.commitlintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["@commitlint/config-conventional"]
}

View File

@ -22,7 +22,9 @@ module.exports = {
}, },
rules: { rules: {
'@typescript-eslint/ban-ts-ignore': 'off', // @TODO '@typescript-eslint/camelcase': ['error', {
allow: ['^__Formulario'],
}],
'@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off', // @TODO '@typescript-eslint/no-explicit-any': 'off', // @TODO
'@typescript-eslint/no-unused-vars': ['error'], // @TODO '@typescript-eslint/no-unused-vars': ['error'], // @TODO

16
.github/workflows/github-actions.yml vendored Normal file
View 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

View File

@ -1,5 +1,9 @@
.commitlintrc.json
.editorconfig
.github .github
.gitignore .gitignore
.travis.yml
.versionrc.json
/storybook /storybook
/node_modules /node_modules
/build /build

12
.versionrc.json Normal file
View 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}
]
}

View File

@ -1,11 +1,11 @@
## What is Vue Formulario? ## What is Vue Formulario?
Vue Formulario is a library, based on <a href="https://vueformulate.com">Vue Formulate</a>, that handles the core logic Vue Formulario is a library, inspired by <a href="https://vueformulate.com">Vue Formulate</a>, that handles the core logic
for working with forms and gives full control on the form presentation. for working with forms and gives full control on the form presentation.
## Examples ## Examples
Every form control have to rendered inside FormularioInput component. This component provides `id` and `context` in Every form control have to rendered inside FormularioField component. This component provides `id` and `context` in
v-slot props. Control should use `context.model` as v-model and `context.runValidation` as handler for `blur` event v-slot props. Control should use `context.model` as v-model and `context.runValidation` as handler for `blur` event
(it is necessary for validation when property `validationBehavior` is `demand`). Errors list for a field can be (it is necessary for validation when property `validationBehavior` is `demand`). Errors list for a field can be
accessed through `context.allErrors`. accessed through `context.allErrors`.
@ -27,7 +27,7 @@ The example below creates the authorization form from data:
v-model="formData" v-model="formData"
name="formName" name="formName"
> >
<FormularioInput <FormularioField
v-slot="{ context }" v-slot="{ context }"
name="username" name="username"
validation="required|email" validation="required|email"
@ -46,9 +46,9 @@ The example below creates the authorization form from data:
{{ error }} {{ error }}
</span> </span>
</div> </div>
</FormularioInput> </FormularioField>
<FormularioInput <FormularioField
v-slot="{ context }" v-slot="{ context }"
name="password" name="password"
validation="required|min:4,length" validation="required|min:4,length"
@ -57,10 +57,10 @@ The example below creates the authorization form from data:
v-model="context.model" v-model="context.model"
type="password" type="password"
> >
</FormularioInput> </FormularioField>
<FormularioGrouping name="options"> <FormularioFieldGroup name="options">
<FormularioInput <FormularioField
v-slot="{ context }" v-slot="{ context }"
name="anonymous" name="anonymous"
> >
@ -72,10 +72,10 @@ The example below creates the authorization form from data:
> >
<label for="options-anonymous">As anonymous</label> <label for="options-anonymous">As anonymous</label>
</div> </div>
</FormularioInput> </FormularioField>
</FormularioGrouping> </FormularioFieldGroup>
<FormularioInput <FormularioField
v-slot="{ context }" v-slot="{ context }"
name="options.tags[0]" name="options.tags[0]"
> >
@ -83,7 +83,7 @@ The example below creates the authorization form from data:
v-model="context.model" v-model="context.model"
type="text" type="text"
> >
</FormularioInput> </FormularioField>
</FormularioForm> </FormularioForm>
``` ```

View File

@ -1,7 +1,6 @@
import alias from '@rollup/plugin-alias' import alias from '@rollup/plugin-alias'
import autoExternal from 'rollup-plugin-auto-external' import autoExternal from 'rollup-plugin-auto-external'
import commonjs from '@rollup/plugin-commonjs' import commonjs from '@rollup/plugin-commonjs'
import { terser } from 'rollup-plugin-terser'
import typescript from 'rollup-plugin-typescript2' import typescript from 'rollup-plugin-typescript2'
import vue from 'rollup-plugin-vue' import vue from 'rollup-plugin-vue'
@ -14,19 +13,17 @@ export default {
globals: { globals: {
'is-plain-object': 'isPlainObject', 'is-plain-object': 'isPlainObject',
'is-url': 'isUrl', 'is-url': 'isUrl',
'nanoid/non-secure': 'nanoid',
vue: 'Vue', vue: 'Vue',
'vue-property-decorator': 'vuePropertyDecorator', 'vue-property-decorator': 'vuePropertyDecorator',
}, },
sourcemap: false, sourcemap: false,
}], }],
external: ['nanoid/non-secure', 'vue', 'vue-property-decorator'], external: ['vue', 'vue-property-decorator'],
plugins: [ plugins: [
typescript({ check: false, sourceMap: false }), typescript({ sourceMap: false }),
vue({ css: true, compileTemplate: true }), vue({ css: true, compileTemplate: true }),
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }), alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
commonjs(), commonjs(),
autoExternal(), autoExternal(),
// terser(),
] ]
} }

View File

@ -16,7 +16,6 @@ export default {
globals: { globals: {
'is-plain-object': 'isPlainObject', 'is-plain-object': 'isPlainObject',
'is-url': 'isUrl', 'is-url': 'isUrl',
'nanoid/non-secure': 'nanoid',
vue: 'Vue', vue: 'Vue',
'vue-property-decorator': 'vuePropertyDecorator', 'vue-property-decorator': 'vuePropertyDecorator',
}, },
@ -27,11 +26,11 @@ export default {
browser: true, browser: true,
preferBuiltins: false, preferBuiltins: false,
}), }),
typescript({ check: false, sourceMap: false }), typescript({ sourceMap: false }),
vue({ css: true, compileTemplate: true }), vue({ css: true, compileTemplate: true }),
alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }), alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }),
commonjs(), commonjs(),
internal(['is-plain-object', 'nanoid/non-secure', 'is-url', 'vue-property-decorator']), internal(['is-plain-object', 'is-url', 'vue-property-decorator']),
terser(), terser(),
] ]
} }

View File

@ -1,9 +1,8 @@
version: '3.6' version: '3.6'
services: services:
node: node:
image: library/node:12 image: node:12-alpine
user: node user: node
volumes: volumes:
- ./:/var/www/vue-formulario - ./:/var/www/vue-formulario
- "$SSH_AUTH_SOCK:/ssh-auth.sock"
working_dir: /var/www/vue-formulario working_dir: /var/www/vue-formulario

View File

@ -1,20 +1,8 @@
{ {
"name": "@retailcrm/vue-formulario", "name": "@retailcrm/vue-formulario",
"version": "0.5.1", "version": "0.5.1",
"license": "MIT",
"author": "RetailDriverLLC <integration@retailcrm.ru>", "author": "RetailDriverLLC <integration@retailcrm.ru>",
"scripts": {
"build": "npm run build:esm & npm run build:umd & npm run build:iife & wait && echo \"Build complete:\nesm: $(gzip -c dist/formulario.esm.js | wc -c)b gzip\numd: $(gzip -c dist/formulario.umd.js | wc -c)b gzip\nmin: $(gzip -c dist/formulario.min.js | wc -c)b gzip\"",
"build:esm": "rollup --config build/rollup.config.js --format es --file dist/formulario.esm.js",
"build:iife": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulario.min.js",
"build:size": "gzip -c dist/formulario.esm.js | wc -c",
"build:umd": "rollup --config build/rollup.config.js --format umd --file dist/formulario.umd.js",
"lint": "vue-cli-service lint",
"storybook:build": "vue-cli-service storybook:build -c storybook/config",
"storybook:serve": "vue-cli-service storybook:serve -p 6006 -c storybook/config",
"test": "NODE_ENV=test jest --config test/jest.conf.js",
"test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage",
"test:watch": "NODE_ENV=test jest --config test/jest.conf.js --watch"
},
"main": "dist/formulario.umd.js", "main": "dist/formulario.umd.js",
"module": "dist/formulario.esm.js", "module": "dist/formulario.esm.js",
"browser": { "browser": {
@ -24,14 +12,35 @@
"dependencies": { "dependencies": {
"is-plain-object": "^3.0.0", "is-plain-object": "^3.0.0",
"is-url": "^1.2.4", "is-url": "^1.2.4",
"nanoid": "^2.1.11",
"vue-class-component": "^7.2.3", "vue-class-component": "^7.2.3",
"vue-property-decorator": "^8.4.2" "vue-property-decorator": "^8.4.2"
}, },
"bugs": {
"url": "https://github.com/retailcrm/vue-formulario/issues"
},
"scripts": {
"build": "npm run build:esm & npm run build:umd & npm run build:iife & wait && echo \"Build complete:\nesm: $(gzip -c dist/formulario.esm.js | wc -c)b gzip\numd: $(gzip -c dist/formulario.umd.js | wc -c)b gzip\nmin: $(gzip -c dist/formulario.min.js | wc -c)b gzip\"",
"build:esm": "rollup --config build/rollup.config.js --format es --file dist/formulario.esm.js",
"build:iife": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulario.min.js",
"build:size": "gzip -c dist/formulario.esm.js | wc -c",
"build:umd": "rollup --config build/rollup.config.js --format umd --file dist/formulario.umd.js",
"lint": "vue-cli-service lint",
"release": "standard-version",
"release:minor": "standard-version --release-as minor",
"release:patch": "standard-version --release-as patch",
"release:major": "standard-version --release-as major",
"storybook:build": "vue-cli-service storybook:build -c storybook/config",
"storybook:serve": "vue-cli-service storybook:serve -p 6006 -c storybook/config",
"test": "NODE_ENV=test jest --config test/jest.conf.js",
"test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage",
"test:watch": "NODE_ENV=test jest --config test/jest.conf.js --watch"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.6", "@babel/core": "^7.9.6",
"@babel/plugin-transform-modules-commonjs": "^7.9.6", "@babel/plugin-transform-modules-commonjs": "^7.9.6",
"@babel/preset-env": "^7.9.6", "@babel/preset-env": "^7.9.6",
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@rollup/plugin-alias": "^3.1.1", "@rollup/plugin-alias": "^3.1.1",
"@rollup/plugin-buble": "^0.21.3", "@rollup/plugin-buble": "^0.21.3",
"@rollup/plugin-commonjs": "^11.1.0", "@rollup/plugin-commonjs": "^11.1.0",
@ -43,7 +52,6 @@
"@storybook/vue": "^6.0.26", "@storybook/vue": "^6.0.26",
"@types/is-url": "^1.2.28", "@types/is-url": "^1.2.28",
"@types/jest": "^26.0.14", "@types/jest": "^26.0.14",
"@types/nanoid": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0", "@typescript-eslint/parser": "^2.26.0",
"@vue/cli-plugin-babel": "^4.3.1", "@vue/cli-plugin-babel": "^4.3.1",
@ -78,6 +86,7 @@
"rollup-plugin-typescript2": "^0.27.3", "rollup-plugin-typescript2": "^0.27.3",
"rollup-plugin-vue": "^5.1.7", "rollup-plugin-vue": "^5.1.7",
"sass-loader": "^10.0.3", "sass-loader": "^10.0.3",
"standard-version": "^9.3.0",
"ts-jest": "^26.4.1", "ts-jest": "^26.4.1",
"typescript": "~3.9.3", "typescript": "~3.9.3",
"vue": "^2.6.11", "vue": "^2.6.11",
@ -88,9 +97,6 @@
"vue-template-es2015-compiler": "^1.9.1", "vue-template-es2015-compiler": "^1.9.1",
"watch": "^1.0.2" "watch": "^1.0.2"
}, },
"bugs": {
"url": "https://github.com/retailcrm/vue-formulario/issues"
},
"contributors": [ "contributors": [
"Justin Schroeder <justin@wearebraid.com>" "Justin Schroeder <justin@wearebraid.com>"
], ],
@ -101,7 +107,6 @@
"validation", "validation",
"validate" "validate"
], ],
"license": "MIT",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },

View File

@ -7,8 +7,11 @@ import {
ValidationRuleFn, ValidationRuleFn,
ValidationMessageFn, ValidationMessageFn,
ValidationMessageI18NFn, ValidationMessageI18NFn,
Violation,
} from '@/validation/validator' } from '@/validation/validator'
import { FormularioForm } from '@/types'
export interface FormularioOptions { export interface FormularioOptions {
validationRules?: Record<string, ValidationRuleFn>; validationRules?: Record<string, ValidationRuleFn>;
validationMessages?: Record<string, ValidationMessageI18NFn|string>; validationMessages?: Record<string, ValidationMessageI18NFn|string>;
@ -21,7 +24,11 @@ export default class Formulario {
public validationRules: Record<string, ValidationRuleFn> = {} public validationRules: Record<string, ValidationRuleFn> = {}
public validationMessages: Record<string, ValidationMessageI18NFn|string> = {} public validationMessages: Record<string, ValidationMessageI18NFn|string> = {}
constructor (options?: FormularioOptions) { private readonly registry: Map<string, FormularioForm>
public constructor (options?: FormularioOptions) {
this.registry = new Map()
this.validationRules = validationRules this.validationRules = validationRules
this.validationMessages = validationMessages this.validationMessages = validationMessages
@ -31,26 +38,70 @@ export default class Formulario {
/** /**
* Given a set of options, apply them to the pre-existing options. * Given a set of options, apply them to the pre-existing options.
*/ */
extend (extendWith: FormularioOptions): Formulario { public extend (extendWith: FormularioOptions): Formulario {
if (typeof extendWith === 'object') { if (typeof extendWith === 'object') {
this.validationRules = merge(this.validationRules, extendWith.validationRules || {}) this.validationRules = merge(this.validationRules, extendWith.validationRules || {})
this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {}) this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {})
return this return this
} }
throw new Error(`[Formulario]: Formulario.extend() should be passed an object (was ${typeof extendWith})`) throw new Error(`[Formulario]: Formulario.extend(): should be passed an object (was ${typeof extendWith})`)
}
public runValidation (id: string): Promise<Record<string, Violation[]>> {
if (!this.registry.has(id)) {
throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`)
}
const form = this.registry.get(id) as FormularioForm
return form.runValidation()
}
public resetValidation (id: string): void {
if (!this.registry.has(id)) {
return
}
const form = this.registry.get(id) as FormularioForm
form.resetValidation()
}
/**
* Used by forms instances to add themselves into a registry
* @internal
*/
public register (id: string, form: FormularioForm): void {
if (this.registry.has(id)) {
throw new Error(`[Formulario]: Formulario.register(): id "${id}" is already in use`)
}
this.registry.set(id, form)
}
/**
* Used by forms instances to remove themselves from a registry
* @internal
*/
public unregister (id: string): void {
if (this.registry.has(id)) {
this.registry.delete(id)
}
} }
/** /**
* Get validation rules by merging any passed in with global rules. * Get validation rules by merging any passed in with global rules.
* @internal
*/ */
getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> { public getRules (extendWith: Record<string, ValidationRuleFn> = {}): Record<string, ValidationRuleFn> {
return merge(this.validationRules, extendWith) return merge(this.validationRules, extendWith)
} }
/** /**
* Get validation messages by merging any passed in with global messages. * Get validation messages by merging any passed in with global messages.
* @internal
*/ */
getMessages (vm: Vue, extendWith: Record<string, ValidationMessageI18NFn|string>): Record<string, ValidationMessageFn> { public getMessages (vm: Vue, extendWith: Record<string, ValidationMessageI18NFn|string>): Record<string, ValidationMessageFn> {
const raw = merge(this.validationMessages || {}, extendWith) const raw = merge(this.validationMessages || {}, extendWith)
const messages: Record<string, ValidationMessageFn> = {} const messages: Record<string, ValidationMessageFn> = {}

225
src/FormularioField.vue Normal file
View 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>

View 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>

View File

@ -1,209 +1,208 @@
<template> <template>
<form @submit.prevent="onFormSubmit"> <form @submit.prevent="onSubmit">
<slot :errors="mergedFormErrors" /> <slot :errors="formErrorsComputed" />
</form> </form>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue' import Vue from 'vue'
import { Component, Model, Prop, Provide, Watch } from 'vue-property-decorator'
import { clone, getNested, has, merge, setNested, shallowEqualObjects } from '@/utils'
import Registry from '@/form/registry'
import FormularioInput from '@/FormularioInput.vue'
import { import {
ErrorHandler, Component,
ErrorObserver, Model,
ErrorObserverRegistry, Prop,
} from '@/validation/ErrorObserver' Provide,
Watch,
} from 'vue-property-decorator'
import {
id,
clone,
deepEquals,
get,
has,
merge,
set,
unset,
} from '@/utils'
import { FormularioField } from '@/types'
import { Violation } from '@/validation/validator' import { Violation } from '@/validation/validator'
const update = (state: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> => {
if (value === undefined) {
return unset(state, path) as Record<string, unknown>
}
return set(state, path, value) as Record<string, unknown>
}
@Component({ name: 'FormularioForm' }) @Component({ name: 'FormularioForm' })
export default class FormularioForm extends Vue { export default class FormularioForm extends Vue {
@Model('input', { default: () => ({}) }) @Model('input', { default: () => ({}) })
public readonly formularioValue!: Record<string, any> public readonly state!: Record<string, unknown>
// Errors record, describing state validation errors of whole form @Prop({ default: () => id('formulario-form') })
@Prop({ default: () => ({}) }) readonly errors!: Record<string, any> public readonly id!: string
// Form errors only used on FormularioForm default slot
// Describes validation errors of whole form
@Prop({ default: () => ({}) }) readonly fieldsErrors!: Record<string, string[]>
// Only used on FormularioForm default slot
@Prop({ default: () => ([]) }) readonly formErrors!: string[] @Prop({ default: () => ([]) }) readonly formErrors!: string[]
@Provide() private proxy: Record<string, unknown> = {}
public path = '' private registry: Map<string, FormularioField> = new Map()
public proxy: Record<string, any> = {}
private registry: Registry = new Registry(this)
private errorObserverRegistry = new ErrorObserverRegistry()
// Local error messages are temporal, they wiped each resetValidation call // Local error messages are temporal, they wiped each resetValidation call
private localFieldsErrors: Record<string, string[]> = {}
private localFormErrors: string[] = [] private localFormErrors: string[] = []
private localFieldErrors: Record<string, string[]> = {}
get initialValues (): Record<string, any> { private get fieldsErrorsComputed (): Record<string, string[]> {
if (this.hasModel && typeof this.formularioValue === 'object') { return merge(this.fieldsErrors || {}, this.localFieldsErrors)
// If there is a v-model on the form/group, use those values as first priority
return { ...this.formularioValue } // @todo - use a deep clone to detach reference types
}
return {}
} }
get mergedFormErrors (): string[] { private get formErrorsComputed (): string[] {
return [...this.formErrors, ...this.localFormErrors] return [...this.formErrors, ...this.localFormErrors]
} }
get mergedFieldErrors (): Record<string, string[]> { @Provide('__FormularioForm_register')
return merge(this.errors || {}, this.localFieldErrors) private register (path: string, field: FormularioField): void {
} if (!this.registry.has(path)) {
this.registry.set(path, field)
}
get hasModel (): boolean { const value = get(this.proxy, path)
return has(this.$options.propsData || {}, 'formularioValue')
}
get hasInitialValue (): boolean { if (!field.hasModel) {
return this.formularioValue && typeof this.formularioValue === 'object' if (value !== undefined) {
} field.proxy = value
} else {
this.setFieldValue(path, null)
this.emitInput()
}
} else if (!deepEquals(field.proxy, value)) {
this.setFieldValue(path, field.proxy)
this.emitInput()
}
@Watch('formularioValue', { deep: true }) if (has(this.fieldsErrorsComputed, path)) {
onFormularioValueChanged (values: Record<string, any>): void { field.setErrors(this.fieldsErrorsComputed[path])
if (this.hasModel && values && typeof values === 'object') {
this.setValues(values)
} }
} }
@Watch('mergedFormErrors') @Provide('__FormularioForm_unregister')
onMergedFormErrorsChanged (errors: string[]): void { private unregister (path: string): void {
this.errorObserverRegistry.filter(o => o.type === 'form').observe(errors) if (this.registry.has(path)) {
this.registry.delete(path)
this.proxy = unset(this.proxy, path) as Record<string, unknown>
this.emitInput()
}
} }
@Watch('mergedFieldErrors', { deep: true, immediate: true }) @Provide('__FormularioForm_getState')
onMergedFieldErrorsChanged (errors: Record<string, string[]>): void { private getState (): Record<string, unknown> {
this.errorObserverRegistry.filter(o => o.type === 'input').observe(errors)
}
created (): void {
this.initProxy()
}
@Provide()
getFormValues (): Record<string, any> {
return this.proxy return this.proxy
} }
onFormSubmit (): Promise<void> { @Provide('__FormularioForm_set')
return this.hasValidationErrors() private setFieldValue (path: string, value: unknown): void {
.then(hasErrors => hasErrors ? undefined : clone(this.proxy)) this.proxy = update(this.proxy, path, value)
.then(data => {
if (typeof data !== 'undefined') {
this.$emit('submit', data)
} else {
this.$emit('error')
}
})
} }
@Provide() @Provide('__FormularioForm_emitInput')
onFormularioFieldValidation (payload: { name: string; violations: Violation[]}): void { private emitInput (): void {
this.$emit('validation', payload) this.$emit('input', clone(this.proxy))
} }
@Provide() @Provide('__FormularioForm_emitValidation')
addErrorObserver (observer: ErrorObserver): void { private emitValidation (path: string, violations: Violation[]): void {
this.errorObserverRegistry.add(observer) this.$emit('validation', { path, violations })
if (observer.type === 'form') {
observer.callback(this.mergedFormErrors)
} else if (observer.field && has(this.mergedFieldErrors, observer.field)) {
observer.callback(this.mergedFieldErrors[observer.field])
}
} }
@Provide() @Watch('state', { deep: true })
removeErrorObserver (observer: ErrorHandler): void { private onStateChange (newState: Record<string, unknown>): void {
this.errorObserverRegistry.remove(observer) const newProxy = clone(newState)
} const oldProxy = this.proxy
@Provide('formularioRegister')
register (field: string, component: FormularioInput): void {
this.registry.add(field, component)
}
@Provide('formularioDeregister')
deregister (field: string): void {
this.registry.remove(field)
}
initProxy (): void {
if (this.hasInitialValue) {
this.proxy = this.initialValues
}
}
setValues (values: Record<string, any>): void {
const keys = Array.from(new Set([...Object.keys(values), ...Object.keys(this.proxy)]))
let proxyHasChanges = false let proxyHasChanges = false
keys.forEach(field => {
if (!this.registry.hasNested(field)) { this.registry.forEach((field, path) => {
return const newValue = get(newState, path, null)
const oldValue = get(oldProxy, path, null)
field.proxy = newValue
if (!deepEquals(newValue, oldValue)) {
field.$emit('input', newValue)
update(newProxy, path, newValue)
proxyHasChanges = true
} }
this.registry.getNested(field).forEach((registryField, registryKey) => {
const $input = this.registry.get(registryKey) as FormularioInput
const oldValue = getNested(this.proxy, registryKey)
const newValue = getNested(values, registryKey)
if (!shallowEqualObjects(newValue, oldValue)) {
this.setFieldValue(registryKey, newValue)
proxyHasChanges = true
}
if (!shallowEqualObjects(newValue, $input.proxy)) {
$input.context.model = newValue
}
})
}) })
this.initProxy() this.proxy = newProxy
if (proxyHasChanges) { if (proxyHasChanges) {
this.$emit('input', { ...this.proxy }) this.emitInput()
} }
} }
setFieldValue (field: string, value: any): void { @Watch('fieldsErrorsComputed', { deep: true, immediate: true })
if (value === undefined) { private onFieldsErrorsChange (fieldsErrors: Record<string, string[]>): void {
// eslint-disable-next-line @typescript-eslint/no-unused-vars this.registry.forEach((field, path) => {
const { [field]: value, ...proxy } = this.proxy field.setErrors(fieldsErrors[path] || [])
this.proxy = proxy })
} else { }
setNested(this.proxy, field, value)
public created (): void {
this.$formulario.register(this.id, this)
if (typeof this.state === 'object') {
this.proxy = clone(this.state)
} }
} }
@Provide('formularioSetter') public beforeDestroy (): void {
setFieldValueAndEmit (field: string, value: any): void { this.$formulario.unregister(this.id)
this.setFieldValue(field, value)
this.$emit('input', { ...this.proxy })
} }
setErrors ({ formErrors, inputErrors }: { formErrors?: string[]; inputErrors?: Record<string, string[]> }): void { public runValidation (): Promise<Record<string, Violation[]>> {
const runs: Promise<void>[] = []
const violations: Record<string, Violation[]> = {}
this.registry.forEach((field, path) => {
runs.push(field.runValidation().then(v => { violations[path] = v }))
})
return Promise.all(runs).then(() => violations)
}
public hasValidationErrors (): Promise<boolean> {
return this.runValidation().then(violations => {
return Object.keys(violations).some(path => violations[path].length > 0)
})
}
public setErrors ({ fieldsErrors, formErrors }: {
fieldsErrors?: Record<string, string[]>;
formErrors?: string[];
}): void {
this.localFieldsErrors = fieldsErrors || {}
this.localFormErrors = formErrors || [] this.localFormErrors = formErrors || []
this.localFieldErrors = inputErrors || {}
} }
hasValidationErrors (): Promise<boolean> { public resetValidation (): void {
return Promise.all(this.registry.reduce((resolvers: Promise<boolean>[], input: FormularioInput) => { this.localFieldsErrors = {}
resolvers.push(input.runValidation() && input.hasValidationErrors())
return resolvers
}, [])).then(results => results.some(hasErrors => hasErrors))
}
resetValidation (): void {
this.localFormErrors = [] this.localFormErrors = []
this.localFieldErrors = {} this.registry.forEach((field: FormularioField) => {
this.registry.forEach((input: FormularioInput) => { field.resetValidation()
input.resetValidation() })
}
private onSubmit (): Promise<void> {
return this.runValidation().then(violations => {
const hasErrors = Object.keys(violations).some(path => violations[path].length > 0)
if (!hasErrors) {
this.$emit('submit', clone(this.proxy))
} else {
this.$emit('error', violations)
}
}) })
} }
} }

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}

View File

@ -1,14 +1,22 @@
import Formulario, { FormularioOptions } from '@/Formulario.ts'
import { VueConstructor } from 'vue' import { VueConstructor } from 'vue'
import Formulario, { FormularioOptions } from '@/Formulario.ts'
import FormularioField from '@/FormularioField.vue'
import FormularioFieldGroup from '@/FormularioFieldGroup.vue'
import FormularioForm from '@/FormularioForm.vue' import FormularioForm from '@/FormularioForm.vue'
import FormularioGrouping from '@/FormularioGrouping.vue'
import FormularioInput from '@/FormularioInput.vue'
export default { export default {
Formulario,
install (Vue: VueConstructor, options?: FormularioOptions): void { install (Vue: VueConstructor, options?: FormularioOptions): void {
Vue.component('FormularioField', FormularioField)
Vue.component('FormularioFieldGroup', FormularioFieldGroup)
Vue.component('FormularioForm', FormularioForm) Vue.component('FormularioForm', FormularioForm)
Vue.component('FormularioGrouping', FormularioGrouping)
Vue.component('FormularioInput', FormularioInput) // @deprecated Use FormularioField instead
Vue.component('FormularioInput', FormularioField)
// @deprecated Use FormularioFieldGroup instead
Vue.component('FormularioGrouping', FormularioFieldGroup)
Vue.mixin({ Vue.mixin({
beforeCreate () { beforeCreate () {

105
src/types.ts Normal file
View 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
View 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
}

View File

@ -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 []
}

View File

@ -1,28 +1,34 @@
import isScalar from '@/utils/isScalar' import { isRecordLike, isScalar } from '@/types'
import has from '@/utils/has'
const cloneInstance = <T>(original: T): T => {
return Object.assign(Object.create(Object.getPrototypeOf(original)), original)
}
/** /**
* A simple (somewhat non-comprehensive) clone function, valid for our use * A simple (somewhat non-comprehensive) clone function, valid for our use
* case of needing to unbind reactive watchers. * case of needing to unbind reactive watchers.
*/ */
export default function clone (value: any): any { export default function clone<T = unknown> (value: T): T {
if (typeof value !== 'object') { if (isScalar(value)) {
return value return value
} }
const copy: any | Record<string, any> = Array.isArray(value) ? [] : {} if (value instanceof Date) {
return new Date(value) as unknown as T
for (const key in value) {
if (has(value, key)) {
if (isScalar(value[key])) {
copy[key] = value[key]
} else if (value instanceof Date) {
copy[key] = new Date(copy[key])
} else {
copy[key] = clone(value[key])
}
}
} }
return copy if (!isRecordLike(value)) {
return cloneInstance(value)
}
if (Array.isArray(value)) {
return value.slice().map(clone) as unknown as T
}
const source: Record<string, unknown> = value as Record<string, unknown>
return Object.keys(source).reduce((copy, key) => ({
...copy,
[key]: clone(source[key])
}), {}) as unknown as T
} }

82
src/utils/compare.ts Normal file
View 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
View 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}`
}

View File

@ -1,71 +1,8 @@
export { default as arrayify } from './arrayify' export { default as id } from './id'
export { default as clone } from './clone' export { default as clone } from './clone'
export { default as has } from './has' export { default as has } from './has'
export { default as isScalar } from './isScalar'
export { default as merge } from './merge' export { default as merge } from './merge'
export { get, set, unset } from './access'
export { default as regexForFormat } from './regexForFormat' export { default as regexForFormat } from './regexForFormat'
export { default as shallowEqualObjects } from './shallowEqualObjects' export { deepEquals, shallowEquals } from './compare'
export { default as snakeToCamel } from './snakeToCamel' export { default as snakeToCamel } from './snakeToCamel'
export function getNested (obj: Record<string, any>, field: string): any {
const fieldParts = field.split('.')
let result: Record<string, any> = obj
for (const key in fieldParts) {
const matches = fieldParts[key].match(/(.+)\[(\d+)\]$/)
if (result === undefined) {
return null
}
if (matches) {
result = result[matches[1]]
if (result === undefined) {
return null
}
result = result[matches[2]]
} else {
result = result[fieldParts[key]]
}
}
return result
}
export function setNested (obj: Record<string, any>, field: string, value: any): void {
const fieldParts = field.split('.')
let subProxy: Record<string, any> = obj
for (let i = 0; i < fieldParts.length; i++) {
const fieldPart = fieldParts[i]
const matches = fieldPart.match(/(.+)\[(\d+)\]$/)
if (subProxy === undefined) {
break
}
if (matches) {
if (subProxy[matches[1]] === undefined) {
subProxy[matches[1]] = []
}
subProxy = subProxy[matches[1]]
if (i === fieldParts.length - 1) {
subProxy[matches[2]] = value
break
} else {
subProxy = subProxy[matches[2]]
}
} else {
if (i === fieldParts.length - 1) {
subProxy[fieldPart] = value
break
} else {
// eslint-disable-next-line max-depth
if (subProxy[fieldPart] === undefined) {
subProxy[fieldPart] = {}
}
subProxy = subProxy[fieldPart]
}
}
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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([])
}
}
})
}
}

View File

@ -1,5 +1,5 @@
import isUrl from 'is-url' import isUrl from 'is-url'
import { has, regexForFormat, shallowEqualObjects } from '@/utils' import { has, regexForFormat, shallowEquals } from '@/utils'
import { import {
ValidationContext, ValidationContext,
ValidationRuleFn, ValidationRuleFn,
@ -130,7 +130,7 @@ const rules: Record<string, ValidationRuleFn> = {
* Rule: Value is in an array (stack). * Rule: Value is in an array (stack).
*/ */
in ({ value }: ValidationContext, ...stack: any[]): boolean { in ({ value }: ValidationContext, ...stack: any[]): boolean {
return stack.some(item => typeof item === 'object' ? shallowEqualObjects(item, value) : item === value) return stack.some(item => shallowEquals(item, value))
}, },
/** /**
@ -198,7 +198,7 @@ const rules: Record<string, ValidationRuleFn> = {
* Rule: Value is not in stack. * Rule: Value is not in stack.
*/ */
not ({ value }: ValidationContext, ...stack: any[]): boolean { not ({ value }: ValidationContext, ...stack: any[]): boolean {
return !stack.some(item => typeof item === 'object' ? shallowEqualObjects(item, value) : item === value) return !stack.some(item => shallowEquals(item, value))
}, },
/** /**

View File

@ -5,10 +5,10 @@ export interface Validator {
} }
export interface Violation { export interface Violation {
message: string;
rule: string|null; rule: string|null;
args: any[]; args: any[];
context: ValidationContext|null; context: ValidationContext|null;
message: string;
} }
export interface ValidationRuleFn { export interface ValidationRuleFn {

View File

@ -1,26 +1,49 @@
<template> <template>
<FormularioForm v-model="values"> <FormularioForm v-model="values">
<h1>Address list</h1> <h1>Delivery</h1>
<FormularioInput <h3>Customer</h3>
<FormularioFieldGroup
name="customer"
class="row mx-n2"
>
<FormularioField
v-slot="{ context }"
name="name"
class="col col-auto px-2 mb-3"
>
<label for="customer-name">Name</label>
<input
id="customer-name"
v-model="context.model"
class="field form-control"
type="text"
@blur="context.runValidation"
>
</FormularioField>
</FormularioFieldGroup>
<h3>Address list</h3>
<FormularioField
v-slot="addressList" v-slot="addressList"
name="addressList" name="addressList"
> >
<FormularioGrouping name="addressList"> <FormularioFieldGroup name="addressList">
<FormularioGrouping <FormularioFieldGroup
v-for="(address, addressIndex) in addressList.context.model" v-for="(address, addressIndex) in addressList.context.model"
:key="'address-' + addressIndex" :key="'address-' + addressIndex"
:name="addressIndex" :name="addressIndex"
:is-array-item="true"
class="row mx-n2" class="row mx-n2"
> >
<FormularioInput <FormularioField
v-slot="{ context }" v-slot="{ context }"
class="col col-auto px-2 mb-3" class="col col-auto px-2 mb-3"
name="street" name="street"
validation="required" validation="required"
> >
<label for="address-street">Street <span class="text-danger">*</span></label> <label for="address-street">Street</label>
<input <input
id="address-street" id="address-street"
v-model="context.model" v-model="context.model"
@ -36,15 +59,15 @@
> >
{{ error }} {{ error }}
</div> </div>
</FormularioInput> </FormularioField>
<FormularioInput <FormularioField
v-slot="{ context }" v-slot="{ context }"
class="col col-auto px-2 mb-3" class="col col-auto px-2 mb-3"
name="building" name="building"
validation="^required|number" validation="^required|alphanumeric"
> >
<label for="address-building">Building <span class="text-danger">*</span></label> <label for="address-building">Building</label>
<input <input
id="address-building" id="address-building"
v-model="context.model" v-model="context.model"
@ -60,9 +83,19 @@
> >
{{ error }} {{ error }}
</div> </div>
</FormularioInput> </FormularioField>
</FormularioGrouping>
</FormularioGrouping> <div class="remove-btn-wrapper">
<button
class="btn btn-danger"
type="button"
@click="removeAddress(addressIndex)"
>
Remove
</button>
</div>
</FormularioFieldGroup>
</FormularioFieldGroup>
<button <button
class="btn btn-primary" class="btn btn-primary"
@ -71,34 +104,38 @@
> >
Add address Add address
</button> </button>
</FormularioInput> </FormularioField>
</FormularioForm> </FormularioForm>
</template> </template>
<script> <script>
import FormularioField from '@/FormularioField'
import FormularioFieldGroup from '@/FormularioFieldGroup'
import FormularioForm from '@/FormularioForm' import FormularioForm from '@/FormularioForm'
import FormularioGrouping from '@/FormularioGrouping'
import FormularioInput from '@/FormularioInput'
export default { export default {
name: 'ExampleAddressListTale', name: 'ExampleAddressListTale',
components: { components: {
FormularioField,
FormularioFieldGroup,
FormularioForm, FormularioForm,
FormularioGrouping,
FormularioInput,
}, },
data: () => ({ data: () => ({
values: { values: {
addressList: [], addressList: [{
street: 'Baker Street',
building: '221b',
}],
}, },
}), }),
created () { created () {
this.$formulario.extend({ this.$formulario.extend({
validationMessages: { validationMessages: {
number: () => 'Value is not a number', alphanumeric: () => 'Value must be alphanumeric',
number: () => 'Value must be a number',
required: () => 'Value is required', required: () => 'Value is required',
}, },
}) })
@ -111,6 +148,10 @@ export default {
building: '', building: '',
}) })
}, },
removeAddress (index) {
this.values.addressList.splice(index, 1)
},
}, },
} }
</script> </script>
@ -119,4 +160,8 @@ export default {
.field { .field {
max-width: 250px; max-width: 250px;
} }
.remove-btn-wrapper {
padding-top: 32px;
}
</style> </style>

View File

@ -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>

View File

@ -3,10 +3,9 @@ import './bootstrap.scss'
import { storiesOf } from '@storybook/vue' import { storiesOf } from '@storybook/vue'
import Vue from 'vue' import Vue from 'vue'
import VueFormulario from '../../dist/formulario.esm' import VueFormulario from '@/index.ts'
import ExampleAddressList from './ExampleAddressList.tale' import ExampleAddressList from './ExampleAddressList.tale'
import FormularioGrouping from './FormularioGrouping.tale'
Vue.mixin({ Vue.mixin({
methods: { methods: {
@ -22,4 +21,3 @@ Vue.use(VueFormulario)
storiesOf('Examples', module) storiesOf('Examples', module)
.add('Address list', () => ExampleAddressList) .add('Address list', () => ExampleAddressList)
.add('FormularioGrouping', () => FormularioGrouping)

View File

@ -3,60 +3,54 @@ import Formulario from '@/Formulario.ts'
import plugin from '@/index.ts' import plugin from '@/index.ts'
describe('Formulario', () => { describe('Formulario', () => {
it('Installs on vue instance', () => { it('installs on vue instance', () => {
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(plugin) localVue.use(plugin)
expect(localVue.component('FormularioField')).toBeTruthy()
expect(localVue.component('FormularioFieldGroup')).toBeTruthy()
expect(localVue.component('FormularioForm')).toBeTruthy() expect(localVue.component('FormularioForm')).toBeTruthy()
expect(localVue.component('FormularioGrouping')).toBeTruthy()
expect(localVue.component('FormularioInput')).toBeTruthy()
const wrapper = mount({ template: '<div />', }, { localVue }) const wrapper = mount({ render: h => h('div'), }, { localVue })
expect(wrapper.vm.$formulario).toBeInstanceOf(Formulario) expect(wrapper.vm.$formulario).toBeInstanceOf(Formulario)
}) })
it ('Pushes Formulario instance to child a component', () => { it ('pushes Formulario instance to child a component', () => {
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(plugin) localVue.use(plugin)
localVue.component('TestComponent', {
render (h) { const ChildComponent = localVue.component('ChildComponent', {
return h('div') render: h => h('div'),
}
}) })
const wrapper = mount({ const parent = mount({
render (h) { render: h => h('div', [h('ChildComponent')]),
return h('div', [h('TestComponent', { ref: 'test' })])
},
}, { localVue }) }, { localVue })
expect(wrapper.vm.$formulario === wrapper.vm.$refs.test.$formulario).toBe(true) const child = parent.findComponent(ChildComponent)
expect(parent.vm.$formulario === child.vm.$formulario).toBe(true)
}) })
it ('Does not pushes Formulario instance to a child component, if it has its own', () => { it ('does not push Formulario instance to a child component, if it has its own', () => {
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(plugin) localVue.use(plugin)
// noinspection JSCheckFunctionSignatures // noinspection JSCheckFunctionSignatures
localVue.component('TestComponent', { const ChildComponent = localVue.component('ChildComponent', {
formulario () { formulario: () => new Formulario(),
return new Formulario() render: h => h('div'),
},
render (h) {
return h('div')
},
}) })
const wrapper = mount({ const parent = mount({
render (h) { render: h => h('div', [h('ChildComponent')]),
return h('div', [h('TestComponent', { ref: 'test' })])
},
}, { localVue }) }, { localVue })
expect(wrapper.vm.$formulario === wrapper.vm.$refs.test.$formulario).toBe(false) const child = parent.findComponent(ChildComponent)
expect(parent.vm.$formulario === child.vm.$formulario).toBe(false)
}) })
}) })

View 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') }],
])
})
})

View 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)
})
})

View File

@ -1,6 +1,8 @@
import Vue from 'vue' import Vue from 'vue'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises' import flushPromises from 'flush-promises'
import Formulario from '@/index.ts' import Formulario from '@/index.ts'
import FormularioForm from '@/FormularioForm.vue' import FormularioForm from '@/FormularioForm.vue'
@ -13,442 +15,452 @@ Vue.use(Formulario, {
}) })
describe('FormularioForm', () => { describe('FormularioForm', () => {
it('render a form DOM element', () => { test('renders a form DOM element', () => {
const wrapper = mount(FormularioForm) const wrapper = mount(FormularioForm)
expect(wrapper.find('form').exists()).toBe(true) expect(wrapper.find('form').exists()).toBe(true)
}) })
it('accepts a default slot', () => { test('accepts a default slot', () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { slots: {
default: '<div class="default-slot-item" />' default: '<div data-default />'
} },
}) })
expect(wrapper.find('form div.default-slot-item').exists()).toBe(true)
expect(wrapper.find('form [data-default]').exists()).toBe(true)
}) })
it('Intercepts submit event', () => { test('can set a fields initial value', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { propsData: { state: { test: 'Has initial value' } },
default: '<button type="submit" />'
}
})
const spy = jest.spyOn(wrapper.vm, 'onFormSubmit')
wrapper.find('form').trigger('submit')
expect(spy).toHaveBeenCalled()
})
it('Adds subcomponents to the registry', () => {
const wrapper = mount(FormularioForm, {
propsData: { formularioValue: {} },
slots: { slots: {
default: ` default: `
<FormularioInput name="sub1" /> <FormularioField v-slot="{ context }" validation="required|in:bar" name="test" >
<FormularioInput name="sub2" />
`
}
})
expect(wrapper.vm['registry'].keys()).toEqual(['sub1', 'sub2'])
})
it('Removes subcomponents from the registry', async () => {
const wrapper = mount({
data: () => ({ active: true }),
template: `
<FormularioForm>
<FormularioInput v-if="active" name="sub1" />
<FormularioInput name="sub2" />
</FormularioForm>
`
})
await flushPromises()
expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub1', 'sub2'])
wrapper.setData({ active: false })
await flushPromises()
expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub2'])
})
it('Getting nested fields from registry', async () => {
const wrapper = mount({
data: () => ({ active: true, nested: { groups: { value: 'value' } }, groups: [{ name: 'group1' }, { name: 'group2' }] }),
template: `
<FormularioForm>
<FormularioInput name="sub1" />
<FormularioInput name="sub2" />
<FormularioInput name="nested.groups.value" />
<FormularioInput name="groups">
<FormularioGrouping :name="'groups[' + index + ']'" v-for="(item, index) in groups" :key="index">
<FormularioInput name="name" />
</FormularioGrouping>
</FormularioInput>
</FormularioForm>
`
})
await flushPromises()
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('sub1').keys())).toEqual(['sub1'])
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys()))
.toEqual(['groups', 'groups[0].name', 'groups[1].name'])
wrapper.setData({ active: true, groups: [{ name: 'group1' }] })
await flushPromises()
expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys()))
.toEqual(['groups', 'groups[0].name'])
})
it('Can set a fields initial value', async () => {
const wrapper = mount(FormularioForm, {
propsData: { formularioValue: { test: 'Has initial value' } },
slots: {
default: `
<FormularioInput v-slot="{ context }" validation="required|in:bar" name="test" >
<input v-model="context.model" type="text"> <input v-model="context.model" type="text">
</FormularioInput> </FormularioField>
` `
} }
}) })
await flushPromises() await flushPromises()
expect(wrapper.find('input').element['value']).toBe('Has initial value') expect(wrapper.find('input').element['value']).toBe('Has initial value')
}) })
it('Lets individual fields override form initial value', () => { describe('emits input event', () => {
test('when individual fields contain a populated value', async () => {
const wrapper = mount(FormularioForm, {
propsData: { state: { field: 'initial' } },
slots: {
default: '<FormularioField name="field" value="populated" />'
},
})
await Vue.nextTick()
const emitted = wrapper.emitted('input')
expect(emitted).toBeTruthy()
expect(emitted[emitted.length - 1]).toEqual([{ field: 'populated' }])
})
test('when individual fields are edited', () => {
const wrapper = mount(FormularioForm, {
propsData: { state: { field: 'initial' } },
slots: {
default: `
<FormularioField v-slot="{ context }" name="field" >
<input v-model="context.model" type="text">
</FormularioField>
`,
},
})
wrapper.find('input').setValue('updated')
const emitted = wrapper.emitted('input')
expect(emitted).toBeTruthy()
expect(emitted[emitted.length - 1]).toEqual([{ field: 'updated' }])
})
})
test('updates a field when the form v-model is modified', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
propsData: { formularioValue: { test: 'has initial value' } }, propsData: { state: { field: 'initial' } },
slots: { slots: {
default: ` default: `
<FormularioInput v-slot="{ context }" name="test" value="123"> <FormularioField v-slot="{ context }" name="field">
<input v-model="context.model" type="text"> <input v-model="context.model" type="text">
</FormularioInput> </FormularioField>
` `
}
})
expect(wrapper.find('input').element['value']).toBe('123')
})
it('Lets fields set form initial value with value prop', () => {
const wrapper = mount({
data: () => ({ values: {} }),
template: `
<FormularioForm v-model="values">
<FormularioInput name="test" value="123" />
</FormularioForm>
`
})
expect(wrapper.vm['values']).toEqual({ test: '123' })
})
it('Receives updates to form model when individual fields are edited', () => {
const wrapper = mount({
data: () => ({ values: { test: '' } }),
template: `
<FormularioForm v-model="values">
<FormularioInput v-slot="{ context }" name="test" >
<input v-model="context.model" type="text">
</FormularioInput>
</FormularioForm>
`
})
wrapper.find('input').setValue('Edited value')
expect(wrapper.vm['values']).toEqual({ test: 'Edited value' })
})
it('Field data updates when it is type of date', async () => {
const wrapper = mount({
data: () => ({ formValues: { date: new Date(123) } }),
template: `
<FormularioForm v-model="formValues" ref="form">
<FormularioInput v-slot="{ context }" name="date" >
<span v-if="context.model">{{ context.model.getTime() }}</span>
</FormularioInput>
</FormularioForm>
`
})
expect(wrapper.find('span').text()).toBe('123')
wrapper.setData({ formValues: { date: new Date(234) } })
await flushPromises()
expect(wrapper.find('span').text()).toBe('234')
})
it('Updates initial form values when input contains a populated v-model', async () => {
const wrapper = mount({
data: () => ({
formValues: { test: '' },
fieldValue: '123',
}),
template: `
<FormularioForm v-model="formValues">
<FormularioInput name="test" v-model="fieldValue" />
</FormularioForm>
`
})
await flushPromises()
expect(wrapper.vm['formValues']).toEqual({ test: '123' })
})
// Replacement test for the above test - not quite as good of a test.
it('Updates calls setFieldValue on form when a field contains a populated v-model on registration', () => {
const wrapper = mount(FormularioForm, {
propsData: {
formularioValue: { test: 'Initial' }
},
slots: {
default: '<FormularioInput name="test" value="Overrides" />'
}, },
}) })
const emitted = wrapper.emitted('input') const input = wrapper.find('input')
expect(emitted).toBeTruthy()
expect(emitted[emitted.length - 1]).toEqual([{ test: 'Overrides' }])
})
it('updates an inputs value when the form v-model is modified', async () => {
const wrapper = mount({
data: () => ({ values: { test: 'abcd' } }),
template: `
<FormularioForm v-model="values">
<FormularioInput v-slot="{ context }" name="test" >
<input v-model="context.model" type="text">
</FormularioInput>
</FormularioForm>
`
})
wrapper.vm.values = { test: '1234' }
await flushPromises()
const input = wrapper.find('input[type="text"]')
expect(input).toBeTruthy() expect(input).toBeTruthy()
expect(input.element['value']).toBe('1234') expect(input.element['value']).toBe('initial')
wrapper.setProps({ state: { field: 'updated' } })
await Vue.nextTick()
expect(input.element['value']).toBe('updated')
}) })
it('Resolves hasValidationErrors to true', async () => { test('updates a field when it is an instance of Date', async () => {
const wrapper = mount(FormularioForm, { const dateA = new Date('1970-01-01')
slots: { default: '<FormularioInput name="fieldName" validation="required" />' } const dateB = new Date()
const wrapper = mount(FormularioForm,{
propsData: { state: { date: dateA } },
scopedSlots: {
default: `
<FormularioField v-slot="{ context }" name="date">
<span v-if="context.model">{{ context.model.toISOString() }}</span>
</FormularioField>
`,
},
}) })
expect(wrapper.find('span').text()).toBe(dateA.toISOString())
wrapper.setProps({ state: { date: dateB } })
await Vue.nextTick()
expect(wrapper.find('span').text()).toBe(dateB.toISOString())
})
test('resolves submitted form values to an object', async () => {
const wrapper = mount(FormularioForm, {
slots: {
default: '<FormularioField name="name" validation="required" value="Justin" />'
},
})
wrapper.find('form').trigger('submit') wrapper.find('form').trigger('submit')
await flushPromises()
const emitted = wrapper.emitted()
expect(emitted['error']).toBeTruthy()
expect(emitted['error'].length).toBe(1)
})
it('Resolves submitted form values to an object', async () => {
const wrapper = mount(FormularioForm, {
slots: { default: '<FormularioInput name="fieldName" validation="required" value="Justin" />' }
})
wrapper.find('form').trigger('submit')
await flushPromises()
const emitted = wrapper.emitted()
expect(emitted['submit']).toBeTruthy()
expect(emitted['submit'].length).toBe(1)
expect(emitted['submit'][0]).toEqual([{ fieldName: 'Justin' }])
})
it('Receives a form-errors prop and displays it', async () => {
const wrapper = mount(FormularioForm, {
propsData: { formErrors: ['first', 'second'] },
})
await flushPromises()
expect(wrapper.vm.mergedFormErrors.length).toBe(2)
})
it('Aggregates form-errors prop with form-named errors', async () => {
const wrapper = mount(FormularioForm, {
propsData: { formErrors: ['first', 'second'] }
})
wrapper.vm.setErrors({ formErrors: ['third'] })
await flushPromises() await flushPromises()
expect(Object.keys(wrapper.vm.mergedFormErrors).length).toBe(3) expect(wrapper.emitted('submit')).toEqual([
[{ name: 'Justin' }],
])
}) })
it('displays field errors on inputs with errors prop', async () => { test('resolves runValidation', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
propsData: { errors: { fieldWithErrors: ['This field has an error'] }},
slots: { slots: {
default: ` default: `
<FormularioInput v-slot="{ context }" name="fieldWithErrors"> <div>
<span v-for="error in context.errors">{{ error }}</span> <FormularioField name="address.street" validation="required" />
</FormularioInput> <FormularioField name="address.building" validation="required" />
` </div>
} `,
},
})
const violations = await wrapper.vm.runValidation()
const state = {
address: {
street: null,
building: null,
},
}
expect(violations).toEqual({
'address.street': [{
message: expect.any(String),
rule: 'required',
args: [],
context: {
name: 'address.street',
value: '',
formValues: state,
},
}],
'address.building': [{
message: expect.any(String),
rule: 'required',
args: [],
context: {
name: 'address.building',
value: '',
formValues: state,
},
}],
})
})
test('resolves runValidation via $formulario', async () => {
const wrapper = mount(FormularioForm, {
propsData: {
id: 'address',
},
slots: {
default: `
<div>
<FormularioField name="address.street" validation="required" />
<FormularioField name="address.building" validation="required" />
</div>
`,
},
})
const violations = await wrapper.vm.$formulario.runValidation('address')
const state = {
address: {
street: null,
building: null,
},
}
expect(violations).toEqual({
'address.street': [{
message: expect.any(String),
rule: 'required',
args: [],
context: {
name: 'address.street',
value: '',
formValues: state,
},
}],
'address.building': [{
message: expect.any(String),
rule: 'required',
args: [],
context: {
name: 'address.building',
value: '',
formValues: state,
},
}],
})
})
test('resolves hasValidationErrors to true', async () => {
const wrapper = mount(FormularioForm, {
slots: {
default: '<FormularioField name="fieldName" validation="required" />',
},
})
wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.emitted('error')).toBeTruthy()
expect(wrapper.emitted('error').length).toBe(1)
})
describe('allows setting fields errors', () => {
/**
* @param props
* @return {Wrapper<FormularioForm>}
*/
const createWrapper = (props = {}) => mount(FormularioForm, {
propsData: props,
scopedSlots: {
default: '<div><div v-for="error in props.errors" data-error /></div>',
},
})
test('via prop', async () => {
const wrapper = createWrapper({ formErrors: ['first', 'second'] })
expect(wrapper.findAll('[data-error]').length).toBe(2)
})
test('manually with setErrors()', async () => {
const wrapper = createWrapper({ formErrors: ['first', 'second'] })
wrapper.vm.setErrors({ formErrors: ['third'] })
await wrapper.vm.$nextTick()
expect(wrapper.findAll('[data-error]').length).toBe(3)
})
})
test('displays field errors on inputs with errors prop', async () => {
const wrapper = mount(FormularioForm, {
propsData: { fieldsErrors: { field: ['This field has an error'] }},
slots: {
default: `
<FormularioField v-slot="{ context }" name="field">
<span v-for="error in context.errors">{{ error }}</span>
</FormularioField>
`,
},
}) })
await wrapper.vm.$nextTick()
expect(wrapper.find('span').exists()).toBe(true) expect(wrapper.find('span').exists()).toBe(true)
expect(wrapper.find('span').text()).toEqual('This field has an error') expect(wrapper.find('span').text()).toEqual('This field has an error')
}) })
it('Is able to display multiple errors on multiple elements', async () => { describe('allows setting fields errors', () => {
const errors = { inputA: ['first'], inputB: ['first', 'second']} /**
const wrapper = mount(FormularioForm, { propsData: { errors } }) * @param props
* @return {Wrapper<FormularioForm>}
await wrapper.vm.$nextTick() */
const createWrapper = (props = {}) => mount(FormularioForm, {
expect(Object.keys(wrapper.vm.mergedFieldErrors).length).toBe(2) propsData: props,
expect(wrapper.vm.mergedFieldErrors.inputA.length).toBe(1)
expect(wrapper.vm.mergedFieldErrors.inputB.length).toBe(2)
})
it('Can set multiple field errors with setErrors()', async () => {
const wrapper = mount(FormularioForm)
expect(Object.keys(wrapper.vm.mergedFieldErrors).length).toBe(0)
wrapper.vm.setErrors({
inputErrors: {
inputA: ['first'],
inputB: ['first', 'second'],
}
})
await wrapper.vm.$nextTick()
await flushPromises()
expect(Object.keys(wrapper.vm.mergedFieldErrors).length).toBe(2)
expect(wrapper.vm.mergedFieldErrors.inputA.length).toBe(1)
expect(wrapper.vm.mergedFieldErrors.inputB.length).toBe(2)
})
it('Emits correct validation event when no errors', async () => {
const wrapper = mount(FormularioForm, {
slots: { slots: {
default: ` default: `
<FormularioInput v-slot="{ context }" name="foo" validation="required|in:foo"> <div>
<input v-model="context.model" type="text" @blur="context.runValidation()"> <FormularioField v-slot="{ context }" name="fieldA">
</FormularioInput> <div v-for="error in context.errors" data-error-a>{{ error }}</div>
<FormularioInput name="bar" validation="required" /> </FormularioField>
<FormularioField v-slot="{ context }" name="fieldB">
<div v-for="error in context.errors" data-error-b>{{ error }}</div>
</FormularioField>
</div>
`, `,
} }
}) })
wrapper.find('input[type="text"]').setValue('foo')
wrapper.find('input[type="text"]').trigger('blur')
await flushPromises() test('via prop', async () => {
const wrapper = createWrapper({
fieldsErrors: { fieldA: ['first'], fieldB: ['first', 'second']},
})
expect(wrapper.emitted('validation')).toBeTruthy() expect(wrapper.findAll('[data-error-a]').length).toBe(1)
expect(wrapper.emitted('validation').length).toBe(1) expect(wrapper.findAll('[data-error-b]').length).toBe(2)
expect(wrapper.emitted('validation')[0][0]).toEqual({ })
name: 'foo',
violations: [], test('manually with setErrors()', async () => {
const wrapper = createWrapper()
expect(wrapper.findAll('[data-error-a]').length).toBe(0)
expect(wrapper.findAll('[data-error-b]').length).toBe(0)
wrapper.vm.setErrors({ fieldsErrors: { fieldA: ['first'], fieldB: ['first', 'second'] } })
await Vue.nextTick()
expect(wrapper.findAll('[data-error-a]').length).toBe(1)
expect(wrapper.findAll('[data-error-b]').length).toBe(2)
}) })
}) })
it('Emits correct validation event on entry', async () => { describe('emits correct validation event', () => {
/**
* @return {Wrapper<FormularioForm>}
*/
const createWrapper = () => mount(FormularioForm, {
slots: {
default: `
<FormularioField v-slot="{ context }" name="foo" validation="required|in:foo">
<input v-model="context.model" type="text" @blur="context.runValidation()">
</FormularioField>
<FormularioField name="bar" validation="required" />
`,
},
})
test('when no errors', async () => {
const wrapper = createWrapper()
wrapper.find('input[type="text"]').setValue('foo')
wrapper.find('input[type="text"]').trigger('blur')
await flushPromises()
expect(wrapper.emitted('validation')).toBeTruthy()
expect(wrapper.emitted('validation')).toEqual([[{
path: 'foo',
violations: [],
}]])
})
test('on entry', async () => {
const wrapper = createWrapper()
wrapper.find('input[type="text"]').setValue('bar')
wrapper.find('input[type="text"]').trigger('blur')
await flushPromises()
expect(wrapper.emitted('validation')).toBeTruthy()
expect(wrapper.emitted('validation')).toEqual([[ {
path: 'foo',
violations: [ {
rule: expect.any(String),
args: ['foo'],
context: {
value: 'bar',
formValues: expect.any(Object),
name: 'foo',
},
message: expect.any(String),
} ],
} ]])
})
})
test('allows resetting form validation', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { default: ` slots: {
<FormularioInput default: `
v-slot="{ context }" <div>
name="firstField" <FormularioField v-slot="{ context }" name="username" validation="required">
validation="required|in:foo" <input v-model="context.model" type="text">
> <div v-for="error in context.allErrors" data-username-error />
<input </FormularioField>
v-model="context.model"
type="text"
@blur="context.runValidation()"
>
</FormularioInput>
<FormularioInput
name="secondField"
validation="required"
/>
` }
})
wrapper.find('input[type="text"]').setValue('bar')
wrapper.find('input[type="text"]').trigger('blur')
await flushPromises() <FormularioField v-slot="{ context }" name="password" validation="required|min:4,length">
<input v-model="context.model" type="password" @blur="context.runValidation()">
expect(wrapper.emitted('validation')).toBeTruthy() <div v-for="error in context.allErrors" data-password-error />
expect(wrapper.emitted('validation').length).toBe(1) </FormularioField>
expect(wrapper.emitted('validation')[0][0]).toEqual({ </div>
name: 'firstField', `,
violations: [ { },
rule: expect.any(String),
args: ['foo'],
context: {
value: 'bar',
formValues: expect.any(Object),
name: 'firstField',
},
message: expect.any(String),
} ],
})
})
it('Allows resetting a form, wiping validation.', async () => {
const wrapper = mount({
data: () => ({ values: {} }),
template: `
<FormularioForm
v-model="values"
name="login"
ref="form"
>
<FormularioInput v-slot="{ context }" name="username" validation="required">
<input v-model="context.model" type="text">
</FormularioInput>
<FormularioInput v-slot="{ context }" name="password" validation="required|min:4,length">
<input v-model="context.model" type="password">
</FormularioInput>
</FormularioForm>
`,
}) })
const password = wrapper.find('input[type="password"]') const password = wrapper.find('input[type="password"]')
password.setValue('foo') password.setValue('foo')
password.trigger('input')
password.trigger('blur') password.trigger('blur')
wrapper.find('form').trigger('submit')
wrapper.vm.$refs.form.setErrors({ inputErrors: { username: ['Failed'] } })
await flushPromises() await flushPromises()
// First make sure we caught the errors wrapper.vm.setErrors({ fieldsErrors: { username: ['required'] } })
expect(Object.keys(wrapper.vm.$refs.form.mergedFieldErrors).length).toBe(1)
wrapper.vm.$refs.form.resetValidation()
await flushPromises() await Vue.nextTick()
expect(Object.keys(wrapper.vm.$refs.form.mergedFieldErrors).length).toBe(0) expect(wrapper.findAll('[data-username-error]').length).toBe(1)
expect(wrapper.findAll('[data-password-error]').length).toBe(1)
wrapper.vm.resetValidation()
await Vue.nextTick()
expect(wrapper.findAll('[data-username-error]').length).toBe(0)
expect(wrapper.findAll('[data-password-error]').length).toBe(0)
}) })
it('Local errors resetted when errors prop cleared', async () => { test('local errors are reset when errors prop cleared', async () => {
const wrapper = mount({ const wrapper = mount(FormularioForm, {
data: () => ({ values: {}, errors: { input: ['failure'] } }), propsData: { fieldsErrors: { input: ['failure'] } },
template: ` slots: {
<FormularioForm default: `
v-model="values" <FormularioField v-slot="{ context }" name="input">
:errors="errors"
ref="form"
>
<FormularioInput
v-slot="{ context }"
name="input"
ref="form"
>
<span v-for="error in context.allErrors">{{ error.message }}</span> <span v-for="error in context.allErrors">{{ error.message }}</span>
</FormularioInput> </FormularioField>
</FormularioForm> `,
` },
}) })
await flushPromises()
expect(wrapper.find('span').exists()).toBe(true) expect(wrapper.find('span').exists()).toBe(true)
wrapper.vm.errors = {} wrapper.setProps({ fieldsErrors: {} })
await flushPromises()
await Vue.nextTick()
expect(wrapper.find('span').exists()).toBe(false) expect(wrapper.find('span').exists()).toBe(false)
}) })
}) })

View File

@ -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)
})
})

View File

@ -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
View 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)
})
})
})

View 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')
})
})
})

View File

@ -1,28 +1,79 @@
import clone from '@/utils/clone' import clone from '@/utils/clone'
class Sample {
constructor() {
this.fieldA = 'fieldA'
this.fieldB = 'fieldB'
}
doSomething () {}
}
describe('clone', () => { describe('clone', () => {
it('Basic objects stay the same', () => { test.each([
const obj = { a: 123, b: 'hello' } [{ a: 123, b: 'hello' }],
expect(clone(obj)).toEqual(obj) [{ a: 123, b: { c: 'hello-world' } }],
[{
id: 123,
addresses: [{
street: 'Baker Street',
building: '221b',
}],
}],
])('recreates object, preserving its structure', state => {
expect(clone(state)).toEqual(state)
expect(clone({ ref: state }).ref === state).toBe(false)
}) })
it('Basic nested objects stay the same', () => { test('retains array structures inside of a pojo', () => {
const obj = { a: 123, b: { c: 'hello-world' } } const obj = { a: 'abc', d: ['first', 'second'] }
expect(clone(obj)).toEqual(obj)
})
it('Simple pojo reference types are re-created', () => {
const c = { c: 'hello-world' }
expect(clone({ a: 123, b: c }).b === c).toBe(false)
})
it('Retains array structures inside of a pojo', () => {
const obj = { a: 'abcd', d: ['first', 'second'] }
expect(Array.isArray(clone(obj).d)).toBe(true) expect(Array.isArray(clone(obj).d)).toBe(true)
}) })
it('Removes references inside array structures', () => { test('removes references inside array structures', () => {
const obj = { a: 'abcd', d: ['first', { foo: 'bar' }] } const obj = { a: 'abc', d: ['first', { foo: 'bar' }] }
expect(clone(obj).d[1] === obj.d[1]).toBe(false) expect(clone(obj).d[1] === obj.d[1]).toBe(false)
}) })
test('creates a copy of a date', () => {
const date = new Date()
const copy = clone(date)
expect(date === copy).toBeFalsy()
expect(copy.toISOString()).toStrictEqual(date.toISOString())
})
test('creates a copy of a nested date', () => {
const date = new Date()
const copy = clone({ date })
expect(date === copy.date).toBeFalsy()
expect(copy.date.toISOString()).toStrictEqual(date.toISOString())
})
test('creates a copy of a class instance', () => {
const sample = new Sample()
const copy = clone(sample)
expect(sample === copy).toBeFalsy()
expect(copy).toBeInstanceOf(Sample)
expect(copy.fieldA).toEqual('fieldA')
expect(copy.fieldB).toEqual('fieldB')
expect(copy.doSomething).toBeTruthy()
expect(copy.doSomething).not.toThrow()
})
test('creates a copy of a nested class instance', () => {
const sample = new Sample()
const copy = clone({ sample })
expect(sample === copy.sample).toBeFalsy()
expect(copy.sample).toBeInstanceOf(Sample)
expect(copy.sample.fieldA).toEqual('fieldA')
expect(copy.sample.fieldB).toEqual('fieldB')
expect(copy.sample.doSomething).toBeTruthy()
expect(copy.sample.doSomething).not.toThrow()
})
}) })

View 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)
})
})
})

View File

@ -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))
})

View File

@ -1,79 +1,73 @@
import regexForFormat from '@/utils/regexForFormat' import regexForFormat from '@/utils/regexForFormat'
describe('regexForFormat', () => { describe('regexForFormat', () => {
it('Allows MM format with other characters', () => { test('allows MM format with other characters', () => {
expect(regexForFormat('abc/MM').test('abc/01')).toBe(true) expect(regexForFormat('abc/MM').test('abc/01')).toBe(true)
}) })
it('Fails MM format with single digit', () => { test('fails MM format with single digit', () => {
expect(regexForFormat('abc/MM').test('abc/1')).toBe(false) expect(regexForFormat('abc/MM').test('abc/1')).toBe(false)
}) })
it('Allows M format with single digit', () => { test('allows M format with single digit', () => {
expect(regexForFormat('M/abc').test('1/abc')).toBe(true) expect(regexForFormat('M/abc').test('1/abc')).toBe(true)
}) })
it('Fails MM format when out of range', () => { test.each([
expect(regexForFormat('M/abc').test('13/abc')).toBe(false) ['13/abc'],
}) ['55/abc'],
])('fails M format when out of range', (string) => {
expect(regexForFormat('M/abc').test(string)).toBe(false)
})
it('Fails M format when out of range', () => { test('replaces double digits before singles', () => {
expect(regexForFormat('M/abc').test('55/abc')).toBe(false) expect(regexForFormat('MMM').test('313131')).toBe(false)
}) })
it('Replaces double digits before singles', () => { test('allows DD format with zero digit', () => {
expect(regexForFormat('MMM').test('313131')).toBe(false) const regex = regexForFormat('xyz/DD')
})
it('Allows DD format with zero digit', () => { expect(regex.test('xyz/01')).toBe(true)
expect(regexForFormat('xyz/DD').test('xyz/01')).toBe(true) expect(regex.test('xyz/9')).toBe(false)
}) })
it('Fails DD format with single digit', () => { test('allows D format with single digit', () => {
expect(regexForFormat('xyz/DD').test('xyz/9')).toBe(false) expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true)
}) })
it('Allows D format with single digit', () => { test.each([
expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true) ['xyz/92'],
}) ['xyz/32'],
])('fails D format with out of range digit', string => {
expect(regexForFormat('xyz/D').test(string)).toBe(false)
})
it('Fails D format with out of range digit', () => { test.each([
expect(regexForFormat('xyz/D').test('xyz/92')).toBe(false) ['00', true],
}) ['0000', false],
])('allows YY format', (string, matches) => {
expect(regexForFormat('YY').test(string)).toBe(matches)
})
it('Fails DD format with out of range digit', () => { test('allows YYYY format with four zeros', () => {
expect(regexForFormat('xyz/D').test('xyz/32')).toBe(false) expect(regexForFormat('YYYY').test('0000')).toBe(true)
}) })
it('Allows YY format with double zeros', () => { test.each([
expect(regexForFormat('YY').test('00')).toBe(true) ['MD-YY', '12-00'],
}) ['DM-YY', '12-00'],
])('allows $format', (format, string) => {
expect(regexForFormat(format).test(string)).toBe(true)
})
it('Fails YY format with four zeros', () => { test.each([
expect(regexForFormat('YY').test('0000')).toBe(false) ['MM/DD/YYYY', '12/18/1987'],
}) ['YYYY-MM-DD', '1987-01-31']
])('$date matches $format', (format, date) => {
expect(regexForFormat(format).test(date)).toBe(true)
})
it('Allows YYYY format with four zeros', () => { test('Fails date like YYYY-MM-DD with out of bounds day', () => {
expect(regexForFormat('YYYY').test('0000')).toBe(true) expect(regexForFormat('YYYY-MM-DD').test('1987-01-32')).toBe(false)
}) })
it('Allows MD-YY', () => {
expect(regexForFormat('MD-YY').test('12-00')).toBe(true)
})
it('Allows DM-YY', () => {
expect(regexForFormat('DM-YY').test('12-00')).toBe(true)
})
it('Allows date like MM/DD/YYYY', () => {
expect(regexForFormat('MM/DD/YYYY').test('12/18/1987')).toBe(true)
})
it('Allows date like YYYY-MM-DD', () => {
expect(regexForFormat('YYYY-MM-DD').test('1987-01-31')).toBe(true)
})
it('Fails date like YYYY-MM-DD with out of bounds day', () => {
expect(regexForFormat('YYYY-MM-DD').test('1987-01-32')).toBe(false)
})
}) })

View File

@ -1,27 +1,22 @@
import snakeToCamel from '@/utils/snakeToCamel' import snakeToCamel from '@/utils/snakeToCamel'
describe('snakeToCamel', () => { describe('snakeToCamel', () => {
it('Converts underscore separated words to camelCase', () => { test.each([
expect(snakeToCamel('this_is_snake_case')).toBe('thisIsSnakeCase') ['this_is_snake_case', 'thisIsSnakeCase'],
['this_is_snake_case_2nd_example', 'thisIsSnakeCase2ndExample'],
])('converts snake_case to camelCase', (raw, expected) => {
expect(snakeToCamel(raw)).toBe(expected)
}) })
it('Converts underscore separated words to camelCase even if they start with a number', () => { test('Does not capitalize the first word or strip first underscore if a phrase starts with an underscore', () => {
expect(snakeToCamel('this_is_snake_case_2nd_example')).toBe('thisIsSnakeCase2ndExample')
})
it('Has no effect on already camelCase words', () => {
expect(snakeToCamel('thisIsCamelCase')).toBe('thisIsCamelCase')
})
it('Does not capitalize the first word or strip first underscore if a phrase starts with an underscore', () => {
expect(snakeToCamel('_this_starts_with_an_underscore')).toBe('_thisStartsWithAnUnderscore') expect(snakeToCamel('_this_starts_with_an_underscore')).toBe('_thisStartsWithAnUnderscore')
}) })
it('Ignores double underscores anywhere in a word', () => { test.each([
expect(snakeToCamel('__unlikely__thing__')).toBe('__unlikely__thing__') ['thisIsCamelCase'],
}) ['__double__underscores__'],
['this-is-kebab-case'],
it('Has no effect hyphenated words', () => { ])('has no effect', (raw) => {
expect(snakeToCamel('not-a-good-name')).toBe('not-a-good-name') expect(snakeToCamel(raw)).toBe(raw)
}) })
}) })

833
yarn.lock

File diff suppressed because it is too large Load Diff