Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

feat(ngModel): add $touched and $untouched states #7673

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
-VALID_CLASS,
-INVALID_CLASS,
-PRISTINE_CLASS,
-DIRTY_CLASS
-DIRTY_CLASS,
-UNTOUCHED_CLASS,
-TOUCHED_CLASS
*/

var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/;
Expand Down Expand Up @@ -1410,7 +1412,9 @@ var inputDirective = ['$browser', '$sniffer', '$filter', function($browser, $sni
var VALID_CLASS = 'ng-valid',
INVALID_CLASS = 'ng-invalid',
PRISTINE_CLASS = 'ng-pristine',
DIRTY_CLASS = 'ng-dirty';
DIRTY_CLASS = 'ng-dirty',
UNTOUCHED_CLASS = 'ng-untouched',
TOUCHED_CLASS = 'ng-touched';

/**
* @ngdoc type
Expand Down Expand Up @@ -1445,6 +1449,8 @@ var VALID_CLASS = 'ng-valid',
*
* @property {Object} $error An object hash with all errors as keys.
*
* @property {boolean} $untouched True if control has not lost focus yet.
* @property {boolean} $touched True if control has lost focus.
* @property {boolean} $pristine True if user has not interacted with the control yet.
* @property {boolean} $dirty True if user has already interacted with the control.
* @property {boolean} $valid True if there is no error.
Expand Down Expand Up @@ -1558,6 +1564,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$parsers = [];
this.$formatters = [];
this.$viewChangeListeners = [];
this.$untouched = true;
this.$touched = false;
this.$pristine = true;
this.$dirty = false;
this.$valid = true;
Expand Down Expand Up @@ -1612,7 +1620,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$


// Setup initial state of the control
$element.addClass(PRISTINE_CLASS);
$element
.addClass(PRISTINE_CLASS)
.addClass(UNTOUCHED_CLASS);
toggleValidCss(true);

// convenience method for easy toggling of classes
Expand Down Expand Up @@ -1682,6 +1692,38 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
$animate.addClass($element, PRISTINE_CLASS);
};

/**
* @ngdoc method
* @name ngModel.NgModelController#$setUntouched
*
* @description
* Sets the control to its untouched state.
*
* This method can be called to remove the 'ng-touched' class and set the control to its
* untouched state (ng-untouched class).
*/
this.$setUntouched = function() {
ctrl.$touched = false;
ctrl.$untouched = true;
$animate.setClass($element, UNTOUCHED_CLASS, TOUCHED_CLASS);
};

/**
* @ngdoc method
* @name ngModel.NgModelController#$setTouched
*
* @description
* Sets the control to its touched state.
*
* This method can be called to remove the 'ng-untouched' class and set the control to its
* touched state (ng-touched class).
*/
this.$setTouched = function() {
ctrl.$touched = true;
ctrl.$untouched = false;
$animate.setClass($element, TOUCHED_CLASS, UNTOUCHED_CLASS);
};

/**
* @ngdoc method
* @name ngModel.NgModelController#$rollbackViewValue
Expand Down Expand Up @@ -2017,6 +2059,12 @@ var ngModelDirective = function() {
});
});
}

element.on('blur', function(ev) {
scope.$apply(function() {

Choose a reason for hiding this comment

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

I don't like that we get one extra-digest here. May be it's better to move it inside upper $apply block and check event object for blur event?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think that there is any other way.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could have some code that checks if the updatedOn has a blur event, and then piggyback on the first digest, otherwise bind to the element the way it currently is.
I think it could be be a good optimization, not sure how expensive a digest is. @matsko what do you think?

modelCtrl.$setTouched();
});
});
}
}
};
Expand Down
2 changes: 2 additions & 0 deletions test/helpers/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ beforeEach(function() {
toBeValid: cssMatcher('ng-valid', 'ng-invalid'),
toBeDirty: cssMatcher('ng-dirty', 'ng-pristine'),
toBePristine: cssMatcher('ng-pristine', 'ng-dirty'),
toBeUntouched: cssMatcher('ng-untouched', 'ng-touched'),
toBeTouched: cssMatcher('ng-touched', 'ng-untouched'),
toBeShown: function() {
this.message = valueFn(
"Expected element " + (this.isNot ? "": "not ") + "to have 'ng-hide' class");
Expand Down
63 changes: 62 additions & 1 deletion test/ng/directive/inputSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ describe('NgModelController', function() {


it('should init the properties', function() {
expect(ctrl.$untouched).toBe(true);
expect(ctrl.$touched).toBe(false);
expect(ctrl.$dirty).toBe(false);
expect(ctrl.$pristine).toBe(true);
expect(ctrl.$valid).toBe(true);
Expand Down Expand Up @@ -133,6 +135,28 @@ describe('NgModelController', function() {
});
});

describe('setUntouched', function() {

it('should set control to its untouched state', function() {
ctrl.$setTouched();

ctrl.$setUntouched();
expect(ctrl.$touched).toBe(false);
expect(ctrl.$untouched).toBe(true);
});
});

describe('setTouched', function() {

it('should set control to its touched state', function() {
ctrl.$setUntouched();

ctrl.$setTouched();
expect(ctrl.$touched).toBe(true);
expect(ctrl.$untouched).toBe(false);
});
});

describe('view -> model', function() {

it('should set the value to $viewValue', function() {
Expand Down Expand Up @@ -265,13 +289,14 @@ describe('NgModelController', function() {

describe('ngModel', function() {

it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty)',
it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty, ng-untouched, ng-touched)',
inject(function($compile, $rootScope, $sniffer) {
var element = $compile('<input type="email" ng-model="value" />')($rootScope);

$rootScope.$digest();
expect(element).toBeValid();
expect(element).toBePristine();
expect(element).toBeUntouched();
expect(element.hasClass('ng-valid-email')).toBe(true);
expect(element.hasClass('ng-invalid-email')).toBe(false);

Expand All @@ -297,6 +322,9 @@ describe('ngModel', function() {
expect(element.hasClass('ng-valid-email')).toBe(true);
expect(element.hasClass('ng-invalid-email')).toBe(false);

browserTrigger(element, 'blur');
expect(element).toBeTouched();

dealoc(element);
}));

Expand All @@ -309,6 +337,23 @@ describe('ngModel', function() {
expect(element).toHaveClass('ng-invalid-required');
}));

it('should set the control touched state on "blur" event', inject(function($compile, $rootScope) {
var element = $compile('<form name="myForm">' +
'<input name="myControl" ng-model="value" >' +
'</form>')($rootScope);
var inputElm = element.find('input');
var control = $rootScope.myForm.myControl;

expect(control.$touched).toBe(false);
expect(control.$untouched).toBe(true);

browserTrigger(inputElm, 'blur');
expect(control.$touched).toBe(true);
expect(control.$untouched).toBe(false);

dealoc(element);
}));


it('should register/deregister a nested ngModel with parent form when entering or leaving DOM',
inject(function($compile, $rootScope) {
Expand Down Expand Up @@ -2633,6 +2678,22 @@ describe('NgModel animations', function() {
assertValidAnimation(animations[1], 'addClass', 'ng-pristine');
}));

it('should trigger an animation when untouched', inject(function($animate) {
model.$setUntouched();

var animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'setClass', 'ng-untouched');
expect(animations[0].args[2]).toBe('ng-touched');
}));

it('should trigger an animation when touched', inject(function($animate) {
model.$setTouched();

var animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'setClass', 'ng-touched', 'ng-untouched');
expect(animations[0].args[2]).toBe('ng-untouched');
}));

it('should trigger custom errors as addClass/removeClass when invalid/valid', inject(function($animate) {
model.$setValidity('custom-error', false);

Expand Down