ealush / vest

Vest ✅ Declarative validations framework

Home Page:https://vestjs.dev/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

isValid returns false with no errors when using skipWhen

martinglover opened this issue · comments

vest@4.0.0-next-6abc96

skipWhen is causing isValid to return false when there are no errors.

Example based on a toggle input, different fields are required the the definition below will return no errors as expected, but the result from isValid() is false

I would expect the rules for a skipWhen to provide the same results for errors and isValid

import { create, test, enforce, skipWhen } from 'vest';

const validator = create((data, field) => {
  skipWhen(data.toggleBoolean, () => {
    test('requiredField', 'Required', () => {
      enforce(data.requiredField).isNotEmpty();
    });
  });

  skipWhen(!data.toggleBoolean, () => {
    test('otherRequiredField', 'Required', () => {
      enforce(data.otherRequiredField).isNotEmpty();
    });
  });
});

const validated = validator({
  toggleBoolean: true,
  requiredField: null,
  otherRequiredField: 'content',
});

console.log(validated.isValid()); // false
console.log(validated.getErrors()); // {}

const validated = validator({
  toggleBoolean: true,
  requiredField: 'content',
  otherRequiredField: 'content',
});

console.log(validated.isValid()); // false
console.log(validated.getErrors()); // {}

I have to define optional against the opposite skipWhen states to get isValid to return the matching results of errors

const validator = create((data, field) => {
  skipWhen(data.toggleBoolean, () => {
    optional('otherRequiredField')
    test('requiredField', 'Required', () => {
      enforce(data.requiredField).isNotEmpty();
    });
  });

  skipWhen(!data.toggleBoolean, () => {
    optional('requiredField')
    test('otherRequiredField', 'Required', () => {
      enforce(data.otherRequiredField).isNotEmpty();
    });
  });
});


const validated = validator({
  toggleBoolean: true,
  requiredField: null,
  otherRequiredField: 'content',
});

console.log(validated.isValid()); // true
console.log(validated.getErrors()); // {}

Hey,
Briefly looking - it seems like isValid is working as expected. It is important to understand, isValid is not the negation of isInvalid.
This means that isValid -> false does not necessarily mean that the form is invalid, but instead, in your case, it means that it might not be valid-yet.

Vest assumes that all fields in the suite are required by default, unless specifically set as optional. As long as not all fields are passing, the suite won't be considered as valid.

The main distinction between the two:

isValid -> all required fields are passing
isInValid -> the suite has errors, AND/OR some app specific business logic that Vest is not aware of.

Because Vest cannot be aware of feature specific business logic, it doesn't provide an isInvalid flag, but it does give you all the information about the suite to infer that from the suite state.


I believe that your case is the exact scenario for which optional was created. I would try making the following changes.

  1. Remove skipWhen - at least from your example, it doesn't look like it does much. Maybe in your actual app it serves a purpose, but if the field can't be interacted with, and is optional to begin with, you can possibly go without skipWhen.
  2. Use conditional optional: Since you're using vest@next, you get more flexibility in how optional works.
    https://vest.vercel.app/docs/writing_your_suite/optional_fields#advanced-usage---supplying-custom-omission-function

Here's something that I came up with that might be useful in your scenario. Not sure if I got all the details correct, but it looks like it could work.

import { create, test, enforce, optional } from "vest";

const suite = create((data) => {
  optional({
    otherRequiredField: () => !data.toggleBoolean,
    requiredField: () => data.toggleBoolean,
  });

  test("requiredField", "Required", () => {
    enforce(data.requiredField).isNotEmpty();
  });

  test("otherRequiredField", "Required", () => {
    enforce(data.otherRequiredField).isNotEmpty();
  });
});

const validated = suite({
  toggleBoolean: true,
  requiredField: null,
  otherRequiredField: "content",
});

Apart from that - I noticed that in your example, you used optional within skipWhen. At the moment optional and skipWhen do not interact - meaning, putting it inside skipWhen doesn't do anything. Would you expect that it does? I designed skipWhen to only filter out tests, and that everything else (only, skip, optional) would be declared at the top of the suite. Do you think even with my suggestion applied, you would still find it more useful to have a nested optional?
My fear was that someone might set a field as optional after its tests were declared - but maybe your approach is more convenient?

