Repository: baianat/vee-validate (js)

Issue:
[Feature] custom components can provide a ctor option for interfacing with vee-validate
#### Versions:
- VueJs: Any
- Vee-Validate: 2.0.0-rc.8

### Description:

Currently only checkboxes have the exception of rejecting false as a valid value for the required rule. This can be a common use case for components that has a toggle state inputs like a switch or a toggle button.

This is original pointed out by @escapedcat in #613

This issue can also extend to having to provide `data-vv-value-path` for each component event tho they are the same component and the access method for them is the same, so that is very verbose for developers and makes their templates bloated more than needed.

### Proposal:

Adding another `data-vv-*` attribute seems verbose and I would like to get rid of as many data attrs as possible for components.

A solution would be that each component can have a ctor option in their definition that defines numerous behaviors when being validated by `vee-validate` for example:

```js
export default {
  $vee: {
      invalidatesFalse: Boolean, // this component does not consider false as a non-empty value.
      new: Boolean, // this component can inherit the parent's validator instance if injected.
      value: Function // returns the current component's value. 
   }
};
```

Pull request:
[WIP] Component Interface
### Overview

Custom components validation has been supported for a while now, but was verbose if the user used custom components as their inputs instead of native HTML5 ones. Mainly because a lot of information must be gathered about these components.

The validator gathered the information using custom `data-*` attributes like `data-vv-name` and `data-vv-value-path`. Which is repetitive if you are going to use components for your validation. Except when using `v-model` which then the plugin uses the model instead as the source of a part of the information, which is the value getter part.

A common solution people tended to navigate to, is to implement a self validating components, which means the directive is used inside the component rather than on it in its parent scope. While this works, it required extra setup.

This PR introduces a simple mechanism to provide the necessary information for a custom component to be validated, by adding an object in the components constructor options, making them available in `$options` namespace.

Here is a small use-case:

```js
const Child = Vue.extend({
  $_veeValidate: {
    rejectsFalse: true,
    value: function () {
      return this.innerValue;
    }
  },
  name: 'child',
  template: `<div></div>`,
  data: () => ({
    innerValue: 'initial'
  })
});
```

As you have noted, the `$_veeValidate` (named following the official Vue style guide) option in the ctor can define the value getter so it will be used to retrieve the up-to-date value of the component whenever the validator needs to. Also it allows components to act as `checkboxes` by rejecting the `false` value from the allowed values in the `required` rule by setting the `rejectsFalse` property.

This allows component authors to support `vee-validate` integration with the intended behaviors set from their end. For example an author might decide that their component will only be validated once the user blurs the input, or whenever the component 'needs' to be validated by using a custom event name.

### Definition

The `$_veeValidate` object could contain the following properties:

| Property Name | Type | Default Value | Description |
| -----------------|:-------------:|:-----:|:-----|
| name | `Function` | `undefined` | a function that returns the component name to be registered as in the validator, cannot be an arrow function because its context will be the component itself |
| value | `Function` | `undefined` | a function that returns the component current value, will be used by the validator when it needs to resolve the field value, like calling `validate` without passing a value |
| rejectsFalse | `Boolean` | `false` | Defines `false` as an invalid value when the component is validated against the `required` rule. |
| validator | `String` | <code>'inherit&#124;new'</code> | Determines how the component get its validator instance, 'new' means it will always instantiate its own validator instance, 'inherit' means it will be injected by its parent using Provide/Inject API, the default is it will instantiate an instance unless it requests a `$validator` injection.
| events | `String`  | '<code>input&#124;blur</code>' | Pipe separated list of event names to validate when triggered

### Introduced Breaking Changes

- The `$validates` ctor option will be deprecated in favor of the mentioned `inject` option inside the namespace `$_veeValidate`.

### Checklist

- [x] Implement `value` getter option.
- [x] Implement `rejectsFalse` option.
- [x] Implement `name` getter option.
- [x] implement `events` option.
- [x] implement `validator` option.
- [x] Finalize ctor option definition and allowed properties.
- [x] Finalize ctor option name: `$_veeValidate`. 
 
#### Issues resolved by this PR:

- closes #707

