I've tried many ways to wrap an <input> with support for v-model and v-validate. The only way I found is:
<template>
<div>
<label>{{label}}</label>
<input type="text" v-model="innerValue" :name="name" v-validate="constraints">
<template v-if="constraints !== ''">
<span class="validation-status validation-status--invalid" v-if="errors.has(name)">❌</span>
<span class="validation-status validation-status--valid" v-else>✅</span>
</template>
</div>
</template>
<script>
export default {
props: {
name: {
required: true
},
value: {
required: true
},
label: String,
constraints: {
type: String,
default: ''
}
},
data () {
return {
innerValue: null
}
},
created () {
this.innerValue = this.value
},
watch: {
innerValue (newVal) {
this.$emit('input', newVal)
},
value (newVal) {
this.innerValue = newVal
}
}
}
</script>
Any other way would cause errors from vee-validate.
This works fine but it looks like a lot of code. Is there a better way to create such a component ?
For instance, this simpler implementation raises an error:
TypeError: Cannot set property '_error' of undefined
<template>
<div>
<label>{{label}}</label>
<input type="text" :value="innerValue" @input="$emit('input', $event.target.value)" :name="name" v-validate="constraints">
<template v-if="constraints !== ''">
<span class="validation-status validation-status--invalid" v-if="errors.has(name)">❌</span>
<span class="validation-status validation-status--valid" v-else>✅</span>
</template>
</div>
</template>
<script>
export default {
props: {
name: {
required: true
},
value: {
required: true
},
label: String,
constraints: {
type: String,
default: ''
}
},
computed: {
innerValue () {
return this.value
}
}
}
</script>
There is no "correct" way to do it, but I generally prefer to create input components that "work" with vee-validate, for example this is a component we use in some of our projects:
<template lang="pug">
div
label.add-mb1.u-block {{ label }}
.input.is-rounded.add-mb2(:class="{ 'has-error': error, 'is-large': size === 'large', 'is-small': size === 'small' }")
input(:type="type" ref="input" :name="name" :value="value" @blur="$emit('blur', $event.target.value)" @input="$emit('input', $event.target.value)" @change="$emit('change', $event.target.value)")
span.input-message {{ error }}
</template>
<script>
export default {
name: 'dk-input',
$_veeValidate: {
value () {
return this.$refs.input.value;
},
name () {
return this.name;
},
alias () {
return this.label;
},
events: 'change|blur'
},
props: {
label: String,
name: {
type: String,
required: true
},
value: [Number, String],
error: String,
success: Boolean,
size: {
type: String,
default: null,
validator: (value) => {
if (!value) return true;
return ['small', 'large'].includes(value);
}
},
type: {
type: String,
default: 'text',
validator: (value) => {
return ['url', 'text', 'tel', 'password', 'email', 'number'].includes(value);
}
}
}
};
</script>
As you can see, there is no need for the innerValue here, but that means the component doesn't know its state, and any errors or state must be passed to it like the error property:
dk-input(
name="firstName"
label="First Name"
data-vv-as="First Name"
v-model="user.firstName"
v-validate="'required'"
:error="errors.first('firstName')"
)
What you are doing is creating a "self-validating" component, which while is nice, could produce some state issues depending on the implementation like you've noticed.
I would like to add more improvements to the custom components validation, like the ability to inject the field state into it if its being validated by vee-validate which should make it easier to author components.
Thank you for your answer. I'll keep my self-validating flavor at the moment and will try to update this thread with my findings later on in my project.
Great, looking forward to it 😄
EDIT:
Let me know if you need any help
It should be noted the error I reported above actually comes from vue-test-utils and not from vee-validate as I originally thought.
Could you post the snippet of the test you are doing? You can take a look at tests/integration which has some examples for validating custom components.
Here's the (slightly changed) TextField component:
<template>
<div>
<label>{{label}} <span v-if="language">[{{language}}]</span></label>
<input type="text" v-model="innerValue" :name="name" v-validate="constraints" data-vv-validate-on="blur">
<template v-if="constraints !== '' && dirty">
<span class="validation-status validation-status--invalid" v-if="errors.has(name)">❌</span>
<span class="validation-status validation-status--valid" v-else>✅</span>
</template>
</div>
</template>
<script>
export default {
inject: ['$validator'],
props: {
name: {
required: true
},
value: {
required: true
},
label: String,
constraints: {
type: String,
default: ''
},
language: String
},
data () {
return {
innerValue: null
}
},
created () {
this.innerValue = this.value
},
watch: {
innerValue (newVal) {
this.$emit('input', newVal)
},
value (newVal) {
this.innerValue = newVal
}
},
computed: {
dirty () {
const flags = this.$validator.flags[this.name]
return flags && (flags.touched || flags.validated)
}
}
}
</script>
And the test file:
import Vue from 'vue'
import { mount } from 'vue-test-utils'
import FieldText from '@/components/fields/Text'
import VeeValidate, {Validator} from 'vee-validate'
Vue.use(VeeValidate)
describe('Text.vue', () => {
describe('Basics', () => {
let subjectValue = 'My pants are magic'
const wrapper = mount(FieldText, {
propsData: {
label: 'Magic pants',
name: 'pants',
value: subjectValue
},
provide: {
'$validator': new Validator()
}
})
it('should exist', () => {
expect(FieldText).toBeTruthy()
})
it('should render an input', () => {
expect(wrapper.contains('input[type="text"]')).toBe(true)
})
it('should render an input with correct value', () => {
expect(wrapper.find('input[type="text"]').element.value).toBe(subjectValue)
})
describe('should implement v-model', () => {
// value goes down...
it('updates the DOM value when value changes', () => {
expect(wrapper.find('input[type="text"]').element.value).toBe(subjectValue)
const newSubjectValue = 'Harry Potter stole my pants'
wrapper.setProps({
value: newSubjectValue
})
expect(wrapper.find('input[type="text"]').element.value).toBe(newSubjectValue)
})
// ...@input goes up
it('triggers `input` when DOM changes', () => {
const field = wrapper.find('input[type="text"]')
const newVal = 'Mouhaha, as a user i can type'
field.element.value = newVal
field.trigger('input')
expect(wrapper.emitted().input.pop()).toEqual([newVal])
})
})
})
describe('vee-validate', () => {
let subjectValue = 'My pants are magic'
const validator = new Validator({pants: 'required'})
const wrapper = mount(FieldText, {
propsData: {
label: 'Magic pants',
name: 'pants',
value: subjectValue,
constraints: 'required'
},
provide: {
'$validator': validator
}
})
it('should not show validation status initially', () => {
expect(wrapper.contains('.validation-status')).toBe(false)
})
it('should show validation status after blur', () => {
const field = wrapper.find('input[type="text"]')
field.element.value = 'spongebob'
field.trigger('input')
field.trigger('blur')
expect(wrapper.findAll('.validation-status.validation-status--valid').length).toBe(1)
})
it('should show error when value is invalid', (done) => {
const field = wrapper.find('input[type="text"]')
const newVal = ''
field.element.value = newVal
field.trigger('input')
field.trigger('blur')
Vue.nextTick(() => {
expect(wrapper.contains('.validation-status--invalid')).toBe(true)
done()
})
})
})
})
You may need to use flush-promises like in the /tests since validations are asynchronous and the tests are synchronous, nextTick isn't necessary with vue-test-utils
This is my version. I disable the automatic validation (v-validate.disable) and makes a manual validation on blur and on input when the content has been validated and is invalid. I make it possible for the consumer to attach listeners or attributes at will, at the same time.
<template>
<div>
<label v-if="label">{{label}}</label>
<input :class="{'border border-red': error}"
v-validate.disable="constraints"
:name="name"
:value="value"
@input="onInput"
@blur="onBlur"
v-on="listeners"
v-bind="$attrs"
>
<span class="text-red" v-if="error">{{error}}</span>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { Component, Prop } from 'vue-property-decorator';
@Component({
name: 'input-field',
inject: ['$validator'],
})
export default class InputField extends Vue {
@Prop(String)
public label!: string;
@Prop({ type: String, required: true })
public value!: string;
@Prop({ type: String, required: true })
public name!: string;
@Prop(String)
public constraints!: string;
private get error() {
return this.errors.first(this.name);
}
private get field() {
return this.fields[this.name];
}
private onInput(ev) {
this.$emit('input', ev.target.value);
if (this.field.validated && this.field.invalid) {
this.validate();
}
}
private onBlur(ev) {
this.$emit('blur', ev);
if (this.field.dirty) {
this.validate();
}
}
private validate() {
this.$validator.validate(this.name);
}
private get listeners() {
const { input, blur, ...listeners } = this.$listeners;
return listeners;
}
// From Vue-validate
private errors!: any;
// From Vue-validate
private fields!: any;
}
</script>
Most helpful comment
There is no "correct" way to do it, but I generally prefer to create input components that "work" with
vee-validate, for example this is a component we use in some of our projects:As you can see, there is no need for the
innerValuehere, but that means the component doesn't know its state, and any errors or state must be passed to it like theerrorproperty:What you are doing is creating a "self-validating" component, which while is nice, could produce some state issues depending on the implementation like you've noticed.
I would like to add more improvements to the custom components validation, like the ability to inject the field state into it if its being validated by
vee-validatewhich should make it easier to author components.