diff options
Diffstat (limited to 'docs/content/guide/dev_guide.forms.ngdoc')
| -rw-r--r-- | docs/content/guide/dev_guide.forms.ngdoc | 610 | 
1 files changed, 610 insertions, 0 deletions
| diff --git a/docs/content/guide/dev_guide.forms.ngdoc b/docs/content/guide/dev_guide.forms.ngdoc new file mode 100644 index 00000000..6849ff4e --- /dev/null +++ b/docs/content/guide/dev_guide.forms.ngdoc @@ -0,0 +1,610 @@ +@ngdoc overview +@name Developer Guide: Forms +@description + +# Overview + +Forms allow users to enter data into your application. Forms represent the bidirectional data +bindings in Angular. + +Forms consist of all of the following: + +  - the individual widgets with which users interact +  - the validation rules for widgets +  - the form, a collection of widgets that contains aggregated validation information + + +# Form + +A form groups a set of widgets together into a single logical data-set. A form is created using +the {@link api/angular.widget.form <form>} element that calls the +{@link api/angular.service.$formFactory $formFactory} service. The form is responsible for managing +the widgets and for tracking validation information. + +A form is: + +- The collection which contains widgets or other forms. +- Responsible for marshaling data from the model into a widget. This is +  triggered by {@link api/angular.scope.$watch $watch} of the model expression. +- Responsible for marshaling data from the widget into the model. This is +  triggered by the widget emitting the `$viewChange` event. +- Responsible for updating the validation state of the widget, when the widget emits +  `$valid` / `$invalid` event. The validation state is useful for controlling the validation +   errors shown to the user in it consist of: + +  - `$valid` / `$invalid`: Complementary set of booleans which show if a widget is valid / invalid. +  - `$error`: an object which has a property for each validation key emited by the widget. +              The value of the key is always true. If widget is valid, then the `$error` +              object has no properties. For example if the widget emits +              `$invalid` event with `REQUIRED` key. The internal state of the `$error` would be +              updated to `$error.REQUIRED == true`. + +- Responsible for aggregating widget validation information into the form. + +  - `$valid` / `$invalid`: Complementary set of booleans which show if all the child widgets +              (or forms) are valid or if any are invalid. +  - `$error`: an object which has a property for each validation key emited by the +              child widget. The value of the key is an array of widgets which fired the invalid +              event. If all child widgets are valid then, then the `$error` object has no +              properties. For example if a child widget emits +              `$invalid` event with `REQUIRED` key. The internal state of the `$error` would be +              updated to `$error.REQUIRED == [ widgetWhichEmitedInvalid ]`. + + +# Widgets + +In Angular, a widget is the term used for the UI with which the user input. Examples of +bult-in Angular widgets are {@link api/angular.widget.input input} and +{@link api/angular.widget.select select}. Widgets provide the rendering and the user +interaction logic. Widgets should be declared inside a form, if no form is provided an implicit +form {@link api/angular.service.$formFactory $formFactory.rootForm} form is used. + +Widgets are implemented as Angular controllers. A widget controller: + +-  implements methods: + +  - `$render` - Updates the DOM from the internal state as represented by `$viewValue`. +  - `$parseView` - Translate `$viewValue` to `$modelValue`. (`$modelValue` will be assigned to +     the model scope by the form) +  - `$parseModel` - Translate `$modelValue` to `$viewValue`. (`$viewValue` will be assigned to +     the DOM inside the `$render` method) + +- responds to events: + +  - `$validate` - Emitted by the form when the form determines that the widget needs to validate +    itself. There may be more then one listener on the `$validate` event. The widget responds +    by emitting `$valid` / `$invalid` event of its own. + +- emits events: + +  - `$viewChange` - Emitted when the user interacts with the widget and it is necessary to update +     the model. +  - `$valid` - Emitted when the widget determines that it is valid (usually as a response to +    `$validate` event or inside `$parseView()` or `$parseModel()` method). +  - `$invalid` - Emitted when the widget determines that it is invalid (usually as a response to +    `$validate` event or inside `$parseView()` or `$parseModel()` method). +  - `$destroy` - Emitted when the widget element is removed from the DOM. + + +# CSS + +Angular-defined widgets and forms set `ng-valid` and `ng-invalid` classes on themselves to allow +the web-designer a way to style them. If you write your own widgets, then their `$render()` +methods must set the appropriate CSS classes to allow styling. +(See {@link dev_guide.templates.css-styling CSS}) + + +# Example + +The following example demonstrates: + +  - How an error is displayed when a required field is empty. +  - Error highlighting. +  - How form submission is disabled when the form is invalid. +  - The internal state of the widget and form in the the 'Debug View' area. + + +<doc:example> +<doc:source> +   <style> +     .ng-invalid { border: solid 1px red; } +     .ng-form {display: block;} +   </style> +   <script> +   function UserFormCntl(){ +     this.state = /^\w\w$/; +     this.zip = /^\d\d\d\d\d$/; +     this.master = { +       customer: 'John Smith', +       address:{ +         line1: '123 Main St.', +         city:'Anytown', +         state:'AA', +         zip:'12345' +       } +     }; +     this.cancel(); +   } + +   UserFormCntl.prototype = { +     cancel: function(){ +       this.form = angular.copy(this.master); +     }, + +     save: function(){ +       this.master = this.form; +       this.cancel(); +     } +   }; +   </script> +   <div ng:controller="UserFormCntl"> + +     <form name="userForm"> + +       <label>Name:</label><br/> +       <input type="text" name="customer" ng:model="form.customer" required/> +       <span class="error" ng:show="userForm.customer.$error.REQUIRED"> +         Customer name is required!</span> +       <br/><br/> + +       <ng:form name="addressForm"> +         <label>Address:</label> <br/> +         <input type="text" name="line1" size="33" required +                ng:model="form.address.line1"/> <br/> +         <input type="text" name="city" size="12" required +                ng:model="form.address.city"/>, +         <input type="text" name="state" ng:pattern="state" size="2" required +                ng:model="form.address.state"/> +         <input type="text" name="zip" ng:pattern="zip" size="5" required +                ng:model="form.address.zip"/><br/><br/> + +         <span class="error" ng:show="addressForm.$invalid"> +           Incomplete address: +           <div class="error" ng:show="addressForm.state.$error.REQUIRED"> +             Missing state!</span> +           <div class="error" ng:show="addressForm.state.$error.PATTERN"> +             Invalid state!</span> +           <div class="error" ng:show="addressForm.zip.$error.REQUIRED"> +             Missing zip!</span> +           <div class="error" ng:show="addressForm.zip.$error.PATTERN"> +             Invalid zip!</span> +         </span> +       </ng:form> + +       <button ng:click="cancel()" +               ng:disabled="{{master.$equals(form)}}">Cancel</button> +       <button ng:click="save()" +               ng:disabled="{{userForm.$invalid || master.$equals(form)}}"> +          Save</button> +     </form> + +     <hr/> +     Debug View: +     <pre>form={{form}}</pre> +     <pre>master={{master}}</pre> +     <pre>userForm={{userForm}}</pre> +     <pre>addressForm={{addressForm}}</pre> +   </div> +</doc:source> +<doc:scenario> +  it('should enable save button', function(){ +    expect(element(':button:contains(Save)').attr('disabled')).toBeTruthy(); +    input('form.customer').enter(''); +    expect(element(':button:contains(Save)').attr('disabled')).toBeTruthy(); +    input('form.customer').enter('change'); +    expect(element(':button:contains(Save)').attr('disabled')).toBeFalsy(); +    element(':button:contains(Save)').click(); +    expect(element(':button:contains(Save)').attr('disabled')).toBeTruthy(); +  }); +  it('should enable cancel button', function(){ +    expect(element(':button:contains(Cancel)').attr('disabled')).toBeTruthy(); +    input('form.customer').enter('change'); +    expect(element(':button:contains(Cancel)').attr('disabled')).toBeFalsy(); +    element(':button:contains(Cancel)').click(); +    expect(element(':button:contains(Cancel)').attr('disabled')).toBeTruthy(); +    expect(element(':input[ng\\:model="form.customer"]').val()).toEqual('John Smith'); +  }); +</doc:scenario> +</doc:example> + +# Life-cycle + +- The `<form>` element triggers creation of a new form {@link dev_guide.scopes scope} using the +  {@link api/angular.service.$formFactory $formfactory}. The new form scope is added to the +  `<form>` element using the jQuery `.data()` method for later retrieval under the key `$form`. +  The form also sets up these listeners: + +  - `$destroy` - This event is emitted by nested widget when it is removed from the view. It gives +     the form a chance to clean up any validation references to the destroyed widget. +  - `$valid` / `$invalid` - This event is emitted by the widget on validation state change. + +- `<input>` element triggers the creation of the widget using the +  {@link api/angular.service.$formFactory $formfactory.$createWidget()} method. The `$createWidget()` +  creates new widget instance by calling the current scope {@link api/angular.scope.$new .$new()} and +  registers these listeners: + +  - `$watch` on the model scope. +  - `$viewChange` event on the widget scope. +  - `$validate` event on the widget scope. +  - Element `change` event when the user enters data. + +<img class="center" src="img/form_data_flow.png" border="1" /> + + +- When the user interacts with the widget: + +  1. The DOM element fires the `change` event which the widget intercepts. Widget then emits +     a `$viewChange` event which includes the new user-entered value. (Remember that the DOM events +     are outside of the Angular environment so the widget must emit its event within the +     {@link api/angular.scope.$apply $apply} method). +  2. The form's `$viewChange` listener copies the user-entered value to the widget's `$viewValue` +     property. Since the `$viewValue` is the raw value as entered by user, it may need to be +     translated to a different format/type (for example, translating a string to a number). +     If you need your widget to translate between the internal `$viewValue` and the external +     `$modelValue` state, you must declare a `$parseView()` method. The `$parseView()` method +     will copy `$viewValue` to `$modelValue` and perform any necessary translations. +  3. The `$modelValue` is written into the application model. +  4. The form then emits a `$validate` event, giving the widget's validators chance to validate the +     input. There can be any number of validators registered. Each validator may in turn +     emit a `$valid` / `$invalid` event with the validator's validation key. For example `REQUIRED`. +  5. Form listens to `$valid`/`$invalid` events and updates both the form as well as the widget +     scope with the validation state. The validation updates the `$valid` and `$invalid`, property +     as well as `$error` object. The widget's `$error` object is updated with the validation key +     such that `$error.REQUIRED == true` when the validation emits `$invalid` with `REQUIRED` +     validation key. Similarly the form's `$error` object gets updated, but instead of boolean +     `true` it contains an array of invalid widgets (widgets which fired `$invalid` event with +     `REQUIRED` validation key). + +- When the model is updated: + +  1. The model `$watch` listener assigns the model value to `$modelValue` on the widget. +  2. The form then calls `$parseModel` method on widget if present. The method converts the +     value to renderable format and assigns it to `$viewValue` (for example converting number to a +     string.) +  3. The form then emits a `$validate` which behaves as described above. +  4. The form then calls `$render` method on the widget to update the DOM structure from the +     `$viewValue`. + + + +# Writing Your Own Widget + +This example shows how to implement a custom HTML editor widget in Angular. + +    <doc:example> +      <doc:source> +        <script> +          function EditorCntl(){ +            this.htmlContent = '<b>Hello</b> <i>World</i>!'; +          } + +          function HTMLEditorWidget(element) { +            var self = this; +            var htmlFilter = angular.filter('html'); + +            this.$parseModel = function(){ +              // need to protect for script injection +              try { +                this.$viewValue = htmlFilter( +                  this.$modelValue || '').get(); +                if (this.$error.HTML) { +                  // we were invalid, but now we are OK. +                  this.$emit('$valid', 'HTML'); +                } +              } catch (e) { +                // if HTML not parsable invalidate form. +                this.$emit('$invalid', 'HTML'); +              } +            } + +            this.$render = function(){ +              element.html(this.$viewValue); +            } + +            element.bind('keyup', function(){ +              self.$apply(function(){ +                self.$emit('$viewChange', element.html()); +              }); +            }); +          } + +          angular.directive('ng:html-editor-model', function(){ +            function linkFn($formFactory, element) { +              var exp = element.attr('ng:html-editor-model'), +                  form = $formFactory.forElement(element), +                  widget; +              element.attr('contentEditable', true); +              widget = form.$createWidget({ +                scope: this, +                model: exp, +                controller: HTMLEditorWidget, +                controllerArgs: [element]}); +              // if the element is destroyed, then we need to +              // notify the form. +              element.bind('$destroy', function(){ +                widget.$destroy(); +              }); +            } +            linkFn.$inject = ['$formFactory']; +            return linkFn; +          }); +        </script> +        <form name='editorForm' ng:controller="EditorCntl"> +          <div ng:html-editor-model="htmlContent"></div> +          <hr/> +          HTML: <br/> +          <textarea ng:model="htmlContent" cols="80"></textarea> +          <hr/> +          <pre>editorForm = {{editorForm}}</pre> +        </form> +      </doc:source> +      <doc:scenario> +        it('should enter invalid HTML', function(){ +          expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-valid/); +          input('htmlContent').enter('<'); +          expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-invalid/); +        }); +      </doc:scenario> +    </doc:example> + + + +# HTML Inputs + +The most common widgets you will use will be in the form of the +standard HTML set. These widgets are bound using the `name` attribute +to an expression. In addition, they can have `required` attribute to further control their +validation. +<doc:example> +  <doc:source> +     <script> +       function Ctrl(){ +         this.input1 = ''; +         this.input2 = ''; +         this.input3 = 'A'; +         this.input4 = false; +         this.input5 = 'c'; +         this.input6 = []; +       } +     </script> +    <table style="font-size:.9em;" ng:controller="Ctrl"> +      <tr> +        <th>Name</th> +        <th>Format</th> +        <th>HTML</th> +        <th>UI</th> +        <th ng:non-bindable>{{input#}}</th> +      </tr> +      <tr> +        <th>text</th> +        <td>String</td> +        <td><tt><input type="text" ng:model="input1"></tt></td> +        <td><input type="text" ng:model="input1" size="4"></td> +        <td><tt>{{input1|json}}</tt></td> +      </tr> +      <tr> +        <th>textarea</th> +        <td>String</td> +        <td><tt><textarea ng:model="input2"></textarea></tt></td> +        <td><textarea ng:model="input2" cols='6'></textarea></td> +        <td><tt>{{input2|json}}</tt></td> +      </tr> +      <tr> +        <th>radio</th> +        <td>String</td> +        <td><tt> +          <input type="radio" ng:model="input3" value="A"><br> +          <input type="radio" ng:model="input3" value="B"> +        </tt></td> +        <td> +          <input type="radio" ng:model="input3" value="A"> +          <input type="radio" ng:model="input3" value="B"> +        </td> +        <td><tt>{{input3|json}}</tt></td> +      </tr> +      <tr> +        <th>checkbox</th> +        <td>Boolean</td> +        <td><tt><input type="checkbox" ng:model="input4"></tt></td> +        <td><input type="checkbox" ng:model="input4"></td> +        <td><tt>{{input4|json}}</tt></td> +      </tr> +      <tr> +        <th>pulldown</th> +        <td>String</td> +        <td><tt> +          <select ng:model="input5"><br> +            <option value="c">C</option><br> +            <option value="d">D</option><br> +          </select><br> +        </tt></td> +        <td> +          <select ng:model="input5"> +            <option value="c">C</option> +            <option value="d">D</option> +          </select> +        </td> +        <td><tt>{{input5|json}}</tt></td> +      </tr> +      <tr> +        <th>multiselect</th> +        <td>Array</td> +        <td><tt> +          <select ng:model="input6" multiple size="4"><br> +            <option value="e">E</option><br> +            <option value="f">F</option><br> +          </select><br> +        </tt></td> +        <td> +          <select ng:model="input6" multiple size="4"> +            <option value="e">E</option> +            <option value="f">F</option> +          </select> +        </td> +        <td><tt>{{input6|json}}</tt></td> +      </tr> +    </table> +  </doc:source> +  <doc:scenario> + +    it('should exercise text', function(){ +     input('input1').enter('Carlos'); +     expect(binding('input1')).toEqual('"Carlos"'); +    }); +    it('should exercise textarea', function(){ +     input('input2').enter('Carlos'); +     expect(binding('input2')).toEqual('"Carlos"'); +    }); +    it('should exercise radio', function(){ +     expect(binding('input3')).toEqual('"A"'); +     input('input3').select('B'); +     expect(binding('input3')).toEqual('"B"'); +     input('input3').select('A'); +     expect(binding('input3')).toEqual('"A"'); +    }); +    it('should exercise checkbox', function(){ +     expect(binding('input4')).toEqual('false'); +     input('input4').check(); +     expect(binding('input4')).toEqual('true'); +    }); +    it('should exercise pulldown', function(){ +     expect(binding('input5')).toEqual('"c"'); +     select('input5').option('d'); +     expect(binding('input5')).toEqual('"d"'); +    }); +    it('should exercise multiselect', function(){ +     expect(binding('input6')).toEqual('[]'); +     select('input6').options('e'); +     expect(binding('input6')).toEqual('["e"]'); +     select('input6').options('e', 'f'); +     expect(binding('input6')).toEqual('["e","f"]'); +    }); +  </doc:scenario> +</doc:example> + +#Testing + +When unit-testing a controller it may be desirable to have a reference to form and to simulate +different form validation states. + +This example demonstrates a login form, where the login button is enabled only when the form is +properly filled out. +<pre> +  <div ng:controller="LoginController"> +    <form name="loginForm"> +      <input type="text" ng:model="username" required/> +      <input type="password" ng:model="password" required/> +      <button ng:disabled="{{!disableLogin()}}" ng:click="login()">Login</login> +    </form> +  </div> +</pre> + +In the unit tests we do not have access to the DOM, and therefore the `loginForm` reference does +not get set on the controller. This example shows how it can be unit-tested, by creating a mock +form. +<pre> +function LoginController() { +  this.disableLogin = function() { +    return this.loginForm.$invalid; +  }; +} + +describe('LoginController', function() { +  it('should disable login button when form is invalid', function() { +    var scope = angular.scope(); +    var loginController = scope.$new(LoginController); + +    // In production the 'loginForm' form instance gets set from the view, +    // but in unit-test we have to set it manually. +    loginController.loginForm = scope.$service('$formFactory')(); + +    expect(loginController.disableLogin()).toBe(false); + +    // Now simulate an invalid form +    loginController.loginForm.$emit('$invalid', 'MyReason'); +    expect(loginController.disableLogin()).toBe(true); + +    // Now simulate a valid form +    loginController.loginForm.$emit('$valid', 'MyReason'); +    expect(loginController.disableLogin()).toBe(false); +  }); +}); +</pre> + +## Custom widgets + +This example demonstrates a login form, where the password has custom validation rules. +<pre> +  <div ng:controller="LoginController"> +    <form name="loginForm"> +      <input type="text" ng:model="username" required/> +      <input type="@StrongPassword" ng:model="password" required/> +      <button ng:disabled="{{!disableLogin()}}" ng:click="login()">Login</login> +    </form> +  </div> +</pre> + +In the unit tests we do not have access to the DOM, and therefore the `loginForm` and custom +input type reference does not get set on the controller. This example shows how it can be +unit-tested, by creating a mock form and a mock custom input type. +<pre> +function LoginController(){ +  this.disableLogin = function() { +    return this.loginForm.$invalid; +  }; + +  this.StrongPassword = function(element) { +    var widget = this; +    element.attr('type', 'password'); // act as password. +    this.$on('$validate', function(){ +      widget.$emit(widget.$viewValue.length > 5 ? '$valid' : '$invalid', 'PASSWORD'); +    }); +  }; +} + +describe('LoginController', function() { +  it('should disable login button when form is invalid', function() { +    var scope = angular.scope(); +    var loginController = scope.$new(LoginController); +    var input = angular.element('<input>'); + +    // In production the 'loginForm' form instance gets set from the view, +    // but in unit-test we have to set it manually. +    loginController.loginForm = scope.$service('$formFactory')(); + +    // now instantiate a custom input type +    loginController.loginForm.$createWidget({ +      scope: loginController, +      model: 'password', +      alias: 'password', +      controller: loginController.StrongPassword, +      controllerArgs: [input] +    }); + +    // Verify that the custom password input type sets the input type to password +    expect(input.attr('type')).toEqual('password'); + +    expect(loginController.disableLogin()).toBe(false); + +    // Now simulate an invalid form +    loginController.loginForm.password.$emit('$invalid', 'PASSWORD'); +    expect(loginController.disableLogin()).toBe(true); + +    // Now simulate a valid form +    loginController.loginForm.password.$emit('$valid', 'PASSWORD'); +    expect(loginController.disableLogin()).toBe(false); + +    // Changing model state, should also influence the form validity +    loginController.password = 'abc'; // too short so it should be invalid +    scope.$digest(); +    expect(loginController.loginForm.password.$invalid).toBe(true); + +    // Changeing model state, should also influence the form validity +    loginController.password = 'abcdef'; // should be valid +    scope.$digest(); +    expect(loginController.loginForm.password.$valid).toBe(true); +  }); +}); +</pre> + + | 
