Repository: baianat/vee-validate (js)

Issue:
Persisting errors when off-screen for validation components
**Is your feature request related to a problem? Please describe.**
Currently errors disappear when the component that is being validated is no longer rendered. For example, a paginated table with some validated text inputs. If I make a validation error on page 2 and switch to page 3 that error disappears.

**Describe the solution you'd like**
I'm unsure if there are any existing workarounds for this(?) I can't find any, please let me know if there are. It would be nice to have an option to persist errors when the component is not rendered, until they are fixed.

Even if this means the component needs a unique identifier to be provided. I imagine assigning this unique Id to the error so we know which component it comes from. Then preventing errors being removed when a component is unrendered and when a component is validated next, remove any errors already existing with it's Id and replace with any new ones from the latest validation.

**Describe alternatives you've considered**
I've considered trying to do this manually by pulling the errors out into a separate variable whenever a page change occurs, but this likely only works for that specific use-case.


Pull request description:
[feat] persist provider state
This PR implements the `.persist` modifier behavior for the Validation Provider component, the current implementation uses a `persist` prop and has a few conditions to get it working properly.

Your provider __must__ be inside an __observer__ scope and must have a `vid`, as it will be the only way to identify the field, a warning message would be printed in dev mode to aid the use of this feature when not configured properly.

```vue
  <div id="app">
    <ValidationObserver>
      <div v-if="!isHidden">
        <ValidationProvider
          rules="required|min:3|max:6"
          vid="myfield"
          v-slot="{ errors }"
          :persist="true"
        >
          <input type="text" v-model="value">
          {{ errors[0] }}
        </ValidationProvider>
      </div>
    </ValidationObserver>
    <button @click="isHidden = !isHidden">Toggle</button>
  </div>
```

- [x] Implementation
- [x] Test
- [x] Docs

We could remove the __observer__ restriction with better implementation. However, the vid is a must.

closes #1961


Patch:
diff --git a/docs/guide/components/validation-provider.md b/docs/guide/components/validation-provider.md
index fa91db4b..0e41a1f2 100644
--- a/docs/guide/components/validation-provider.md
+++ b/docs/guide/components/validation-provider.md
@@ -289,6 +289,32 @@ Ideally you would pass the props you need to either the `ValidationProvider` or
 
 Using either of these approaches is at your preference.
 
+### Persisting Provider Errors
+
+Sometimes when building something like a multi-step form, you would need to use `v-if` on your providers to toggle the visibility of your steps. However, when the provider is hidden and shown again, it does not keep its state.
+
+You can use the `persist` prop to allow the provider to __remember__ its state across mounting/destroyed lifecycles, but there are a couple of caveats:
+
+- Your Provider __must be inside__ an __observer__ component.
+- Your Provider __must have__ a `vid` property set.
+
+```vue
+<ValidationObserver>
+  <div v-if="!isHidden">
+    <ValidationProvider
+      rules="required|min:3|max:6"
+      vid="myfield"
+      v-slot="{ errors }"
+      :persist="true"
+    >
+      <input type="text" v-model="value">
+      {{ errors[0] }}
+    </ValidationProvider>
+  </div>
+</ValidationObserver>
+<button @click="isHidden = !isHidden">Toggle</button>
+```
+
 ## Adding Errors Manually
 
 You may want to add manual errors to a field, such cases like pre-filling initially due to a server response, or an async request. You can do this using `refs` and the `applyResult` method.
