Skip to content
237 changes: 162 additions & 75 deletions client/src/js/ng-django-forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
};
}
});


Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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();
}
});
}
Expand All @@ -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]);
}
});
}
}
});
Expand Down
66 changes: 64 additions & 2 deletions client/tests/djangoFormsSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ describe('unit tests for module ng.django.forms', function() {
function compileForm($compile, scope, replace_value) {
var template =
'<form name="valid_form" action=".">' +
'<input name="email_field" ng-model="model.email" type="text" {value} />' +
'<input name="email_field" ng-model="model.email" djng-rejected type="text" {value} />' +
'</form>';
var form = angular.element(template.replace('{value}', replace_value));
$compile(form)(scope);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -125,7 +187,7 @@ describe('unit tests for module ng.django.forms', function() {
beforeEach(inject(function($compile) {
var form = angular.element(
'<form name="form" action=".">' +
'<input name="email_field" ng-model="model.email" type="text" />' +
'<input name="email_field" ng-model="model.email" djng-rejected type="text" />' +
'</form>'
);
$compile(form)(scope);
Expand Down
6 changes: 4 additions & 2 deletions djangular/forms/angular_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading