Skip to content

Commit

Permalink
feat(vest): support custom optional logic
Browse files Browse the repository at this point in the history
  • Loading branch information
ealush committed Nov 10, 2021
1 parent f2d458d commit d3e7613
Show file tree
Hide file tree
Showing 11 changed files with 247 additions and 96 deletions.
64 changes: 9 additions & 55 deletions packages/vest/docs/cross_field_validations.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,66 +6,14 @@ Take for example the password confirmation field, by itself it serves no purpose

All these cases can be easily handled with Vest in different ways, depending on your requirements and validation strategy.

## The Any utility
## skipWhen for conditionally skipping a test

Your specific example can be handled with the `any` utility function. The `any` utility function takes a series of functions or expressions, and as long as at least one evaluates to `true`, it will mark your validation as passing.

### Use any to use different conditions in the same test

You could also use any within your test, if you have a joined test for both scenarios. This means that you have to return a boolean from your tests.

Demo: https://codesandbox.io/s/demo-forked-ltn8l?file=/src/validate.js
Sometimes you might want to skip running a certain validation based on some criteria, for example - only test for password strength if password DOESN'T have Errors. You could access the intermediate validation result and use it mid-run.

```js
import { create, test, enforce } from 'vest';
import any from 'vest/any';

export default create((data = {}) => {
test('email_or_phone', 'Email or phone must be set', () =>
any(
() => {
enforce(data.email).isNotEmpty();
return true;
},
() => {
enforce(data.phone).isNotEmpty();
return true;
}
)
);
});
```

## skipWhen for conditionally skipping fields

If your field depends on a different field's existence or a different simple condition, you could use skipWhen.
In the following example I only validate `confirm` if password is not empty:

DEMO: https://codesandbox.io/s/demo-forked-z2ur9?file=/src/validate.js

```js
import { create, test, enforce, skipWhen } from 'vest';
export default create('user_form', (data = {}) => {
test('password', 'Password is required', () => {
enforce(data.password).isNotEmpty();
});
skipWhen(!data.password, () => {
test('confirm', 'Passwords do not match', () => {
enforce(data.confirm).equals(data.password);
});
});
});
```

## skipWhen for conditionally skipping field based on a previous result

Sometimes you might want to run a certain validation based on the validation result of a previously run test, for example - only test for password strength if password DOESN'T have Errors. You could access the intermediate validation result and use it mid-run.

This requires using the function created from create():

```js
import { create, test, enforce, skipWhen } from 'vest';
const suite = create('user_form', (data = {}) => {
const suite = create((data = {}) => {
test('password', 'Password is required', () => {
enforce(data.password).isNotEmpty();
});
Expand All @@ -77,3 +25,9 @@ const suite = create('user_form', (data = {}) => {
});
export default suite;
```

## Optional tests

By default, all the tests inside Vest are required in order for the suite to be considered as "valid". Sometimes your app's logic may allow tests not to be filled out and you want them not to be accounted for in the suites validity. The optional utility allows you to specify logic to determine if a test is optional or not, for example - if it depends on a different test.

Read more in the [optional tests doc](./optional).
62 changes: 57 additions & 5 deletions packages/vest/docs/optional.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# optional fields

> Since 3.2.0
By default, all the tests inside Vest are required in order for the suite to be considered as "valid". Sometimes your app's logic may allow tests not to be filled out and you want them not to be accounted for in the suites validity.

It is possible to mark fields in your suite as optional fields. This means that when they are skipped, the suite may still be considered as valid.
All fields are by default required, unless explicitly marked as optional using the `optional` function.
For cases like this, Vest provides the `optional` function which allows you to mark a a field, or multiple fields as optional. Vest's definition of "optional" is that the field did not have any test runs in the lifetime of the suite.

