diff --git a/client/src/js/ng-django-forms.js b/client/src/js/ng-django-forms.js index 48d1e2d..0d3acaf 100644 --- a/client/src/js/ng-django-forms.js +++ b/client/src/js/ng-django-forms.js @@ -146,57 +146,118 @@ djng_forms_module.directive('ngModel', function() { }); -// This directive is added automatically by django-angular for widgets of type RadioSelect and -// CheckboxSelectMultiple. This is necessary to adjust the behavior of a collection of input fields, -// which forms a group for one `django.forms.Field`. +// This directive is added automatically by django-angular for widgets of type CheckboxSelectMultiple. +// This is necessary to adjust the behavior of a collection of input fields, which forms a group for +// one `django.forms.Field`. djng_forms_module.directive('validateMultipleFields', function() { return { restrict: 'A', - require: '^?form', - link: function(scope, element, attrs, formCtrl) { - var subFields, checkboxElems = []; - - function validate(event) { - var valid = false; - angular.forEach(checkboxElems, function(checkbox) { - valid = valid || checkbox.checked; - }); - formCtrl.$setValidity('required', valid); - if (event) { - formCtrl.$dirty = true; - formCtrl.$pristine = false; - // element.on('change', validate) is jQuery and runs outside of Angular's digest cycle. - // Therefore Angular does not get the end-of-digest signal and $apply() must be invoked manually. - scope.$apply(); + require: 'validateMultipleFields', + controller: 'ValidateMultipleFieldsCtrl', + link: { + pre: function(scope, element, attrs, ctrl) { + + var subFields; + + try { + subFields = angular.fromJson(attrs.validateMultipleFields); + } catch (SyntaxError) { + if (!angular.isString(attrs.validateMultipleFields)) + return; + subFields = attrs.validateMultipleFields; } - } - if (!formCtrl) - return; - try { - subFields = angular.fromJson(attrs.validateMultipleFields); - } catch (SyntaxError) { - if (!angular.isString(attrs.validateMultipleFields)) - return; - subFields = [attrs.validateMultipleFields]; - formCtrl = formCtrl[subFields]; + ctrl.setSubFields(subFields); + }, + post: function(scope, element, attrs, ctrl) { + ctrl.controlStateChange(); } - angular.forEach(element.find('input'), function(elem) { - if (subFields.indexOf(elem.name) >= 0) { - checkboxElems.push(elem); - angular.element(elem).on('change', validate); - } - }); + } + } +}); + - // remove "change" event handlers from each input field - element.on('$destroy', function() { - angular.forEach(element.find('input'), function(elem) { - angular.element(elem).off('change'); +djng_forms_module.controller('ValidateMultipleFieldsCtrl', function() { + + var vm = this, + ctrls, + subFields; + + vm.setSubFields = setSubFields; + vm.registerCtrl = registerCtrl; + vm.controlStateChange = controlStateChange; + + /* ------------- */ + + function setSubFields(value) { + subFields = value; + } + + function registerCtrl(ctrl) { + + if(_isNotValidSubField(ctrl.$name)) + return; + + ctrls = ctrls || []; + ctrls.push(ctrl); + + return true; + } + + function controlStateChange() { + + var value = false; + + // get collective value for group + angular.forEach(ctrls, function(ctrl) { + value = !!(value || ctrl.$modelValue); + }); + + /* + * set 'required' validity of all controls depending on value. + * this then automatically sets the 'required' error state of the parent ngForm + */ + angular.forEach(ctrls, function(ctrl) { + ctrl.$setValidity('required', value); + if(ctrl.djngClearRejected) + ctrl.djngClearRejected(); + }); + } + + function _isNotValidSubField(name) { + return !!subFields && subFields.indexOf(name) == -1; + } +}); + + +djng_forms_module.directive('ngModel', function() { + return { + restrict:'A', + /* + * ensure that this gets fired after ng.django.forms restore value ngModel + * directive, as if initial/bound value is set, $viewChangeListener is fired + */ + priority: 2, + require: [ + '?^validateMultipleFields', + '?ngModel' + ], + link: function(scope, element, attrs, ctrls) { + + var vmfCtrl = ctrls[0], + ngModel = ctrls[1]; + + if(!vmfCtrl || !ngModel) + return; + + if(vmfCtrl.registerCtrl(ngModel)) { + + ngModel.$viewChangeListeners.push(function() { + vmfCtrl.controlStateChange(); }); - }); - validate(); + } } - }; + } }); @@ -245,6 +306,41 @@ djng_forms_module.directive('validateDate', function() { }); +djng_forms_module.directive('djngRejected', function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, element, attrs, ctrl) { + + if(!ctrl || attrs.djngRejected !== '') + return; + + var clearRejectedError = function(value) { + + if(ctrl.$error.rejected) + ctrl.djngClearRejected(); + + return value; + }; + + ctrl.djngClearRejected = function() { + ctrl.$message = undefined; + ctrl.$setValidity('rejected', true); + }; + + ctrl.djngAddRejected = function(msg) { + ctrl.$message = msg; + ctrl.$setValidity('rejected', false); + ctrl.$setPristine(); + }; + + ctrl.$formatters.push(clearRejectedError); + ctrl.$parsers.push(clearRejectedError); + } + } +}); + + // If forms are validated using Ajax, the server shall return a dictionary of detected errors to the // client code. The success-handler of this Ajax call, now can set those error messages on their // prepared list-items. The simplest way, is to add this code snippet into the controllers function @@ -263,18 +359,9 @@ djng_forms_module.factory('djangoForm', function() { } return false; } - - function resetFieldValidity(field) { - var pos = field.$viewChangeListeners.push(field.clearRejected = function() { - field.$message = ''; - field.$setValidity('rejected', true); - field.$viewChangeListeners.splice(pos - 1, 1); - delete field.clearRejected; - }); - } function isField(field) { - return angular.isArray(field.$viewChangeListeners); + return !!field && angular.isArray(field.$viewChangeListeners); } return { @@ -295,14 +382,14 @@ djng_forms_module.factory('djangoForm', function() { var field, key = rejected.$name; if (form.hasOwnProperty(key)) { field = form[key]; - if (isField(field) && field.clearRejected) { - field.clearRejected(); + if (isField(field) && field.djngClearRejected) { + field.djngClearRejected(); } else { field.$message = ''; // this field is a composite of input elements angular.forEach(field, function(subField, subKey) { - if (subField && isField(subField) && subField.clearRejected) { - subField.clearRejected(); + if (isField(subField) && subField.djngClearRejected) { + subField.djngClearRejected(); } }); } @@ -312,25 +399,25 @@ djng_forms_module.factory('djangoForm', function() { // add the new upstream errors angular.forEach(errors, function(errors, key) { var field; - if (errors.length > 0) { - if (key === NON_FIELD_ERRORS) { - form.$message = errors[0]; - form.$setPristine(); - } else if (form.hasOwnProperty(key)) { - field = form[key]; - field.$message = errors[0]; - field.$setValidity('rejected', false); - field.$setPristine(); - if (isField(field)) { - resetFieldValidity(field); - } else { - // this field is a composite of input elements - angular.forEach(field, function(subField, subKey) { - if (subField && isField(subField)) { - resetFieldValidity(subField); - } - }); - } + if (errors.length === 0) + return; + if (key === NON_FIELD_ERRORS) { + form.$message = errors[0]; + form.$setPristine(); + } else if (form.hasOwnProperty(key)) { + field = form[key]; + if (isField(field)) { + field.djngAddRejected(errors[0]); + } else { + // this field is a composite of input elements + angular.forEach(field, function(subField, subKey) { + if (isField(subField)) { + // add message to ngForm + field.$message = errors[0]; + field.$setPristine(); + subField.djngAddRejected(errors[0]); + } + }); } } }); diff --git a/client/tests/djangoFormsSpec.js b/client/tests/djangoFormsSpec.js index 3285901..686c3c7 100644 --- a/client/tests/djangoFormsSpec.js +++ b/client/tests/djangoFormsSpec.js @@ -4,7 +4,7 @@ describe('unit tests for module ng.django.forms', function() { function compileForm($compile, scope, replace_value) { var template = '
'; var form = angular.element(template.replace('{value}', replace_value)); $compile(form)(scope); @@ -60,6 +60,68 @@ describe('unit tests for module ng.django.forms', function() { }); }); + + describe('test directive djngRejected', function() { + + var scope, form, field; + + beforeEach(function() { + module('ng.django.forms'); + }); + + beforeEach(inject(function($rootScope, $compile) { + scope = $rootScope.$new(); + compileForm($compile, scope, ''); + form = scope.valid_form; + field = scope.valid_form.email_field; + })); + + it('should add djngAddRejected method to ngModel', function() { + expect(typeof field.djngAddRejected).toBe('function'); + }); + + it('should add djngClearRejected method to ngModel', function() { + expect(typeof field.djngClearRejected).toBe('function'); + }); + + it('should set rejected state on control', function() { + field.djngAddRejected('i am rejected'); + expect(field.$error.rejected).toBe(true); + expect(field.$message).toBe('i am rejected'); + expect(field.$pristine).toBe(true); + }); + + it('should remove rejected state from control', function() { + field.djngAddRejected('i am rejected'); + expect(field.$error.rejected).toBe(true); + expect(field.$message).toBe('i am rejected'); + expect(field.$pristine).toBe(true); + field.djngClearRejected(); + expect(field.$error.rejected).toBe(false); + expect(field.$message).toBe(undefined); + }); + + it('should remove rejected state when model changes', function() { + field.djngAddRejected('i am rejected'); + expect(field.$error.rejected).toBe(true); + expect(field.$message).toBe('i am rejected'); + expect(field.$pristine).toBe(true); + scope.model = {email: 'barry@barry.com'}; + scope.$digest(); + expect(field.$error.rejected).toBe(false); + expect(field.$message).toBe(undefined); + }); + + it('should remove rejected state when $viewValue changes', function() { + field.djngAddRejected('i am rejected'); + expect(field.$error.rejected).toBe(true); + expect(field.$message).toBe('i am rejected'); + expect(field.$pristine).toBe(true); + field.$setViewValue('barry@barry.com'); + expect(field.$error.rejected).toBe(false); + expect(field.$message).toBe(undefined); + }); + }); describe('test directive validateDate', function() { var scope, form; @@ -125,7 +187,7 @@ describe('unit tests for module ng.django.forms', function() { beforeEach(inject(function($compile) { var form = angular.element( '' ); $compile(form)(scope); diff --git a/djangular/forms/angular_model.py b/djangular/forms/angular_model.py index 595c174..99005b4 100644 --- a/djangular/forms/angular_model.py +++ b/djangular/forms/angular_model.py @@ -61,16 +61,18 @@ def get_field_errors(self, field): if field.is_hidden: return errors identifier = format_html('{0}.{1}', self.form_name, field.html_name) - errors.append(SafeTuple((identifier, self.field_error_css_classes, '$pristine', '$message', 'invalid', '$message'))) + errors.append(SafeTuple((identifier, self.field_error_css_classes, '$pristine', '$error.rejected', 'invalid', '$message'))) return errors def non_field_errors(self): errors = super(NgModelFormMixin, self).non_field_errors() - errors.append(SafeTuple((self.form_name, self.form_error_css_classes, '$pristine', '$message', 'invalid', '$message'))) + errors.append(SafeTuple((self.form_name, self.form_error_css_classes, '$pristine', '$error.rejected', 'invalid', '$message'))) return errors def get_widget_attrs(self, bound_field): attrs = super(NgModelFormMixin, self).get_widget_attrs(bound_field) + # add rejected error removal directive + attrs.update({'djng-rejected': ''}) identifier = self.add_prefix(bound_field.name) ng = { 'name': bound_field.name, diff --git a/djangular/forms/widgets.py b/djangular/forms/widgets.py index 01e1b1b..022cb62 100644 --- a/djangular/forms/widgets.py +++ b/djangular/forms/widgets.py @@ -81,9 +81,9 @@ def get_field_attrs(self, field): class RadioFieldRendererMixin(object): def __init__(self, name, value, attrs, choices): attrs.pop('djng-error', None) - self.field_attrs = [] + self.field_attrs = [format_html('ng-form="{0}"', name)] if attrs.pop('radio_select_required', False): - self.field_attrs.append(format_html('validate-multiple-fields="{0}"', name)) + attrs.update({'required': ''}) super(RadioFieldRendererMixin, self).__init__(name, value, attrs, choices)