diff options
| -rw-r--r-- | angularFiles.js | 1 | ||||
| -rw-r--r-- | docs/content/api/angular.inputType.ngdoc | 58 | ||||
| -rw-r--r-- | docs/content/cookbook/advancedform.ngdoc | 2 | ||||
| -rw-r--r-- | docs/content/guide/dev_guide.forms.ngdoc | 82 | ||||
| -rw-r--r-- | docs/src/templates/index.html | 2 | ||||
| -rw-r--r-- | src/Angular.js | 12 | ||||
| -rw-r--r-- | src/AngularPublic.js | 9 | ||||
| -rw-r--r-- | src/scenario/Scenario.js | 2 | ||||
| -rw-r--r-- | src/scenario/dsl.js | 2 | ||||
| -rw-r--r-- | src/service/formFactory.js | 414 | ||||
| -rw-r--r-- | src/widget/form.js | 134 | ||||
| -rw-r--r-- | src/widget/input.js | 1007 | ||||
| -rw-r--r-- | src/widget/select.js | 138 | ||||
| -rw-r--r-- | test/BinderSpec.js | 5 | ||||
| -rw-r--r-- | test/service/formFactorySpec.js | 206 | ||||
| -rw-r--r-- | test/widget/formSpec.js | 207 | ||||
| -rw-r--r-- | test/widget/inputSpec.js | 1255 | ||||
| -rw-r--r-- | test/widget/selectSpec.js | 806 |
18 files changed, 2233 insertions, 2109 deletions
diff --git a/angularFiles.js b/angularFiles.js index af859471..af38755c 100644 --- a/angularFiles.js +++ b/angularFiles.js @@ -23,7 +23,6 @@ angularFiles = { 'src/service/filter/filters.js', 'src/service/filter/limitTo.js', 'src/service/filter/orderBy.js', - 'src/service/formFactory.js', 'src/service/interpolate.js', 'src/service/location.js', 'src/service/log.js', diff --git a/docs/content/api/angular.inputType.ngdoc b/docs/content/api/angular.inputType.ngdoc index 9cbf9eb2..a5d1f74a 100644 --- a/docs/content/api/angular.inputType.ngdoc +++ b/docs/content/api/angular.inputType.ngdoc @@ -32,61 +32,3 @@ All `inputType` widgets support: - **`ng:pattern`** Sets `PATTERN` validation error key if the value does not match the RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for patterns defined as scope expressions. - - - -# Example - -<doc:example> -<doc:source> - <script> - angular.inputType('json', function(element, scope) { - scope.$parseView = function() { - try { - this.$modelValue = angular.fromJson(this.$viewValue); - if (this.$error.JSON) { - this.$emit('$valid', 'JSON'); - } - } catch (e) { - this.$emit('$invalid', 'JSON'); - } - } - - scope.$parseModel = function() { - this.$viewValue = angular.toJson(this.$modelValue); - } - }); - - function Ctrl($scope) { - $scope.data = { - framework:'angular', - codenames:'supper-powers' - } - $scope.required = false; - $scope.disabled = false; - $scope.readonly = false; - } - </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - <input type="json" ng:model="data" size="80" - ng:required="{{required}}" ng:disabled="{{disabled}}" - ng:readonly="{{readonly}}"/><br/> - Required: <input type="checkbox" ng:model="required"> <br/> - Disabled: <input type="checkbox" ng:model="disabled"> <br/> - Readonly: <input type="checkbox" ng:model="readonly"> <br/> - <pre>data={{data}}</pre> - <pre>myForm={{myForm}}</pre> - </form> - </div> -</doc:source> -<doc:scenario> - it('should invalidate on wrong input', function() { - expect(element('form[name=myForm]').prop('className')).toMatch('ng-valid'); - input('data').enter('{}'); - expect(binding('data')).toEqual('{}'); - input('data').enter('{'); - expect(element('form[name=myForm]').prop('className')).toMatch('ng-invalid'); - }); -</doc:scenario> -</doc:example> diff --git a/docs/content/cookbook/advancedform.ngdoc b/docs/content/cookbook/advancedform.ngdoc index 58a8dfd5..3e3b2d28 100644 --- a/docs/content/cookbook/advancedform.ngdoc +++ b/docs/content/cookbook/advancedform.ngdoc @@ -52,7 +52,7 @@ detection, and preventing invalid form submission. }; $scope.isSaveDisabled = function() { - return $scope.myForm.$invalid || angular.equals(master, $scope.form); + return $scope.myForm.invalid || angular.equals(master, $scope.form); }; $scope.cancel(); diff --git a/docs/content/guide/dev_guide.forms.ngdoc b/docs/content/guide/dev_guide.forms.ngdoc index 5a6fe54e..97d82bb1 100644 --- a/docs/content/guide/dev_guide.forms.ngdoc +++ b/docs/content/guide/dev_guide.forms.ngdoc @@ -138,7 +138,7 @@ The following example demonstrates: }; $scope.isSaveDisabled = function() { - return $scope.userForm.$invalid || angular.equals($scope.master, $scope.form); + return $scope.userForm.invalid || angular.equals($scope.master, $scope.form); }; $scope.cancel(); @@ -150,7 +150,7 @@ The following example demonstrates: <label>Name:</label><br/> <input type="text" name="customer" ng:model="form.customer" required/> - <span class="error" ng:show="userForm.customer.$error.REQUIRED"> + <span class="error" ng:show="userForm.customer.error.REQUIRED"> Customer name is required!</span> <br/><br/> @@ -165,15 +165,15 @@ The following example demonstrates: <input type="text" name="zip" ng:pattern="zip" size="5" required ng:model="form.address.zip"/><br/><br/> - <span class="error" ng:show="addressForm.$invalid"> + <span class="error" ng:show="addressForm.invalid"> Incomplete address: - <span class="error" ng:show="addressForm.state.$error.REQUIRED"> + <span class="error" ng:show="addressForm.state.error.REQUIRED"> Missing state!</span> - <span class="error" ng:show="addressForm.state.$error.PATTERN"> + <span class="error" ng:show="addressForm.state.error.PATTERN"> Invalid state!</span> - <span class="error" ng:show="addressForm.zip.$error.REQUIRED"> + <span class="error" ng:show="addressForm.zip.error.REQUIRED"> Missing zip!</span> - <span class="error" ng:show="addressForm.zip.$error.PATTERN"> + <span class="error" ng:show="addressForm.zip.error.PATTERN"> Invalid zip!</span> </span> </ng:form> @@ -284,56 +284,38 @@ This example shows how to implement a custom HTML editor widget in Angular. $scope.htmlContent = '<b>Hello</b> <i>World</i>!'; } - HTMLEditorWidget.$inject = ['$scope', '$element', '$sanitize']; - function HTMLEditorWidget(scope, element, $sanitize) { - scope.$parseModel = function() { - // need to protect for script injection - try { - scope.$viewValue = $sanitize( - scope.$modelValue || ''); - if (this.$error.HTML) { - // we were invalid, but now we are OK. - scope.$emit('$valid', 'HTML'); - } - } catch (e) { - // if HTML not parsable invalidate form. - scope.$emit('$invalid', 'HTML'); - } - } + angular.module('formModule', []).directive('ngHtmlEditor', function ($sanitize) { + return { + require: 'ngModel', + link: function(scope, elm, attr, ctrl) { + attr.$set('contentEditable', true); - scope.$render = function() { - element.html(this.$viewValue); - } + ctrl.$render = function() { + elm.html(ctrl.viewValue); + }; - element.bind('keyup', function() { - scope.$apply(function() { - scope.$emit('$viewChange', element.html()); - }); - }); - } + ctrl.formatters.push(function(value) { + try { + value = $sanitize(value || ''); + ctrl.emitValidity('HTML', true); + } catch (e) { + ctrl.emitValidity('HTML', false); + } + + }); - angular.module('formModule', [], function($compileProvider){ - $compileProvider.directive('ngHtmlEditorModel', function ($formFactory) { - return function(scope, element, attr) { - var form = $formFactory.forElement(element), - widget; - element.attr('contentEditable', true); - widget = form.$createWidget({ - scope: scope, - model: attr.ngHtmlEditorModel, - controller: HTMLEditorWidget, - controllerArgs: {$element: element}}); - // if the element is destroyed, then we need to - // notify the form. - element.bind('$destroy', function() { - widget.$destroy(); + elm.bind('keyup', function() { + scope.$apply(function() { + ctrl.read(elm.html()); + }); }); - }; - }); + + } + }; }); </script> <form name='editorForm' ng:controller="EditorCntl"> - <div ng:html-editor-model="htmlContent"></div> + <div ng:html-editor ng:model="htmlContent"></div> <hr/> HTML: <br/> <textarea ng:model="htmlContent" cols="80"></textarea> diff --git a/docs/src/templates/index.html b/docs/src/templates/index.html index 93d8cdda..46a77da3 100644 --- a/docs/src/templates/index.html +++ b/docs/src/templates/index.html @@ -101,7 +101,7 @@ <div id="sidebar"> <input type="text" ng:model="search" id="search-box" placeholder="search the docs" - tabindex="1" accesskey="s"> + tabindex="1" accesskey="s" ng:bind-immediate> <ul id="content-list" ng:class="sectionId" ng:cloak> <li ng:repeat="page in pages | filter:search" ng:class="getClass(page)"> diff --git a/src/Angular.js b/src/Angular.js index fec866f5..1265ad9f 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -91,7 +91,6 @@ var $boolean = 'boolean', angular = window.angular || (window.angular = {}), angularModule, /** @name angular.module.ng */ - angularInputType = extensionMap(angular, 'inputType', lowercase), nodeName_, uid = ['0', '0', '0'], DATE_ISOSTRING_LN = 24; @@ -272,17 +271,6 @@ identity.$inject = []; function valueFn(value) {return function() {return value;};} -function extensionMap(angular, name, transform) { - var extPoint; - return angular[name] || (extPoint = angular[name] = function(name, fn, prop){ - name = (transform || identity)(name); - if (isDefined(fn)) { - extPoint[name] = extend(fn, prop || {}); - } - return extPoint[name]; - }); -} - /** * @ngdoc function * @name angular.isUndefined diff --git a/src/AngularPublic.js b/src/AngularPublic.js index d1ae4a18..20ca5edb 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -98,7 +98,13 @@ function publishExternalAPI(angular){ ngSwitchDefault: ngSwitchDefaultDirective, ngOptions: ngOptionsDirective, ngView: ngViewDirective, - ngTransclude: ngTranscludeDirective + ngTransclude: ngTranscludeDirective, + ngModel: ngModelDirective, + ngList: ngListDirective, + ngChange: ngChangeDirective, + ngBindImmediate: ngBindImmediateDirective, + required: requiredDirective, + ngRequired: requiredDirective }). directive(ngEventDirectives). directive(ngAttributeAliasDirectives); @@ -110,7 +116,6 @@ function publishExternalAPI(angular){ $provide.service('$exceptionHandler', $ExceptionHandlerProvider); $provide.service('$filter', $FilterProvider); $provide.service('$interpolate', $InterpolateProvider); - $provide.service('$formFactory', $FormFactoryProvider); $provide.service('$http', $HttpProvider); $provide.service('$httpBackend', $HttpBackendProvider); $provide.service('$location', $LocationProvider); diff --git a/src/scenario/Scenario.js b/src/scenario/Scenario.js index 7e33181c..cd3c335f 100644 --- a/src/scenario/Scenario.js +++ b/src/scenario/Scenario.js @@ -327,7 +327,7 @@ function browserTrigger(element, type, keys) { (function(fn){ var parentTrigger = fn.trigger; fn.trigger = function(type) { - if (/(click|change|keydown)/.test(type)) { + if (/(click|change|keydown|blur)/.test(type)) { var processDefaults = []; this.each(function(index, node) { processDefaults.push(browserTrigger(node, type)); diff --git a/src/scenario/dsl.js b/src/scenario/dsl.js index fb0037e0..f6cc8086 100644 --- a/src/scenario/dsl.js +++ b/src/scenario/dsl.js @@ -203,7 +203,7 @@ angular.scenario.dsl('input', function() { return this.addFutureAction("input '" + this.name + "' enter '" + value + "'", function($window, $document, done) { var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':input'); input.val(value); - input.trigger('keydown'); + input.trigger('blur'); done(); }); }; diff --git a/src/service/formFactory.js b/src/service/formFactory.js deleted file mode 100644 index b051f7b9..00000000 --- a/src/service/formFactory.js +++ /dev/null @@ -1,414 +0,0 @@ -'use strict'; - -/** - * @ngdoc object - * @name angular.module.ng.$formFactory - * - * @description - * Use `$formFactory` to create a new instance of a {@link angular.module.ng.$formFactory.Form Form} - * controller or to find the nearest form instance for a given DOM element. - * - * The form instance is a collection of widgets, and is responsible for life cycle and validation - * of widget. - * - * Keep in mind that both form and widget instances are {@link api/angular.module.ng.$rootScope.Scope scopes}. - * - * @param {Form=} parentForm The form which should be the parent form of the new form controller. - * If none specified default to the `rootForm`. - * @returns {Form} A new {@link angular.module.ng.$formFactory.Form Form} instance. - * - * @example - * - * This example shows how one could write a widget which would enable data-binding on - * `contenteditable` feature of HTML. - * - <doc:example module="formModule"> - <doc:source> - <script> - function EditorCntl($scope) { - $scope.htmlContent = '<b>Hello</b> <i>World</i>!'; - } - - HTMLEditorWidget.$inject = ['$scope', '$element', '$sanitize']; - function HTMLEditorWidget(scope, element, $sanitize) { - scope.$parseModel = function() { - // need to protect for script injection - try { - scope.$viewValue = $sanitize( - scope.$modelValue || ''); - if (this.$error.HTML) { - // we were invalid, but now we are OK. - scope.$emit('$valid', 'HTML'); - } - } catch (e) { - // if HTML not parsable invalidate form. - scope.$emit('$invalid', 'HTML'); - } - } - - scope.$render = function() { - element.html(this.$viewValue); - } - - element.bind('keyup', function() { - scope.$apply(function() { - scope.$emit('$viewChange', element.html()); - }); - }); - } - - angular.module('formModule', [], function($compileProvider){ - $compileProvider.directive('ngHtmlEditorModel', function ($formFactory) { - return function(scope, element, attr) { - var form = $formFactory.forElement(element), - widget; - element.attr('contentEditable', true); - widget = form.$createWidget({ - scope: scope, - model: attr.ngHtmlEditorModel, - controller: HTMLEditorWidget, - controllerArgs: {$element: element}}); - // if the element is destroyed, then we need to - // notify the form. - element.bind('$destroy', function() { - widget.$destroy(); - }); - }; - }); - }); - </script> - <form name='editorForm' ng:controller="EditorCntl"> - <div ng:html-editor-model="htmlContent"></div> - <hr/> - HTML: <br/> - <textarea ng:model="htmlContent" cols="80"></textarea> - <hr/> - <pre>editorForm = {{editorForm|json}}</pre> - </form> - </doc:source> - <doc:scenario> - it('should enter invalid HTML', function() { - expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-valid/); - input('htmlContent').enter('<'); - expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-invalid/); - }); - </doc:scenario> - </doc:example> - */ - -/** - * @ngdoc object - * @name angular.module.ng.$formFactory.Form - * @description - * The `Form` is a controller which keeps track of the validity of the widgets contained within it. - */ - -function $FormFactoryProvider() { - var $parse; - this.$get = ['$rootScope', '$parse', '$controller', - function($rootScope, $parse_, $controller) { - $parse = $parse_; - /** - * @ngdoc proprety - * @name rootForm - * @propertyOf angular.module.ng.$formFactory - * @description - * Static property on `$formFactory` - * - * Each application ({@link guide/dev_guide.scopes.internals root scope}) gets a root form which - * is the top-level parent of all forms. - */ - formFactory.rootForm = formFactory($rootScope); - - - /** - * @ngdoc method - * @name forElement - * @methodOf angular.module.ng.$formFactory - * @description - * Static method on `$formFactory` service. - * - * Retrieve the closest form for a given element or defaults to the `root` form. Used by the - * {@link angular.module.ng.$compileProvider.directive.form form} element. - * @param {Element} element The element where the search for form should initiate. - */ - formFactory.forElement = function(element) { - return element.inheritedData('$form') || formFactory.rootForm; - }; - return formFactory; - - function formFactory(parent) { - var scope = (parent || formFactory.rootForm).$new(); - $controller(FormController, {$scope: scope}); - return scope; - } - - }]; - - function propertiesUpdate(widget) { - widget.$valid = !(widget.$invalid = - !(widget.$readonly || widget.$disabled || equals(widget.$error, {}))); - } - - /** - * @ngdoc property - * @name $error - * @propertyOf angular.module.ng.$formFactory.Form - * @description - * Property of the form and widget instance. - * - * Summary of all of the errors on the page. If a widget emits `$invalid` with `REQUIRED` key, - * then the `$error` object will have a `REQUIRED` key with an array of widgets which have - * emitted this key. `form.$error.REQUIRED == [ widget ]`. - */ - - /** - * @ngdoc property - * @name $invalid - * @propertyOf angular.module.ng.$formFactory.Form - * @description - * Property of the form and widget instance. - * - * True if any of the widgets of the form are invalid. - */ - - /** - * @ngdoc property - * @name $valid - * @propertyOf angular.module.ng.$formFactory.Form - * @description - * Property of the form and widget instance. - * - * True if all of the widgets of the form are valid. - */ - - /** - * @ngdoc event - * @name angular.module.ng.$formFactory.Form#$valid - * @eventOf angular.module.ng.$formFactory.Form - * @eventType listen on form - * @description - * Upon receiving the `$valid` event from the widget update the `$error`, `$valid` and `$invalid` - * properties of both the widget as well as the from. - * - * @param {string} validationKey The validation key to be used when updating the `$error` object. - * The validation key is what will allow the template to bind to a specific validation error - * such as `<div ng:show="form.$error.KEY">error for key</div>`. - */ - - /** - * @ngdoc event - * @name angular.module.ng.$formFactory.Form#$invalid - * @eventOf angular.module.ng.$formFactory.Form - * @eventType listen on form - * @description - * Upon receiving the `$invalid` event from the widget update the `$error`, `$valid` and `$invalid` - * properties of both the widget as well as the from. - * - * @param {string} validationKey The validation key to be used when updating the `$error` object. - * The validation key is what will allow the template to bind to a specific validation error - * such as `<div ng:show="form.$error.KEY">error for key</div>`. - */ - - /** - * @ngdoc event - * @name angular.module.ng.$formFactory.Form#$validate - * @eventOf angular.module.ng.$formFactory.Form - * @eventType emit on widget - * @description - * Emit the `$validate` event on the widget, giving a widget a chance to emit a - * `$valid` / `$invalid` event base on its state. The `$validate` event is triggered when the - * model or the view changes. - */ - - /** - * @ngdoc event - * @name angular.module.ng.$formFactory.Form#$viewChange - * @eventOf angular.module.ng.$formFactory.Form - * @eventType listen on widget - * @description - * A widget is responsible for emitting this event whenever the view changes do to user interaction. - * The event takes a `$viewValue` parameter, which is the new value of the view. This - * event triggers a call to `$parseView()` as well as `$validate` event on widget. - * - * @param {*} viewValue The new value for the view which will be assigned to `widget.$viewValue`. - */ - - FormController.$inject = ['$scope', '$injector']; - function FormController($scope, $injector) { - this.$injector = $injector; - - var form = this.form = $scope, - $error = form.$error = {}; - - form.$on('$destroy', function(event){ - var widget = event.targetScope; - if (widget.$widgetId) { - delete form[widget.$widgetId]; - } - forEach($error, removeWidget, widget); - }); - - form.$on('$valid', function(event, error){ - var widget = event.targetScope; - delete widget.$error[error]; - propertiesUpdate(widget); - removeWidget($error[error], error, widget); - }); - - form.$on('$invalid', function(event, error){ - var widget = event.targetScope; - addWidget(error, widget); - widget.$error[error] = true; - propertiesUpdate(widget); - }); - - propertiesUpdate(form); - form.$createWidget = bind(this, this.$createWidget); - - function removeWidget(queue, errorKey, widget) { - if (queue) { - widget = widget || this; // so that we can be used in forEach; - for (var i = 0, length = queue.length; i < length; i++) { - if (queue[i] === widget) { - queue.splice(i, 1); - if (!queue.length) { - delete $error[errorKey]; - } - } - } - propertiesUpdate(form); - } - } - - function addWidget(errorKey, widget) { - var queue = $error[errorKey]; - if (queue) { - for (var i = 0, length = queue.length; i < length; i++) { - if (queue[i] === widget) { - return; - } - } - } else { - $error[errorKey] = queue = []; - } - queue.push(widget); - propertiesUpdate(form); - } - } - - - /** - * @ngdoc method - * @name $createWidget - * @methodOf angular.module.ng.$formFactory.Form - * @description - * - * Use form's `$createWidget` instance method to create new widgets. The widgets can be created - * using an alias which makes the accessible from the form and available for data-binding, - * useful for displaying validation error messages. - * - * The creation of a widget sets up: - * - * - `$watch` of `expression` on `model` scope. This code path syncs the model to the view. - * The `$watch` listener will: - * - * - assign the new model value of `expression` to `widget.$modelValue`. - * - call `widget.$parseModel` method if present. The `$parseModel` is responsible for copying - * the `widget.$modelValue` to `widget.$viewValue` and optionally converting the data. - * (For example to convert a number into string) - * - emits `$validate` event on widget giving a widget a chance to emit `$valid` / `$invalid` - * event. - * - call `widget.$render()` method on widget. The `$render` method is responsible for - * reading the `widget.$viewValue` and updating the DOM. - * - * - Listen on `$viewChange` event from the `widget`. This code path syncs the view to the model. - * The `$viewChange` listener will: - * - * - assign the value to `widget.$viewValue`. - * - call `widget.$parseView` method if present. The `$parseView` is responsible for copying - * the `widget.$viewValue` to `widget.$modelValue` and optionally converting the data. - * (For example to convert a string into number) - * - emits `$validate` event on widget giving a widget a chance to emit `$valid` / `$invalid` - * event. - * - Assign the `widget.$modelValue` to the `expression` on the `model` scope. - * - * - Creates these set of properties on the `widget` which are updated as a response to the - * `$valid` / `$invalid` events: - * - * - `$error` - object - validation errors will be published as keys on this object. - * Data-binding to this property is useful for displaying the validation errors. - * - `$valid` - boolean - true if there are no validation errors - * - `$invalid` - boolean - opposite of `$valid`. - * @param {Object} params Named parameters: - * - * - `scope` - `{Scope}` - The scope to which the model for this widget is attached. - * - `model` - `{string}` - The name of the model property on model scope. - * - `controller` - {WidgetController} - The controller constructor function. - * The controller constructor should create these instance methods. - * - `$parseView()`: optional method responsible for copying `$viewVale` to `$modelValue`. - * The method may fire `$valid`/`$invalid` events. - * - `$parseModel()`: optional method responsible for copying `$modelVale` to `$viewValue`. - * The method may fire `$valid`/`$invalid` events. - * - `$render()`: required method which needs to update the DOM of the widget to match the - * `$viewValue`. - * - * - `controllerArgs` - `{Array}` (Optional) - Any extra arguments will be curried to the - * WidgetController constructor. - * - `onChange` - `{(string|function())}` (Optional) - Expression to execute when user changes the - * value. - * - `alias` - `{string}` (Optional) - The name of the form property under which the widget - * instance should be published. The name should be unique for each form. - * @returns {Widget} Instance of a widget scope. - */ - FormController.prototype.$createWidget = function(params) { - var form = this.form, - modelScope = params.scope, - onChange = params.onChange, - alias = params.alias, - scopeGet = $parse(params.model), - scopeSet = scopeGet.assign, - widget = form.$new(); - - this.$injector.instantiate(params.controller, extend({$scope: widget}, params.controllerArgs)); - - if (!scopeSet) { - throw Error("Expression '" + params.model + "' is not assignable!"); - } - - widget.$error = {}; - // Set the state to something we know will change to get the process going. - widget.$modelValue = Number.NaN; - // watch for scope changes and update the view appropriately - modelScope.$watch(scopeGet, function(value) { - if (!equals(widget.$modelValue, value)) { - widget.$modelValue = value; - widget.$parseModel ? widget.$parseModel() : (widget.$viewValue = value); - widget.$emit('$validate'); - widget.$render && widget.$render(); - } - }); - - widget.$on('$viewChange', function(event, viewValue){ - if (!equals(widget.$viewValue, viewValue)) { - widget.$viewValue = viewValue; - widget.$parseView ? widget.$parseView() : (widget.$modelValue = widget.$viewValue); - scopeSet(modelScope, widget.$modelValue); - if (onChange) modelScope.$eval(onChange); - widget.$emit('$validate'); - } - }); - - propertiesUpdate(widget); - - // assign the widgetModel to the form - if (alias && !form.hasOwnProperty(alias)) { - form[alias] = widget; - widget.$widgetId = alias; - } else { - alias = null; - } - - return widget; - }; -} diff --git a/src/widget/form.js b/src/widget/form.js index deaf38d5..23b07107 100644 --- a/src/widget/form.js +++ b/src/widget/form.js @@ -1,5 +1,86 @@ 'use strict'; +FormController.$inject = ['$scope', 'name']; +function FormController($scope, name) { + var form = this, + errors = form.error = {}; + + // publish the form into scope + name(this); + + $scope.$on('$destroy', function(event, widget) { + if (!widget) return; + + if (widget.widgetId) { + delete form[widget.widgetId]; + } + forEach(errors, removeWidget, widget); + }); + + $scope.$on('$valid', function(event, error, widget) { + removeWidget(errors[error], error, widget); + + if (equals(errors, {})) { + form.valid = true; + form.invalid = false; + } + }); + + $scope.$on('$invalid', function(event, error, widget) { + addWidget(error, widget); + + form.valid = false; + form.invalid = true; + }); + + $scope.$on('$viewTouch', function() { + form.dirty = true; + form.pristine = false; + }); + + // init state + form.dirty = false; + form.pristine = true; + form.valid = true; + form.invalid = false; + + function removeWidget(queue, errorKey, widget) { + if (queue) { + widget = widget || this; // so that we can be used in forEach; + for (var i = 0, length = queue.length; i < length; i++) { + if (queue[i] === widget) { + queue.splice(i, 1); + if (!queue.length) { + delete errors[errorKey]; + } + } + } + } + } + + function addWidget(errorKey, widget) { + var queue = errors[errorKey]; + if (queue) { + for (var i = 0, length = queue.length; i < length; i++) { + if (queue[i] === widget) { + return; + } + } + } else { + errors[errorKey] = queue = []; + } + queue.push(widget); + } +} + +FormController.prototype.registerWidget = function(widget, alias) { + if (alias && !this.hasOwnProperty(alias)) { + widget.widgetId = alias; + this[alias] = widget; + } +}; + + /** * @ngdoc directive * @name angular.module.ng.$compileProvider.directive.form @@ -57,55 +138,54 @@ $scope.text = 'guest'; } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - text: <input type="text" name="input" ng:model="text" required> - <span class="error" ng:show="myForm.text.$error.REQUIRED">Required!</span> - </form> + <form name="myForm" ng:controller="Ctrl"> + text: <input type="text" name="input" ng:model="text" required> + <span class="error" ng:show="myForm.input.error.REQUIRED">Required!</span> <tt>text = {{text}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> - </div> + <tt>myForm.input.valid = {{myForm.input.valid}}</tt><br/> + <tt>myForm.input.error = {{myForm.input.error}}</tt><br/> + <tt>myForm.valid = {{myForm.valid}}</tt><br/> + <tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br/> + </form> </doc:source> <doc:scenario> it('should initialize to model', function() { expect(binding('text')).toEqual('guest'); - expect(binding('myForm.input.$valid')).toEqual('true'); + expect(binding('myForm.input.valid')).toEqual('true'); }); it('should be invalid if empty', function() { input('text').enter(''); expect(binding('text')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); </doc:scenario> </doc:example> */ -var ngFormDirective = ['$formFactory', function($formFactory) { +var ngFormDirective = [function() { return { + name: 'form', restrict: 'E', + scope: true, + inject: { + name: 'accessor' + }, + controller: FormController, compile: function() { return { - pre: function(scope, formElement, attr) { - var name = attr.name, - parentForm = $formFactory.forElement(formElement), - form = $formFactory(parentForm); - formElement.data('$form', form); - formElement.bind('submit', function(event){ + pre: function(scope, formElement, attr, controller) { + formElement.data('$form', controller); + formElement.bind('submit', function(event) { if (!attr.action) event.preventDefault(); }); - if (name) { - scope[name] = form; - } - watch('valid'); - watch('invalid'); - function watch(name) { - form.$watch('$' + name, function(value) { + + forEach(['valid', 'invalid', 'dirty', 'pristine'], function(name) { + scope.$watch(function() { + return controller[name]; + }, function(value) { formElement[value ? 'addClass' : 'removeClass']('ng-' + name); }); - } + }); } }; } diff --git a/src/widget/input.js b/src/widget/input.js index 05390b38..6c95327c 100644 --- a/src/widget/input.js +++ b/src/widget/input.js @@ -4,7 +4,6 @@ var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; var EMAIL_REGEXP = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/; var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; -var INTEGER_REGEXP = /^\s*(\-|\+)?\d+\s*$/; /** @@ -36,37 +35,36 @@ var INTEGER_REGEXP = /^\s*(\-|\+)?\d+\s*$/; $scope.word = /^\w*$/; } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - Single word: <input type="text" name="input" ng:model="text" - ng:pattern="word" required> - <span class="error" ng:show="myForm.input.$error.REQUIRED"> - Required!</span> - <span class="error" ng:show="myForm.input.$error.PATTERN"> - Single word only!</span> - </form> + <form name="myForm" ng:controller="Ctrl"> + Single word: <input type="text" name="input" ng:model="text" + ng:pattern="word" required> + <span class="error" ng:show="myForm.input.error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.input.error.PATTERN"> + Single word only!</span> + <tt>text = {{text}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> - </div> + <tt>myForm.input.valid = {{myForm.input.valid}}</tt><br/> + <tt>myForm.input.error = {{myForm.input.error}}</tt><br/> + <tt>myForm.valid = {{myForm.valid}}</tt><br/> + <tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br/> + </form> </doc:source> <doc:scenario> it('should initialize to model', function() { expect(binding('text')).toEqual('guest'); - expect(binding('myForm.input.$valid')).toEqual('true'); + expect(binding('myForm.input.valid')).toEqual('true'); }); it('should be invalid if empty', function() { input('text').enter(''); expect(binding('text')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); it('should be invalid if multi word', function() { input('text').enter('hello world'); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); </doc:scenario> </doc:example> @@ -100,48 +98,39 @@ var INTEGER_REGEXP = /^\s*(\-|\+)?\d+\s*$/; $scope.text = 'me@example.com'; } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> + <form name="myForm" ng:controller="Ctrl"> Email: <input type="email" name="input" ng:model="text" required> - <span class="error" ng:show="myForm.input.$error.REQUIRED"> + <span class="error" ng:show="myForm.input.error.REQUIRED"> Required!</span> - <span class="error" ng:show="myForm.input.$error.EMAIL"> + <span class="error" ng:show="myForm.input.error.EMAIL"> Not valid email!</span> + <tt>text = {{text}}</tt><br/> + <tt>myForm.input.valid = {{myForm.input.valid}}</tt><br/> + <tt>myForm.input.error = {{myForm.input.error}}</tt><br/> + <tt>myForm.valid = {{myForm.valid}}</tt><br/> + <tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br/> + <tt>myForm.error.EMAIL = {{!!myForm.error.EMAIL}}</tt><br/> </form> - <tt>text = {{text}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> - <tt>myForm.$error.EMAIL = {{!!myForm.$error.EMAIL}}</tt><br/> - </div> </doc:source> <doc:scenario> it('should initialize to model', function() { expect(binding('text')).toEqual('me@example.com'); - expect(binding('myForm.input.$valid')).toEqual('true'); + expect(binding('myForm.input.valid')).toEqual('true'); }); it('should be invalid if empty', function() { input('text').enter(''); expect(binding('text')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); it('should be invalid if not email', function() { input('text').enter('xxx'); - expect(binding('text')).toEqual('xxx'); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); </doc:scenario> </doc:example> */ -angularInputType('email', function(element, widget) { - widget.$on('$validate', function(event) { - var value = widget.$viewValue; - widget.$emit(!value || value.match(EMAIL_REGEXP) ? "$valid" : "$invalid", "EMAIL"); - }); -}); /** @@ -173,48 +162,39 @@ angularInputType('email', function(element, widget) { $scope.text = 'http://google.com'; } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - URL: <input type="url" name="input" ng:model="text" required> - <span class="error" ng:show="myForm.input.$error.REQUIRED"> - Required!</span> - <span class="error" ng:show="myForm.input.$error.url"> - Not valid url!</span> - </form> + <form name="myForm" ng:controller="Ctrl"> + URL: <input type="url" name="input" ng:model="text" required> + <span class="error" ng:show="myForm.input.error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.input.error.url"> + Not valid url!</span> <tt>text = {{text}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> - <tt>myForm.$error.url = {{!!myForm.$error.url}}</tt><br/> - </div> + <tt>myForm.input.valid = {{myForm.input.valid}}</tt><br/> + <tt>myForm.input.error = {{myForm.input.error}}</tt><br/> + <tt>myForm.valid = {{myForm.valid}}</tt><br/> + <tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br/> + <tt>myForm.error.url = {{!!myForm.error.url}}</tt><br/> + </form> </doc:source> <doc:scenario> it('should initialize to model', function() { expect(binding('text')).toEqual('http://google.com'); - expect(binding('myForm.input.$valid')).toEqual('true'); + expect(binding('myForm.input.valid')).toEqual('true'); }); it('should be invalid if empty', function() { input('text').enter(''); expect(binding('text')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); it('should be invalid if not url', function() { input('text').enter('xxx'); - expect(binding('text')).toEqual('xxx'); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); </doc:scenario> </doc:example> */ -angularInputType('url', function(element, widget) { - widget.$on('$validate', function(event) { - var value = widget.$viewValue; - widget.$emit(!value || value.match(URL_REGEXP) ? "$valid" : "$invalid", "URL"); - }); -}); /** @@ -241,53 +221,58 @@ angularInputType('url', function(element, widget) { $scope.names = ['igor', 'misko', 'vojta']; } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - List: <input type="list" name="input" ng:model="names" required> - <span class="error" ng:show="myForm.list.$error.REQUIRED"> - Required!</span> - </form> + <form name="myForm" ng:controller="Ctrl"> + List: <input type="list" name="input" ng:model="names" required> + <span class="error" ng:show="myForm.list.error.REQUIRED"> + Required!</span> <tt>names = {{names}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> - </div> + <tt>myForm.input.valid = {{myForm.input.valid}}</tt><br/> + <tt>myForm.input.error = {{myForm.input.error}}</tt><br/> + <tt>myForm.valid = {{myForm.valid}}</tt><br/> + <tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br/> + </form> </doc:source> <doc:scenario> it('should initialize to model', function() { expect(binding('names')).toEqual('["igor","misko","vojta"]'); - expect(binding('myForm.input.$valid')).toEqual('true'); + expect(binding('myForm.input.valid')).toEqual('true'); }); it('should be invalid if empty', function() { input('names').enter(''); - expect(binding('names')).toEqual('[]'); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('names')).toEqual(''); + expect(binding('myForm.input.valid')).toEqual('false'); }); </doc:scenario> </doc:example> */ -angularInputType('list', function(element, widget) { - function parse(viewValue) { - var list = []; - forEach(viewValue.split(/\s*,\s*/), function(value){ - if (value) list.push(trim(value)); - }); - return list; - } - widget.$parseView = function() { - isString(widget.$viewValue) && (widget.$modelValue = parse(widget.$viewValue)); - }; - widget.$parseModel = function() { - var modelValue = widget.$modelValue; - if (isArray(modelValue) - && (!isString(widget.$viewValue) || !equals(parse(widget.$viewValue), modelValue))) { - widget.$viewValue = modelValue.join(', '); +var ngListDirective = function() { + return { + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + var parse = function(viewValue) { + var list = []; + + if (viewValue) { + forEach(viewValue.split(/\s*,\s*/), function(value) { + if (value) list.push(value); + }); + } + + return list; + }; + + ctrl.parsers.push(parse); + ctrl.formatters.push(function(value) { + if (isArray(value) && !equals(parse(ctrl.viewValue), value)) { + return value.join(', '); + } + + return undefined; + }); } }; -}); - +}; /** * @ngdoc inputType @@ -320,113 +305,40 @@ angularInputType('list', function(element, widget) { $scope.value = 12; } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - Number: <input type="number" name="input" ng:model="value" - min="0" max="99" required> - <span class="error" ng:show="myForm.list.$error.REQUIRED"> - Required!</span> - <span class="error" ng:show="myForm.list.$error.NUMBER"> - Not valid number!</span> - </form> + <form name="myForm" ng:controller="Ctrl"> + Number: <input type="number" name="input" ng:model="value" + min="0" max="99" required> + <span class="error" ng:show="myForm.list.error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.list.error.NUMBER"> + Not valid number!</span> <tt>value = {{value}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> - </div> + <tt>myForm.input.valid = {{myForm.input.valid}}</tt><br/> + <tt>myForm.input.error = {{myForm.input.error}}</tt><br/> + <tt>myForm.valid = {{myForm.valid}}</tt><br/> + <tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br/> + </form> </doc:source> <doc:scenario> it('should initialize to model', function() { expect(binding('value')).toEqual('12'); - expect(binding('myForm.input.$valid')).toEqual('true'); + expect(binding('myForm.input.valid')).toEqual('true'); }); it('should be invalid if empty', function() { input('value').enter(''); expect(binding('value')).toEqual(''); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('myForm.input.valid')).toEqual('false'); }); it('should be invalid if over max', function() { input('value').enter('123'); - expect(binding('value')).toEqual('123'); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - </doc:scenario> - </doc:example> - */ -angularInputType('number', numericRegexpInputType(NUMBER_REGEXP, 'NUMBER')); - - -/** - * @ngdoc inputType - * @name angular.inputType.integer - * - * @description - * Text input with integer validation and transformation. Sets the `INTEGER` - * validation error key if not a valid integer. - * - * @param {string} ng:model Assignable angular expression to data-bind to. - * @param {string=} name Property name of the form under which the widgets is published. - * @param {string=} min Sets the `MIN` validation error key if the value entered is less then `min`. - * @param {string=} max Sets the `MAX` validation error key if the value entered is greater then `min`. - * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. - * @param {number=} ng:minlength Sets `MINLENGTH` validation error key if the value is shorter than - * minlength. - * @param {number=} ng:maxlength Sets `MAXLENGTH` validation error key if the value is longer than - * maxlength. - * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the - * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for - * patterns defined as scope expressions. - * @param {string=} ng:change Angular expression to be executed when input changes due to user - * interaction with the input element. - * - * @example - <doc:example> - <doc:source> - <script> - function Ctrl($scope) { - $scope.value = 12; - } - </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - Integer: <input type="integer" name="input" ng:model="value" - min="0" max="99" required> - <span class="error" ng:show="myForm.list.$error.REQUIRED"> - Required!</span> - <span class="error" ng:show="myForm.list.$error.INTEGER"> - Not valid integer!</span> - </form> - <tt>value = {{value}}</tt><br/> - <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> - <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> - </div> - </doc:source> - <doc:scenario> - it('should initialize to model', function() { - expect(binding('value')).toEqual('12'); - expect(binding('myForm.input.$valid')).toEqual('true'); - }); - - it('should be invalid if empty', function() { - input('value').enter('1.2'); - expect(binding('value')).toEqual('12'); - expect(binding('myForm.input.$valid')).toEqual('false'); - }); - - it('should be invalid if over max', function() { - input('value').enter('123'); - expect(binding('value')).toEqual('123'); - expect(binding('myForm.input.$valid')).toEqual('false'); + expect(binding('value')).toEqual('12'); + expect(binding('myForm.input.valid')).toEqual('false'); }); </doc:scenario> </doc:example> */ -angularInputType('integer', numericRegexpInputType(INTEGER_REGEXP, 'INTEGER')); /** @@ -452,15 +364,13 @@ angularInputType('integer', numericRegexpInputType(INTEGER_REGEXP, 'INTEGER')); $scope.value2 = 'YES' } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - Value1: <input type="checkbox" ng:model="value1"> <br/> - Value2: <input type="checkbox" ng:model="value2" - ng:true-value="YES" ng:false-value="NO"> <br/> - </form> + <form name="myForm" ng:controller="Ctrl"> + Value1: <input type="checkbox" ng:model="value1"> <br/> + Value2: <input type="checkbox" ng:model="value2" + ng:true-value="YES" ng:false-value="NO"> <br/> <tt>value1 = {{value1}}</tt><br/> <tt>value2 = {{value2}}</tt><br/> - </div> + </form> </doc:source> <doc:scenario> it('should change state', function() { @@ -475,31 +385,7 @@ angularInputType('integer', numericRegexpInputType(INTEGER_REGEXP, 'INTEGER')); </doc:scenario> </doc:example> */ -angularInputType('checkbox', function(inputElement, widget) { - var trueValue = inputElement.attr('ng:true-value'), - falseValue = inputElement.attr('ng:false-value'); - - if (!isString(trueValue)) trueValue = true; - if (!isString(falseValue)) falseValue = false; - inputElement.bind('click', function() { - widget.$apply(function() { - widget.$emit('$viewChange', inputElement[0].checked); - }); - }); - - widget.$render = function() { - inputElement[0].checked = widget.$viewValue; - }; - - widget.$parseModel = function() { - widget.$viewValue = widget.$modelValue === trueValue; - }; - - widget.$parseView = function() { - widget.$modelValue = widget.$viewValue ? trueValue : falseValue; - }; -}); /** @@ -523,14 +409,12 @@ angularInputType('checkbox', function(inputElement, widget) { $scope.color = 'blue'; } </script> - <div ng:controller="Ctrl"> - <form name="myForm"> - <input type="radio" ng:model="color" value="red"> Red <br/> - <input type="radio" ng:model="color" value="green"> Green <br/> - <input type="radio" ng:model="color" value="blue"> Blue <br/> - </form> + <form name="myForm" ng:controller="Ctrl"> + <input type="radio" ng:model="color" value="red"> Red <br/> + <input type="radio" ng:model="color" value="green"> Green <br/> + <input type="radio" ng:model="color" value="blue"> Blue <br/> <tt>color = {{color}}</tt><br/> - </div> + </form> </doc:source> <doc:scenario> it('should change state', function() { @@ -542,63 +426,6 @@ angularInputType('checkbox', function(inputElement, widget) { </doc:scenario> </doc:example> */ -angularInputType('radio', function(inputElement, widget, attr) { - //correct the name - attr.$set('name', widget.$id + '@' + attr.name); - inputElement.bind('click', function() { - widget.$apply(function() { - if (inputElement[0].checked) { - widget.$emit('$viewChange', attr.value); - } - }); - }); - - widget.$render = function() { - inputElement[0].checked = isDefined(attr.value) && (attr.value == widget.$viewValue); - }; - - if (inputElement[0].checked) { - widget.$viewValue = attr.value; - } -}); - - -function numericRegexpInputType(regexp, error) { - return function(inputElement, widget) { - var min = 1 * (inputElement.attr('min') || Number.MIN_VALUE), - max = 1 * (inputElement.attr('max') || Number.MAX_VALUE); - - widget.$on('$validate', function(event){ - var value = widget.$viewValue, - filled = value && trim(value) != '', - valid = isString(value) && value.match(regexp); - - widget.$emit(!filled || valid ? "$valid" : "$invalid", error); - filled && (value = 1 * value); - widget.$emit(valid && value < min ? "$invalid" : "$valid", "MIN"); - widget.$emit(valid && value > max ? "$invalid" : "$valid", "MAX"); - }); - - widget.$parseView = function() { - if (widget.$viewValue.match(regexp)) { - widget.$modelValue = 1 * widget.$viewValue; - } else if (widget.$viewValue == '') { - widget.$modelValue = null; - } - }; - - widget.$parseModel = function() { - widget.$viewValue = isNumber(widget.$modelValue) - ? '' + widget.$modelValue - : ''; - }; - }; -} - - -var HTML5_INPUTS_TYPES = makeMap( - "search,tel,url,email,datetime,date,month,week,time,datetime-local,number,range,color," + - "radio,checkbox,text,button,submit,reset,hidden,password"); /** @@ -641,188 +468,67 @@ var HTML5_INPUTS_TYPES = makeMap( <div ng:controller="Ctrl"> <form name="myForm"> User name: <input type="text" name="userName" ng:model="user.name" required> - <span class="error" ng:show="myForm.userName.$error.REQUIRED"> + <span class="error" ng:show="myForm.userName.error.REQUIRED"> Required!</span><br> Last name: <input type="text" name="lastName" ng:model="user.last" ng:minlength="3" ng:maxlength="10"> - <span class="error" ng:show="myForm.lastName.$error.MINLENGTH"> + <span class="error" ng:show="myForm.lastName.error.MINLENGTH"> Too short!</span> - <span class="error" ng:show="myForm.lastName.$error.MAXLENGTH"> + <span class="error" ng:show="myForm.lastName.error.MAXLENGTH"> Too long!</span><br> </form> <hr> <tt>user = {{user}}</tt><br/> - <tt>myForm.userName.$valid = {{myForm.userName.$valid}}</tt><br> - <tt>myForm.userName.$error = {{myForm.userName.$error}}</tt><br> - <tt>myForm.lastName.$valid = {{myForm.lastName.$valid}}</tt><br> - <tt>myForm.userName.$error = {{myForm.lastName.$error}}</tt><br> - <tt>myForm.$valid = {{myForm.$valid}}</tt><br> - <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br> - <tt>myForm.$error.MINLENGTH = {{!!myForm.$error.MINLENGTH}}</tt><br> - <tt>myForm.$error.MAXLENGTH = {{!!myForm.$error.MAXLENGTH}}</tt><br> + <tt>myForm.userName.valid = {{myForm.userName.valid}}</tt><br> + <tt>myForm.userName.error = {{myForm.userName.error}}</tt><br> + <tt>myForm.lastName.valid = {{myForm.lastName.valid}}</tt><br> + <tt>myForm.userName.error = {{myForm.lastName.error}}</tt><br> + <tt>myForm.valid = {{myForm.valid}}</tt><br> + <tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br> + <tt>myForm.error.MINLENGTH = {{!!myForm.error.MINLENGTH}}</tt><br> + <tt>myForm.error.MAXLENGTH = {{!!myForm.error.MAXLENGTH}}</tt><br> </div> </doc:source> <doc:scenario> it('should initialize to model', function() { expect(binding('user')).toEqual('{"last":"visitor","name":"guest"}'); - expect(binding('myForm.userName.$valid')).toEqual('true'); - expect(binding('myForm.$valid')).toEqual('true'); + expect(binding('myForm.userName.valid')).toEqual('true'); + expect(binding('myForm.valid')).toEqual('true'); }); it('should be invalid if empty when required', function() { input('user.name').enter(''); - expect(binding('user')).toEqual('{"last":"visitor","name":""}'); - expect(binding('myForm.userName.$valid')).toEqual('false'); - expect(binding('myForm.$valid')).toEqual('false'); + expect(binding('user')).toEqual('{"last":"visitor","name":null}'); + expect(binding('myForm.userName.valid')).toEqual('false'); + expect(binding('myForm.valid')).toEqual('false'); }); it('should be valid if empty when min length is set', function() { input('user.last').enter(''); expect(binding('user')).toEqual('{"last":"","name":"guest"}'); - expect(binding('myForm.lastName.$valid')).toEqual('true'); - expect(binding('myForm.$valid')).toEqual('true'); + expect(binding('myForm.lastName.valid')).toEqual('true'); + expect(binding('myForm.valid')).toEqual('true'); }); it('should be invalid if less than required min length', function() { input('user.last').enter('xx'); - expect(binding('user')).toEqual('{"last":"xx","name":"guest"}'); - expect(binding('myForm.lastName.$valid')).toEqual('false'); - expect(binding('myForm.lastName.$error')).toMatch(/MINLENGTH/); - expect(binding('myForm.$valid')).toEqual('false'); + expect(binding('user')).toEqual('{"last":"visitor","name":"guest"}'); + expect(binding('myForm.lastName.valid')).toEqual('false'); + expect(binding('myForm.lastName.error')).toMatch(/MINLENGTH/); + expect(binding('myForm.valid')).toEqual('false'); }); it('should be valid if longer than max length', function() { input('user.last').enter('some ridiculously long name'); expect(binding('user')) - .toEqual('{"last":"some ridiculously long name","name":"guest"}'); - expect(binding('myForm.lastName.$valid')).toEqual('false'); - expect(binding('myForm.lastName.$error')).toMatch(/MAXLENGTH/); - expect(binding('myForm.$valid')).toEqual('false'); + .toEqual('{"last":"visitor","name":"guest"}'); + expect(binding('myForm.lastName.valid')).toEqual('false'); + expect(binding('myForm.lastName.error')).toMatch(/MAXLENGTH/); + expect(binding('myForm.valid')).toEqual('false'); }); </doc:scenario> </doc:example> */ -var inputDirective = ['$defer', '$formFactory', function($defer, $formFactory) { - return { - restrict: 'E', - link: function(modelScope, inputElement, attr) { - if (!attr.ngModel) return; - - var form = $formFactory.forElement(inputElement), - // We have to use .getAttribute, since jQuery tries to be smart and use the - // type property. Trouble is some browser change unknown to text. - type = attr.type || 'text', - TypeController, - patternMatch, widget, - pattern = attr.ngPattern, - modelExp = attr.ngModel, - minlength = parseInt(attr.ngMinlength, 10), - maxlength = parseInt(attr.ngMaxlength, 10), - loadFromScope = type.match(/^\s*\@\s*(.*)/); - - if (!pattern) { - patternMatch = valueFn(true); - } else { - if (pattern.match(/^\/(.*)\/$/)) { - pattern = new RegExp(pattern.substr(1, pattern.length - 2)); - patternMatch = function(value) { - return pattern.test(value); - }; - } else { - patternMatch = function(value) { - var patternObj = modelScope.$eval(pattern); - if (!patternObj || !patternObj.test) { - throw new Error('Expected ' + pattern + ' to be a RegExp but was ' + patternObj); - } - return patternObj.test(value); - }; - } - } - - type = lowercase(type); - TypeController = (loadFromScope - ? assertArgFn(modelScope.$eval(loadFromScope[1]), loadFromScope[1]) - : angularInputType(type)) || noop; - - if (!HTML5_INPUTS_TYPES[type]) { - try { - // jquery will not let you so we have to go to bare metal - inputElement[0].setAttribute('type', 'text'); - } catch(e){ - // also turns out that ie8 will not allow changing of types, but since it is not - // html5 anyway we can ignore the error. - } - } - - //TODO(misko): setting $inject is a hack - !TypeController.$inject && (TypeController.$inject = ['$element', '$scope', '$attr']); - widget = form.$createWidget({ - scope: modelScope, - model: modelExp, - onChange: attr.ngChange, - alias: attr.name, - controller: TypeController, - controllerArgs: {$element: inputElement, $attr: attr} - }); - - widget.$pristine = !(widget.$dirty = false); - - widget.$on('$validate', function() { - var $viewValue = trim(widget.$viewValue), - inValid = attr.required && !$viewValue, - tooLong = maxlength && $viewValue && $viewValue.length > maxlength, - tooShort = minlength && $viewValue && $viewValue.length < minlength, - missMatch = $viewValue && !patternMatch($viewValue); - - if (widget.$error.REQUIRED != inValid){ - widget.$emit(inValid ? '$invalid' : '$valid', 'REQUIRED'); - } - if (widget.$error.PATTERN != missMatch){ - widget.$emit(missMatch ? '$invalid' : '$valid', 'PATTERN'); - } - if (widget.$error.MINLENGTH != tooShort){ - widget.$emit(tooShort ? '$invalid' : '$valid', 'MINLENGTH'); - } - if (widget.$error.MAXLENGTH != tooLong){ - widget.$emit(tooLong ? '$invalid' : '$valid', 'MAXLENGTH'); - } - }); - - forEach(['valid', 'invalid', 'pristine', 'dirty'], function(name) { - widget.$watch('$' + name, function(value) { - inputElement[value ? 'addClass' : 'removeClass']('ng-' + name); - }); - }); - - inputElement.bind('$destroy', function() { - widget.$destroy(); - }); - - if (type != 'checkbox' && type != 'radio') { - // TODO (misko): checkbox / radio does not really belong here, but until we can do - // widget registration with CSS, we are hacking it this way. - widget.$render = function() { - inputElement.val(widget.$viewValue || ''); - }; - - inputElement.bind('keydown change input', function(event){ - var key = event.keyCode; - if (/*command*/ key != 91 && - /*modifiers*/ !(15 < key && key < 19) && - /*arrow*/ !(37 < key && key < 40)) { - $defer(function() { - widget.$dirty = !(widget.$pristine = false); - var value = trim(inputElement.val()); - if (widget.$viewValue !== value ) { - widget.$emit('$viewChange', value); - } - }); - } - }); - } - } - }; -}]; /** @@ -850,3 +556,452 @@ var inputDirective = ['$defer', '$formFactory', function($defer, $formFactory) { * @param {string=} ng:change Angular expression to be executed when input changes due to user * interaction with the input element. */ +var inputType = { + 'text': textInputType, + 'number': numberInputType, + 'url': urlInputType, + 'email': emailInputType, + + 'radio': radioInputType, + 'checkbox': checkboxInputType, + + 'hidden': noop, + 'button': noop, + 'submit': noop, + 'reset': noop +}; + + +function isEmpty(value) { + return isUndefined(value) || value === '' || value === null || value !== value; +} + + +function textInputType(scope, element, attr, ctrl) { + element.bind('blur', function() { + var touched = ctrl.touch(), + value = trim(element.val()); + + if (ctrl.viewValue !== value) { + scope.$apply(function() { + ctrl.read(value); + }); + } else if (touched) { + scope.$apply(); + } + }); + + ctrl.render = function() { + element.val(ctrl.viewValue || ''); + }; + + // pattern validator + var pattern = attr.ngPattern, + patternValidator; + + var emit = function(regexp, value) { + if (isEmpty(value) || regexp.test(value)) { + ctrl.emitValidity('PATTERN', true); + return value; + } else { + ctrl.emitValidity('PATTERN', false); + return undefined; + } + }; + + if (pattern) { + if (pattern.match(/^\/(.*)\/$/)) { + pattern = new RegExp(pattern.substr(1, pattern.length - 2)); + patternValidator = function(value) { + return emit(pattern, value) + }; + } else { + patternValidator = function(value) { + var patternObj = scope.$eval(pattern); + + if (!patternObj || !patternObj.test) { + throw new Error('Expected ' + pattern + ' to be a RegExp but was ' + patternObj); + } + return emit(patternObj, value); + }; + } + + ctrl.formatters.push(patternValidator); + ctrl.parsers.push(patternValidator); + } + + // min length validator + if (attr.ngMinlength) { + var minlength = parseInt(attr.ngMinlength, 10); + var minLengthValidator = function(value) { + if (!isEmpty(value) && value.length < minlength) { + ctrl.emitValidity('MINLENGTH', false); + return undefined; + } else { + ctrl.emitValidity('MINLENGTH', true); + return value; + } + }; + + ctrl.parsers.push(minLengthValidator); + ctrl.formatters.push(minLengthValidator); + } + + // max length validator + if (attr.ngMaxlength) { + var maxlength = parseInt(attr.ngMaxlength, 10); + var maxLengthValidator = function(value) { + if (!isEmpty(value) && value.length > maxlength) { + ctrl.emitValidity('MAXLENGTH', false); + return undefined; + } else { + ctrl.emitValidity('MAXLENGTH', true); + return value; + } + }; + + ctrl.parsers.push(maxLengthValidator); + ctrl.formatters.push(maxLengthValidator); + } +}; + +function numberInputType(scope, element, attr, ctrl) { + textInputType(scope, element, attr, ctrl); + + ctrl.parsers.push(function(value) { + var empty = isEmpty(value); + if (empty || NUMBER_REGEXP.test(value)) { + ctrl.emitValidity('NUMBER', true); + return value === '' ? null : (empty ? value : parseFloat(value)); + } else { + ctrl.emitValidity('NUMBER', false); + return undefined; + } + }); + + ctrl.formatters.push(function(value) { + return isEmpty(value) ? '' : '' + value; + }); + + if (attr.min) { + var min = parseFloat(attr.min); + var minValidator = function(value) { + if (!isEmpty(value) && value < min) { + ctrl.emitValidity('MIN', false); + return undefined; + } else { + ctrl.emitValidity('MIN', true); + return value; + } + }; + + ctrl.parsers.push(minValidator); + ctrl.formatters.push(minValidator); + } + + if (attr.max) { + var max = parseFloat(attr.max); + var maxValidator = function(value) { + if (!isEmpty(value) && value > max) { + ctrl.emitValidity('MAX', false); + return undefined; + } else { + ctrl.emitValidity('MAX', true); + return value; + } + }; + + ctrl.parsers.push(maxValidator); + ctrl.formatters.push(maxValidator); + } + + ctrl.formatters.push(function(value) { + + if (isEmpty(value) || isNumber(value)) { + ctrl.emitValidity('NUMBER', true); + return value; + } else { + ctrl.emitValidity('NUMBER', false); + return undefined; + } + }); +} + +function urlInputType(scope, element, attr, ctrl) { + textInputType(scope, element, attr, ctrl); + + var urlValidator = function(value) { + if (isEmpty(value) || URL_REGEXP.test(value)) { + ctrl.emitValidity('URL', true); + return value; + } else { + ctrl.emitValidity('URL', false); + return undefined; + } + }; + + ctrl.formatters.push(urlValidator); + ctrl.parsers.push(urlValidator); +} + +function emailInputType(scope, element, attr, ctrl) { + textInputType(scope, element, attr, ctrl); + + var emailValidator = function(value) { + if (isEmpty(value) || EMAIL_REGEXP.test(value)) { + ctrl.emitValidity('EMAIL', true); + return value; + } else { + ctrl.emitValidity('EMAIL', false); + return undefined; + } + }; + + ctrl.formatters.push(emailValidator); + ctrl.parsers.push(emailValidator); +} + +function radioInputType(scope, element, attr, ctrl) { + // correct the name + element.attr('name', attr.id + '@' + attr.name); + + element.bind('click', function() { + if (element[0].checked) { + scope.$apply(function() { + ctrl.touch(); + ctrl.read(attr.value); + }); + }; + }); + + ctrl.render = function() { + var value = attr.value; + element[0].checked = isDefined(value) && (value == ctrl.viewValue); + }; +} + +function checkboxInputType(scope, element, attr, ctrl) { + var trueValue = attr.ngTrueValue, + falseValue = attr.ngFalseValue; + + if (!isString(trueValue)) trueValue = true; + if (!isString(falseValue)) falseValue = false; + + element.bind('click', function() { + scope.$apply(function() { + ctrl.touch(); + ctrl.read(element[0].checked); + }); + }); + + ctrl.render = function() { + element[0].checked = ctrl.viewValue; + }; + + ctrl.formatters.push(function(value) { + return value === trueValue; + }); + + ctrl.parsers.push(function(value) { + return value ? trueValue : falseValue; + }); +} + + +var inputDirective = [function() { + return { + restrict: 'E', + require: '?ngModel', + link: function(scope, element, attr, ctrl) { + if (ctrl) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl); + } + } + }; +}]; + + +var NgModelController = ['$scope', '$exceptionHandler', 'ngModel', + function($scope, $exceptionHandler, ngModel) { + this.viewValue = Number.NaN; + this.modelValue = Number.NaN; + this.parsers = []; + this.formatters = []; + this.error = {}; + this.pristine = true; + this.dirty = false; + this.valid = true; + this.invalid = false; + this.render = noop; + + this.touch = function() { + if (this.dirty) return false; + + this.dirty = true; + this.pristine = false; + try { + $scope.$emit('$viewTouch'); + } catch (e) { + $exceptionHandler(e); + } + return true; + }; + + // don't $emit valid if already valid, the same for $invalid + // not sure about this method name, should the argument be reversed ? emitError ? + this.emitValidity = function(name, isValid) { + + if (!isValid && this.error[name]) return; + if (isValid && !this.error[name]) return; + + if (!isValid) { + this.error[name] = true; + this.invalid = true; + this.valid = false; + } + + if (isValid) { + delete this.error[name]; + if (equals(this.error, {})) { + this.valid = true; + this.invalid = false; + } + } + + return $scope.$emit(isValid ? '$valid' : '$invalid', name, this); + }; + + // view -> model + this.read = function(value) { + this.viewValue = value; + + forEach(this.parsers, function(fn) { + value = fn(value); + }); + + if (isDefined(value) && this.model !== value) { + this.modelValue = value; + ngModel(value); + $scope.$emit('$viewChange', value, this); + } + }; + + // model -> value + var ctrl = this; + $scope.$watch(function() { + return ngModel(); + }, function(value) { + + // ignore change from view + if (ctrl.modelValue === value) return; + + var formatters = ctrl.formatters, + idx = formatters.length; + + ctrl.modelValue = value; + while(idx--) { + value = formatters[idx](value); + } + + if (isDefined(value) && ctrl.viewValue !== value) { + ctrl.viewValue = value; + ctrl.render(); + } + }); +}]; + + +var ngModelDirective = [function() { + return { + inject: { + ngModel: 'accessor' + }, + require: ['ngModel', '^?form'], + controller: NgModelController, + link: function(scope, element, attr, controllers) { + var modelController = controllers[0], + formController = controllers[1]; + + if (formController) { + formController.registerWidget(modelController, attr.name); + } + + forEach(['valid', 'invalid', 'pristine', 'dirty'], function(name) { + scope.$watch(function() { + return modelController[name]; + }, function(value) { + element[value ? 'addClass' : 'removeClass']('ng-' + name); + }); + }); + + element.bind('$destroy', function() { + scope.$emit('$destroy', modelController); + }); + } + }; +}]; + + +var ngChangeDirective = valueFn({ + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + scope.$on('$viewChange', function(event, value, widget) { + if (ctrl === widget) scope.$eval(attr.ngChange); + }); + } +}); + + +var ngBindImmediateDirective = ['$browser', function($browser) { + return { + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + element.bind('keydown change input', function(event) { + var key = event.keyCode; + + // command modifiers arrows + if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + + $browser.defer(function() { + var touched = ctrl.touch(), + value = trim(element.val()); + + if (ctrl.viewValue !== value) { + scope.$apply(function() { + ctrl.read(value); + }); + } else if (touched) { + scope.$apply(); + } + }); + }); + } + }; +}]; + + +var requiredDirective = [function() { + return { + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var validator = function(value) { + if (attr.required && isEmpty(value)) { + ctrl.emitValidity('REQUIRED', false); + return null; + } else { + ctrl.emitValidity('REQUIRED', true); + return value; + } + }; + + ctrl.formatters.push(validator); + ctrl.parsers.unshift(validator); + + attr.$observe('required', function() { + validator(ctrl.viewValue); + }); + } + }; +}]; diff --git a/src/widget/select.js b/src/widget/select.js index f70575a6..e7386147 100644 --- a/src/widget/select.js +++ b/src/widget/select.js @@ -123,87 +123,79 @@ */ var ngOptionsDirective = valueFn({ terminal: true }); -var selectDirective = ['$formFactory', '$compile', '$parse', - function($formFactory, $compile, $parse){ +var selectDirective = ['$compile', '$parse', function($compile, $parse) { //00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777 var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/; return { restrict: 'E', - link: function(modelScope, selectElement, attr) { - if (!attr.ngModel) return; - var form = $formFactory.forElement(selectElement), - multiple = attr.multiple, - optionsExp = attr.ngOptions, - modelExp = attr.ngModel, - widget = form.$createWidget({ - scope: modelScope, - model: modelExp, - onChange: attr.ngChange, - alias: attr.name, - controller: ['$scope', optionsExp ? Options : (multiple ? Multiple : Single)]}); - - selectElement.bind('$destroy', function() { widget.$destroy(); }); - - widget.$pristine = !(widget.$dirty = false); - - widget.$on('$validate', function() { - var valid = !attr.required || !!widget.$modelValue; - if (valid && multiple && attr.required) valid = !!widget.$modelValue.length; - if (valid !== !widget.$error.REQUIRED) { - widget.$emit(valid ? '$valid' : '$invalid', 'REQUIRED'); - } - }); + require: '?ngModel', + link: function(scope, element, attr, ctrl) { + if (!ctrl) return; + + var multiple = attr.multiple, + optionsExp = attr.ngOptions; + + // required validator + if (multiple && (attr.required || attr.ngRequired)) { + var requiredValidator = function(value) { + ctrl.emitValidity('REQUIRED', !attr.required || (value && value.length)); + return value; + }; - widget.$on('$viewChange', function() { - widget.$pristine = !(widget.$dirty = true); - }); + ctrl.parsers.push(requiredValidator); + ctrl.formatters.unshift(requiredValidator); - forEach(['valid', 'invalid', 'pristine', 'dirty'], function(name) { - widget.$watch('$' + name, function(value) { - selectElement[value ? 'addClass' : 'removeClass']('ng-' + name); + attr.$observe('required', function() { + requiredValidator(ctrl.viewValue); }); - }); + } + + if (optionsExp) Options(scope, element, ctrl); + else if (multiple) Multiple(scope, element, ctrl); + else Single(scope, element, ctrl); + //////////////////////////// - function Multiple(widget) { - widget.$render = function() { - var items = new HashMap(this.$viewValue); - forEach(selectElement.children(), function(option){ - option.selected = isDefined(items.get(option.value)); - }); + + + function Single(scope, selectElement, ctrl) { + ctrl.render = function() { + selectElement.val(ctrl.viewValue); }; selectElement.bind('change', function() { - widget.$apply(function() { - var array = []; - forEach(selectElement.children(), function(option){ - if (option.selected) { - array.push(option.value); - } - }); - widget.$emit('$viewChange', array); + scope.$apply(function() { + ctrl.touch(); + ctrl.read(selectElement.val()); }); }); - } - function Single(widget) { - widget.$render = function() { - selectElement.val(widget.$viewValue); + function Multiple(scope, selectElement, ctrl) { + ctrl.render = function() { + var items = new HashMap(ctrl.viewValue); + forEach(selectElement.children(), function(option) { + option.selected = isDefined(items.get(option.value)); + }); }; selectElement.bind('change', function() { - widget.$apply(function() { - widget.$emit('$viewChange', selectElement.val()); + scope.$apply(function() { + var array = []; + forEach(selectElement.children(), function(option) { + if (option.selected) { + array.push(option.value); + } + }); + ctrl.touch(); + ctrl.read(array); }); }); - - widget.$viewValue = selectElement.val(); } - function Options(widget) { + function Options(scope, selectElement, ctrl) { var match; if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) { @@ -234,15 +226,15 @@ var selectDirective = ['$formFactory', '$compile', '$parse', // developer declared null option, so user should be able to select it nullOption = jqLite(option).remove(); // compile the element since there might be bindings in it - $compile(nullOption)(modelScope); + $compile(nullOption)(scope); } }); selectElement.html(''); // clear contents selectElement.bind('change', function() { - widget.$apply(function() { + scope.$apply(function() { var optionGroup, - collection = valuesFn(modelScope) || [], + collection = valuesFn(scope) || [], locals = {}, key, value, optionElement, index, groupIndex, length, groupLength; @@ -259,7 +251,7 @@ var selectDirective = ['$formFactory', '$compile', '$parse', key = optionElement.val(); if (keyName) locals[keyName] = key; locals[valueName] = collection[key]; - value.push(valueFn(modelScope, locals)); + value.push(valueFn(scope, locals)); } } } @@ -272,17 +264,21 @@ var selectDirective = ['$formFactory', '$compile', '$parse', } else { locals[valueName] = collection[key]; if (keyName) locals[keyName] = key; - value = valueFn(modelScope, locals); + value = valueFn(scope, locals); } } - if (isDefined(value) && modelScope.$viewVal !== value) { - widget.$emit('$viewChange', value); + ctrl.touch(); + + if (ctrl.viewValue !== value) { + ctrl.read(value); } }); }); - widget.$watch(render); - widget.$render = render; + ctrl.render = render; + + // TODO(vojta): can't we optimize this ? + scope.$watch(render); function render() { var optionGroups = {'':[]}, // Temporary location for the option groups before we render them @@ -291,8 +287,8 @@ var selectDirective = ['$formFactory', '$compile', '$parse', optionGroup, option, existingParent, existingOptions, existingOption, - modelValue = widget.$modelValue, - values = valuesFn(modelScope) || [], + modelValue = ctrl.modelValue, + values = valuesFn(scope) || [], keys = keyName ? sortedKeys(values) : values, groupLength, length, groupIndex, index, @@ -313,20 +309,20 @@ var selectDirective = ['$formFactory', '$compile', '$parse', // We now build up the list of options we need (we merge later) for (index = 0; length = keys.length, index < length; index++) { locals[valueName] = values[keyName ? locals[keyName]=keys[index]:index]; - optionGroupName = groupByFn(modelScope, locals) || ''; + optionGroupName = groupByFn(scope, locals) || ''; if (!(optionGroup = optionGroups[optionGroupName])) { optionGroup = optionGroups[optionGroupName] = []; optionGroupNames.push(optionGroupName); } if (multiple) { - selected = selectedSet.remove(valueFn(modelScope, locals)) != undefined; + selected = selectedSet.remove(valueFn(scope, locals)) != undefined; } else { - selected = modelValue === valueFn(modelScope, locals); + selected = modelValue === valueFn(scope, locals); selectedSet = selectedSet || selected; // see if at least one item is selected } optionGroup.push({ id: keyName ? keys[index] : index, // either the index into array or key from object - label: displayFn(modelScope, locals) || '', // what will be seen by the user + label: displayFn(scope, locals) || '', // what will be seen by the user selected: selected // determine if we should be selected }); } diff --git a/test/BinderSpec.js b/test/BinderSpec.js index 5e27fd0f..eb916d00 100644 --- a/test/BinderSpec.js +++ b/test/BinderSpec.js @@ -156,7 +156,7 @@ describe('Binder', function() { expect(html.indexOf('action="foo();"')).toBeGreaterThan(0); }); - it('RepeaterAdd', inject(function($rootScope, $compile, $browser) { + it('RepeaterAdd', inject(function($rootScope, $compile) { element = $compile('<div><input type="text" ng:model="item.x" ng:repeat="item in items"></div>')($rootScope); $rootScope.items = [{x:'a'}, {x:'b'}]; $rootScope.$apply(); @@ -166,8 +166,7 @@ describe('Binder', function() { expect(second.val()).toEqual('b'); first.val('ABC'); - browserTrigger(first, 'keydown'); - $browser.defer.flush(); + browserTrigger(first, 'blur'); expect($rootScope.items[0].x).toEqual('ABC'); })); diff --git a/test/service/formFactorySpec.js b/test/service/formFactorySpec.js deleted file mode 100644 index 1a23aa49..00000000 --- a/test/service/formFactorySpec.js +++ /dev/null @@ -1,206 +0,0 @@ -'use strict'; - -describe('$formFactory', function() { - - it('should have global form', inject(function($rootScope, $formFactory) { - expect($formFactory.rootForm).toBeTruthy(); - expect($formFactory.rootForm.$createWidget).toBeTruthy(); - })); - - - describe('new form', function() { - var form; - var scope; - var log; - - function WidgetCtrl($formFactory, $scope) { - log += '<init>'; - $scope.$render = function() { - log += '$render();'; - }; - $scope.$on('$validate', function(e){ - log += '$validate();'; - }); - - this.$formFactory = $formFactory; - } - - WidgetCtrl.$inject = ['$formFactory', '$scope']; - - WidgetCtrl.prototype = { - getFormFactory: function() { - return this.$formFactory; - } - }; - - beforeEach(inject(function($rootScope, $formFactory) { - log = ''; - scope = $rootScope.$new(); - form = $formFactory(scope); - })); - - describe('$createWidget', function() { - var widget; - - beforeEach(function() { - widget = form.$createWidget({ - scope:scope, - model:'text', - alias:'text', - controller:WidgetCtrl - }); - }); - - - describe('data flow', function() { - it('should have status properties', inject(function($rootScope, $formFactory) { - expect(widget.$error).toEqual({}); - expect(widget.$valid).toBe(true); - expect(widget.$invalid).toBe(false); - })); - - - it('should update view when model changes', inject(function($rootScope, $formFactory) { - scope.text = 'abc'; - scope.$digest(); - expect(log).toEqual('<init>$validate();$render();'); - expect(widget.$modelValue).toEqual('abc'); - - scope.text = 'xyz'; - scope.$digest(); - expect(widget.$modelValue).toEqual('xyz'); - - })); - }); - - - describe('validation', function() { - it('should update state on error', inject(function($rootScope, $formFactory) { - widget.$emit('$invalid', 'E'); - expect(widget.$valid).toEqual(false); - expect(widget.$invalid).toEqual(true); - - widget.$emit('$valid', 'E'); - expect(widget.$valid).toEqual(true); - expect(widget.$invalid).toEqual(false); - })); - - - it('should have called the model setter before the validation', inject(function($rootScope, $formFactory) { - var modelValue; - widget.$on('$validate', function() { - modelValue = scope.text; - }); - widget.$emit('$viewChange', 'abc'); - expect(modelValue).toEqual('abc'); - })); - - - describe('form', function() { - it('should invalidate form when widget is invalid', inject(function($rootScope, $formFactory) { - expect(form.$error).toEqual({}); - expect(form.$valid).toEqual(true); - expect(form.$invalid).toEqual(false); - - widget.$emit('$invalid', 'REASON'); - - expect(form.$error.REASON).toEqual([widget]); - expect(form.$valid).toEqual(false); - expect(form.$invalid).toEqual(true); - - var widget2 = form.$createWidget({ - scope:scope, model:'text', - alias:'text', - controller:WidgetCtrl - }); - widget2.$emit('$invalid', 'REASON'); - - expect(form.$error.REASON).toEqual([widget, widget2]); - expect(form.$valid).toEqual(false); - expect(form.$invalid).toEqual(true); - - widget.$emit('$valid', 'REASON'); - - expect(form.$error.REASON).toEqual([widget2]); - expect(form.$valid).toEqual(false); - expect(form.$invalid).toEqual(true); - - widget2.$emit('$valid', 'REASON'); - - expect(form.$error).toEqual({}); - expect(form.$valid).toEqual(true); - expect(form.$invalid).toEqual(false); - })); - }); - - }); - - describe('id assignment', function() { - it('should default to name expression', inject(function($rootScope, $formFactory) { - expect(form.text).toEqual(widget); - })); - - - it('should use ng:id', inject(function($rootScope, $formFactory) { - widget = form.$createWidget({ - scope:scope, - model:'text', - alias:'my.id', - controller:WidgetCtrl - }); - expect(form['my.id']).toEqual(widget); - })); - - - it('should not override existing names', inject(function($rootScope, $formFactory) { - var widget2 = form.$createWidget({ - scope:scope, - model:'text', - alias:'text', - controller:WidgetCtrl - }); - expect(form.text).toEqual(widget); - expect(widget2).not.toEqual(widget); - })); - }); - - describe('dealocation', function() { - it('should dealocate', inject(function($rootScope, $formFactory) { - var widget2 = form.$createWidget({ - scope:scope, - model:'text', - alias:'myId', - controller:WidgetCtrl - }); - expect(form.myId).toEqual(widget2); - var widget3 = form.$createWidget({ - scope:scope, - model:'text', - alias:'myId', - controller:WidgetCtrl - }); - expect(form.myId).toEqual(widget2); - - widget3.$destroy(); - expect(form.myId).toEqual(widget2); - - widget2.$destroy(); - expect(form.myId).toBeUndefined(); - })); - - - it('should remove invalid fields from errors, when child widget removed', inject(function($rootScope, $formFactory) { - widget.$emit('$invalid', 'MyError'); - - expect(form.$error.MyError).toEqual([widget]); - expect(form.$invalid).toEqual(true); - - widget.$destroy(); - - expect(form.$error.MyError).toBeUndefined(); - expect(form.$invalid).toEqual(false); - })); - }); - }); - }); -}); diff --git a/test/widget/formSpec.js b/test/widget/formSpec.js index f2e90d9e..bc5f3ea7 100644 --- a/test/widget/formSpec.js +++ b/test/widget/formSpec.js @@ -1,122 +1,205 @@ 'use strict'; describe('form', function() { - var doc; + var doc, widget, scope, $compile; + + beforeEach(module(function($compileProvider) { + $compileProvider.directive('storeModelCtrl', function() { + return { + require: 'ngModel', + link: function(scope, elm, attr, ctrl) { + widget = ctrl; + } + }; + }); + })); + + beforeEach(inject(function($injector) { + $compile = $injector.get('$compile'); + scope = $injector.get('$rootScope'); + })); afterEach(function() { dealoc(doc); }); - it('should attach form to DOM', inject(function($rootScope, $compile) { - doc = angular.element('<form>'); - $compile(doc)($rootScope); + it('should instantiate form and attach it to DOM', function() { + doc = $compile('<form>')(scope); expect(doc.data('$form')).toBeTruthy(); - })); + expect(doc.data('$form') instanceof FormController).toBe(true); + }); + + it('should remove the widget when element removed', function() { + doc = $compile( + '<form name="form">' + + '<input type="text" name="alias" ng:model="value" store-model-ctrl/>' + + '</form>')(scope); - it('should prevent form submission', inject(function($rootScope, $compile) { + var form = scope.form; + widget.emitValidity('REQUIRED', false); + expect(form.alias).toBe(widget); + expect(form.error.REQUIRED).toEqual([widget]); + + doc.find('input').remove(); + expect(form.error.REQUIRED).toBeUndefined(); + expect(form.alias).toBeUndefined(); + }); + + + it('should prevent form submission', function() { var startingUrl = '' + window.location; - doc = angular.element('<form name="myForm"><input type=submit val=submit>'); - $compile(doc)($rootScope); + doc = jqLite('<form name="myForm"><input type="submit" value="submit" />'); + $compile(doc)(scope); + browserTrigger(doc.find('input')); waitsFor( function() { return true; }, 'let browser breath, so that the form submision can manifest itself', 10); + runs(function() { expect('' + window.location).toEqual(startingUrl); }); - })); + }); - it('should not prevent form submission if action attribute present', - inject(function($compile, $rootScope) { + it('should not prevent form submission if action attribute present', function() { var callback = jasmine.createSpy('submit').andCallFake(function(event) { expect(event.isDefaultPrevented()).toBe(false); event.preventDefault(); }); - doc = angular.element('<form name="x" action="some.py" />'); - $compile(doc)($rootScope); + doc = $compile('<form name="x" action="some.py" />')(scope); doc.bind('submit', callback); browserTrigger(doc, 'submit'); expect(callback).toHaveBeenCalledOnce(); - })); + }); - it('should publish form to scope', inject(function($rootScope, $compile) { - doc = angular.element('<form name="myForm"></form>'); - $compile(doc)($rootScope); - expect($rootScope.myForm).toBeTruthy(); + it('should publish form to scope', function() { + doc = $compile('<form name="myForm"></form>')(scope); + expect(scope.myForm).toBeTruthy(); expect(doc.data('$form')).toBeTruthy(); - expect(doc.data('$form')).toEqual($rootScope.myForm); - })); - + expect(doc.data('$form')).toEqual(scope.myForm); + }); - it('should have ng-valide/ng-invalid style', inject(function($rootScope, $compile) { - doc = angular.element('<form name="myForm"><input type=text ng:model=text required>'); - $compile(doc)($rootScope); - $rootScope.text = 'misko'; - $rootScope.$digest(); - expect(doc.hasClass('ng-valid')).toBe(true); - expect(doc.hasClass('ng-invalid')).toBe(false); + it('should allow name to be an expression', function() { + doc = $compile('<form name="obj.myForm"></form>')(scope); - $rootScope.text = ''; - $rootScope.$digest(); - expect(doc.hasClass('ng-valid')).toBe(false); - expect(doc.hasClass('ng-invalid')).toBe(true); - })); + expect(scope.obj).toBeDefined(); + expect(scope.obj.myForm).toBeTruthy(); + }); - it('should chain nested forms', inject(function($rootScope, $compile) { - doc = angular.element( - '<ng:form name=parent>' + - '<ng:form name=child>' + - '<input type=text ng:model=text name=text>' + + it('should chain nested forms', function() { + doc = jqLite( + '<ng:form name="parent">' + + '<ng:form name="child">' + + '<input type="text" ng:model="text" name="text">' + '</ng:form>' + '</ng:form>'); - $compile(doc)($rootScope); - var parent = $rootScope.parent; - var child = $rootScope.child; + $compile(doc)(scope); + + var parent = scope.parent; + var child = scope.child; var input = child.text; - input.$emit('$invalid', 'MyError'); - expect(parent.$error.MyError).toEqual([input]); - expect(child.$error.MyError).toEqual([input]); + input.emitValidity('MyError', false); + expect(parent.error.MyError).toEqual([input]); + expect(child.error.MyError).toEqual([input]); - input.$emit('$valid', 'MyError'); - expect(parent.$error.MyError).toBeUndefined(); - expect(child.$error.MyError).toBeUndefined(); - })); + input.emitValidity('MyError', true); + expect(parent.error.MyError).toBeUndefined(); + expect(child.error.MyError).toBeUndefined(); + }); - it('should chain nested forms in repeater', inject(function($rootScope, $compile) { - doc = angular.element( + it('should chain nested forms in repeater', function() { + doc = jqLite( '<ng:form name=parent>' + '<ng:form ng:repeat="f in forms" name=child>' + '<input type=text ng:model=text name=text>' + '</ng:form>' + '</ng:form>'); - $compile(doc)($rootScope); - $rootScope.forms = [1]; - $rootScope.$digest(); + $compile(doc)(scope); - var parent = $rootScope.parent; + scope.$apply(function() { + scope.forms = [1]; + }); + + var parent = scope.parent; var child = doc.find('input').scope().child; var input = child.text; + expect(parent).toBeDefined(); expect(child).toBeDefined(); expect(input).toBeDefined(); - input.$emit('$invalid', 'myRule'); - expect(input.$error.myRule).toEqual(true); - expect(child.$error.myRule).toEqual([input]); - expect(parent.$error.myRule).toEqual([input]); + input.emitValidity('myRule', false); + expect(input.error.myRule).toEqual(true); + expect(child.error.myRule).toEqual([input]); + expect(parent.error.myRule).toEqual([input]); - input.$emit('$valid', 'myRule'); - expect(parent.$error.myRule).toBeUndefined(); - expect(child.$error.myRule).toBeUndefined(); - })); + input.emitValidity('myRule', true); + expect(parent.error.myRule).toBeUndefined(); + expect(child.error.myRule).toBeUndefined(); + }); + + + it('should publish widgets', function() { + doc = jqLite('<form name="form"><input type="text" name="w1" ng:model="some" /></form>'); + $compile(doc)(scope); + + var widget = scope.form.w1; + expect(widget).toBeDefined(); + expect(widget.pristine).toBe(true); + expect(widget.dirty).toBe(false); + expect(widget.valid).toBe(true); + expect(widget.invalid).toBe(false); + }); + + + describe('validation', function() { + + beforeEach(function() { + doc = $compile( + '<form name="form">' + + '<input type="text" ng:model="name" name="name" store-model-ctrl/>' + + '</form>')(scope); + + scope.$digest(); + }); + + + it('should have ng-valid/ng-invalid css class', function() { + expect(doc).toBeValid(); + + widget.emitValidity('ERROR', false); + scope.$apply(); + expect(doc).toBeInvalid(); + + widget.emitValidity('ANOTHER', false); + scope.$apply(); + + widget.emitValidity('ERROR', true); + scope.$apply(); + expect(doc).toBeInvalid(); + + widget.emitValidity('ANOTHER', true); + scope.$apply(); + expect(doc).toBeValid(); + }); + + + it('should have ng-pristine/ng-dirty css class', function() { + expect(doc).toBePristine(); + + widget.touch(); + scope.$apply(); + expect(doc).toBeDirty(); + }); + }); }); diff --git a/test/widget/inputSpec.js b/test/widget/inputSpec.js index 3b3aa282..daea7246 100644 --- a/test/widget/inputSpec.js +++ b/test/widget/inputSpec.js @@ -1,630 +1,953 @@ 'use strict'; -describe('widget: input', function() { - var compile = null, element = null, scope = null, defer = null; - var $compile = null; - var doc = null; +describe('NgModelController', function() { + var ctrl, scope, ngModelAccessor; - beforeEach(inject(function($rootScope, $compile, $browser) { + beforeEach(inject(function($rootScope, $controller) { scope = $rootScope; - defer = $browser.defer; - set$compile($compile); - element = null; - compile = function(html, parent) { - if (parent) { - parent.html(html); - element = parent.children(); - } else { - element = jqLite(html); - } - $compile(element)(scope); - scope.$apply(); - return scope; - }; + ngModelAccessor = jasmine.createSpy('ngModel accessor'); + ctrl = $controller(NgModelController, {$scope: scope, ngModel: ngModelAccessor}); + + // mock accessor (locals) + ngModelAccessor.andCallFake(function(val) { + if (isDefined(val)) scope.value = val; + return scope.value; + }); })); - function set$compile(c) { $compile = c; } - afterEach(function() { - dealoc(element); - dealoc(doc); - }); + it('should init the properties', function() { + expect(ctrl.dirty).toBe(false); + expect(ctrl.pristine).toBe(true); + expect(ctrl.valid).toBe(true); + expect(ctrl.invalid).toBe(false); + expect(ctrl.viewValue).toBeDefined(); + expect(ctrl.modelValue).toBeDefined(); - describe('text', function() { - var form = null, - formElement = null, - inputElement = null; + expect(ctrl.formatters).toEqual([]); + expect(ctrl.parsers).toEqual([]); + }); - function createInput(flags){ - var prefix = ''; - forEach(flags, function(value, key){ - prefix += key + '="' + value + '" '; - }); - formElement = doc = angular.element('<form name="form"><input ' + prefix + - 'type="text" ng:model="name" name="name" ng:change="change()"></form>'); - inputElement = formElement.find('input'); - $compile(doc)(scope); - form = formElement.inheritedData('$form'); - }; + describe('touch', function() { + it('should only fire $viewTouch when pristine', function() { + var spy = jasmine.createSpy('$viewTouch'); + scope.$on('$viewTouch', spy); - it('should bind update scope from model', function() { - createInput(); - scope.name = 'misko'; - scope.$digest(); - expect(inputElement.val()).toEqual('misko'); + ctrl.touch(); + expect(ctrl.pristine).toBe(false); + expect(ctrl.dirty).toBe(true); + expect(spy).toHaveBeenCalledOnce(); + + spy.reset(); + ctrl.touch(); + expect(ctrl.pristine).toBe(false); + expect(ctrl.dirty).toBe(true); + expect(spy).not.toHaveBeenCalled(); }); + }); - it('should require', function() { - createInput({required:''}); - scope.$digest(); - expect(scope.form.name.$valid).toBe(false); - scope.name = 'misko'; - scope.$digest(); - expect(scope.form.name.$valid).toBe(true); + describe('emitValidity', function() { + + it('should emit $invalid only when $valid', function() { + var spy = jasmine.createSpy('$invalid'); + scope.$on('$invalid', spy); + + ctrl.emitValidity('ERROR', false); + expect(spy).toHaveBeenCalledOnce(); + + spy.reset(); + ctrl.emitValidity('ERROR', false); + expect(spy).not.toHaveBeenCalled(); }); - it('should call $destroy on element remove', function() { - createInput(); - var log = ''; - form.$on('$destroy', function() { - log += 'destroy;'; - }); - inputElement.remove(); - expect(log).toEqual('destroy;'); + it('should set and unset the error', function() { + ctrl.emitValidity('REQUIRED', false); + expect(ctrl.error.REQUIRED).toBe(true); + + ctrl.emitValidity('REQUIRED', true); + expect(ctrl.error.REQUIRED).toBeUndefined(); }); - it('should update the model and trim input', function() { - createInput(); - var log = ''; - scope.change = function() { - log += 'change();'; - }; - inputElement.val(' a '); - browserTrigger(inputElement); - defer.flush(); - expect(scope.name).toEqual('a'); - expect(log).toEqual('change();'); + it('should set valid/invalid', function() { + ctrl.emitValidity('FIRST', false); + expect(ctrl.valid).toBe(false); + expect(ctrl.invalid).toBe(true); + + ctrl.emitValidity('SECOND', false); + expect(ctrl.valid).toBe(false); + expect(ctrl.invalid).toBe(true); + + ctrl.emitValidity('SECOND', true); + expect(ctrl.valid).toBe(false); + expect(ctrl.invalid).toBe(true); + + ctrl.emitValidity('FIRST', true); + expect(ctrl.valid).toBe(true); + expect(ctrl.invalid).toBe(false); }); - it('should change non-html5 types to text', inject(function($rootScope, $compile) { - doc = angular.element('<form name="form"><input type="abc" ng:model="name"></form>'); - $compile(doc)($rootScope); - expect(doc.find('input').attr('type')).toEqual('text'); - })); + it('should emit $valid only when $invalid', function() { + var spy = jasmine.createSpy('$valid'); + scope.$on('$valid', spy); + ctrl.emitValidity('ERROR', true); + expect(spy).not.toHaveBeenCalled(); - it('should not change html5 types to text', inject(function($rootScope, $compile) { - doc = angular.element('<form name="form"><input type="number" ng:model="name"></form>'); - $compile(doc)($rootScope); - expect(doc.find('input')[0].getAttribute('type')).toEqual('number'); - })); + ctrl.emitValidity('ERROR', false); + ctrl.emitValidity('ERROR', true); + expect(spy).toHaveBeenCalledOnce(); + }); }); - describe("input", function() { + describe('view -> model', function() { - describe("text", function() { - it('should input-text auto init and listen on keydown/change/input events', function() { - compile('<input type="text" ng:model="name"/>'); + it('should set the value to $viewValue', function() { + ctrl.read('some-val'); + expect(ctrl.viewValue).toBe('some-val'); + }); - scope.name = 'Adam'; - scope.$digest(); - expect(element.val()).toEqual("Adam"); - element.val('Shyam'); - browserTrigger(element, 'keydown'); - // keydown event must be deferred - expect(scope.name).toEqual('Adam'); - defer.flush(); - expect(scope.name).toEqual('Shyam'); + it('should pipeline all registered parsers and set result to $modelValue', function() { + var log = []; - element.val('Kai'); - browserTrigger(element, 'change'); - defer.flush(); - expect(scope.name).toEqual('Kai'); + ctrl.parsers.push(function(value) { + log.push(value); + return value + '-a'; + }); - if (!(msie<=8)) { - element.val('Lunar'); - browserTrigger(element, 'input'); - defer.flush(); - expect(scope.name).toEqual('Lunar'); - } + ctrl.parsers.push(function(value) { + log.push(value); + return value + '-b'; }); + ctrl.read('init'); + expect(log).toEqual(['init', 'init-a']); + expect(ctrl.modelValue).toBe('init-a-b'); + }); - it('should not trigger eval if value does not change', function() { - compile('<input type="text" ng:model="name" ng:change="count = count + 1" ng:init="count=0"/>'); - scope.name = 'Misko'; - scope.$digest(); - expect(scope.name).toEqual("Misko"); - expect(scope.count).toEqual(0); - browserTrigger(element, 'keydown'); - defer.flush(); - expect(scope.name).toEqual("Misko"); - expect(scope.count).toEqual(0); + + it('should fire $viewChange only if value changed and is valid', function() { + var spy = jasmine.createSpy('$viewChange'); + scope.$on('$viewChange', spy); + + ctrl.read('val'); + expect(spy).toHaveBeenCalledOnce(); + spy.reset(); + + // invalid + ctrl.parsers.push(function() {return undefined;}); + ctrl.read('val'); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + + describe('model -> view', function() { + + it('should set the value to $modelValue', function() { + scope.$apply(function() { + scope.value = 10; }); + expect(ctrl.modelValue).toBe(10); + }); - it('should allow complex reference binding', function() { - compile('<div>'+ - '<input type="text" ng:model="obj[\'abc\'].name"/>'+ - '</div>'); - scope.obj = { abc: { name: 'Misko'} }; - scope.$digest(); - expect(element.find('input').val()).toEqual('Misko'); + it('should pipeline all registered formatters in reversed order and set result to $viewValue', + function() { + var log = []; + + ctrl.formatters.unshift(function(value) { + log.push(value); + return value + 2; }); + ctrl.formatters.unshift(function(value) { + log.push(value); + return value + ''; + }); - describe("ng:format", function() { - it("should format text", function() { - compile('<input type="list" ng:model="list"/>'); + scope.$apply(function() { + scope.value = 3; + }); + expect(log).toEqual([3, 5]); + expect(ctrl.viewValue).toBe('5'); + }); - scope.list = ['x', 'y', 'z']; - scope.$digest(); - expect(element.val()).toEqual("x, y, z"); - element.val('1, 2, 3'); - browserTrigger(element); - defer.flush(); - expect(scope.list).toEqual(['1', '2', '3']); - }); + it('should $render only if value changed and is valid', function() { + spyOn(ctrl, 'render'); + scope.$apply(function() { + scope.value= 3; + }); + expect(ctrl.render).toHaveBeenCalledOnce(); + ctrl.render.reset(); - it("should render as blank if null", function() { - compile('<input type="text" ng:model="age" ng:format="number" ng:init="age=null"/>'); - expect(scope.age).toBeNull(); - expect(element[0].value).toEqual(''); - }); + // invalid + ctrl.formatters.push(function() {return undefined;}); + scope.$apply(function() { + scope.value= 5; + }); + expect(ctrl.render).not.toHaveBeenCalled(); + }); + }); +}); +describe('ng:model', function() { - it("should show incorrect text while number does not parse", function() { - compile('<input type="number" ng:model="age"/>'); - scope.age = 123; - scope.$digest(); - expect(element.val()).toEqual('123'); - try { - // to allow non-number values, we have to change type so that - // the browser which have number validation will not interfere with - // this test. IE8 won't allow it hence the catch. - element[0].setAttribute('type', 'text'); - } catch (e){} - element.val('123X'); - browserTrigger(element, 'change'); - defer.flush(); - expect(element.val()).toEqual('123X'); - expect(scope.age).toEqual(123); - expect(element).toBeInvalid(); - }); + it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty)', + inject(function($compile, $rootScope) { + var element = $compile('<input type="email" ng:model="value" />')($rootScope); + $rootScope.$digest(); + expect(element).toBeValid(); + expect(element).toBePristine(); - it("should not clobber text if model changes due to itself", function() { - // When the user types 'a,b' the 'a,' stage parses to ['a'] but if the - // $parseModel function runs it will change to 'a', in essence preventing - // the user from ever typying ','. - compile('<input type="list" ng:model="list"/>'); - - element.val('a '); - browserTrigger(element, 'change'); - defer.flush(); - expect(element.val()).toEqual('a '); - expect(scope.list).toEqual(['a']); - - element.val('a ,'); - browserTrigger(element, 'change'); - defer.flush(); - expect(element.val()).toEqual('a ,'); - expect(scope.list).toEqual(['a']); - - element.val('a , '); - browserTrigger(element, 'change'); - defer.flush(); - expect(element.val()).toEqual('a , '); - expect(scope.list).toEqual(['a']); - - element.val('a , b'); - browserTrigger(element, 'change'); - defer.flush(); - expect(element.val()).toEqual('a , b'); - expect(scope.list).toEqual(['a', 'b']); - }); + $rootScope.$apply(function() { + $rootScope.value = 'invalid-email'; + }); + expect(element).toBeInvalid(); + expect(element).toBePristine(); + element.val('invalid-again'); + browserTrigger(element, 'blur'); + expect(element).toBeInvalid(); + expect(element).toBeDirty(); - it("should come up blank when no value specified", function() { - compile('<input type="number" ng:model="age"/>'); - scope.$digest(); - expect(element.val()).toEqual(''); - expect(scope.age).toEqual(null); - }); - }); + element.val('vojta@google.com'); + browserTrigger(element, 'blur'); + expect(element).toBeValid(); + expect(element).toBeDirty(); + dealoc(element); + })); +}); - describe("checkbox", function() { - it("should format booleans", function() { - compile('<input type="checkbox" ng:model="name" ng:init="name=false"/>'); - expect(scope.name).toBe(false); - expect(element[0].checked).toBe(false); - }); +describe('input', function() { + var formElm, inputElm, scope, $compile; - it('should support type="checkbox" with non-standard capitalization', function() { - compile('<input type="checkBox" ng:model="checkbox"/>'); + function compileInput(inputHtml) { + formElm = jqLite('<form name="form">' + inputHtml + '</form>'); + inputElm = formElm.find('input'); + $compile(formElm)(scope); + } - browserTrigger(element); - expect(scope.checkbox).toBe(true); + function changeInputValueTo(value) { + inputElm.val(value); + browserTrigger(inputElm, 'blur'); + } - browserTrigger(element); - expect(scope.checkbox).toBe(false); - }); + beforeEach(inject(function($injector) { + $compile = $injector.get('$compile'); + scope = $injector.get('$rootScope'); + })); + afterEach(function() { + dealoc(formElm); + }); - it('should allow custom enumeration', function() { - compile('<input type="checkbox" ng:model="name" ng:true-value="y" ng:false-value="n">'); - scope.name='y'; - scope.$digest(); - expect(element[0].checked).toBe(true); + it('should bind to a model', function() { + compileInput('<input type="text" ng:model="name" name="alias" ng:change="change()" />'); - scope.name='n'; - scope.$digest(); - expect(element[0].checked).toBe(false); + scope.$apply(function() { + scope.name = 'misko'; + }); - scope.name='abc'; - scope.$digest(); - expect(element[0].checked).toBe(false); + expect(inputElm.val()).toBe('misko'); + }); - browserTrigger(element); - expect(scope.name).toEqual('y'); - browserTrigger(element); - expect(scope.name).toEqual('n'); - }); + it('should call $destroy on element remove', function() { + compileInput('<input type="text" ng:model="name" name="alias" ng:change="change()" />'); + var spy = jasmine.createSpy('on destroy'); + scope.$on('$destroy', spy); - it('should fire ng:change when the value changes', function() { - compile('<input type="checkbox" ng:model="foo" ng:change="changeFn()">'); - scope.changeFn = jasmine.createSpy('changeFn'); - scope.$digest(); - expect(scope.changeFn).not.toHaveBeenCalledOnce(); - browserTrigger(element); - expect(scope.changeFn).toHaveBeenCalledOnce(); - }); - }); + inputElm.remove(); + expect(spy).toHaveBeenCalled(); + }); + + + it('should update the model on "blur" event', function() { + compileInput('<input type="text" ng:model="name" name="alias" ng:change="change()" />'); + + changeInputValueTo('adam'); + expect(scope.name).toEqual('adam'); + }); + + + it('should update the model and trim the value', function() { + compileInput('<input type="text" ng:model="name" name="alias" ng:change="change()" />'); + + changeInputValueTo(' a '); + expect(scope.name).toEqual('a'); + }); + + + it('should allow complex reference binding', function() { + compileInput('<input type="text" ng:model="obj[\'abc\'].name"/>'); + + scope.$apply(function() { + scope.obj = { abc: { name: 'Misko'} }; }); + expect(inputElm.val()).toEqual('Misko'); + }); - it("should process required", inject(function($formFactory) { - compile('<input type="text" ng:model="price" name="p" required/>', jqLite(document.body)); - expect(element.hasClass('ng-invalid')).toBeTruthy(); + it('should ignore input without ng:model attr', function() { + compileInput('<input type="text" name="whatever" required />'); - scope.price = 'xxx'; + browserTrigger(inputElm, 'blur'); + expect(inputElm.hasClass('ng-valid')).toBe(false); + expect(inputElm.hasClass('ng-invalid')).toBe(false); + expect(inputElm.hasClass('ng-pristine')).toBe(false); + expect(inputElm.hasClass('ng-dirty')).toBe(false); + }); + + + it('should report error on assignment error', function() { + expect(function() { + compileInput('<input type="text" ng:model="throw \'\'">'); scope.$digest(); - expect(element.hasClass('ng-invalid')).toBeFalsy(); + }).toThrow("Syntax Error: Token '''' is an unexpected token at column 7 of the expression [throw ''] starting at ['']."); + }); - element.val(''); - browserTrigger(element); - defer.flush(); - expect(element.hasClass('ng-invalid')).toBeTruthy(); - })); + it("should render as blank if null", function() { + compileInput('<input type="text" ng:model="age" />'); - it('should allow bindings on ng:required', function() { - compile('<input type="text" ng:model="price" ng:required="{{required}}"/>', - jqLite(document.body)); - scope.price = ''; - scope.required = false; + scope.$apply(function() { + scope.age = null; + }); + + expect(scope.age).toBeNull(); + expect(inputElm.val()).toEqual(''); + }); + + + describe('pattern', function() { + + it('should validate in-lined pattern', function() { + compileInput('<input type="text" ng:model="value" ng:pattern="/^\\d\\d\\d-\\d\\d-\\d\\d\\d\\d$/" />'); scope.$digest(); - expect(element).toBeValid(); - scope.price = 'xxx'; + changeInputValueTo('x000-00-0000x'); + expect(inputElm).toBeInvalid(); + + changeInputValueTo('000-00-0000'); + expect(inputElm).toBeValid(); + + changeInputValueTo('000-00-0000x'); + expect(inputElm).toBeInvalid(); + + changeInputValueTo('123-45-6789'); + expect(inputElm).toBeValid(); + + changeInputValueTo('x'); + expect(inputElm).toBeInvalid(); + }); + + + it('should validate pattern from scope', function() { + compileInput('<input type="text" ng:model="value" ng:pattern="regexp" />'); + scope.regexp = /^\d\d\d-\d\d-\d\d\d\d$/; scope.$digest(); - expect(element).toBeValid(); - scope.price = ''; - scope.required = true; + changeInputValueTo('x000-00-0000x'); + expect(inputElm).toBeInvalid(); + + changeInputValueTo('000-00-0000'); + expect(inputElm).toBeValid(); + + changeInputValueTo('000-00-0000x'); + expect(inputElm).toBeInvalid(); + + changeInputValueTo('123-45-6789'); + expect(inputElm).toBeValid(); + + changeInputValueTo('x'); + expect(inputElm).toBeInvalid(); + + scope.regexp = /abc?/; + + changeInputValueTo('ab'); + expect(inputElm).toBeValid(); + + changeInputValueTo('xx'); + expect(inputElm).toBeInvalid(); + }); + + + xit('should throw an error when scope pattern can\'t be found', function() { + compileInput('<input type="text" ng:model="foo" ng:pattern="fooRegexp" />'); + + expect(function() { changeInputValueTo('xx'); }). + toThrow('Expected fooRegexp to be a RegExp but was undefined'); + }); + }); + + + describe('minlength', function() { + + it('should invalid shorter than given minlenght', function() { + compileInput('<input type="text" ng:model="value" ng:minlength="3" />'); + + changeInputValueTo('aa'); + expect(scope.value).toBeUndefined(); + + changeInputValueTo('aaa'); + expect(scope.value).toBe('aaa'); + }); + }); + + + describe('maxlength', function() { + + it('should invalid shorter than given maxlenght', function() { + compileInput('<input type="text" ng:model="value" ng:maxlength="5" />'); + + changeInputValueTo('aaaaaaaa'); + expect(scope.value).toBeUndefined(); + + changeInputValueTo('aaa'); + expect(scope.value).toBe('aaa'); + }); + }); + + + // INPUT TYPES + + describe('number', function() { + + it('should not update model if view invalid', function() { + compileInput('<input type="number" ng:model="age"/>'); + + scope.$apply(function() { + scope.age = 123; + }); + expect(inputElm.val()).toBe('123'); + + try { + // to allow non-number values, we have to change type so that + // the browser which have number validation will not interfere with + // this test. IE8 won't allow it hence the catch. + inputElm[0].setAttribute('type', 'text'); + } catch (e) {} + + changeInputValueTo('123X'); + expect(inputElm.val()).toBe('123X'); + expect(scope.age).toBe(123); + expect(inputElm).toBeInvalid(); + }); + + + it('should render as blank if null', function() { + compileInput('<input type="number" ng:model="age" />'); + + scope.$apply(function() { + scope.age = null; + }); + + expect(scope.age).toBeNull(); + expect(inputElm.val()).toEqual(''); + }); + + + it('should come up blank when no value specified', function() { + compileInput('<input type="number" ng:model="age" />'); + scope.$digest(); - expect(element).toBeInvalid(); + expect(inputElm.val()).toBe(''); + + scope.$apply(function() { + scope.age = null; + }); - element.val('abc'); - browserTrigger(element); - defer.flush(); - expect(element).toBeValid(); + expect(scope.age).toBeNull(); + expect(inputElm.val()).toBe(''); }); - describe('textarea', function() { - it("should process textarea", function() { - compile('<textarea ng:model="name"></textarea>'); + it('should parse empty string to null', function() { + compileInput('<input type="number" ng:model="age" />'); - scope.name = 'Adam'; + scope.$apply(function() { + scope.age = 10; + }); + + changeInputValueTo(''); + expect(scope.age).toBeNull(); + expect(inputElm).toBeValid(); + }); + + + describe('min', function() { + + it('should validate', function() { + compileInput('<input type="number" ng:model="value" name="alias" min="10" />'); scope.$digest(); - expect(element.val()).toEqual("Adam"); - element.val('Shyam'); - browserTrigger(element); - defer.flush(); - expect(scope.name).toEqual('Shyam'); + changeInputValueTo('1'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeFalsy(); + expect(scope.form.alias.error.MIN).toBeTruthy(); - element.val('Kai'); - browserTrigger(element); - defer.flush(); - expect(scope.name).toEqual('Kai'); + changeInputValueTo('100'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(100); + expect(scope.form.alias.error.MIN).toBeFalsy(); }); }); - describe('radio', function() { - it('should support type="radio"', function() { - compile('<div>' + - '<input type="radio" name="r" ng:model="chose" value="A"/>' + - '<input type="radio" name="r" ng:model="chose" value="B"/>' + - '<input type="radio" name="r" ng:model="chose" value="C"/>' + - '</div>'); - var a = element[0].childNodes[0]; - var b = element[0].childNodes[1]; - expect(b.name.split('@')[1]).toEqual('r'); - scope.chose = 'A'; - scope.$digest(); - expect(a.checked).toBe(true); + describe('max', function() { - scope.chose = 'B'; + it('should validate', function() { + compileInput('<input type="number" ng:model="value" name="alias" max="10" />'); scope.$digest(); - expect(a.checked).toBe(false); - expect(b.checked).toBe(true); - expect(scope.clicked).not.toBeDefined(); - browserTrigger(a); - expect(scope.chose).toEqual('A'); + changeInputValueTo('20'); + expect(inputElm).toBeInvalid(); + expect(scope.value).toBeFalsy(); + expect(scope.form.alias.error.MAX).toBeTruthy(); + + changeInputValueTo('0'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(scope.form.alias.error.MAX).toBeFalsy(); }); + }); - it('should honor model over html checked keyword after', function() { - compile('<div ng:init="choose=\'C\'">' + - '<input type="radio" ng:model="choose" value="A""/>' + - '<input type="radio" ng:model="choose" value="B" checked/>' + - '<input type="radio" ng:model="choose" value="C"/>' + - '</div>'); + describe('required', function() { - expect(scope.choose).toEqual('C'); - var inputs = element.find('input'); - expect(inputs[1].checked).toBe(false); - expect(inputs[2].checked).toBe(true); + it('should be valid even if value is 0', function() { + compileInput('<input type="number" ng:model="value" name="alias" required />'); + + changeInputValueTo('0'); + expect(inputElm).toBeValid(); + expect(scope.value).toBe(0); + expect(scope.form.alias.error.REQUIRED).toBeFalsy(); }); + it('should be valid even if value 0 is set from model', function() { + compileInput('<input type="number" ng:model="value" name="alias" required />'); - it('should honor model over html checked keyword before', function() { - compile('<div ng:init="choose=\'A\'">' + - '<input type="radio" ng:model="choose" value="A""/>' + - '<input type="radio" ng:model="choose" value="B" checked/>' + - '<input type="radio" ng:model="choose" value="C"/>' + - '</div>'); + scope.$apply(function() { + scope.value = 0; + }); - expect(scope.choose).toEqual('A'); - var inputs = element.find('input'); - expect(inputs[0].checked).toBe(true); - expect(inputs[1].checked).toBe(false); + expect(inputElm).toBeValid(); + expect(inputElm.val()).toBe('0') + expect(scope.form.alias.error.REQUIRED).toBeFalsy(); }); + }); + }); + describe('email', function() { - it('it should work with value attribute that is data-bound', function(){ - compile( - '<li>'+ - '<input ng:repeat="item in [\'a\', \'b\']" ' + - ' type="radio" ng:model="choice" value="{{item}}" name="choice">'+ - '</li>'); + it('should validate e-mail', function() { + compileInput('<input type="email" ng:model="email" name="alias" />'); - var inputs = element.find('input'); - expect(inputs[0].checked).toBe(false); - expect(inputs[1].checked).toBe(false); + var widget = scope.form.alias; + changeInputValueTo('vojta@google.com'); - scope.choice = 'b'; - scope.$digest(); - expect(inputs[0].checked).toBe(false); - expect(inputs[1].checked).toBe(true); + expect(scope.email).toBe('vojta@google.com'); + expect(inputElm).toBeValid(); + expect(widget.error.EMAIL).toBeUndefined(); + + changeInputValueTo('invalid@'); + expect(scope.email).toBe('vojta@google.com'); + expect(inputElm).toBeInvalid(); + expect(widget.error.EMAIL).toBeTruthy(); + }); + + + describe('EMAIL_REGEXP', function() { + + it('should validate email', function() { + expect(EMAIL_REGEXP.test('a@b.com')).toBe(true); + expect(EMAIL_REGEXP.test('a@B.c')).toBe(false); }); + }); + }); + + + describe('url', function() { - it('should data-bind the value attribute on initialization', inject( - function($rootScope, $compile){ - $rootScope.choice = 'b'; - $rootScope.items = ['a', 'b']; - element = $compile( - '<li>'+ - '<input ng:repeat="item in items" ' + - ' type="radio" ng:model="choice" value="{{item}}" name="choice">'+ - '</li>')($rootScope); + it('should validate url', function() { + compileInput('<input type="url" ng:model="url" name="alias" />'); + var widget = scope.form.alias; - $rootScope.$digest(); - var inputs = element.find('input'); - expect(inputs[0].checked).toBe(false); - expect(inputs[1].checked).toBe(true); - })); + changeInputValueTo('http://www.something.com'); + expect(scope.url).toBe('http://www.something.com'); + expect(inputElm).toBeValid(); + expect(widget.error.URL).toBeUndefined(); + + changeInputValueTo('invalid.com'); + expect(scope.url).toBe('http://www.something.com'); + expect(inputElm).toBeInvalid(); + expect(widget.error.URL).toBeTruthy(); }); - describe('password', function () { - it('should not change password type to text', function () { - compile('<input type="password" ng:model="name" >'); - expect(element.attr('type')).toBe('password'); + describe('URL_REGEXP', function() { + + it('should validate url', function() { + expect(URL_REGEXP.test('http://server:123/path')).toBe(true); + expect(URL_REGEXP.test('a@B.c')).toBe(false); }); }); + }); + - describe('number', function(){ - it('should clear number on non-number', inject(function($compile, $rootScope){ - $rootScope.value = 123; - var element = $compile('<input type="number" ng:model="value" >')($rootScope); - $rootScope.$digest(); - expect(element.val()).toEqual('123'); - $rootScope.value = undefined; - $rootScope.$digest(); - expect(element.val()).toEqual(''); - dealoc(element); - })); + describe('radio', function() { + + it('should update the model', function() { + compileInput( + '<input type="radio" ng:model="color" value="white" />' + + '<input type="radio" ng:model="color" value="red" />' + + '<input type="radio" ng:model="color" value="blue" />'); + + scope.$apply(function() { + scope.color = 'white'; + }); + expect(inputElm[0].checked).toBe(true); + expect(inputElm[1].checked).toBe(false); + expect(inputElm[2].checked).toBe(false); + + scope.$apply(function() { + scope.color = 'red'; + }); + expect(inputElm[0].checked).toBe(false); + expect(inputElm[1].checked).toBe(true); + expect(inputElm[2].checked).toBe(false); + + browserTrigger(inputElm[2]); + expect(scope.color).toBe('blue'); }); - it('should ignore text widget which have no name', function() { - compile('<input type="text"/>'); - expect(element.attr('ng-exception')).toBeFalsy(); - expect(element.hasClass('ng-exception')).toBeFalsy(); + // TODO(vojta): change interpolate ? + xit('should allow {{expr}} as value', function() { + scope.some = 11; + compileInput( + '<input type="radio" ng:model="value" value="{{some}}" />' + + '<input type="radio" ng:model="value" value="{{other}}" />'); + + browserTrigger(inputElm[0]); + expect(scope.value).toBe(true); + + browserTrigger(inputElm[1]); + expect(scope.value).toBe(false); }); + }); - it('should ignore checkbox widget which have no name', function() { - compile('<input type="checkbox"/>'); - expect(element.attr('ng-exception')).toBeFalsy(); - expect(element.hasClass('ng-exception')).toBeFalsy(); + describe('checkbox', function() { + + it('should ignore checkbox without ng:model attr', function() { + compileInput('<input type="checkbox" name="whatever" required />'); + + browserTrigger(inputElm, 'blur'); + expect(inputElm.hasClass('ng-valid')).toBe(false); + expect(inputElm.hasClass('ng-invalid')).toBe(false); + expect(inputElm.hasClass('ng-pristine')).toBe(false); + expect(inputElm.hasClass('ng-dirty')).toBe(false); }); - it('should report error on assignment error', inject(function($log) { - expect(function() { - compile('<input type="text" ng:model="throw \'\'">'); - }).toThrow("Syntax Error: Token '''' is an unexpected token at column 7 of the expression [throw ''] starting at ['']."); - $log.error.logs.shift(); - })); + it('should format booleans', function() { + compileInput('<input type="checkbox" ng:model="name" />'); + + scope.$apply(function() { + scope.name = false; + }); + expect(inputElm[0].checked).toBe(false); + + scope.$apply(function() { + scope.name = true; + }); + expect(inputElm[0].checked).toBe(true); + }); + + + it('should support type="checkbox" with non-standard capitalization', function() { + compileInput('<input type="checkBox" ng:model="checkbox" />'); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(true); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(false); + }); + + + it('should allow custom enumeration', function() { + compileInput('<input type="checkbox" ng:model="name" ng:true-value="y" ' + + 'ng:false-value="n">'); + + scope.$apply(function() { + scope.name = 'y'; + }); + expect(inputElm[0].checked).toBe(true); + + scope.$apply(function() { + scope.name = 'n'; + }); + expect(inputElm[0].checked).toBe(false); + + scope.$apply(function() { + scope.name = 'something else'; + }); + expect(inputElm[0].checked).toBe(false); + + browserTrigger(inputElm, 'click'); + expect(scope.name).toEqual('y'); + + browserTrigger(inputElm, 'click'); + expect(scope.name).toEqual('n'); + }); }); - describe('scope declaration', function() { - it('should read the declaration from scope', inject(function($rootScope, $compile, $formFactory) { - var input, formFactory; - var element = angular.element('<input type="@MyType" ng:model="abc">'); - $rootScope.MyType = function($f, i) { - input = i; - formFactory = $f; - }; - $rootScope.MyType.$inject = ['$formFactory', '$element']; + describe('textarea', function() { - $compile(element)($rootScope); + it("should process textarea", function() { + compileInput('<textarea ng:model="name"></textarea>'); + inputElm = formElm.find('textarea'); - expect(formFactory).toBe($formFactory); - expect(input[0]).toBe(element[0]); - dealoc(element); - })); + scope.$apply(function() { + scope.name = 'Adam'; + }); + expect(inputElm.val()).toEqual('Adam'); - it('should throw an error of Controller not declared in scope', inject(function($rootScope, $compile) { - var input; - var element = angular.element('<input type="@DontExist" ng:model="abc">'); - var error; - try { - $compile(element)($rootScope); - error = 'no error thrown'; - } catch (e) { - error = e; - } - expect(error.message).toEqual("Argument 'DontExist' is not a function, got undefined"); - })); + changeInputValueTo('Shyam'); + expect(scope.name).toEqual('Shyam'); + + changeInputValueTo('Kai'); + expect(scope.name).toEqual('Kai'); + }); + + + it('should ignore textarea without ng:model attr', function() { + compileInput('<textarea name="whatever" required></textarea>'); + inputElm = formElm.find('textarea'); + + browserTrigger(inputElm, 'blur'); + expect(inputElm.hasClass('ng-valid')).toBe(false); + expect(inputElm.hasClass('ng-invalid')).toBe(false); + expect(inputElm.hasClass('ng-pristine')).toBe(false); + expect(inputElm.hasClass('ng-dirty')).toBe(false); + }); }); - describe('text subtypes', function() { + describe('ng:list', function() { - function itShouldVerify(type, validList, invalidList, params, fn) { - describe(type, function() { - forEach(validList, function(value){ - it('should validate "' + value + '"', function() { - setup(value); - expect(element).toBeValid(); - }); - }); - forEach(invalidList, function(value){ - it('should NOT validate "' + value + '"', function() { - setup(value); - expect(element).toBeInvalid(); - }); - }); + it('should parse text into an array', function() { + compileInput('<input type="text" ng:model="list" ng:list />'); - function setup(value){ - var html = ['<input type="', type.split(' ')[0], '" ']; - forEach(params||{}, function(value, key){ - html.push(key + '="' + value + '" '); - }); - html.push('ng:model="value">'); - compile(html.join('')); - (fn||noop)(scope); - scope.value = null; - try { - // to allow non-number values, we have to change type so that - // the browser which have number validation will not interfere with - // this test. IE8 won't allow it hence the catch. - element[0].setAttribute('type', 'text'); - } catch (e){} - if (value != undefined) { - element.val(value); - browserTrigger(element, 'keydown'); - defer.flush(); - } - scope.$digest(); - } + // model -> view + scope.$apply(function() { + scope.list = ['x', 'y', 'z']; }); - } + expect(inputElm.val()).toBe('x, y, z'); + // view -> model + changeInputValueTo('1, 2, 3'); + expect(scope.list).toEqual(['1', '2', '3']); + }); - itShouldVerify('email', ['a@b.com'], ['a@B.c']); + it("should not clobber text if model changes due to itself", function() { + // When the user types 'a,b' the 'a,' stage parses to ['a'] but if the + // $parseModel function runs it will change to 'a', in essence preventing + // the user from ever typying ','. + compileInput('<input type="text" ng:model="list" ng:list />'); - itShouldVerify('url', ['http://server:123/path'], ['a@b.c']); + changeInputValueTo('a '); + expect(inputElm.val()).toEqual('a '); + expect(scope.list).toEqual(['a']); + changeInputValueTo('a ,'); + expect(inputElm.val()).toEqual('a ,'); + expect(scope.list).toEqual(['a']); - itShouldVerify('number', - ['', '1', '12.34', '-4', '+13', '.1'], - ['x', '12b', '-6', '101'], - {min:-5, max:100}); + changeInputValueTo('a , '); + expect(inputElm.val()).toEqual('a , '); + expect(scope.list).toEqual(['a']); + changeInputValueTo('a , b'); + expect(inputElm.val()).toEqual('a , b'); + expect(scope.list).toEqual(['a', 'b']); + }); - itShouldVerify('integer', - [null, '', '1', '12', '-4', '+13'], - ['x', '12b', '-6', '101', '1.', '1.2'], - {min:-5, max:100}); + xit('should require at least one item', function() { + compileInput('<input type="text" ng:model="list" ng:list required />'); - itShouldVerify('integer', - [null, '', '0', '1'], - ['-1', '2'], - {min:0, max:1}); + changeInputValueTo(' , '); + expect(inputElm).toBeInvalid(); + }); - itShouldVerify('text with inlined pattern constraint', - ['', '000-00-0000', '123-45-6789'], - ['x000-00-0000x', 'x000-00-0000', '000-00-0000x', 'x'], - {'ng:pattern':'/^\\d\\d\\d-\\d\\d-\\d\\d\\d\\d$/'}); + it('should convert empty string to an empty array', function() { + compileInput('<input type="text" ng:model="list" ng:list />'); + changeInputValueTo(''); + expect(scope.list).toEqual([]); + }); + }); + + describe('required', function() { + + it('should allow bindings on required', function() { + compileInput('<input type="text" ng:model="value" required="{{required}}" />'); + + scope.$apply(function() { + scope.required = false; + }); + + changeInputValueTo(''); + expect(inputElm).toBeValid(); + + + scope.$apply(function() { + scope.required = true; + }); + expect(inputElm).toBeInvalid(); + + scope.$apply(function() { + scope.value = 'some'; + }); + expect(inputElm).toBeValid(); + + changeInputValueTo(''); + expect(inputElm).toBeInvalid(); + + scope.$apply(function() { + scope.required = false; + }); + expect(inputElm).toBeValid(); + }); + + + it('should invalid initial value with bound required', function() { + compileInput('<input type="text" ng:model="value" required="{{required}}" />'); + + scope.$apply(function() { + scope.required = true; + }); + + expect(inputElm).toBeInvalid(); + }); + + + it('should be $invalid but $pristine if not touched', function() { + compileInput('<input type="text" ng:model="name" name="alias" required />'); + + scope.$apply(function() { + scope.name = ''; + }); + + expect(inputElm).toBeInvalid(); + expect(inputElm).toBePristine(); + + changeInputValueTo(''); + expect(inputElm).toBeInvalid(); + expect(inputElm).toBeDirty(); + }); + + + it('should allow empty string if not required', function() { + compileInput('<input type="text" ng:model="foo" />'); + changeInputValueTo('a'); + changeInputValueTo(''); + expect(scope.foo).toBe(''); + }); + + + it('should set $invalid when model undefined', function() { + compileInput('<input type="text" ng:model="notDefiend" required />'); + scope.$digest(); + expect(inputElm).toBeInvalid(); + }) + }); + + + describe('ng:change', function() { + + it('should $eval expression after new value is set in the model', function() { + compileInput('<input type="text" ng:model="value" ng:change="change()" />'); + + scope.change = jasmine.createSpy('change').andCallFake(function() { + expect(scope.value).toBe('new value'); + }); + + changeInputValueTo('new value'); + expect(scope.change).toHaveBeenCalledOnce(); + }); + + it('should not $eval the expression if changed from model', function() { + compileInput('<input type="text" ng:model="value" ng:change="change()" />'); + + scope.change = jasmine.createSpy('change'); + scope.$apply(function() { + scope.value = true; + }); + + expect(scope.change).not.toHaveBeenCalled(); + }); + + + it('should $eval ng:change expression on checkbox', function() { + compileInput('<input type="checkbox" ng:model="foo" ng:change="changeFn()">'); + + scope.changeFn = jasmine.createSpy('changeFn'); + scope.$digest(); + expect(scope.changeFn).not.toHaveBeenCalled(); + + browserTrigger(inputElm, 'click'); + expect(scope.changeFn).toHaveBeenCalledOnce(); + }); + }); - itShouldVerify('text with pattern constraint on scope', - ['', '000-00-0000', '123-45-6789'], - ['x000-00-0000x', 'x'], - {'ng:pattern':'regexp'}, function(scope){ - scope.regexp = /^\d\d\d-\d\d-\d\d\d\d$/; - }); + describe('ng:bind-change', function() { - itShouldVerify('text with ng:minlength limit', - ['', 'aaa', 'aaaaa', 'aaaaaaaaa'], - ['a', 'aa'], - {'ng:minlength': 3}); + it('should bind keydown, change, input events', inject(function($browser) { + compileInput('<input type="text" ng:model="value" ng:bind-immediate />'); + inputElm.val('value1'); + browserTrigger(inputElm, 'keydown'); - itShouldVerify('text with ng:maxlength limit', - ['', 'a', 'aa', 'aaa'], - ['aaaa', 'aaaaa', 'aaaaaaaaa'], - {'ng:maxlength': 3}); + // should be async (because of keydown) + expect(scope.value).toBeUndefined(); + $browser.defer.flush(); + expect(scope.value).toBe('value1'); - it('should throw an error when scope pattern can\'t be found', inject(function($rootScope, $compile) { - var el = jqLite('<input ng:model="foo" ng:pattern="fooRegexp">'); - $compile(el)($rootScope); + inputElm.val('value2'); + browserTrigger(inputElm, 'change'); + $browser.defer.flush(); + expect(scope.value).toBe('value2'); - el.val('xx'); - browserTrigger(el, 'keydown'); - expect(function() { defer.flush(); }). - toThrow('Expected fooRegexp to be a RegExp but was undefined'); + if (msie < 9) return; - dealoc(el); + inputElm.val('value3'); + browserTrigger(inputElm, 'input'); + $browser.defer.flush(); + expect(scope.value).toBe('value3'); })); }); }); diff --git a/test/widget/selectSpec.js b/test/widget/selectSpec.js index 00bc2192..9db47c05 100644 --- a/test/widget/selectSpec.js +++ b/test/widget/selectSpec.js @@ -1,60 +1,69 @@ 'use strict'; describe('select', function() { - var compile = null, element = null, scope = null; + var scope, formElement, element, $compile; - beforeEach(inject(function($compile, $rootScope) { + function compile(html) { + formElement = jqLite('<form name="form">' + html + '</form>'); + element = formElement.find('select'); + $compile(formElement)(scope); + scope.$apply(); + } + + beforeEach(inject(function($injector, $rootScope) { scope = $rootScope; - element = null; - compile = function(html, parent) { - if (parent) { - parent.html(html); - element = parent.children(); - } else { - element = jqLite(html); - } - element = $compile(element)($rootScope); - scope.$apply(); - return scope; - }; + $compile = $injector.get('$compile'); + formElement = element = null; })); afterEach(function() { - dealoc(element); + dealoc(formElement); }); describe('select-one', function() { - it('should compile children of a select without a name, but not create a model for it', + it('should compile children of a select without a ng:model, but not create a model for it', function() { compile('<select>' + '<option selected="true">{{a}}</option>' + '<option value="">{{b}}</option>' + '<option>C</option>' + '</select>'); - scope.a = 'foo'; - scope.b = 'bar'; - scope.$digest(); + scope.$apply(function() { + scope.a = 'foo'; + scope.b = 'bar'; + }); expect(element.text()).toBe('foobarC'); }); - it('should require', inject(function($formFactory) { - compile('<select name="select" ng:model="selection" required ng:change="log=log+\'change;\'">' + + + it('should require', function() { + compile( + '<select name="select" ng:model="selection" required ng:change="change()">' + '<option value=""></option>' + '<option value="c">C</option>' + '</select>'); - scope.log = ''; - scope.selection = 'c'; - scope.$digest(); - expect($formFactory.forElement(element).select.$error.REQUIRED).toEqual(undefined); + + scope.change = function() { + scope.log += 'change;'; + }; + + scope.$apply(function() { + scope.log = ''; + scope.selection = 'c'; + }); + + expect(scope.form.select.error.REQUIRED).toBeFalsy(); expect(element).toBeValid(); expect(element).toBePristine(); - scope.selection = ''; - scope.$digest(); - expect($formFactory.forElement(element).select.$error.REQUIRED).toEqual(true); + scope.$apply(function() { + scope.selection = ''; + }); + + expect(scope.form.select.error.REQUIRED).toBeTruthy(); expect(element).toBeInvalid(); expect(element).toBePristine(); expect(scope.log).toEqual(''); @@ -64,10 +73,12 @@ describe('select', function() { expect(element).toBeValid(); expect(element).toBeDirty(); expect(scope.log).toEqual('change;'); - })); + }); + it('should not be invalid if no require', function() { - compile('<select name="select" ng:model="selection">' + + compile( + '<select name="select" ng:model="selection">' + '<option value=""></option>' + '<option value="c">C</option>' + '</select>'); @@ -75,35 +86,45 @@ describe('select', function() { expect(element).toBeValid(); expect(element).toBePristine(); }); - }); describe('select-multiple', function() { + it('should support type="select-multiple"', function() { - compile('<select ng:model="selection" multiple>' + - '<option>A</option>' + - '<option>B</option>' + - '</select>'); - scope.selection = ['A']; - scope.$digest(); + compile( + '<select ng:model="selection" multiple>' + + '<option>A</option>' + + '<option>B</option>' + + '</select>'); + + scope.$apply(function() { + scope.selection = ['A']; + }); + expect(element[0].childNodes[0].selected).toEqual(true); }); - it('should require', inject(function($formFactory) { - compile('<select name="select" ng:model="selection" multiple required>' + + + it('should require', function() { + compile( + '<select name="select" ng:model="selection" multiple required>' + '<option>A</option>' + '<option>B</option>' + '</select>'); - scope.selection = []; - scope.$digest(); - expect($formFactory.forElement(element).select.$error.REQUIRED).toEqual(true); + scope.$apply(function() { + scope.selection = []; + }); + + expect(scope.form.select.error.REQUIRED).toBeTruthy(); expect(element).toBeInvalid(); expect(element).toBePristine(); - scope.selection = ['A']; - scope.$digest(); + scope.$apply(function() { + scope.selection = ['A']; + }); + expect(element).toBeValid(); expect(element).toBePristine(); @@ -111,17 +132,14 @@ describe('select', function() { browserTrigger(element, 'change'); expect(element).toBeValid(); expect(element).toBeDirty(); - })); - + }); }); describe('ng:options', function() { - var select, scope; - - function createSelect(attrs, blank, unknown){ + function createSelect(attrs, blank, unknown) { var html = '<select'; - forEach(attrs, function(value, key){ + forEach(attrs, function(value, key) { if (isBoolean(value)) { if (value) html += ' ' + key; } else { @@ -132,18 +150,18 @@ describe('select', function() { (blank ? (isString(blank) ? blank : '<option value="">blank</option>') : '') + (unknown ? (isString(unknown) ? unknown : '<option value="?">unknown</option>') : '') + '</select>'; - select = jqLite(html); - scope = compile(select); + + compile(html); } - function createSingleSelect(blank, unknown){ + function createSingleSelect(blank, unknown) { createSelect({ 'ng:model':'selected', 'ng:options':'value.name for value in values' }, blank, unknown); } - function createMultiSelect(blank, unknown){ + function createMultiSelect(blank, unknown) { createSelect({ 'ng:model':'selected', 'multiple':true, @@ -151,366 +169,474 @@ describe('select', function() { }, blank, unknown); } - afterEach(function() { - dealoc(select); - dealoc(scope); - }); - it('should throw when not formated "? for ? in ?"', inject(function($rootScope, $exceptionHandler) { + it('should throw when not formated "? for ? in ?"', function() { expect(function() { compile('<select ng:model="selected" ng:options="i dont parse"></select>'); }).toThrow("Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in" + " _collection_' but got 'i dont parse'."); - })); + }); + it('should render a list', function() { createSingleSelect(); - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - scope.$digest(); - var options = select.find('option'); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[0]; + }); + + var options = element.find('option'); expect(options.length).toEqual(3); expect(sortedHtml(options[0])).toEqual('<option value="0">A</option>'); expect(sortedHtml(options[1])).toEqual('<option value="1">B</option>'); expect(sortedHtml(options[2])).toEqual('<option value="2">C</option>'); }); + it('should render an object', function() { createSelect({ - 'ng:model':'selected', + 'ng:model': 'selected', 'ng:options': 'value as key for (key, value) in object' }); - scope.object = {'red':'FF0000', 'green':'00FF00', 'blue':'0000FF'}; - scope.selected = scope.object.red; - scope.$digest(); - var options = select.find('option'); + + scope.$apply(function() { + scope.object = {'red': 'FF0000', 'green': '00FF00', 'blue': '0000FF'}; + scope.selected = scope.object.red; + }); + + var options = element.find('option'); expect(options.length).toEqual(3); expect(sortedHtml(options[0])).toEqual('<option value="blue">blue</option>'); expect(sortedHtml(options[1])).toEqual('<option value="green">green</option>'); expect(sortedHtml(options[2])).toEqual('<option value="red">red</option>'); expect(options[2].selected).toEqual(true); - scope.object.azur = '8888FF'; - scope.$digest(); - options = select.find('option'); + scope.$apply(function() { + scope.object.azur = '8888FF'; + }); + + options = element.find('option'); expect(options[3].selected).toEqual(true); }); + it('should grow list', function() { createSingleSelect(); - scope.values = []; - scope.$digest(); - expect(select.find('option').length).toEqual(1); // because we add special empty option - expect(sortedHtml(select.find('option')[0])).toEqual('<option value="?"></option>'); - - scope.values.push({name:'A'}); - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.find('option').length).toEqual(1); - expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); - - scope.values.push({name:'B'}); - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); - expect(sortedHtml(select.find('option')[1])).toEqual('<option value="1">B</option>'); + + scope.$apply(function() { + scope.values = []; + }); + + expect(element.find('option').length).toEqual(1); // because we add special empty option + expect(sortedHtml(element.find('option')[0])).toEqual('<option value="?"></option>'); + + scope.$apply(function() { + scope.values.push({name:'A'}); + scope.selected = scope.values[0]; + }); + + expect(element.find('option').length).toEqual(1); + expect(sortedHtml(element.find('option')[0])).toEqual('<option value="0">A</option>'); + + scope.$apply(function() { + scope.values.push({name:'B'}); + }); + + expect(element.find('option').length).toEqual(2); + expect(sortedHtml(element.find('option')[0])).toEqual('<option value="0">A</option>'); + expect(sortedHtml(element.find('option')[1])).toEqual('<option value="1">B</option>'); }); + it('should shrink list', function() { createSingleSelect(); - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.find('option').length).toEqual(3); - - scope.values.pop(); - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); - expect(sortedHtml(select.find('option')[1])).toEqual('<option value="1">B</option>'); - - scope.values.pop(); - scope.$digest(); - expect(select.find('option').length).toEqual(1); - expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); - - scope.values.pop(); - scope.selected = null; - scope.$digest(); - expect(select.find('option').length).toEqual(1); // we add back the special empty option + + scope.$apply(function() { + scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; + scope.selected = scope.values[0]; + }); + + expect(element.find('option').length).toEqual(3); + + scope.$apply(function() { + scope.values.pop(); + }); + + expect(element.find('option').length).toEqual(2); + expect(sortedHtml(element.find('option')[0])).toEqual('<option value="0">A</option>'); + expect(sortedHtml(element.find('option')[1])).toEqual('<option value="1">B</option>'); + + scope.$apply(function() { + scope.values.pop(); + }); + + expect(element.find('option').length).toEqual(1); + expect(sortedHtml(element.find('option')[0])).toEqual('<option value="0">A</option>'); + + scope.$apply(function() { + scope.values.pop(); + scope.selected = null; + }); + + expect(element.find('option').length).toEqual(1); // we add back the special empty option }); + it('should shrink and then grow list', function() { createSingleSelect(); - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.find('option').length).toEqual(3); - - scope.values = [{name:'1'}, {name:'2'}]; - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.find('option').length).toEqual(2); - - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.find('option').length).toEqual(3); + + scope.$apply(function() { + scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; + scope.selected = scope.values[0]; + }); + + expect(element.find('option').length).toEqual(3); + + scope.$apply(function() { + scope.values = [{name: '1'}, {name: '2'}]; + scope.selected = scope.values[0]; + }); + + expect(element.find('option').length).toEqual(2); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[0]; + }); + + expect(element.find('option').length).toEqual(3); }); + it('should update list', function() { createSingleSelect(); - scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; - scope.selected = scope.values[0]; - scope.$digest(); - - scope.values = [{name:'B'}, {name:'C'}, {name:'D'}]; - scope.selected = scope.values[0]; - scope.$digest(); - var options = select.find('option'); + + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}, {name: 'C'}]; + scope.selected = scope.values[0]; + }); + + scope.$apply(function() { + scope.values = [{name: 'B'}, {name: 'C'}, {name: 'D'}]; + scope.selected = scope.values[0]; + }); + + var options = element.find('option'); expect(options.length).toEqual(3); expect(sortedHtml(options[0])).toEqual('<option value="0">B</option>'); expect(sortedHtml(options[1])).toEqual('<option value="1">C</option>'); expect(sortedHtml(options[2])).toEqual('<option value="2">D</option>'); }); + it('should preserve existing options', function() { createSingleSelect(true); - scope.values = []; - scope.$digest(); - expect(select.find('option').length).toEqual(1); - - scope.values = [{name:'A'}]; - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(jqLite(select.find('option')[0]).text()).toEqual('blank'); - expect(jqLite(select.find('option')[1]).text()).toEqual('A'); - - scope.values = []; - scope.selected = null; - scope.$digest(); - expect(select.find('option').length).toEqual(1); - expect(jqLite(select.find('option')[0]).text()).toEqual('blank'); + scope.$apply(function() { + scope.values = []; + }); + + expect(element.find('option').length).toEqual(1); + + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = scope.values[0]; + }); + + expect(element.find('option').length).toEqual(2); + expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); + expect(jqLite(element.find('option')[1]).text()).toEqual('A'); + + scope.$apply(function() { + scope.values = []; + scope.selected = null; + }); + + expect(element.find('option').length).toEqual(1); + expect(jqLite(element.find('option')[0]).text()).toEqual('blank'); }); + describe('binding', function() { + it('should bind to scope value', function() { createSingleSelect(); - scope.values = [{name:'A'}, {name:'B'}]; - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.val()).toEqual('0'); - scope.selected = scope.values[1]; - scope.$digest(); - expect(select.val()).toEqual('1'); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; + }); + + expect(element.val()).toEqual('0'); + + scope.$apply(function() { + scope.selected = scope.values[1]; + }); + + expect(element.val()).toEqual('1'); }); + it('should bind to scope value and group', function() { createSelect({ - 'ng:model':'selected', - 'ng:options':'item.name group by item.group for item in values' - }); - scope.values = [{name:'A'}, - {name:'B', group:'first'}, - {name:'C', group:'second'}, - {name:'D', group:'first'}, - {name:'E', group:'second'}]; - scope.selected = scope.values[3]; - scope.$digest(); - expect(select.val()).toEqual('3'); + 'ng:model': 'selected', + 'ng:options': 'item.name group by item.group for item in values' + }); + + scope.$apply(function() { + scope.values = [{name: 'A'}, + {name: 'B', group: 'first'}, + {name: 'C', group: 'second'}, + {name: 'D', group: 'first'}, + {name: 'E', group: 'second'}]; + scope.selected = scope.values[3]; + }); + + expect(element.val()).toEqual('3'); - var first = jqLite(select.find('optgroup')[0]); + var first = jqLite(element.find('optgroup')[0]); var b = jqLite(first.find('option')[0]); var d = jqLite(first.find('option')[1]); expect(first.attr('label')).toEqual('first'); expect(b.text()).toEqual('B'); expect(d.text()).toEqual('D'); - var second = jqLite(select.find('optgroup')[1]); + var second = jqLite(element.find('optgroup')[1]); var c = jqLite(second.find('option')[0]); var e = jqLite(second.find('option')[1]); expect(second.attr('label')).toEqual('second'); expect(c.text()).toEqual('C'); expect(e.text()).toEqual('E'); - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.val()).toEqual('0'); + scope.$apply(function() { + scope.selected = scope.values[0]; + }); + + expect(element.val()).toEqual('0'); }); + it('should bind to scope value through experession', function() { - createSelect({'ng:model':'selected', 'ng:options':'item.id as item.name for item in values'}); - scope.values = [{id:10, name:'A'}, {id:20, name:'B'}]; - scope.selected = scope.values[0].id; - scope.$digest(); - expect(select.val()).toEqual('0'); + createSelect({ + 'ng:model': 'selected', + 'ng:options': 'item.id as item.name for item in values' + }); - scope.selected = scope.values[1].id; - scope.$digest(); - expect(select.val()).toEqual('1'); + scope.$apply(function() { + scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; + scope.selected = scope.values[0].id; + }); + + expect(element.val()).toEqual('0'); + + scope.$apply(function() { + scope.selected = scope.values[1].id; + }); + + expect(element.val()).toEqual('1'); }); + it('should bind to object key', function() { createSelect({ - 'ng:model':'selected', - 'ng:options':'key as value for (key, value) in object' + 'ng:model': 'selected', + 'ng:options': 'key as value for (key, value) in object' }); - scope.object = {'red':'FF0000', 'green':'00FF00', 'blue':'0000FF'}; - scope.selected = 'green'; - scope.$digest(); - expect(select.val()).toEqual('green'); - scope.selected = 'blue'; - scope.$digest(); - expect(select.val()).toEqual('blue'); + scope.$apply(function() { + scope.object = {red: 'FF0000', green: '00FF00', blue: '0000FF'}; + scope.selected = 'green'; + }); + + expect(element.val()).toEqual('green'); + + scope.$apply(function() { + scope.selected = 'blue'; + }); + + expect(element.val()).toEqual('blue'); }); + it('should bind to object value', function() { createSelect({ - 'ng:model':'selected', - 'ng:options':'value as key for (key, value) in object' + 'ng:model': 'selected', + 'ng:options': 'value as key for (key, value) in object' }); - scope.object = {'red':'FF0000', 'green':'00FF00', 'blue':'0000FF'}; - scope.selected = '00FF00'; - scope.$digest(); - expect(select.val()).toEqual('green'); - scope.selected = '0000FF'; - scope.$digest(); - expect(select.val()).toEqual('blue'); + scope.$apply(function() { + scope.object = {red: 'FF0000', green: '00FF00', blue:'0000FF'}; + scope.selected = '00FF00'; + }); + + expect(element.val()).toEqual('green'); + + scope.$apply(function() { + scope.selected = '0000FF'; + }); + + expect(element.val()).toEqual('blue'); }); + it('should insert a blank option if bound to null', function() { createSingleSelect(); - scope.values = [{name:'A'}]; - scope.selected = null; - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(select.val()).toEqual(''); - expect(jqLite(select.find('option')[0]).val()).toEqual(''); - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.val()).toEqual('0'); - expect(select.find('option').length).toEqual(1); + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = null; + }); + + expect(element.find('option').length).toEqual(2); + expect(element.val()).toEqual(''); + expect(jqLite(element.find('option')[0]).val()).toEqual(''); + + scope.$apply(function() { + scope.selected = scope.values[0]; + }); + + expect(element.val()).toEqual('0'); + expect(element.find('option').length).toEqual(1); }); + it('should reuse blank option if bound to null', function() { createSingleSelect(true); - scope.values = [{name:'A'}]; - scope.selected = null; - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(select.val()).toEqual(''); - expect(jqLite(select.find('option')[0]).val()).toEqual(''); - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.val()).toEqual('0'); - expect(select.find('option').length).toEqual(2); + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = null; + }); + + expect(element.find('option').length).toEqual(2); + expect(element.val()).toEqual(''); + expect(jqLite(element.find('option')[0]).val()).toEqual(''); + + scope.$apply(function() { + scope.selected = scope.values[0]; + }); + + expect(element.val()).toEqual('0'); + expect(element.find('option').length).toEqual(2); }); + it('should insert a unknown option if bound to something not in the list', function() { createSingleSelect(); - scope.values = [{name:'A'}]; - scope.selected = {}; - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(select.val()).toEqual('?'); - expect(jqLite(select.find('option')[0]).val()).toEqual('?'); - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.val()).toEqual('0'); - expect(select.find('option').length).toEqual(1); + scope.$apply(function() { + scope.values = [{name: 'A'}]; + scope.selected = {}; + }); + + expect(element.find('option').length).toEqual(2); + expect(element.val()).toEqual('?'); + expect(jqLite(element.find('option')[0]).val()).toEqual('?'); + + scope.$apply(function() { + scope.selected = scope.values[0]; + }); + + expect(element.val()).toEqual('0'); + expect(element.find('option').length).toEqual(1); }); + it('should select correct input if previously selected option was "?"', function() { createSingleSelect(); - scope.values = [{name:'A'},{name:'B'}]; - scope.selected = {}; - scope.$digest(); - expect(select.find('option').length).toEqual(3); - expect(select.val()).toEqual('?'); - expect(select.find('option').eq(0).val()).toEqual('?'); - browserTrigger(select.find('option').eq(1)); - expect(select.val()).toEqual('0'); - expect(select.find('option').eq(0).prop('selected')).toBeTruthy(); - expect(select.find('option').length).toEqual(2); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = {}; + }); + + expect(element.find('option').length).toEqual(3); + expect(element.val()).toEqual('?'); + expect(element.find('option').eq(0).val()).toEqual('?'); + + browserTrigger(element.find('option').eq(1)); + expect(element.val()).toEqual('0'); + expect(element.find('option').eq(0).prop('selected')).toBeTruthy(); + expect(element.find('option').length).toEqual(2); }); }); describe('blank option', function () { + it('should be compiled as template, be watched and updated', function () { var option; - createSingleSelect('<option value="">blank is {{blankVal}}</option>'); - scope.blankVal = 'so blank'; - scope.values = [{name:'A'}]; - scope.$digest(); + + scope.$apply(function() { + scope.blankVal = 'so blank'; + scope.values = [{name: 'A'}]; + }); // check blank option is first and is compiled - expect(select.find('option').length == 2); - option = jqLite(select.find('option')[0]); + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); expect(option.val()).toBe(''); expect(option.text()).toBe('blank is so blank'); - // change blankVal and $digest - scope.blankVal = 'not so blank'; - scope.$digest(); + scope.$apply(function() { + scope.blankVal = 'not so blank'; + }); // check blank option is first and is compiled - expect(select.find('option').length == 2); - option = jqLite(select.find('option')[0]); + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); expect(option.val()).toBe(''); expect(option.text()).toBe('blank is not so blank'); }); + it('should support binding via ng:bind-template attribute', function () { var option; - createSingleSelect('<option value="" ng:bind-template="blank is {{blankVal}}"></option>'); - scope.blankVal = 'so blank'; - scope.values = [{name:'A'}]; - scope.$digest(); + + scope.$apply(function() { + scope.blankVal = 'so blank'; + scope.values = [{name: 'A'}]; + }); // check blank option is first and is compiled - expect(select.find('option').length == 2); - option = jqLite(select.find('option')[0]); + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); expect(option.val()).toBe(''); expect(option.text()).toBe('blank is so blank'); }); + it('should support biding via ng:bind attribute', function () { var option; - createSingleSelect('<option value="" ng:bind="blankVal"></option>'); - scope.blankVal = 'is blank'; - scope.values = [{name:'A'}]; - scope.$digest(); + + scope.$apply(function() { + scope.blankVal = 'is blank'; + scope.values = [{name: 'A'}]; + }); // check blank option is first and is compiled - expect(select.find('option').length == 2); - option = jqLite(select.find('option')[0]); + expect(element.find('option').length).toBe(2); + option = element.find('option').eq(0); expect(option.val()).toBe(''); expect(option.text()).toBe('is blank'); }); + it('should be rendered with the attributes preserved', function () { var option; - createSingleSelect('<option value="" class="coyote" id="road-runner" ' + 'custom-attr="custom-attr">{{blankVal}}</option>'); - scope.blankVal = 'is blank'; - scope.$digest(); + + scope.$apply(function() { + scope.blankVal = 'is blank'; + }); // check blank option is first and is compiled - option = jqLite(select.find('option')[0]); + option = element.find('option').eq(0); expect(option.hasClass('coyote')).toBeTruthy(); expect(option.attr('id')).toBe('road-runner'); expect(option.attr('custom-attr')).toBe('custom-attr'); @@ -519,76 +645,101 @@ describe('select', function() { describe('on change', function() { + it('should update model on change', function() { createSingleSelect(); - scope.values = [{name:'A'}, {name:'B'}]; - scope.selected = scope.values[0]; - scope.$digest(); - expect(select.val()).toEqual('0'); - select.val('1'); - browserTrigger(select, 'change'); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; + }); + + expect(element.val()).toEqual('0'); + + element.val('1'); + browserTrigger(element, 'change'); expect(scope.selected).toEqual(scope.values[1]); }); + it('should update model on change through expression', function() { - createSelect({'ng:model':'selected', 'ng:options':'item.id as item.name for item in values'}); - scope.values = [{id:10, name:'A'}, {id:20, name:'B'}]; - scope.selected = scope.values[0].id; - scope.$digest(); - expect(select.val()).toEqual('0'); + createSelect({ + 'ng:model': 'selected', + 'ng:options': 'item.id as item.name for item in values' + }); - select.val('1'); - browserTrigger(select, 'change'); + scope.$apply(function() { + scope.values = [{id: 10, name: 'A'}, {id: 20, name: 'B'}]; + scope.selected = scope.values[0].id; + }); + + expect(element.val()).toEqual('0'); + + element.val('1'); + browserTrigger(element, 'change'); expect(scope.selected).toEqual(scope.values[1].id); }); + it('should update model to null on change', function() { createSingleSelect(true); - scope.values = [{name:'A'}, {name:'B'}]; - scope.selected = scope.values[0]; - select.val('0'); - scope.$digest(); - select.val(''); - browserTrigger(select, 'change'); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = scope.values[0]; + element.val('0'); + }); + + element.val(''); + browserTrigger(element, 'change'); expect(scope.selected).toEqual(null); }); }); + describe('select-many', function() { + it('should read multiple selection', function() { createMultiSelect(); - scope.values = [{name:'A'}, {name:'B'}]; - scope.selected = []; - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(jqLite(select.find('option')[0]).prop('selected')).toBeFalsy(); - expect(jqLite(select.find('option')[1]).prop('selected')).toBeFalsy(); + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = []; + }); - scope.selected.push(scope.values[1]); - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(select.find('option')[0].selected).toEqual(false); - expect(select.find('option')[1].selected).toEqual(true); + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeFalsy(); + expect(element.find('option')[1].selected).toBeFalsy(); - scope.selected.push(scope.values[0]); - scope.$digest(); - expect(select.find('option').length).toEqual(2); - expect(select.find('option')[0].selected).toEqual(true); - expect(select.find('option')[1].selected).toEqual(true); + scope.$apply(function() { + scope.selected.push(scope.values[1]); + }); + + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeFalsy(); + expect(element.find('option')[1].selected).toBeTruthy(); + + scope.$apply(function() { + scope.selected.push(scope.values[0]); + }); + + expect(element.find('option').length).toEqual(2); + expect(element.find('option')[0].selected).toBeTruthy(); + expect(element.find('option')[1].selected).toBeTruthy(); }); + it('should update model on change', function() { createMultiSelect(); - scope.values = [{name:'A'}, {name:'B'}]; - scope.selected = []; - scope.$digest(); - select.find('option')[0].selected = true; + scope.$apply(function() { + scope.values = [{name: 'A'}, {name: 'B'}]; + scope.selected = []; + }); - browserTrigger(select, 'change'); + element.find('option')[0].selected = true; + + browserTrigger(element, 'change'); expect(scope.selected).toEqual([scope.values[0]]); }); @@ -602,16 +753,57 @@ describe('select', function() { scope.selected = ['1']; scope.$digest(); - expect(select.find('option')[1].selected).toBe(true); + expect(element.find('option')[1].selected).toBe(true); - select.find('option')[0].selected = true; - browserTrigger(select, 'change'); + element.find('option')[0].selected = true; + browserTrigger(element, 'change'); expect(scope.selected).toEqual(['0', '1']); - select.find('option')[1].selected = false; - browserTrigger(select, 'change'); + element.find('option')[1].selected = false; + browserTrigger(element, 'change'); expect(scope.selected).toEqual(['0']); }); }); + + + describe('ng:required', function() { + + it('should allow bindings on ng:required', function() { + createSelect({ + 'ng:model': 'value', + 'ng:options': 'item.name for item in values', + 'ng:required': '{{required}}' + }, true); + + + scope.$apply(function() { + scope.values = [{name: 'A', id: 1}, {name: 'B', id: 2}]; + scope.required = false; + }); + + element.val(''); + browserTrigger(element, 'change'); + expect(element).toBeValid(); + + scope.$apply(function() { + scope.required = true; + }); + expect(element).toBeInvalid(); + + scope.$apply(function() { + scope.value = scope.values[0]; + }); + expect(element).toBeValid(); + + element.val(''); + browserTrigger(element, 'change'); + expect(element).toBeInvalid(); + + scope.$apply(function() { + scope.required = false; + }); + expect(element).toBeValid(); + }); + }); }); }); |