## Usage
If your app requires a more custom logic, please see the [advanced section below](#advanced).

## Basic Usage - allowing tests not to run

`optional` can take a field name as its argument, or an array of field names.

Expand All @@ -22,7 +23,7 @@ const suite = create((data, currentField) => {
**/

test('pet_name', 'Pet Name is required', () => {
enforce(data.name).isNotEmpty();
enforce(data.pet_name).isNotEmpty();
});

test('pet_color', 'If provided, pet color must be a string', () => {
Expand All @@ -41,6 +42,57 @@ suite({ age: 'Five' }, /* -> only validate pet_age */ 'pet_age').isValid();
// 🚨 When erroring, optional fields still make the suite invalid
```

## Advanced Usage - Supplying custom omission function :id=advanced

Since every app is different, your app's logic may require some other definition of "optional", for example - if the user typed inside the field and then removed its content, or alternatively - if a field may be empty only if a different field is supplied - then Vest cannot be aware of this logic, and you will have to tell Vest to conditionally omit the results for this field by supplying `optional` with a custom omission function.

To provide a custom optional function, instead of passing a list of fields, you need to provide an object with predicate functions. These functions will be run when your suite finishes its **synchronous** run, and when they evaluate to true - will omit _any_ failures your field might have from the suite.

!> **IMPORTANT** You should avoid using the custom omission function along with async tests. This is unsupported and may cause unexpected behavior. The reason for this limitation is due to the fact that the omission conditionals are calculated at the end of the suite, while the async tests may keep running afterwards. Allowing it will require re-calculation for each async test that finishes, which could be expensive.

### Examples

**An example allowing a field to be empty even if its `touched` or `dirty`**

```js
const suite = create(data => {
optional({
pet_name: () => !data.pet_name,
});

test('pet_name', 'Pet Name may be left empty', () => {
enforce(data.pet_name).isNotEmpty();
});
});
```

**An example allowing a field to be empty if a different field is filled**

```js
const suite = create(data => {
optional({
pet_name: () => !suite.get().hasErrors('owner_name'),
owner_name: () => !suite.get().hasErrors('pet_name'),
});

test(
'pet_name',
'Pet Name may be left empty only if owner name is supplied',
() => {
enforce(data.pet_name).isNotEmpty();
}
);

test(
'owner_name',
'Owner Name may be left empty only if pet name is supplied',
() => {
enforce(data.owner_name).isNotEmpty();
}
);
});
```

## Difference between `optional` and `warn`

While on its surface, optional might seem similar to warn, they are quite different.
Expand Down
5 changes: 4 additions & 1 deletion packages/vest/src/core/state/createStateRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ export default function createStateRef(
{ suiteId }: { suiteId: string }
) {
return {
optionalFields: state.registerStateKey<Record<string, boolean>>(() => ({})),
omittedFields: state.registerStateKey<Record<string, true>>({}),
optionalFields: state.registerStateKey<
Record<string, (() => boolean) | boolean>
>(() => ({})),
prevTestObjects: state.registerStateKey<VestTest[]>(() => []),
suiteId: state.registerStateKey<string>(() => suiteId),
testCallbacks: state.registerStateKey<{
Expand Down
13 changes: 6 additions & 7 deletions packages/vest/src/core/state/stateHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ export function useTestCallbacks(): TStateHandlerReturn<{
return useStateRef().testCallbacks();
}
export function useOptionalFields(): TStateHandlerReturn<
Record<string, boolean>
Record<string, (() => boolean) | boolean>
> {
return useStateRef().optionalFields();
}

export function useOmittedFields(): TStateHandlerReturn<Record<string, true>> {
return useStateRef().omittedFields();
}

export function useStateRef(): Exclude<TStateRef, void> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return ctx.useX().stateRef!; // I should revisit this
Expand Down Expand Up @@ -74,12 +79,6 @@ export function useCursorAt(): TStateHandlerReturn<number> {
return useStateRef().testObjectsCursor();
}

export function isOptionalField(fieldName: string): boolean {
const [optionalFields] = useOptionalFields();

return !!optionalFields[fieldName];
}

export function useAllIncomplete(): VestTest[] {
const [testObjects] = useTestObjects();

Expand Down
3 changes: 2 additions & 1 deletion packages/vest/src/core/suite/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createState } from 'vast';
import createStateRef from 'createStateRef';
import context from 'ctx';
import matchingFieldName from 'matchingFieldName';
import omitOptionalTests from 'omitOptionalTests';
import { IVestResult, produceFullResult } from 'produce';
import { produceDraft, TDraftResult } from 'produceDraft';
import { useTestObjects, usePrevTestObjects } from 'stateHooks';
Expand Down Expand Up @@ -52,7 +53,7 @@ export default function create<T extends (...args: any[]) => void>(

// Run the consumer's callback
suiteCallback(...args);

omitOptionalTests();
const res = produceFullResult();

return res;
Expand Down
14 changes: 12 additions & 2 deletions packages/vest/src/core/test/VestTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default class VestTest {
}

setStatus(status: KStatus): void {
if (this.isFinalStatus()) {
if (this.isFinalStatus() && status !== STATUS_OMITTED) {
return;
}

Expand Down Expand Up @@ -89,6 +89,10 @@ export default class VestTest {
removeTestFromState(this);
}

omit(): void {
this.setStatus(STATUS_OMITTED);
}

valueOf(): boolean {
return !this.isFailing();
}
Expand All @@ -105,6 +109,10 @@ export default class VestTest {
return this.hasFailures() || this.isPassing();
}

isOmitted(): boolean {
return this.status === STATUS_OMITTED;
}

isUntested(): boolean {
return this.status === STATUS_UNTESTED;
}
Expand Down Expand Up @@ -141,6 +149,7 @@ const STATUS_WARNING = 'WARNING';
const STATUS_PASSING = 'PASSING';
const STATUS_PENDING = 'PENDING';
const STATUS_CANCELED = 'CANCELED';
const STATUS_OMITTED = 'OMITTED';

type KStatus =
| 'UNTESTED'
Expand All @@ -149,4 +158,5 @@ type KStatus =
| 'WARNING'
| 'PASSING'
| 'PENDING'
| 'CANCELED';
| 'CANCELED'
| 'OMITTED';
46 changes: 46 additions & 0 deletions packages/vest/src/core/test/omitOptionalTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assign from 'assign';
import { isEmpty } from 'isEmpty';
import isFunction from 'isFunction';

import VestTest from 'VestTest';
import {
useTestObjects,
useOptionalFields,
useOmittedFields,
} from 'stateHooks';

export default function omitOptionalTests(): void {
const [testObjects] = useTestObjects();
const [optionalFields] = useOptionalFields();
const [, setOmittedFields] = useOmittedFields();

if (isEmpty(optionalFields)) {
return;
}

const shouldOmit: Record<string, boolean> = {};

testObjects.forEach(testObject => {
const fieldName = testObject.fieldName;

if (shouldOmit.hasOwnProperty(fieldName)) {
omit(testObject);
}

const optionalConfig = optionalFields[fieldName];
if (isFunction(optionalConfig)) {
shouldOmit[fieldName] = optionalConfig();

omit(testObject);
}
});

function omit(testObject: VestTest) {
if (shouldOmit[testObject.fieldName]) {
testObject.omit();
setOmittedFields(omittedFields =>
assign(omittedFields, { [testObject.fieldName]: true })
);
}
}
}
54 changes: 49 additions & 5 deletions packages/vest/src/hooks/__tests__/optional.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useOptionalFields } from 'stateHooks';
import { optional, create } from 'vest';
import { optional, create, test } from 'vest';

describe('optional hook', () => {
it('Should add optional fields to state', () => {
Expand All @@ -8,10 +8,10 @@ describe('optional hook', () => {
expect(useOptionalFields()[0]).toMatchInlineSnapshot(`Object {}`);
optional('field_1');
expect(useOptionalFields()[0]).toMatchInlineSnapshot(`
Object {
"field_1": true,
}
`);
Object {
"field_1": true,
}
`);
optional(['field_2', 'field_3']);
expect(useOptionalFields()[0]).toMatchInlineSnapshot(`
Object {
Expand All @@ -25,4 +25,48 @@ describe('optional hook', () => {
done();
});
});

describe('Functional Optional Interface', () => {
it('Should omit test failures based on optional functions', () => {
const suite = create(() => {
optional({
f1: () => true,
f2: () => true,
});

test('f1', () => false);
test('f2', () => false);
});

const res = suite();

expect(res.hasErrors('f1')).toBe(false);
expect(res.hasErrors('f2')).toBe(false);
expect(res.isValid('f1')).toBe(true);
expect(res.isValid('f2')).toBe(true);
expect(res.isValid()).toBe(true);
});

describe('example: "any of" test', () => {
it('Should allow specifying custom optional based on other tests in the suite', () => {
const suite = create(() => {
optional({
f1: () => !suite.get().hasErrors('f2'),
f2: () => !suite.get().hasErrors('f1'),
});

test('f1', () => false);
test('f2', () => true);
});

const res = suite();

expect(res.hasErrors('f1')).toBe(false);
expect(res.hasErrors('f2')).toBe(false);
expect(res.isValid('f1')).toBe(true);
expect(res.isValid('f2')).toBe(true);
expect(res.isValid()).toBe(true);
});
});
});
});
Loading

0 comments on commit d3e7613

Please sign in to comment.