diff --git a/angularFiles.js b/angularFiles.js index f692ceb0..d0b0d8dd 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -53,6 +53,7 @@ var angularFiles = { 'src/ng/directive/form.js', 'src/ng/directive/input.js', 'src/ng/directive/ngBind.js', + 'src/ng/directive/ngChange.js', 'src/ng/directive/ngClass.js', 'src/ng/directive/ngCloak.js', 'src/ng/directive/ngController.js', @@ -61,6 +62,8 @@ var angularFiles = { 'src/ng/directive/ngIf.js', 'src/ng/directive/ngInclude.js', 'src/ng/directive/ngInit.js', + 'src/ng/directive/ngList.js', + 'src/ng/directive/ngModel.js', 'src/ng/directive/ngNonBindable.js', 'src/ng/directive/ngPluralize.js', 'src/ng/directive/ngRepeat.js', @@ -70,7 +73,8 @@ var angularFiles = { 'src/ng/directive/ngTransclude.js', 'src/ng/directive/script.js', 'src/ng/directive/select.js', - 'src/ng/directive/style.js' + 'src/ng/directive/style.js', + 'src/ng/directive/validators.js' ], 'angularLoader': [ diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 24bf1a80..3e689b27 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -1,11 +1,12 @@ 'use strict'; -/* global VALID_CLASS: true, - INVALID_CLASS: true, - PRISTINE_CLASS: true, - DIRTY_CLASS: true, - UNTOUCHED_CLASS: true, - TOUCHED_CLASS: true, +/* global VALID_CLASS: false, + INVALID_CLASS: false, + PRISTINE_CLASS: false, + DIRTY_CLASS: false, + UNTOUCHED_CLASS: false, + TOUCHED_CLASS: false, + $ngModelMinErr: false, */ // Regex code is obtained from SO: https://stackoverflow.com/questions/3143070/javascript-regex-iso-datetime#answer-3143231 @@ -18,9 +19,6 @@ var DATETIMELOCAL_REGEXP = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)(?::(\d\d)(\.\d{ var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/; var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/; -var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; - -var $ngModelMinErr = new minErr('ngModel'); var inputType = { @@ -1522,1344 +1520,6 @@ var inputDirective = ['$browser', '$sniffer', '$filter', '$parse', }; }]; -var VALID_CLASS = 'ng-valid', - INVALID_CLASS = 'ng-invalid', - PRISTINE_CLASS = 'ng-pristine', - DIRTY_CLASS = 'ng-dirty', - UNTOUCHED_CLASS = 'ng-untouched', - TOUCHED_CLASS = 'ng-touched', - PENDING_CLASS = 'ng-pending'; - -/** - * @ngdoc type - * @name ngModel.NgModelController - * - * @property {string} $viewValue Actual string value in the view. - * @property {*} $modelValue The value in the model that the control is bound to. - * @property {Array.} $parsers Array of functions to execute, as a pipeline, whenever - the control reads value from the DOM. The functions are called in array order, each passing - its return value through to the next. The last return value is forwarded to the - {@link ngModel.NgModelController#$validators `$validators`} collection. - -Parsers are used to sanitize / convert the {@link ngModel.NgModelController#$viewValue -`$viewValue`}. - -Returning `undefined` from a parser means a parse error occurred. In that case, -no {@link ngModel.NgModelController#$validators `$validators`} will run and the `ngModel` -will be set to `undefined` unless {@link ngModelOptions `ngModelOptions.allowInvalid`} -is set to `true`. The parse error is stored in `ngModel.$error.parse`. - - * - * @property {Array.} $formatters Array of functions to execute, as a pipeline, whenever - the model value changes. The functions are called in reverse array order, each passing the value through to the - next. The last return value is used as the actual DOM value. - Used to format / convert values for display in the control. - * ```js - * function formatter(value) { - * if (value) { - * return value.toUpperCase(); - * } - * } - * ngModel.$formatters.push(formatter); - * ``` - * - * @property {Object.} $validators A collection of validators that are applied - * whenever the model value changes. The key value within the object refers to the name of the - * validator while the function refers to the validation operation. The validation operation is - * provided with the model value as an argument and must return a true or false value depending - * on the response of that validation. - * - * ```js - * ngModel.$validators.validCharacters = function(modelValue, viewValue) { - * var value = modelValue || viewValue; - * return /[0-9]+/.test(value) && - * /[a-z]+/.test(value) && - * /[A-Z]+/.test(value) && - * /\W+/.test(value); - * }; - * ``` - * - * @property {Object.} $asyncValidators A collection of validations that are expected to - * perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided - * is expected to return a promise when it is run during the model validation process. Once the promise - * is delivered then the validation status will be set to true when fulfilled and false when rejected. - * When the asynchronous validators are triggered, each of the validators will run in parallel and the model - * value will only be updated once all validators have been fulfilled. As long as an asynchronous validator - * is unfulfilled, its key will be added to the controllers `$pending` property. Also, all asynchronous validators - * will only run once all synchronous validators have passed. - * - * Please note that if $http is used then it is important that the server returns a success HTTP response code - * in order to fulfill the validation and a status level of `4xx` in order to reject the validation. - * - * ```js - * ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { - * var value = modelValue || viewValue; - * - * // Lookup user by username - * return $http.get('/api/users/' + value). - * then(function resolved() { - * //username exists, this means validation fails - * return $q.reject('exists'); - * }, function rejected() { - * //username does not exist, therefore this validation passes - * return true; - * }); - * }; - * ``` - * - * @property {Array.} $viewChangeListeners Array of functions to execute whenever the - * view value has changed. It is called with no arguments, and its return value is ignored. - * This can be used in place of additional $watches against the model value. - * - * @property {Object} $error An object hash with all failing validator ids as keys. - * @property {Object} $pending An object hash with all pending validator ids as keys. - * - * @property {boolean} $untouched True if control has not lost focus yet. - * @property {boolean} $touched True if control has lost focus. - * @property {boolean} $pristine True if user has not interacted with the control yet. - * @property {boolean} $dirty True if user has already interacted with the control. - * @property {boolean} $valid True if there is no error. - * @property {boolean} $invalid True if at least one error on the control. - * @property {string} $name The name attribute of the control. - * - * @description - * - * `NgModelController` provides API for the {@link ngModel `ngModel`} directive. - * The controller contains services for data-binding, validation, CSS updates, and value formatting - * and parsing. It purposefully does not contain any logic which deals with DOM rendering or - * listening to DOM events. - * Such DOM related logic should be provided by other directives which make use of - * `NgModelController` for data-binding to control elements. - * Angular provides this DOM logic for most {@link input `input`} elements. - * At the end of this page you can find a {@link ngModel.NgModelController#custom-control-example - * custom control example} that uses `ngModelController` to bind to `contenteditable` elements. - * - * @example - * ### Custom Control Example - * This example shows how to use `NgModelController` with a custom control to achieve - * data-binding. Notice how different directives (`contenteditable`, `ng-model`, and `required`) - * collaborate together to achieve the desired result. - * - * Note that `contenteditable` is an HTML5 attribute, which tells the browser to let the element - * contents be edited in place by the user. This will not work on older browsers. - * - * We are using the {@link ng.service:$sce $sce} service here and include the {@link ngSanitize $sanitize} - * module to automatically remove "bad" content like inline event listener (e.g. ``). - * However, as we are using `$sce` the model can still decide to provide unsafe content if it marks - * that content using the `$sce` service. - * - * - - [contenteditable] { - border: 1px solid black; - background-color: white; - min-height: 20px; - } - - .ng-invalid { - border: 1px solid red; - } - - - - angular.module('customControl', ['ngSanitize']). - directive('contenteditable', ['$sce', function($sce) { - return { - restrict: 'A', // only activate on element attribute - require: '?ngModel', // get a hold of NgModelController - link: function(scope, element, attrs, ngModel) { - if (!ngModel) return; // do nothing if no ng-model - - // Specify how UI should be updated - ngModel.$render = function() { - element.html($sce.getTrustedHtml(ngModel.$viewValue || '')); - }; - - // Listen for change events to enable binding - element.on('blur keyup change', function() { - scope.$evalAsync(read); - }); - read(); // initialize - - // Write data to the model - function read() { - var html = element.html(); - // When we clear the content editable the browser leaves a
behind - // If strip-br attribute is provided then we strip this out - if ( attrs.stripBr && html == '
' ) { - html = ''; - } - ngModel.$setViewValue(html); - } - } - }; - }]); -
- -
-
Change me!
- Required! -
- -
-
- - it('should data-bind and become invalid', function() { - if (browser.params.browser == 'safari' || browser.params.browser == 'firefox') { - // SafariDriver can't handle contenteditable - // and Firefox driver can't clear contenteditables very well - return; - } - var contentEditable = element(by.css('[contenteditable]')); - var content = 'Change me!'; - - expect(contentEditable.getText()).toEqual(content); - - contentEditable.clear(); - contentEditable.sendKeys(protractor.Key.BACK_SPACE); - expect(contentEditable.getText()).toEqual(''); - expect(contentEditable.getAttribute('class')).toMatch(/ng-invalid-required/); - }); - - *
- * - * - */ -var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', '$interpolate', - function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q, $interpolate) { - this.$viewValue = Number.NaN; - this.$modelValue = Number.NaN; - this.$$rawModelValue = undefined; // stores the parsed modelValue / model set from scope regardless of validity. - this.$validators = {}; - this.$asyncValidators = {}; - this.$parsers = []; - this.$formatters = []; - this.$viewChangeListeners = []; - this.$untouched = true; - this.$touched = false; - this.$pristine = true; - this.$dirty = false; - this.$valid = true; - this.$invalid = false; - this.$error = {}; // keep invalid keys here - this.$$success = {}; // keep valid keys here - this.$pending = undefined; // keep pending keys here - this.$name = $interpolate($attr.name || '', false)($scope); - - - var parsedNgModel = $parse($attr.ngModel), - parsedNgModelAssign = parsedNgModel.assign, - ngModelGet = parsedNgModel, - ngModelSet = parsedNgModelAssign, - pendingDebounce = null, - ctrl = this; - - this.$$setOptions = function(options) { - ctrl.$options = options; - if (options && options.getterSetter) { - var invokeModelGetter = $parse($attr.ngModel + '()'), - invokeModelSetter = $parse($attr.ngModel + '($$$p)'); - - ngModelGet = function($scope) { - var modelValue = parsedNgModel($scope); - if (isFunction(modelValue)) { - modelValue = invokeModelGetter($scope); - } - return modelValue; - }; - ngModelSet = function($scope, newValue) { - if (isFunction(parsedNgModel($scope))) { - invokeModelSetter($scope, {$$$p: ctrl.$modelValue}); - } else { - parsedNgModelAssign($scope, ctrl.$modelValue); - } - }; - } else if (!parsedNgModel.assign) { - throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", - $attr.ngModel, startingTag($element)); - } - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$render - * - * @description - * Called when the view needs to be updated. It is expected that the user of the ng-model - * directive will implement this method. - * - * The `$render()` method is invoked in the following situations: - * - * * `$rollbackViewValue()` is called. If we are rolling back the view value to the last - * committed value then `$render()` is called to update the input control. - * * The value referenced by `ng-model` is changed programmatically and both the `$modelValue` and - * the `$viewValue` are different to last time. - * - * Since `ng-model` does not do a deep watch, `$render()` is only invoked if the values of - * `$modelValue` and `$viewValue` are actually different to their previous value. If `$modelValue` - * or `$viewValue` are objects (rather than a string or number) then `$render()` will not be - * invoked if you only change a property on the objects. - */ - this.$render = noop; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$isEmpty - * - * @description - * This is called when we need to determine if the value of an input is empty. - * - * For instance, the required directive does this to work out if the input has data or not. - * - * The default `$isEmpty` function checks whether the value is `undefined`, `''`, `null` or `NaN`. - * - * You can override this for input directives whose concept of being empty is different to the - * default. The `checkboxInputType` directive does this because in its case a value of `false` - * implies empty. - * - * @param {*} value The value of the input to check for emptiness. - * @returns {boolean} True if `value` is "empty". - */ - this.$isEmpty = function(value) { - return isUndefined(value) || value === '' || value === null || value !== value; - }; - - var parentForm = $element.inheritedData('$formController') || nullFormCtrl, - currentValidationRunId = 0; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setValidity - * - * @description - * Change the validity state, and notify the form. - * - * This method can be called within $parsers/$formatters or a custom validation implementation. - * However, in most cases it should be sufficient to use the `ngModel.$validators` and - * `ngModel.$asyncValidators` collections which will call `$setValidity` automatically. - * - * @param {string} validationErrorKey Name of the validator. The `validationErrorKey` will be assigned - * to either `$error[validationErrorKey]` or `$pending[validationErrorKey]` - * (for unfulfilled `$asyncValidators`), so that it is available for data-binding. - * The `validationErrorKey` should be in camelCase and will get converted into dash-case - * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` - * class and can be bound to as `{{someForm.someControl.$error.myError}}` . - * @param {boolean} isValid Whether the current state is valid (true), invalid (false), pending (undefined), - * or skipped (null). Pending is used for unfulfilled `$asyncValidators`. - * Skipped is used by Angular when validators do not run because of parse errors and - * when `$asyncValidators` do not run because any of the `$validators` failed. - */ - addSetValidityMethod({ - ctrl: this, - $element: $element, - set: function(object, property) { - object[property] = true; - }, - unset: function(object, property) { - delete object[property]; - }, - parentForm: parentForm, - $animate: $animate - }); - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setPristine - * - * @description - * Sets the control to its pristine state. - * - * This method can be called to remove the `ng-dirty` class and set the control to its pristine - * state (`ng-pristine` class). A model is considered to be pristine when the control - * has not been changed from when first compiled. - */ - this.$setPristine = function() { - ctrl.$dirty = false; - ctrl.$pristine = true; - $animate.removeClass($element, DIRTY_CLASS); - $animate.addClass($element, PRISTINE_CLASS); - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setDirty - * - * @description - * Sets the control to its dirty state. - * - * This method can be called to remove the `ng-pristine` class and set the control to its dirty - * state (`ng-dirty` class). A model is considered to be dirty when the control has been changed - * from when first compiled. - */ - this.$setDirty = function() { - ctrl.$dirty = true; - ctrl.$pristine = false; - $animate.removeClass($element, PRISTINE_CLASS); - $animate.addClass($element, DIRTY_CLASS); - parentForm.$setDirty(); - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setUntouched - * - * @description - * Sets the control to its untouched state. - * - * This method can be called to remove the `ng-touched` class and set the control to its - * untouched state (`ng-untouched` class). Upon compilation, a model is set as untouched - * by default, however this function can be used to restore that state if the model has - * already been touched by the user. - */ - this.$setUntouched = function() { - ctrl.$touched = false; - ctrl.$untouched = true; - $animate.setClass($element, UNTOUCHED_CLASS, TOUCHED_CLASS); - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setTouched - * - * @description - * Sets the control to its touched state. - * - * This method can be called to remove the `ng-untouched` class and set the control to its - * touched state (`ng-touched` class). A model is considered to be touched when the user has - * first focused the control element and then shifted focus away from the control (blur event). - */ - this.$setTouched = function() { - ctrl.$touched = true; - ctrl.$untouched = false; - $animate.setClass($element, TOUCHED_CLASS, UNTOUCHED_CLASS); - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$rollbackViewValue - * - * @description - * Cancel an update and reset the input element's value to prevent an update to the `$modelValue`, - * which may be caused by a pending debounced event or because the input is waiting for a some - * future event. - * - * If you have an input that uses `ng-model-options` to set up debounced events or events such - * as blur you can have a situation where there is a period when the `$viewValue` - * is out of synch with the ngModel's `$modelValue`. - * - * In this case, you can run into difficulties if you try to update the ngModel's `$modelValue` - * programmatically before these debounced/future events have resolved/occurred, because Angular's - * dirty checking mechanism is not able to tell whether the model has actually changed or not. - * - * The `$rollbackViewValue()` method should be called before programmatically changing the model of an - * input which may have such events pending. This is important in order to make sure that the - * input field will be updated with the new model value and any pending operations are cancelled. - * - * - * - * angular.module('cancel-update-example', []) - * - * .controller('CancelUpdateController', ['$scope', function($scope) { - * $scope.resetWithCancel = function(e) { - * if (e.keyCode == 27) { - * $scope.myForm.myInput1.$rollbackViewValue(); - * $scope.myValue = ''; - * } - * }; - * $scope.resetWithoutCancel = function(e) { - * if (e.keyCode == 27) { - * $scope.myValue = ''; - * } - * }; - * }]); - * - * - *
- *

Try typing something in each input. See that the model only updates when you - * blur off the input. - *

- *

Now see what happens if you start typing then press the Escape key

- * - *
- *

With $rollbackViewValue()

- *
- * myValue: "{{ myValue }}" - * - *

Without $rollbackViewValue()

- *
- * myValue: "{{ myValue }}" - *
- *
- *
- *
- */ - this.$rollbackViewValue = function() { - $timeout.cancel(pendingDebounce); - ctrl.$viewValue = ctrl.$$lastCommittedViewValue; - ctrl.$render(); - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$validate - * - * @description - * Runs each of the registered validators (first synchronous validators and then - * asynchronous validators). - * If the validity changes to invalid, the model will be set to `undefined`, - * unless {@link ngModelOptions `ngModelOptions.allowInvalid`} is `true`. - * If the validity changes to valid, it will set the model to the last available valid - * modelValue, i.e. either the last parsed value or the last value set from the scope. - */ - this.$validate = function() { - // ignore $validate before model is initialized - if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { - return; - } - - var viewValue = ctrl.$$lastCommittedViewValue; - // Note: we use the $$rawModelValue as $modelValue might have been - // set to undefined during a view -> model update that found validation - // errors. We can't parse the view here, since that could change - // the model although neither viewValue nor the model on the scope changed - var modelValue = ctrl.$$rawModelValue; - - // Check if the there's a parse error, so we don't unset it accidentially - var parserName = ctrl.$$parserName || 'parse'; - var parserValid = ctrl.$error[parserName] ? false : undefined; - - var prevValid = ctrl.$valid; - var prevModelValue = ctrl.$modelValue; - - var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; - - ctrl.$$runValidators(parserValid, modelValue, viewValue, function(allValid) { - // If there was no change in validity, don't update the model - // This prevents changing an invalid modelValue to undefined - if (!allowInvalid && prevValid !== allValid) { - // Note: Don't check ctrl.$valid here, as we could have - // external validators (e.g. calculated on the server), - // that just call $setValidity and need the model value - // to calculate their validity. - ctrl.$modelValue = allValid ? modelValue : undefined; - - if (ctrl.$modelValue !== prevModelValue) { - ctrl.$$writeModelToScope(); - } - } - }); - - }; - - this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) { - currentValidationRunId++; - var localValidationRunId = currentValidationRunId; - - // check parser error - if (!processParseErrors(parseValid)) { - validationDone(false); - return; - } - if (!processSyncValidators()) { - validationDone(false); - return; - } - processAsyncValidators(); - - function processParseErrors(parseValid) { - var errorKey = ctrl.$$parserName || 'parse'; - if (parseValid === undefined) { - setValidity(errorKey, null); - } else { - setValidity(errorKey, parseValid); - if (!parseValid) { - forEach(ctrl.$validators, function(v, name) { - setValidity(name, null); - }); - forEach(ctrl.$asyncValidators, function(v, name) { - setValidity(name, null); - }); - return false; - } - } - return true; - } - - function processSyncValidators() { - var syncValidatorsValid = true; - forEach(ctrl.$validators, function(validator, name) { - var result = validator(modelValue, viewValue); - syncValidatorsValid = syncValidatorsValid && result; - setValidity(name, result); - }); - if (!syncValidatorsValid) { - forEach(ctrl.$asyncValidators, function(v, name) { - setValidity(name, null); - }); - return false; - } - return true; - } - - function processAsyncValidators() { - var validatorPromises = []; - var allValid = true; - forEach(ctrl.$asyncValidators, function(validator, name) { - var promise = validator(modelValue, viewValue); - if (!isPromiseLike(promise)) { - throw $ngModelMinErr("$asyncValidators", - "Expected asynchronous validator to return a promise but got '{0}' instead.", promise); - } - setValidity(name, undefined); - validatorPromises.push(promise.then(function() { - setValidity(name, true); - }, function(error) { - allValid = false; - setValidity(name, false); - })); - }); - if (!validatorPromises.length) { - validationDone(true); - } else { - $q.all(validatorPromises).then(function() { - validationDone(allValid); - }, noop); - } - } - - function setValidity(name, isValid) { - if (localValidationRunId === currentValidationRunId) { - ctrl.$setValidity(name, isValid); - } - } - - function validationDone(allValid) { - if (localValidationRunId === currentValidationRunId) { - - doneCallback(allValid); - } - } - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$commitViewValue - * - * @description - * Commit a pending update to the `$modelValue`. - * - * Updates may be pending by a debounced event or because the input is waiting for a some future - * event defined in `ng-model-options`. this method is rarely needed as `NgModelController` - * usually handles calling this in response to input events. - */ - this.$commitViewValue = function() { - var viewValue = ctrl.$viewValue; - - $timeout.cancel(pendingDebounce); - - // If the view value has not changed then we should just exit, except in the case where there is - // a native validator on the element. In this case the validation state may have changed even though - // the viewValue has stayed empty. - if (ctrl.$$lastCommittedViewValue === viewValue && (viewValue !== '' || !ctrl.$$hasNativeValidators)) { - return; - } - ctrl.$$lastCommittedViewValue = viewValue; - - // change to dirty - if (ctrl.$pristine) { - this.$setDirty(); - } - this.$$parseAndValidate(); - }; - - this.$$parseAndValidate = function() { - var viewValue = ctrl.$$lastCommittedViewValue; - var modelValue = viewValue; - var parserValid = isUndefined(modelValue) ? undefined : true; - - if (parserValid) { - for (var i = 0; i < ctrl.$parsers.length; i++) { - modelValue = ctrl.$parsers[i](modelValue); - if (isUndefined(modelValue)) { - parserValid = false; - break; - } - } - } - if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { - // ctrl.$modelValue has not been touched yet... - ctrl.$modelValue = ngModelGet($scope); - } - var prevModelValue = ctrl.$modelValue; - var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; - ctrl.$$rawModelValue = modelValue; - - if (allowInvalid) { - ctrl.$modelValue = modelValue; - writeToModelIfNeeded(); - } - - // Pass the $$lastCommittedViewValue here, because the cached viewValue might be out of date. - // This can happen if e.g. $setViewValue is called from inside a parser - ctrl.$$runValidators(parserValid, modelValue, ctrl.$$lastCommittedViewValue, function(allValid) { - if (!allowInvalid) { - // Note: Don't check ctrl.$valid here, as we could have - // external validators (e.g. calculated on the server), - // that just call $setValidity and need the model value - // to calculate their validity. - ctrl.$modelValue = allValid ? modelValue : undefined; - writeToModelIfNeeded(); - } - }); - - function writeToModelIfNeeded() { - if (ctrl.$modelValue !== prevModelValue) { - ctrl.$$writeModelToScope(); - } - } - }; - - this.$$writeModelToScope = function() { - ngModelSet($scope, ctrl.$modelValue); - forEach(ctrl.$viewChangeListeners, function(listener) { - try { - listener(); - } catch (e) { - $exceptionHandler(e); - } - }); - }; - - /** - * @ngdoc method - * @name ngModel.NgModelController#$setViewValue - * - * @description - * Update the view value. - * - * This method should be called when an input directive want to change the view value; typically, - * this is done from within a DOM event handler. - * - * For example {@link ng.directive:input input} calls it when the value of the input changes and - * {@link ng.directive:select select} calls it when an option is selected. - * - * If the new `value` is an object (rather than a string or a number), we should make a copy of the - * object before passing it to `$setViewValue`. This is because `ngModel` does not perform a deep - * watch of objects, it only looks for a change of identity. If you only change the property of - * the object then ngModel will not realise that the object has changed and will not invoke the - * `$parsers` and `$validators` pipelines. - * - * For this reason, you should not change properties of the copy once it has been passed to - * `$setViewValue`. Otherwise you may cause the model value on the scope to change incorrectly. - * - * When this method is called, the new `value` will be staged for committing through the `$parsers` - * and `$validators` pipelines. If there are no special {@link ngModelOptions} specified then the staged - * value sent directly for processing, finally to be applied to `$modelValue` and then the - * **expression** specified in the `ng-model` attribute. - * - * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. - * - * In case the {@link ng.directive:ngModelOptions ngModelOptions} directive is used with `updateOn` - * and the `default` trigger is not listed, all those actions will remain pending until one of the - * `updateOn` events is triggered on the DOM element. - * All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions} - * directive is used with a custom debounce for this particular event. - * - * Note that calling this function does not trigger a `$digest`. - * - * @param {string} value Value from the view. - * @param {string} trigger Event that triggered the update. - */ - this.$setViewValue = function(value, trigger) { - ctrl.$viewValue = value; - if (!ctrl.$options || ctrl.$options.updateOnDefault) { - ctrl.$$debounceViewValueCommit(trigger); - } - }; - - this.$$debounceViewValueCommit = function(trigger) { - var debounceDelay = 0, - options = ctrl.$options, - debounce; - - if (options && isDefined(options.debounce)) { - debounce = options.debounce; - if (isNumber(debounce)) { - debounceDelay = debounce; - } else if (isNumber(debounce[trigger])) { - debounceDelay = debounce[trigger]; - } else if (isNumber(debounce['default'])) { - debounceDelay = debounce['default']; - } - } - - $timeout.cancel(pendingDebounce); - if (debounceDelay) { - pendingDebounce = $timeout(function() { - ctrl.$commitViewValue(); - }, debounceDelay); - } else if ($rootScope.$$phase) { - ctrl.$commitViewValue(); - } else { - $scope.$apply(function() { - ctrl.$commitViewValue(); - }); - } - }; - - // model -> value - // Note: we cannot use a normal scope.$watch as we want to detect the following: - // 1. scope value is 'a' - // 2. user enters 'b' - // 3. ng-change kicks in and reverts scope value to 'a' - // -> scope value did not change since the last digest as - // ng-change executes in apply phase - // 4. view should be changed back to 'a' - $scope.$watch(function ngModelWatch() { - var modelValue = ngModelGet($scope); - - // if scope model value and ngModel value are out of sync - // TODO(perf): why not move this to the action fn? - if (modelValue !== ctrl.$modelValue) { - ctrl.$modelValue = ctrl.$$rawModelValue = modelValue; - - var formatters = ctrl.$formatters, - idx = formatters.length; - - var viewValue = modelValue; - while (idx--) { - viewValue = formatters[idx](viewValue); - } - if (ctrl.$viewValue !== viewValue) { - ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; - ctrl.$render(); - - ctrl.$$runValidators(undefined, modelValue, viewValue, noop); - } - } - - return modelValue; - }); -}]; - - -/** - * @ngdoc directive - * @name ngModel - * - * @element input - * @priority 1 - * - * @description - * The `ngModel` directive binds an `input`,`select`, `textarea` (or custom form control) to a - * property on the scope using {@link ngModel.NgModelController NgModelController}, - * which is created and exposed by this directive. - * - * `ngModel` is responsible for: - * - * - Binding the view into the model, which other directives such as `input`, `textarea` or `select` - * require. - * - Providing validation behavior (i.e. required, number, email, url). - * - Keeping the state of the control (valid/invalid, dirty/pristine, touched/untouched, validation errors). - * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`, `ng-touched`, `ng-untouched`) including animations. - * - Registering the control with its parent {@link ng.directive:form form}. - * - * Note: `ngModel` will try to bind to the property given by evaluating the expression on the - * current scope. If the property doesn't already exist on this scope, it will be created - * implicitly and added to the scope. - * - * For best practices on using `ngModel`, see: - * - * - [Understanding Scopes](https://github.com/angular/angular.js/wiki/Understanding-Scopes) - * - * For basic examples, how to use `ngModel`, see: - * - * - {@link ng.directive:input input} - * - {@link input[text] text} - * - {@link input[checkbox] checkbox} - * - {@link input[radio] radio} - * - {@link input[number] number} - * - {@link input[email] email} - * - {@link input[url] url} - * - {@link input[date] date} - * - {@link input[datetime-local] datetime-local} - * - {@link input[time] time} - * - {@link input[month] month} - * - {@link input[week] week} - * - {@link ng.directive:select select} - * - {@link ng.directive:textarea textarea} - * - * # CSS classes - * The following CSS classes are added and removed on the associated input/select/textarea element - * depending on the validity of the model. - * - * - `ng-valid`: the model is valid - * - `ng-invalid`: the model is invalid - * - `ng-valid-[key]`: for each valid key added by `$setValidity` - * - `ng-invalid-[key]`: for each invalid key added by `$setValidity` - * - `ng-pristine`: the control hasn't been interacted with yet - * - `ng-dirty`: the control has been interacted with - * - `ng-touched`: the control has been blurred - * - `ng-untouched`: the control hasn't been blurred - * - `ng-pending`: any `$asyncValidators` are unfulfilled - * - * Keep in mind that ngAnimate can detect each of these classes when added and removed. - * - * ## Animation Hooks - * - * Animations within models are triggered when any of the associated CSS classes are added and removed - * on the input element which is attached to the model. These classes are: `.ng-pristine`, `.ng-dirty`, - * `.ng-invalid` and `.ng-valid` as well as any other validations that are performed on the model itself. - * The animations that are triggered within ngModel are similar to how they work in ngClass and - * animations can be hooked into using CSS transitions, keyframes as well as JS animations. - * - * The following example shows a simple way to utilize CSS transitions to style an input element - * that has been rendered as invalid after it has been validated: - * - *
- * //be sure to include ngAnimate as a module to hook into more
- * //advanced animations
- * .my-input {
- *   transition:0.5s linear all;
- *   background: white;
- * }
- * .my-input.ng-invalid {
- *   background: red;
- *   color:white;
- * }
- * 
- * - * @example - * - - - - Update input to see transitions when valid/invalid. - Integer is a valid value. -
- -
-
- *
- * - * ## Binding to a getter/setter - * - * Sometimes it's helpful to bind `ngModel` to a getter/setter function. A getter/setter is a - * function that returns a representation of the model when called with zero arguments, and sets - * the internal state of a model when called with an argument. It's sometimes useful to use this - * for models that have an internal representation that's different than what the model exposes - * to the view. - * - *
- * **Best Practice:** It's best to keep getters fast because Angular is likely to call them more - * frequently than other parts of your code. - *
- * - * You use this behavior by adding `ng-model-options="{ getterSetter: true }"` to an element that - * has `ng-model` attached to it. You can also add `ng-model-options="{ getterSetter: true }"` to - * a `
`, which will enable this behavior for all ``s within it. See - * {@link ng.directive:ngModelOptions `ngModelOptions`} for more. - * - * The following example shows how to use `ngModel` with a getter/setter: - * - * @example - * - -
- - Name: - - -
user.name = 
-
-
- - angular.module('getterSetterExample', []) - .controller('ExampleController', ['$scope', function($scope) { - var _name = 'Brian'; - $scope.user = { - name: function(newName) { - if (angular.isDefined(newName)) { - _name = newName; - } - return _name; - } - }; - }]); - - *
- */ -var ngModelDirective = ['$rootScope', function($rootScope) { - return { - restrict: 'A', - require: ['ngModel', '^?form', '^?ngModelOptions'], - controller: NgModelController, - // Prelink needs to run before any input directive - // so that we can set the NgModelOptions in NgModelController - // before anyone else uses it. - priority: 1, - compile: function ngModelCompile(element) { - // Setup initial state of the control - element.addClass(PRISTINE_CLASS).addClass(UNTOUCHED_CLASS).addClass(VALID_CLASS); - - return { - pre: function ngModelPreLink(scope, element, attr, ctrls) { - var modelCtrl = ctrls[0], - formCtrl = ctrls[1] || nullFormCtrl; - - modelCtrl.$$setOptions(ctrls[2] && ctrls[2].$options); - - // notify others, especially parent forms - formCtrl.$addControl(modelCtrl); - - attr.$observe('name', function(newValue) { - if (modelCtrl.$name !== newValue) { - formCtrl.$$renameControl(modelCtrl, newValue); - } - }); - - scope.$on('$destroy', function() { - formCtrl.$removeControl(modelCtrl); - }); - }, - post: function ngModelPostLink(scope, element, attr, ctrls) { - var modelCtrl = ctrls[0]; - if (modelCtrl.$options && modelCtrl.$options.updateOn) { - element.on(modelCtrl.$options.updateOn, function(ev) { - modelCtrl.$$debounceViewValueCommit(ev && ev.type); - }); - } - - element.on('blur', function(ev) { - if (modelCtrl.$touched) return; - - if ($rootScope.$$phase) { - scope.$evalAsync(modelCtrl.$setTouched); - } else { - scope.$apply(modelCtrl.$setTouched); - } - }); - } - }; - } - }; -}]; - - -/** - * @ngdoc directive - * @name ngChange - * - * @description - * Evaluate the given expression when the user changes the input. - * The expression is evaluated immediately, unlike the JavaScript onchange event - * which only triggers at the end of a change (usually, when the user leaves the - * form element or presses the return key). - * - * The `ngChange` expression is only evaluated when a change in the input value causes - * a new value to be committed to the model. - * - * It will not be evaluated: - * * if the value returned from the `$parsers` transformation pipeline has not changed - * * if the input has continued to be invalid since the model will stay `null` - * * if the model is changed programmatically and not by a change to the input value - * - * - * Note, this directive requires `ngModel` to be present. - * - * @element input - * @param {expression} ngChange {@link guide/expression Expression} to evaluate upon change - * in input value. - * - * @example - * - * - * - *
- * - * - *
- * debug = {{confirmed}}
- * counter = {{counter}}
- *
- *
- * - * var counter = element(by.binding('counter')); - * var debug = element(by.binding('confirmed')); - * - * it('should evaluate the expression if changing from view', function() { - * expect(counter.getText()).toContain('0'); - * - * element(by.id('ng-change-example1')).click(); - * - * expect(counter.getText()).toContain('1'); - * expect(debug.getText()).toContain('true'); - * }); - * - * it('should not evaluate the expression if changing from model', function() { - * element(by.id('ng-change-example2')).click(); - - * expect(counter.getText()).toContain('0'); - * expect(debug.getText()).toContain('true'); - * }); - * - *
- */ -var ngChangeDirective = valueFn({ - restrict: 'A', - require: 'ngModel', - link: function(scope, element, attr, ctrl) { - ctrl.$viewChangeListeners.push(function() { - scope.$eval(attr.ngChange); - }); - } -}); - - -var requiredDirective = function() { - return { - restrict: 'A', - require: '?ngModel', - link: function(scope, elm, attr, ctrl) { - if (!ctrl) return; - attr.required = true; // force truthy in case we are on non input element - - ctrl.$validators.required = function(modelValue, viewValue) { - return !attr.required || !ctrl.$isEmpty(viewValue); - }; - - attr.$observe('required', function() { - ctrl.$validate(); - }); - } - }; -}; - - -var patternDirective = function() { - return { - restrict: 'A', - require: '?ngModel', - link: function(scope, elm, attr, ctrl) { - if (!ctrl) return; - - var regexp, patternExp = attr.ngPattern || attr.pattern; - attr.$observe('pattern', function(regex) { - if (isString(regex) && regex.length > 0) { - regex = new RegExp('^' + regex + '$'); - } - - if (regex && !regex.test) { - throw minErr('ngPattern')('noregexp', - 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, - regex, startingTag(elm)); - } - - regexp = regex || undefined; - ctrl.$validate(); - }); - - ctrl.$validators.pattern = function(value) { - return ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value); - }; - } - }; -}; - - -var maxlengthDirective = function() { - return { - restrict: 'A', - require: '?ngModel', - link: function(scope, elm, attr, ctrl) { - if (!ctrl) return; - - var maxlength = -1; - attr.$observe('maxlength', function(value) { - var intVal = int(value); - maxlength = isNaN(intVal) ? -1 : intVal; - ctrl.$validate(); - }); - ctrl.$validators.maxlength = function(modelValue, viewValue) { - return (maxlength < 0) || ctrl.$isEmpty(modelValue) || (viewValue.length <= maxlength); - }; - } - }; -}; - -var minlengthDirective = function() { - return { - restrict: 'A', - require: '?ngModel', - link: function(scope, elm, attr, ctrl) { - if (!ctrl) return; - - var minlength = 0; - attr.$observe('minlength', function(value) { - minlength = int(value) || 0; - ctrl.$validate(); - }); - ctrl.$validators.minlength = function(modelValue, viewValue) { - return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength; - }; - } - }; -}; - - -/** - * @ngdoc directive - * @name ngList - * - * @description - * Text input that converts between a delimited string and an array of strings. The default - * delimiter is a comma followed by a space - equivalent to `ng-list=", "`. You can specify a custom - * delimiter as the value of the `ngList` attribute - for example, `ng-list=" | "`. - * - * The behaviour of the directive is affected by the use of the `ngTrim` attribute. - * * If `ngTrim` is set to `"false"` then whitespace around both the separator and each - * list item is respected. This implies that the user of the directive is responsible for - * dealing with whitespace but also allows you to use whitespace as a delimiter, such as a - * tab or newline character. - * * Otherwise whitespace around the delimiter is ignored when splitting (although it is respected - * when joining the list items back together) and whitespace around each list item is stripped - * before it is added to the model. - * - * ### Example with Validation - * - * - * - * angular.module('listExample', []) - * .controller('ExampleController', ['$scope', function($scope) { - * $scope.names = ['morpheus', 'neo', 'trinity']; - * }]); - * - * - *
- * List: - * - * Required! - *
- * names = {{names}}
- * myForm.namesInput.$valid = {{myForm.namesInput.$valid}}
- * myForm.namesInput.$error = {{myForm.namesInput.$error}}
- * myForm.$valid = {{myForm.$valid}}
- * myForm.$error.required = {{!!myForm.$error.required}}
- *
- *
- * - * var listInput = element(by.model('names')); - * var names = element(by.exactBinding('names')); - * var valid = element(by.binding('myForm.namesInput.$valid')); - * var error = element(by.css('span.error')); - * - * it('should initialize to model', function() { - * expect(names.getText()).toContain('["morpheus","neo","trinity"]'); - * expect(valid.getText()).toContain('true'); - * expect(error.getCssValue('display')).toBe('none'); - * }); - * - * it('should be invalid if empty', function() { - * listInput.clear(); - * listInput.sendKeys(''); - * - * expect(names.getText()).toContain(''); - * expect(valid.getText()).toContain('false'); - * expect(error.getCssValue('display')).not.toBe('none'); - * }); - * - *
- * - * ### Example - splitting on whitespace - * - * - * - *
{{ list | json }}
- *
- * - * it("should split the text by newlines", function() { - * var listInput = element(by.model('list')); - * var output = element(by.binding('list | json')); - * listInput.sendKeys('abc\ndef\nghi'); - * expect(output.getText()).toContain('[\n "abc",\n "def",\n "ghi"\n]'); - * }); - * - *
- * - * @element input - * @param {string=} ngList optional delimiter that should be used to split the value. - */ -var ngListDirective = function() { - return { - restrict: 'A', - priority: 100, - require: 'ngModel', - link: function(scope, element, attr, ctrl) { - // We want to control whitespace trimming so we use this convoluted approach - // to access the ngList attribute, which doesn't pre-trim the attribute - var ngList = element.attr(attr.$attr.ngList) || ', '; - var trimValues = attr.ngTrim !== 'false'; - var separator = trimValues ? trim(ngList) : ngList; - - var parse = function(viewValue) { - // If the viewValue is invalid (say required but empty) it will be `undefined` - if (isUndefined(viewValue)) return; - - var list = []; - - if (viewValue) { - forEach(viewValue.split(separator), function(value) { - if (value) list.push(trimValues ? trim(value) : value); - }); - } - - return list; - }; - - ctrl.$parsers.push(parse); - ctrl.$formatters.push(function(value) { - if (isArray(value)) { - return value.join(ngList); - } - - return undefined; - }); - - // Override the standard $isEmpty because an empty array means the input is empty. - ctrl.$isEmpty = function(value) { - return !value || !value.length; - }; - } - }; -}; var CONSTANT_VALUE_REGEXP = /^(true|false|\d+)$/; @@ -2940,277 +1600,5 @@ var ngValueDirective = function() { }; }; -/** - * @ngdoc directive - * @name ngModelOptions - * - * @description - * Allows tuning how model updates are done. Using `ngModelOptions` you can specify a custom list of - * events that will trigger a model update and/or a debouncing delay so that the actual update only - * takes place when a timer expires; this timer will be reset after another change takes place. - * - * Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might - * be different than the value in the actual model. This means that if you update the model you - * should also invoke {@link ngModel.NgModelController `$rollbackViewValue`} on the relevant input field in - * order to make sure it is synchronized with the model and that any debounced action is canceled. - * - * The easiest way to reference the control's {@link ngModel.NgModelController `$rollbackViewValue`} - * method is by making sure the input is placed inside a form that has a `name` attribute. This is - * important because `form` controllers are published to the related scope under the name in their - * `name` attribute. - * - * Any pending changes will take place immediately when an enclosing form is submitted via the - * `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` - * to have access to the updated model. - * - * `ngModelOptions` has an effect on the element it's declared on and its descendants. - * - * @param {Object} ngModelOptions options to apply to the current model. Valid keys are: - * - `updateOn`: string specifying which event should the input be bound to. You can set several - * events using an space delimited list. There is a special event called `default` that - * matches the default events belonging of the control. - * - `debounce`: integer value which contains the debounce model update value in milliseconds. A - * value of 0 triggers an immediate update. If an object is supplied instead, you can specify a - * custom value for each event. For example: - * `ng-model-options="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` - * - `allowInvalid`: boolean value which indicates that the model can be set with values that did - * not validate correctly instead of the default behavior of setting the model to undefined. - * - `getterSetter`: boolean value which determines whether or not to treat functions bound to - `ngModel` as getters/setters. - * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for - * ``, ``, ... . Right now, the only supported value is `'UTC'`, - * otherwise the default timezone of the browser will be used. - * - * @example - The following example shows how to override immediate updates. Changes on the inputs within the - form will update the model only when the control loses focus (blur event). If `escape` key is - pressed while the input field is focused, the value is reset to the value in the current model. - - -
-
- Name: -
- - Other data: -
-
-
user.name = 
-
-
- - angular.module('optionsExample', []) - .controller('ExampleController', ['$scope', function($scope) { - $scope.user = { name: 'say', data: '' }; - - $scope.cancel = function(e) { - if (e.keyCode == 27) { - $scope.userForm.userName.$rollbackViewValue(); - } - }; - }]); - - - var model = element(by.binding('user.name')); - var input = element(by.model('user.name')); - var other = element(by.model('user.data')); - - it('should allow custom events', function() { - input.sendKeys(' hello'); - input.click(); - expect(model.getText()).toEqual('say'); - other.click(); - expect(model.getText()).toEqual('say hello'); - }); - - it('should $rollbackViewValue when model changes', function() { - input.sendKeys(' hello'); - expect(input.getAttribute('value')).toEqual('say hello'); - input.sendKeys(protractor.Key.ESCAPE); - expect(input.getAttribute('value')).toEqual('say'); - other.click(); - expect(model.getText()).toEqual('say'); - }); - -
- - This one shows how to debounce model changes. Model will be updated only 1 sec after last change. - If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty. - - - -
-
- Name: - -
-
-
user.name = 
-
-
- - angular.module('optionsExample', []) - .controller('ExampleController', ['$scope', function($scope) { - $scope.user = { name: 'say' }; - }]); - -
- - This one shows how to bind to getter/setters: - - - -
-
- Name: - -
-
user.name = 
-
-
- - angular.module('getterSetterExample', []) - .controller('ExampleController', ['$scope', function($scope) { - var _name = 'Brian'; - $scope.user = { - name: function(newName) { - return angular.isDefined(newName) ? (_name = newName) : _name; - } - }; - }]); - -
- */ -var ngModelOptionsDirective = function() { - return { - restrict: 'A', - controller: ['$scope', '$attrs', function($scope, $attrs) { - var that = this; - this.$options = $scope.$eval($attrs.ngModelOptions); - // Allow adding/overriding bound events - if (this.$options.updateOn !== undefined) { - this.$options.updateOnDefault = false; - // extract "default" pseudo-event from list of events that can trigger a model update - this.$options.updateOn = trim(this.$options.updateOn.replace(DEFAULT_REGEXP, function() { - that.$options.updateOnDefault = true; - return ' '; - })); - } else { - this.$options.updateOnDefault = true; - } - }] - }; -}; - -// helper methods -function addSetValidityMethod(context) { - var ctrl = context.ctrl, - $element = context.$element, - classCache = {}, - set = context.set, - unset = context.unset, - parentForm = context.parentForm, - $animate = context.$animate; - - classCache[INVALID_CLASS] = !(classCache[VALID_CLASS] = $element.hasClass(VALID_CLASS)); - - ctrl.$setValidity = setValidity; - - function setValidity(validationErrorKey, state, options) { - if (state === undefined) { - createAndSet('$pending', validationErrorKey, options); - } else { - unsetAndCleanup('$pending', validationErrorKey, options); - } - if (!isBoolean(state)) { - unset(ctrl.$error, validationErrorKey, options); - unset(ctrl.$$success, validationErrorKey, options); - } else { - if (state) { - unset(ctrl.$error, validationErrorKey, options); - set(ctrl.$$success, validationErrorKey, options); - } else { - set(ctrl.$error, validationErrorKey, options); - unset(ctrl.$$success, validationErrorKey, options); - } - } - if (ctrl.$pending) { - cachedToggleClass(PENDING_CLASS, true); - ctrl.$valid = ctrl.$invalid = undefined; - toggleValidationCss('', null); - } else { - cachedToggleClass(PENDING_CLASS, false); - ctrl.$valid = isObjectEmpty(ctrl.$error); - ctrl.$invalid = !ctrl.$valid; - toggleValidationCss('', ctrl.$valid); - } - - // re-read the state as the set/unset methods could have - // combined state in ctrl.$error[validationError] (used for forms), - // where setting/unsetting only increments/decrements the value, - // and does not replace it. - var combinedState; - if (ctrl.$pending && ctrl.$pending[validationErrorKey]) { - combinedState = undefined; - } else if (ctrl.$error[validationErrorKey]) { - combinedState = false; - } else if (ctrl.$$success[validationErrorKey]) { - combinedState = true; - } else { - combinedState = null; - } - toggleValidationCss(validationErrorKey, combinedState); - parentForm.$setValidity(validationErrorKey, combinedState, ctrl); - } - - function createAndSet(name, value, options) { - if (!ctrl[name]) { - ctrl[name] = {}; - } - set(ctrl[name], value, options); - } - - function unsetAndCleanup(name, value, options) { - if (ctrl[name]) { - unset(ctrl[name], value, options); - } - if (isObjectEmpty(ctrl[name])) { - ctrl[name] = undefined; - } - } - - function cachedToggleClass(className, switchValue) { - if (switchValue && !classCache[className]) { - $animate.addClass($element, className); - classCache[className] = true; - } else if (!switchValue && classCache[className]) { - $animate.removeClass($element, className); - classCache[className] = false; - } - } - - function toggleValidationCss(validationErrorKey, isValid) { - validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; - - cachedToggleClass(VALID_CLASS + validationErrorKey, isValid === true); - cachedToggleClass(INVALID_CLASS + validationErrorKey, isValid === false); - } -} - -function isObjectEmpty(obj) { - if (obj) { - for (var prop in obj) { - return false; - } - } - return true; -} diff --git a/src/ng/directive/ngChange.js b/src/ng/directive/ngChange.js new file mode 100644 index 00000000..d77f14d4 --- /dev/null +++ b/src/ng/directive/ngChange.js @@ -0,0 +1,78 @@ +'use strict'; + +/** + * @ngdoc directive + * @name ngChange + * + * @description + * Evaluate the given expression when the user changes the input. + * The expression is evaluated immediately, unlike the JavaScript onchange event + * which only triggers at the end of a change (usually, when the user leaves the + * form element or presses the return key). + * + * The `ngChange` expression is only evaluated when a change in the input value causes + * a new value to be committed to the model. + * + * It will not be evaluated: + * * if the value returned from the `$parsers` transformation pipeline has not changed + * * if the input has continued to be invalid since the model will stay `null` + * * if the model is changed programmatically and not by a change to the input value + * + * + * Note, this directive requires `ngModel` to be present. + * + * @element input + * @param {expression} ngChange {@link guide/expression Expression} to evaluate upon change + * in input value. + * + * @example + * + * + * + *
+ * + * + *
+ * debug = {{confirmed}}
+ * counter = {{counter}}
+ *
+ *
+ * + * var counter = element(by.binding('counter')); + * var debug = element(by.binding('confirmed')); + * + * it('should evaluate the expression if changing from view', function() { + * expect(counter.getText()).toContain('0'); + * + * element(by.id('ng-change-example1')).click(); + * + * expect(counter.getText()).toContain('1'); + * expect(debug.getText()).toContain('true'); + * }); + * + * it('should not evaluate the expression if changing from model', function() { + * element(by.id('ng-change-example2')).click(); + + * expect(counter.getText()).toContain('0'); + * expect(debug.getText()).toContain('true'); + * }); + * + *
+ */ +var ngChangeDirective = valueFn({ + restrict: 'A', + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + ctrl.$viewChangeListeners.push(function() { + scope.$eval(attr.ngChange); + }); + } +}); diff --git a/src/ng/directive/ngList.js b/src/ng/directive/ngList.js new file mode 100644 index 00000000..7415336c --- /dev/null +++ b/src/ng/directive/ngList.js @@ -0,0 +1,128 @@ +'use strict'; + + +/** + * @ngdoc directive + * @name ngList + * + * @description + * Text input that converts between a delimited string and an array of strings. The default + * delimiter is a comma followed by a space - equivalent to `ng-list=", "`. You can specify a custom + * delimiter as the value of the `ngList` attribute - for example, `ng-list=" | "`. + * + * The behaviour of the directive is affected by the use of the `ngTrim` attribute. + * * If `ngTrim` is set to `"false"` then whitespace around both the separator and each + * list item is respected. This implies that the user of the directive is responsible for + * dealing with whitespace but also allows you to use whitespace as a delimiter, such as a + * tab or newline character. + * * Otherwise whitespace around the delimiter is ignored when splitting (although it is respected + * when joining the list items back together) and whitespace around each list item is stripped + * before it is added to the model. + * + * ### Example with Validation + * + * + * + * angular.module('listExample', []) + * .controller('ExampleController', ['$scope', function($scope) { + * $scope.names = ['morpheus', 'neo', 'trinity']; + * }]); + * + * + *
+ * List: + * + * Required! + *
+ * names = {{names}}
+ * myForm.namesInput.$valid = {{myForm.namesInput.$valid}}
+ * myForm.namesInput.$error = {{myForm.namesInput.$error}}
+ * myForm.$valid = {{myForm.$valid}}
+ * myForm.$error.required = {{!!myForm.$error.required}}
+ *
+ *
+ * + * var listInput = element(by.model('names')); + * var names = element(by.exactBinding('names')); + * var valid = element(by.binding('myForm.namesInput.$valid')); + * var error = element(by.css('span.error')); + * + * it('should initialize to model', function() { + * expect(names.getText()).toContain('["morpheus","neo","trinity"]'); + * expect(valid.getText()).toContain('true'); + * expect(error.getCssValue('display')).toBe('none'); + * }); + * + * it('should be invalid if empty', function() { + * listInput.clear(); + * listInput.sendKeys(''); + * + * expect(names.getText()).toContain(''); + * expect(valid.getText()).toContain('false'); + * expect(error.getCssValue('display')).not.toBe('none'); + * }); + * + *
+ * + * ### Example - splitting on whitespace + * + * + * + *
{{ list | json }}
+ *
+ * + * it("should split the text by newlines", function() { + * var listInput = element(by.model('list')); + * var output = element(by.binding('list | json')); + * listInput.sendKeys('abc\ndef\nghi'); + * expect(output.getText()).toContain('[\n "abc",\n "def",\n "ghi"\n]'); + * }); + * + *
+ * + * @element input + * @param {string=} ngList optional delimiter that should be used to split the value. + */ +var ngListDirective = function() { + return { + restrict: 'A', + priority: 100, + require: 'ngModel', + link: function(scope, element, attr, ctrl) { + // We want to control whitespace trimming so we use this convoluted approach + // to access the ngList attribute, which doesn't pre-trim the attribute + var ngList = element.attr(attr.$attr.ngList) || ', '; + var trimValues = attr.ngTrim !== 'false'; + var separator = trimValues ? trim(ngList) : ngList; + + var parse = function(viewValue) { + // If the viewValue is invalid (say required but empty) it will be `undefined` + if (isUndefined(viewValue)) return; + + var list = []; + + if (viewValue) { + forEach(viewValue.split(separator), function(value) { + if (value) list.push(trimValues ? trim(value) : value); + }); + } + + return list; + }; + + ctrl.$parsers.push(parse); + ctrl.$formatters.push(function(value) { + if (isArray(value)) { + return value.join(ngList); + } + + return undefined; + }); + + // Override the standard $isEmpty because an empty array means the input is empty. + ctrl.$isEmpty = function(value) { + return !value || !value.length; + }; + } + }; +}; diff --git a/src/ng/directive/ngModel.js b/src/ng/directive/ngModel.js new file mode 100644 index 00000000..44cfdbdd --- /dev/null +++ b/src/ng/directive/ngModel.js @@ -0,0 +1,1338 @@ +'use strict'; + +/* global VALID_CLASS: true, + INVALID_CLASS: true, + PRISTINE_CLASS: true, + DIRTY_CLASS: true, + UNTOUCHED_CLASS: true, + TOUCHED_CLASS: true, +*/ + +var VALID_CLASS = 'ng-valid', + INVALID_CLASS = 'ng-invalid', + PRISTINE_CLASS = 'ng-pristine', + DIRTY_CLASS = 'ng-dirty', + UNTOUCHED_CLASS = 'ng-untouched', + TOUCHED_CLASS = 'ng-touched', + PENDING_CLASS = 'ng-pending'; + + +var $ngModelMinErr = new minErr('ngModel'); + +/** + * @ngdoc type + * @name ngModel.NgModelController + * + * @property {string} $viewValue Actual string value in the view. + * @property {*} $modelValue The value in the model that the control is bound to. + * @property {Array.} $parsers Array of functions to execute, as a pipeline, whenever + the control reads value from the DOM. The functions are called in array order, each passing + its return value through to the next. The last return value is forwarded to the + {@link ngModel.NgModelController#$validators `$validators`} collection. + +Parsers are used to sanitize / convert the {@link ngModel.NgModelController#$viewValue +`$viewValue`}. + +Returning `undefined` from a parser means a parse error occurred. In that case, +no {@link ngModel.NgModelController#$validators `$validators`} will run and the `ngModel` +will be set to `undefined` unless {@link ngModelOptions `ngModelOptions.allowInvalid`} +is set to `true`. The parse error is stored in `ngModel.$error.parse`. + + * + * @property {Array.} $formatters Array of functions to execute, as a pipeline, whenever + the model value changes. The functions are called in reverse array order, each passing the value through to the + next. The last return value is used as the actual DOM value. + Used to format / convert values for display in the control. + * ```js + * function formatter(value) { + * if (value) { + * return value.toUpperCase(); + * } + * } + * ngModel.$formatters.push(formatter); + * ``` + * + * @property {Object.} $validators A collection of validators that are applied + * whenever the model value changes. The key value within the object refers to the name of the + * validator while the function refers to the validation operation. The validation operation is + * provided with the model value as an argument and must return a true or false value depending + * on the response of that validation. + * + * ```js + * ngModel.$validators.validCharacters = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * return /[0-9]+/.test(value) && + * /[a-z]+/.test(value) && + * /[A-Z]+/.test(value) && + * /\W+/.test(value); + * }; + * ``` + * + * @property {Object.} $asyncValidators A collection of validations that are expected to + * perform an asynchronous validation (e.g. a HTTP request). The validation function that is provided + * is expected to return a promise when it is run during the model validation process. Once the promise + * is delivered then the validation status will be set to true when fulfilled and false when rejected. + * When the asynchronous validators are triggered, each of the validators will run in parallel and the model + * value will only be updated once all validators have been fulfilled. As long as an asynchronous validator + * is unfulfilled, its key will be added to the controllers `$pending` property. Also, all asynchronous validators + * will only run once all synchronous validators have passed. + * + * Please note that if $http is used then it is important that the server returns a success HTTP response code + * in order to fulfill the validation and a status level of `4xx` in order to reject the validation. + * + * ```js + * ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) { + * var value = modelValue || viewValue; + * + * // Lookup user by username + * return $http.get('/api/users/' + value). + * then(function resolved() { + * //username exists, this means validation fails + * return $q.reject('exists'); + * }, function rejected() { + * //username does not exist, therefore this validation passes + * return true; + * }); + * }; + * ``` + * + * @property {Array.} $viewChangeListeners Array of functions to execute whenever the + * view value has changed. It is called with no arguments, and its return value is ignored. + * This can be used in place of additional $watches against the model value. + * + * @property {Object} $error An object hash with all failing validator ids as keys. + * @property {Object} $pending An object hash with all pending validator ids as keys. + * + * @property {boolean} $untouched True if control has not lost focus yet. + * @property {boolean} $touched True if control has lost focus. + * @property {boolean} $pristine True if user has not interacted with the control yet. + * @property {boolean} $dirty True if user has already interacted with the control. + * @property {boolean} $valid True if there is no error. + * @property {boolean} $invalid True if at least one error on the control. + * @property {string} $name The name attribute of the control. + * + * @description + * + * `NgModelController` provides API for the {@link ngModel `ngModel`} directive. + * The controller contains services for data-binding, validation, CSS updates, and value formatting + * and parsing. It purposefully does not contain any logic which deals with DOM rendering or + * listening to DOM events. + * Such DOM related logic should be provided by other directives which make use of + * `NgModelController` for data-binding to control elements. + * Angular provides this DOM logic for most {@link input `input`} elements. + * At the end of this page you can find a {@link ngModel.NgModelController#custom-control-example + * custom control example} that uses `ngModelController` to bind to `contenteditable` elements. + * + * @example + * ### Custom Control Example + * This example shows how to use `NgModelController` with a custom control to achieve + * data-binding. Notice how different directives (`contenteditable`, `ng-model`, and `required`) + * collaborate together to achieve the desired result. + * + * Note that `contenteditable` is an HTML5 attribute, which tells the browser to let the element + * contents be edited in place by the user. This will not work on older browsers. + * + * We are using the {@link ng.service:$sce $sce} service here and include the {@link ngSanitize $sanitize} + * module to automatically remove "bad" content like inline event listener (e.g. ``). + * However, as we are using `$sce` the model can still decide to provide unsafe content if it marks + * that content using the `$sce` service. + * + * + + [contenteditable] { + border: 1px solid black; + background-color: white; + min-height: 20px; + } + + .ng-invalid { + border: 1px solid red; + } + + + + angular.module('customControl', ['ngSanitize']). + directive('contenteditable', ['$sce', function($sce) { + return { + restrict: 'A', // only activate on element attribute + require: '?ngModel', // get a hold of NgModelController + link: function(scope, element, attrs, ngModel) { + if (!ngModel) return; // do nothing if no ng-model + + // Specify how UI should be updated + ngModel.$render = function() { + element.html($sce.getTrustedHtml(ngModel.$viewValue || '')); + }; + + // Listen for change events to enable binding + element.on('blur keyup change', function() { + scope.$evalAsync(read); + }); + read(); // initialize + + // Write data to the model + function read() { + var html = element.html(); + // When we clear the content editable the browser leaves a
behind + // If strip-br attribute is provided then we strip this out + if ( attrs.stripBr && html == '
' ) { + html = ''; + } + ngModel.$setViewValue(html); + } + } + }; + }]); +
+ +
+
Change me!
+ Required! +
+ +
+
+ + it('should data-bind and become invalid', function() { + if (browser.params.browser == 'safari' || browser.params.browser == 'firefox') { + // SafariDriver can't handle contenteditable + // and Firefox driver can't clear contenteditables very well + return; + } + var contentEditable = element(by.css('[contenteditable]')); + var content = 'Change me!'; + + expect(contentEditable.getText()).toEqual(content); + + contentEditable.clear(); + contentEditable.sendKeys(protractor.Key.BACK_SPACE); + expect(contentEditable.getText()).toEqual(''); + expect(contentEditable.getAttribute('class')).toMatch(/ng-invalid-required/); + }); + + *
+ * + * + */ +var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse', '$animate', '$timeout', '$rootScope', '$q', '$interpolate', + function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q, $interpolate) { + this.$viewValue = Number.NaN; + this.$modelValue = Number.NaN; + this.$$rawModelValue = undefined; // stores the parsed modelValue / model set from scope regardless of validity. + this.$validators = {}; + this.$asyncValidators = {}; + this.$parsers = []; + this.$formatters = []; + this.$viewChangeListeners = []; + this.$untouched = true; + this.$touched = false; + this.$pristine = true; + this.$dirty = false; + this.$valid = true; + this.$invalid = false; + this.$error = {}; // keep invalid keys here + this.$$success = {}; // keep valid keys here + this.$pending = undefined; // keep pending keys here + this.$name = $interpolate($attr.name || '', false)($scope); + + + var parsedNgModel = $parse($attr.ngModel), + parsedNgModelAssign = parsedNgModel.assign, + ngModelGet = parsedNgModel, + ngModelSet = parsedNgModelAssign, + pendingDebounce = null, + ctrl = this; + + this.$$setOptions = function(options) { + ctrl.$options = options; + if (options && options.getterSetter) { + var invokeModelGetter = $parse($attr.ngModel + '()'), + invokeModelSetter = $parse($attr.ngModel + '($$$p)'); + + ngModelGet = function($scope) { + var modelValue = parsedNgModel($scope); + if (isFunction(modelValue)) { + modelValue = invokeModelGetter($scope); + } + return modelValue; + }; + ngModelSet = function($scope, newValue) { + if (isFunction(parsedNgModel($scope))) { + invokeModelSetter($scope, {$$$p: ctrl.$modelValue}); + } else { + parsedNgModelAssign($scope, ctrl.$modelValue); + } + }; + } else if (!parsedNgModel.assign) { + throw $ngModelMinErr('nonassign', "Expression '{0}' is non-assignable. Element: {1}", + $attr.ngModel, startingTag($element)); + } + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$render + * + * @description + * Called when the view needs to be updated. It is expected that the user of the ng-model + * directive will implement this method. + * + * The `$render()` method is invoked in the following situations: + * + * * `$rollbackViewValue()` is called. If we are rolling back the view value to the last + * committed value then `$render()` is called to update the input control. + * * The value referenced by `ng-model` is changed programmatically and both the `$modelValue` and + * the `$viewValue` are different to last time. + * + * Since `ng-model` does not do a deep watch, `$render()` is only invoked if the values of + * `$modelValue` and `$viewValue` are actually different to their previous value. If `$modelValue` + * or `$viewValue` are objects (rather than a string or number) then `$render()` will not be + * invoked if you only change a property on the objects. + */ + this.$render = noop; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$isEmpty + * + * @description + * This is called when we need to determine if the value of an input is empty. + * + * For instance, the required directive does this to work out if the input has data or not. + * + * The default `$isEmpty` function checks whether the value is `undefined`, `''`, `null` or `NaN`. + * + * You can override this for input directives whose concept of being empty is different to the + * default. The `checkboxInputType` directive does this because in its case a value of `false` + * implies empty. + * + * @param {*} value The value of the input to check for emptiness. + * @returns {boolean} True if `value` is "empty". + */ + this.$isEmpty = function(value) { + return isUndefined(value) || value === '' || value === null || value !== value; + }; + + var parentForm = $element.inheritedData('$formController') || nullFormCtrl, + currentValidationRunId = 0; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setValidity + * + * @description + * Change the validity state, and notify the form. + * + * This method can be called within $parsers/$formatters or a custom validation implementation. + * However, in most cases it should be sufficient to use the `ngModel.$validators` and + * `ngModel.$asyncValidators` collections which will call `$setValidity` automatically. + * + * @param {string} validationErrorKey Name of the validator. The `validationErrorKey` will be assigned + * to either `$error[validationErrorKey]` or `$pending[validationErrorKey]` + * (for unfulfilled `$asyncValidators`), so that it is available for data-binding. + * The `validationErrorKey` should be in camelCase and will get converted into dash-case + * for class name. Example: `myError` will result in `ng-valid-my-error` and `ng-invalid-my-error` + * class and can be bound to as `{{someForm.someControl.$error.myError}}` . + * @param {boolean} isValid Whether the current state is valid (true), invalid (false), pending (undefined), + * or skipped (null). Pending is used for unfulfilled `$asyncValidators`. + * Skipped is used by Angular when validators do not run because of parse errors and + * when `$asyncValidators` do not run because any of the `$validators` failed. + */ + addSetValidityMethod({ + ctrl: this, + $element: $element, + set: function(object, property) { + object[property] = true; + }, + unset: function(object, property) { + delete object[property]; + }, + parentForm: parentForm, + $animate: $animate + }); + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setPristine + * + * @description + * Sets the control to its pristine state. + * + * This method can be called to remove the `ng-dirty` class and set the control to its pristine + * state (`ng-pristine` class). A model is considered to be pristine when the control + * has not been changed from when first compiled. + */ + this.$setPristine = function() { + ctrl.$dirty = false; + ctrl.$pristine = true; + $animate.removeClass($element, DIRTY_CLASS); + $animate.addClass($element, PRISTINE_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setDirty + * + * @description + * Sets the control to its dirty state. + * + * This method can be called to remove the `ng-pristine` class and set the control to its dirty + * state (`ng-dirty` class). A model is considered to be dirty when the control has been changed + * from when first compiled. + */ + this.$setDirty = function() { + ctrl.$dirty = true; + ctrl.$pristine = false; + $animate.removeClass($element, PRISTINE_CLASS); + $animate.addClass($element, DIRTY_CLASS); + parentForm.$setDirty(); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setUntouched + * + * @description + * Sets the control to its untouched state. + * + * This method can be called to remove the `ng-touched` class and set the control to its + * untouched state (`ng-untouched` class). Upon compilation, a model is set as untouched + * by default, however this function can be used to restore that state if the model has + * already been touched by the user. + */ + this.$setUntouched = function() { + ctrl.$touched = false; + ctrl.$untouched = true; + $animate.setClass($element, UNTOUCHED_CLASS, TOUCHED_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setTouched + * + * @description + * Sets the control to its touched state. + * + * This method can be called to remove the `ng-untouched` class and set the control to its + * touched state (`ng-touched` class). A model is considered to be touched when the user has + * first focused the control element and then shifted focus away from the control (blur event). + */ + this.$setTouched = function() { + ctrl.$touched = true; + ctrl.$untouched = false; + $animate.setClass($element, TOUCHED_CLASS, UNTOUCHED_CLASS); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$rollbackViewValue + * + * @description + * Cancel an update and reset the input element's value to prevent an update to the `$modelValue`, + * which may be caused by a pending debounced event or because the input is waiting for a some + * future event. + * + * If you have an input that uses `ng-model-options` to set up debounced events or events such + * as blur you can have a situation where there is a period when the `$viewValue` + * is out of synch with the ngModel's `$modelValue`. + * + * In this case, you can run into difficulties if you try to update the ngModel's `$modelValue` + * programmatically before these debounced/future events have resolved/occurred, because Angular's + * dirty checking mechanism is not able to tell whether the model has actually changed or not. + * + * The `$rollbackViewValue()` method should be called before programmatically changing the model of an + * input which may have such events pending. This is important in order to make sure that the + * input field will be updated with the new model value and any pending operations are cancelled. + * + * + * + * angular.module('cancel-update-example', []) + * + * .controller('CancelUpdateController', ['$scope', function($scope) { + * $scope.resetWithCancel = function(e) { + * if (e.keyCode == 27) { + * $scope.myForm.myInput1.$rollbackViewValue(); + * $scope.myValue = ''; + * } + * }; + * $scope.resetWithoutCancel = function(e) { + * if (e.keyCode == 27) { + * $scope.myValue = ''; + * } + * }; + * }]); + * + * + *
+ *

Try typing something in each input. See that the model only updates when you + * blur off the input. + *

+ *

Now see what happens if you start typing then press the Escape key

+ * + *
+ *

With $rollbackViewValue()

+ *
+ * myValue: "{{ myValue }}" + * + *

Without $rollbackViewValue()

+ *
+ * myValue: "{{ myValue }}" + *
+ *
+ *
+ *
+ */ + this.$rollbackViewValue = function() { + $timeout.cancel(pendingDebounce); + ctrl.$viewValue = ctrl.$$lastCommittedViewValue; + ctrl.$render(); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$validate + * + * @description + * Runs each of the registered validators (first synchronous validators and then + * asynchronous validators). + * If the validity changes to invalid, the model will be set to `undefined`, + * unless {@link ngModelOptions `ngModelOptions.allowInvalid`} is `true`. + * If the validity changes to valid, it will set the model to the last available valid + * modelValue, i.e. either the last parsed value or the last value set from the scope. + */ + this.$validate = function() { + // ignore $validate before model is initialized + if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { + return; + } + + var viewValue = ctrl.$$lastCommittedViewValue; + // Note: we use the $$rawModelValue as $modelValue might have been + // set to undefined during a view -> model update that found validation + // errors. We can't parse the view here, since that could change + // the model although neither viewValue nor the model on the scope changed + var modelValue = ctrl.$$rawModelValue; + + // Check if the there's a parse error, so we don't unset it accidentially + var parserName = ctrl.$$parserName || 'parse'; + var parserValid = ctrl.$error[parserName] ? false : undefined; + + var prevValid = ctrl.$valid; + var prevModelValue = ctrl.$modelValue; + + var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; + + ctrl.$$runValidators(parserValid, modelValue, viewValue, function(allValid) { + // If there was no change in validity, don't update the model + // This prevents changing an invalid modelValue to undefined + if (!allowInvalid && prevValid !== allValid) { + // Note: Don't check ctrl.$valid here, as we could have + // external validators (e.g. calculated on the server), + // that just call $setValidity and need the model value + // to calculate their validity. + ctrl.$modelValue = allValid ? modelValue : undefined; + + if (ctrl.$modelValue !== prevModelValue) { + ctrl.$$writeModelToScope(); + } + } + }); + + }; + + this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) { + currentValidationRunId++; + var localValidationRunId = currentValidationRunId; + + // check parser error + if (!processParseErrors(parseValid)) { + validationDone(false); + return; + } + if (!processSyncValidators()) { + validationDone(false); + return; + } + processAsyncValidators(); + + function processParseErrors(parseValid) { + var errorKey = ctrl.$$parserName || 'parse'; + if (parseValid === undefined) { + setValidity(errorKey, null); + } else { + setValidity(errorKey, parseValid); + if (!parseValid) { + forEach(ctrl.$validators, function(v, name) { + setValidity(name, null); + }); + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + return false; + } + } + return true; + } + + function processSyncValidators() { + var syncValidatorsValid = true; + forEach(ctrl.$validators, function(validator, name) { + var result = validator(modelValue, viewValue); + syncValidatorsValid = syncValidatorsValid && result; + setValidity(name, result); + }); + if (!syncValidatorsValid) { + forEach(ctrl.$asyncValidators, function(v, name) { + setValidity(name, null); + }); + return false; + } + return true; + } + + function processAsyncValidators() { + var validatorPromises = []; + var allValid = true; + forEach(ctrl.$asyncValidators, function(validator, name) { + var promise = validator(modelValue, viewValue); + if (!isPromiseLike(promise)) { + throw $ngModelMinErr("$asyncValidators", + "Expected asynchronous validator to return a promise but got '{0}' instead.", promise); + } + setValidity(name, undefined); + validatorPromises.push(promise.then(function() { + setValidity(name, true); + }, function(error) { + allValid = false; + setValidity(name, false); + })); + }); + if (!validatorPromises.length) { + validationDone(true); + } else { + $q.all(validatorPromises).then(function() { + validationDone(allValid); + }, noop); + } + } + + function setValidity(name, isValid) { + if (localValidationRunId === currentValidationRunId) { + ctrl.$setValidity(name, isValid); + } + } + + function validationDone(allValid) { + if (localValidationRunId === currentValidationRunId) { + + doneCallback(allValid); + } + } + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$commitViewValue + * + * @description + * Commit a pending update to the `$modelValue`. + * + * Updates may be pending by a debounced event or because the input is waiting for a some future + * event defined in `ng-model-options`. this method is rarely needed as `NgModelController` + * usually handles calling this in response to input events. + */ + this.$commitViewValue = function() { + var viewValue = ctrl.$viewValue; + + $timeout.cancel(pendingDebounce); + + // If the view value has not changed then we should just exit, except in the case where there is + // a native validator on the element. In this case the validation state may have changed even though + // the viewValue has stayed empty. + if (ctrl.$$lastCommittedViewValue === viewValue && (viewValue !== '' || !ctrl.$$hasNativeValidators)) { + return; + } + ctrl.$$lastCommittedViewValue = viewValue; + + // change to dirty + if (ctrl.$pristine) { + this.$setDirty(); + } + this.$$parseAndValidate(); + }; + + this.$$parseAndValidate = function() { + var viewValue = ctrl.$$lastCommittedViewValue; + var modelValue = viewValue; + var parserValid = isUndefined(modelValue) ? undefined : true; + + if (parserValid) { + for (var i = 0; i < ctrl.$parsers.length; i++) { + modelValue = ctrl.$parsers[i](modelValue); + if (isUndefined(modelValue)) { + parserValid = false; + break; + } + } + } + if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) { + // ctrl.$modelValue has not been touched yet... + ctrl.$modelValue = ngModelGet($scope); + } + var prevModelValue = ctrl.$modelValue; + var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid; + ctrl.$$rawModelValue = modelValue; + + if (allowInvalid) { + ctrl.$modelValue = modelValue; + writeToModelIfNeeded(); + } + + // Pass the $$lastCommittedViewValue here, because the cached viewValue might be out of date. + // This can happen if e.g. $setViewValue is called from inside a parser + ctrl.$$runValidators(parserValid, modelValue, ctrl.$$lastCommittedViewValue, function(allValid) { + if (!allowInvalid) { + // Note: Don't check ctrl.$valid here, as we could have + // external validators (e.g. calculated on the server), + // that just call $setValidity and need the model value + // to calculate their validity. + ctrl.$modelValue = allValid ? modelValue : undefined; + writeToModelIfNeeded(); + } + }); + + function writeToModelIfNeeded() { + if (ctrl.$modelValue !== prevModelValue) { + ctrl.$$writeModelToScope(); + } + } + }; + + this.$$writeModelToScope = function() { + ngModelSet($scope, ctrl.$modelValue); + forEach(ctrl.$viewChangeListeners, function(listener) { + try { + listener(); + } catch (e) { + $exceptionHandler(e); + } + }); + }; + + /** + * @ngdoc method + * @name ngModel.NgModelController#$setViewValue + * + * @description + * Update the view value. + * + * This method should be called when an input directive want to change the view value; typically, + * this is done from within a DOM event handler. + * + * For example {@link ng.directive:input input} calls it when the value of the input changes and + * {@link ng.directive:select select} calls it when an option is selected. + * + * If the new `value` is an object (rather than a string or a number), we should make a copy of the + * object before passing it to `$setViewValue`. This is because `ngModel` does not perform a deep + * watch of objects, it only looks for a change of identity. If you only change the property of + * the object then ngModel will not realise that the object has changed and will not invoke the + * `$parsers` and `$validators` pipelines. + * + * For this reason, you should not change properties of the copy once it has been passed to + * `$setViewValue`. Otherwise you may cause the model value on the scope to change incorrectly. + * + * When this method is called, the new `value` will be staged for committing through the `$parsers` + * and `$validators` pipelines. If there are no special {@link ngModelOptions} specified then the staged + * value sent directly for processing, finally to be applied to `$modelValue` and then the + * **expression** specified in the `ng-model` attribute. + * + * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called. + * + * In case the {@link ng.directive:ngModelOptions ngModelOptions} directive is used with `updateOn` + * and the `default` trigger is not listed, all those actions will remain pending until one of the + * `updateOn` events is triggered on the DOM element. + * All these actions will be debounced if the {@link ng.directive:ngModelOptions ngModelOptions} + * directive is used with a custom debounce for this particular event. + * + * Note that calling this function does not trigger a `$digest`. + * + * @param {string} value Value from the view. + * @param {string} trigger Event that triggered the update. + */ + this.$setViewValue = function(value, trigger) { + ctrl.$viewValue = value; + if (!ctrl.$options || ctrl.$options.updateOnDefault) { + ctrl.$$debounceViewValueCommit(trigger); + } + }; + + this.$$debounceViewValueCommit = function(trigger) { + var debounceDelay = 0, + options = ctrl.$options, + debounce; + + if (options && isDefined(options.debounce)) { + debounce = options.debounce; + if (isNumber(debounce)) { + debounceDelay = debounce; + } else if (isNumber(debounce[trigger])) { + debounceDelay = debounce[trigger]; + } else if (isNumber(debounce['default'])) { + debounceDelay = debounce['default']; + } + } + + $timeout.cancel(pendingDebounce); + if (debounceDelay) { + pendingDebounce = $timeout(function() { + ctrl.$commitViewValue(); + }, debounceDelay); + } else if ($rootScope.$$phase) { + ctrl.$commitViewValue(); + } else { + $scope.$apply(function() { + ctrl.$commitViewValue(); + }); + } + }; + + // model -> value + // Note: we cannot use a normal scope.$watch as we want to detect the following: + // 1. scope value is 'a' + // 2. user enters 'b' + // 3. ng-change kicks in and reverts scope value to 'a' + // -> scope value did not change since the last digest as + // ng-change executes in apply phase + // 4. view should be changed back to 'a' + $scope.$watch(function ngModelWatch() { + var modelValue = ngModelGet($scope); + + // if scope model value and ngModel value are out of sync + // TODO(perf): why not move this to the action fn? + if (modelValue !== ctrl.$modelValue) { + ctrl.$modelValue = ctrl.$$rawModelValue = modelValue; + + var formatters = ctrl.$formatters, + idx = formatters.length; + + var viewValue = modelValue; + while (idx--) { + viewValue = formatters[idx](viewValue); + } + if (ctrl.$viewValue !== viewValue) { + ctrl.$viewValue = ctrl.$$lastCommittedViewValue = viewValue; + ctrl.$render(); + + ctrl.$$runValidators(undefined, modelValue, viewValue, noop); + } + } + + return modelValue; + }); +}]; + + +/** + * @ngdoc directive + * @name ngModel + * + * @element input + * @priority 1 + * + * @description + * The `ngModel` directive binds an `input`,`select`, `textarea` (or custom form control) to a + * property on the scope using {@link ngModel.NgModelController NgModelController}, + * which is created and exposed by this directive. + * + * `ngModel` is responsible for: + * + * - Binding the view into the model, which other directives such as `input`, `textarea` or `select` + * require. + * - Providing validation behavior (i.e. required, number, email, url). + * - Keeping the state of the control (valid/invalid, dirty/pristine, touched/untouched, validation errors). + * - Setting related css classes on the element (`ng-valid`, `ng-invalid`, `ng-dirty`, `ng-pristine`, `ng-touched`, `ng-untouched`) including animations. + * - Registering the control with its parent {@link ng.directive:form form}. + * + * Note: `ngModel` will try to bind to the property given by evaluating the expression on the + * current scope. If the property doesn't already exist on this scope, it will be created + * implicitly and added to the scope. + * + * For best practices on using `ngModel`, see: + * + * - [Understanding Scopes](https://github.com/angular/angular.js/wiki/Understanding-Scopes) + * + * For basic examples, how to use `ngModel`, see: + * + * - {@link ng.directive:input input} + * - {@link input[text] text} + * - {@link input[checkbox] checkbox} + * - {@link input[radio] radio} + * - {@link input[number] number} + * - {@link input[email] email} + * - {@link input[url] url} + * - {@link input[date] date} + * - {@link input[datetime-local] datetime-local} + * - {@link input[time] time} + * - {@link input[month] month} + * - {@link input[week] week} + * - {@link ng.directive:select select} + * - {@link ng.directive:textarea textarea} + * + * # CSS classes + * The following CSS classes are added and removed on the associated input/select/textarea element + * depending on the validity of the model. + * + * - `ng-valid`: the model is valid + * - `ng-invalid`: the model is invalid + * - `ng-valid-[key]`: for each valid key added by `$setValidity` + * - `ng-invalid-[key]`: for each invalid key added by `$setValidity` + * - `ng-pristine`: the control hasn't been interacted with yet + * - `ng-dirty`: the control has been interacted with + * - `ng-touched`: the control has been blurred + * - `ng-untouched`: the control hasn't been blurred + * - `ng-pending`: any `$asyncValidators` are unfulfilled + * + * Keep in mind that ngAnimate can detect each of these classes when added and removed. + * + * ## Animation Hooks + * + * Animations within models are triggered when any of the associated CSS classes are added and removed + * on the input element which is attached to the model. These classes are: `.ng-pristine`, `.ng-dirty`, + * `.ng-invalid` and `.ng-valid` as well as any other validations that are performed on the model itself. + * The animations that are triggered within ngModel are similar to how they work in ngClass and + * animations can be hooked into using CSS transitions, keyframes as well as JS animations. + * + * The following example shows a simple way to utilize CSS transitions to style an input element + * that has been rendered as invalid after it has been validated: + * + *
+ * //be sure to include ngAnimate as a module to hook into more
+ * //advanced animations
+ * .my-input {
+ *   transition:0.5s linear all;
+ *   background: white;
+ * }
+ * .my-input.ng-invalid {
+ *   background: red;
+ *   color:white;
+ * }
+ * 
+ * + * @example + * + + + + Update input to see transitions when valid/invalid. + Integer is a valid value. +
+ +
+
+ *
+ * + * ## Binding to a getter/setter + * + * Sometimes it's helpful to bind `ngModel` to a getter/setter function. A getter/setter is a + * function that returns a representation of the model when called with zero arguments, and sets + * the internal state of a model when called with an argument. It's sometimes useful to use this + * for models that have an internal representation that's different than what the model exposes + * to the view. + * + *
+ * **Best Practice:** It's best to keep getters fast because Angular is likely to call them more + * frequently than other parts of your code. + *
+ * + * You use this behavior by adding `ng-model-options="{ getterSetter: true }"` to an element that + * has `ng-model` attached to it. You can also add `ng-model-options="{ getterSetter: true }"` to + * a `
`, which will enable this behavior for all ``s within it. See + * {@link ng.directive:ngModelOptions `ngModelOptions`} for more. + * + * The following example shows how to use `ngModel` with a getter/setter: + * + * @example + * + +
+ + Name: + + +
user.name = 
+
+
+ + angular.module('getterSetterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + var _name = 'Brian'; + $scope.user = { + name: function(newName) { + if (angular.isDefined(newName)) { + _name = newName; + } + return _name; + } + }; + }]); + + *
+ */ +var ngModelDirective = ['$rootScope', function($rootScope) { + return { + restrict: 'A', + require: ['ngModel', '^?form', '^?ngModelOptions'], + controller: NgModelController, + // Prelink needs to run before any input directive + // so that we can set the NgModelOptions in NgModelController + // before anyone else uses it. + priority: 1, + compile: function ngModelCompile(element) { + // Setup initial state of the control + element.addClass(PRISTINE_CLASS).addClass(UNTOUCHED_CLASS).addClass(VALID_CLASS); + + return { + pre: function ngModelPreLink(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0], + formCtrl = ctrls[1] || nullFormCtrl; + + modelCtrl.$$setOptions(ctrls[2] && ctrls[2].$options); + + // notify others, especially parent forms + formCtrl.$addControl(modelCtrl); + + attr.$observe('name', function(newValue) { + if (modelCtrl.$name !== newValue) { + formCtrl.$$renameControl(modelCtrl, newValue); + } + }); + + scope.$on('$destroy', function() { + formCtrl.$removeControl(modelCtrl); + }); + }, + post: function ngModelPostLink(scope, element, attr, ctrls) { + var modelCtrl = ctrls[0]; + if (modelCtrl.$options && modelCtrl.$options.updateOn) { + element.on(modelCtrl.$options.updateOn, function(ev) { + modelCtrl.$$debounceViewValueCommit(ev && ev.type); + }); + } + + element.on('blur', function(ev) { + if (modelCtrl.$touched) return; + + if ($rootScope.$$phase) { + scope.$evalAsync(modelCtrl.$setTouched); + } else { + scope.$apply(modelCtrl.$setTouched); + } + }); + } + }; + } + }; +}]; + + + +'use strict'; + +var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/; + +/** + * @ngdoc directive + * @name ngModelOptions + * + * @description + * Allows tuning how model updates are done. Using `ngModelOptions` you can specify a custom list of + * events that will trigger a model update and/or a debouncing delay so that the actual update only + * takes place when a timer expires; this timer will be reset after another change takes place. + * + * Given the nature of `ngModelOptions`, the value displayed inside input fields in the view might + * be different than the value in the actual model. This means that if you update the model you + * should also invoke {@link ngModel.NgModelController `$rollbackViewValue`} on the relevant input field in + * order to make sure it is synchronized with the model and that any debounced action is canceled. + * + * The easiest way to reference the control's {@link ngModel.NgModelController `$rollbackViewValue`} + * method is by making sure the input is placed inside a form that has a `name` attribute. This is + * important because `form` controllers are published to the related scope under the name in their + * `name` attribute. + * + * Any pending changes will take place immediately when an enclosing form is submitted via the + * `submit` event. Note that `ngClick` events will occur before the model is updated. Use `ngSubmit` + * to have access to the updated model. + * + * `ngModelOptions` has an effect on the element it's declared on and its descendants. + * + * @param {Object} ngModelOptions options to apply to the current model. Valid keys are: + * - `updateOn`: string specifying which event should the input be bound to. You can set several + * events using an space delimited list. There is a special event called `default` that + * matches the default events belonging of the control. + * - `debounce`: integer value which contains the debounce model update value in milliseconds. A + * value of 0 triggers an immediate update. If an object is supplied instead, you can specify a + * custom value for each event. For example: + * `ng-model-options="{ updateOn: 'default blur', debounce: {'default': 500, 'blur': 0} }"` + * - `allowInvalid`: boolean value which indicates that the model can be set with values that did + * not validate correctly instead of the default behavior of setting the model to undefined. + * - `getterSetter`: boolean value which determines whether or not to treat functions bound to + `ngModel` as getters/setters. + * - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for + * ``, ``, ... . Right now, the only supported value is `'UTC'`, + * otherwise the default timezone of the browser will be used. + * + * @example + + The following example shows how to override immediate updates. Changes on the inputs within the + form will update the model only when the control loses focus (blur event). If `escape` key is + pressed while the input field is focused, the value is reset to the value in the current model. + + + +
+
+ Name: +
+ + Other data: +
+
+
user.name = 
+
+
+ + angular.module('optionsExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.user = { name: 'say', data: '' }; + + $scope.cancel = function(e) { + if (e.keyCode == 27) { + $scope.userForm.userName.$rollbackViewValue(); + } + }; + }]); + + + var model = element(by.binding('user.name')); + var input = element(by.model('user.name')); + var other = element(by.model('user.data')); + + it('should allow custom events', function() { + input.sendKeys(' hello'); + input.click(); + expect(model.getText()).toEqual('say'); + other.click(); + expect(model.getText()).toEqual('say hello'); + }); + + it('should $rollbackViewValue when model changes', function() { + input.sendKeys(' hello'); + expect(input.getAttribute('value')).toEqual('say hello'); + input.sendKeys(protractor.Key.ESCAPE); + expect(input.getAttribute('value')).toEqual('say'); + other.click(); + expect(model.getText()).toEqual('say'); + }); + +
+ + This one shows how to debounce model changes. Model will be updated only 1 sec after last change. + If the `Clear` button is pressed, any debounced action is canceled and the value becomes empty. + + + +
+
+ Name: + +
+
+
user.name = 
+
+
+ + angular.module('optionsExample', []) + .controller('ExampleController', ['$scope', function($scope) { + $scope.user = { name: 'say' }; + }]); + +
+ + This one shows how to bind to getter/setters: + + + +
+
+ Name: + +
+
user.name = 
+
+
+ + angular.module('getterSetterExample', []) + .controller('ExampleController', ['$scope', function($scope) { + var _name = 'Brian'; + $scope.user = { + name: function(newName) { + return angular.isDefined(newName) ? (_name = newName) : _name; + } + }; + }]); + +
+ */ +var ngModelOptionsDirective = function() { + return { + restrict: 'A', + controller: ['$scope', '$attrs', function($scope, $attrs) { + var that = this; + this.$options = $scope.$eval($attrs.ngModelOptions); + // Allow adding/overriding bound events + if (this.$options.updateOn !== undefined) { + this.$options.updateOnDefault = false; + // extract "default" pseudo-event from list of events that can trigger a model update + this.$options.updateOn = trim(this.$options.updateOn.replace(DEFAULT_REGEXP, function() { + that.$options.updateOnDefault = true; + return ' '; + })); + } else { + this.$options.updateOnDefault = true; + } + }] + }; +}; + + + +// helper methods +function addSetValidityMethod(context) { + var ctrl = context.ctrl, + $element = context.$element, + classCache = {}, + set = context.set, + unset = context.unset, + parentForm = context.parentForm, + $animate = context.$animate; + + classCache[INVALID_CLASS] = !(classCache[VALID_CLASS] = $element.hasClass(VALID_CLASS)); + + ctrl.$setValidity = setValidity; + + function setValidity(validationErrorKey, state, options) { + if (state === undefined) { + createAndSet('$pending', validationErrorKey, options); + } else { + unsetAndCleanup('$pending', validationErrorKey, options); + } + if (!isBoolean(state)) { + unset(ctrl.$error, validationErrorKey, options); + unset(ctrl.$$success, validationErrorKey, options); + } else { + if (state) { + unset(ctrl.$error, validationErrorKey, options); + set(ctrl.$$success, validationErrorKey, options); + } else { + set(ctrl.$error, validationErrorKey, options); + unset(ctrl.$$success, validationErrorKey, options); + } + } + if (ctrl.$pending) { + cachedToggleClass(PENDING_CLASS, true); + ctrl.$valid = ctrl.$invalid = undefined; + toggleValidationCss('', null); + } else { + cachedToggleClass(PENDING_CLASS, false); + ctrl.$valid = isObjectEmpty(ctrl.$error); + ctrl.$invalid = !ctrl.$valid; + toggleValidationCss('', ctrl.$valid); + } + + // re-read the state as the set/unset methods could have + // combined state in ctrl.$error[validationError] (used for forms), + // where setting/unsetting only increments/decrements the value, + // and does not replace it. + var combinedState; + if (ctrl.$pending && ctrl.$pending[validationErrorKey]) { + combinedState = undefined; + } else if (ctrl.$error[validationErrorKey]) { + combinedState = false; + } else if (ctrl.$$success[validationErrorKey]) { + combinedState = true; + } else { + combinedState = null; + } + toggleValidationCss(validationErrorKey, combinedState); + parentForm.$setValidity(validationErrorKey, combinedState, ctrl); + } + + function createAndSet(name, value, options) { + if (!ctrl[name]) { + ctrl[name] = {}; + } + set(ctrl[name], value, options); + } + + function unsetAndCleanup(name, value, options) { + if (ctrl[name]) { + unset(ctrl[name], value, options); + } + if (isObjectEmpty(ctrl[name])) { + ctrl[name] = undefined; + } + } + + function cachedToggleClass(className, switchValue) { + if (switchValue && !classCache[className]) { + $animate.addClass($element, className); + classCache[className] = true; + } else if (!switchValue && classCache[className]) { + $animate.removeClass($element, className); + classCache[className] = false; + } + } + + function toggleValidationCss(validationErrorKey, isValid) { + validationErrorKey = validationErrorKey ? '-' + snake_case(validationErrorKey, '-') : ''; + + cachedToggleClass(VALID_CLASS + validationErrorKey, isValid === true); + cachedToggleClass(INVALID_CLASS + validationErrorKey, isValid === false); + } +} + +function isObjectEmpty(obj) { + if (obj) { + for (var prop in obj) { + return false; + } + } + return true; +} diff --git a/src/ng/directive/validators.js b/src/ng/directive/validators.js new file mode 100644 index 00000000..465ba639 --- /dev/null +++ b/src/ng/directive/validators.js @@ -0,0 +1,91 @@ +'use strict'; + +var requiredDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + attr.required = true; // force truthy in case we are on non input element + + ctrl.$validators.required = function(modelValue, viewValue) { + return !attr.required || !ctrl.$isEmpty(viewValue); + }; + + attr.$observe('required', function() { + ctrl.$validate(); + }); + } + }; +}; + + +var patternDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var regexp, patternExp = attr.ngPattern || attr.pattern; + attr.$observe('pattern', function(regex) { + if (isString(regex) && regex.length > 0) { + regex = new RegExp('^' + regex + '$'); + } + + if (regex && !regex.test) { + throw minErr('ngPattern')('noregexp', + 'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp, + regex, startingTag(elm)); + } + + regexp = regex || undefined; + ctrl.$validate(); + }); + + ctrl.$validators.pattern = function(value) { + return ctrl.$isEmpty(value) || isUndefined(regexp) || regexp.test(value); + }; + } + }; +}; + + +var maxlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var maxlength = -1; + attr.$observe('maxlength', function(value) { + var intVal = int(value); + maxlength = isNaN(intVal) ? -1 : intVal; + ctrl.$validate(); + }); + ctrl.$validators.maxlength = function(modelValue, viewValue) { + return (maxlength < 0) || ctrl.$isEmpty(modelValue) || (viewValue.length <= maxlength); + }; + } + }; +}; + +var minlengthDirective = function() { + return { + restrict: 'A', + require: '?ngModel', + link: function(scope, elm, attr, ctrl) { + if (!ctrl) return; + + var minlength = 0; + attr.$observe('minlength', function(value) { + minlength = int(value) || 0; + ctrl.$validate(); + }); + ctrl.$validators.minlength = function(modelValue, viewValue) { + return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength; + }; + } + }; +}; diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index f0341722..1fa360d3 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1,1462 +1,5 @@ 'use strict'; -describe('NgModelController', function() { - /* global NgModelController: false */ - var ctrl, scope, ngModelAccessor, element, parentFormCtrl; - - beforeEach(inject(function($rootScope, $controller) { - var attrs = {name: 'testAlias', ngModel: 'value'}; - - parentFormCtrl = { - $$setPending: jasmine.createSpy('$$setPending'), - $setValidity: jasmine.createSpy('$setValidity'), - $setDirty: jasmine.createSpy('$setDirty'), - $$clearControlValidity: noop - }; - - element = jqLite('
'); - element.data('$formController', parentFormCtrl); - - scope = $rootScope; - ngModelAccessor = jasmine.createSpy('ngModel accessor'); - ctrl = $controller(NgModelController, { - $scope: scope, - $element: element.find('input'), - $attrs: attrs - }); - })); - - - afterEach(function() { - dealoc(element); - }); - - - it('should init the properties', function() { - expect(ctrl.$untouched).toBe(true); - expect(ctrl.$touched).toBe(false); - 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(); - - expect(ctrl.$formatters).toEqual([]); - expect(ctrl.$parsers).toEqual([]); - - expect(ctrl.$name).toBe('testAlias'); - }); - - - describe('setValidity', function() { - - function expectOneError() { - expect(ctrl.$error).toEqual({someError: true}); - expect(ctrl.$$success).toEqual({}); - expect(ctrl.$pending).toBeUndefined(); - } - - function expectOneSuccess() { - expect(ctrl.$error).toEqual({}); - expect(ctrl.$$success).toEqual({someError: true}); - expect(ctrl.$pending).toBeUndefined(); - } - - function expectOnePending() { - expect(ctrl.$error).toEqual({}); - expect(ctrl.$$success).toEqual({}); - expect(ctrl.$pending).toEqual({someError: true}); - } - - function expectCleared() { - expect(ctrl.$error).toEqual({}); - expect(ctrl.$$success).toEqual({}); - expect(ctrl.$pending).toBeUndefined(); - } - - it('should propagate validity to the parent form', function() { - expect(parentFormCtrl.$setValidity).not.toHaveBeenCalled(); - ctrl.$setValidity('ERROR', false); - expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('ERROR', false, ctrl); - }); - - it('should transition from states correctly', function() { - expectCleared(); - - ctrl.$setValidity('someError', false); - expectOneError(); - - ctrl.$setValidity('someError', undefined); - expectOnePending(); - - ctrl.$setValidity('someError', true); - expectOneSuccess(); - - ctrl.$setValidity('someError', null); - expectCleared(); - }); - - it('should set valid/invalid with multiple errors', function() { - ctrl.$setValidity('first', false); - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - - ctrl.$setValidity('second', false); - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - - ctrl.$setValidity('third', undefined); - expect(ctrl.$valid).toBe(undefined); - expect(ctrl.$invalid).toBe(undefined); - - ctrl.$setValidity('third', null); - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - - ctrl.$setValidity('second', true); - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - - ctrl.$setValidity('first', true); - expect(ctrl.$valid).toBe(true); - expect(ctrl.$invalid).toBe(false); - }); - - }); - - describe('setPristine', function() { - - it('should set control to its pristine state', function() { - ctrl.$setViewValue('edit'); - expect(ctrl.$dirty).toBe(true); - expect(ctrl.$pristine).toBe(false); - - ctrl.$setPristine(); - expect(ctrl.$dirty).toBe(false); - expect(ctrl.$pristine).toBe(true); - }); - }); - - describe('setDirty', function() { - - it('should set control to its dirty state', function() { - expect(ctrl.$pristine).toBe(true); - expect(ctrl.$dirty).toBe(false); - - ctrl.$setDirty(); - expect(ctrl.$pristine).toBe(false); - expect(ctrl.$dirty).toBe(true); - }); - - it('should set parent form to its dirty state', function() { - ctrl.$setDirty(); - expect(parentFormCtrl.$setDirty).toHaveBeenCalled(); - }); - }); - - describe('setUntouched', function() { - - it('should set control to its untouched state', function() { - ctrl.$setTouched(); - - ctrl.$setUntouched(); - expect(ctrl.$touched).toBe(false); - expect(ctrl.$untouched).toBe(true); - }); - }); - - describe('setTouched', function() { - - it('should set control to its touched state', function() { - ctrl.$setUntouched(); - - ctrl.$setTouched(); - expect(ctrl.$touched).toBe(true); - expect(ctrl.$untouched).toBe(false); - }); - }); - - describe('view -> model', function() { - - it('should set the value to $viewValue', function() { - ctrl.$setViewValue('some-val'); - expect(ctrl.$viewValue).toBe('some-val'); - }); - - - it('should pipeline all registered parsers and set result to $modelValue', function() { - var log = []; - - ctrl.$parsers.push(function(value) { - log.push(value); - return value + '-a'; - }); - - ctrl.$parsers.push(function(value) { - log.push(value); - return value + '-b'; - }); - - ctrl.$setViewValue('init'); - expect(log).toEqual(['init', 'init-a']); - expect(ctrl.$modelValue).toBe('init-a-b'); - }); - - - it('should fire viewChangeListeners when the value changes in the view (even if invalid)', - function() { - var spy = jasmine.createSpy('viewChangeListener'); - ctrl.$viewChangeListeners.push(spy); - ctrl.$setViewValue('val'); - expect(spy).toHaveBeenCalledOnce(); - spy.reset(); - - // invalid - ctrl.$parsers.push(function() {return undefined;}); - ctrl.$setViewValue('val2'); - expect(spy).toHaveBeenCalledOnce(); - }); - - - it('should reset the model when the view is invalid', function() { - ctrl.$setViewValue('aaaa'); - expect(ctrl.$modelValue).toBe('aaaa'); - - // add a validator that will make any input invalid - ctrl.$parsers.push(function() {return undefined;}); - expect(ctrl.$modelValue).toBe('aaaa'); - ctrl.$setViewValue('bbbb'); - expect(ctrl.$modelValue).toBeUndefined(); - }); - - it('should not reset the model when the view is invalid due to an external validator', function() { - ctrl.$setViewValue('aaaa'); - expect(ctrl.$modelValue).toBe('aaaa'); - - ctrl.$setValidity('someExternalError', false); - ctrl.$setViewValue('bbbb'); - expect(ctrl.$modelValue).toBe('bbbb'); - }); - - it('should not reset the view when the view is invalid', function() { - // this test fails when the view changes the model and - // then the model listener in ngModel picks up the change and - // tries to update the view again. - - // add a validator that will make any input invalid - ctrl.$parsers.push(function() {return undefined;}); - spyOn(ctrl, '$render'); - - // first digest - ctrl.$setViewValue('bbbb'); - expect(ctrl.$modelValue).toBeUndefined(); - expect(ctrl.$viewValue).toBe('bbbb'); - expect(ctrl.$render).not.toHaveBeenCalled(); - expect(scope.value).toBeUndefined(); - - // further digests - scope.$apply('value = "aaa"'); - expect(ctrl.$viewValue).toBe('aaa'); - ctrl.$render.reset(); - - ctrl.$setViewValue('cccc'); - expect(ctrl.$modelValue).toBeUndefined(); - expect(ctrl.$viewValue).toBe('cccc'); - expect(ctrl.$render).not.toHaveBeenCalled(); - expect(scope.value).toBeUndefined(); - }); - - it('should call parentForm.$setDirty only when pristine', function() { - ctrl.$setViewValue(''); - expect(ctrl.$pristine).toBe(false); - expect(ctrl.$dirty).toBe(true); - expect(parentFormCtrl.$setDirty).toHaveBeenCalledOnce(); - - parentFormCtrl.$setDirty.reset(); - ctrl.$setViewValue(''); - expect(ctrl.$pristine).toBe(false); - expect(ctrl.$dirty).toBe(true); - expect(parentFormCtrl.$setDirty).not.toHaveBeenCalled(); - }); - - it('should remove all other errors when any parser returns undefined', function() { - var a, b, val = function(val, x) { - return x ? val : x; - }; - - ctrl.$parsers.push(function(v) { return val(v, a); }); - ctrl.$parsers.push(function(v) { return val(v, b); }); - - ctrl.$validators.high = function(value) { - return !isDefined(value) || value > 5; - }; - - ctrl.$validators.even = function(value) { - return !isDefined(value) || value % 2 === 0; - }; - - a = b = true; - - ctrl.$setViewValue('3'); - expect(ctrl.$error).toEqual({ high: true, even: true }); - - ctrl.$setViewValue('10'); - expect(ctrl.$error).toEqual({}); - - a = undefined; - - ctrl.$setViewValue('12'); - expect(ctrl.$error).toEqual({ parse: true }); - - a = true; - b = undefined; - - ctrl.$setViewValue('14'); - expect(ctrl.$error).toEqual({ parse: true }); - - a = undefined; - b = undefined; - - ctrl.$setViewValue('16'); - expect(ctrl.$error).toEqual({ parse: true }); - - a = b = false; //not undefined - - ctrl.$setViewValue('2'); - expect(ctrl.$error).toEqual({ high: true }); - }); - - it('should not remove external validators when a parser failed', function() { - ctrl.$parsers.push(function(v) { return undefined; }); - ctrl.$setValidity('externalError', false); - ctrl.$setViewValue('someValue'); - expect(ctrl.$error).toEqual({ externalError: true, parse: true }); - }); - - it('should remove all non-parse-related CSS classes from the form when a parser fails', - inject(function($compile, $rootScope) { - - var element = $compile('
' + - '' + - '
')($rootScope); - var inputElm = element.find('input'); - var ctrl = $rootScope.myForm.myControl; - - var parserIsFailing = false; - ctrl.$parsers.push(function(value) { - return parserIsFailing ? undefined : value; - }); - - ctrl.$validators.alwaysFail = function() { - return false; - }; - - ctrl.$setViewValue('123'); - scope.$digest(); - - expect(element).toHaveClass('ng-valid-parse'); - expect(element).not.toHaveClass('ng-invalid-parse'); - expect(element).toHaveClass('ng-invalid-always-fail'); - - parserIsFailing = true; - ctrl.$setViewValue('12345'); - scope.$digest(); - - expect(element).not.toHaveClass('ng-valid-parse'); - expect(element).toHaveClass('ng-invalid-parse'); - expect(element).not.toHaveClass('ng-invalid-always-fail'); - - dealoc(element); - })); - - it('should set the ng-invalid-parse and ng-valid-parse CSS class when parsers fail and pass', function() { - var pass = true; - ctrl.$parsers.push(function(v) { - return pass ? v : undefined; - }); - - var input = element.find('input'); - - ctrl.$setViewValue('1'); - expect(input).toHaveClass('ng-valid-parse'); - expect(input).not.toHaveClass('ng-invalid-parse'); - - pass = undefined; - - ctrl.$setViewValue('2'); - expect(input).not.toHaveClass('ng-valid-parse'); - expect(input).toHaveClass('ng-invalid-parse'); - }); - - it('should update the model after all async validators resolve', inject(function($q) { - var defer; - ctrl.$asyncValidators.promiseValidator = function(value) { - defer = $q.defer(); - return defer.promise; - }; - - // set view value on first digest - ctrl.$setViewValue('b'); - - expect(ctrl.$modelValue).toBeUndefined(); - expect(scope.value).toBeUndefined(); - - defer.resolve(); - scope.$digest(); - - expect(ctrl.$modelValue).toBe('b'); - expect(scope.value).toBe('b'); - - // set view value on further digests - ctrl.$setViewValue('c'); - - expect(ctrl.$modelValue).toBe('b'); - expect(scope.value).toBe('b'); - - defer.resolve(); - scope.$digest(); - - expect(ctrl.$modelValue).toBe('c'); - expect(scope.value).toBe('c'); - - })); - - }); - - - describe('model -> view', function() { - - it('should set the value to $modelValue', function() { - scope.$apply('value = 10'); - expect(ctrl.$modelValue).toBe(10); - }); - - - 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 + ''; - }); - - scope.$apply('value = 3'); - expect(log).toEqual([3, 5]); - expect(ctrl.$viewValue).toBe('5'); - }); - - - it('should $render only if value changed', function() { - spyOn(ctrl, '$render'); - - scope.$apply('value = 3'); - expect(ctrl.$render).toHaveBeenCalledOnce(); - ctrl.$render.reset(); - - ctrl.$formatters.push(function() {return 3;}); - scope.$apply('value = 5'); - expect(ctrl.$render).not.toHaveBeenCalled(); - }); - - - it('should clear the view even if invalid', function() { - spyOn(ctrl, '$render'); - - ctrl.$formatters.push(function() {return undefined;}); - scope.$apply('value = 5'); - expect(ctrl.$render).toHaveBeenCalledOnce(); - }); - - it('should render immediately even if there are async validators', inject(function($q) { - spyOn(ctrl, '$render'); - ctrl.$asyncValidators.someValidator = function() { - return $q.defer().promise; - }; - - scope.$apply('value = 5'); - expect(ctrl.$viewValue).toBe(5); - expect(ctrl.$render).toHaveBeenCalledOnce(); - })); - - it('should not rerender nor validate in case view value is not changed', function() { - ctrl.$formatters.push(function(value) { - return 'nochange'; - }); - - spyOn(ctrl, '$render'); - ctrl.$validators.spyValidator = jasmine.createSpy('spyValidator'); - scope.$apply('value = "first"'); - scope.$apply('value = "second"'); - expect(ctrl.$validators.spyValidator).toHaveBeenCalledOnce(); - expect(ctrl.$render).toHaveBeenCalledOnce(); - }); - - }); - - describe('validation', function() { - - describe('$validate', function() { - it('should perform validations when $validate() is called', function() { - scope.$apply('value = ""'); - - var validatorResult = false; - ctrl.$validators.someValidator = function(value) { - return validatorResult; - }; - - ctrl.$validate(); - - expect(ctrl.$valid).toBe(false); - - validatorResult = true; - ctrl.$validate(); - - expect(ctrl.$valid).toBe(true); - }); - - it('should pass the last parsed modelValue to the validators', function() { - ctrl.$parsers.push(function(modelValue) { - return modelValue + 'def'; - }); - - ctrl.$setViewValue('abc'); - - ctrl.$validators.test = function(modelValue, viewValue) { - return true; - }; - - spyOn(ctrl.$validators, 'test'); - - ctrl.$validate(); - - expect(ctrl.$validators.test).toHaveBeenCalledWith('abcdef', 'abc'); - }); - - it('should set the model to undefined when it becomes invalid', function() { - var valid = true; - ctrl.$validators.test = function(modelValue, viewValue) { - return valid; - }; - - scope.$apply('value = "abc"'); - expect(scope.value).toBe('abc'); - - valid = false; - ctrl.$validate(); - - expect(scope.value).toBeUndefined(); - }); - - it('should update the model when it becomes valid', function() { - var valid = true; - ctrl.$validators.test = function(modelValue, viewValue) { - return valid; - }; - - scope.$apply('value = "abc"'); - expect(scope.value).toBe('abc'); - - valid = false; - ctrl.$validate(); - expect(scope.value).toBeUndefined(); - - valid = true; - ctrl.$validate(); - expect(scope.value).toBe('abc'); - }); - - it('should not update the model when it is valid, but there is a parse error', function() { - ctrl.$parsers.push(function(modelValue) { - return undefined; - }); - - ctrl.$setViewValue('abc'); - expect(ctrl.$error.parse).toBe(true); - expect(scope.value).toBeUndefined(); - - ctrl.$validators.test = function(modelValue, viewValue) { - return true; - }; - - ctrl.$validate(); - expect(ctrl.$error).toEqual({parse: true}); - expect(scope.value).toBeUndefined(); - }); - - it('should not set an invalid model to undefined when validity is the same', function() { - ctrl.$validators.test = function() { - return false; - }; - - scope.$apply('value = "invalid"'); - expect(ctrl.$valid).toBe(false); - expect(scope.value).toBe('invalid'); - - ctrl.$validate(); - expect(ctrl.$valid).toBe(false); - expect(scope.value).toBe('invalid'); - }); - - it('should not change a model that has a formatter', function() { - ctrl.$validators.test = function() { - return true; - }; - - ctrl.$formatters.push(function(modelValue) { - return 'xyz'; - }); - - scope.$apply('value = "abc"'); - expect(ctrl.$viewValue).toBe('xyz'); - - ctrl.$validate(); - expect(scope.value).toBe('abc'); - }); - - it('should not change a model that has a parser', function() { - ctrl.$validators.test = function() { - return true; - }; - - ctrl.$parsers.push(function(modelValue) { - return 'xyz'; - }); - - scope.$apply('value = "abc"'); - - ctrl.$validate(); - expect(scope.value).toBe('abc'); - }); - }); - - describe('view -> model update', function() { - it('should always perform validations using the parsed model value', function() { - var captures; - ctrl.$validators.raw = function() { - captures = arguments; - return captures[0]; - }; - - ctrl.$parsers.push(function(value) { - return value.toUpperCase(); - }); - - ctrl.$setViewValue('my-value'); - - expect(captures).toEqual(['MY-VALUE', 'my-value']); - }); - - it('should always perform validations using the formatted view value', function() { - var captures; - ctrl.$validators.raw = function() { - captures = arguments; - return captures[0]; - }; - - ctrl.$formatters.push(function(value) { - return value + '...'; - }); - - scope.$apply('value = "matias"'); - - expect(captures).toEqual(['matias', 'matias...']); - }); - - it('should only perform validations if the view value is different', function() { - var count = 0; - ctrl.$validators.countMe = function() { - count++; - }; - - ctrl.$setViewValue('my-value'); - expect(count).toBe(1); - - ctrl.$setViewValue('my-value'); - expect(count).toBe(1); - - ctrl.$setViewValue('your-value'); - expect(count).toBe(2); - }); - }); - - it('should perform validations twice each time the model value changes within a digest', function() { - var count = 0; - ctrl.$validators.number = function(value) { - count++; - return (/^\d+$/).test(value); - }; - - scope.$apply('value = ""'); - expect(count).toBe(1); - - scope.$apply('value = 1'); - expect(count).toBe(2); - - scope.$apply('value = 1'); - expect(count).toBe(2); - - scope.$apply('value = ""'); - expect(count).toBe(3); - }); - - it('should only validate to true if all validations are true', function() { - var curry = function(v) { - return function() { - return v; - }; - }; - - ctrl.$modelValue = undefined; - ctrl.$validators.a = curry(true); - ctrl.$validators.b = curry(true); - ctrl.$validators.c = curry(false); - - ctrl.$validate(); - expect(ctrl.$valid).toBe(false); - - ctrl.$validators.c = curry(true); - - ctrl.$validate(); - expect(ctrl.$valid).toBe(true); - }); - - it('should register invalid validations on the $error object', function() { - var curry = function(v) { - return function() { - return v; - }; - }; - - ctrl.$modelValue = undefined; - ctrl.$validators.unique = curry(false); - ctrl.$validators.tooLong = curry(false); - ctrl.$validators.notNumeric = curry(true); - - ctrl.$validate(); - - expect(ctrl.$error.unique).toBe(true); - expect(ctrl.$error.tooLong).toBe(true); - expect(ctrl.$error.notNumeric).not.toBe(true); - }); - - it('should render a validator asynchronously when a promise is returned', inject(function($q) { - var defer; - ctrl.$asyncValidators.promiseValidator = function(value) { - defer = $q.defer(); - return defer.promise; - }; - - scope.$apply('value = ""'); - - expect(ctrl.$valid).toBeUndefined(); - expect(ctrl.$invalid).toBeUndefined(); - expect(ctrl.$pending.promiseValidator).toBe(true); - - defer.resolve(); - scope.$digest(); - - expect(ctrl.$valid).toBe(true); - expect(ctrl.$invalid).toBe(false); - expect(ctrl.$pending).toBeUndefined(); - - scope.$apply('value = "123"'); - - defer.reject(); - scope.$digest(); - - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - expect(ctrl.$pending).toBeUndefined(); - })); - - it('should throw an error when a promise is not returned for an asynchronous validator', inject(function($q) { - ctrl.$asyncValidators.async = function(value) { - return true; - }; - - expect(function() { - scope.$apply('value = "123"'); - }).toThrowMinErr("ngModel", "$asyncValidators", - "Expected asynchronous validator to return a promise but got 'true' instead."); - })); - - it('should only run the async validators once all the sync validators have passed', - inject(function($q) { - - var stages = {}; - - stages.sync = { status1: false, status2: false, count: 0 }; - ctrl.$validators.syncValidator1 = function(modelValue, viewValue) { - stages.sync.count++; - return stages.sync.status1; - }; - - ctrl.$validators.syncValidator2 = function(modelValue, viewValue) { - stages.sync.count++; - return stages.sync.status2; - }; - - stages.async = { defer: null, count: 0 }; - ctrl.$asyncValidators.asyncValidator = function(modelValue, viewValue) { - stages.async.defer = $q.defer(); - stages.async.count++; - return stages.async.defer.promise; - }; - - scope.$apply('value = "123"'); - - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - - expect(stages.sync.count).toBe(2); - expect(stages.async.count).toBe(0); - - stages.sync.status1 = true; - - scope.$apply('value = "456"'); - - expect(stages.sync.count).toBe(4); - expect(stages.async.count).toBe(0); - - stages.sync.status2 = true; - - scope.$apply('value = "789"'); - - expect(stages.sync.count).toBe(6); - expect(stages.async.count).toBe(1); - - stages.async.defer.resolve(); - scope.$apply(); - - expect(ctrl.$valid).toBe(true); - expect(ctrl.$invalid).toBe(false); - })); - - it('should ignore expired async validation promises once delivered', inject(function($q) { - var defer, oldDefer, newDefer; - ctrl.$asyncValidators.async = function(value) { - defer = $q.defer(); - return defer.promise; - }; - - scope.$apply('value = ""'); - oldDefer = defer; - scope.$apply('value = "123"'); - newDefer = defer; - - newDefer.reject(); - scope.$digest(); - oldDefer.resolve(); - scope.$digest(); - - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - expect(ctrl.$pending).toBeUndefined(); - })); - - it('should clear and ignore all pending promises when the model value changes', inject(function($q) { - ctrl.$validators.sync = function(value) { - return true; - }; - - var defers = []; - ctrl.$asyncValidators.async = function(value) { - var defer = $q.defer(); - defers.push(defer); - return defer.promise; - }; - - scope.$apply('value = "123"'); - expect(ctrl.$pending).toEqual({async: true}); - expect(ctrl.$valid).toBe(undefined); - expect(ctrl.$invalid).toBe(undefined); - expect(defers.length).toBe(1); - expect(isObject(ctrl.$pending)).toBe(true); - - scope.$apply('value = "456"'); - expect(ctrl.$pending).toEqual({async: true}); - expect(ctrl.$valid).toBe(undefined); - expect(ctrl.$invalid).toBe(undefined); - expect(defers.length).toBe(2); - expect(isObject(ctrl.$pending)).toBe(true); - - defers[1].resolve(); - scope.$digest(); - expect(ctrl.$valid).toBe(true); - expect(ctrl.$invalid).toBe(false); - expect(isObject(ctrl.$pending)).toBe(false); - })); - - it('should clear and ignore all pending promises when a parser fails', inject(function($q) { - var failParser = false; - ctrl.$parsers.push(function(value) { - return failParser ? undefined : value; - }); - - var defer; - ctrl.$asyncValidators.async = function(value) { - defer = $q.defer(); - return defer.promise; - }; - - ctrl.$setViewValue('x..y..z'); - expect(ctrl.$valid).toBe(undefined); - expect(ctrl.$invalid).toBe(undefined); - - failParser = true; - - ctrl.$setViewValue('1..2..3'); - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - expect(isObject(ctrl.$pending)).toBe(false); - - defer.resolve(); - scope.$digest(); - - expect(ctrl.$valid).toBe(false); - expect(ctrl.$invalid).toBe(true); - expect(isObject(ctrl.$pending)).toBe(false); - })); - - it('should clear all errors from async validators if a parser fails', inject(function($q) { - var failParser = false; - ctrl.$parsers.push(function(value) { - return failParser ? undefined : value; - }); - - ctrl.$asyncValidators.async = function(value) { - return $q.reject(); - }; - - ctrl.$setViewValue('x..y..z'); - expect(ctrl.$error).toEqual({async: true}); - - failParser = true; - - ctrl.$setViewValue('1..2..3'); - expect(ctrl.$error).toEqual({parse: true}); - })); - - it('should clear all errors from async validators if a sync validator fails', inject(function($q) { - var failValidator = false; - ctrl.$validators.sync = function(value) { - return !failValidator; - }; - - ctrl.$asyncValidators.async = function(value) { - return $q.reject(); - }; - - ctrl.$setViewValue('x..y..z'); - expect(ctrl.$error).toEqual({async: true}); - - failValidator = true; - - ctrl.$setViewValue('1..2..3'); - expect(ctrl.$error).toEqual({sync: true}); - })); - - it('should re-evaluate the form validity state once the asynchronous promise has been delivered', - inject(function($compile, $rootScope, $q) { - - var element = $compile('
' + - '' + - '' + - '
')($rootScope); - var inputElm = element.find('input'); - - var formCtrl = $rootScope.myForm; - var usernameCtrl = formCtrl.username; - var ageCtrl = formCtrl.age; - - var usernameDefer; - usernameCtrl.$asyncValidators.usernameAvailability = function() { - usernameDefer = $q.defer(); - return usernameDefer.promise; - }; - - $rootScope.$digest(); - expect(usernameCtrl.$invalid).toBe(true); - expect(formCtrl.$invalid).toBe(true); - - usernameCtrl.$setViewValue('valid-username'); - $rootScope.$digest(); - - expect(formCtrl.$pending.usernameAvailability).toBeTruthy(); - expect(usernameCtrl.$invalid).toBe(undefined); - expect(formCtrl.$invalid).toBe(undefined); - - usernameDefer.resolve(); - $rootScope.$digest(); - expect(usernameCtrl.$invalid).toBe(false); - expect(formCtrl.$invalid).toBe(true); - - ageCtrl.$setViewValue(22); - $rootScope.$digest(); - - expect(usernameCtrl.$invalid).toBe(false); - expect(ageCtrl.$invalid).toBe(false); - expect(formCtrl.$invalid).toBe(false); - - usernameCtrl.$setViewValue('valid'); - $rootScope.$digest(); - - expect(usernameCtrl.$invalid).toBe(true); - expect(ageCtrl.$invalid).toBe(false); - expect(formCtrl.$invalid).toBe(true); - - usernameCtrl.$setViewValue('another-valid-username'); - $rootScope.$digest(); - - usernameDefer.resolve(); - $rootScope.$digest(); - - expect(usernameCtrl.$invalid).toBe(false); - expect(formCtrl.$invalid).toBe(false); - expect(formCtrl.$pending).toBeFalsy(); - expect(ageCtrl.$invalid).toBe(false); - - dealoc(element); - })); - - - it('should minimize janky setting of classes during $validate() and ngModelWatch', inject(function($animate, $compile, $rootScope) { - var addClass = $animate.$$addClassImmediately; - var removeClass = $animate.$$removeClassImmediately; - var addClassCallCount = 0; - var removeClassCallCount = 0; - var input; - $animate.$$addClassImmediately = function(element, className) { - if (input && element[0] === input[0]) ++addClassCallCount; - return addClass.call($animate, element, className); - }; - - $animate.$$removeClassImmediately = function(element, className) { - if (input && element[0] === input[0]) ++removeClassCallCount; - return removeClass.call($animate, element, className); - }; - - dealoc(element); - - $rootScope.value = "123456789"; - element = $compile( - '
' + - '' + - '
' - )($rootScope); - - var form = $rootScope.form; - input = element.children().eq(0); - - $rootScope.$digest(); - - expect(input).toBeValid(); - expect(input).not.toHaveClass('ng-invalid-maxlength'); - expect(input).toHaveClass('ng-valid-maxlength'); - expect(addClassCallCount).toBe(1); - expect(removeClassCallCount).toBe(0); - - dealoc(element); - })); - - it('should always use the most recent $viewValue for validation', function() { - ctrl.$parsers.push(function(value) { - if (value && value.substr(-1) === 'b') { - value = 'a'; - ctrl.$setViewValue(value); - ctrl.$render(); - } - - return value; - }); - - ctrl.$validators.mock = function(modelValue) { - return true; - }; - - spyOn(ctrl.$validators, 'mock').andCallThrough(); - - ctrl.$setViewValue('ab'); - - expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'a'); - expect(ctrl.$validators.mock.calls.length).toEqual(2); - }); - - it('should validate even if the modelValue did not change', function() { - ctrl.$parsers.push(function(value) { - if (value && value.substr(-1) === 'b') { - value = 'a'; - } - - return value; - }); - - ctrl.$validators.mock = function(modelValue) { - return true; - }; - - spyOn(ctrl.$validators, 'mock').andCallThrough(); - - ctrl.$setViewValue('a'); - - expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'a'); - expect(ctrl.$validators.mock.calls.length).toEqual(1); - - ctrl.$setViewValue('ab'); - - expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'ab'); - expect(ctrl.$validators.mock.calls.length).toEqual(2); - }); - - }); -}); - - -describe('ngModel', function() { - var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; - - it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty, ng-untouched, ng-touched)', - inject(function($compile, $rootScope, $sniffer) { - var element = $compile('')($rootScope); - - $rootScope.$digest(); - expect(element).toBeValid(); - expect(element).toBePristine(); - expect(element).toBeUntouched(); - expect(element.hasClass('ng-valid-email')).toBe(true); - expect(element.hasClass('ng-invalid-email')).toBe(false); - - $rootScope.$apply("value = 'invalid-email'"); - expect(element).toBeInvalid(); - expect(element).toBePristine(); - expect(element.hasClass('ng-valid-email')).toBe(false); - expect(element.hasClass('ng-invalid-email')).toBe(true); - - element.val('invalid-again'); - browserTrigger(element, ($sniffer.hasEvent('input')) ? 'input' : 'change'); - expect(element).toBeInvalid(); - expect(element).toBeDirty(); - expect(element.hasClass('ng-valid-email')).toBe(false); - expect(element.hasClass('ng-invalid-email')).toBe(true); - - element.val('vojta@google.com'); - browserTrigger(element, $sniffer.hasEvent('input') ? 'input' : 'change'); - expect(element).toBeValid(); - expect(element).toBeDirty(); - expect(element.hasClass('ng-valid-email')).toBe(true); - expect(element.hasClass('ng-invalid-email')).toBe(false); - - browserTrigger(element, 'blur'); - expect(element).toBeTouched(); - - dealoc(element); - })); - - - it('should set invalid classes on init', inject(function($compile, $rootScope) { - var element = $compile('')($rootScope); - $rootScope.$digest(); - - expect(element).toBeInvalid(); - expect(element).toHaveClass('ng-invalid-required'); - - dealoc(element); - })); - - describe('custom formatter and parser that are added by a directive in post linking', function() { - var inputElm, scope; - beforeEach(module(function($compileProvider) { - $compileProvider.directive('customFormat', function() { - return { - require: 'ngModel', - link: function(scope, element, attrs, ngModelCtrl) { - ngModelCtrl.$formatters.push(function(value) { - return value.part; - }); - ngModelCtrl.$parsers.push(function(value) { - return {part: value}; - }); - } - }; - }); - })); - - afterEach(function() { - dealoc(inputElm); - }); - - function createInput(type) { - inject(function($compile, $rootScope) { - scope = $rootScope; - inputElm = $compile('')($rootScope); - }); - } - - it('should use them after the builtin ones for text inputs', function() { - createInput('text'); - scope.$apply('val = {part: "a"}'); - expect(inputElm.val()).toBe('a'); - - inputElm.val('b'); - browserTrigger(inputElm, 'change'); - expect(scope.val).toEqual({part: 'b'}); - }); - - it('should use them after the builtin ones for number inputs', function() { - createInput('number'); - scope.$apply('val = {part: 1}'); - expect(inputElm.val()).toBe('1'); - - inputElm.val('2'); - browserTrigger(inputElm, 'change'); - expect(scope.val).toEqual({part: 2}); - }); - - it('should use them after the builtin ones for date inputs', function() { - createInput('date'); - scope.$apply(function() { - scope.val = {part: new Date(2000, 10, 8)}; - }); - expect(inputElm.val()).toBe('2000-11-08'); - - inputElm.val('2001-12-09'); - browserTrigger(inputElm, 'change'); - expect(scope.val).toEqual({part: new Date(2001, 11, 9)}); - }); - }); - - - it('should always format the viewValue as a string for a blank input type when the value is present', - inject(function($compile, $rootScope, $sniffer) { - - var form = $compile('
')($rootScope); - - $rootScope.val = 123; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe('123'); - - $rootScope.val = null; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe(null); - - dealoc(form); - })); - - it('should always format the viewValue as a string for a `text` input type when the value is present', - inject(function($compile, $rootScope, $sniffer) { - - var form = $compile('
')($rootScope); - $rootScope.val = 123; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe('123'); - - $rootScope.val = null; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe(null); - - dealoc(form); - })); - - it('should always format the viewValue as a string for an `email` input type when the value is present', - inject(function($compile, $rootScope, $sniffer) { - - var form = $compile('
')($rootScope); - $rootScope.val = 123; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe('123'); - - $rootScope.val = null; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe(null); - - dealoc(form); - })); - - it('should always format the viewValue as a string for a `url` input type when the value is present', - inject(function($compile, $rootScope, $sniffer) { - - var form = $compile('
')($rootScope); - $rootScope.val = 123; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe('123'); - - $rootScope.val = null; - $rootScope.$digest(); - expect($rootScope.form.field.$viewValue).toBe(null); - - dealoc(form); - })); - - it('should set the control touched state on "blur" event', inject(function($compile, $rootScope) { - var element = $compile('
' + - '' + - '
')($rootScope); - var inputElm = element.find('input'); - var control = $rootScope.myForm.myControl; - - expect(control.$touched).toBe(false); - expect(control.$untouched).toBe(true); - - browserTrigger(inputElm, 'blur'); - expect(control.$touched).toBe(true); - expect(control.$untouched).toBe(false); - - dealoc(element); - })); - - it('should not cause a digest on "blur" event if control is already touched', - inject(function($compile, $rootScope) { - - var element = $compile('
' + - '' + - '
')($rootScope); - var inputElm = element.find('input'); - var control = $rootScope.myForm.myControl; - - control.$setTouched(); - spyOn($rootScope, '$apply'); - browserTrigger(inputElm, 'blur'); - - expect($rootScope.$apply).not.toHaveBeenCalled(); - - dealoc(element); - })); - - it('should digest asynchronously on "blur" event if a apply is already in progress', - inject(function($compile, $rootScope) { - - var element = $compile('
' + - '' + - '
')($rootScope); - var inputElm = element.find('input'); - var control = $rootScope.myForm.myControl; - - $rootScope.$apply(function() { - expect(control.$touched).toBe(false); - expect(control.$untouched).toBe(true); - - browserTrigger(inputElm, 'blur'); - - expect(control.$touched).toBe(false); - expect(control.$untouched).toBe(true); - }); - - expect(control.$touched).toBe(true); - expect(control.$untouched).toBe(false); - - dealoc(element); - })); - - - it('should register/deregister a nested ngModel with parent form when entering or leaving DOM', - inject(function($compile, $rootScope) { - - var element = $compile('
' + - '' + - '
')($rootScope); - var isFormValid; - - $rootScope.inputPresent = false; - $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); - - $rootScope.$apply(); - - expect($rootScope.myForm.$valid).toBe(true); - expect(isFormValid).toBe(true); - expect($rootScope.myForm.myControl).toBeUndefined(); - - $rootScope.inputPresent = true; - $rootScope.$apply(); - - expect($rootScope.myForm.$valid).toBe(false); - expect(isFormValid).toBe(false); - expect($rootScope.myForm.myControl).toBeDefined(); - - $rootScope.inputPresent = false; - $rootScope.$apply(); - - expect($rootScope.myForm.$valid).toBe(true); - expect(isFormValid).toBe(true); - expect($rootScope.myForm.myControl).toBeUndefined(); - - dealoc(element); - })); - - - it('should register/deregister a nested ngModel with parent form when entering or leaving DOM with animations', - function() { - - // ngAnimate performs the dom manipulation after digest, and since the form validity can be affected by a form - // control going away we must ensure that the deregistration happens during the digest while we are still doing - // dirty checking. - module('ngAnimate'); - - inject(function($compile, $rootScope) { - var element = $compile('
' + - '' + - '
')($rootScope); - var isFormValid; - - $rootScope.inputPresent = false; - // this watch ensure that the form validity gets updated during digest (so that we can observe it) - $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); - - $rootScope.$apply(); - - expect($rootScope.myForm.$valid).toBe(true); - expect(isFormValid).toBe(true); - expect($rootScope.myForm.myControl).toBeUndefined(); - - $rootScope.inputPresent = true; - $rootScope.$apply(); - - expect($rootScope.myForm.$valid).toBe(false); - expect(isFormValid).toBe(false); - expect($rootScope.myForm.myControl).toBeDefined(); - - $rootScope.inputPresent = false; - $rootScope.$apply(); - - expect($rootScope.myForm.$valid).toBe(true); - expect(isFormValid).toBe(true); - expect($rootScope.myForm.myControl).toBeUndefined(); - - dealoc(element); - }); - }); - - it('should keep previously defined watches consistent when changes in validity are made', - inject(function($compile, $rootScope) { - - var isFormValid; - $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); - - var element = $compile('
' + - '' + - '
')($rootScope); - - $rootScope.$apply(); - expect(isFormValid).toBe(false); - expect($rootScope.myForm.$valid).toBe(false); - - $rootScope.value='value'; - $rootScope.$apply(); - expect(isFormValid).toBe(true); - expect($rootScope.myForm.$valid).toBe(true); - - dealoc(element); - })); - -}); - describe('input', function() { var formElm, inputElm, scope, $compile, $sniffer, $browser, changeInputValueTo, currentSpec; @@ -5219,116 +3762,3 @@ describe('input', function() { }); }); }); - -describe('NgModel animations', function() { - beforeEach(module('ngAnimateMock')); - - function findElementAnimations(element, queue) { - var node = element[0]; - var animations = []; - for (var i = 0; i < queue.length; i++) { - var animation = queue[i]; - if (animation.element[0] == node) { - animations.push(animation); - } - } - return animations; - } - - function assertValidAnimation(animation, event, classNameA, classNameB) { - expect(animation.event).toBe(event); - expect(animation.args[1]).toBe(classNameA); - if (classNameB) expect(animation.args[2]).toBe(classNameB); - } - - var doc, input, scope, model; - beforeEach(inject(function($rootScope, $compile, $rootElement, $animate) { - scope = $rootScope.$new(); - doc = jqLite('
' + - ' ' + - '
'); - $rootElement.append(doc); - $compile(doc)(scope); - $animate.queue = []; - - input = doc.find('input'); - model = scope.myForm.myInput; - })); - - afterEach(function() { - dealoc(input); - }); - - it('should trigger an animation when invalid', inject(function($animate) { - model.$setValidity('required', false); - - var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); - assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); - assertValidAnimation(animations[2], 'addClass', 'ng-invalid-required'); - })); - - it('should trigger an animation when valid', inject(function($animate) { - model.$setValidity('required', false); - - $animate.queue = []; - - model.$setValidity('required', true); - - var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'addClass', 'ng-valid'); - assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); - assertValidAnimation(animations[2], 'addClass', 'ng-valid-required'); - assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-required'); - })); - - it('should trigger an animation when dirty', inject(function($animate) { - model.$setViewValue('some dirty value'); - - var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'removeClass', 'ng-pristine'); - assertValidAnimation(animations[1], 'addClass', 'ng-dirty'); - })); - - it('should trigger an animation when pristine', inject(function($animate) { - model.$setPristine(); - - var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'removeClass', 'ng-dirty'); - assertValidAnimation(animations[1], 'addClass', 'ng-pristine'); - })); - - it('should trigger an animation when untouched', inject(function($animate) { - model.$setUntouched(); - - var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'setClass', 'ng-untouched'); - expect(animations[0].args[2]).toBe('ng-touched'); - })); - - it('should trigger an animation when touched', inject(function($animate) { - model.$setTouched(); - - var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'setClass', 'ng-touched', 'ng-untouched'); - expect(animations[0].args[2]).toBe('ng-untouched'); - })); - - it('should trigger custom errors as addClass/removeClass when invalid/valid', inject(function($animate) { - model.$setValidity('custom-error', false); - - var animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); - assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); - assertValidAnimation(animations[2], 'addClass', 'ng-invalid-custom-error'); - - $animate.queue = []; - model.$setValidity('custom-error', true); - - animations = findElementAnimations(input, $animate.queue); - assertValidAnimation(animations[0], 'addClass', 'ng-valid'); - assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); - assertValidAnimation(animations[2], 'addClass', 'ng-valid-custom-error'); - assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-custom-error'); - })); -}); diff --git a/test/ng/directive/ngModelSpec.js b/test/ng/directive/ngModelSpec.js new file mode 100644 index 00000000..10d51e20 --- /dev/null +++ b/test/ng/directive/ngModelSpec.js @@ -0,0 +1,1575 @@ +'use strict'; + +describe('ngModel', function() { + + describe('NgModelController', function() { + /* global NgModelController: false */ + var ctrl, scope, ngModelAccessor, element, parentFormCtrl; + + beforeEach(inject(function($rootScope, $controller) { + var attrs = {name: 'testAlias', ngModel: 'value'}; + + parentFormCtrl = { + $$setPending: jasmine.createSpy('$$setPending'), + $setValidity: jasmine.createSpy('$setValidity'), + $setDirty: jasmine.createSpy('$setDirty'), + $$clearControlValidity: noop + }; + + element = jqLite('
'); + element.data('$formController', parentFormCtrl); + + scope = $rootScope; + ngModelAccessor = jasmine.createSpy('ngModel accessor'); + ctrl = $controller(NgModelController, { + $scope: scope, + $element: element.find('input'), + $attrs: attrs + }); + })); + + + afterEach(function() { + dealoc(element); + }); + + + it('should init the properties', function() { + expect(ctrl.$untouched).toBe(true); + expect(ctrl.$touched).toBe(false); + 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(); + + expect(ctrl.$formatters).toEqual([]); + expect(ctrl.$parsers).toEqual([]); + + expect(ctrl.$name).toBe('testAlias'); + }); + + + describe('setValidity', function() { + + function expectOneError() { + expect(ctrl.$error).toEqual({someError: true}); + expect(ctrl.$$success).toEqual({}); + expect(ctrl.$pending).toBeUndefined(); + } + + function expectOneSuccess() { + expect(ctrl.$error).toEqual({}); + expect(ctrl.$$success).toEqual({someError: true}); + expect(ctrl.$pending).toBeUndefined(); + } + + function expectOnePending() { + expect(ctrl.$error).toEqual({}); + expect(ctrl.$$success).toEqual({}); + expect(ctrl.$pending).toEqual({someError: true}); + } + + function expectCleared() { + expect(ctrl.$error).toEqual({}); + expect(ctrl.$$success).toEqual({}); + expect(ctrl.$pending).toBeUndefined(); + } + + it('should propagate validity to the parent form', function() { + expect(parentFormCtrl.$setValidity).not.toHaveBeenCalled(); + ctrl.$setValidity('ERROR', false); + expect(parentFormCtrl.$setValidity).toHaveBeenCalledOnceWith('ERROR', false, ctrl); + }); + + it('should transition from states correctly', function() { + expectCleared(); + + ctrl.$setValidity('someError', false); + expectOneError(); + + ctrl.$setValidity('someError', undefined); + expectOnePending(); + + ctrl.$setValidity('someError', true); + expectOneSuccess(); + + ctrl.$setValidity('someError', null); + expectCleared(); + }); + + it('should set valid/invalid with multiple errors', function() { + ctrl.$setValidity('first', false); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + + ctrl.$setValidity('second', false); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + + ctrl.$setValidity('third', undefined); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + + ctrl.$setValidity('third', null); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + + ctrl.$setValidity('second', true); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + + ctrl.$setValidity('first', true); + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + }); + + }); + + describe('setPristine', function() { + + it('should set control to its pristine state', function() { + ctrl.$setViewValue('edit'); + expect(ctrl.$dirty).toBe(true); + expect(ctrl.$pristine).toBe(false); + + ctrl.$setPristine(); + expect(ctrl.$dirty).toBe(false); + expect(ctrl.$pristine).toBe(true); + }); + }); + + describe('setDirty', function() { + + it('should set control to its dirty state', function() { + expect(ctrl.$pristine).toBe(true); + expect(ctrl.$dirty).toBe(false); + + ctrl.$setDirty(); + expect(ctrl.$pristine).toBe(false); + expect(ctrl.$dirty).toBe(true); + }); + + it('should set parent form to its dirty state', function() { + ctrl.$setDirty(); + expect(parentFormCtrl.$setDirty).toHaveBeenCalled(); + }); + }); + + describe('setUntouched', function() { + + it('should set control to its untouched state', function() { + ctrl.$setTouched(); + + ctrl.$setUntouched(); + expect(ctrl.$touched).toBe(false); + expect(ctrl.$untouched).toBe(true); + }); + }); + + describe('setTouched', function() { + + it('should set control to its touched state', function() { + ctrl.$setUntouched(); + + ctrl.$setTouched(); + expect(ctrl.$touched).toBe(true); + expect(ctrl.$untouched).toBe(false); + }); + }); + + describe('view -> model', function() { + + it('should set the value to $viewValue', function() { + ctrl.$setViewValue('some-val'); + expect(ctrl.$viewValue).toBe('some-val'); + }); + + + it('should pipeline all registered parsers and set result to $modelValue', function() { + var log = []; + + ctrl.$parsers.push(function(value) { + log.push(value); + return value + '-a'; + }); + + ctrl.$parsers.push(function(value) { + log.push(value); + return value + '-b'; + }); + + ctrl.$setViewValue('init'); + expect(log).toEqual(['init', 'init-a']); + expect(ctrl.$modelValue).toBe('init-a-b'); + }); + + + it('should fire viewChangeListeners when the value changes in the view (even if invalid)', + function() { + var spy = jasmine.createSpy('viewChangeListener'); + ctrl.$viewChangeListeners.push(spy); + ctrl.$setViewValue('val'); + expect(spy).toHaveBeenCalledOnce(); + spy.reset(); + + // invalid + ctrl.$parsers.push(function() {return undefined;}); + ctrl.$setViewValue('val2'); + expect(spy).toHaveBeenCalledOnce(); + }); + + + it('should reset the model when the view is invalid', function() { + ctrl.$setViewValue('aaaa'); + expect(ctrl.$modelValue).toBe('aaaa'); + + // add a validator that will make any input invalid + ctrl.$parsers.push(function() {return undefined;}); + expect(ctrl.$modelValue).toBe('aaaa'); + ctrl.$setViewValue('bbbb'); + expect(ctrl.$modelValue).toBeUndefined(); + }); + + it('should not reset the model when the view is invalid due to an external validator', function() { + ctrl.$setViewValue('aaaa'); + expect(ctrl.$modelValue).toBe('aaaa'); + + ctrl.$setValidity('someExternalError', false); + ctrl.$setViewValue('bbbb'); + expect(ctrl.$modelValue).toBe('bbbb'); + }); + + it('should not reset the view when the view is invalid', function() { + // this test fails when the view changes the model and + // then the model listener in ngModel picks up the change and + // tries to update the view again. + + // add a validator that will make any input invalid + ctrl.$parsers.push(function() {return undefined;}); + spyOn(ctrl, '$render'); + + // first digest + ctrl.$setViewValue('bbbb'); + expect(ctrl.$modelValue).toBeUndefined(); + expect(ctrl.$viewValue).toBe('bbbb'); + expect(ctrl.$render).not.toHaveBeenCalled(); + expect(scope.value).toBeUndefined(); + + // further digests + scope.$apply('value = "aaa"'); + expect(ctrl.$viewValue).toBe('aaa'); + ctrl.$render.reset(); + + ctrl.$setViewValue('cccc'); + expect(ctrl.$modelValue).toBeUndefined(); + expect(ctrl.$viewValue).toBe('cccc'); + expect(ctrl.$render).not.toHaveBeenCalled(); + expect(scope.value).toBeUndefined(); + }); + + it('should call parentForm.$setDirty only when pristine', function() { + ctrl.$setViewValue(''); + expect(ctrl.$pristine).toBe(false); + expect(ctrl.$dirty).toBe(true); + expect(parentFormCtrl.$setDirty).toHaveBeenCalledOnce(); + + parentFormCtrl.$setDirty.reset(); + ctrl.$setViewValue(''); + expect(ctrl.$pristine).toBe(false); + expect(ctrl.$dirty).toBe(true); + expect(parentFormCtrl.$setDirty).not.toHaveBeenCalled(); + }); + + it('should remove all other errors when any parser returns undefined', function() { + var a, b, val = function(val, x) { + return x ? val : x; + }; + + ctrl.$parsers.push(function(v) { return val(v, a); }); + ctrl.$parsers.push(function(v) { return val(v, b); }); + + ctrl.$validators.high = function(value) { + return !isDefined(value) || value > 5; + }; + + ctrl.$validators.even = function(value) { + return !isDefined(value) || value % 2 === 0; + }; + + a = b = true; + + ctrl.$setViewValue('3'); + expect(ctrl.$error).toEqual({ high: true, even: true }); + + ctrl.$setViewValue('10'); + expect(ctrl.$error).toEqual({}); + + a = undefined; + + ctrl.$setViewValue('12'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = true; + b = undefined; + + ctrl.$setViewValue('14'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = undefined; + b = undefined; + + ctrl.$setViewValue('16'); + expect(ctrl.$error).toEqual({ parse: true }); + + a = b = false; //not undefined + + ctrl.$setViewValue('2'); + expect(ctrl.$error).toEqual({ high: true }); + }); + + it('should not remove external validators when a parser failed', function() { + ctrl.$parsers.push(function(v) { return undefined; }); + ctrl.$setValidity('externalError', false); + ctrl.$setViewValue('someValue'); + expect(ctrl.$error).toEqual({ externalError: true, parse: true }); + }); + + it('should remove all non-parse-related CSS classes from the form when a parser fails', + inject(function($compile, $rootScope) { + + var element = $compile('
' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + var ctrl = $rootScope.myForm.myControl; + + var parserIsFailing = false; + ctrl.$parsers.push(function(value) { + return parserIsFailing ? undefined : value; + }); + + ctrl.$validators.alwaysFail = function() { + return false; + }; + + ctrl.$setViewValue('123'); + scope.$digest(); + + expect(element).toHaveClass('ng-valid-parse'); + expect(element).not.toHaveClass('ng-invalid-parse'); + expect(element).toHaveClass('ng-invalid-always-fail'); + + parserIsFailing = true; + ctrl.$setViewValue('12345'); + scope.$digest(); + + expect(element).not.toHaveClass('ng-valid-parse'); + expect(element).toHaveClass('ng-invalid-parse'); + expect(element).not.toHaveClass('ng-invalid-always-fail'); + + dealoc(element); + })); + + it('should set the ng-invalid-parse and ng-valid-parse CSS class when parsers fail and pass', function() { + var pass = true; + ctrl.$parsers.push(function(v) { + return pass ? v : undefined; + }); + + var input = element.find('input'); + + ctrl.$setViewValue('1'); + expect(input).toHaveClass('ng-valid-parse'); + expect(input).not.toHaveClass('ng-invalid-parse'); + + pass = undefined; + + ctrl.$setViewValue('2'); + expect(input).not.toHaveClass('ng-valid-parse'); + expect(input).toHaveClass('ng-invalid-parse'); + }); + + it('should update the model after all async validators resolve', inject(function($q) { + var defer; + ctrl.$asyncValidators.promiseValidator = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + // set view value on first digest + ctrl.$setViewValue('b'); + + expect(ctrl.$modelValue).toBeUndefined(); + expect(scope.value).toBeUndefined(); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$modelValue).toBe('b'); + expect(scope.value).toBe('b'); + + // set view value on further digests + ctrl.$setViewValue('c'); + + expect(ctrl.$modelValue).toBe('b'); + expect(scope.value).toBe('b'); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$modelValue).toBe('c'); + expect(scope.value).toBe('c'); + + })); + + }); + + + describe('model -> view', function() { + + it('should set the value to $modelValue', function() { + scope.$apply('value = 10'); + expect(ctrl.$modelValue).toBe(10); + }); + + + 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 + ''; + }); + + scope.$apply('value = 3'); + expect(log).toEqual([3, 5]); + expect(ctrl.$viewValue).toBe('5'); + }); + + + it('should $render only if value changed', function() { + spyOn(ctrl, '$render'); + + scope.$apply('value = 3'); + expect(ctrl.$render).toHaveBeenCalledOnce(); + ctrl.$render.reset(); + + ctrl.$formatters.push(function() {return 3;}); + scope.$apply('value = 5'); + expect(ctrl.$render).not.toHaveBeenCalled(); + }); + + + it('should clear the view even if invalid', function() { + spyOn(ctrl, '$render'); + + ctrl.$formatters.push(function() {return undefined;}); + scope.$apply('value = 5'); + expect(ctrl.$render).toHaveBeenCalledOnce(); + }); + + it('should render immediately even if there are async validators', inject(function($q) { + spyOn(ctrl, '$render'); + ctrl.$asyncValidators.someValidator = function() { + return $q.defer().promise; + }; + + scope.$apply('value = 5'); + expect(ctrl.$viewValue).toBe(5); + expect(ctrl.$render).toHaveBeenCalledOnce(); + })); + + it('should not rerender nor validate in case view value is not changed', function() { + ctrl.$formatters.push(function(value) { + return 'nochange'; + }); + + spyOn(ctrl, '$render'); + ctrl.$validators.spyValidator = jasmine.createSpy('spyValidator'); + scope.$apply('value = "first"'); + scope.$apply('value = "second"'); + expect(ctrl.$validators.spyValidator).toHaveBeenCalledOnce(); + expect(ctrl.$render).toHaveBeenCalledOnce(); + }); + + }); + + describe('validation', function() { + + describe('$validate', function() { + it('should perform validations when $validate() is called', function() { + scope.$apply('value = ""'); + + var validatorResult = false; + ctrl.$validators.someValidator = function(value) { + return validatorResult; + }; + + ctrl.$validate(); + + expect(ctrl.$valid).toBe(false); + + validatorResult = true; + ctrl.$validate(); + + expect(ctrl.$valid).toBe(true); + }); + + it('should pass the last parsed modelValue to the validators', function() { + ctrl.$parsers.push(function(modelValue) { + return modelValue + 'def'; + }); + + ctrl.$setViewValue('abc'); + + ctrl.$validators.test = function(modelValue, viewValue) { + return true; + }; + + spyOn(ctrl.$validators, 'test'); + + ctrl.$validate(); + + expect(ctrl.$validators.test).toHaveBeenCalledWith('abcdef', 'abc'); + }); + + it('should set the model to undefined when it becomes invalid', function() { + var valid = true; + ctrl.$validators.test = function(modelValue, viewValue) { + return valid; + }; + + scope.$apply('value = "abc"'); + expect(scope.value).toBe('abc'); + + valid = false; + ctrl.$validate(); + + expect(scope.value).toBeUndefined(); + }); + + it('should update the model when it becomes valid', function() { + var valid = true; + ctrl.$validators.test = function(modelValue, viewValue) { + return valid; + }; + + scope.$apply('value = "abc"'); + expect(scope.value).toBe('abc'); + + valid = false; + ctrl.$validate(); + expect(scope.value).toBeUndefined(); + + valid = true; + ctrl.$validate(); + expect(scope.value).toBe('abc'); + }); + + it('should not update the model when it is valid, but there is a parse error', function() { + ctrl.$parsers.push(function(modelValue) { + return undefined; + }); + + ctrl.$setViewValue('abc'); + expect(ctrl.$error.parse).toBe(true); + expect(scope.value).toBeUndefined(); + + ctrl.$validators.test = function(modelValue, viewValue) { + return true; + }; + + ctrl.$validate(); + expect(ctrl.$error).toEqual({parse: true}); + expect(scope.value).toBeUndefined(); + }); + + it('should not set an invalid model to undefined when validity is the same', function() { + ctrl.$validators.test = function() { + return false; + }; + + scope.$apply('value = "invalid"'); + expect(ctrl.$valid).toBe(false); + expect(scope.value).toBe('invalid'); + + ctrl.$validate(); + expect(ctrl.$valid).toBe(false); + expect(scope.value).toBe('invalid'); + }); + + it('should not change a model that has a formatter', function() { + ctrl.$validators.test = function() { + return true; + }; + + ctrl.$formatters.push(function(modelValue) { + return 'xyz'; + }); + + scope.$apply('value = "abc"'); + expect(ctrl.$viewValue).toBe('xyz'); + + ctrl.$validate(); + expect(scope.value).toBe('abc'); + }); + + it('should not change a model that has a parser', function() { + ctrl.$validators.test = function() { + return true; + }; + + ctrl.$parsers.push(function(modelValue) { + return 'xyz'; + }); + + scope.$apply('value = "abc"'); + + ctrl.$validate(); + expect(scope.value).toBe('abc'); + }); + }); + + describe('view -> model update', function() { + it('should always perform validations using the parsed model value', function() { + var captures; + ctrl.$validators.raw = function() { + captures = arguments; + return captures[0]; + }; + + ctrl.$parsers.push(function(value) { + return value.toUpperCase(); + }); + + ctrl.$setViewValue('my-value'); + + expect(captures).toEqual(['MY-VALUE', 'my-value']); + }); + + it('should always perform validations using the formatted view value', function() { + var captures; + ctrl.$validators.raw = function() { + captures = arguments; + return captures[0]; + }; + + ctrl.$formatters.push(function(value) { + return value + '...'; + }); + + scope.$apply('value = "matias"'); + + expect(captures).toEqual(['matias', 'matias...']); + }); + + it('should only perform validations if the view value is different', function() { + var count = 0; + ctrl.$validators.countMe = function() { + count++; + }; + + ctrl.$setViewValue('my-value'); + expect(count).toBe(1); + + ctrl.$setViewValue('my-value'); + expect(count).toBe(1); + + ctrl.$setViewValue('your-value'); + expect(count).toBe(2); + }); + }); + + it('should perform validations twice each time the model value changes within a digest', function() { + var count = 0; + ctrl.$validators.number = function(value) { + count++; + return (/^\d+$/).test(value); + }; + + scope.$apply('value = ""'); + expect(count).toBe(1); + + scope.$apply('value = 1'); + expect(count).toBe(2); + + scope.$apply('value = 1'); + expect(count).toBe(2); + + scope.$apply('value = ""'); + expect(count).toBe(3); + }); + + it('should only validate to true if all validations are true', function() { + var curry = function(v) { + return function() { + return v; + }; + }; + + ctrl.$modelValue = undefined; + ctrl.$validators.a = curry(true); + ctrl.$validators.b = curry(true); + ctrl.$validators.c = curry(false); + + ctrl.$validate(); + expect(ctrl.$valid).toBe(false); + + ctrl.$validators.c = curry(true); + + ctrl.$validate(); + expect(ctrl.$valid).toBe(true); + }); + + it('should register invalid validations on the $error object', function() { + var curry = function(v) { + return function() { + return v; + }; + }; + + ctrl.$modelValue = undefined; + ctrl.$validators.unique = curry(false); + ctrl.$validators.tooLong = curry(false); + ctrl.$validators.notNumeric = curry(true); + + ctrl.$validate(); + + expect(ctrl.$error.unique).toBe(true); + expect(ctrl.$error.tooLong).toBe(true); + expect(ctrl.$error.notNumeric).not.toBe(true); + }); + + it('should render a validator asynchronously when a promise is returned', inject(function($q) { + var defer; + ctrl.$asyncValidators.promiseValidator = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + scope.$apply('value = ""'); + + expect(ctrl.$valid).toBeUndefined(); + expect(ctrl.$invalid).toBeUndefined(); + expect(ctrl.$pending.promiseValidator).toBe(true); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + expect(ctrl.$pending).toBeUndefined(); + + scope.$apply('value = "123"'); + + defer.reject(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(ctrl.$pending).toBeUndefined(); + })); + + it('should throw an error when a promise is not returned for an asynchronous validator', inject(function($q) { + ctrl.$asyncValidators.async = function(value) { + return true; + }; + + expect(function() { + scope.$apply('value = "123"'); + }).toThrowMinErr("ngModel", "$asyncValidators", + "Expected asynchronous validator to return a promise but got 'true' instead."); + })); + + it('should only run the async validators once all the sync validators have passed', + inject(function($q) { + + var stages = {}; + + stages.sync = { status1: false, status2: false, count: 0 }; + ctrl.$validators.syncValidator1 = function(modelValue, viewValue) { + stages.sync.count++; + return stages.sync.status1; + }; + + ctrl.$validators.syncValidator2 = function(modelValue, viewValue) { + stages.sync.count++; + return stages.sync.status2; + }; + + stages.async = { defer: null, count: 0 }; + ctrl.$asyncValidators.asyncValidator = function(modelValue, viewValue) { + stages.async.defer = $q.defer(); + stages.async.count++; + return stages.async.defer.promise; + }; + + scope.$apply('value = "123"'); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + + expect(stages.sync.count).toBe(2); + expect(stages.async.count).toBe(0); + + stages.sync.status1 = true; + + scope.$apply('value = "456"'); + + expect(stages.sync.count).toBe(4); + expect(stages.async.count).toBe(0); + + stages.sync.status2 = true; + + scope.$apply('value = "789"'); + + expect(stages.sync.count).toBe(6); + expect(stages.async.count).toBe(1); + + stages.async.defer.resolve(); + scope.$apply(); + + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + })); + + it('should ignore expired async validation promises once delivered', inject(function($q) { + var defer, oldDefer, newDefer; + ctrl.$asyncValidators.async = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + scope.$apply('value = ""'); + oldDefer = defer; + scope.$apply('value = "123"'); + newDefer = defer; + + newDefer.reject(); + scope.$digest(); + oldDefer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(ctrl.$pending).toBeUndefined(); + })); + + it('should clear and ignore all pending promises when the model value changes', inject(function($q) { + ctrl.$validators.sync = function(value) { + return true; + }; + + var defers = []; + ctrl.$asyncValidators.async = function(value) { + var defer = $q.defer(); + defers.push(defer); + return defer.promise; + }; + + scope.$apply('value = "123"'); + expect(ctrl.$pending).toEqual({async: true}); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + expect(defers.length).toBe(1); + expect(isObject(ctrl.$pending)).toBe(true); + + scope.$apply('value = "456"'); + expect(ctrl.$pending).toEqual({async: true}); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + expect(defers.length).toBe(2); + expect(isObject(ctrl.$pending)).toBe(true); + + defers[1].resolve(); + scope.$digest(); + expect(ctrl.$valid).toBe(true); + expect(ctrl.$invalid).toBe(false); + expect(isObject(ctrl.$pending)).toBe(false); + })); + + it('should clear and ignore all pending promises when a parser fails', inject(function($q) { + var failParser = false; + ctrl.$parsers.push(function(value) { + return failParser ? undefined : value; + }); + + var defer; + ctrl.$asyncValidators.async = function(value) { + defer = $q.defer(); + return defer.promise; + }; + + ctrl.$setViewValue('x..y..z'); + expect(ctrl.$valid).toBe(undefined); + expect(ctrl.$invalid).toBe(undefined); + + failParser = true; + + ctrl.$setViewValue('1..2..3'); + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(isObject(ctrl.$pending)).toBe(false); + + defer.resolve(); + scope.$digest(); + + expect(ctrl.$valid).toBe(false); + expect(ctrl.$invalid).toBe(true); + expect(isObject(ctrl.$pending)).toBe(false); + })); + + it('should clear all errors from async validators if a parser fails', inject(function($q) { + var failParser = false; + ctrl.$parsers.push(function(value) { + return failParser ? undefined : value; + }); + + ctrl.$asyncValidators.async = function(value) { + return $q.reject(); + }; + + ctrl.$setViewValue('x..y..z'); + expect(ctrl.$error).toEqual({async: true}); + + failParser = true; + + ctrl.$setViewValue('1..2..3'); + expect(ctrl.$error).toEqual({parse: true}); + })); + + it('should clear all errors from async validators if a sync validator fails', inject(function($q) { + var failValidator = false; + ctrl.$validators.sync = function(value) { + return !failValidator; + }; + + ctrl.$asyncValidators.async = function(value) { + return $q.reject(); + }; + + ctrl.$setViewValue('x..y..z'); + expect(ctrl.$error).toEqual({async: true}); + + failValidator = true; + + ctrl.$setViewValue('1..2..3'); + expect(ctrl.$error).toEqual({sync: true}); + })); + + it('should re-evaluate the form validity state once the asynchronous promise has been delivered', + inject(function($compile, $rootScope, $q) { + + var element = $compile('
' + + '' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + + var formCtrl = $rootScope.myForm; + var usernameCtrl = formCtrl.username; + var ageCtrl = formCtrl.age; + + var usernameDefer; + usernameCtrl.$asyncValidators.usernameAvailability = function() { + usernameDefer = $q.defer(); + return usernameDefer.promise; + }; + + $rootScope.$digest(); + expect(usernameCtrl.$invalid).toBe(true); + expect(formCtrl.$invalid).toBe(true); + + usernameCtrl.$setViewValue('valid-username'); + $rootScope.$digest(); + + expect(formCtrl.$pending.usernameAvailability).toBeTruthy(); + expect(usernameCtrl.$invalid).toBe(undefined); + expect(formCtrl.$invalid).toBe(undefined); + + usernameDefer.resolve(); + $rootScope.$digest(); + expect(usernameCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(true); + + ageCtrl.$setViewValue(22); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(false); + expect(ageCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(false); + + usernameCtrl.$setViewValue('valid'); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(true); + expect(ageCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(true); + + usernameCtrl.$setViewValue('another-valid-username'); + $rootScope.$digest(); + + usernameDefer.resolve(); + $rootScope.$digest(); + + expect(usernameCtrl.$invalid).toBe(false); + expect(formCtrl.$invalid).toBe(false); + expect(formCtrl.$pending).toBeFalsy(); + expect(ageCtrl.$invalid).toBe(false); + + dealoc(element); + })); + + + it('should minimize janky setting of classes during $validate() and ngModelWatch', inject(function($animate, $compile, $rootScope) { + var addClass = $animate.$$addClassImmediately; + var removeClass = $animate.$$removeClassImmediately; + var addClassCallCount = 0; + var removeClassCallCount = 0; + var input; + $animate.$$addClassImmediately = function(element, className) { + if (input && element[0] === input[0]) ++addClassCallCount; + return addClass.call($animate, element, className); + }; + + $animate.$$removeClassImmediately = function(element, className) { + if (input && element[0] === input[0]) ++removeClassCallCount; + return removeClass.call($animate, element, className); + }; + + dealoc(element); + + $rootScope.value = "123456789"; + element = $compile( + '
' + + '' + + '
' + )($rootScope); + + var form = $rootScope.form; + input = element.children().eq(0); + + $rootScope.$digest(); + + expect(input).toBeValid(); + expect(input).not.toHaveClass('ng-invalid-maxlength'); + expect(input).toHaveClass('ng-valid-maxlength'); + expect(addClassCallCount).toBe(1); + expect(removeClassCallCount).toBe(0); + + dealoc(element); + })); + + it('should always use the most recent $viewValue for validation', function() { + ctrl.$parsers.push(function(value) { + if (value && value.substr(-1) === 'b') { + value = 'a'; + ctrl.$setViewValue(value); + ctrl.$render(); + } + + return value; + }); + + ctrl.$validators.mock = function(modelValue) { + return true; + }; + + spyOn(ctrl.$validators, 'mock').andCallThrough(); + + ctrl.$setViewValue('ab'); + + expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'a'); + expect(ctrl.$validators.mock.calls.length).toEqual(2); + }); + + it('should validate even if the modelValue did not change', function() { + ctrl.$parsers.push(function(value) { + if (value && value.substr(-1) === 'b') { + value = 'a'; + } + + return value; + }); + + ctrl.$validators.mock = function(modelValue) { + return true; + }; + + spyOn(ctrl.$validators, 'mock').andCallThrough(); + + ctrl.$setViewValue('a'); + + expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'a'); + expect(ctrl.$validators.mock.calls.length).toEqual(1); + + ctrl.$setViewValue('ab'); + + expect(ctrl.$validators.mock).toHaveBeenCalledWith('a', 'ab'); + expect(ctrl.$validators.mock.calls.length).toEqual(2); + }); + + }); + }); + + + describe('directive', function() { + var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i; + + it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty, ng-untouched, ng-touched)', + inject(function($compile, $rootScope, $sniffer) { + var element = $compile('')($rootScope); + + $rootScope.$digest(); + expect(element).toBeValid(); + expect(element).toBePristine(); + expect(element).toBeUntouched(); + expect(element.hasClass('ng-valid-email')).toBe(true); + expect(element.hasClass('ng-invalid-email')).toBe(false); + + $rootScope.$apply("value = 'invalid-email'"); + expect(element).toBeInvalid(); + expect(element).toBePristine(); + expect(element.hasClass('ng-valid-email')).toBe(false); + expect(element.hasClass('ng-invalid-email')).toBe(true); + + element.val('invalid-again'); + browserTrigger(element, ($sniffer.hasEvent('input')) ? 'input' : 'change'); + expect(element).toBeInvalid(); + expect(element).toBeDirty(); + expect(element.hasClass('ng-valid-email')).toBe(false); + expect(element.hasClass('ng-invalid-email')).toBe(true); + + element.val('vojta@google.com'); + browserTrigger(element, $sniffer.hasEvent('input') ? 'input' : 'change'); + expect(element).toBeValid(); + expect(element).toBeDirty(); + expect(element.hasClass('ng-valid-email')).toBe(true); + expect(element.hasClass('ng-invalid-email')).toBe(false); + + browserTrigger(element, 'blur'); + expect(element).toBeTouched(); + + dealoc(element); + })); + + + it('should set invalid classes on init', inject(function($compile, $rootScope) { + var element = $compile('')($rootScope); + $rootScope.$digest(); + + expect(element).toBeInvalid(); + expect(element).toHaveClass('ng-invalid-required'); + + dealoc(element); + })); + + describe('custom formatter and parser that are added by a directive in post linking', function() { + var inputElm, scope; + beforeEach(module(function($compileProvider) { + $compileProvider.directive('customFormat', function() { + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModelCtrl) { + ngModelCtrl.$formatters.push(function(value) { + return value.part; + }); + ngModelCtrl.$parsers.push(function(value) { + return {part: value}; + }); + } + }; + }); + })); + + afterEach(function() { + dealoc(inputElm); + }); + + function createInput(type) { + inject(function($compile, $rootScope) { + scope = $rootScope; + inputElm = $compile('')($rootScope); + }); + } + + it('should use them after the builtin ones for text inputs', function() { + createInput('text'); + scope.$apply('val = {part: "a"}'); + expect(inputElm.val()).toBe('a'); + + inputElm.val('b'); + browserTrigger(inputElm, 'change'); + expect(scope.val).toEqual({part: 'b'}); + }); + + it('should use them after the builtin ones for number inputs', function() { + createInput('number'); + scope.$apply('val = {part: 1}'); + expect(inputElm.val()).toBe('1'); + + inputElm.val('2'); + browserTrigger(inputElm, 'change'); + expect(scope.val).toEqual({part: 2}); + }); + + it('should use them after the builtin ones for date inputs', function() { + createInput('date'); + scope.$apply(function() { + scope.val = {part: new Date(2000, 10, 8)}; + }); + expect(inputElm.val()).toBe('2000-11-08'); + + inputElm.val('2001-12-09'); + browserTrigger(inputElm, 'change'); + expect(scope.val).toEqual({part: new Date(2001, 11, 9)}); + }); + }); + + + it('should always format the viewValue as a string for a blank input type when the value is present', + inject(function($compile, $rootScope, $sniffer) { + + var form = $compile('
')($rootScope); + + $rootScope.val = 123; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe('123'); + + $rootScope.val = null; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe(null); + + dealoc(form); + })); + + it('should always format the viewValue as a string for a `text` input type when the value is present', + inject(function($compile, $rootScope, $sniffer) { + + var form = $compile('
')($rootScope); + $rootScope.val = 123; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe('123'); + + $rootScope.val = null; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe(null); + + dealoc(form); + })); + + it('should always format the viewValue as a string for an `email` input type when the value is present', + inject(function($compile, $rootScope, $sniffer) { + + var form = $compile('
')($rootScope); + $rootScope.val = 123; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe('123'); + + $rootScope.val = null; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe(null); + + dealoc(form); + })); + + it('should always format the viewValue as a string for a `url` input type when the value is present', + inject(function($compile, $rootScope, $sniffer) { + + var form = $compile('
')($rootScope); + $rootScope.val = 123; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe('123'); + + $rootScope.val = null; + $rootScope.$digest(); + expect($rootScope.form.field.$viewValue).toBe(null); + + dealoc(form); + })); + + it('should set the control touched state on "blur" event', inject(function($compile, $rootScope) { + var element = $compile('
' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + var control = $rootScope.myForm.myControl; + + expect(control.$touched).toBe(false); + expect(control.$untouched).toBe(true); + + browserTrigger(inputElm, 'blur'); + expect(control.$touched).toBe(true); + expect(control.$untouched).toBe(false); + + dealoc(element); + })); + + it('should not cause a digest on "blur" event if control is already touched', + inject(function($compile, $rootScope) { + + var element = $compile('
' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + var control = $rootScope.myForm.myControl; + + control.$setTouched(); + spyOn($rootScope, '$apply'); + browserTrigger(inputElm, 'blur'); + + expect($rootScope.$apply).not.toHaveBeenCalled(); + + dealoc(element); + })); + + it('should digest asynchronously on "blur" event if a apply is already in progress', + inject(function($compile, $rootScope) { + + var element = $compile('
' + + '' + + '
')($rootScope); + var inputElm = element.find('input'); + var control = $rootScope.myForm.myControl; + + $rootScope.$apply(function() { + expect(control.$touched).toBe(false); + expect(control.$untouched).toBe(true); + + browserTrigger(inputElm, 'blur'); + + expect(control.$touched).toBe(false); + expect(control.$untouched).toBe(true); + }); + + expect(control.$touched).toBe(true); + expect(control.$untouched).toBe(false); + + dealoc(element); + })); + + + it('should register/deregister a nested ngModel with parent form when entering or leaving DOM', + inject(function($compile, $rootScope) { + + var element = $compile('
' + + '' + + '
')($rootScope); + var isFormValid; + + $rootScope.inputPresent = false; + $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); + + $rootScope.$apply(); + + expect($rootScope.myForm.$valid).toBe(true); + expect(isFormValid).toBe(true); + expect($rootScope.myForm.myControl).toBeUndefined(); + + $rootScope.inputPresent = true; + $rootScope.$apply(); + + expect($rootScope.myForm.$valid).toBe(false); + expect(isFormValid).toBe(false); + expect($rootScope.myForm.myControl).toBeDefined(); + + $rootScope.inputPresent = false; + $rootScope.$apply(); + + expect($rootScope.myForm.$valid).toBe(true); + expect(isFormValid).toBe(true); + expect($rootScope.myForm.myControl).toBeUndefined(); + + dealoc(element); + })); + + + it('should register/deregister a nested ngModel with parent form when entering or leaving DOM with animations', + function() { + + // ngAnimate performs the dom manipulation after digest, and since the form validity can be affected by a form + // control going away we must ensure that the deregistration happens during the digest while we are still doing + // dirty checking. + module('ngAnimate'); + + inject(function($compile, $rootScope) { + var element = $compile('
' + + '' + + '
')($rootScope); + var isFormValid; + + $rootScope.inputPresent = false; + // this watch ensure that the form validity gets updated during digest (so that we can observe it) + $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); + + $rootScope.$apply(); + + expect($rootScope.myForm.$valid).toBe(true); + expect(isFormValid).toBe(true); + expect($rootScope.myForm.myControl).toBeUndefined(); + + $rootScope.inputPresent = true; + $rootScope.$apply(); + + expect($rootScope.myForm.$valid).toBe(false); + expect(isFormValid).toBe(false); + expect($rootScope.myForm.myControl).toBeDefined(); + + $rootScope.inputPresent = false; + $rootScope.$apply(); + + expect($rootScope.myForm.$valid).toBe(true); + expect(isFormValid).toBe(true); + expect($rootScope.myForm.myControl).toBeUndefined(); + + dealoc(element); + }); + }); + + it('should keep previously defined watches consistent when changes in validity are made', + inject(function($compile, $rootScope) { + + var isFormValid; + $rootScope.$watch('myForm.$valid', function(value) { isFormValid = value; }); + + var element = $compile('
' + + '' + + '
')($rootScope); + + $rootScope.$apply(); + expect(isFormValid).toBe(false); + expect($rootScope.myForm.$valid).toBe(false); + + $rootScope.value='value'; + $rootScope.$apply(); + expect(isFormValid).toBe(true); + expect($rootScope.myForm.$valid).toBe(true); + + dealoc(element); + })); + + }); + + describe('animations', function() { + beforeEach(module('ngAnimateMock')); + + function findElementAnimations(element, queue) { + var node = element[0]; + var animations = []; + for (var i = 0; i < queue.length; i++) { + var animation = queue[i]; + if (animation.element[0] == node) { + animations.push(animation); + } + } + return animations; + } + + function assertValidAnimation(animation, event, classNameA, classNameB) { + expect(animation.event).toBe(event); + expect(animation.args[1]).toBe(classNameA); + if (classNameB) expect(animation.args[2]).toBe(classNameB); + } + + var doc, input, scope, model; + beforeEach(inject(function($rootScope, $compile, $rootElement, $animate) { + scope = $rootScope.$new(); + doc = jqLite('
' + + ' ' + + '
'); + $rootElement.append(doc); + $compile(doc)(scope); + $animate.queue = []; + + input = doc.find('input'); + model = scope.myForm.myInput; + })); + + afterEach(function() { + dealoc(input); + }); + + it('should trigger an animation when invalid', inject(function($animate) { + model.$setValidity('required', false); + + var animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); + assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); + assertValidAnimation(animations[2], 'addClass', 'ng-invalid-required'); + })); + + it('should trigger an animation when valid', inject(function($animate) { + model.$setValidity('required', false); + + $animate.queue = []; + + model.$setValidity('required', true); + + var animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'addClass', 'ng-valid'); + assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); + assertValidAnimation(animations[2], 'addClass', 'ng-valid-required'); + assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-required'); + })); + + it('should trigger an animation when dirty', inject(function($animate) { + model.$setViewValue('some dirty value'); + + var animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'removeClass', 'ng-pristine'); + assertValidAnimation(animations[1], 'addClass', 'ng-dirty'); + })); + + it('should trigger an animation when pristine', inject(function($animate) { + model.$setPristine(); + + var animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'removeClass', 'ng-dirty'); + assertValidAnimation(animations[1], 'addClass', 'ng-pristine'); + })); + + it('should trigger an animation when untouched', inject(function($animate) { + model.$setUntouched(); + + var animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'setClass', 'ng-untouched'); + expect(animations[0].args[2]).toBe('ng-touched'); + })); + + it('should trigger an animation when touched', inject(function($animate) { + model.$setTouched(); + + var animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'setClass', 'ng-touched', 'ng-untouched'); + expect(animations[0].args[2]).toBe('ng-untouched'); + })); + + it('should trigger custom errors as addClass/removeClass when invalid/valid', inject(function($animate) { + model.$setValidity('custom-error', false); + + var animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'removeClass', 'ng-valid'); + assertValidAnimation(animations[1], 'addClass', 'ng-invalid'); + assertValidAnimation(animations[2], 'addClass', 'ng-invalid-custom-error'); + + $animate.queue = []; + model.$setValidity('custom-error', true); + + animations = findElementAnimations(input, $animate.queue); + assertValidAnimation(animations[0], 'addClass', 'ng-valid'); + assertValidAnimation(animations[1], 'removeClass', 'ng-invalid'); + assertValidAnimation(animations[2], 'addClass', 'ng-valid-custom-error'); + assertValidAnimation(animations[3], 'removeClass', 'ng-invalid-custom-error'); + })); + }); + +});