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 = '
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 @@
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: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:
+
+
+
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.
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.
MyWebsocketCtrl's scope: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 @@ + + +
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")