angular/angular.js

View on GitHub
src/ng/directive/ngModelOptions.js

Summary

Maintainability
A
1 hr
Test Coverage
'use strict';

/* exported defaultModelOptions */
var defaultModelOptions;
var DEFAULT_REGEXP = /(\s+|^)default(\s+|$)/;

/**
 * @ngdoc type
 * @name ModelOptions
 * @description
 * A container for the options set by the {@link ngModelOptions} directive
 */
function ModelOptions(options) {
  this.$$options = options;
}

ModelOptions.prototype = {

  /**
   * @ngdoc method
   * @name ModelOptions#getOption
   * @param {string} name the name of the option to retrieve
   * @returns {*} the value of the option
   * @description
   * Returns the value of the given option
   */
  getOption: function(name) {
    return this.$$options[name];
  },

  /**
   * @ngdoc method
   * @name ModelOptions#createChild
   * @param {Object} options a hash of options for the new child that will override the parent's options
   * @return {ModelOptions} a new `ModelOptions` object initialized with the given options.
   */
  createChild: function(options) {
    var inheritAll = false;

    // make a shallow copy
    options = extend({}, options);

    // Inherit options from the parent if specified by the value `"$inherit"`
    forEach(options, /** @this */ function(option, key) {
      if (option === '$inherit') {
        if (key === '*') {
          inheritAll = true;
        } else {
          options[key] = this.$$options[key];
          // `updateOn` is special so we must also inherit the `updateOnDefault` option
          if (key === 'updateOn') {
            options.updateOnDefault = this.$$options.updateOnDefault;
          }
        }
      } else {
        if (key === 'updateOn') {
          // If the `updateOn` property contains the `default` event then we have to remove
          // it from the event list and set the `updateOnDefault` flag.
          options.updateOnDefault = false;
          options[key] = trim(option.replace(DEFAULT_REGEXP, function() {
            options.updateOnDefault = true;
            return ' ';
          }));
        }
      }
    }, this);

    if (inheritAll) {
      // We have a property of the form: `"*": "$inherit"`
      delete options['*'];
      defaults(options, this.$$options);
    }

    // Finally add in any missing defaults
    defaults(options, defaultModelOptions.$$options);

    return new ModelOptions(options);
  }
};


defaultModelOptions = new ModelOptions({
  updateOn: '',
  updateOnDefault: true,
  debounce: 0,
  getterSetter: false,
  allowInvalid: false,
  timezone: null
});


