mobx-form
Version:
A simple form helper for mobx
978 lines (803 loc) • 24 kB
JavaScript
'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;