Either way - I hope that helps. If not, let's try to find another approach.

Thank you for getting back quickly.

I think I may have provided an over simplified reproducing example and confused the issue, below is a closer to real world example

I'm was using skipWhen to skip multiple test depending on a checkbox state
When useNewAddress is true I want selectedAddress and primaryAddress as required fields and the definitions within the skipWhen ignored when useNewAddress is false
When useNewAddress is false I want addressLine1, addressTown and addressPostCode as required fields and the definitions within the skipWhen ignored when useNewAddress is true

But what I'm seeing from isValid does not match what I'm seeing form errors, isValid is processing the validation rules defined within the skipWhen not skipping them

My intention is not to use optional at all in these cases, this was a work around example to get isValid to return the expected result. I was expecting the field definitions within the skipWhen to not be processed so optional would not be required to be specified for the opposite state

I hope this provides a clearer picture of the issue
If defining optional is just the way things work it's a small price for the simplicity vest provides for validation

import { create, test, enforce, skipWhen, only } from 'vest';

const testData = {
  useNewAddress: true, // define if new address or existing address
  selectedAddress: null, // required only when useNewAddress is false
  primaryAddress: null, // required only when useNewAddress is false
  addressLine1: 'line 1', // required only when useNewAddress is true
  addressTown: 'town', // required only when useNewAddress is true
  addressPostCode: 'postcode', // required only when useNewAddress is true
};

const validator = create((data, field = null) => {
  only(field);

  skipWhen(data.useNewAddress, () => {
    test('selectedAddress', 'Required', () => {
      enforce(data.selectedAddress).isNotEmpty();
    });
    test('selectedAddress', 'Must be a number', () => {
      enforce(data.selectedAddress).isNumber();
    });
    test('primaryAddress', 'Required', () => {
      enforce(data.primaryAddress).isBoolean();
    });
  });

  skipWhen(!data.useNewAddress, () => {
    test('addressLine1', 'Required', () => {
      enforce(data.addressLine1).isNotEmpty();
    });
    test('addressLine1', 'Required', () => {
      enforce(data.addressLine1).isString();
    });
    test('addressTown', 'Required', () => {
      enforce(data.addressTown).isNotEmpty();
    });
    test('addressTown', 'Must be a string', () => {
      enforce(data.addressTown).isString();
    });
    test('addressPostCode', 'Required', () => {
      enforce(data.addressPostCode).isNotEmpty();
    });
    test('addressPostCode', 'Must be a string', () => {
      enforce(data.addressPostCode).isString();
    });
  });
});

const validated = validator(testData);

console.log(validated.isValid()); // false
console.log(validated.getErrors()); // {}

I see. Yes, this adds some clarity. At the moment, optional is the best way to achieve what you're after. There are a few alternatives, but they might put some more burden n you during development. For example, if you can guarantee that for the lifetime of the suite data.useNewAddress will remain unchanged, you can wrap it with an if/else block, and have Vest "not see" the ignored fields - but since it is being controlled by the user, it means that you would have to re create() the suite every time the checkbox is toggled. Not ideal.

Alternatively - I raised an idea in #737 that might be helpful in solving this. I'm going to fiddle with it today, and hopefully release a testing version that we can play with these ideas. Would you be willing to help out by testing it?

@martinglover I just released vest@4.0.0-dev-fc2efe for testing

You can npm i vest@4.0.0-dev-fc2efe to install it.

It might be useful in your situation. It is a slight change, but you can see it as skipWhen-meets-optional. The way functional-optional works is by omitting tests that returns true in the optional function. This testing version does the same for all tests within skipWhen. I think this tiny change can be the solution for your scenario as well.

If you're interested in what changed, here's the diff: #738 (files)

Let me know if that works for you. If it does, I'll try to find a way to introduce it to Vest. It's a good thing that V4 is not released yet, so we can test out these changes before it reaches mass audience.

Thank you for your feedback and helping make Vest better 🙏🏻

Sorry for the delayed response, I've tested the latest next release with the omitWhen and it performs exactly as needed for my scenario

Thank you for the quick implementation of this update

Amazing! Glad that I was able to make that work out for you.

Thanks for all the feedback and making the scenario clear. It really helped me shape this feature 🙏