Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Debounced validation functionality #92

Merged
merged 12 commits into from
Jan 6, 2016
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ __No observers were used nor harmed while developing and testing this addon.__
* Meta data based cycle tracking to detect cycles within your model relationships which could break the CP chain
* Custom validators
* Ember CLI generator to create custom validators with a unit test
* Debounced validations
* I18n support

[![Introduction to ember-cp-validations](https://i.vimeocdn.com/video/545445254.png?mw=1920&mh=1080&q=70)](https://vimeo.com/146857699)
Expand Down
78 changes: 49 additions & 29 deletions addon/validations/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import cycleBreaker from '../utils/cycle-breaker';
const {
get,
set,
run,
RSVP,
isNone,
guidFor,
Expand Down Expand Up @@ -179,19 +180,19 @@ function createGlobalValidationProps(validatableAttrs) {
function createMixin(GlobalValidations, AttrValidations) {
return Ember.Mixin.create({
validate() {
return get(this, 'validations').validate(...arguments);
},
validateSync() {
return get(this, 'validations').validateSync(...arguments);
},
validations: computed(function() {
return GlobalValidations.create({
model: this,
attrs: AttrValidations.create({
_model: this
})
});
}).readOnly()
return get(this, 'validations').validate(...arguments);
},
validateSync() {
return get(this, 'validations').validateSync(...arguments);
},
validations: computed(function() {
return GlobalValidations.create({
model: this,
attrs: AttrValidations.create({
_model: this
})
});
}).readOnly()
});
}

Expand All @@ -205,28 +206,28 @@ function createCPValidationFor(attribute, validations) {
var dependentKeys = getCPDependentKeysFor(attribute, validations);
return computed(...dependentKeys, cycleBreaker(function() {
var model = get(this, '_model');
// var modelErrors = get(model, 'errors');
var validators = getValidatorsFor(attribute, model);

var validationResults = validators.map((validator) => {
var validationReturnValue = validator.validate(get(model, attribute), validator.processOptions(), model, attribute);
var validationResults = validators.map(validator => {
let options = validator.processOptions();
let debounce = get(options, 'debounce') || 0;
let validationReturnValue;

if(debounce > 0) {
// Return a promise and pass the resolve method to the debounce handler
validationReturnValue = new Promise(function(resolve) {
run.debounce(validator, getValidationResult, validator, options, model, attribute, resolve, debounce, false);
});
} else {
validationReturnValue = getValidationResult(validator, options, model, attribute);
}

return validationReturnValueHandler(attribute, validationReturnValue, model);
});

validationResults = flatten(validationResults);
var validationResultsCollection = ValidationResultCollection.create({
attribute, content: validationResults
return ValidationResultCollection.create({
attribute, content: flatten(validationResults)
});

// https://github.com/emberjs/data/issues/3707
// if (hasEmberData() && model instanceof self.DS.Model && !isNone(modelErrors) && canInvoke(modelErrors, 'add')) {
// if(modelErrors.has(attribute)) {
// modelErrors.remove(attribute);
// }
// get(validationResultsCollection, 'messages').forEach((m) => modelErrors.add(attribute, m));
// }

return validationResultsCollection;
}));
}

Expand Down Expand Up @@ -271,6 +272,25 @@ function getCPDependentKeysFor(attribute, validations) {
return dependentKeys.uniq();
}

/**
* Used to retrieve the validation result by calling the validate method on the validator.
* If resolve is passed, that means that this validation has been debounced to we pass the
* result to the resolve method.
* @param {Validator} validator
* @param {Object} options
* @param {Object} model
* @param {String} attribute
* @param {Function} resolve
*/
function getValidationResult(validator, options, model, attribute, resolve) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of this function, we should likely just do the following, from the calling side.

_ => resolve(validator.validate(get(model, attribute), options, model));

let result = validator.validate(get(model, attribute), options, model, attribute);
if(resolve && typeof resolve === 'function') {
resolve(result);
} else {
return result;
}
}

/**
* A handler used to create ValidationResult object from values returned from a validator
* @param {String} attribute
Expand Down
4 changes: 3 additions & 1 deletion addon/validations/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ export default Ember.Object.extend({
var validations = get(this, '_validations');
set(validations, 'isValidating', true);
get(this, '_promise').then(
(result) => this.update(result), (result) => this.update(result)).catch(reason => {
result => this.update(result),
result => this.update(result)
).catch(reason => {
// TODO: send into error state
throw reason;
}).finally(() => {
Expand Down
13 changes: 13 additions & 0 deletions docs/docs/validators/common.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ validator('x-validator', {
})
```

## debounce
Debounces the validation with the given time in `milliseconds`. All debounced validations will be handled asynchronously (wrapped in a promise).

```javascript
// Examples
validator('length', {
debounce: 500
})
validator('x-validator', {
debounce: 250
})
```

## message
This option can take two forms. It can either be a `string` or a `function`. If a string is used, then it will overwrite all error message types for the specified validator.

Expand Down
42 changes: 13 additions & 29 deletions tests/dummy/app/components/validated-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,54 +7,38 @@ import Ember from 'ember';

const {
computed,
observer,
defineProperty,
run
} = Ember;

export default Ember.Component.extend({
classNames: ['validated-input'],
classNameBindings: ['showErrorClass:has-error', 'isValid:has-success'],
model: null,
value: null,
rawInputValue: null,
type: 'text',
valuePath: '',
placeholder: '',
attributeValidation: null,
isTyping: false,

didValidate: computed.oneWay('targetObject.didValidate'),

showErrorClass: computed('isTyping', 'showMessage', 'hasContent', 'attributeValidation', function() {
return this.get('attributeValidation') && !this.get('isTyping') && this.get('showMessage') && this.get('hasContent');
}),

hasContent: computed.notEmpty('rawInputValue'),

isValid: computed.and('hasContent', 'attributeValidation.isValid'),
init() {
this._super(...arguments);
var valuePath = this.get('valuePath');
defineProperty(this, 'attributeValidation', computed.oneWay(`model.validations.attrs.${valuePath}`));
defineProperty(this, 'value', computed.alias(`model.${valuePath}`));
},

notValidating: computed.not('attributeValidation.isValidating'),
didValidate: computed.oneWay('targetObject.didValidate'),
hasContent: computed.notEmpty('value'),
isValid: computed.and('hasContent', 'attributeValidation.isValid', 'notValidating'),
isInvalid: computed.oneWay('attributeValidation.isInvalid'),

inputValueChange: observer('rawInputValue', function() {
this.set('isTyping', true);
run.debounce(this, this.setValue, 500, false);
showErrorClass: computed('notValidating', 'showMessage', 'hasContent', 'attributeValidation', function() {
return this.get('attributeValidation') && this.get('notValidating') && this.get('showMessage') && this.get('hasContent');
}),

showMessage: computed('attributeValidation.isDirty', 'isInvalid', 'didValidate', function() {
return (this.get('attributeValidation.isDirty') || this.get('didValidate')) && this.get('isInvalid');
}),

setValue() {
this.set('value', this.get('rawInputValue'));
this.set('isTyping', false);
},

init() {
this._super(...arguments);
var valuePath = this.get('valuePath');
defineProperty(this, 'attributeValidation', computed.oneWay(`model.validations.attrs.${valuePath}`));
this.set('rawInputValue', this.get(`model.${valuePath}`));
defineProperty(this, 'value', computed.alias(`model.${valuePath}`));
}
})
});
20 changes: 16 additions & 4 deletions tests/dummy/app/models/user-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,24 @@ from 'ember-cp-validations';
var attr = DS.attr;

var Validations = buildValidations({
firstName: validator('presence', true),
lastName: validator('presence', true),
firstName: validator('presence', {
presence: true,
debounce: 500
}),
lastName: validator('presence', {
presence: true,
debounce: 500
}),
dob: {
description: 'Date of birth',
debounce: 500,
validators: [
validator('presence', true),
validator('date', {
before: 'now',
after() { return moment().subtract(120, 'years').format('M/D/YYYY'); },
after() {
return moment().subtract(120, 'years').format('M/D/YYYY');
},
format: 'M/D/YYYY',
message: function(type, value, context) {
if (type === 'before') {
Expand All @@ -33,16 +42,19 @@ var Validations = buildValidations({
}
}
})
]},
]
},
phone: [
validator('format', {
allowBlank: true,
debounce: 500,
type: 'phone'
})
],
url: [
validator('format', {
allowBlank: true,
debounce: 500,
type: 'url'
})
]
Expand Down
34 changes: 21 additions & 13 deletions tests/dummy/app/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ from 'ember-cp-validations';
var attr = DS.attr;

var Validations = buildValidations({
username: [
validator('presence', true),
validator('length', {
max: 15
})
],
username: {
debounce: 500,
validators: [
validator('presence', true),
validator('length', {
max: 15
})
]
},
password: {
description: 'Password',
debounce: 500,
validators: [
validator('presence', true),
validator('length', {
Expand All @@ -33,15 +37,19 @@ var Validations = buildValidations({
})
]
},
email: [
validator('presence', true),
validator('format', {
type: 'email'
})
],
email: {
debounce: 500,
validators: [
validator('presence', true),
validator('format', {
type: 'email'
})
]
},
emailConfirmation: validator('confirmation', {
on: 'email',
message: 'Email addresses do not match'
message: 'Email addresses do not match',
debounce: 500
}),
details: validator('belongs-to')
});
Expand Down
2 changes: 1 addition & 1 deletion tests/dummy/app/templates/components/validated-input.hbs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div class="form-group">
{{input type=type value=rawInputValue placeholder=placeholder class="form-control" name=valuePath}}
{{input type=type value=value placeholder=placeholder class="form-control" name=valuePath}}
{{#if isValid}}
<span class="valid-input fa fa-check"></span>
{{/if}}
Expand Down
32 changes: 32 additions & 0 deletions tests/integration/validations/factory-general-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,35 @@ test("custom messages object", function(assert) {
assert.equal(object.get('validations.attrs.firstName.isValidating'), false);
assert.equal(object.get('validations.attrs.firstName.message'), 'Test error message');
});

test("debounced validations", function(assert) {
var done = assert.async();
var Validations = buildValidations({
firstName: validator(Validators.presence),
lastName: validator(Validators.presence, { debounce: 500 }),
});
var object = setupObject(this, Ember.Object.extend(Validations));

assert.equal(object.get('validations.isValid'), false, 'isValid was expected to be FALSE');
// TODO: I feel like initially a debounced validation should not be debounced.
assert.equal(object.get('validations.isValidating'), true, 'isValidating was expected to be TRUE');
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right when we create the object, the lastName debounced promised is created which sets the isValidating to true

assert.equal(object.get('validations.isTruelyValid'), false, 'isTruelyValid was expected to be FALSE');

Ember.run.later(() => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont know if this is the correct behavior. Instantly, we have to wait for all the debounced validations to finish before we can do anything else. I guess one solution is just to call object.validate().then(...) since I considered the debounced validations as async

assert.equal(object.get('validations.attrs.lastName.isValid'), false);
assert.equal(object.get('validations.attrs.lastName.isValidating'), false);
assert.equal(object.get('validations.attrs.lastName.message'), 'lastName should be present');

object.set('lastName', 'Golan');
// TODO: This is true because a new Validation result object is created which initially defaults to TRUE
assert.equal(object.get('validations.attrs.lastName.isValid'), true);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, this doesnt feel right. When we set a new value to the debounced CP, it creates a new ValidationResult object with the new promise but that new object gets instantiated by default with isValid: true

assert.equal(object.get('validations.attrs.lastName.isValidating'), true);

Ember.run.later(() => {
assert.equal(object.get('validations.attrs.lastName.isValid'), true);
assert.equal(object.get('validations.attrs.lastName.isValidating'), false);
assert.equal(object.get('validations.attrs.lastName.message'), null);
done();
}, 600);
}, 600);
});