Repository: baianat/vee-validate
Pull request #1362 (2018-06-03 00:48:50)

improve model detection
#### Overview

This PR aims to improve the way VeeValidate deals with `v-model` bound fields in custom components.

When setting up the field, the plugin will check if the [component customizes its v-model](https://vuejs.org/v2/guide/components-custom-events.html#Customizing-Component-v-model) behavior and use that info to improve the overall detection code. 

Which should eliminate many cases where `data-vv-validate-on` and `data-vv-value-path` is used on components authored by 3rd parties.

Related issues: #1324


Patch:
diff --git a/src/core/field.js b/src/core/field.js
index 898ef8d5..066114da 100644
--- a/src/core/field.js
+++ b/src/core/field.js
@@ -507,25 +507,45 @@ export default class Field {
 
     let inputEvent = this.component || isTextInput(this.el) ? 'input' : 'change';
     inputEvent = this.model && this.model.lazy ? 'change' : inputEvent;
+    if (this.component && this.component.$options.model) {
+      inputEvent = this.component.$options.model.event;
+    }
+
     // force change event for non-text type fields, otherwise use the configured.
     // if no event is configured then respect the user choice.
     let events = !this.events.length || this.component || isTextInput(this.el) ? this.events : ['change'];
 
-    // if there is a watchable model and an on input validation is requested.
-    if (this.model && this.model.expression && events.indexOf(inputEvent) !== -1) {
-      const debouncedFn = debounce(fn, this.delay[inputEvent], false, token);
-      const unwatch = this.vm.$watch(this.model.expression, (...args) => {
-        this.flags.pending = true;
-        this._cancellationToken = token;
-        debouncedFn(...args);
-      });
-      this.watchers.push({
-        tag: 'input_model',
-        unwatch
-      });
+    // if there is a model and an on input validation is requested.
+    if (this.model && events.indexOf(inputEvent) !== -1) {
+      let ctx = null;
+      let expression = this.model.expression;
+      // if its watchable from the context vm.
+      if (this.model.expression) {
+        ctx = this.vm;
+        expression = this.model.expression;
+      }
 
-      // filter out input event as it is already handled by the watcher API.
-      events = events.filter(e => e !== inputEvent);
+      // watch it from the custom component vm instead.
+      if (!expression && this.component && this.component.$options.model) {
+        ctx = this.component;
+        expression = this.component.$options.model.prop || 'value';
+      }
+
+      if (ctx && expression) {
+        const debouncedFn = debounce(fn, this.delay[inputEvent], false, token);
+        const unwatch = ctx.$watch(expression, (...args) => {
+          this.flags.pending = true;
+          this._cancellationToken = token;
+          debouncedFn(...args);
+        });
+        this.watchers.push({
+          tag: 'input_model',
+          unwatch
+        });
+
+        // filter out input event as it is already handled by the watcher API.
+        events = events.filter(e => e !== inputEvent);
+      }
     }
 
     // Add events.
diff --git a/src/core/generator.js b/src/core/generator.js
index fb4db7c1..b439fb19 100644
--- a/src/core/generator.js
+++ b/src/core/generator.js
@@ -134,17 +134,26 @@ export default class Generator {
    * @param {*} vnode
    */
   static resolveEvents (el, vnode) {
+    // resolve it from the root element.
     let events = getDataAttribute(el, 'validate-on');
 
+    // resolve from data-vv-validate-on if its a vue component.
     if (!events && vnode.componentInstance && vnode.componentInstance.$attrs) {
       events = vnode.componentInstance.$attrs['data-vv-validate-on'];
     }
 
+    // resolve it from $_veeValidate options.
     if (!events && vnode.componentInstance) {
       const config = Generator.getCtorConfig(vnode);
       events = config && config.events;
     }
 
+    // resolve the model event if its configured for custom components.
+    if (!events && vnode.componentInstance) {
+      const { event } = vnode.componentInstance.$options.model || { event: 'input' };
+      events = event;
+    }
+
     return events;
   }
 
@@ -181,6 +190,7 @@ export default class Generator {
     // https://github.com/vuejs/vue/blob/dev/src/core/util/lang.js#L26
     const watchable = !/[^\w.$]/.test(model.expression) && hasPath(model.expression, vnode.context);
     const lazy = !!(model.modifiers && model.modifiers.lazy);
+
     if (!watchable) {
       return { expression: null, lazy };
     }
@@ -244,8 +254,10 @@ export default class Generator {
         };
       }
 
+      const { prop } = vnode.componentInstance.$options.model || { prop: 'value' };
+
       return () => {
-        return vnode.componentInstance.value;
+        return vnode.componentInstance[prop];
       };
     }
 


Test patch:
diff --git a/tests/integration/components/Model.vue b/tests/integration/components/Model.vue
index a3e3649f..84606caf 100644
--- a/tests/integration/components/Model.vue
+++ b/tests/integration/components/Model.vue
@@ -3,18 +3,29 @@
     <input type="text" name="field" v-validate="'required'" v-model="value" id="watchable">
     <input type="text" name="unwatchablefield" v-validate="'required'" v-model="form['value']" id="unwatchable">
     <input type="text" name="argField" v-validate:input="'required'" v-model="input">
+    <child-with-model v-model="value" v-validate="'required'" data-vv-name="customModel"></child-with-model>
+
+    <child-with-model v-for="(val, n) in values" :key="n" v-model="val.value" v-validate="'required'" :data-vv-name="`loop[${n}]`"></child-with-model>
   </div>
 </template>
 
 <script>
+import ChildWithModel from './stubs/ChildWithModel';
+
 export default {
   name: 'model-test',
+  components: {
+    ChildWithModel
+  },
   data: () => ({
     value: null,
     input: null,
     form: {
       value: null
-    }
+    },
+    values: [
+      { value: null }
+    ]
   })
 };
 </script>
diff --git a/tests/integration/components/stubs/ChildWithModel.vue b/tests/integration/components/stubs/ChildWithModel.vue
new file mode 100644
index 00000000..b9d9cbf9
--- /dev/null
+++ b/tests/integration/components/stubs/ChildWithModel.vue
@@ -0,0 +1,16 @@
+<template>
+  <div></div>
+</template>
+
+
+<script>
+export default {
+  model: {
+    prop: 'innerValue',
+    event: 'change'
+  },
+  props: {
+    innerValue: null
+  }
+}
+</script>
diff --git a/tests/integration/model.js b/tests/integration/model.js
index 1b005862..69c4a4ca 100644
--- a/tests/integration/model.js
+++ b/tests/integration/model.js
@@ -1,4 +1,4 @@
-import { shallow, createLocalVue } from '@vue/test-utils';
+import { mount, createLocalVue } from '@vue/test-utils';
 import flushPromises from 'flush-promises';
 import VeeValidate from '@/index';
 import TestComponent from './components/Model';
@@ -7,7 +7,7 @@ const Vue = createLocalVue();
 Vue.use(VeeValidate);
 
 test('watches input value on model change', async () => {
-  const wrapper = shallow(TestComponent, { localVue: Vue });
+  const wrapper = mount(TestComponent, { localVue: Vue });
   expect(wrapper.vm.errors.count()).toBe(0);
   wrapper.setData({ value: '' });
   await flushPromises();
@@ -15,17 +15,15 @@ test('watches input value on model change', async () => {
 });
 
 test('model can be watched via the directive arg', async() => {
-  const wrapper = shallow(TestComponent, { localVue: Vue });
+  const wrapper = mount(TestComponent, { localVue: Vue });
   expect(wrapper.vm.errors.has('argField')).toBe(false);
   wrapper.setData({ input: '' });
   await flushPromises();
   expect(wrapper.vm.errors.has('argField')).toBe(true);
 });
 
-
-
 test('falls back to DOM events if the model is unwatchable', async() => {
-  const wrapper = shallow(TestComponent, { localVue: Vue });
+  const wrapper = mount(TestComponent, { localVue: Vue });
   expect(wrapper.vm.errors.count()).toBe(0);
   wrapper.setData({
     form: {
@@ -40,3 +38,37 @@ test('falls back to DOM events if the model is unwatchable', async() => {
   expect(wrapper.vm.errors.has('unwatchablefield')).toBe(true);
 });
 
+test('detects the model config on the component ctor options', async () => {
+  const wrapper = mount(TestComponent, { localVue: Vue });
+  const field = wrapper.vm.$validator.fields.find({ name: 'customModel' });
+
+  expect(field.events).toEqual(['change']);
+});
+
+test('watches the model from the child context if it cannot be watched from the parent', async () => {
+  const wrapper = mount(TestComponent, { localVue: Vue });
+  const field = wrapper.vm.$validator.fields.find({ name: 'loop[0]' });
+
+  expect(field.events).toEqual(['change']);
+  wrapper.setData({
+    values: [
+      { value: 'someval' }
+    ]
+  });
+
+  await flushPromises();
+
+  // check resolving
+  expect(field.value).toBe('someval');
+  expect(wrapper.vm.$validator.errors.count()).toBe(0);
+
+  // check watchers
+  wrapper.setData({
+    values: [
+      { value: '' }
+    ]
+  });
+  await flushPromises();
+  expect(wrapper.vm.$validator.errors.count()).toBe(1);
+});
+