UNPKG

react-native-form-model

Version:

An easily testable and opinionated React Native form model builder written in pure JavaScript.

277 lines (250 loc) 8.74 kB
import _ from 'lodash'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { InputFieldViewLike } from '..'; import { lz } from '../../util/locale'; import { EditableFieldModel } from '../FormElement'; import FormError, { FormParseError, FormValidationError } from '../FormError'; import { InputFieldModelLike, InputFieldValidationResult, InputFieldValidationValue, ViewRef, } from '../formTypes'; import FieldModel, { FieldModelOptions } from './FieldModel'; export interface InputFieldState<T> { value?: T; error?: FormError; } export interface InputFieldValueInfo<T, I = string> extends InputFieldState<T> { field: InputFieldModel<T, I>; } export interface InputFieldModelOptions<T, I = string> extends FieldModelOptions { value: BehaviorSubject<T>; onValueChange?: (info: InputFieldValueInfo<T, I>) => void; defaultValue?: T; placeholder?: string; disabled?: boolean; autoFocus?: boolean; /** * If true, this field will be skipped, when the previous * submitted field is searching for the next field to focus. */ skipNextFocus?: boolean; /** Parse an input value or throw an error. */ parseInput: (input: I) => T; formatValue?: (value: T | undefined) => string; validation?: (value?: T) => InputFieldValidationValue; } export interface InputFieldModelDelegate<T, I> { willValidate: (field: InputFieldModel<T, I>) => void; } export type ParsedInputFieldModelOptions<T, I> = Omit< InputFieldModelOptions<T, I>, 'parseInput' > & Partial<Pick<InputFieldModelOptions<T, I>, 'parseInput'>>; export default class InputFieldModel< T, I = string, View extends InputFieldViewLike = any > extends FieldModel<View> implements EditableFieldModel, InputFieldModelLike<T> { readonly value: BehaviorSubject<T>; readonly edited: BehaviorSubject<boolean>; defaultValue: T; placeholder: string; disabled: boolean; autoFocus: boolean; skipNextFocus: boolean; /** Parse an input value or throw an error. */ parseInput: (input: I) => T; formatValue: (value: T | undefined) => string; validation?: (value?: T) => InputFieldValidationValue; delegate?: InputFieldModelDelegate<T, I>; private _onValueChangeCb?: InputFieldModelOptions<T, I>['onValueChange']; private _didAutoFocus = false; constructor(options: InputFieldModelOptions<T, I>) { super(options); const { defaultValue = options.value.value, placeholder = '', disabled = false, autoFocus = false, skipNextFocus = false, formatValue = x => x === null || typeof x === 'undefined' ? '' : String(x), parseInput, } = options; this.value = options.value; this.defaultValue = defaultValue; this.edited = new BehaviorSubject<boolean>(false); this.placeholder = placeholder; this.disabled = disabled; this.autoFocus = autoFocus; this.skipNextFocus = skipNextFocus; this.parseInput = parseInput; this.formatValue = formatValue; this.validation = options.validation; this._onValueChangeCb = options.onValueChange; } getState(): InputFieldState<T> { const value = this.value.value; const { error } = this.normalizedValidationResult(value); return error ? { error } : { value }; } parseState(input: I): InputFieldState<T> { let value: T | undefined; let error: Error | undefined; try { // TODO: Debounce input parsing. See [task](https://trello.com/c/CP9flI1D) value = this.parseInput(input); } catch (err: any) { let parsedError: FormParseError = err; if (!(err instanceof FormParseError)) { parsedError = new FormParseError(err?.message || String(err)); } error = parsedError; } if (!error) { const { valid = true, error: validationError } = this.normalizedValidationResult(value); if (!valid) { error = validationError || new FormValidationError(lz('invalidValue')); } } return { value, error }; } setInput(input: I): InputFieldState<T> { const state = this.parseState(input); this.setState(state); return state; } resetInput(): InputFieldState<T> { const value = this.defaultValue; const { error } = this.normalizedValidationResult(value); const state = error ? { error } : { value }; this.setState(state); this.edited.next(false); return state; } isEditable(): this is EditableFieldModel { return true; } isValueValid(value: T | undefined): value is T { return this.normalizedValidationResult(value).valid; } normalizedValidationResult( value: T | undefined ): InputFieldValidationResult { let validation: InputFieldValidationValue | undefined; let valid = true; let error: Error | undefined; try { validation = this.validation?.(value); } catch (err: any) { let parsedError: Error = err; if (!(err instanceof Error)) { parsedError = new Error( String(err?.message || err || 'Unknown validation error') ); } error = parsedError; } if (error) { valid = false; } else if (typeof validation === 'undefined') { // Assume valid } else if (typeof validation === 'boolean') { valid = validation; } else if (typeof validation === 'string') { // Assume error description valid = false; error = new FormValidationError(validation); } else if (typeof validation === 'object') { if (validation instanceof Error) { valid = false; error = validation; } else { if (typeof validation.valid === 'undefined') { valid = !validation.error; } else { valid = !!validation.valid; } error = validation.error || undefined; if (error && !(error instanceof Error)) { error = new FormValidationError(String(error || '')); } } } else { console.error( 'Unexpected form validation result. Assuming invalid.' ); valid = false; error = new Error('Unexpected validation result'); } if (!valid && !error) { error = new FormValidationError(lz('invalidValue')); } return { valid, error }; } validate(): InputFieldValidationResult { this.delegate?.willValidate(this); const { valid = true, error } = this.normalizedValidationResult( this.value.value ); this.setState({ value: this.value.value, error: valid ? undefined : error, }); return { valid, error }; } formattedValue(): Observable<string> { return this.value.pipe(map(v => this.formatValue(v))); } setState({ value, error }: InputFieldState<T>) { if (error) { // if (!(error instanceof FormValidationError)) { // value = this.defaultValue; // } if ( this.errors.value.length !== 1 || error !== this.errors.value[0] ) { this.errors.next([error]); } } else if (this.errors.value.length !== 0) { this.errors.next([]); } const didChangeValue = !_.isEqual(value, this.value.value); if (didChangeValue) { this.value.next(value!); } if (this.isMounted && !this.edited.value) { this.edited.next(true); } if (didChangeValue) { this._onValueChangeCb?.({ value: value!, error, field: this, }); } } onMount(viewRef: ViewRef<View>) { super.onMount(viewRef); if (this.autoFocus && !this._didAutoFocus) { this._didAutoFocus = true; viewRef.current?.focus(); } } onUnmount(viewRef: ViewRef<View>) { super.onUnmount(viewRef); this.edited.next(false); } }