import { observable, observe, action, computed, isObservableArray, toJS, untracked, makeObservable, autorun, runInAction, } from "mobx"; import { debounce, head, isEmpty, isBoolean, isDate, isEqual, isNil, isNull, isNumber, isPlainObject, isString, map, omit, toNumber, toString, } 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 = undefined; $value: any = undefined; $type: string | undefined = undefined; $label: string | undefined = undefined; $placeholder: string | undefined = undefined; $default: any = undefined; $initial: any = undefined; $bindings: any = undefined; $extra: any = undefined; $related: string[] | undefined = undefined; $validatedWith: string | undefined = undefined; $validators: any[] | undefined = undefined; $rules: string[] | undefined = 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 = undefined; 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); } }