Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Safe Value #80

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/debouncedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type OriginalInfo<V> = Pick<IState<V>, 'activated' | 'touched' | 'error' | 'ownE
* The state for debounce purpose.
* Changes from the original state (`$`) will be debounced.
*/
export class DebouncedState<S extends IState<V>, V = ValueOf<S>> extends ValidatableState<V> implements IState<V> {
export class DebouncedState<S extends IState<V>, V = ValueOf<S>, SV extends V = V> extends ValidatableState<V, SV> implements IState<V, SV> {

/**
* The original state.
Expand Down Expand Up @@ -136,7 +136,7 @@ export class DebouncedState<S extends IState<V>, V = ValueOf<S>> extends Validat
* The field state with debounce.
* Value changes from `onChange` will be debounced.
*/
export class DebouncedFieldState<V> extends DebouncedState<FieldState<V>, V> {
export class DebouncedFieldState<V, SV extends V = V> extends DebouncedState<FieldState<V, SV>, V, SV> {
constructor(initialValue: V, delay = defaultDelay) {
super(new FieldState(initialValue), delay)
}
Expand Down
2 changes: 1 addition & 1 deletion src/fieldState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ValidatableState } from './state'
/**
* The state for a field.
*/
export class FieldState<V> extends ValidatableState<V> implements IState<V> {
export class FieldState<V, SV extends V = V> extends ValidatableState<V, SV> implements IState<V, SV> {

@observable.ref value!: V

Expand Down
12 changes: 6 additions & 6 deletions src/formState.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { observable, computed, isObservable, action, reaction, makeObservable, override } from 'mobx'
import { IState, ValidateStatus, ValidateResult, ValueOfStatesObject } from './types'
import { IState, ValidateStatus, ValidateResult, ValueOfStatesObject, SafeValueOfStatesObject } from './types'
import { ValidatableState } from './state'

abstract class AbstractFormState<T, V> extends ValidatableState<V> implements IState<V> {
abstract class AbstractFormState<T, V, SV extends V = V> extends ValidatableState<V, SV> implements IState<V, SV> {

/** Reference of child states. */
abstract readonly $: T
Expand Down Expand Up @@ -66,7 +66,7 @@ abstract class AbstractFormState<T, V> extends ValidatableState<V> implements IS
this.resetChildStates()
}

override async validate(): Promise<ValidateResult<V>> {
override async validate(): Promise<ValidateResult<SV>> {
if (this.disabled) {
return this.validateResult
}
Expand Down Expand Up @@ -113,7 +113,7 @@ export type StatesObject = { [key: string]: IState }
export class FormState<
TStates extends StatesObject
> extends AbstractFormState<
TStates, ValueOfStatesObject<TStates>
TStates, ValueOfStatesObject<TStates>, SafeValueOfStatesObject<TStates>
> {

@observable.ref readonly $: Readonly<TStates>
Expand Down Expand Up @@ -171,9 +171,9 @@ export class FormState<
* The state for a array form (list of child states).
*/
export class ArrayFormState<
V, T extends IState<V> = IState<V>
V, SV extends V = V, T extends IState<V, SV> = IState<V, SV>
> extends AbstractFormState<
readonly T[], V[]
readonly T[], V[], SV[]
> {

@observable.ref protected childStates: T[]
Expand Down
62 changes: 61 additions & 1 deletion src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FieldState, FormState, ArrayFormState, TransformedState, DebouncedState, DebouncedFieldState } from '.'
import { defaultDelay, delay } from './testUtils'
import { assertType, defaultDelay, delay } from './testUtils'

describe('FieldState', () => {
it('should be newable', () => {
Expand Down Expand Up @@ -126,3 +126,63 @@ describe('Composition', () => {
expect(hostState.error).toBe('empty hostname')
})
})

interface HostInput {
hostname: string | null
port: number | null
}

function parseHost(input: string): HostInput {
const [hostname, portStr] = input.split(':')
const port = parseInt(portStr, 10)
return { hostname, port }
}

function stringifyHost(host: HostInput) {
const suffix = (host.port == null || Number.isNaN(host.port)) ? '' : `:${host.port}`
return host.hostname + suffix
}

function createRawHostState(host: HostInput) {
const hostnameState = new FieldState<string | null>(host.hostname).withValidator<string>(
v => !v && 'empty hostname'
)
const portState = new FieldState<number | null, number>(host.port)
return new FormState({
hostname: hostnameState,
port: portState
})
}

function createDebouncedHostState(hostStr: string) {
const host = parseHost(hostStr)
const rawState = createRawHostState(host)
const state = new DebouncedState(
new TransformedState(rawState, stringifyHost, parseHost),
defaultDelay
).withValidator(
v => !v && 'empty'
)
return state
}

describe('safeValue', () => {
it('should work well', () => {
const state = new FieldState<string | null>('foo').withValidator<string>(
v => v == null && 'empty'
)
assertType<string>(state.safeValue)
})
it('should work well with multiple validators', () => {
const state = new FieldState<string | number | null>('foo').withValidator<string | number>(
v => v == null && 'empty'
).withValidator<string>(
v => typeof v !== 'string' && 'not string'
)
assertType<string>(state.safeValue)
})
it('should work well with complex states', () => {
const rawHostState = createRawHostState({ hostname: 'foo', port: 80 })
assertType<{ hostname: string, port: number }>(rawHostState.safeValue)
})
})
19 changes: 12 additions & 7 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,18 @@ export abstract class BaseState extends Disposable implements Pick<
}

/** Extraction for State's validating logic */
export abstract class ValidatableState<V> extends BaseState implements IState<V> {
export abstract class ValidatableState<V, SV extends V> extends BaseState implements IState<V, SV> {

abstract value: V
abstract touched: boolean
abstract onChange(value: V): void
abstract set(value: V): void

@computed get safeValue(): SV {
if (!this.validated || this.hasError) throw new Error('TODO')
return this.value as SV
}

/** The original validate status (regardless of `validationDisabled`) */
@observable protected _validateStatus: ValidateStatus = ValidateStatus.NotValidated

Expand Down Expand Up @@ -76,9 +81,9 @@ export abstract class ValidatableState<V> extends BaseState implements IState<V>
/** List of validator functions. */
@observable.shallow private validatorList: Validator<V>[] = []

@action withValidator(...validators: Validator<V>[]) {
@action withValidator<NSV = this['safeValue']>(...validators: Validator<V>[]): (this & { safeValue: NSV }) {
this.validatorList.push(...validators)
return this
return this as (this & { safeValue: NSV })
}

/** Current validation info. */
Expand Down Expand Up @@ -108,15 +113,15 @@ export abstract class ValidatableState<V> extends BaseState implements IState<V>
})()
}

@computed protected get validateResult(): ValidateResult<V> {
@computed protected get validateResult(): ValidateResult<SV> {
return (
this.error
? { hasError: true, error: this.error } as const
: { hasError: false, value: this.value } as const
? { hasError: true, error: this.error }
: { hasError: false, value: this.value as SV }
)
}

async validate(): Promise<ValidateResult<V>> {
async validate(): Promise<ValidateResult<SV>> {
if (this.disabled) {
return this.validateResult
}
Expand Down
13 changes: 9 additions & 4 deletions src/transformedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { computed } from 'mobx'
import { BaseState } from './state'
import { IState, Validator, ValueOf } from './types'

export class TransformedState<S extends IState<$V>, V, $V = ValueOf<S>> extends BaseState implements IState<V> {
export class TransformedState<S extends IState<$V>, V, $V = ValueOf<S>, SV extends V = V> extends BaseState implements IState<V, SV> {

/** The original state, whose value will be transformed. */
public $: S
Expand All @@ -23,6 +23,11 @@ export class TransformedState<S extends IState<$V>, V, $V = ValueOf<S>> extends
return this.parseOriginalValue(this.$.value)
}

@computed get safeValue() {
if (!this.validated || this.hasError) throw new Error('TODO')
return this.value as SV
}

@computed get ownError() {
return this.$.ownError
}
Expand All @@ -46,7 +51,7 @@ export class TransformedState<S extends IState<$V>, V, $V = ValueOf<S>> extends
async validate() {
const result = await this.$.validate()
if (result.hasError) return result
return { ...result, value: this.value }
return { ...result, value: this.value as SV }
}

set(value: V) {
Expand All @@ -61,12 +66,12 @@ export class TransformedState<S extends IState<$V>, V, $V = ValueOf<S>> extends
this.$.reset()
}

withValidator(...validators: Array<Validator<V>>) {
withValidator<NSV = SV>(...validators: Array<Validator<V>>): (this & { safeValue: NSV }) {
const rawValidators = validators.map(validator => (
(rawValue: $V) => validator(this.parseOriginalValue(rawValue))
))
this.$.withValidator(...rawValidators)
return this
return this as (this & { safeValue: NSV })
}

disableWhen(predict: () => boolean) {
Expand Down
21 changes: 15 additions & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ export type ValidateResultWithValue<T> = { hasError: false, value: T }
export type ValidateResult<T> = ValidateResultWithError | ValidateResultWithValue<T>

/** interface for State */
export interface IState<V = unknown> {
export interface IState<Value = unknown, SafeValue extends Value = Value> {
/** Value in the state. */
value: V
value: Value
safeValue: SafeValue
/** If value has been touched. */
touched: boolean
/** The error info of validation. */
Expand All @@ -50,15 +51,15 @@ export interface IState<V = unknown> {
*/
validated: boolean
/** Fire a validation behavior. */
validate(): Promise<ValidateResult<V>>
validate(): Promise<ValidateResult<this['safeValue']>>
/** Set `value` on change event. */
onChange(value: V): void
onChange(value: Value): void
/** Set `value` imperatively. */
set(value: V): void
set(value: Value): void
/** Reset to initial status. */
reset(): void
/** Append validator(s). */
withValidator(...validators: Array<Validator<V>>): this
withValidator<NSV = SafeValue>(...validators: Array<Validator<Value>>): (this & { safeValue: NSV })
/**
* Configure when state should be disabled, which means:
* - corresponding UI is invisible or disabled
Expand All @@ -71,6 +72,9 @@ export interface IState<V = unknown> {
dispose(): void
}

/** Safe Value of `IState` */
export type SafeValueOf<S> = S extends IState ? S['safeValue'] : never

/** Function to do dispose. */
export interface Disposer {
(): void
Expand All @@ -81,6 +85,11 @@ export type ValueOfStatesObject<StatesObject> = {
[K in keyof StatesObject]: ValueOf<StatesObject[K]>
}

/** Safe Value of states object. */
export type SafeValueOfStatesObject<StatesObject> = {
[K in keyof StatesObject]: SafeValueOf<StatesObject[K]>
}

/** Value of `IState` */
export type ValueOf<S> = S extends IState<infer V> ? V : never

Expand Down