diff --git a/client/karma.conf.1.3.js b/client/karma.conf.1.3.js new file mode 100644 index 0000000..b4b3951 --- /dev/null +++ b/client/karma.conf.1.3.js @@ -0,0 +1,59 @@ +'use strict'; + +// Karma configuration +module.exports = function(config) { + function getFiles() { + var fs = require('fs'); + var files = []; + ['angular.js', 'angular-mocks.js', 'angular-messages.js'].forEach(function(item) { + var cachename = 'cdncache/' + item; + files.push(fs.existsSync(cachename) ? cachename : 'http://code.angularjs.org/1.3.0/' + item); + }); + return files.concat(['src/js/*.js', 'tests/*.js', 'mocks/*.js']); + } + + config.set({ + // frameworks to use + frameworks: ['jasmine'], + + // list of files / patterns to load in the browser + files: getFiles(), + + // list of files to exclude + exclude: [], + + // test results reporter to use + // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' + reporters: ['progress'], + + // web server port + port: 9090, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera (has to be installed with `npm install karma-opera-launcher`) + // - Safari (only Mac; has to be installed with `npm install karma-safari-launcher`) + // - PhantomJS + // - IE (only Windows; has to be installed with `npm install karma-ie-launcher`) + browsers: ['ChromeCanary'], + + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false + }); +}; diff --git a/client/karma.conf.js b/client/karma.conf.js index 59be032..2bbb0f7 100644 --- a/client/karma.conf.js +++ b/client/karma.conf.js @@ -20,7 +20,7 @@ module.exports = function(config) { files: getFiles(), // list of files to exclude - exclude: [], + exclude: ['tests/djangoNgMessagesSpec.js'], // test results reporter to use // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' diff --git a/client/src/js/ng-django-angular-messages.js b/client/src/js/ng-django-angular-messages.js new file mode 100644 index 0000000..45f61b2 --- /dev/null +++ b/client/src/js/ng-django-angular-messages.js @@ -0,0 +1,257 @@ +(function(angular, undefined) { +'use strict'; + + +if(angular.version.minor < 3 ) { + // throw new Error('The ng.django.angular.messages module requires AngularJS 1.3+'); +} + + +angular + .module('ng.django.angular.messages',[ + 'ng.django.forms' + ]) + .directive('form', formDirectiveFactory()) + .directive('ngForm', formDirectiveFactory(true)) + .directive('djngError', djngError) + .directive('djngRejected', djngRejected) + .factory('djngAngularMessagesForm', djngAngularMessagesForm); + + + + +/** + * An extension to form + * + * Adds the following methods and functionality: + * + * - djngSetValidFieldsPristine() + */ + +function formDirectiveFactory(isNgForm) { + + return function() { + + return { + restrict: isNgForm ? 'EAC' : 'E', + require: 'form', + link: { + pre: function(scope, element, attrs, formCtrl) { + + var controls, + modelName; + + var _superAdd = formCtrl.$addControl; + + formCtrl.$addControl = function(control) { + + _superAdd(control) + + controls = controls || []; + + if(controls.indexOf(control) === -1) { + controls.push(control); + } + } + + var _superRemove = formCtrl.$removeControl; + + formCtrl.$removeControl = function(control) { + + _superRemove(control) + + if(controls && controls.indexOf(control) !== -1) { + controls.splice(controls.indexOf(control), 1); + } + } + + formCtrl.djngSetValidFieldsPristine = function() { + + var i = 0, + len = controls.length, + control; + + for(; i < len; i++) { + control = controls[i]; + if(control.$valid) { + control.$setPristine(); + } + } + } + } + } + } + } + +} + + +function djngError($timeout) { + + return { + restrict: 'A', + require: [ + '?^form', + '?ngModel' + ], + link: function(scope, element, attrs, ctrls) { + + var formCtrl = ctrls[0], + ngModel = ctrls[1]; + + if (attrs.djngError !== 'bound-msgs-field' || !formCtrl || !ngModel) + return; + + element.removeAttr('djng-error'); + element.removeAttr('djng-error-msg'); + + $timeout(function(){ + + // TODO: use ngModel.djngAddRejected to set message + + ngModel.$message = attrs.djngErrorMsg; + ngModel.$validate(); + formCtrl.$setSubmitted(); + }); + } + } +} + + +function djngRejected() { + + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, element, attrs, ngModel) { + + if(!ngModel || attrs.djngRejected !== 'validator') + return; + + var _hasMessage = false, + _value = null; + + ngModel.$validators.rejected = function(value) { + + if(_hasMessage && (_value !== value)) { + + _hasMessage = false; + _value = null; + + ngModel.$message = undefined; + + }else{ + + _hasMessage = !!ngModel.$message; + + if(_hasMessage) + _value = value; + } + + return !_hasMessage; + } + + /* + ctrl.djngClearRejected = function() { + if(!!ctrl.$message) { + ctrl.$message = undefined; + ctrl.$validate(); + } + }; + + ctrl.djngAddRejected = function(msg) { + ctrl.$message = msg; + ctrl.$validate(); + }; + */ + } + } +} + + +function djngAngularMessagesForm() { + + var NON_FIELD_ERRORS = '__all__'; + + return { + setErrors: setErrors + } + + /* ============================ */ + + function setErrors(form, errors) { + _clearFormMessage(form); + _displayErrors(form, errors); + return _isNotEmpty(errors); + }; + + function _clearFormMessage(form) { + form.$message = undefined; + }; + + function _displayErrors(form, errors) { + + form.$setSubmitted(); + + angular.forEach(errors, function(error, key) { + + var field, + message = error[0]; + + if(key == NON_FIELD_ERRORS) { + + form.$message = message; + /* + * Only set current valid fields to pristine + * + * Any field that's been submitted with an error should + * still display its error + * + * Any field that was valid when the form was submitted, + * may have caused the NON_FIELD_ERRORS, so should be set + * to pristine to prevent it's valid state being displayed + */ + form.djngSetValidFieldsPristine(); + + }else if(form.hasOwnProperty(key)) { + + field = form[key]; + field.$message = message; + + if (isField(field)) { + + field.$validate(); + + } else { + + // this field is a composite of input elements + field.$setSubmitted(); + + angular.forEach(field, function(subField, subKey) { + + if(isField(subField)) { + subField.$validate(); + } + }); + } + + } + }); + } + + function isField(field) { + return !!field && angular.isArray(field.$viewChangeListeners); + } + + function _isNotEmpty(obj) { + for (var p in obj) { + if (obj.hasOwnProperty(p)) + return true; + } + return false; + } +}; + + + +})(window.angular); diff --git a/client/src/js/ng-django-forms.js b/client/src/js/ng-django-forms.js index 48d1e2d..d91fe46 100644 --- a/client/src/js/ng-django-forms.js +++ b/client/src/js/ng-django-forms.js @@ -363,4 +363,4 @@ djng_forms_module.directive('djngBindIf', function() { }; }); -})(window.angular); +})(window.angular); \ No newline at end of file diff --git a/client/tests/djangoNgMessagesSpec.js b/client/tests/djangoNgMessagesSpec.js new file mode 100644 index 0000000..92d2b8f --- /dev/null +++ b/client/tests/djangoNgMessagesSpec.js @@ -0,0 +1,199 @@ +'use strict'; + +describe('unit tests for module ng.django.angular.messages', function() { + + function compileForm($compile, scope, replace_value, replace_rejected_type) { + var template = + '
' + + '' + + '
'; + template = template.replace('{value}', replace_value) + template = template.replace('{rejected_type}', replace_rejected_type || 'validator') + var form = angular.element(template); + $compile(form)(scope); + scope.$digest(); + } + + function compileFormWithBoundError($compile, scope, replace_value, replace_djng_error_type) { + var template = + '
' + + '' + + '
'; + template = template.replace('{value}', replace_value) + template = template.replace('{error_type}', replace_djng_error_type || 'bound-msgs-field') + var form = angular.element(template); + $compile(form)(scope); + scope.$digest(); + } + + beforeEach(function() { + module('ng.django.angular.messages'); + }); + + + describe('form extension behaviour', function() { + + var scope, form, field; + + beforeEach(inject(function($rootScope, $compile) { + scope = $rootScope.$new(); + compileForm($compile, scope, ''); + form = scope.valid_form; + field = scope.valid_form.email_field; + })); + + it('should set valid fields back to pristine', function() { + field.$setViewValue('example@example.com'); + expect(field.$valid).toBe(true); + expect(field.$pristine).toBe(false); + form.djngSetValidFieldsPristine(); + expect(field.$valid).toBe(true); + expect(field.$pristine).toBe(true); + }); + + it('should leave invalid fields dirty', function() { + field.$setViewValue('example'); + expect(field.$valid).toBe(false); + expect(field.$pristine).toBe(false); + form.djngSetValidFieldsPristine(); + expect(field.$valid).toBe(false); + expect(field.$pristine).toBe(false); + }); + + }); + + + describe('bound form error handling', function() { + + var scope, form, field, $timeout; + + beforeEach(inject(function($rootScope, $compile, _$timeout_) { + scope = $rootScope.$new(); + compileFormWithBoundError($compile, scope, 'value="barry"'); + form = scope.valid_form; + field = scope.valid_form.email_field; + $timeout = _$timeout_; + })); + + it('should invalidate field when djng-error exists', function() { + $timeout.flush(); + expect(field.$valid).toBe(false); + expect(field.$error.rejected).toBe(true); + expect(field.$message).toBe('valid email required'); + }); + + it('should set form to $submitted', function() { + $timeout.flush(); + expect(form.$submitted).toBe(true); + }); + + it('should ignore incorrect djng-error type', inject(function($rootScope, $compile) { + scope = $rootScope.$new(); + compileFormWithBoundError($compile, scope, 'value="barry"', 'bound-field'); + form = scope.valid_form; + field = scope.valid_form.email_field; + expect(field.$valid).toBe(true); + expect(field.$error.rejected).toBe(undefined); + expect(field.$message).toBe(undefined); + })); + }); + + + describe('rejected validation directive', function() { + + var scope, form, field; + + beforeEach(inject(function($rootScope, $compile) { + scope = $rootScope.$new(); + compileForm($compile, scope, ''); + form = scope.valid_form; + field = scope.valid_form.email_field; + })); + + it('should invalidate field when rejected message exists', function() { + field.$setViewValue('example@example.com'); + field.$message = 'email already in use'; + field.$validate(); + expect(field.$valid).toBe(false); + expect(field.$error.rejected).toBe(true); + }); + + it('should clear message and return true when new value is set on field and rejected message exists from previous failed rejected validation', function() { + field.$setViewValue('example@example.com'); + field.$message = 'email already in use'; + field.$validate(); + expect(field.$valid).toBe(false); + expect(field.$error.rejected).toBe(true); + field.$setViewValue('example@example2.com'); + expect(field.$valid).toBe(true); + expect(field.$error.rejected).toBe(undefined); + }); + + it('should not remove message if field value is the same as previous failed rejected validation', function() { + field.$setViewValue('example@example.com'); + field.$message = 'email already in use'; + field.$validate(); + expect(field.$valid).toBe(false); + expect(field.$error.rejected).toBe(true); + field.$setViewValue('example@example.com'); + field.$validate(); + expect(field.$valid).toBe(false); + expect(field.$error.rejected).toBe(true); + }); + + it('should ignore incorrect djng-rejected type', inject(function($rootScope, $compile) { + scope = $rootScope.$new(); + compileForm($compile, scope, '', 'none'); + form = scope.valid_form; + field = scope.valid_form.email_field; + field.$setViewValue('example@example.com'); + field.$message = 'email already in use'; + field.$validate(); + expect(field.$valid).toBe(true); + expect(field.$error.rejected).toBe(undefined); + })); + }); + + + describe('form rejected error handling', function() { + + var scope, form, field, djngAngularMessagesForm, + formError = {errors: {__all__: ["The email is rejected by the server."]}}, + fieldError = {errors: {email_field: ["This field is required."]}}; + + beforeEach(inject(function($rootScope, $compile, _djngAngularMessagesForm_){ + scope = $rootScope.$new(); + compileForm($compile, scope, ''); + form = scope.valid_form; + field = scope.valid_form.email_field; + djngAngularMessagesForm = _djngAngularMessagesForm_; + })); + + it('should return false if no errors listed', function() { + expect(djngAngularMessagesForm.setErrors(form, undefined)).toBe(false); + }); + + it('should return true if non field errors listed', function() { + expect(djngAngularMessagesForm.setErrors(form, formError.errors)).toBe(true); + }); + + it('should return true if field errors listed', function() { + expect(djngAngularMessagesForm.setErrors(form, fieldError.errors)).toBe(true); + }); + + it('should add rejected non field errors to form.$message and set valid fields pristine', function() { + field.$setViewValue('example@example.com'); + expect(field.$pristine).toBe(false); + djngAngularMessagesForm.setErrors(form, formError.errors); + expect(form.$message).toBe('The email is rejected by the server.'); + expect(field.$pristine).toBe(true); + }); + + it('should add rejected error message to field.$message and validate', function() { + djngAngularMessagesForm.setErrors(form, fieldError.errors); + expect(field.$message).toBe('This field is required.'); + expect(field.$valid).toBe(false); + }); + }); + +}); diff --git a/djangular/forms/__init__.py b/djangular/forms/__init__.py index 3932fa2..f4bd26a 100644 --- a/djangular/forms/__init__.py +++ b/djangular/forms/__init__.py @@ -11,6 +11,7 @@ from .models import PatchedModelFormMetaclass as ModelFormMetaclass else: from django.forms.models import ModelFormMetaclass +from .angular_messages import NgMessagesMixin class NgDeclarativeFieldsMetaclass(BaseFieldsModifierMetaclass, DeclarativeFieldsMetaclass): diff --git a/djangular/forms/angular_messages.py b/djangular/forms/angular_messages.py new file mode 100644 index 0000000..13b4613 --- /dev/null +++ b/djangular/forms/angular_messages.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.utils.html import format_html, format_html_join +from django.utils.encoding import python_2_unicode_compatible, force_text +from django.utils.safestring import mark_safe, SafeText + +from djangular.forms.angular_base import TupleErrorList, SafeTuple, NgFormBaseMixin + + + +class NgMessagesFormErrorList(TupleErrorList): + ul_format = '' + + +class NgMessagesFieldErrorList(TupleErrorList): + + ul_format_valid = '' + li_format_valid = '
  • {3}
  • ' + + ul_format = '' + li_format = '
  • {3}
  • ' + """ a span is necessary due to this bug https://github.com/angular/angular.js/issues/8089 """ + li_format_bind = '
  • ' + + def as_ul(self): + if not self: + return SafeText() + first = self[0] + if isinstance(first, tuple): + valid_list = [] + invalid_list = [] + for e in self: + """ Ignore $pristine errors, as they relate to the original rejected error handling or djng-error bound-field""" + if e[2] == '$pristine': + continue + + if e[3] == '$valid': + li_format = self.li_format_valid + error_list = valid_list + elif e[5] == '$message': + li_format = self.li_format_bind + error_list = invalid_list + else: + li_format = self.li_format + error_list = invalid_list + + msg_type = e[3].split('.') + err_tuple = (e[0], msg_type[0] if len(msg_type) == 1 else msg_type.pop(), e[4], force_text(e[5])) + error_list.append(format_html(li_format, *err_tuple)) + + return mark_safe(format_html(self.ul_format_valid, first[0], first[1], self._get_form_name(first[0]), mark_safe(''.join(valid_list)))) \ + + mark_safe(format_html(self.ul_format, first[0], first[1], self._get_form_name(first[0]), mark_safe(''.join(invalid_list)))) + + return format_html('', + format_html_join('', '
  • {0}
  • ', ((force_text(e),) for e in self))) + + def _get_form_name(self, value): + parts = value.split('.') + parts.pop() + return '.'.join(parts) + + + +class NgMessagesMixin(NgFormBaseMixin): + + def __init__(self, data=None, *args, **kwargs): + self.form_error_class = kwargs.pop('form_error_class', NgMessagesFormErrorList) + error_class = kwargs.pop('error_class', NgMessagesFieldErrorList) + kwargs.setdefault('error_class', error_class) + super(NgMessagesMixin, self).__init__(data, *args, **kwargs) + + def get_field_errors(self, field): + errors = super(NgMessagesMixin, self).get_field_errors(field) + if field.is_hidden: + return errors + identifier = format_html('{0}.{1}', self.form_name, field.name) + errors.append(SafeTuple((identifier, self.field_error_css_classes, '$dirty', 'rejected', 'invalid', '$message'))) + return errors + + def non_field_errors(self): + errors = super(NgMessagesMixin, self).non_field_errors() + return self.form_error_class(errors) + + def get_widget_attrs(self, bound_field): + attrs = super(NgMessagesMixin, self).get_widget_attrs(bound_field) + attrs.update({'djng-rejected': 'validator'}) + if self.is_bound: + self._apply_bound_error(bound_field, attrs) + return attrs + + def _apply_bound_error(self, bound_field, attrs): + for error in bound_field.errors: + if error[3] == '$pristine': + # override NgFormValidationMixin djng-error 'bound-field' + attrs.update({'djng-error': 'bound-msgs-field'}) + attrs.update({'djng-error-msg': error[5]}) diff --git a/djangular/static/djangular/css/styles.css b/djangular/static/djangular/css/styles.css index 5ece16e..3730322 100644 --- a/djangular/static/djangular/css/styles.css +++ b/djangular/static/djangular/css/styles.css @@ -2,10 +2,15 @@ display: none !important; } -form .ng-invalid-bound.ng-pristine, form .ng-invalid.ng-dirty { +form.ng-submitted .ng-invalid, +form .ng-invalid-bound.ng-pristine, +form .ng-invalid.ng-dirty { border-color: #e9322d; } -form .ng-invalid-bound.ng-pristine:focus, form .ng-invalid.ng-dirty:focus { + +form.ng-submitted .ng-invalid:focus, +form .ng-invalid-bound.ng-pristine:focus, +form .ng-invalid.ng-dirty:focus { box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; } diff --git a/docs/angular-form-validation.rst b/docs/angular-form-validation.rst index 4f57b7f..73a1e41 100644 --- a/docs/angular-form-validation.rst +++ b/docs/angular-form-validation.rst @@ -127,7 +127,7 @@ rendered in templates using a special field tag. Say, the form contains from djangular.forms import NgFormValidationMixin class MyValidatedForm(NgFormValidationMixin, forms.Form): - email = forms.EmailField(label='Email') + email = forms.EmailField(label='Email') then access the potential validation errors in templates using ``{{ form.email.errors }}``. This renders the form with an unsorted list of potential errors, which may occur during client side diff --git a/docs/angular-messages.rst b/docs/angular-messages.rst new file mode 100644 index 0000000..aa01df3 --- /dev/null +++ b/docs/angular-messages.rst @@ -0,0 +1,130 @@ +.. _angular-messages: + +================================================== +Render Form field error lists in ngMessages format +================================================== + +.. note:: This requires AngularJS 1.3+ and the ``angular-messages`` module_. + +.. _module: https://docs.angularjs.org/api/ngMessages + +NgMessagesMixin +=============== + +The ``NgMessagesMixin`` mixin is used in conjunction with the ``NgFormValidationMixin`` to facilitate +the rendering of form field error lists, in the correct format for the ngMessages directive. + +.. code-block:: python + + from django import forms + from djangular.forms import NgForm, NgFormValidationMixin, NgMessagesMixin + + class MyNgMessagesForm(NgMessagesMixin, NgFormValidationMixin, NgForm): + form_name = 'my_form' + email = forms.EmailField(label='Email') + +Then using ``{{ form.email.errors }}`` would output the following markup: + +.. code-block:: html + + + + +Handling Ajax form errors +......................... + +The ``NgMessagesMixin`` adds the ``djng-rejected`` directive attribute to each form ``input``. This directive +handles the display and remvoval of server side errors, by adding a ``rejected`` validator to the ``input``'s +``ngModel.$validators`` pipeline. + +.. code-block:: html + + + +The ``djngAngularMessagesForm.setErrors`` method is used to parse the errors from the server response and apply +them to the relevant fields. + +.. code-block:: javascript + + .factory('myFormService', function($http, djngAngularMessagesForm) { + + return { + submit: function(data, form) { + return $http.post('my/form/url', data) + .success(function(response) { + if(!djngAngularMessagesForm.setErrors(form, response.errors)) { + // we have no errors + } + }) + } + } + }); + +The markup below is a snippet of the ``{{ form.email.errors }}`` shown earlier. It shows the specific part that deals +with the display of the rejected error message. The ```` to bind to the value of ``my_form.email.$message`` +and display the message is necessary due to the following bug/issue_. + +.. _bug/issue: https://github.com/angular/angular.js/issues/8089 + +.. code-block:: html + +
  • + /* rejected error message will be displayed here */ +
  • + + +Use with other django-angular form mixins +........................................... + +The ``NgMessagesMixin`` must always be used in conjunction with the ``NgFormValidationMixin`` and it should also +be inherited after all other django-angular form mixins. + +Valid examples: + +.. code-block:: python + + from django import forms + from djangular.forms import NgForm, NgFormValidationMixin, NgMessagesMixin + + class MyNgMessagesForm(NgMessagesMixin, NgFormValidationMixin, NgForm): + # custom form logic + +Or + +.. code-block:: python + + from django import forms + from djangular.forms import NgForm, NgModelFormMixin, NgFormValidationMixin, NgMessagesMixin + + class MyNgMessagesForm(NgMessagesMixin, NgModelFormMixin, NgFormValidationMixin, NgForm): + # custom form logic + +Invalid examples: + +.. code-block:: python + + from django import forms + from djangular.forms import NgForm, NgModelFormMixin, NgMessagesMixin + + class MyNgMessagesForm(NgMessagesMixin, NgModelFormMixin, NgForm): + # custom form logic + +Or + +.. code-block:: python + + from django import forms + from djangular.forms import NgForm, NgFormValidationMixin, NgMessagesMixin + + class MyNgMessagesForm(NgFormValidationMixin, NgMessagesMixin, NgForm): + # custom form logic + +.. note:: Depending on the combination of form mixins used, up to a 30% decrease in watchers can be achieved + when using the ``NgMessagesMixin`` + diff --git a/docs/changelog.rst b/docs/changelog.rst index ff71ee8..3b67ed1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,7 +32,7 @@ Release History ------ * Using ``field.html_name`` instead of ``field.name``. Otherwise ``add_prefix()`` function on form objects doesn't work properly. -* Fixed Firefox checkbox change sync issue caused by ``click```and ``change```firing in +* Fixed Firefox checkbox change sync issue caused by ``click`` and ``change`` firing in opposite order to other browsers. Switched to ``ng-change`` to normalise behaviour. * Moved rejected error cleanup logic into ``field.clearRejected`` method, so that it can be removed from anywhere that has access to the field. diff --git a/docs/index.rst b/docs/index.rst index 6f4d02e..49a6243 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,6 +21,7 @@ Contents demos angular-model-form angular-form-validation + angular-messages basic-crud-operations remote-method-invocation csrf-protection diff --git a/examples/server/forms/ng_messages.py b/examples/server/forms/ng_messages.py new file mode 100644 index 0000000..040074e --- /dev/null +++ b/examples/server/forms/ng_messages.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# start tutorial +from django.core.exceptions import ValidationError +from djangular.forms import NgModelFormMixin, NgFormValidationMixin, NgMessagesMixin + +from . import subscribe_form + + +class SubscribeForm(NgMessagesMixin, NgModelFormMixin, NgFormValidationMixin, subscribe_form.SubscribeForm): + scope_prefix = 'subscribe_data' + form_name = 'my_form' + + def clean(self): + if self.cleaned_data.get('first_name') == 'John' and self.cleaned_data.get('last_name') == 'Doe': + raise ValidationError('The full name "John Doe" is rejected by the server.') + return super(SubscribeForm, self).clean() diff --git a/examples/server/forms/subscribe_form.py b/examples/server/forms/subscribe_form.py index 1cfafb7..3897845 100644 --- a/examples/server/forms/subscribe_form.py +++ b/examples/server/forms/subscribe_form.py @@ -21,6 +21,7 @@ class SubscribeForm(Bootstrap3Form): NOTIFY_BY = (('email', 'EMail'), ('phone', 'Phone'), ('sms', 'SMS'), ('postal', 'Postcard'),) first_name = forms.CharField(label='First name', min_length=3, max_length=20) + last_name = forms.RegexField(r'^[A-Z][a-z -]?', label='Last name', error_messages={'invalid': 'Last names shall start in upper case'}) sex = forms.ChoiceField(choices=(('m', 'Male'), ('f', 'Female')), @@ -53,4 +54,4 @@ class SubscribeForm(Bootstrap3Form): validators=[validate_password], help_text='The password is "secret"') confirmation_key = forms.CharField(max_length=40, required=True, widget=forms.HiddenInput(), - initial='hidden value') + initial='hidden value') \ No newline at end of file diff --git a/examples/server/static/js/ng-messages.js b/examples/server/static/js/ng-messages.js new file mode 100644 index 0000000..e3adecf --- /dev/null +++ b/examples/server/static/js/ng-messages.js @@ -0,0 +1,15 @@ +angular.module('djangular-demo').controller('MyFormCtrl', function($scope, $http, $window, djngAngularMessagesForm) { + $scope.submit = function() { + if ($scope.subscribe_data) { + $http.post(".", $scope.subscribe_data).success(function(out_data) { + if (!djngAngularMessagesForm.setErrors($scope.my_form, out_data.errors)) { + // on successful post, redirect onto success page + $window.location.href = out_data.success_url; + } + }).error(function() { + console.error('An error occured during submission'); + }); + } + return false; + }; +}); diff --git a/examples/server/templates/base.html b/examples/server/templates/base.html index 2ef7435..ca3c581 100644 --- a/examples/server/templates/base.html +++ b/examples/server/templates/base.html @@ -35,6 +35,9 @@
  • Combined Validation
  • +
  • + Angular Messages +
  • {% if WITH_WS4REDIS %}
  • Three-Way Data-Binding @@ -48,9 +51,21 @@
    {% block container %}{% endblock %}
    - + + + {% block scripts %}{% endblock %} + + + + {% block demo_scripts %} + {% endblock %} + Fork me on GitHub diff --git a/examples/server/templates/form-data-valid.html b/examples/server/templates/form-data-valid.html index b1d8cd2..d4163fa 100644 --- a/examples/server/templates/form-data-valid.html +++ b/examples/server/templates/form-data-valid.html @@ -4,10 +4,3 @@

    Thank's for submission

    The entered data was valid!

    {% endblock container %} - -{% block scripts %} - {{ block.super }} - -{% endblock scripts %} diff --git a/examples/server/templates/model-scope.html b/examples/server/templates/model-scope.html index cf30198..0935bd6 100644 --- a/examples/server/templates/model-scope.html +++ b/examples/server/templates/model-scope.html @@ -19,18 +19,18 @@
    MyFormCtrl's scope:
    {% endverbatim %} {% endblock form_foot %} -{% block scripts %} +{% block ng_module_definition %} {{ block.super }} - +{% endblock %} + +{% block demo_scripts %} + {{ block.super }} - -{% endblock scripts %} +{% endblock %} {% block tutorial_intro %}

    This example shows how to diff --git a/examples/server/templates/ng-messages.html b/examples/server/templates/ng-messages.html new file mode 100644 index 0000000..aec6d6e --- /dev/null +++ b/examples/server/templates/ng-messages.html @@ -0,0 +1,52 @@ +{% extends "model-scope.html" %} +{% load tutorial_tags %} + +{% block form_title %}Angular-Messages Demo{% endblock %} + +{% block form_header %}Adds support for form field error lists to be rendered to use the ngMessages directive.
    Requires AngularJS 1.3+.{% endblock %} + + +{% block form_tag %}name="{{ form.form_name }}" method="post" action="." novalidate ng-controller="MyFormCtrl"{% endblock %} + +{% block form_submission %} +

    +   + +

    +

    Test submission of invalid Form data:
    +   + +

    +{% endblock %} + +{% block tutorial_intro %} +

    In this example, +Form field error display and management is handled by the ngMessages directive.

    +

    If a Form inherits from NgMessagesMixin then each field error list is rendered +in the correct format for the ng-messages module directive.

    + +{% endblock tutorial_intro %} + +{% block tutorial_code %} +
    {% pygments "forms/ng_messages.py" %}
    +
    {% pygments "views/ng_messages.py" %}
    +
    {% pygments "tutorial/ng-messages.html" %}
    +
    {% pygments "static/js/ng-messages.js" %}
    +

    Use this mixin if you want your field error lists to be managed by the ng-messages directive.
    +Note: An advantage over the standard field error list handling/display, is up to a 30% reduction in watchers.

    +{% endblock tutorial_code %} + +{% block angular_version %}1.3.0{% endblock %} + +{% block scripts %} + + {{ block.super }} + +{% endblock scripts %} + +{% block ng_module_dependencies %}['ng.django.forms', 'ng.django.angular.messages', 'ngMessages']{% endblock %} + +{% block demo_scripts %} + {{ block.super }} + +{% endblock %} diff --git a/examples/server/templates/subscribe-form.html b/examples/server/templates/subscribe-form.html index 1ff91f1..eac1172 100644 --- a/examples/server/templates/subscribe-form.html +++ b/examples/server/templates/subscribe-form.html @@ -74,7 +74,7 @@

    How does it work?

    {% block scripts %} - {% endblock %} + +{% block ng_module_dependencies %}['ng.django.forms']{% endblock %} + diff --git a/examples/server/templates/three-way-data-binding.html b/examples/server/templates/three-way-data-binding.html index 89ade8c..2c86f9c 100644 --- a/examples/server/templates/three-way-data-binding.html +++ b/examples/server/templates/three-way-data-binding.html @@ -19,18 +19,26 @@
    MyWebsocketCtrl's scope:
    {% block scripts %} {{ block.super }} - +{% endblock %} + +{% block demo_scripts %} - -{% endblock scripts %} + {{ block.super }} +{% endblock %} + + + {% block tutorial_intro %}

    This example shows, diff --git a/examples/server/tests/test_ng_messages.py b/examples/server/tests/test_ng_messages.py new file mode 100644 index 0000000..36f94a6 --- /dev/null +++ b/examples/server/tests/test_ng_messages.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +from django.test import TestCase +from django import forms +from pyquery.pyquery import PyQuery +from djangular.forms import NgForm, NgFormValidationMixin, NgMessagesMixin + + +class MessagesForm(NgMessagesMixin, NgForm): + form_name = 'messages_form' + email = forms.EmailField(label='E-Mail', required=True) + + +class MessagesValidationForm(NgMessagesMixin, NgFormValidationMixin, NgForm): + form_name = 'messages_form' + email = forms.EmailField(label='E-Mail', required=True) + + + + +class NgMessagesMixinTest(TestCase): + + def setUp(self): + self.form = MessagesForm() + self.dom = PyQuery(str(self.form)) + + def test_validate_rejected_directive_present(self): + email = self.dom('input[name=email]') + self.assertEqual(len(email), 1) + attrib = dict(email[0].attrib.items()) + self.assertEqual(attrib.get('djng-rejected'), 'validator') + + def test_ng_messages_directive_present(self): + messages = self.dom('ul[ng-messages]') + self.assertEqual(len(messages), 1) + attrib = dict(messages[0].attrib.items()) + self.assertEqual(attrib.get('class'), 'djng-field-errors') + self.assertEqual(attrib.get('ng-messages'), 'messages_form.email.$error') + self.assertEqual(attrib.get('ng-show'), 'messages_form.$submitted || messages_form.email.$dirty') + + def test_rejected_message_direcive_present(self): + message = self.dom('ul[ng-messages]').children() + self.assertEqual(len(message), 1) + attrib = dict(message[0].attrib.items()) + self.assertEqual(attrib.get('ng-message'), 'rejected') + span = message.children() + attrib = dict(span[0].attrib.items()) + self.assertEqual(attrib.get('ng-bind'), 'messages_form.email.$message') + + def test_form_valid_ul_present(self): + ul = self.dom('ul') + self.assertEqual(len(ul), 2) + attrib = dict(ul[0].attrib.items()) + self.assertEqual(attrib.get('class'), 'djng-field-errors') + self.assertEqual(attrib.get('ng-show'), 'messages_form.$submitted || messages_form.email.$dirty') + self.assertIsNone(attrib.get('ng-messages')) + + +class NgMessagesMixinWithValidationTest(TestCase): + + def setUp(self): + self.form = MessagesValidationForm() + self.dom = PyQuery(str(self.form)) + + def test_correct_input_directives_present(self): + email = self.dom('input[name=email]') + self.assertEqual(len(email), 1) + attrib = dict(email[0].attrib.items()) + self.assertEqual(attrib.get('djng-rejected'), 'validator') + self.assertEqual(attrib.get('ng-required'), 'true') + + def test_ng_messages_directive_present(self): + messages = self.dom('ul[ng-messages]') + self.assertEqual(len(messages), 1) + attrib = dict(messages[0].attrib.items()) + self.assertEqual(attrib.get('class'), 'djng-field-errors') + self.assertEqual(attrib.get('ng-messages'), 'messages_form.email.$error') + self.assertEqual(attrib.get('ng-show'), 'messages_form.$submitted || messages_form.email.$dirty') + + def test_rejected_message_direcive_present(self): + message = self.dom('ul[ng-messages]').children('li[ng-message=rejected]') + self.assertEqual(len(message), 1) + attrib = dict(message[0].attrib.items()) + self.assertEqual(attrib.get('ng-message'), 'rejected') + span = message.children() + attrib = dict(span[0].attrib.items()) + self.assertEqual(attrib.get('ng-bind'), 'messages_form.email.$message') + + def test_all_correct_message_directives_present(self): + message = self.dom('ul[ng-messages]').children() + self.assertEqual(len(message), 3) + rejected = message('li[ng-message=rejected]') + self.assertEqual(len(rejected), 1) + required = message('li[ng-message=required]') + self.assertEqual(len(required), 1) + email = message('li[ng-message=email]') + self.assertEqual(len(email), 1) + + def test_form_valid_ul_present(self): + ul = self.dom('ul') + self.assertEqual(len(ul), 2) + attrib = dict(ul[0].attrib.items()) + self.assertEqual(attrib.get('class'), 'djng-field-errors') + self.assertEqual(attrib.get('ng-show'), 'messages_form.$submitted || messages_form.email.$dirty') + self.assertIsNone(attrib.get('ng-messages')) + + def test_form_valid_li_present(self): + ul = PyQuery(self.dom('ul')[0]) + li = ul.children() + self.assertEqual(len(li), 1) + attrib = dict(li[0].attrib.items()) + self.assertEqual(attrib.get('ng-show'), 'messages_form.email.$valid') + + +class BoundNgMessagesMixinFormTest(TestCase): + + def setUp(self): + self.form = MessagesForm(data={'email': 'james'}) + self.dom = PyQuery(str(self.form)) + + def test_bound_form_input_for_djng_error(self): + email = self.dom('input[name=email]') + self.assertEqual(len(email), 1) + attrib = dict(email[0].attrib.items()) + self.assertEqual(attrib.get('value'), 'james') + self.assertEqual(attrib.get('djng-error'), 'bound-msgs-field') + self.assertEqual(attrib.get('djng-error-msg'), 'Enter a valid email address.') diff --git a/examples/server/tutorial/ng-messages.html b/examples/server/tutorial/ng-messages.html new file mode 100644 index 0000000..94e773c --- /dev/null +++ b/examples/server/tutorial/ng-messages.html @@ -0,0 +1,14 @@ + + +

    + {% csrf_token %} + {{ form.as_div }} + + +
    diff --git a/examples/server/urls.py b/examples/server/urls.py index 50dc004..85a71ea 100644 --- a/examples/server/urls.py +++ b/examples/server/urls.py @@ -7,6 +7,7 @@ from server.views.model_scope import SubscribeView as ModelScopeView from server.views.combined_validation import SubscribeView as CombinedValidationView from server.views.threeway_databinding import SubscribeView as ThreeWayDataBindingView +from server.views.ng_messages import SubscribeView as NgMessagesView from server.views import NgFormDataValidView @@ -19,6 +20,8 @@ name='djng_model_scope'), url(r'^combined_validation/$', CombinedValidationView.as_view(), name='djng_combined_validation'), + url(r'^angular_messages/$', NgMessagesView.as_view(), + name='djng_angular_messages'), url(r'^threeway_databinding/$', ThreeWayDataBindingView.as_view(), name='djng_3way_databinding'), url(r'^form_data_valid', NgFormDataValidView.as_view(), name='form_data_valid'), diff --git a/examples/server/views/ng_messages.py b/examples/server/views/ng_messages.py new file mode 100644 index 0000000..eec3245 --- /dev/null +++ b/examples/server/views/ng_messages.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from server.forms.ng_messages import SubscribeForm +# start tutorial +import json +from django.http import HttpResponse +from django.core.urlresolvers import reverse_lazy +from django.views.generic.edit import FormView +from django.utils.encoding import force_text + + +class SubscribeView(FormView): + template_name = 'ng-messages.html' + form_class = SubscribeForm + success_url = reverse_lazy('form_data_valid') + + def post(self, request, **kwargs): + if request.is_ajax(): + return self.ajax(request) + return super(SubscribeView, self).post(request, **kwargs) + + def ajax(self, request): + form = self.form_class(data=json.loads(request.body)) + response_data = {'errors': form.errors, 'success_url': force_text(self.success_url)} + return HttpResponse(json.dumps(response_data), content_type="application/json")