/**
 * @ngdoc directive
 * @name ngModelOptions
 * @restrict A
 * @priority 10
 *
 * @description
 * This directive allows you to modify the behaviour of {@link ngModel} directives within your
 * application. You can specify an `ngModelOptions` directive on any element. All {@link ngModel}
 * directives will use the options of their nearest `ngModelOptions` ancestor.
 *
 * The `ngModelOptions` settings are found by evaluating the value of the attribute directive as
 * an AngularJS expression. This expression should evaluate to an object, whose properties contain
 * the settings. For example: `<div ng-model-options="{ debounce: 100 }"`.
 *
 * ## Inheriting Options
 *
 * You can specify that an `ngModelOptions` setting should be inherited from a parent `ngModelOptions`
 * directive by giving it the value of `"$inherit"`.
 * Then it will inherit that setting from the first `ngModelOptions` directive found by traversing up the
 * DOM tree. If there is no ancestor element containing an `ngModelOptions` directive then default settings
 * will be used.
 *
 * For example given the following fragment of HTML
 *
 *
 * ```html
 * <div ng-model-options="{ allowInvalid: true, debounce: 200 }">
 *   <form ng-model-options="{ updateOn: 'blur', allowInvalid: '$inherit' }">
 *     <input ng-model-options="{ updateOn: 'default', allowInvalid: '$inherit' }" />
 *   </form>
 * </div>
 * ```
 *
 * the `input` element will have the following settings
 *
 * ```js
 * { allowInvalid: true, updateOn: 'default', debounce: 0 }
 * ```
 *
 * Notice that the `debounce` setting was not inherited and used the default value instead.
 *
 * You can specify that all undefined settings are automatically inherited from an ancestor by
 * including a property with key of `"*"` and value of `"$inherit"`.
 *
 * For example given the following fragment of HTML
 *
 *
 * ```html
 * <div ng-model-options="{ allowInvalid: true, debounce: 200 }">
 *   <form ng-model-options="{ updateOn: 'blur', "*": '$inherit' }">
 *     <input ng-model-options="{ updateOn: 'default', "*": '$inherit' }" />
 *   </form>
 * </div>
 * ```
 *
 * the `input` element will have the following settings
 *
 * ```js
 * { allowInvalid: true, updateOn: 'default', debounce: 200 }
 * ```
 *
 * Notice that the `debounce` setting now inherits the value from the outer `<div>` element.
 *
 * If you are creating a reusable component then you should be careful when using `"*": "$inherit"`
 * since you may inadvertently inherit a setting in the future that changes the behavior of your component.
 *
 *
 * ## Triggering and debouncing model updates
 *
 * The `updateOn` and `debounce` properties allow you to 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 from 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.
 *
 * ### Overriding immediate updates
 *
 * 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.
 *
 * <example name="ngModelOptions-directive-blur" module="optionsExample">
 *   <file name="index.html">
 *     <div ng-controller="ExampleController">
 *       <form name="userForm">
 *         <label>
 *           Name:
 *           <input type="text" name="userName"
 *                  ng-model="user.name"
 *                  ng-model-options="{ updateOn: 'blur' }"
 *                  ng-keyup="cancel($event)" />
 *         </label><br />
 *         <label>
 *           Other data:
 *           <input type="text" ng-model="user.data" />
 *         </label><br />
 *       </form>
 *       <pre>user.name = <span ng-bind="user.name"></span></pre>
 *     </div>
 *   </file>
 *   <file name="app.js">
 *     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();
 *           }
 *         };
 *       }]);
 *   </file>
 *   <file name="protractor.js" type="protractor">
 *     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');
 *     });
 *   </file>
 * </example>
 *
 * ### Debouncing updates
 *
 * The next example 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.
 *
 * <example name="ngModelOptions-directive-debounce" module="optionsExample">
 *   <file name="index.html">
 *     <div ng-controller="ExampleController">
 *       <form name="userForm">
 *         Name:
 *         <input type="text" name="userName"
 *                ng-model="user.name"
 *                ng-model-options="{ debounce: 1000 }" />
 *         <button ng-click="userForm.userName.$rollbackViewValue(); user.name=''">Clear</button><br />
 *       </form>
 *       <pre>user.name = <span ng-bind="user.name"></span></pre>
 *     </div>
 *   </file>
 *   <file name="app.js">
 *     angular.module('optionsExample', [])
 *       .controller('ExampleController', ['$scope', function($scope) {
 *         $scope.user = { name: 'say' };
 *       }]);
 *   </file>
 * </example>
 *
 * ### Default events, extra triggers, and catch-all debounce values
 *
 * This example shows the relationship between "default" update events and
 * additional `updateOn` triggers.
 *
 * `default` events are those that are bound to the control, and when fired, update the `$viewValue`
 * via {@link ngModel.NgModelController#$setViewValue $setViewValue}. Every event that is not listed
 * in `updateOn` is considered a "default" event, since different control types have different
 * default events.
 *
 * The control in this example updates by "default", "click", and "blur", with different `debounce`
 * values. You can see that "click" doesn't have an individual `debounce` value -
 * therefore it uses the `*` debounce value.
 *
 * There is also a button that calls {@link ngModel.NgModelController#$setViewValue $setViewValue}
 * directly with a "custom" event. Since "custom" is not defined in the `updateOn` list,
 * it is considered a "default" event and will update the
 * control if "default" is defined in `updateOn`, and will receive the "default" debounce value.
 * Note that this is just to illustrate how custom controls would possibly call `$setViewValue`.
 *
 * You can change the `updateOn` and `debounce` configuration to test different scenarios. This
 * is done with {@link ngModel.NgModelController#$overrideModelOptions $overrideModelOptions}.
 *
  <example name="ngModelOptions-advanced" module="optionsExample">
    <file name="index.html">
       <model-update-demo></model-update-demo>
    </file>
    <file name="app.js">
      angular.module('optionsExample', [])
        .component('modelUpdateDemo', {
          templateUrl: 'template.html',
          controller: function() {
            this.name = 'Chinua';

            this.options = {
              updateOn: 'default blur click',
              debounce: {
                default: 2000,
                blur: 0,
                '*': 1000
              }
            };

            this.updateEvents = function() {
              var eventList = this.options.updateOn.split(' ');
              eventList.push('*');
              var events = {};

              for (var i = 0; i < eventList.length; i++) {
                events[eventList[i]] = this.options.debounce[eventList[i]];
              }

              this.events = events;
            };

            this.updateOptions = function() {
              var options = angular.extend(this.options, {
                updateOn: Object.keys(this.events).join(' ').replace('*', ''),
                debounce: this.events
              });

              this.form.input.$overrideModelOptions(options);
            };

            // Initialize the event form
            this.updateEvents();
          }
        });
    </file>
    <file name="template.html">
      <form name="$ctrl.form">
        Input: <input type="text" name="input" ng-model="$ctrl.name" ng-model-options="$ctrl.options" />
      </form>
      Model: <tt>{{$ctrl.name}}</tt>
      <hr>
      <button ng-click="$ctrl.form.input.$setViewValue('some value', 'custom')">Trigger setViewValue with 'some value' and 'custom' event</button>

      <hr>
      <form ng-submit="$ctrl.updateOptions()">
        <b>updateOn</b><br>
        <input type="text" ng-model="$ctrl.options.updateOn" ng-change="$ctrl.updateEvents()" ng-model-options="{debounce: 500}">

        <table>
          <tr>
            <th>Option</th>
            <th>Debounce value</th>
          </tr>
          <tr ng-repeat="(key, value) in $ctrl.events">
            <td>{{key}}</td>
            <td><input type="number" ng-model="$ctrl.events[key]" /></td>
          </tr>
        </table>

        <br>
        <input type="submit" value="Update options">
      </form>
    </file>
  </example>
 *
 *
 * ## Model updates and validation
 *
 * The default behaviour in `ngModel` is that the model value is set to `undefined` when the
 * validation determines that the value is invalid. By setting the `allowInvalid` property to true,
 * the model will still be updated even if the value is invalid.
 *
 *
 * ## Connecting to the scope
 *
 * By setting the `getterSetter` property to true you are telling ngModel that the `ngModel` expression
 * on the scope refers to a "getter/setter" function rather than the value itself.
 *
 * The following example shows how to bind to getter/setters:
 *
 * <example name="ngModelOptions-directive-getter-setter" module="getterSetterExample">
 *   <file name="index.html">
 *     <div ng-controller="ExampleController">
 *       <form name="userForm">
 *         <label>
 *           Name:
 *           <input type="text" name="userName"
 *                  ng-model="user.name"
 *                  ng-model-options="{ getterSetter: true }" />
 *         </label>
 *       </form>
 *       <pre>user.name = <span ng-bind="user.name()"></span></pre>
 *     </div>
 *   </file>
 *   <file name="app.js">
 *     angular.module('getterSetterExample', [])
 *       .controller('ExampleController', ['$scope', function($scope) {
 *         var _name = 'Brian';
 *         $scope.user = {
 *           name: function(newName) {
 *             return angular.isDefined(newName) ? (_name = newName) : _name;
 *           }
 *         };
 *       }]);
 *   </file>
 * </example>
 *
 *
 * ## Programmatically changing options
 *
 * The `ngModelOptions` expression is only evaluated once when the directive is linked; it is not
 * watched for changes. However, it is possible to override the options on a single
 * {@link ngModel.NgModelController} instance with
 * {@link ngModel.NgModelController#$overrideModelOptions `NgModelController#$overrideModelOptions()`}.
 * See also the example for
 * {@link ngModelOptions#default-events-extra-triggers-and-catch-all-debounce-values
 * Default events, extra triggers, and catch-all debounce values}.
 *
 *
 * ## Specifying timezones
 *
 * You can specify the timezone that date/time input directives expect by providing its name in the
 * `timezone` property.
 *
 *
 * ## Formatting the value of `time` and `datetime-local`
 *
 * With the options `timeSecondsFormat` and `timeStripZeroSeconds` it is possible to adjust the value
 * that is displayed in the control. Note that browsers may apply their own formatting
 * in the user interface.
 *
   <example name="ngModelOptions-time-format" module="timeExample">
     <file name="index.html">
       <time-example></time-example>
     </file>
     <file name="script.js">
        angular.module('timeExample', [])
          .component('timeExample', {
            templateUrl: 'timeExample.html',
            controller: function() {
              this.time = new Date(1970, 0, 1, 14, 57, 0);

              this.options = {
                timeSecondsFormat: 'ss',
                timeStripZeroSeconds: true
              };

              this.optionChange = function() {
                this.timeForm.timeFormatted.$overrideModelOptions(this.options);
                this.time = new Date(this.time);
              };
            }
          });
     </file>
     <file name="timeExample.html">
       <form name="$ctrl.timeForm">
         <strong>Default</strong>:
         <input type="time" ng-model="$ctrl.time" step="any" /><br>
         <strong>With options</strong>:
         <input type="time" name="timeFormatted" ng-model="$ctrl.time" step="any" ng-model-options="$ctrl.options" />
         <br>

         Options:<br>
         <code>timeSecondsFormat</code>:
         <input
           type="text"
           ng-model="$ctrl.options.timeSecondsFormat"
           ng-change="$ctrl.optionChange()">
         <br>
         <code>timeStripZeroSeconds</code>:
         <input
           type="checkbox"
           ng-model="$ctrl.options.timeStripZeroSeconds"
           ng-change="$ctrl.optionChange()">
        </form>
      </file>
 *  </example>
 *
 * @param {Object} ngModelOptions options to apply to {@link ngModel} directives on this element and
 *   and its descendents.
 *
 * **General options**:
 *
 *   - `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 to the control. These are the events that are bound to
 *     the control, and when fired, update the `$viewValue` via `$setViewValue`.
 *
 *     `ngModelOptions` considers every event that is not listed in `updateOn` a "default" event,
 *     since different control types use different default events.
 *
 *     See also the section {@link ngModelOptions#triggering-and-debouncing-model-updates
 *     Triggering and debouncing model updates}.
 *
 *   - `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 }
 *     }"
 *     ```
 *     You can use the `*` key to specify a debounce value that applies to all events that are not
 *     specifically listed. In the following example, `mouseup` would have a debounce delay of 1000:
 *     ```
 *     ng-model-options="{
 *       updateOn: 'default blur mouseup',
 *       debounce: { 'default': 500, 'blur': 0, '*': 1000 }
 *     }"
 *     ```
 *   - `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.
 *
 *
 *  **Input-type specific options**:
 *
 *   - `timezone`: Defines the timezone to be used to read/write the `Date` instance in the model for
 *     `<input type="date" />`, `<input type="time" />`, ... . It understands UTC/GMT and the
 *     continental US time zone abbreviations, but for general use, use a time zone offset, for
 *     example, `'+0430'` (4 hours, 30 minutes east of the Greenwich meridian)
 *     If not specified, the timezone of the browser will be used.
 *     Note that changing the timezone will have no effect on the current date, and is only applied after
 *     the next input / model change.
 *
 *   - `timeSecondsFormat`: Defines if the `time` and `datetime-local` types should show seconds and
 *     milliseconds. The option follows the format string of {@link date date filter}.
 *     By default, the options is `undefined` which is equal to `'ss.sss'` (seconds and milliseconds).
 *     The other options are `'ss'` (strips milliseconds), and `''` (empty string), which strips both
 *     seconds and milliseconds.
 *     Note that browsers that support `time` and `datetime-local` require the hour and minutes
 *     part of the time string, and may show the value differently in the user interface.
 *     {@link ngModelOptions#formatting-the-value-of-time-and-datetime-local- See the example}.
 *
 *   - `timeStripZeroSeconds`: Defines if the `time` and `datetime-local` types should strip the
 *     seconds and milliseconds from the formatted value if they are zero. This option is applied
 *     after `timeSecondsFormat`.
 *     This option can be used to make the formatting consistent over different browsers, as some
 *     browsers with support for `time` will natively hide the milliseconds and
 *     seconds if they are zero, but others won't, and browsers that don't implement these input
 *     types will always show the full string.
 *     {@link ngModelOptions#formatting-the-value-of-time-and-datetime-local- See the example}.
 *
 */
var ngModelOptionsDirective = function() {
  NgModelOptionsController.$inject = ['$attrs', '$scope'];
  function NgModelOptionsController($attrs, $scope) {
    this.$$attrs = $attrs;
    this.$$scope = $scope;
  }
  NgModelOptionsController.prototype = {
    $onInit: function() {
      var parentOptions = this.parentCtrl ? this.parentCtrl.$options : defaultModelOptions;
      var modelOptionsDefinition = this.$$scope.$eval(this.$$attrs.ngModelOptions);

      this.$options = parentOptions.createChild(modelOptionsDefinition);
    }
  };

  return {
    restrict: 'A',
    // ngModelOptions needs to run before ngModel and input directives
    priority: 10,
    require: {parentCtrl: '?^^ngModelOptions'},
    bindToController: true,
    controller: NgModelOptionsController
  };
};


// shallow copy over values from `src` that are not already specified on `dst`
function defaults(dst, src) {
  forEach(src, function(value, key) {
    if (!isDefined(dst[key])) {
      dst[key] = value;
    }
  });
}