UNPKG

mobx-react-form

Version:
898 lines (760 loc) 26.2 kB
import { observable, observe, action, computed, isObservableArray, toJS, untracked, makeObservable, autorun, runInAction, } from "mobx"; import _ from "lodash"; import Base from "./Base"; import { $try, hasFiles, isBool, isEvent, pathToStruct, isArrayFromStruct } from "./utils"; import { parseInput, parseCheckOutput, defaultValue, } from "./parser"; import { OptionsModel, OptionsEnum } from "./models/OptionsModel"; import { FieldInterface, FieldConstructor } from "./models/FieldInterface"; import { FieldPropsEnum } from "./models/FieldProps"; const applyFieldPropFunc = (instance: FieldInterface, prop: any): any => { if (typeof prop !== 'function') return prop; return prop.apply(instance, [{ field: instance, form: instance.state.form }]) }; const retrieveFieldPropFunc = (prop: any): Function | any | undefined => (typeof prop === 'function') ? prop : undefined; const propGetter = (instance: FieldInterface, prop: FieldPropsEnum): any => (typeof instance[`_${prop}`] === 'function') ? instance[`_${prop}`].apply(instance, [{ form: instance.state.form, field: instance, }]) : instance[`$${prop}`]; const setupFieldProps = (instance: FieldInterface, props: any, data: any) => Object.assign(instance, { // retrieve functions _label: retrieveFieldPropFunc(props.$label || data?.label), _placeholder: retrieveFieldPropFunc(props.$placeholder || data?.placeholder), _disabled: retrieveFieldPropFunc(props.$disabled || data?.disabled), _rules: retrieveFieldPropFunc(props.$rules || data?.rules), _related: retrieveFieldPropFunc(props.$related || data?.related), _deleted: retrieveFieldPropFunc(props.$deleted || data?.deleted), _validators: retrieveFieldPropFunc(props.$validators || data?.validators), _validatedWith: retrieveFieldPropFunc(props.$validatedWith || data?.validatedWith), _bindings: retrieveFieldPropFunc(props.$bindings || data?.bindings), _extra: retrieveFieldPropFunc(props.$extra || data?.extra), _options: retrieveFieldPropFunc(props.$options || data?.options), _autoFocus: retrieveFieldPropFunc(props.$autoFocus || data?.autoFocus), _inputMode: retrieveFieldPropFunc(props.$inputMode || data?.inputMode), // apply functions or value $label: applyFieldPropFunc(instance, props.$label || data?.label || ""), $placeholder: applyFieldPropFunc(instance, props.$placeholder || data?.placeholder || ""), $disabled: applyFieldPropFunc(instance, props.$disabled || data?.disabled || false), $rules: applyFieldPropFunc(instance, props.$rules || data?.rules || null), $related: applyFieldPropFunc(instance, props.$related || data?.related || []), $deleted: applyFieldPropFunc(instance, props.$deleted || data?.deleted || false), $validatedWith: applyFieldPropFunc(instance, props.$validatedWith || data?.validatedWith || FieldPropsEnum.value), $bindings: applyFieldPropFunc(instance, props.$bindings || data?.bindings || FieldPropsEnum.default), $extra: applyFieldPropFunc(instance, props.$extra || data?.extra || null), $options: applyFieldPropFunc(instance, props.$options || data?.options || {}), $autoFocus: applyFieldPropFunc(instance, props.$autoFocus || data?.autoFocus || false), $inputMode: applyFieldPropFunc(instance, props.$inputMode || data?.inputMode || undefined), $validators: applyFieldPropFunc(instance, props.$validators || data?.validators || null), // other props $hooks: props.$hooks || data?.hooks || {}, $handlers: props.$handlers || data?.handlers || {}, $observers: props.$observers || data?.observers || null, $interceptors: props.$interceptors || data?.interceptors || null, $ref: props.$ref || data?.ref || undefined, $nullable: props.$nullable || data?.nullable || false, $autoComplete: props.$autoComplete || data?.autoComplete || undefined, }); const setupDefaultProp = ( instance: Field, data: any, props: any, update: boolean, { isEmptyArray, fallbackValueOption }: { isEmptyArray: boolean, fallbackValueOption: any } ) => parseInput((val) => val, { isEmptyArray, type: instance.type, unified: update ? defaultValue({ fallbackValueOption, type: instance.type, value: instance.value }) : data?.default, separated: props.$default, fallback: instance.$initial, }); export default class Field extends Base implements FieldInterface { hasInitialNestedFields = false; incremental = false; id: any; key: any; name: any; $observers: any; $interceptors: any; $converter = ($: any) => $; $input = ($: any) => $; $output = ($: any) => $; _value: Function; _label: Function; _placeholder: Function; _disabled: Function; _rules: Function; _related: Function; _deleted: Function; _validatedWith: Function; _validators: Function; _bindings: Function; _extra: Function; _options: Function; _autoFocus: Function; _inputMode: Function; $options: OptionsModel | undefined; $value: any; $type: string | undefined; $label: string | undefined; $placeholder: string | undefined; $default: any; $initial: any; $bindings: any; $extra: any; $related: string[] | undefined; $validatedWith: string | undefined; $validators: any[] | undefined; $rules: string[] | undefined; $disabled: boolean = false; $focused: boolean = false; $blurred: boolean = false; $deleted: boolean = false; $autoFocus: boolean = false; $inputMode: string = undefined; $ref: any = undefined; $nullable: boolean = false; $autoComplete: string|undefined = undefined; showError: boolean = false; errorSync: string | null = null; errorAsync: string | null = null; validationErrorStack: string[] = []; validationFunctionsData: any[] = []; validationAsyncData = { valid: true, message: null }; debouncedValidation: any; disposeValidationOnBlur: any; disposeValidationOnChange: any; files: any; constructor({ key, path, struct, data = {}, props = {}, update = false, state, }: FieldConstructor) { super(); makeObservable(this, { $options: observable, $value: observable, $type: observable, $label: observable, $placeholder: observable, $default: observable, $initial: observable, $bindings: observable, $extra: observable, $related: observable, $validatedWith: observable, $validators: observable, $rules: observable, $disabled: observable, $focused: observable, $blurred: observable, $deleted: observable, showError: observable, errorSync: observable, errorAsync: observable, validationErrorStack: observable, validationFunctionsData: observable, validationAsyncData: observable, files: observable, autoFocus: computed, inputMode: computed, ref: computed, checkValidationErrors: computed, checked: computed, value: computed, initial: computed, default: computed, actionRunning: computed, type: computed, label: computed, placeholder: computed, extra: computed, options: computed, bindings: computed, related: computed, disabled: computed, rules: computed, validators: computed, validatedValue: computed, error: computed, hasError: computed, isValid: computed, isDefault: computed, isDirty: computed, isPristine: computed, isEmpty: computed, blurred: computed, touched: computed, deleted: computed, setupField: action, initNestedFields: action, invalidate: action, setValidationAsyncData: action, resetValidation: action, clear: action, reset: action, focus: action, blur: action, showErrors: action, update: action }); this.state = state; this.setupField(key, path, struct, data, props, update); // this.checkValidationPlugins(); this.initNestedFields(data, update); this.incremental = this.hasIncrementalKeys; this.debouncedValidation = _.debounce( this.validate, this.state.options.get(OptionsEnum.validationDebounceWait, this), this.state.options.get(OptionsEnum.validationDebounceOptions, this) ); this.observeValidationOnBlur(); this.observeValidationOnChange(); this.initMOBXEvent(FieldPropsEnum.observers); this.initMOBXEvent(FieldPropsEnum.interceptors); // setup hooks & handlers from initialization methods runInAction(() => Object.assign(this.$hooks, (this as any).hooks?.apply(this, [this]))); runInAction(() => Object.assign(this.$handlers, (this as any).handlers?.apply(this, [this]))); this.execHook(FieldPropsEnum.onInit); // handle Field onChange Hook autorun(() => this.changed && this.execHook(FieldPropsEnum.onChange)); } /* ------------------------------------------------------------------ */ /* COMPUTED */ get checkValidationErrors(): boolean { return ( !this.validationAsyncData.valid || !_.isEmpty(this.validationErrorStack) || _.isString(this.errorAsync) || _.isString(this.errorSync) ); } set value(newVal) { if (_.isString(newVal) && this.state.options.get(OptionsEnum.autoTrimValue, this)) { newVal = newVal.trim(); } if (this.$value === newVal) return; if (this.handleSetNumberValue(newVal)) return; this.$value = this.$converter(newVal); this.$changed ++; if (!this.actionRunning) { this.state.form.$changed ++; }; } handleSetNumberValue(newVal: any): boolean { if (!this.state.options.get(OptionsEnum.autoParseNumbers, this)) return false; if (_.isNumber(this.$initial) || this.type == 'number') { if (new RegExp("^-?\\d+(,\\d+)*(\\.\\d+([eE]\\d+)?)?$", "g").exec(newVal)) { this.$value = this.$converter(_.toNumber(newVal)); this.$changed ++; if (!this.actionRunning) { this.state.form.$changed ++; }; return true; } } } get actionRunning(): boolean { return this.submitting || this.clearing || this.resetting; } get checked(): boolean { return this.type === "checkbox" ? this.value : undefined; } get value(): any { return (typeof this._value === 'function' && !this.hasNestedFields) ? propGetter(this, FieldPropsEnum.value) : this.getComputedProp(FieldPropsEnum.value); } get initial(): any { return this.$initial ? toJS(this.$initial) : this.getComputedProp(FieldPropsEnum.initial); } get default(): any { return this.$default ? toJS(this.$default) : this.getComputedProp(FieldPropsEnum.default); } set initial(val) { this.$initial = val; } set default(val) { this.$default = val; } get nullable(): boolean { return propGetter(this, FieldPropsEnum.nullable); } get autoComplete(): string|undefined { return propGetter(this, FieldPropsEnum.autoComplete); } get ref() { return propGetter(this, FieldPropsEnum.ref) } get extra() { return propGetter(this, FieldPropsEnum.extra) } get autoFocus() { return propGetter(this, FieldPropsEnum.autoFocus) } get inputMode() { return propGetter(this, FieldPropsEnum.inputMode) } get type() { return propGetter(this, FieldPropsEnum.type) } get label() { return propGetter(this, FieldPropsEnum.label) } get placeholder() { return propGetter(this, FieldPropsEnum.placeholder) } get options() { return propGetter(this, FieldPropsEnum.options) } get bindings() { return propGetter(this, FieldPropsEnum.bindings) } get related() { return propGetter(this, FieldPropsEnum.related) } get disabled() { return propGetter(this, FieldPropsEnum.disabled) } get rules() { return propGetter(this, FieldPropsEnum.rules) } get validators() { return propGetter(this, FieldPropsEnum.validators) } get validatedWith() { return propGetter(this, FieldPropsEnum.validatedWith) } get validatedValue() { return parseCheckOutput(this, this.validatedWith); } get error(): string { if (this.showError === false) return null; return this.errorAsync || this.errorSync || null; } get hasError(): boolean { return this.checkValidationErrors || this.check(FieldPropsEnum.hasError, true); } get isValid(): boolean { return !this.checkValidationErrors && this.check(FieldPropsEnum.isValid, true); } get isDefault(): boolean { return !_.isNil(this.default) && _.isEqual(this.default, this.value); } get isDirty(): boolean { const value = this.changed ? this.value : this.initial; return !_.isEqual(this.initial, value); } get isPristine(): boolean { const value = this.changed ? this.value : this.initial; return _.isEqual(this.initial, value); } get isEmpty(): boolean { if (this.hasNestedFields) return this.check(FieldPropsEnum.isEmpty, true); if (_.isBoolean(this.value)) return !!this.$value; if (_.isNumber(this.value)) return false; if (_.isDate(this.value)) return false; if (_.isNull(this.value)) return false; return _.isEmpty(this.value); } get focused(): boolean { return this.hasNestedFields ? this.check(FieldPropsEnum.focused, true) : this.$focused; } get blurred(): boolean { return this.hasNestedFields ? this.check(FieldPropsEnum.blurred, true) : this.$blurred; } get touched(): boolean { return this.hasNestedFields ? this.check(FieldPropsEnum.touched, true) : this.$touched; } get deleted(): boolean { return this.hasNestedFields ? this.check(FieldPropsEnum.deleted, true) : this.$deleted; } /* ------------------------------------------------------------------ */ /* EVENTS HANDLERS */ sync = action((e: any, v: any = null) => { const $get = ($: any) => isBool($, this.value) ? $.target.checked : $.target.value; // assume "v" or "e" are the values if (_.isNil(e) || _.isNil(e.target)) { if (!_.isNil(v) && !_.isNil(v.target)) { v = $get(v); // eslint-disable-line } this.value = $try(e, v); return; } if (!_.isNil(e.target)) { this.value = $get(e); return; } this.value = e; }); onSync = (...args: any) => this.type === "file" ? this.onDrop(...args) : this.execHandler( FieldPropsEnum.onChange, args, this.sync, FieldPropsEnum.onSync ); onChange = this.onSync; onToggle = (...args: any) => this.execHandler( FieldPropsEnum.onToggle, args, this.sync ); onBlur = (...args: any) => this.execHandler( FieldPropsEnum.onBlur, args, action(() => { this.$focused = false; this.$blurred = true; }) ); onFocus = (...args: any) => this.execHandler( FieldPropsEnum.onFocus, args, action(() => { this.$focused = true; this.$touched = true; }) ); onDrop = (...args: any) => this.execHandler( FieldPropsEnum.onDrop, args, action(() => { const e = args[0]; let files: unknown[] | null = null; if (isEvent(e) && hasFiles(e)) { files = _.map(e.target.files); } this.files = [ ..._.map(this.files), ...(files || args) ]; }) ); onKeyDown = (...args: any) => this.execHandler( FieldPropsEnum.onKeyDown, args, ); onKeyUp = (...args: any) => this.execHandler( FieldPropsEnum.onKeyUp, args, ); setupField( $key: string, $path: string, $struct: string, $data: any, $props: any, update: boolean ): void { this.key = $key; this.path = $path; this.id = this.state.options.get(OptionsEnum.uniqueId)?.apply(this, [this]); const fallbackValueOption: any = this.state.options.get(OptionsEnum.fallbackValue, this); const applyInputConverterOnInit: boolean = this.state.options.get(OptionsEnum.applyInputConverterOnInit, this); const struct: string[] = this.state.struct(); const structPath: string = pathToStruct(this.path); const isEmptyArray: boolean = isArrayFromStruct(struct, structPath); const { $type, $input, $output, $converter, $converters, $computed } = $props; if (_.isPlainObject($data)) { const { type, input, output, converter, converters, computed } = $data; this.name = _.toString($data.name || $key); this.$type = $type || type || "text"; this.$converter = $try($converter, $converters, converter, converters, this.$converter); this.$input = $try($input, input, this.$input); this.$output = $try($output, output, this.$output); const value = parseInput(applyInputConverterOnInit ? this.$input : (val) => val, { fallbackValueOption, isEmptyArray, type: this.type, unified: computed || $data.value, separated: $computed || $props.$value, fallback: $props.$initial, }); this._value = retrieveFieldPropFunc(value) this.$value = (typeof this._value === 'function') ? applyFieldPropFunc(this, value) : value; this.$initial = parseInput((val) => val, { fallbackValueOption, isEmptyArray, type: this.type, unified: $data.initial, separated: $props.$initial, fallback: this.$value, }); this.$default = setupDefaultProp(this, $data, $props, update, { fallbackValueOption, isEmptyArray, }); setupFieldProps(this, $props, $data); return; } /* The field IS the value here */ this.name = _.toString($key); this.$type = $type || "text"; this.$converter = $try($converter, $converters, this.$converter); this.$input = $try($input, this.$input); this.$output = $try($output, this.$output); const value = parseInput(applyInputConverterOnInit ? this.$input : (val) => val, { fallbackValueOption, isEmptyArray, type: this.type, unified: $computed || $data, separated: $computed || $props.$value, }); this._value = retrieveFieldPropFunc(value) this.$value = (typeof this._value === 'function') ? applyFieldPropFunc(this, value) : value; this.$initial = parseInput((val) => val, { fallbackValueOption, isEmptyArray, type: this.type, unified: $data, separated: $props.$initial, fallback: this.$value, }); this.$default = setupDefaultProp(this, $data, $props, update, { fallbackValueOption, isEmptyArray, }); setupFieldProps(this, $props, $data); } getComputedProp(key: string): any { if (this.incremental || this.hasNestedFields) { return (key === FieldPropsEnum.value) ? this.get(key, false) : untracked(() => this.get(key, false)); } // @ts-ignore const val = this[`$${key}`]; if (Array.isArray(val) || isObservableArray(val)) { return [].slice.call(val); } return toJS(val); } // checkValidationPlugins(): void { // const { drivers } = this.state.form.validator; // const form = this.state.form.name ? `${this.state.form.name}/` : ""; // if (_.isNil(drivers.dvr) && !_.isNil(this.rules)) { // throw new Error( // `The DVR validation rules are defined but no DVR plugin provided. Field: "${ // form + this.path // }".` // ); // } // if (_.isNil(drivers.vjf) && !_.isNil(this.validators)) { // throw new Error( // `The VJF validators functions are defined but no VJF plugin provided. Field: "${ // form + this.path // }".` // ); // } // } initNestedFields(field: any, update: boolean): void { const fields = _.isNil(field) ? null : field.fields; if (Array.isArray(fields) && !_.isEmpty(fields)) { this.hasInitialNestedFields = true; } this.initFields({ fields }, update); if (!update && Array.isArray(fields) && _.isEmpty(fields)) { if (Array.isArray(this.value) && !_.isEmpty(this.value)) { this.hasInitialNestedFields = true; this.initFields({ fields, values: this.value }, update); } } } invalidate(message: string, deep: boolean = true, async: boolean = false): void { if (async === true) { this.errorAsync = message; this.showErrors(true, deep); return; } if (Array.isArray(message)) { this.validationErrorStack = message; this.showErrors(true, deep); return; } this.validationErrorStack.unshift(message); this.showErrors(true, deep); } setValidationAsyncData(valid: boolean = false, message: string|null = null): void { this.validationAsyncData = { valid, message }; } resetValidation(deep: boolean = false): void { this.showError = false; this.errorSync = null; this.errorAsync = null; this.validationAsyncData = { valid: true, message: null }; this.validationFunctionsData = []; this.validationErrorStack = []; Promise.resolve().then(action(() => { this.$resetting = false; this.$clearing = false; })) deep && this.each((field: FieldInterface) => field.resetValidation(deep)); } clear(deep: boolean = true, execHook: boolean = true): void { execHook && this.execHook(FieldPropsEnum.onClear); this.$clearing = true; this.$touched = false; this.$blurred = false; this.$changed = 0; this.files = undefined; this.$value = defaultValue({ fallbackValueOption: this.state.options.get(OptionsEnum.fallbackValue), value: this.$value, nullable: this.$nullable, type: this.type, }); deep && this.each((field: FieldInterface) => field.clear(deep)); this.state.options.get(OptionsEnum.validateOnClear, this) ? this.validate({ showErrors: this.state.options.get(OptionsEnum.showErrorsOnClear, this), }) : this.resetValidation(deep); } reset(deep: boolean = true, execHook: boolean = true): void { execHook && this.execHook(FieldPropsEnum.onReset); this.$resetting = true; this.$touched = false; this.$blurred = false; this.$changed = 0; this.files = undefined; const useDefaultValue = this.$default !== this.$initial; if (useDefaultValue) this.value = this.$default; if (!useDefaultValue) this.value = this.$initial; deep && this.each((field: FieldInterface) => field.reset(deep)); this.state.options.get(OptionsEnum.validateOnReset, this) ? this.validate({ showErrors: this.state.options.get(OptionsEnum.showErrorsOnReset, this), }) : this.resetValidation(deep); } focus(): void { if(this.ref && !this.focused) this.ref.focus(); this.$focused = true; this.$touched = true; } blur(): void { if(this.ref && this.focused) this.ref.blur(); this.$focused = false; this.$blurred = true; } trim(): void { if (!_.isString(this.value)) return; this.$value = this.value.trim(); } showErrors(show: boolean = true, deep: boolean = true): void { this.showError = show; this.errorSync = _.head(this.validationErrorStack) as string || null; this.errorAsync = !this.validationAsyncData.valid ? this.validationAsyncData.message : null deep && this.each((field: FieldInterface) => field.showErrors(show, deep)); } observeValidationOnBlur(): void { const opt = this.state.options; if (opt.get(OptionsEnum.validateOnBlur, this)) { this.disposeValidationOnBlur = observe(this, "$focused", (change) => change.newValue === false && this.debouncedValidation({ showErrors: opt.get(OptionsEnum.showErrorsOnBlur, this), }) ); } } observeValidationOnChange(): void { const opt = this.state.options; if (opt.get(OptionsEnum.validateOnChange, this)) { this.disposeValidationOnChange = observe(this, "$value", () => !this.actionRunning && this.debouncedValidation({ showErrors: opt.get(OptionsEnum.showErrorsOnChange, this), }) ); } else if ( opt.get(OptionsEnum.validateOnChangeAfterInitialBlur, this) || opt.get(OptionsEnum.validateOnChangeAfterSubmit, this) ) { this.disposeValidationOnChange = observe(this, "$value", () => !this.actionRunning && ((opt.get(OptionsEnum.validateOnChangeAfterInitialBlur, this) && this.blurred) || (opt.get(OptionsEnum.validateOnChangeAfterSubmit, this) && this.state.form.submitted)) && this.debouncedValidation({ showErrors: opt.get(OptionsEnum.showErrorsOnChange, this), }) ); } } initMOBXEvent(type: string): void { if (!Array.isArray(this[`$${type}`])) return; let fn: any; if (type === FieldPropsEnum.observers) fn = this.observe; if (type === FieldPropsEnum.interceptors) fn = this.intercept; this[`$${type}`].map((obj: any) => fn(_.omit(obj, FieldPropsEnum.path))); } bind(props = {}) { return { ...this.state.bindings.load(this, this.bindings, props), ref: ($ref) => (this.$ref = $ref), } } update(fields: any): void { if (!_.isPlainObject(fields)) { throw new Error("The update() method accepts only plain objects."); } const fallback = this.state.options.get(OptionsEnum.fallback, this); const applyInputConverterOnUpdate = this.state.options.get(OptionsEnum.applyInputConverterOnUpdate, this); const x = this.state.struct().findIndex(s => s.startsWith(this.path.replace(/\.\d+\./, '[].') + '[]')); if (!fallback && this.fields.size === 0 && x < 0) { this.value = parseInput(applyInputConverterOnUpdate ? this.$input : (val) => val, { fallbackValueOption: this.state.options.get(OptionsEnum.fallbackValue, this), separated: fields, }); return; } super.update(fields); } }