feat(input): support dynamic element validation

Interpolates the form and form control attribute name, so that dynamic form controls (such as those
rendered in an ngRepeat) will always have their expected interpolated name.

The control will be present in its parent form controller with the interpolated property name, and
this name can change when the interpolated value changes.

Closes #4791
Closes #1404
This commit is contained in:
Caitlin Potter
2014-09-23 10:30:03 -04:00
parent dc3de7fb7a
commit 729c238e19
5 changed files with 154 additions and 10 deletions
+31 -7
View File
@@ -4,6 +4,7 @@
*/
var nullFormCtrl = {
$addControl: noop,
$$renameControl: nullFormRenameControl,
$removeControl: noop,
$setValidity: noop,
$$setPending: noop,
@@ -14,6 +15,10 @@ var nullFormCtrl = {
},
SUBMITTED_CLASS = 'ng-submitted';
function nullFormRenameControl(control, name) {
control.$name = name;
}
/**
* @ngdoc type
* @name form.FormController
@@ -51,17 +56,18 @@ SUBMITTED_CLASS = 'ng-submitted';
*
*/
//asks for $scope to fool the BC controller module
FormController.$inject = ['$element', '$attrs', '$scope', '$animate'];
function FormController(element, attrs, $scope, $animate) {
FormController.$inject = ['$element', '$attrs', '$scope', '$animate', '$interpolate'];
function FormController(element, attrs, $scope, $animate, $interpolate) {
var form = this,
parentForm = element.parent().controller('form') || nullFormCtrl,
controls = [];
var parentForm = form.$$parentForm = element.parent().controller('form') || nullFormCtrl;
// init state
form.$error = {};
form.$$success = {};
form.$pending = undefined;
form.$name = attrs.name || attrs.ngForm;
form.$name = $interpolate(attrs.name || attrs.ngForm || '')($scope);
form.$dirty = false;
form.$pristine = true;
form.$valid = true;
@@ -127,6 +133,17 @@ function FormController(element, attrs, $scope, $animate) {
}
};
// Private API: rename a form control
form.$$renameControl = function(control, newName) {
var oldName = control.$name;
if (form[oldName] === control) {
delete form[oldName];
}
form[newName] = control;
control.$name = newName;
};
/**
* @ngdoc method
* @name form.FormController#$removeControl
@@ -466,13 +483,20 @@ var formDirectiveFactory = function(isNgForm) {
});
}
var parentFormCtrl = formElement.parent().controller('form'),
alias = attr.name || attr.ngForm;
var parentFormCtrl = controller.$$parentForm,
alias = controller.$name;
if (alias) {
setter(scope, alias, controller, alias);
attr.$observe(attr.name ? 'name' : 'ngForm', function(newValue) {
if (alias === newValue) return;
setter(scope, alias, undefined, alias);
alias = newValue;
setter(scope, alias, controller, alias);
parentFormCtrl.$$renameControl(controller, alias);
});
}
if (parentFormCtrl) {
if (parentFormCtrl !== nullFormCtrl) {
formElement.on('$destroy', function() {
parentFormCtrl.$removeControl(controller);
if (alias) {
+9 -3
View File
@@ -1657,8 +1657,8 @@ var VALID_CLASS = 'ng-valid',
*
*
*/
var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q',
function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q) {
var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', '$interpolate',
function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q, $interpolate) {
this.$viewValue = Number.NaN;
this.$modelValue = Number.NaN;
this.$validators = {};
@@ -1675,7 +1675,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$error = {}; // keep invalid keys here
this.$$success = {}; // keep valid keys here
this.$pending = undefined; // keep pending keys here
this.$name = $attr.name;
this.$name = $interpolate($attr.name || '', false)($scope);
var parsedNgModel = $parse($attr.ngModel),
@@ -2387,6 +2387,12 @@ var ngModelDirective = function() {
// notify others, especially parent forms
formCtrl.$addControl(modelCtrl);
attr.$observe('name', function(newValue) {
if (modelCtrl.$name !== newValue) {
formCtrl.$$renameControl(modelCtrl, newValue);
}
});
scope.$on('$destroy', function() {
formCtrl.$removeControl(modelCtrl);
});
+51
View File
@@ -782,6 +782,57 @@ describe('form', function() {
});
});
it('should rename nested form controls when interpolated name changes', function() {
scope.idA = 'A';
scope.idB = 'X';
doc = $compile(
'<form name="form">' +
'<div ng-form="nested{{idA}}">' +
'<div ng-form name="nested{{idB}}"' +
'</div>' +
'</div>' +
'</form'
)(scope);
scope.$digest();
var formA = scope.form.nestedA;
expect(formA).toBeDefined();
expect(formA.$name).toBe('nestedA');
var formX = formA.nestedX;
expect(formX).toBeDefined();
expect(formX.$name).toBe('nestedX');
scope.idA = 'B';
scope.idB = 'Y';
scope.$digest();
expect(scope.form.nestedA).toBeUndefined();
expect(scope.form.nestedB).toBe(formA);
expect(formA.nestedX).toBeUndefined();
expect(formA.nestedY).toBe(formX);
});
it('should rename forms with no parent when interpolated name changes', function() {
var element = $compile('<form name="name{{nameID}}"></form>')(scope);
var element2 = $compile('<div ng-form="name{{nameID}}"></div>')(scope);
scope.nameID = "A";
scope.$digest();
var form = element.controller('form');
var form2 = element2.controller('form');
expect(form.$name).toBe('nameA');
expect(form2.$name).toBe('nameA');
scope.nameID = "B";
scope.$digest();
expect(form.$name).toBe('nameB');
expect(form2.$name).toBe('nameB');
});
describe('$setSubmitted', function() {
beforeEach(function() {
doc = $compile(
+36
View File
@@ -1289,6 +1289,42 @@ describe('input', function() {
}
}));
it('should interpolate input names', function() {
scope.nameID = '47';
compileInput('<input type="text" ng-model="name" name="name{{nameID}}" />');
expect(scope.form.name47.$pristine).toBeTruthy();
changeInputValueTo('caitp');
expect(scope.form.name47.$dirty).toBeTruthy();
});
it('should rename form controls in form when interpolated name changes', function() {
scope.nameID = "A";
compileInput('<input type="text" ng-model="name" name="name{{nameID}}" />');
expect(scope.form.nameA.$name).toBe('nameA');
var oldModel = scope.form.nameA;
scope.nameID = "B";
scope.$digest();
expect(scope.form.nameA).toBeUndefined();
expect(scope.form.nameB).toBe(oldModel);
expect(scope.form.nameB.$name).toBe('nameB');
});
it('should rename form controls in null form when interpolated name changes', function() {
var element = $compile('<input type="text" ng-model="name" name="name{{nameID}}" />')(scope);
scope.nameID = "A";
scope.$digest();
var model = element.controller('ngModel');
expect(model.$name).toBe('nameA');
scope.nameID = "B";
scope.$digest();
expect(model.$name).toBe('nameB');
});
describe('"change" event', function() {
function assertBrowserSupportsChangeEvent(inputEventSupported) {
// Force browser to report a lack of an 'input' event
+27
View File
@@ -148,6 +148,33 @@ describe('select', function() {
});
it('should interpolate select names', function() {
scope.robots = ['c3p0', 'r2d2'];
scope.name = 'r2d2';
scope.nameID = 47;
compile('<select ng-model="name" name="name{{nameID}}">' +
'<option ng-repeat="r in robots">{{r}}</option>' +
'</select>');
expect(scope.form.name47.$pristine).toBeTruthy();
browserTrigger(element.find('option').eq(0));
expect(scope.form.name47.$dirty).toBeTruthy();
expect(scope.name).toBe('c3p0');
});
it('should rename select controls in form when interpolated name changes', function() {
scope.nameID = "A";
compile('<select ng-model="name" name="name{{nameID}}"></select>');
expect(scope.form.nameA.$name).toBe('nameA');
var oldModel = scope.form.nameA;
scope.nameID = "B";
scope.$digest();
expect(scope.form.nameA).toBeUndefined();
expect(scope.form.nameB).toBe(oldModel);
expect(scope.form.nameB.$name).toBe('nameB');
});
describe('empty option', function() {
it('should select the empty option when model is undefined', function() {