Skip to content

Commit

Permalink
Structured validation results (#87)
Browse files Browse the repository at this point in the history
* implement struct validation result

* fix typo

* restruct normalize raw error

* update state comment

* fix typo

* remove rawError from ValidateResultWithError

* update test case

* fix typo

* update check error condition

* remove debug info

* add test case

* fix throw error test case

* restruct error hierarchy

* restruct docs and misc

* fix test case

* update comments

* rename _error

* fix override state props

* abstract error

* fix infinity error

* fix typo

* fix typo
  • Loading branch information
Luncher authored May 29, 2022
1 parent b23f897 commit ec9e6cf
Show file tree
Hide file tree
Showing 14 changed files with 389 additions and 45 deletions.
10 changes: 8 additions & 2 deletions dumi/docs/concepts/state/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface IState<V> {
error: ValidationError
/** The state's own error info, regardless of child states. */
ownError: ValidationError
/** The state's validation result, regardless of child states. */
rawError: ValidationResult
/** Append validator(s). */
withValidator(...validators: Array<Validator<V>>): this
/** Fire a validation behavior imperatively. */
Expand Down Expand Up @@ -67,11 +69,11 @@ Validation is the process of validating user input values.
Validation is important for cases like:

* When user inputs, we display error tips if validation not passed, so users see that and correct the input
* Before form submiiting, we check if all value is valid, so invalid requests to the server can be avoided
* Before form submitting, we check if all value is valid, so invalid requests to the server can be avoided

That's why validation should provide such features:

* It should run automatically, when users changed the value, or when some other data change influcend the value validity
* It should run automatically, when users changed the value, or when some other data change influenced the value validity
* It should produce details such as a meaningful message, so users can get friendly hint

With formstate-x, we define validators and append them to states with `withValidator`. formstate-x will do validation for us. Through `validateStatus` & `error`, we can access the validate status and result.
Expand All @@ -97,6 +99,10 @@ States will not be auto-validated until it becomes **activated**. And they will

`ownError` & `hasOwnError` are special fields especially for composed states. You can check details about them in issue [#71](https://github.com/qiniu/formstate-x/issues/71).

### Raw Error

The state's validation result, regardless of child states. The difference compared to `ownError` is that it contains the type of `ValidationErrorObject`. You can check details about them in issue [#82](https://github.com/qiniu/formstate-x/issues/82).

### Disable State

You may find that we defined method `disableWhen` to configure when a state should be disabled. It is useful in some specific cases. You can check details in section [Disable State](/guide/advanced#disable-state).
4 changes: 4 additions & 0 deletions dumi/docs/concepts/validator/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export type ValidationResult =
| null
| undefined
| false
| ValidationErrorObject

/** Object type validation result. */
export type ValidationErrorObject = { message: string }

/** Return value of validator. */
export type ValidatorReturned =
Expand Down
2 changes: 1 addition & 1 deletion src/adapter/v2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,8 +703,8 @@ describe('toV2', () => {
}
value: V
touched = false
ownError = undefined
error = undefined
rawError = undefined
activated = false
validateStatus = v3.ValidateStatus.NotValidated
async validate() {
Expand Down
23 changes: 20 additions & 3 deletions src/adapter/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as v2 from 'formstate-x-v2'
import { BaseState } from '../state'
import * as v3 from '..'
import Disposable from '../disposable'
import { isPromiseLike, normalizeError } from '../utils'

interface IV3StateFromV2<T extends v2.ComposibleValidatable<unknown, V>, V> extends v3.IState<V> {
/** The original ([email protected]) state */
Expand All @@ -24,7 +25,7 @@ class Upgrader<T extends v2.ComposibleValidatable<unknown, V>, V> extends BaseSt

@computed get value() { return this.stateV2.value }
@computed get touched() { return this.stateV2.dirty }
@computed get ownError() {
@computed get rawError() {
return getV3OwnError(this.stateV2)
}
@computed get error() { return this.stateV2.error }
Expand All @@ -47,7 +48,7 @@ class Upgrader<T extends v2.ComposibleValidatable<unknown, V>, V> extends BaseSt
isV2FieldState(this.stateV2)
|| isV2FormState(this.stateV2)
) {
this.stateV2.validators(...validators)
this.stateV2.validators(...portV2Validators(...validators))
return this
}
throwNotSupported()
Expand All @@ -64,7 +65,23 @@ class Upgrader<T extends v2.ComposibleValidatable<unknown, V>, V> extends BaseSt
}
}

/** Convets [email protected] state to [email protected] state */
function portV2Validators<V>(...validators: Array<v3.Validator<V>>): Array<v2.Validator<V>> {
const normalizeRet = (v: any) => (
normalizeError(v)
)
return validators.map(validator => {
return (value: V) => {
const returned = validator(value)
if (isPromiseLike(returned)) {
return returned.then(normalizeRet)
} else {
return normalizeRet(returned)
}
}
})
}

/** Converts [email protected] state to [email protected] state */
export function fromV2<T extends v2.ComposibleValidatable<unknown, unknown>>(stateV2: T): IV3StateFromV2<T, T['value']> {
return new Upgrader(stateV2)
}
Expand Down
28 changes: 28 additions & 0 deletions src/debouncedState.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,20 @@ describe('DebouncedState validation', () => {
expect(state.error).toBe('empty')
expect(state.ownError).toBe('empty')
})

it('should work well with resolved error object', async () => {
const fooState = new FieldState('')
const formState = new FormState({ foo: fooState })
const state = new DebouncedState(formState, defaultDelay).withValidator(
() => ({ message: 'mock msg' })
)

await state.validate()
expect(state.hasError).toBe(true)
expect(state.ownError).toBe('mock msg')
expect(state.error).toBe('mock msg')
expect(state.rawError).toEqual({ message: 'mock msg' })
})
})

function createFieldState<T>(initialValue: T, delay = defaultDelay) {
Expand Down Expand Up @@ -607,4 +621,18 @@ describe('DebouncedFieldState validation', () => {
expect(validator).toBeCalled()
expect(state.validateStatus).toBe(ValidateStatus.Validated)
})

it('should work well with resolved error object', async () => {
const state = createFieldState(0).withValidator(
_ => ({ message: 'empty' })
)

state.validate()

await delay()
expect(state.hasError).toBe(true)
expect(state.error).toBe('empty')
expect(state.ownError).toBe('empty')
expect(state.rawError).toEqual({ message: 'empty' })
})
})
16 changes: 8 additions & 8 deletions src/debouncedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { action, computed, makeObservable, observable, override, reaction } from
import { FieldState } from './fieldState'
import { ValidatableState } from './state'
import { IState, ValidateStatus, ValueOf } from './types'
import { debounce } from './utils'
import { debounce, isPassed } from './utils'

const defaultDelay = 200 // ms

/** Infomation synced from original state */
type OriginalInfo<V> = Pick<IState<V>, 'activated' | 'touched' | 'error' | 'ownError'>
/** Information synced from original state */
type OriginalInfo<V> = Pick<IState<V>, 'activated' | 'touched' | 'error' | 'rawError'>

/**
* The state for debounce purpose.
Expand All @@ -24,7 +24,7 @@ export class DebouncedState<S extends IState<V>, V = ValueOf<S>> extends Validat
/** Debounced version of original value */
@observable.ref value!: V

/** Orignal information, same version with current `value` */
/** Original information, same version with current `value` */
@observable.ref private synced!: OriginalInfo<V>

/** Original information for current `value` */
Expand All @@ -42,18 +42,18 @@ export class DebouncedState<S extends IState<V>, V = ValueOf<S>> extends Validat
activated: this.$.activated,
touched: this.$.touched,
error: this.$.error,
ownError: this.$.ownError
rawError: this.$.rawError
}
}

