Skip to content

Commit

Permalink
Run refiners as long as validators passed (#1068)
Browse files Browse the repository at this point in the history
* Run refiners as long as validators passed

Previously refiners ran only when there were no past failures during
validation of the value itself or its subvalues. This behavior was
causing inferior UX when validating forms with additional validations
defined as refiners. If there was a refinement error in some subfield,
the refiners defined on the parent of that subfield were not executed.
Only after fixing the error in the subfield did the user receive
information about the failure on the parent field.

This commit changes the condition under which refinements are run.
Refinements are executed if there were no previous failures or those
failures came exclusively from other refiners.

The change helps in the form validation scenario because as long as the
form structure is valid (which is checked using `validator`s), failures
from all refiners are collected.

Refiners still run only if the general structure of the value is
correct. They will get passed a structurally-valid value. The only
difference now is that those values were may not have been checked via a
refiner.

If some check is so important that other refiners should not
run if that check fails, that check should be defined as a `validator`,
not a `refiner`. Failing that check will not run other refiners.

Fixes #979

* refactor refinement code

Co-authored-by: Ian Storm Taylor <[email protected]>
  • Loading branch information
Gelio and ianstormtaylor authored Jun 6, 2022
1 parent f2697ea commit d1508a9
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 7 deletions.
12 changes: 6 additions & 6 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@ export function* run<T, S>(
}
}

let valid = true
let status: 'valid' | 'not_refined' | 'not_valid' = 'valid'

for (const failure of struct.validator(value, ctx)) {
valid = false
status = 'not_valid'
yield [failure, undefined]
}

Expand All @@ -163,7 +163,7 @@ export function* run<T, S>(

for (const t of ts) {
if (t[0]) {
valid = false
status = t[0].refinement != null ? 'not_refined' : 'not_valid'
yield [t[0], undefined]
} else if (coerce) {
v = t[1]
Expand All @@ -181,14 +181,14 @@ export function* run<T, S>(
}
}

if (valid) {
if (status !== 'not_valid') {
for (const failure of struct.refiner(value as T, ctx)) {
valid = false
status = 'not_refined'
yield [failure, undefined]
}
}

if (valid) {
if (status === 'valid') {
yield [undefined, value as T]
}
}
Expand Down
46 changes: 45 additions & 1 deletion test/api/validate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { deepStrictEqual, strictEqual } from 'assert'
import { validate, string, StructError, define, refine, object } from '../..'
import {
validate,
string,
StructError,
define,
refine,
object,
any,
} from '../..'

describe('validate', () => {
it('valid as helper', () => {
Expand Down Expand Up @@ -93,4 +101,40 @@ describe('validate', () => {
B.validate({ a: null })
deepStrictEqual(order, ['validator', 'refiner'])
})

it('refiners even if nested refiners fail', () => {
let ranOuterRefiner = false

const A = refine(any(), 'A', () => {
return 'inner refiner failed'
})

const B = refine(object({ a: A }), 'B', () => {
ranOuterRefiner = true
return true
})

const [error] = B.validate({ a: null })
// Collect all failures. Ensures all validation runs.
error?.failures()
strictEqual(ranOuterRefiner, true)
})

it('skips refiners if validators return errors', () => {
let ranRefiner = false

const A = define('A', () => {
return false
})

const B = refine(object({ a: A }), 'B', () => {
ranRefiner = true
return true
})

const [error] = B.validate({ a: null })
// Collect all failures. Ensures all validation runs.
error?.failures()
strictEqual(ranRefiner, false)
})
})
41 changes: 41 additions & 0 deletions test/validation/refine/invalid-multiple-refinements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { string, refine, object } from '../../..'

const PasswordValidator = refine(string(), 'MinimumLength', (pw) =>
pw.length >= 8 ? true : 'required minimum length of 8'
)
const changePasswordStruct = object({
newPassword: PasswordValidator,
confirmPassword: string(),
})

export const Struct = refine(
changePasswordStruct,
'PasswordsDoNotMatch',
(values) => {
return values.newPassword === values.confirmPassword
? true
: 'Passwords do not match'
}
)

export const data = {
newPassword: '1234567',
confirmPassword: '123456789',
}

export const failures = [
{
value: data.newPassword,
type: 'string',
refinement: 'MinimumLength',
path: ['newPassword'],
branch: [data, data.newPassword],
},
{
value: data,
type: 'object',
refinement: 'PasswordsDoNotMatch',
path: [],
branch: [data],
},
]

0 comments on commit d1508a9

Please sign in to comment.