UNPKG

mobx-form

Version:
978 lines (803 loc) 24 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var mobx = require('mobx'); var trim = _interopDefault(require('jq-trim')); var debounce = _interopDefault(require('debouncy')); function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread2(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } function _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; } const isNullishOrEmpty = value => typeof value === 'undefined' || value === null || value === ''; /** * Field class provides abstract the validation of a single field */ class Field { 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); } // consider the case where the value is an array // we consider it actually has a value if the value is defined // and the array is not empy if (Array.isArray(this.value)) { return this.value.length > 0; } return !isNullishOrEmpty(this.value); } /** * flag to know if a validation is in progress on this field */ get blurred() { return !!this._blurredOnce; } /** the raw error in caes validator throws a real error */ /** * the error message associated with this field. * This is used to indicate what error happened during * the validation process */ get errorMessage() { var _this$rawError; return (_this$rawError = this.rawError) === null || _this$rawError === void 0 ? void 0 : _this$rawError.message; } /** * whether the validation should be launch after a * new value is set in the field. This is usually associated * to forms that set the value on the fields after each * onChange event */ get autoValidate() { return this._autoValidate; } /** * used to keep track of the original message */ /** * whether the field is valid or not */ get valid() { return !this.errorMessage; } /** * whether the user has interacted or not with the field */ get interacted() { return this._interacted; } /** * get the value set on the field */ 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(); } } /** * setter for the value of the field */ set value(val) { this._setValue(val); } /** * set the value of the field, optionaly * reset the errorMessage and interacted flags * * @param {any} value * @param { object} params the options object * @param {Boolean} params.resetInteractedFlag whether or not to reset the interacted flag * */ setValue(value, { resetInteractedFlag, commit } = {}) { if (resetInteractedFlag) { this._setValueOnly(value); this.rawError = undefined; this._interacted = false; } else { this._setValue(value); } if (commit) { this.commit(); } } /** * Restore the initial value of the field */ restoreInitialValue({ resetInteractedFlag = true, commit = true } = {}) { this.setValue(this._initialValue, { resetInteractedFlag, commit }); } get dirty() { return this._initialValue !== this.value; } commit() { this._initialValue = this.value; } /** * clear the valid state of the field by * removing the errorMessage string. A field is * considered valid if the errorMessage is not empty */ resetError() { this.rawError = undefined; } clearValidation() { this.resetError(); } /** * mark the field as already blurred so validation can * start to be applied to the field. */ async _doValidate() { const { _validateFn, model } = this; if (!_validateFn) return Promise.resolve(true); let ret; if (Array.isArray(_validateFn)) { for (let i = 0; i < _validateFn.length; i++) { const vfn = _validateFn[i]; if (typeof vfn !== 'function') { throw new Error('Validator must be a function or a function[]'); } try { var _ret; ret = await vfn(this, model.fields, model); if (ret === false || ((_ret = ret) === null || _ret === void 0 ? void 0 : _ret.error)) { return ret; } } catch (err) { return Promise.reject(err); } } } else { try { ret = _validateFn(this, model.fields, model); } catch (err) { return Promise.reject(err); } } return ret; } setDisabled(disabled) { if (disabled) { this.resetError(); } this._disabled = disabled; } get originalErrorMessage() { return this._originalErrorMessage || `Validation for "${this.name}" failed`; } get validating() { return this._validating; } /** * validate the field. If force is true the validation will be perform * even if the field was not initially interacted or blurred * * @param params {object} arguments object * @param params.force {boolean} [force=false] */ _validate({ 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) { // if we're not forcing the validation // and we haven't interacted with the field // we asume this field pass the validation status this.resetError(); return; } // if the field requires the user to lost focus before starting the validation // we wait until the field is marked as blurredOnce. Except in the case the // field has an error already in which case we do want to execute the validation if (this.waitForBlur && !this._blurredOnce && !this.errorMessage) { return; } } else { this._blurredOnce = true; } if (required) { if (!this.hasValue) { // we can indicate that the field is required by passing the error message as the value of // the required field. If we pass a boolean or a function then the value of the error message // can be set in the requiredMessage field of the validator descriptor 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(); const res = this._doValidate(); // eslint-disable-next-line consistent-return return new Promise(resolve => { res.then(mobx.action(res_ => { if (validationTs !== this._validationTs) return; // ignore stale validations this.setValidating(false); // if the function returned a boolean we assume it is // the flag for the valid state if (typeof res_ === 'boolean') { this.setErrorMessage(res_ ? undefined : this.originalErrorMessage); resolve(); return; } if (res_ && res_.error) { this.setErrorMessage(res_.error); resolve(); return; } this.resetError(); resolve(); // we use this to chain validators }), mobx.action((errorArg = {}) => { if (validationTs !== this._validationTs) return; // ignore stale validations this.setValidating(false); const { error, message } = errorArg; let errorToSet = errorArg; if (!message) { errorToSet = _objectSpread2(_objectSpread2({}, errorToSet), {}, { message: message || this.originalErrorMessage }); } if (error) { errorToSet = _objectSpread2(_objectSpread2({}, errorToSet), {}, { message: error }); } this.setError(errorToSet); resolve(); // we use this to chain validators })); }); } 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; } constructor(model, value, validatorDescriptor = {}, fieldName) { this._disabled = void 0; this._required = void 0; this._validatedOnce = false; this._validating = false; this._initialValue = void 0; this._value = void 0; this._interacted = void 0; this._blurredOnce = false; this.rawError = void 0; this._autoValidate = false; this._originalErrorMessage = void 0; this.markBlurredAndValidate = () => { if (!this._blurredOnce) { this._blurredOnce = true; } this.validate(); }; this.validate = opts => { this._debouncedValidation.cancel(); return this._validate(opts); }; this.setValidating = validating => { this._validating = validating; }; this.setRequired = val => { this._required = val; }; mobx.makeObservable(this, { resetValidatedOnce: mobx.action, _validatedOnce: mobx.observable, validatedAtLeastOnce: mobx.computed, _disabled: mobx.observable, _required: mobx.observable, waitForBlur: mobx.computed, disabled: mobx.computed, required: mobx.computed, resetInteractedFlag: mobx.action, markAsInteracted: mobx.action, hasValue: mobx.computed, _autoValidate: mobx.observable, _value: mobx.observable, _initialValue: mobx.observable, _interacted: mobx.observable, _blurredOnce: mobx.observable, dirty: mobx.computed, blurred: mobx.computed, errorMessage: mobx.computed, rawError: mobx.observable.ref, setError: mobx.action, resetError: mobx.action, error: mobx.computed, autoValidate: mobx.computed, valid: mobx.computed, validating: mobx.computed, _validating: mobx.observable, setValidating: mobx.action, interacted: mobx.computed, _setValueOnly: mobx.action, _setValue: mobx.action, setValue: mobx.action, restoreInitialValue: mobx.action, commit: mobx.action, clearValidation: mobx.action, markBlurredAndValidate: mobx.action, _doValidate: mobx.action, setDisabled: mobx.action, validate: mobx.action, originalErrorMessage: mobx.computed, _validate: mobx.action, setRequired: mobx.action, setErrorMessage: mobx.action }); 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; // useful to determine if the field has a value set // only used if provided this._hasValueFn = hasValue; this._required = required; this._autoValidate = autoValidate; this._disabled = disabled; this.meta = meta; // store other props passed on the fields } } const toString = Object.prototype.toString; const isObject = o => o && toString.call(o) === '[object Object]'; /** * a helper class to generate a dynamic form * provided some keys and validators descriptors * * @export * @class FormModel */ class FormModel { get validatedAtLeastOnce() { const keys = Object.keys(this.fields); return keys.every(key => this.fields[key].validatedAtLeastOnce); } get dataIsReady() { return this.interacted && this.requiredAreFilled && this.valid; } get requiredFields() { const keys = Object.keys(this.fields); return keys.filter(key => this.fields[key].required); } get requiredAreFilled() { const keys = Object.keys(this.fields); return keys.every(key => { const field = this.fields[key]; if (field.required) { return !!field.hasValue; } return true; }); } // flag to indicate whether the form is valid or not // since some of the validators might be async validators // this value might be false until the validation process finish get valid() { if (this._validating) { return false; // consider the form invalid until the validation process finish } const keys = Object.keys(this.fields); return keys.every(key => { const field = this.fields[key]; return !!field.valid; }); } /** * whether or not the form has been "interacted", meaning that at * least a value has set on any of the fields after the model * has been created */ get interacted() { const keys = this._fieldKeys(); return keys.some(key => { const field = this.fields[key]; return !!field.interacted; }); } /** * Restore the initial values set at the creation time of the model * */ 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; }); } /** * Set multiple values to more than one field a time using an object * where each key is the name of a field. The value will be set to each * field and from that point on the values set are considered the new * initial values. Validation and interacted flags are also reset if the second argument is true * */ updateFrom(obj, _ref = {}) { let { resetInteractedFlag = true } = _ref, opts = _objectWithoutProperties(_ref, ["resetInteractedFlag"]); Object.keys(obj).forEach(key => this.updateField(key, obj[key], _objectSpread2({ resetInteractedFlag }, opts))); } /** * return the array of errors found. The array is an Array<String> * */ get summary() { return this._fieldKeys().reduce((seq, key) => { const field = this.fields[key]; if (field.errorMessage) { seq.push(field.errorMessage); } return seq; }, []); } get validating() { return this._validating || this._fieldKeys().some(key => { const f = this._getField(key); return f.validating; }); } /** * Manually perform the form validation * */ validate() { this._validating = true; return Promise.all(this._fieldKeys().map(key => { const field = this.fields[key]; return Promise.resolve(field.validate({ force: true })); })).then(() => { this.setValidating(false); }).catch(() => { this.setValidating(false); }); } /** * Update the value of the field identified by the provided name. * Optionally if reset is set to true, interacted and * errorMessage are cleared in the Field. * */ updateField(name, value, opts = {}) { const { throwIfMissingField } = opts, restOpts = _objectWithoutProperties(opts, ["throwIfMissingField"]); const theField = this._getField(name, { throwIfMissingField }); theField === null || theField === void 0 ? void 0 : theField.setValue(value, restOpts); } /** * return the data as plain Javascript object (mobx magic removed from the fields) * */ get serializedData() { const keys = Object.keys(this.fields); return mobx.toJS(keys.reduce((seq, key) => { const field = this.fields[key]; const value = mobx.toJS(field.value); // this is required to make sure forms that use the serializedData object // have the values without leading or trailing spaces seq[key] = typeof value === 'string' ? trim(value) : value; return seq; }, {})); } /** * Creates an instance of FormModel. * * @param {Object|Array} [descriptors={}] * @param {Object} [initialState={}] * * initialState => an object which keys are the names of the fields and the values the initial values for the form. * validators => an object which keys are the names of the fields and the values are the descriptors for the validators */ constructor({ descriptors = {}, initialState, options = {} } = {}) { this.fields = {}; this._validating = false; this.setValidating = validating => { this._validating = validating; }; this.addFields = fieldsDescriptor => { if (fieldsDescriptor == null || !isObject(fieldsDescriptor) && !Array.isArray(fieldsDescriptor)) { throw new Error('fieldDescriptor has to be an Object or an Array'); } if (Array.isArray(fieldsDescriptor)) { fieldsDescriptor.forEach(field => { const { value, name } = field, descriptor = _objectWithoutProperties(field, ["value", "name"]); this._createField({ value, name, descriptor }); }); return; } const fieldsToAdd = Object.keys(fieldsDescriptor); fieldsToAdd.forEach(key => { const _fieldsDescriptor$key = fieldsDescriptor[key], { value } = _fieldsDescriptor$key, descriptor = _objectWithoutProperties(_fieldsDescriptor$key, ["value"]); this._createField({ value, name: key, descriptor }); }); }; mobx.makeObservable(this, { resetValidatedOnce: mobx.action, validatedAtLeastOnce: mobx.computed, dataIsReady: mobx.computed, requiredFields: mobx.computed, requiredAreFilled: mobx.computed, fields: mobx.observable, _validating: mobx.observable, setValidating: mobx.action, validating: mobx.computed, valid: mobx.computed, interacted: mobx.computed, restoreInitialValues: mobx.action, updateFrom: mobx.action, summary: mobx.computed, validate: mobx.action, updateField: mobx.action, serializedData: mobx.computed, resetInteractedFlag: mobx.action, disableFields: mobx.action, addFields: mobx.action, enableFields: mobx.action, commit: mobx.action, dirty: mobx.computed }); this.addFields(descriptors); initialState && this.updateFrom(initialState, { throwIfMissingField: options.throwIfMissingField, commit: true }); } _getField(name, { throwIfMissingField = true } = {}) { const theField = this.fields[name]; if (!theField && throwIfMissingField) { throw new Error(`Field "${name}" not found`); } return theField; } _eachField(cb) { Object.keys(this.fields).forEach(key => cb(this.fields[key])); } _fieldKeys() { return Object.keys(this.fields); } 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({ value, name, descriptor }) { mobx.extendObservable(this.fields, { [name]: new Field(this, value, descriptor, name) }); } 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(); }); } } /** * return an instance of a FormModel refer to the constructor * * @param {Object|Array} fieldDescriptors * @param {Object} initialState * @param {Object} options */ const createModel = ({ descriptors, initialState, options }) => new FormModel({ descriptors, initialState, options }); const createModelFromState = (initialState = {}, validators = {}, options = {}) => { const stateKeys = Object.keys(initialState); const validatorsKeys = Object.keys(validators); const descriptors = Array.from(new Set([...stateKeys, ...validatorsKeys]), key => _objectSpread2(_objectSpread2({}, validators[key] || {}), {}, { value: initialState[key], name: key })); return createModel({ descriptors, options }); }; exports.FormModel = FormModel; exports.createModel = createModel; exports.createModelFromState = createModelFromState;