Skip to content

Commit

Permalink
fix(input): improve html5 validation support
Browse files Browse the repository at this point in the history
This CL improves mocking support for HTML5 validation, and ensures that it works correctly along
with debounced commission of view values.

Closes angular#7936
Closes angular#7937
  • Loading branch information
caitp committed Jun 24, 2014
1 parent deb008d commit 1f6a5a1
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 22 deletions.
1 change: 1 addition & 0 deletions src/.jshintrc
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"assertNotHasOwnProperty": false,
"getter": false,
"getBlockElements": false,
"VALIDITY_STATE_PROPERTY": false,

/* filters.js */
"getFirstThursdayOfYear": false,
Expand Down
5 changes: 5 additions & 0 deletions src/Angular.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
-nodeName_,
-uid,
-REGEX_STRING_REGEXP,
-VALIDITY_STATE_PROPERTY,
-lowercase,
-uppercase,
Expand Down Expand Up @@ -105,6 +106,10 @@

var REGEX_STRING_REGEXP = /^\/(.+)\/([a-z]*)$/;

// The name of a form control's ValidityState property.
// This is used so that it's possible for internal tests to create mock ValidityStates.
var VALIDITY_STATE_PROPERTY = 'validity';

/**
* @ngdoc function
* @name angular.lowercase
Expand Down
57 changes: 37 additions & 20 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -862,15 +862,29 @@ function validate(ctrl, validatorName, validity, value){
return validity ? value : undefined;
}

function testFlags(validity, flags) {
var i, flag;
if (flags) {
for (i=0; i<flags.length; ++i) {
flag = flags[i];
if (validity[flag]) {
return true;
}
}
}
return false;
}

function addNativeHtml5Validators(ctrl, validatorName, element) {
var validity = element.prop('validity');
// Pass validity so that behaviour can be mocked easier.
function addNativeHtml5Validators(ctrl, validatorName, badFlags, ignoreFlags, validity) {
if (isObject(validity)) {
ctrl.$$hasNativeValidators = true;
var validator = function(value) {
// Don't overwrite previous validation, don't consider valueMissing to apply (ng-required can
// perform the required validation)
if (!ctrl.$error[validatorName] && (validity.badInput || validity.customError ||
validity.typeMismatch) && !validity.valueMissing) {
if (!ctrl.$error[validatorName] &&
!testFlags(validity, ignoreFlags) &&
testFlags(validity, badFlags)) {
ctrl.$setValidity(validatorName, false);
return;
}
Expand All @@ -881,8 +895,9 @@ function addNativeHtml5Validators(ctrl, validatorName, element) {
}

function textInputType(scope, element, attr, ctrl, $sniffer, $browser) {
var validity = element.prop('validity');
var validity = element.prop(VALIDITY_STATE_PROPERTY);
var placeholder = element[0].placeholder, noevent = {};
ctrl.$$validityState = validity;

// In composition mode, users are still inputing intermediate text buffer,
// hold the listener until composition is done.
Expand Down Expand Up @@ -921,16 +936,16 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) {
value = trim(value);
}

if (ctrl.$viewValue !== value ||
// If the value is still empty/falsy, and there is no `required` error, run validators
// again. This enables HTML5 constraint validation errors to affect Angular validation
// even when the first character entered causes an error.
(validity && value === '' && !validity.valueMissing)) {
// If a control is suffering from bad input, browsers discard its value, so it may be
// necessary to revalidate even if the control's value is the same empty value twice in
// a row.
var revalidate = validity && ctrl.$$hasNativeValidators;
if (ctrl.$viewValue !== value || (value === '' && revalidate)) {
if (scope.$$phase) {
ctrl.$setViewValue(value, event);
ctrl.$setViewValue(value, event, revalidate);
} else {
scope.$apply(function() {
ctrl.$setViewValue(value, event);
ctrl.$setViewValue(value, event, revalidate);
});
}
}
Expand Down Expand Up @@ -1079,6 +1094,8 @@ function createDateInputType(type, regexp, parseDate, format) {
};
}

var numberBadFlags = ['badInput'];

function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
textInputType(scope, element, attr, ctrl, $sniffer, $browser);

Expand All @@ -1093,7 +1110,7 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
}
});

addNativeHtml5Validators(ctrl, 'number', element);
addNativeHtml5Validators(ctrl, 'number', numberBadFlags, null, ctrl.$$validityState);

ctrl.$formatters.push(function(value) {
return ctrl.$isEmpty(value) ? '' : '' + value;
Expand Down Expand Up @@ -1773,11 +1790,11 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* event defined in `ng-model-options`. this method is rarely needed as `NgModelController`
* usually handles calling this in response to input events.
*/
this.$commitViewValue = function() {
this.$commitViewValue = function(revalidate) {
var viewValue = ctrl.$viewValue;

$timeout.cancel(pendingDebounce);
if (ctrl.$$lastCommittedViewValue === viewValue) {
if (!revalidate && ctrl.$$lastCommittedViewValue === viewValue) {
return;
}
ctrl.$$lastCommittedViewValue = viewValue;
Expand Down Expand Up @@ -1842,14 +1859,14 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
* @param {string} value Value from the view.
* @param {string} trigger Event that triggered the update.
*/
this.$setViewValue = function(value, trigger) {
this.$setViewValue = function(value, trigger, revalidate) {
ctrl.$viewValue = value;
if (!ctrl.$options || ctrl.$options.updateOnDefault) {
ctrl.$$debounceViewValueCommit(trigger);
ctrl.$$debounceViewValueCommit(trigger, revalidate);
}
};

this.$$debounceViewValueCommit = function(trigger) {
this.$$debounceViewValueCommit = function(trigger, revalidate) {
var debounceDelay = 0,
options = ctrl.$options,
debounce;
Expand All @@ -1868,10 +1885,10 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
$timeout.cancel(pendingDebounce);
if (debounceDelay) {
pendingDebounce = $timeout(function() {
ctrl.$commitViewValue();
ctrl.$commitViewValue(revalidate);
}, debounceDelay);
} else {
ctrl.$commitViewValue();
ctrl.$commitViewValue(revalidate);
}
};

Expand Down
1 change: 1 addition & 0 deletions test/.jshintrc
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"assertNotHasOwnProperty": false,
"getter": false,
"getBlockElements": false,
"VALIDITY_STATE_PROPERTY": true,

/* AngularPublic.js */
"version": false,
Expand Down
40 changes: 38 additions & 2 deletions test/ng/directive/inputSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -608,17 +608,26 @@ describe('ngModel', function() {


describe('input', function() {
var formElm, inputElm, scope, $compile, $sniffer, $browser, changeInputValueTo;
var formElm, inputElm, scope, $compile, $sniffer, $browser, changeInputValueTo, currentSpec;

function compileInput(inputHtml) {
function compileInput(inputHtml, mockValidity) {
inputElm = jqLite(inputHtml);
if (isObject(mockValidity)) {
VALIDITY_STATE_PROPERTY = 'ngMockValidity';
inputElm.prop(VALIDITY_STATE_PROPERTY, mockValidity);
currentSpec.after(function() {
VALIDITY_STATE_PROPERTY = 'validity';
});
}
formElm = jqLite('<form name="form"></form>');
formElm.append(inputElm);
$compile(formElm)(scope);
scope.$digest();
}

var attrs;
beforeEach(function() { currentSpec = this; });
afterEach(function() { currentSpec = null; });
beforeEach(module(function($compileProvider) {
$compileProvider.directive('attrCapture', function() {
return function(scope, element, $attrs) {
Expand Down Expand Up @@ -2238,6 +2247,33 @@ describe('input', function() {
});


it('should invalidate number if suffering from bad input', function() {
compileInput('<input type="number" ng-model="age" />', {
valid: false,
badInput: true
});

changeInputValueTo('10a');
expect(scope.age).toBeUndefined();
expect(inputElm).toBeInvalid();
});


it('should validate number if transition from bad input to empty string', function() {
var validity = {
valid: false,
badInput: true
};
compileInput('<input type="number" ng-model="age" />', validity);
changeInputValueTo('10a');
validity.badInput = false;
validity.valid = true;
changeInputValueTo('');
expect(scope.age).toBeNull();
expect(inputElm).toBeValid();
});


describe('min', function() {

it('should validate', function() {
Expand Down

0 comments on commit 1f6a5a1

Please sign in to comment.