UNPKG

mobx-form

Version:
502 lines (499 loc) 13.3 kB
// src/FormModel.ts import { observable, extendObservable, makeAutoObservable } from "mobx"; import trim from "lodash/trim"; import debounce from "lodash/debounce"; var toString = Object.prototype.toString; var isObject = (o) => o && toString.call(o) === "[object Object]"; var isNullishOrEmpty = (value) => { return typeof value === "undefined" || value === null || value === ""; }; class Field { _name; meta; _model; _waitForBlur = false; _disabled = false; _required = false; _validatedOnce = false; _clearErrorOnValueChange = false; _hasValueFn; get name() { return this._name; } get model() { return this._model; } get validatedAtLeastOnce() { return this._validatedOnce; } get waitForBlur() { return !!this._waitForBlur; } get disabled() { return !!this._disabled; } get required() { if (this.disabled) return false; return !!this._required; } resetInteractedFlag() { this._interacted = false; } markAsInteracted() { this._interacted = true; } resetValidatedOnce() { this._validatedOnce = false; } get hasValue() { if (this._hasValueFn) { return this._hasValueFn(this.value); } if (Array.isArray(this.value)) { return this.value.length > 0; } return !isNullishOrEmpty(this.value); } _validationTs = 0; _validating = false; _initialValue; _value; _interacted = false; _blurredOnce = false; get blurred() { return !!this._blurredOnce; } rawError; get errorMessage() { return this.rawError?.message; } _autoValidate = false; get autoValidate() { return this._autoValidate; } _originalErrorMessage; get valid() { return !this.errorMessage; } get interacted() { return this._interacted; } get value() { return this._value; } _setValueOnly = (val) => { if (!this._interacted) { this._interacted = true; } if (this._value === val) { return; } this._value = val; }; _setValue = (val) => { if (this._value !== val && this._clearErrorOnValueChange && !this.valid) { this.resetError(); } this._setValueOnly(val); if (this._autoValidate) { this._debouncedValidation?.(); } }; set value(val) { this._setValue(val); } setValue = (value, { resetInteractedFlag, commit } = {}) => { if (resetInteractedFlag) { this._setValueOnly(value); this.rawError = undefined; this._interacted = false; } else { this._setValue(value); } if (commit) { this.commit(); } }; restoreInitialValue = ({ resetInteractedFlag = true, commit = true } = {}) => { this.setValue(this._initialValue, { resetInteractedFlag, commit }); }; get dirty() { return this._initialValue !== this.value; } commit() { this._initialValue = this.value; } resetError() { this.rawError = undefined; } clearValidation() { this.resetError(); } markBlurredAndValidate = () => { if (!this._blurredOnce) { this._blurredOnce = true; } this.validate(); }; _validateFn; _doValidate = async () => { const { _validateFn, model } = this; if (!_validateFn) return Promise.resolve(true); const invokeFn = async (vfn, field, fields, model2) => { if (!vfn) return true; if (typeof vfn !== "function") { throw new Error("Validator must be a function or a function[]"); } ret = await vfn({ value: this.value, field, fields, model: model2 }); if (ret === false || ret?.error) { return ret; } }; let ret; if (Array.isArray(_validateFn)) { for (let i = 0;i < _validateFn.length; i++) { const vfn = _validateFn[i]; ret = await invokeFn(vfn, this, model.fields, model); } } else { ret = await invokeFn(_validateFn, this, model.fields, model); } return ret; }; setDisabled(disabled) { if (disabled) { this.resetError(); } this._disabled = disabled; } validate = async (opts) => { this._debouncedValidation?.cancel?.(); return await this._validate(opts); }; get originalErrorMessage() { return this._originalErrorMessage || `Validation for "${this.name}" failed`; } setValidating = (validating) => { this._validating = validating; }; get validating() { return this._validating; } _validate = async ({ force = false } = {}) => { const { required } = this; if (!this._validatedOnce) { this._validatedOnce = true; } const shouldSkipValidation = this.disabled || !required && !this._validateFn; if (shouldSkipValidation) return; if (!force) { const userDidntInteractedWithTheField = !this._interacted; if (userDidntInteractedWithTheField && !this.hasValue) { this.resetError(); return; } if (this.waitForBlur && !this._blurredOnce && !this.errorMessage) { return; } } else { this._blurredOnce = true; } if (required) { if (!this.hasValue) { this.setError({ message: typeof this._required === "string" ? this._required : `Field: "${this.name}" is required` }); return; } this.resetError(); } this.setValidating(true); const validationTs = this._validationTs = Date.now(); let res; try { res = await this._doValidate(); if (validationTs !== this._validationTs) return; this.setValidating(false); if (typeof res === "boolean") { this.setErrorMessage(res ? undefined : this.originalErrorMessage); return; } if (res?.error) { this.setErrorMessage(res.error); return; } this.resetError(); } catch (err) { const errorArg = err; if (validationTs !== this._validationTs) return; this.setValidating(false); let errorToSet = errorArg; const message = errorArg.message; if (!message) { errorToSet = { ...errorArg, message: message || this.originalErrorMessage }; } const error = errorArg.error; if (error) { errorToSet = { ...errorToSet, message: error }; } this.setError(errorToSet); } }; setRequired = (val) => { this._required = val; }; setErrorMessage = (msg) => { if (trim(msg) === "") { msg = undefined; } if (!msg) { this.resetError(); } else { this.setError({ message: msg }); } }; setError = (error) => { this.rawError = error; }; get error() { return this.errorMessage; } _debouncedValidation; constructor(model, value, validatorDescriptor, fieldName) { const DEBOUNCE_THRESHOLD = 300; this._value = value; this._model = model; this._name = fieldName; this._initialValue = value; const { waitForBlur, disabled, errorMessage, validator, hasValue, required, autoValidate = true, meta, validationDebounceThreshold = DEBOUNCE_THRESHOLD, clearErrorOnValueChange } = validatorDescriptor; this._debouncedValidation = debounce(this._validate, validationDebounceThreshold); this._waitForBlur = waitForBlur; this._originalErrorMessage = errorMessage; this._validateFn = validator; this._clearErrorOnValueChange = clearErrorOnValueChange; this._hasValueFn = hasValue; this._required = required; this._autoValidate = autoValidate; this._disabled = disabled; this.meta = meta; makeAutoObservable(this, { _value: observable.ref, _initialValue: observable.ref }); } } class FormModel { get validatedAtLeastOnce() { const keys = this._fieldKeys; return keys.every((key) => this.fields[key].validatedAtLeastOnce); } get dataIsReady() { return this.interacted && this.requiredAreFilled && this.valid; } get requiredFields() { const keys = this._fieldKeys; return keys.filter((key) => this.fields[key].required); } get requiredAreFilled() { const keys = this._fieldKeys; return keys.every((key) => { const field = this.fields[key]; if (field.required) { return !!field.hasValue; } return true; }); } fields = {}; _validating = false; get valid() { if (this._validating) { return false; } const keys = this._fieldKeys; return keys.every((key) => { const field = this.fields[key]; return !!field.valid; }); } get interacted() { const keys = this._fieldKeys; return keys.some((key) => { const field = this.fields[key]; return !!field.interacted; }); } restoreInitialValues(opts = {}) { this._eachField((field) => field.restoreInitialValue(opts)); } commit() { this._eachField((field) => field.commit()); } get dirty() { return this._fieldKeys.some((key) => { const f = this._getField(key); return f.dirty; }); } updateFrom(obj, { resetInteractedFlag = true, ...opts } = {}) { const keys = Object.keys(obj); keys.forEach((key) => this.updateField(key, obj[key], { resetInteractedFlag, ...opts })); } get summary() { return this._fieldKeys.reduce((seq, key) => { const field = this.fields[key]; if (field.errorMessage) { seq.push(field.errorMessage); } return seq; }, []); } setValidating = (validating) => { this._validating = validating; }; get validating() { return this._validating || this._fieldKeys.some((key) => { const f = this._getField(key); return f.validating; }); } validate = async () => { this._validating = true; try { await Promise.all(this._fieldKeys.map((key) => { const field = this.fields[key]; return field.validate({ force: true }); })); this.setValidating(false); } catch (_) { this.setValidating(false); } }; updateField = (name, value, opts = {}) => { const { throwIfMissingField, ...restOpts } = opts; const theField = this._getField(name, { throwIfMissingField }); theField?.setValue(value, restOpts); }; get serializedData() { const keys = this._fieldKeys; return keys.reduce((seq, key) => { const field = this.fields[key]; const value = field.value; const valueToSet = typeof value === "string" ? trim(value) : value; seq[key] = valueToSet; return seq; }, {}); } constructor(args) { const { descriptors = {}, initialState, options = {} } = args || {}; this.addFields(descriptors); if (initialState) { this.updateFrom(initialState, { throwIfMissingField: options.throwIfMissingField, commit: true }); } makeAutoObservable(this, { fields: observable.shallow }); } _getField(name, { throwIfMissingField = true } = {}) { const theName = name; const theField = this.fields[name]; if (!theField && throwIfMissingField) { throw new Error(`Field "${theName}" not found`); } return theField; } _eachField(cb) { const keys = this._fieldKeys; keys.forEach((key) => cb(this.fields[key])); } get _fieldKeys() { const keys = Object.keys(this.fields); return keys; } resetInteractedFlag() { this._eachField((field) => field.resetInteractedFlag()); } disableFields = (fieldKeys) => { if (!Array.isArray(fieldKeys)) throw new TypeError("fieldKeys should be an array with the names of the fields to disable"); fieldKeys.forEach((key) => { const field = this._getField(key); field.setDisabled(true); }); }; _createField({ name, descriptor }) { const { value, ...restDescriptor } = descriptor; extendObservable(this.fields, { [name]: new Field(this, value, restDescriptor, name) }); } addFields = (fieldsDescriptor) => { if (fieldsDescriptor == null || !isObject(fieldsDescriptor)) { throw new Error("fieldDescriptor has to be an Object"); } const fieldsToAdd = Object.keys(fieldsDescriptor); fieldsToAdd.forEach((key) => { this._createField({ name: key, descriptor: fieldsDescriptor[key] }); }); }; enableFields(fieldKeys) { if (!Array.isArray(fieldKeys)) throw new TypeError("fieldKeys should be an array with the names of the fields to disable"); fieldKeys.forEach((key) => { const field = this._getField(key); field.setDisabled(false); }); } resetValidatedOnce() { this._fieldKeys.forEach((key) => { this.fields[key].resetValidatedOnce(); }); } } var createModel = (args) => new FormModel(args); var createModelFromState = (initialState = {}, validators = {}, options) => { const theValidators = validators || {}; const stateKeys = Object.keys(initialState); const validatorsKeys = Object.keys(theValidators); const descriptors = Array.from(new Set([...stateKeys, ...validatorsKeys])).reduce((seq, key) => { const res = theValidators[key] || {}; seq[key] = res; return seq; }, {}); return createModel({ initialState, descriptors, options }); }; export { createModelFromState, createModel, FormModel, Field };