@@ -355,6 +381,7 @@ All the following props are optional.
 | bails     | `boolean` | `true`                | If true, the validation will stop on the first failing rule.                 |
 | debounce  | `number`  | `0`                   | Debounces the validation for the specified amount of milliseconds.           |
 | tag       | `string`  | `span`                | The default tag to [render](#rendering). |
+| persist   | `boolean` | `false`               | If true, the provider will keep its errors across mounted/destroyed lifecycles |
 
 ### Methods
 
diff --git a/src/components/observer.js b/src/components/observer.js
index f4c056d2..441c0af8 100644
--- a/src/components/observer.js
+++ b/src/components/observer.js
@@ -1,4 +1,4 @@
-import { isCallable, values, findIndex } from '../utils';
+import { isCallable, values, findIndex, warn } from '../utils';
 
 const flagMergingStrategy = {
   pristine: 'every',
@@ -115,6 +115,7 @@ export const ValidationObserver = {
   },
   render (h) {
     let slots = this.$scopedSlots.default;
+    this._persistedStore = this._persistedStore || {};
     if (!isCallable(slots)) {
       return h(this.tag, this.$slots.default);
     }
@@ -132,11 +133,13 @@ export const ValidationObserver = {
       }
 
       this.refs = Object.assign({}, this.refs, { [subscriber.vid]: subscriber });
+      if (subscriber.persist && this._persistedStore[subscriber.vid]) {
+        this.restoreProviderState(subscriber);
+      }
     },
     unsubscribe ({ vid }, kind = 'provider') {
       if (kind === 'provider') {
-        this.$delete(this.refs, vid);
-        return;
+        this.removeProvider(vid);
       }
 
       const idx = findIndex(this.observers, o => o.vid === vid);
@@ -152,6 +155,32 @@ export const ValidationObserver = {
     },
     reset () {
       return [...values(this.refs), ...this.observers].forEach(ref => ref.reset());
-    }
+    },
+    restoreProviderState (provider) {
+      const state = this._persistedStore[provider.vid];
+      provider.setFlags(state.flags);
+      provider.applyResult(state);
+      delete this._persistedStore[provider.vid];
+    },
+    removeProvider (vid) {
+      const provider = this.refs[vid];
+      // save it for the next time.
+      if (provider && provider.persist) {
+        /* istanbul ignore else */
+        if (process.env.NODE_ENV !== 'production') {
+          if (vid.indexOf('_vee_') === 0) {
+            warn('Please provide a `vid` prop when using `persist`, there might be unexpected issues otherwise.');
+          }
+        }
+
+        this._persistedStore[vid] = {
+          flags: provider.flags,
+          errors: provider.messages,
+          failedRules: provider.failedRules
+        };
+      }
+
+      this.$delete(this.refs, vid);
+    },
   }
 };
diff --git a/src/components/provider.js b/src/components/provider.js
index 9a42dd3c..dfc4c377 100644
--- a/src/components/provider.js
+++ b/src/components/provider.js
@@ -220,7 +220,7 @@ export const ValidationProvider = {
       default: () => {
         PROVIDER_COUNTER++;
 
-        return PROVIDER_COUNTER;
+        return `_vee_${PROVIDER_COUNTER}`;
       }
     },
     name: {


Test patch:
diff --git a/tests/unit/component.js b/tests/unit/component.js
index 55c0568d..cd08f8bf 100644
--- a/tests/unit/component.js
+++ b/tests/unit/component.js
@@ -864,4 +864,45 @@ describe('Validation Observer Component', () => {
     expect(errors.at(0).text()).toBe(DEFAULT_REQUIRED_MESSAGE);
     expect(errors.at(1).text()).toBe(DEFAULT_REQUIRED_MESSAGE);
   });
+
+  test('persist provider state after destroyed', async () => {
+    const wrapper = mount({
+      data: () => ({
+        value: '',
+        isHidden: false
+      }),
+      template: `
+      <div>
+        <ValidationObserver>
+          <div v-if="!isHidden">
+            <ValidationProvider
+              rules="required|min:3|max:6"
+              vid="myfield"
+              v-slot="{ errors }"
+              :persist="true"
+            >
+              <input type="text" v-model="value">
+              <span>{{ errors[0] }}</span>
+            </ValidationProvider>
+          </div>
+        </ValidationObserver>
+        <button @click="isHidden = !isHidden">Toggle</button>
+      </div>
+      `
+    }, { localVue: Vue });
+
+    const button = wrapper.find('button');
+    const input = wrapper.find('input');
+    await flushPromises();
+    input.element.value = 'se';
+    input.trigger('input');
+    await flushPromises();
+
+    button.trigger('click');
+    await flushPromises();
+    button.trigger('click');
+    await flushPromises();
+    const span = wrapper.find('span');
+    expect(span.text()).toBeTruthy();
+  });
 });