Skip to content

Commit

Permalink
Showing 10 changed files with 1,486 additions and 343 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -46,7 +46,6 @@
},
"homepage": "https://github.com/royriojas/mobx-form#readme",
"dependencies": {
"coalescy": "1.0.0",
"debouncy": "1.0.8",
"jq-trim": "0.1.2"
},
4 changes: 2 additions & 2 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@ export default [
},
{
input: 'src/FormModel.js',
external: ['mobx', 'debouncy', 'coalescy', 'jq-trim'],
external: ['mobx', 'debouncy', 'jq-trim'],
output: [{ file: pkg.main, format: 'cjs' }, { file: pkg.module, format: 'es' }],
plugins: [
babel({
@@ -92,7 +92,7 @@ export default [

{
input: 'src/FormModel.js',
external: ['mobx', 'debouncy', 'coalescy', 'jq-trim'],
external: ['mobx', 'debouncy', 'jq-trim'],
output: [{ file: pkg._legacy_module, format: 'es' }],
plugins: [
babel({
File renamed without changes.
76 changes: 48 additions & 28 deletions src/Field.js
Original file line number Diff line number Diff line change
@@ -14,20 +14,19 @@ export default class Field {

@computed
get waitForBlur() {
return this._waitForBlur;
return !!this._waitForBlur;
}

@computed
get disabled() {
return this._disabled;
return !!this._disabled;
}

@computed
get required() {
if (this.disabled) return false;
// if this._required is a function we evaluate it
// to find if the field needs to be considered required
return typeof this._required === 'function' ? this._required({ field: this, fields: this.model.fields }) : !!this._required;

return !!this._required;
}

@action
@@ -98,11 +97,11 @@ export default class Field {
* onChange event
*/
@observable
_interactive = false;
_autoValidate = false;

@computed
get interactive() {
return this._interactive;
get autoValidate() {
return this._autoValidate;
}

/**
@@ -167,32 +166,36 @@ export default class Field {
* reset the errorMessage and interacted flags
*
* @param {any} value
* @param {Boolean} reset
* @param { object} params the options object
* @param {Boolean} params.resetInteractedFlag whether or not to reset the interacted flag
*
*/
@action
setValue(value, reset) {
if (reset) {
setValue(value, { resetInteractedFlag, commit } = {}) {
if (resetInteractedFlag) {
this._setValueOnly(value);
this.errorMessage = '';
this._interacted = false;
} else {
this._setValue(value);
}
}

@action
replaceValue(value, reset) {
this.setValue(value, reset);
this._initialValue = value;
if (commit) {
this.commit();
}
}

/**
* Restore the initial value of the field
*/
@action
restoreInitialValue() {
this.setValue(this._initialValue, true);
restoreInitialValue({ resetInteractedFlag = true } = {}) {
this.setValue(this._initialValue, { resetInteractedFlag });
}

@action
commit() {
this._initialValue = this.value;
}

/**
@@ -258,22 +261,34 @@ export default class Field {
this._disabled = disabled;
}

@action
validate = opts => {
this._debouncedValidation.cancel();
return this._validate(opts);
};

@computed
get originalErrorMessage() {
return this._originalErrorMessage || `Validation for "${this.name}" failed`;
}

/**
* validate the field. If force is true the validation will be perform
* even if the field was not initially interacted or blurred
*
* @param {boolean} [force=false]
* @param params {object} arguments object
* @param params.force {boolean} [force=false]
*/
@action
validate(force = false) {
_validate({ force = false } = {}) {
const { required } = this;

const shouldSkipValidation = this.disabled || (!required && !this._validateFn);

if (shouldSkipValidation) return;

if (!force) {
const userDidntInteractWithTheField = !this._interacted || (this._waitForBlur && !this._blurredOnce);
const userDidntInteractWithTheField = !this._interacted || (this.waitForBlur && !this._blurredOnce);

if (userDidntInteractWithTheField) {
// if we're not forcing the validation
@@ -289,7 +304,7 @@ export default class Field {
// 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.errorMessage = typeof this._required === 'string' ? this._required : 'Required';
this.errorMessage = typeof this._required === 'string' ? this._required : `Field: "${this.name}" is required`;
return;
}
this.errorMessage = '';
@@ -304,7 +319,7 @@ export default class Field {
// if the function returned a boolean we assume it is
// the flag for the valid state
if (typeof res_ === 'boolean') {
this.errorMessage = res_ ? '' : this._originalErrorMessage;
this.errorMessage = res_ ? '' : this.originalErrorMessage;
resolve();
return;
}
@@ -320,7 +335,7 @@ export default class Field {
}),
action((errorArg = {}) => {
const { error, message } = errorArg;
this.errorMessage = (error || message || '').trim() || this._originalErrorMessage;
this.errorMessage = (error || message || '').trim() || this.originalErrorMessage;
resolve(); // we use this to chain validators
}),
);
@@ -332,25 +347,30 @@ export default class Field {
this._required = val;
};

@action
setErrorMessage(msg) {
this.errorMessage = msg;
}

constructor(model, value, validatorDescriptor = {}, fieldName) {
const DEBOUNCE_THRESHOLD = 300;

this._value = value;
this.model = model;
this.name = fieldName;

this._debouncedValidation = debounce(this.validate, DEBOUNCE_THRESHOLD, this);
this._debouncedValidation = debounce(this._validate, DEBOUNCE_THRESHOLD);
this._initialValue = value;

const { waitForBlur, disabled, errorMessage, validator, hasValueFn, required, autoValidate = true, meta } = validatorDescriptor;
const { waitForBlur, disabled, errorMessage, validator, hasValue, required, autoValidate = true, meta } = validatorDescriptor;

this._waitForBlur = waitForBlur;
this._originalErrorMessage = errorMessage;
this._validateFn = validator || (() => Promise.resolve());
this._validateFn = validator;

// useful to determine if the field has a value set
// only used if provided
this._hasValueFn = hasValueFn;
this._hasValueFn = hasValue;

this._required = required;
this._autoValidate = autoValidate;
72 changes: 26 additions & 46 deletions src/FormModel.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { observable, computed, extendObservable, action, toJS } from 'mobx';
import clsc from 'coalescy';
import trim from 'jq-trim';
import Field from './Field';

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
@@ -11,8 +14,6 @@ import Field from './Field';
* @class FormModel
*/
export class FormModel {
// TODO: what would be a better name for this??
// I'm not convinced, but I guess this is good enough for now
@computed
get dataIsReady() {
return this.interacted && this.requiredAreFilled && this.valid;
@@ -36,11 +37,6 @@ export class FormModel {
});
}

@computed
get atLeastOneRequiredIsFilled() {
return this.requiredFields.some(key => !!this.fields[key].hasValue);
}

@observable
fields = {};

@@ -82,8 +78,8 @@ export class FormModel {
* Restore the initial values set at the creation time of the model
* */
@action
restoreInitialValues() {
this._eachField(field => field.restoreInitialValue());
restoreInitialValues(opts) {
this._eachField(field => field.restoreInitialValue(opts));
}

/**
@@ -93,23 +89,8 @@ export class FormModel {
* initial values. Validation and interacted flags are also reset if the second argument is true
* */
@action
updateFrom(obj, reset = true) {
Object.keys(obj).forEach(key => this.updateField(key, obj[key], reset));
}

/**
* return the value of the field which name is provided. Aditionally a
* default value can be provided.
* */
valueOf(name, defaultValue) {
return clsc(this._getField(name).value, defaultValue);
}

/**
* return the errorMessage of the field which name is provided.
* */
errorOf(name) {
return this._getField(name).errorMessage;
updateFrom(obj, { resetInteractedFlag = true, ...opts } = {}) {
Object.keys(obj).forEach(key => this.updateField(key, obj[key], { resetInteractedFlag, ...opts }));
}

/**
@@ -131,17 +112,18 @@ export class FormModel {
* */
@action
validate() {
this.validating = true;
this._validating = true;

return Promise.all(
this._fieldKeys().map(key => {
const field = this.fields[key];
return Promise.resolve(field.validate(true));
return Promise.resolve(field.validate({ force: true }));
}),
).then(() => {
this.validating = false;
return Promise.resolve();
});
).then(
action(() => {
this._validating = false;
}),
);
}

/**
@@ -150,10 +132,10 @@ export class FormModel {
* errorMessage are cleared in the Field.
* */
@action
updateField(name, value, reset) {
updateField(name, value, opts) {
const theField = this._getField(name);

theField.setValue(value, reset);
theField.setValue(value, opts);
}

/**
@@ -210,12 +192,10 @@ export class FormModel {
}

@action
disableFields(fieldKeys = []) {
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.fields[key];
if (!field) {
throw new Error(`FormModel: Field ${key} not found`);
}
const field = this._getField(key);
field.setDisabled(true);
});
}
@@ -228,7 +208,9 @@ export class FormModel {

@action
addFields = fieldsDescriptor => {
fieldsDescriptor = 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 => {
@@ -246,12 +228,10 @@ export class FormModel {
};

@action
enableFields(fieldKeys = []) {
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.fields[key];
if (!field) {
throw new Error(`FormModel: Field ${key} not found`);
}
const field = this._getField(key);
field.setDisabled(false);
});
}
20 changes: 20 additions & 0 deletions src/resources/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const deferred = () => {
let resolve;
let reject;

const promise = new Promise((resolver, rejector) => {
resolve = resolver;
reject = rejector;
});

promise.resolve = arg => resolve(arg);

promise.reject = arg => reject(arg);

return promise;
};

export const sleep = (timeout = 1000) =>
new Promise(resolve => {
setTimeout(resolve, timeout);
});
370 changes: 370 additions & 0 deletions tests/FormModel.old.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,370 @@
import sleep from 'sleep.async';
import { createModelFromState } from '../src/FormModel';

describe('form-model', () => {
describe('restoreInitialValues', () => {
it('should reset the initial values on the form', () => {
const model = createModelFromState({ valueA: '', valueB: '' });

model.updateField('valueA', 'some');
model.updateField('valueB', 'b');

expect(model.valueOf('valueA')).toEqual('some');
expect(model.valueOf('valueB')).toEqual('b');

model.restoreInitialValues();

expect(model.valueOf('valueA')).toEqual('');
expect(model.valueOf('valueB')).toEqual('');
});

it('should reset the initial values on the form even if not empty strings', () => {
const model = createModelFromState({ valueA: [], valueB: [] });

model.updateField('valueA', [1, 2, 3]);
model.updateField('valueB', [4, 5, 6]);

expect(model.valueOf('valueA')).toEqual([1, 2, 3]);
expect(model.valueOf('valueB')).toEqual([4, 5, 6]);

model.restoreInitialValues();

expect(model.valueOf('valueA')).toEqual([]);
expect(model.valueOf('valueB')).toEqual([]);
});
});

describe('serializedData', () => {
it('should return always the data serialized as a Javascript object', () => {
const model = createModelFromState({ name: 'John', lastName: 'Doe' });
expect(model.serializedData).toEqual({ name: 'John', lastName: 'Doe' });

model.updateField('name', 'Jane');
model.updateField('lastName', 'Martins');

expect(model.serializedData).toEqual({
name: 'Jane',
lastName: 'Martins',
});
});
});

describe('requiredAreFilled', () => {
it('should be true if all required fields have a value', () => {
const model = createModelFromState(
{ name: '', lastName: '', email: '' },
{
name: { required: true, requiredMessage: 'The name is required' },
lastName: {
required: ({ fields }) => !!fields.name.value,
requiredMessage: 'lastName is required',
},
email: {
required: ({ fields }) => fields.lastName.value === 'Doo',
requiredMessage: 'Email is required for all Doos',
}, // only required when last name is Doo
},
);

expect(model.requiredAreFilled).toBe(false);
expect(model.requiredFields).toEqual(['name']);

model.updateField('name', 'John');

expect(model.requiredAreFilled).toBe(false); // now lastName is also required!
expect(model.requiredFields.sort()).toEqual(['name', 'lastName'].sort());

model.updateField('lastName', 'Doo');

expect(model.requiredFields.sort()).toEqual(['name', 'lastName', 'email'].sort());
expect(model.requiredAreFilled).toBe(false);

model.updateField('email', 'some@email.com');
expect(model.requiredAreFilled).toBe(true);
});

it('should allow the creation of a form that will track if all required fields are filled', async () => {
const model = createModelFromState(
{ name: '', lastName: '', email: '' },
{
// using generic validation
name: { required: 'Name is required' },
// using a custom validation functiont that returns a Boolean
lastName: {
required: 'lastName is required',
validator: field => field.value !== 'Doe',
errorMessage: 'Please do not enter Doe',
},
// using an async function that throws when it fails, since throws are converted to rejections
// this just works. If validation passed no need to return anything.
email: {
validator: async ({ value }) => {
await sleep(100);

if (value === 'johndoe@gmail.com') {
throw new Error('Email already used');
}
},
},
},
);

expect(model.requiredAreFilled).toEqual(false);

model.updateField('name', 'Snoopy');
expect(model.requiredAreFilled).toEqual(false);

model.updateField('lastName', 'Doo');
expect(model.requiredAreFilled).toEqual(true);

await model.validate();
expect(model.valid).toEqual(true);

model.updateFrom({
name: '',
lastName: 'Doe',
email: 'johndoe@gmail.com',
});

await model.validate();
expect(model.valid).toEqual(false);

expect(model.summary).toEqual(['Name is required', 'Please do not enter Doe', 'Email already used']);
});

it('errors thrown inside the validator execution should not break the validation when validation is not async', async () => {
const model = createModelFromState(
{ name: '', lastName: '', email: '' },
{
// using generic validation
name: { required: 'Name is required' },
// using a custom validation functiont that returns a Boolean
lastName: {
required: 'lastName is required',
validator: field => field.value !== 'Doe',
errorMessage: 'Please do not enter Doe',
},
// using an async function that throws when it fails, since throws are converted to rejections
// this just works. If validation passed no need to return anything.
email: {
validator: ({ value }) => {
if (value === 'johndoe@gmail.com') {
throw new Error('Email already used');
}
},
},
},
);

expect(model.requiredAreFilled).toEqual(false);

model.updateField('name', 'Snoopy');
expect(model.requiredAreFilled).toEqual(false);

model.updateField('lastName', 'Doo');
expect(model.requiredAreFilled).toEqual(true);

await model.validate();
expect(model.valid).toEqual(true);

model.updateFrom({
name: '',
lastName: 'Doe',
email: 'johndoe@gmail.com',
});

await model.validate();
expect(model.valid).toEqual(false);

expect(model.summary).toEqual(['Name is required', 'Please do not enter Doe', 'Email already used']);
});

it('should allow validators to access fields in the model', async () => {
const model = createModelFromState(
{ name: 'Snoopy', lastName: 'Brown', email: '' },
{
// using generic validation
name: { required: 'Name is required' },
// using a custom validation functiont that returns a Boolean
lastName: {
required: 'lastName is required',
validator: field => field.value !== 'Doe',
errorMessage: 'Please do not enter Doe',
},
// using an async function that throws when it fails, since throws are converted to rejections
// this just works. If validation passed no need to return anything.
email: {
validator: ({ value = '' }, fields, _model) => {
if (_model.validateEmails) {
if (!(value.indexOf('@') > 1)) {
throw new Error('INVALID_EMAIL');
}
}
return true;
},
},
},
);

await model.validate();
expect(model.valid).toEqual(true);

model.validateEmails = true;
await model.validate();
expect(model.valid).toEqual(false);
});
});

describe('addFields', () => {
it('should create a model from an object descriptor', async () => {
const model = createModelFromState({});

model.addFields({
// using generic validation
name: {
value: 'Snoopy',
required: 'Name is required',
},
// using a custom validation functiont that returns a Boolean
lastName: {
value: 'Brown',
required: 'lastName is required',
validator: field => field.value !== 'Doe',
errorMessage: 'Please do not enter Doe',
},
email: {
validator: ({ value = '' }, fields, _model) => {
if (_model.validateEmails) {
if (!(value.indexOf('@') > 1)) {
throw new Error('INVALID_EMAIL');
}
}
return true;
},
},
});

await model.validate();
expect(model.valid).toEqual(true);

model.validateEmails = true;
await model.validate();
expect(model.valid).toEqual(false);
});

it('should create a model from a descriptor of type array', async () => {
const model = createModelFromState({});

model.addFields([
{
name: 'name',
value: 'Snoopy',
required: 'Name is required',
},
{
name: 'lastName',
value: 'Brown',
required: 'lastName is required',
validator: field => field.value !== 'Doe',
errorMessage: 'Please do not enter Doe',
},
{
name: 'email',
validator: ({ value = '' }, fields, _model) => {
if (_model.validateEmails) {
if (!(value.indexOf('@') > 1)) {
throw new Error('INVALID_EMAIL');
}
}
return true;
},
},
]);

expect(model.fields.name.name).toEqual('name');
expect(model.fields.lastName.name).toEqual('lastName');
expect(model.fields.email.name).toEqual('email');

expect(model.fields.name.value).toEqual('Snoopy');
expect(model.fields.lastName.value).toEqual('Brown');
expect(model.fields.email.value).toEqual(undefined);

await model.validate();
expect(model.valid).toEqual(true);

model.validateEmails = true;
await model.validate();
expect(model.valid).toEqual(false);
});

it('should store non recognized fields as meta in the fields', async () => {
const model = createModelFromState({});

const descriptorFields = [
{
name: 'numOfBedrooms',
value: undefined,
required: '# of bedrooms is required',
meta: {
type: 'Select',
label: '# of Bedrooms',
items: [
{ id: 'STUDIO', value: 'Studio' },
{ id: 'ONE_BED', value: 'One bed' },
{ id: 'TWO_BEDS', value: 'Two beds' },
{ id: 'THREE_BEDS', value: 'Three beds' },
{ id: 'FOUR_BEDS', value: 'Four beds' },
],
},
},
{
name: 'moveInRange',
value: undefined,
required: 'Move-in range is required',
meta: {
type: 'Select',
label: 'When do you plan to rent?',
items: [
{ id: 'NEXT_4_WEEKS', value: 'Next 4 weeks' },
{ id: 'NEXT_2_MONTHS', value: 'Next 2 months' },
{ id: 'NEXT_4_MONTHS', value: 'Next 4 months' },
{ id: 'BEYOND_4_MONTHS', value: 'Beyond 4 months' },
{ id: 'I_DONT_KNOW', value: "I don't know" },
],
},
},
{
name: 'comments',
value: '',
required: 'Comments are required',
meta: {
type: 'TextArea',
label: 'Comments',
},
},
];

model.addFields(descriptorFields);

const { fields } = model;

expect(fields.numOfBedrooms.meta).toEqual(descriptorFields[0].meta);
expect(fields.moveInRange.meta).toEqual(descriptorFields[1].meta);
expect(fields.comments.meta).toEqual(descriptorFields[2].meta);

await model.validate();
expect(model.valid).toEqual(false);

expect(fields.numOfBedrooms.errorMessage).toEqual(descriptorFields[0].required);
expect(fields.moveInRange.errorMessage).toEqual(descriptorFields[1].required);
expect(fields.comments.errorMessage).toEqual(descriptorFields[2].required);

fields.numOfBedrooms.setValue('STUDIO');
fields.moveInRange.setValue('NEXT_4_WEEKS');
fields.comments.setValue('Some comment');

await model.validate();
expect(model.valid).toEqual(true);
});
});
});
1,255 changes: 994 additions & 261 deletions tests/FormModel.test.js

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions tests/__snapshots__/FormModel.test.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel dataIsReady should throw when calling disableFields with an argument different from an array 1`] = `"fieldKeys should be an array with the names of the fields to disable"`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel dataIsReady should throw when calling disableFields with no parameters 1`] = `"fieldKeys should be an array with the names of the fields to disable"`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel dataIsReady should throw when calling disableFields with null 1`] = `"fieldKeys should be an array with the names of the fields to disable"`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel dataIsReady should throw when calling enableFields with an argument different from an array 1`] = `"fieldKeys should be an array with the names of the fields to disable"`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel dataIsReady should throw when calling enableFields with an argument different from an array 2`] = `"fieldKeys should be an array with the names of the fields to disable"`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel dataIsReady when trying to disable a field that does not exist should throw an error 1`] = `"Field \\"non existant field\\" not found"`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel required fields when required fields are missing should contain a summary of missing fields 1`] = `
Array [
"The name is required",
"Field: \\"email\\" is required",
]
`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel required fields when required fields are missing when required property is set to true (boolean) should have an errorMessage on failed fields 1`] = `"Field: \\"name\\" is required"`;

exports[`FormModel A FormModel can be created using createModel, createModelFromState or using the Constructor directly createModel required fields when required fields are missing when required property is set to true (boolean) should have an errorMessage on failed fields 2`] = `"Field: \\"email\\" is required"`;

exports[`FormModel Adding fields after model is created should throw when calling addFields with no values 1`] = `"fieldDescriptor has to be an Object or an Array"`;
5 changes: 0 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
@@ -1545,11 +1545,6 @@ co@^4.6.0:
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=

coalescy@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/coalescy/-/coalescy-1.0.0.tgz#4b065846b836361ada6c4b4a4abf4bc1cac31bf1"
integrity sha1-SwZYRrg2NhrabEtKSr9LwcrDG/E=

code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"

0 comments on commit d9b0da0

Please sign in to comment.