Patch:
diff --git a/src/core/field.js b/src/core/field.js
index db111097..7f97ceda 100644
--- a/src/core/field.js
+++ b/src/core/field.js
@@ -1,4 +1,4 @@
-import { uniqId, createFlags, assign, normalizeRules, isNullOrUndefined, setDataAttribute, toggleClass, getInputEventName, debounce, isCallable, warn, toArray } from './utils';
+import { uniqId, createFlags, assign, normalizeRules, isNullOrUndefined, setDataAttribute, toggleClass, getInputEventName, debounce, isCallable, warn, toArray, getPath } from './utils';
 import Generator from './generator';
 
 const DEFAULT_OPTIONS = {
@@ -44,6 +44,7 @@ export default class Field {
     this.flags = createFlags();
     this.vm = options.vm;
     this.component = options.component;
+    this.ctorConfig = this.component ? getPath('$options.$_veeValidate', this.component) : undefined;
     this.update(options);
     this.updated = false;
   }
@@ -97,7 +98,11 @@ export default class Field {
    * If the field rejects false as a valid value for the required rule.
    */
   get rejectsFalse () {
-    if (this.isVue || this.isHeadless) {
+    if (this.isVue && this.ctorConfig) {
+      return !!this.ctorConfig.rejectsFalse;
+    }
+
+    if (this.isHeadless) {
       return false;
     }
 
diff --git a/src/core/generator.js b/src/core/generator.js
index c8a6c42b..b0c6d1e8 100644
--- a/src/core/generator.js
+++ b/src/core/generator.js
@@ -1,4 +1,4 @@
-import { getScope, getDataAttribute, isObject, toArray, find, getPath, hasPath, isNullOrUndefined } from './utils';
+import { getScope, getDataAttribute, isObject, toArray, find, getPath, hasPath, isNullOrUndefined, isCallable } from './utils';
 
 /**
  * Generates the options required to construct a field.
@@ -30,6 +30,14 @@ export default class Generator {
     };
   }
 
+  static getCtorConfig (vnode) {
+    if (!vnode.child) return null;
+
+    const config = getPath('child.$options.$_veeValidate', vnode);
+
+    return config;
+  }
+
   /**
    *
    * @param {*} el
@@ -107,11 +115,18 @@ export default class Generator {
    * @param {*} vnode
    */
   static resolveEvents (el, vnode) {
-    if (vnode.child) {
-      return getDataAttribute(el, 'validate-on') || (vnode.child.$attrs && vnode.child.$attrs['data-vv-validate-on']);
+    let events = getDataAttribute(el, 'validate-on');
+
+    if (!events && vnode.child && vnode.child.$attrs) {
+      events = vnode.child.$attrs['data-vv-validate-on'];
     }
 
-    return getDataAttribute(el, 'validate-on');
+    if (!events && vnode.child) {
+      const config = Generator.getCtorConfig(vnode);
+      events = config.events;
+    }
+
+    return events;
   }
 
   /**
@@ -166,11 +181,28 @@ export default class Generator {
      * @return {String} The field name.
      */
   static resolveName (el, vnode) {
-    if (vnode.child) {
-      return getDataAttribute(el, 'name') || (vnode.child.$attrs && (vnode.child.$attrs['data-vv-name'] || vnode.child.$attrs['name'])) || vnode.child.name;
+    let name = getDataAttribute(el, 'name');
+
+    if (!name && !vnode.child) {
+      return el.name;
     }
 
-    return getDataAttribute(el, 'name') || el.name;
+    if (!name && vnode.child && vnode.child.$attrs) {
+      name = vnode.child.$attrs['data-vv-name'] || vnode.child.$attrs['name'];
+    }
+
+    if (!name && vnode.child) {
+      const config = Generator.getCtorConfig(vnode);
+      if (config && isCallable(config.name)) {
+        const boundGetter = config.name.bind(vnode.child);
+
+        return boundGetter();
+      }
+
+      return vnode.child.name;
+    }
+
+    return name;
   }
 
   /**
@@ -184,11 +216,23 @@ export default class Generator {
     }
 
     if (vnode.child) {
-      return () => {
-        const path = getDataAttribute(el, 'value-path') || (vnode.child.$attrs && vnode.child.$attrs['data-vv-value-path']);
-        if (path) {
+      const path = getDataAttribute(el, 'value-path') || (vnode.child.$attrs && vnode.child.$attrs['data-vv-value-path']);
+      if (path) {
+        return () => {
           return getPath(path, vnode.child);
-        }
+        };
+      }
+
+      const config = Generator.getCtorConfig(vnode);
+      if (config && isCallable(config.value)) {
+        const boundGetter = config.value.bind(vnode.child);
+
+        return () => {
+          return boundGetter();
+        };
+      }
+
+      return () => {
         return vnode.child.value;
       };
     }
diff --git a/src/mixin.js b/src/mixin.js
index 5c31d3c6..d90c77f3 100644
--- a/src/mixin.js
+++ b/src/mixin.js
@@ -1,4 +1,4 @@
-import { isObject, isCallable, createProxy, createFlags } from './core/utils';
+import { isObject, isCallable, createProxy, createFlags, warn } from './core/utils';
 import Validator from './core/validator';
 
 const fakeFlags = createProxy({}, {
@@ -53,8 +53,15 @@ export default (Vue, options = {}) => {
   };
 
   mixin.beforeCreate = function beforeCreate () {
+    // TODO: Deprecate
+    /* istanbul ignore next */
+    if (this.$options.$validates) {
+      warn('The ctor $validates option has been deprecated please set the $_veeValidate.validator option to "new" instead');
+      this.$validator = createValidator(this, options);
+    }
+
     // if its a root instance, inject anyways, or if it requested a new instance.
-    if (this.$options.$validates || !this.$parent) {
+    if (!this.$parent || (this.$options.$_veeValidate && /new/.test(this.$options.$_veeValidate.validator))) {
       this.$validator = createValidator(this, options);
     }
 


Test patch:
diff --git a/tests/generator.js b/tests/generator.js
index e17828b1..fda9e640 100644
--- a/tests/generator.js
+++ b/tests/generator.js
@@ -246,13 +246,14 @@ describe('resolves the value getters', () => {
     `;
     let el = document.querySelector('#el');
     const vnode = { context: {}, child: { someval: 'test', value: 'test2', third: 33 }, data: {} };
-    const getter = Generator.resolveGetter(el, vnode);
+    let getter = Generator.resolveGetter(el, vnode);
     expect(getter()).toBe('test');
     vnode.child.someval = 'changed';
     expect(getter()).toBe('changed');
 
     // test fallback to value property.
     el.setAttribute('data-vv-value-path', '');
+    getter = Generator.resolveGetter(el, vnode);
     expect(getter()).toBe('test2');
     vnode.child.value = 'changed';
     expect(getter()).toBe('changed');
diff --git a/tests/mixin.js b/tests/mixin.js
index 65667389..27473b1c 100644
--- a/tests/mixin.js
+++ b/tests/mixin.js
@@ -1,5 +1,6 @@
 import Vue from 'vue/dist/vue';
 import makeMixin from '../src/mixin';
+import makeDirective from '../src/directive';
 import ErrorBag from '../src/core/errorBag';
 import FieldBag from '../src/core/fieldBag';
 import plugin from './../src/index';
@@ -119,3 +120,124 @@ test('component pauses the validator before destroy if it owns it', () => {
   app.$destroy();
   expect(validator.pause).toHaveBeenCalledTimes(1);
 });
+
+describe('components can have a definition object in the ctor options', () => {
+  const createVM = () => {
+    const mixin = makeMixin(Vue, { inject: false });    
+    const Child = Vue.extend({
+      mixins: [mixin],
+      name: 'child',
+      template: `<div></div>`,
+      data: () => ({
+        innerValue: '102'
+      }),
+      props: {
+        name: String 
+      },
+      inject: ['$validator'],
+      $_veeValidate: {
+        rejectsFalse: true,
+        value: function () {
+          return this.innerValue;
+        },
+        name: function () {
+          return this.name
+        },
+        events: 'blur'
+      }
+    });
+
+    return Vue.extend({
+      mixins: [mixin],
+      directives: {
+        validate: makeDirective({ inject: false })
+      },
+      components: { Child },
+      template: `
+        <div>
+          <child v-validate="'required'" name="field"></child>
+        </div>
+      `
+    });
+  };
+
+  test('uses the value getter in the definition', () => {
+    const VM = createVM();
+
+    const app = new VM().$mount();
+    const child = app.$children[0];
+    const field = app.$validator.fields.items[0];
+
+    // test value resolution.
+    expect(field.value).toBe('102');
+    child.innerValue = 20;
+    expect(field.value).toBe(20);
+  });
+
+  test('uses the name getter in the definition', () => {
+    const VM = createVM();
+
+    const app = new VM().$mount();
+    const field = app.$validator.fields.items[0];
+    expect(field.name).toBe('field');
+  });
+
+  test('uses the events defined in the definition', () => {
+    const VM = createVM();
+
+    const app = new VM().$mount();
+    const field = app.$validator.fields.items[0];
+    expect(field.events).toEqual(['blur']);
+    expect(field.watchers).toHaveLength(3); // blur (flags), input (flags), blur (validate)
+  });
+
+  test('components can reject the false value if provided in the required rule', async () => {
+    const VM = createVM();
+
+    const app = new VM().$mount();
+    const child = app.$children[0];
+
+    expect(await app.$validator.validate('field')).toBe(true);
+    child.innerValue = false;
+
+    expect(await app.$validator.validate('field')).toBe(false);
+  });
+
+  test('Creates a new instance when the validator option is set to new', () => {
+    const mixin = makeMixin(Vue, { inject: false });
+    const Child = Vue.extend({
+      mixins: [mixin],
+      name: 'child',
+      template: `<div></div>`,
+      $_veeValidate: {
+        validator: 'new'
+      }
+    });
+    const OtherChild = Vue.extend({
+      mixins: [mixin],
+      name: 'other-child',
+      template: `<div></div>`,
+      inject: ['$validator'],
+      computed: {
+        somval() { return 1 ;}
+      },
+      $_veeValidate: {
+        validator: 'inherit'
+      }
+    });
+    const VM = Vue.extend({
+      mixins: [mixin],
+      components: { Child, OtherChild },
+      template: `
+        <div>
+          <child></child>
+          <other-child></other-child>
+        </div>
+      `
+    });
+  
+    const app = new VM().$mount();
+    expect(app.$validator).not.toBe(app.$children[0].$validator); // got a different instance.
+    expect(app.$validator).toBe(app.$children[1].$validator); // got the parent's
+  });
+});
\ No newline at end of file