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
143 changes: 99 additions & 44 deletions addon/validations/factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import cycleBreaker from '../utils/cycle-breaker';

const {
get,
set,
run,
RSVP,
isNone,
guidFor,
Expand Down Expand Up @@ -80,6 +80,7 @@ export default function buildValidations(validations = {}) {

// Private
props._validators = {};
props._debouncedValidations = {};
props._validatableAttributes = validatableAttrs;
props._validationRules = validations;

Expand Down Expand Up @@ -205,19 +206,20 @@ 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(),
willDestroy
});
}

Expand All @@ -233,28 +235,30 @@ 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);
return validationReturnValueHandler(attribute, validationReturnValue, model);
});
var validationResults = validators.map(validator => {
let options = validator.processOptions();
let debounce = get(options, 'debounce') || 0;
let attrValue = get(model, attribute);
let value;

if(debounce > 0) {
let cache = getDebouncedValidationsCacheFor(attribute, model);
// Return a promise and pass the resolve method to the debounce handler
value = new Promise(resolve => {
cache[getKey(validator)] = run.debounce(validator, () => resolve(validator.validate(attrValue, options, model, attribute)), debounce, false);
});
} else {
value = validator.validate(attrValue, options, model, attribute);
}

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

// 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;
return ValidationResultCollection.create({
attribute, content: flatten(validationResults)
});
}));
}

Expand Down Expand Up @@ -306,23 +310,23 @@ function getCPDependentKeysFor(attribute, validations) {
* @method validationReturnValueHandler
* @private
* @param {String} attribute
* @param {Unknown} validationReturnValue
* @param {Unknown} value
* @param {Object} model
* @return {ValidationResult}
*/
function validationReturnValueHandler(attribute, validationReturnValue, model) {
function validationReturnValueHandler(attribute, value, model) {
var result, _promise;

if (canInvoke(validationReturnValue, 'then')) {
_promise = Promise.resolve(validationReturnValue);
if (canInvoke(value, 'then')) {
_promise = Promise.resolve(value);
result = ValidationResult.create({
attribute, _promise, model
});
} else {
result = ValidationResult.create({
attribute, model
});
result.update(validationReturnValue);
result.update(value);
}

return result;
Expand All @@ -340,7 +344,7 @@ function getKey(model) {
}

/**
* Get validatiors for the give attribute. If they are not in the cache, then create them.
* Get validators for the give attribute. If they are not in the cache, then create them.
* @method getValidatorsFor
* @private
* @param {String} attribute
Expand All @@ -349,15 +353,38 @@ function getKey(model) {
*/
function getValidatorsFor(attribute, model) {
var key = getKey(model);
var currentValidators = get(model, `validations._validators.${key}.${attribute}`);
var validators = get(model, `validations._validators.${key}.${attribute}`);

if (!Ember.isNone(currentValidators)) {
return currentValidators;
if (!isNone(validators)) {
return validators;
}

return createValidatorsFor(attribute, model);
}

/**
* Get debounced validation cache for the given attribute. If it doesnt exist, create a new one.
* @method getValidatorCacheFor
* @private
* @param {String} attribute
* @param {Object} model
* @return {Map}
*/
function getDebouncedValidationsCacheFor(attribute, model) {
var key = getKey(model);
var debouncedValidations = get(model, `validations._debouncedValidations`);

if (isNone(debouncedValidations[key])) {
debouncedValidations[key] = {};
}

if (isNone(debouncedValidations[key][attribute])) {
debouncedValidations[key][attribute] = {};
}

return debouncedValidations[key][attribute];
}

/**
* Create validators for the give attribute and store them in a cache
* @method createValidatorsFor
Expand All @@ -370,6 +397,7 @@ function createValidatorsFor(attribute, model) {
var key = getKey(model);
var validations = get(model, 'validations');
var validationRules = makeArray(get(validations, `_validationRules.${attribute}`));
var validatorCache = get(validations, '_validators');
var owner = getOwner(model);
var validators = [];
var validator;
Expand All @@ -396,12 +424,12 @@ function createValidatorsFor(attribute, model) {
});

// Check to see if there is already a cache started for this model instanse, if not create a new pojo
if (isNone(get(validations, `_validators.${key}`))) {
set(validations, `_validators.${key}`, {});
if (isNone(validatorCache[key])) {
validatorCache[key] = {};
}

// Add validators to model instance cache
set(validations, `_validators.${key}.${attribute}`, validators);
validatorCache[key][attribute] = validators;

return validators;
}
Expand Down Expand Up @@ -490,7 +518,6 @@ function validate(options = {}, async = true) {
return resultObject;
}


/**
* ### Options
* - `on` (**Array**): Only validate the given attributes. If empty, will validate over all validatable attribute
Expand All @@ -509,3 +536,31 @@ function validate(options = {}, async = true) {
function validateSync(options) {
return this.validate(options, false);
}

/**
* willDestroy override for the final created mixin. Cancels all ongoing debounce timers
* and removes all cached data for the current object being destroyed
*
* @method willDestroy
* @private
*/
function willDestroy() {
this._super(...arguments);
let key = getKey(this);
let debounceCache = get(this, `validations._debouncedValidations`);
let validatorCache = get(this, `validations._validators`);

// Cancel all debounced timers
if(!isNone(debounceCache[key])) {
let modelCache = debounceCache[key];
// Itterate over each attribute and cancel all of its debounced validations
Object.keys(modelCache).forEach(attr => {
let attrCache = modelCache[attr];
Object.keys(attrCache).forEach(v => run.cancel(attrCache[v]));
});
}

// Remove all cached information stored for this model instance
delete debounceCache[key];
delete validatorCache[key];
}
5 changes: 4 additions & 1 deletion addon/validations/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const {
* This class is `private` and is only used by {{#crossLink 'ResultCollection'}}{{/crossLink}}
* @module Validations
* @class Result
* @private
*/

var ValidationsObject = Ember.Object.extend({
Expand Down Expand Up @@ -256,7 +257,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 addon/validations/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ const {
* })
* ```
*
* ### 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
2 changes: 1 addition & 1 deletion bower.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"ember-qunit-notifications": "0.1.0",
"ember-resolver": "~0.1.20",
"jquery": "^1.11.1",
"loader.js": "ember-cli/loader.js#3.4.0",
"loader.js": "ember-cli/loader.js#3.2.1",
"qunit": "~1.20.0",
"moment": ">= 2.8.0",
"moment-timezone": ">= 0.1.0"
Expand Down
46 changes: 13 additions & 33 deletions tests/dummy/app/components/validated-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,54 +7,34 @@ 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,
validation: 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'),

isInvalid: computed.oneWay('attributeValidation.isInvalid'),

inputValueChange: observer('rawInputValue', function() {
this.set('isTyping', true);
run.debounce(this, this.setValue, 500, false);
}),

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, 'validation', computed.oneWay(`model.validations.attrs.${valuePath}`));
defineProperty(this, 'value', computed.alias(`model.${valuePath}`));
}
},

notValidating: computed.not('validation.isValidating'),
didValidate: computed.oneWay('targetObject.didValidate'),
hasContent: computed.notEmpty('value'),
isValid: computed.and('hasContent', 'validation.isValid', 'notValidating'),
isInvalid: computed.oneWay('validation.isInvalid'),
showErrorClass: computed.and('notValidating', 'showMessage', 'hasContent', 'validation'),
showMessage: computed('validation.isDirty', 'isInvalid', 'didValidate', function() {
return (this.get('validation.isDirty') || this.get('didValidate')) && this.get('isInvalid');
})
});
Loading