@computed get touched() {
return this.original.touched
}

@override override get ownError() {
@override override get rawError() {
if (this.disabled) return undefined
if (this._error) return this._error
return this.original.ownError
if (!isPassed(this.validationResult)) return this.validationResult
return this.original.rawError
}

@override override get error() {
Expand Down
56 changes: 56 additions & 0 deletions src/fieldState.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,35 @@ describe('FieldState', () => {
expect(state.value).toBe(initialValue)
expect(state.touched).toBe(false)
})

it('should setError well', async () => {
const initialValue = ''
const state = new FieldState(initialValue)

state.setError('')
expect(state.hasError).toBe(false)
expect(state.error).toBe(undefined)
expect(state.ownError).toBe(undefined)
expect(state.rawError).toBe('')

state.setError('123')
expect(state.hasError).toBe(true)
expect(state.error).toBe('123')
expect(state.ownError).toBe('123')
expect(state.rawError).toBe('123')

state.setError({ message: 'mewo3' })
expect(state.hasError).toBe(true)
expect(state.error).toBe('mewo3')
expect(state.ownError).toBe('mewo3')
expect(state.rawError).toEqual({ message: 'mewo3' })

state.reset()
expect(state.hasError).toBe(false)
expect(state.error).toBe(undefined)
expect(state.ownError).toBe(undefined)
expect(state.rawError).toBe(undefined)
})
})

describe('FieldState validation', () => {
Expand Down Expand Up @@ -373,4 +402,31 @@ describe('FieldState validation', () => {
assertType<string>(res.value)
}
})

describe('should work well with resolved error object', () => {
it('should work well with sync resolved', async () => {
const state = new FieldState('').withValidator(
_ => ({ message: 'error-object-msg' })
)

const res = await state.validate()
expect(state.hasError).toBe(true)
expect(state.error).toBe('error-object-msg')
expect(state.rawError).toEqual({ message: 'error-object-msg' })
expect(res).toEqual({ hasError: true, error: 'error-object-msg' })
})

it('should work well with async resolved', async () => {
const state = new FieldState('').withValidator(
_ => null,
_ => delayValue({ message: 'error-object-msg' }, 100)
)

const res = await state.validate()
expect(state.hasError).toBe(true)
expect(state.error).toBe('error-object-msg')
expect(state.rawError).toEqual({ message: 'error-object-msg' })
expect(res).toEqual({ hasError: true, error: 'error-object-msg' })
})
})
})
Loading

0 comments on commit ec9e6cf

Please sign in to comment.