Repository: baianat/vee-validate (js)

Issue:
Errors are removed from parent when switching to another component
**Versions**

- vee-validate: 2.1.5
- vue: 2.5.4

**Describe the bug**
The validation `error` of the parent is removed of its content when I switch between child components that contains inputs to validate.

**To reproduce**
Steps to reproduce the behavior:
1. Set `inject: false` in VeeValidate config
2. Create a parent component `ParentComponent`
3. Create two child components `ChildComponent1` & `ChildComponent2` and *only one is required at the same time (with a `v-if="component === 1" [...] v-else` for example)*
4. Add some inputs to validate in `ChildComponent1`
5. Try to get validation errors on this component
6. Switch to `ChildComponent2` (set `component` to `2`) (see that `ParentComponent` doesn't have the error anymore)
7. Switch back to `ChildComponent1` (and see that errors are not displayed anymore)

**Expected behavior**
This is expected what happens before the `2.1.5` release: even if the input is not in the DOM anymore, the error still stay in the parent where the `$validator` is injected. If the child reappear, then we have validation errors back.

**Screenshots**
When I trigger a validation error on a `ChildComponent1`, this is injected in the `ParentComponent`
![capture d ecran 2019-01-13 a 14 46 59](https://user-images.githubusercontent.com/1854141/51086419-26a56080-1747-11e9-8365-eaa0fa9e7dad.png)

-- I switch to `ChildComponent2` (`ChildComponent1` is now out of the DOM (`v-if`)) --

Errors are removed because relative fields are not in the DOM anymore
![capture d ecran 2019-01-13 a 14 47 16](https://user-images.githubusercontent.com/1854141/51086421-29a05100-1747-11e9-852e-1ddd4b53f25e.png)

**Desktop:**
 - OS: macOS Mojave
 - Browser : Safari / Chrome
 - Version: 12 / 71

Pull request description:
feat: added persist directive (fixes #1799)
🔎 __Overview__

The `.persist` directive (as discussed in #1799 between @HapLifeMan & @logaretm) consists in keeping the validation state (field instance & associated errors) when the field is removed from the DOM/component, allowing multi-views forms to use vee-validate properly, without missing errors from previous views.

I also implemented corresponding tests, & written docs about the feature.

🤓 __Code snippets/examples__

```vue
<template>
    <div>
        <div v-if="displayField">
            <input v-model="email" v-validate.persist="'required|email'" data-vv-validate-on="input" name="email" type="text">
        </div>
        <label><input type="checkbox" v-model="displayField"> Display the field</label>
        <div>{{ errors.first('email') }}</div>
    </div>
</template>

<script>
  export default {
    data: () => ({
      email: '',
      displayField: true,
    }),
  }
</script>
```
Noticeable change: The error stays even if the field is removed from the component.

✔ __Issues affected__

> fixes & satisfies #1799


Patch:
diff --git a/docs/api/directive.md b/docs/api/directive.md
index 9cb7cbe7..844d92aa 100644
--- a/docs/api/directive.md
+++ b/docs/api/directive.md
@@ -65,6 +65,41 @@ export default {
 };
 </script>
 ```
+### persist
+
+You can use `.persist` modifier to keep the field (and its possible error(s)) in memory when the field is unmount/removed from the DOM (e.g. in a `v-if` block).
+If the field disappears from your component, it will stay accessible in the `fields` object.
+You'll be able to run a `validateAll()` with all your disappeared fields, very handy if you want to develop a multi-steps form with switching components.
+
+```vue
+<template>
+  <div>
+    <div v-if="displayField">
+      <input v-model="email" v-validate.persist="'required|email'" data-vv-validate-on="input" name="email" type="text">
+    </div>
+    <label><input type="checkbox" v-model="displayField"> Display the field</label>
+    <div>{{ errors.first('email') }}</div>
+  </div>
+</template>
+
+<script>
+export default {
+  data: () => ({
+    email: '',
+    displayField: true
+  })
+};
+</script>
+```
+
+::: tip
+You can force it to disappear completely by changing the `persist` property in the field object:
+```js
+this.$validator.fields.find({name: 'email'}).persist = false;
+this.diplayField = false;
+// The field instance and its errors will also disappear from the memory in the next tick
+```
+:::
 
 ### continues
 
diff --git a/src/components/provider.js b/src/components/provider.js
index 50216486..477ba8a9 100644
--- a/src/components/provider.js
+++ b/src/components/provider.js
@@ -187,6 +187,10 @@ export const ValidationProvider = {
       type: Boolean,
       default: false
     },
+    persist: {
+      type: Boolean,
+      default: false
+    },
     bails: {
       type: Boolean,
       default: () => VeeValidate.config.fastExit
diff --git a/src/core/field.js b/src/core/field.js
index 19996ffd..c394222f 100644
--- a/src/core/field.js
+++ b/src/core/field.js
@@ -27,6 +27,7 @@ import {
 const DEFAULT_OPTIONS = {
   targetOf: null,
   immediate: false,
+  persist: false,
   scope: null,
   listen: true,
   name: null,
@@ -92,7 +93,7 @@ export default class Field {
     this._delay = !isNullOrUndefined(options.delay) ? options.delay : 0; // cache initial delay
     this.validity = options.validity;
     this.aria = options.aria;
-    this.flags = createFlags();
+    this.flags = options.flags || createFlags();
     this.vm = options.vm;
     this.componentInstance = options.component;
     this.ctorConfig = this.componentInstance ? getPath('$options.$_veeValidate', this.componentInstance) : undefined;
@@ -232,6 +233,7 @@ export default class Field {
   update (options: Object) {
     this.targetOf = options.targetOf || null;
     this.immediate = options.immediate || this.immediate || false;
+    this.persist = options.persist || this.persist || false;
 
     // update errors scope if the field scope was changed.
     if (!isNullOrUndefined(options.scope) && options.scope !== this.scope && isCallable(this.validator.update)) {
diff --git a/src/core/resolver.js b/src/core/resolver.js
index b842c0e9..044cb6d0 100644
--- a/src/core/resolver.js
+++ b/src/core/resolver.js
@@ -42,6 +42,7 @@ export default class Resolver {
       delay: Resolver.resolveDelay(el, vnode, options),
       rules: Resolver.resolveRules(el, binding, vnode),
       immediate: !!binding.modifiers.initial || !!binding.modifiers.immediate,
+      persist: !!binding.modifiers.persist,
       validity: options.validity,
       aria: options.aria,
       initialValue: Resolver.resolveInitialValue(vnode)
diff --git a/src/core/validator.js b/src/core/validator.js
index 6496d190..da38865e 100644
--- a/src/core/validator.js
+++ b/src/core/validator.js
@@ -164,6 +164,16 @@ export default class Validator {
    * Registers a field to be validated.
    */
   attach (fieldOpts: FieldOptions): Field {
+    // We search for a field with the same name, having persist enabled
+    const oldField = fieldOpts.persist ? this.fields.find({ name: fieldOpts.name, persist: true }) : null;
+
+    if (oldField) {
+      // We keep the flags of the old field, then we remove its instance
+      fieldOpts.flags = oldField.flags;
+      oldField.destroy();
+      this.fields.remove(oldField);
+    }
+
     // fixes initial value detection with v-model and select elements.
     const value = fieldOpts.initialValue;
     const field = new Field(fieldOpts);
@@ -201,9 +211,12 @@ export default class Validator {
     let field = isCallable(name.destroy) ? name : this._resolveField(name, scope, uid);
     if (!field) return;
 
-    field.destroy();
-    this.errors.remove(field.name, field.scope, field.vmId);
-    this.fields.remove(field);
+    // We destroy/remove the field & error instances if it's not a `persist` one
+    if (!field.persist) {
+      field.destroy();
+      this.errors.remove(field.name, field.scope, field.vmId);
+      this.fields.remove(field);
+    }
   }
 
   /**


Test patch:
diff --git a/tests/integration/components/Persist.vue b/tests/integration/components/Persist.vue
new file mode 100644
index 00000000..3646d25d
--- /dev/null
+++ b/tests/integration/components/Persist.vue
@@ -0,0 +1,19 @@
+<template>
+  <div v-if="displayFields">
+    <input type="text" name="field" v-validate="'required'">
+    <input type="text" name="field-immediate" v-validate.immediate="'required'">
+    <input type="text" name="field-persist" v-validate.persist="'required'">
+    <input type="text" name="field-persist-immediate" v-validate.persist.immediate="'required'">
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'persist-test',
+  data () {
+    return {
+      displayFields: true
+    };
+  }
+};
+</script>
diff --git a/tests/integration/persist.js b/tests/integration/persist.js
new file mode 100644
index 00000000..99b80adc
--- /dev/null
+++ b/tests/integration/persist.js
@@ -0,0 +1,57 @@
+import { shallow, createLocalVue } from '@vue/test-utils';
+import flushPromises from 'flush-promises';
+import VeeValidate from '@/index';
+import TestComponent from './components/Persist';
+
+const Vue = createLocalVue();
+Vue.use(VeeValidate);
+
+test('persist fields should stay in the field list, as well as their errors', async () => {
+  const wrapper = shallow(TestComponent, { localVue: Vue });
+
+  await flushPromises();
+
+  // await new Promise(resolve => wrapper.vm.$nextTick(() => resolve()));
+  expect(wrapper.vm.errors.count()).toBe(2);
+  expect(Object.keys(wrapper.vm.fields).length).toBe(4);
+
+  wrapper.vm.displayFields = false; // We hide all the fields
+  await flushPromises();
+
+  expect(wrapper.vm.errors.count()).toBe(1); // Only one persist field is errored (because one is immediate)
+  expect(Object.keys(wrapper.vm.fields).length).toBe(2); // We expect the persist fields to be there
+
+  wrapper.vm.displayFields = true;
+  await flushPromises();
+
+  expect(wrapper.vm.errors.count()).toBe(2);
+  expect(Object.keys(wrapper.vm.fields).length).toBe(4); // The four fields are visible
+
+  expect(await wrapper.vm.$validator.validateAll()).toBe(false); // 4 required -> 4 errors
+
+  expect(wrapper.vm.errors.count()).toBe(4); // The .validateAll call has triggered 4 errors
+  expect(Object.keys(wrapper.vm.fields).length).toBe(4);
+
+  wrapper.vm.displayFields = false;
+  await flushPromises();
+
+  expect(wrapper.vm.errors.count()).toBe(2); // The two persist fields are still errored
+  expect(Object.keys(wrapper.vm.fields).length).toBe(2);
+
+  wrapper.vm.displayFields = true;
+  await flushPromises();
+
+  expect(wrapper.vm.errors.count()).toBe(3); // The two persist fields are still errored
+  expect(Object.keys(wrapper.vm.fields).length).toBe(4);
+
+  // The `valid` flag must stay the same as before
+  expect(wrapper.vm.fields['field-persist'].valid).toBe(false);
+
+  // One of the ways to remove the field is to set the persist prop to false
+  wrapper.vm.$validator.fields.find({ name: 'field-persist' }).persist = false;
+  wrapper.vm.displayFields = false;
+  await flushPromises();
+
+  expect(wrapper.vm.errors.count()).toBe(1);
+  expect(Object.keys(wrapper.vm.fields).length).toBe(1);
+});