From 4f78fd692c0ec51241476e6be9a4df06cd62fdd6 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Thu, 8 Sep 2011 13:56:29 -0700 Subject: feat(forms): new and improved forms --- docs/content/guide/dev_guide.forms.ngdoc | 610 +++++++++++++++++++++++++++++++ 1 file changed, 610 insertions(+) create mode 100644 docs/content/guide/dev_guide.forms.ngdoc (limited to 'docs/content/guide/dev_guide.forms.ngdoc') 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. + + + + + + +
+ +
+ +
+ + + Customer name is required! +

+ + +
+
+ , + +

+ + + Incomplete address: +
+ Missing state! +
+ Invalid state! +
+ Missing zip! +
+ Invalid zip! + + + + + + + +
+ Debug View: +
form={{form}}
+
master={{master}}
+
userForm={{userForm}}
+
addressForm={{addressForm}}
+
+ + + 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'); + }); + + + +# Life-cycle + +- The `
` 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 + `` 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. + +- `` 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. + + + + +- 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. + + + + + +
+
+ HTML:
+ +
+
editorForm = {{editorForm}}
+ +
+ + 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/); + }); + +
+ + + +# 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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameFormatHTMLUI{{input#}}
textString<input type="text" ng:model="input1">{{input1|json}}
textareaString<textarea ng:model="input2"></textarea>{{input2|json}}
radioString + <input type="radio" ng:model="input3" value="A">
+ <input type="radio" ng:model="input3" value="B"> +
+ + + {{input3|json}}
checkboxBoolean<input type="checkbox" ng:model="input4">{{input4|json}}
pulldownString + <select ng:model="input5">
+   <option value="c">C</option>
+   <option value="d">D</option>
+ </select>
+
+ + {{input5|json}}
multiselectArray + <select ng:model="input6" multiple size="4">
+   <option value="e">E</option>
+   <option value="f">F</option>
+ </select>
+
+ + {{input6|json}}
+
+ + + 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"]'); + }); + +
+ +#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. +
+  
+
+ + +
+
+ +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. +
+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);
+  });
+});
+
+ +## Custom widgets + +This example demonstrates a login form, where the password has custom validation rules. +
+  
+
+ + +
+
+ +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. +
+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('');
+
+    // 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);
+  });
+});
+
+ + -- cgit v1.2.3