From 21c725f1a12d1de758cab6e4c4fafc5c420eb565 Mon Sep 17 00:00:00 2001
From: Vojta Jina
Date: Wed, 15 Feb 2012 17:16:02 -0800
Subject: refactor(forms): Even better forms
- remove $formFactory completely
- remove parallel scope hierarchy (forms, widgets)
- use new compiler features (widgets, forms are controllers)
- any directive can add formatter/parser (validators, convertors)
Breaks no custom input types
Breaks removed integer input type
Breaks remove list input type (ng-list directive instead)
Breaks inputs bind only blur event by default (added ng:bind-change directive)
---
src/Angular.js | 12 -
src/AngularPublic.js | 9 +-
src/scenario/Scenario.js | 2 +-
src/scenario/dsl.js | 2 +-
src/service/formFactory.js | 414 ------------------
src/widget/form.js | 134 ++++--
src/widget/input.js | 1007 +++++++++++++++++++++++++-------------------
src/widget/select.js | 138 +++---
8 files changed, 764 insertions(+), 954 deletions(-)
delete mode 100644 src/service/formFactory.js
(limited to 'src')
diff --git a/src/Angular.js b/src/Angular.js
index fec866f5..1265ad9f 100644
--- a/src/Angular.js
+++ b/src/Angular.js
@@ -91,7 +91,6 @@ var $boolean = 'boolean',
angular = window.angular || (window.angular = {}),
angularModule,
/** @name angular.module.ng */
- angularInputType = extensionMap(angular, 'inputType', lowercase),
nodeName_,
uid = ['0', '0', '0'],
DATE_ISOSTRING_LN = 24;
@@ -272,17 +271,6 @@ identity.$inject = [];
function valueFn(value) {return function() {return value;};}
-function extensionMap(angular, name, transform) {
- var extPoint;
- return angular[name] || (extPoint = angular[name] = function(name, fn, prop){
- name = (transform || identity)(name);
- if (isDefined(fn)) {
- extPoint[name] = extend(fn, prop || {});
- }
- return extPoint[name];
- });
-}
-
/**
* @ngdoc function
* @name angular.isUndefined
diff --git a/src/AngularPublic.js b/src/AngularPublic.js
index d1ae4a18..20ca5edb 100644
--- a/src/AngularPublic.js
+++ b/src/AngularPublic.js
@@ -98,7 +98,13 @@ function publishExternalAPI(angular){
ngSwitchDefault: ngSwitchDefaultDirective,
ngOptions: ngOptionsDirective,
ngView: ngViewDirective,
- ngTransclude: ngTranscludeDirective
+ ngTransclude: ngTranscludeDirective,
+ ngModel: ngModelDirective,
+ ngList: ngListDirective,
+ ngChange: ngChangeDirective,
+ ngBindImmediate: ngBindImmediateDirective,
+ required: requiredDirective,
+ ngRequired: requiredDirective
}).
directive(ngEventDirectives).
directive(ngAttributeAliasDirectives);
@@ -110,7 +116,6 @@ function publishExternalAPI(angular){
$provide.service('$exceptionHandler', $ExceptionHandlerProvider);
$provide.service('$filter', $FilterProvider);
$provide.service('$interpolate', $InterpolateProvider);
- $provide.service('$formFactory', $FormFactoryProvider);
$provide.service('$http', $HttpProvider);
$provide.service('$httpBackend', $HttpBackendProvider);
$provide.service('$location', $LocationProvider);
diff --git a/src/scenario/Scenario.js b/src/scenario/Scenario.js
index 7e33181c..cd3c335f 100644
--- a/src/scenario/Scenario.js
+++ b/src/scenario/Scenario.js
@@ -327,7 +327,7 @@ function browserTrigger(element, type, keys) {
(function(fn){
var parentTrigger = fn.trigger;
fn.trigger = function(type) {
- if (/(click|change|keydown)/.test(type)) {
+ if (/(click|change|keydown|blur)/.test(type)) {
var processDefaults = [];
this.each(function(index, node) {
processDefaults.push(browserTrigger(node, type));
diff --git a/src/scenario/dsl.js b/src/scenario/dsl.js
index fb0037e0..f6cc8086 100644
--- a/src/scenario/dsl.js
+++ b/src/scenario/dsl.js
@@ -203,7 +203,7 @@ angular.scenario.dsl('input', function() {
return this.addFutureAction("input '" + this.name + "' enter '" + value + "'", function($window, $document, done) {
var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':input');
input.val(value);
- input.trigger('keydown');
+ input.trigger('blur');
done();
});
};
diff --git a/src/service/formFactory.js b/src/service/formFactory.js
deleted file mode 100644
index b051f7b9..00000000
--- a/src/service/formFactory.js
+++ /dev/null
@@ -1,414 +0,0 @@
-'use strict';
-
-/**
- * @ngdoc object
- * @name angular.module.ng.$formFactory
- *
- * @description
- * Use `$formFactory` to create a new instance of a {@link angular.module.ng.$formFactory.Form Form}
- * controller or to find the nearest form instance for a given DOM element.
- *
- * The form instance is a collection of widgets, and is responsible for life cycle and validation
- * of widget.
- *
- * Keep in mind that both form and widget instances are {@link api/angular.module.ng.$rootScope.Scope scopes}.
- *
- * @param {Form=} parentForm The form which should be the parent form of the new form controller.
- * If none specified default to the `rootForm`.
- * @returns {Form} A new {@link angular.module.ng.$formFactory.Form Form} instance.
- *
- * @example
- *
- * This example shows how one could write a widget which would enable data-binding on
- * `contenteditable` feature of HTML.
- *
-
-
-
-
-
-
- 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/);
- });
-
-
- */
-
-/**
- * @ngdoc object
- * @name angular.module.ng.$formFactory.Form
- * @description
- * The `Form` is a controller which keeps track of the validity of the widgets contained within it.
- */
-
-function $FormFactoryProvider() {
- var $parse;
- this.$get = ['$rootScope', '$parse', '$controller',
- function($rootScope, $parse_, $controller) {
- $parse = $parse_;
- /**
- * @ngdoc proprety
- * @name rootForm
- * @propertyOf angular.module.ng.$formFactory
- * @description
- * Static property on `$formFactory`
- *
- * Each application ({@link guide/dev_guide.scopes.internals root scope}) gets a root form which
- * is the top-level parent of all forms.
- */
- formFactory.rootForm = formFactory($rootScope);
-
-
- /**
- * @ngdoc method
- * @name forElement
- * @methodOf angular.module.ng.$formFactory
- * @description
- * Static method on `$formFactory` service.
- *
- * Retrieve the closest form for a given element or defaults to the `root` form. Used by the
- * {@link angular.module.ng.$compileProvider.directive.form form} element.
- * @param {Element} element The element where the search for form should initiate.
- */
- formFactory.forElement = function(element) {
- return element.inheritedData('$form') || formFactory.rootForm;
- };
- return formFactory;
-
- function formFactory(parent) {
- var scope = (parent || formFactory.rootForm).$new();
- $controller(FormController, {$scope: scope});
- return scope;
- }
-
- }];
-
- function propertiesUpdate(widget) {
- widget.$valid = !(widget.$invalid =
- !(widget.$readonly || widget.$disabled || equals(widget.$error, {})));
- }
-
- /**
- * @ngdoc property
- * @name $error
- * @propertyOf angular.module.ng.$formFactory.Form
- * @description
- * Property of the form and widget instance.
- *
- * Summary of all of the errors on the page. If a widget emits `$invalid` with `REQUIRED` key,
- * then the `$error` object will have a `REQUIRED` key with an array of widgets which have
- * emitted this key. `form.$error.REQUIRED == [ widget ]`.
- */
-
- /**
- * @ngdoc property
- * @name $invalid
- * @propertyOf angular.module.ng.$formFactory.Form
- * @description
- * Property of the form and widget instance.
- *
- * True if any of the widgets of the form are invalid.
- */
-
- /**
- * @ngdoc property
- * @name $valid
- * @propertyOf angular.module.ng.$formFactory.Form
- * @description
- * Property of the form and widget instance.
- *
- * True if all of the widgets of the form are valid.
- */
-
- /**
- * @ngdoc event
- * @name angular.module.ng.$formFactory.Form#$valid
- * @eventOf angular.module.ng.$formFactory.Form
- * @eventType listen on form
- * @description
- * Upon receiving the `$valid` event from the widget update the `$error`, `$valid` and `$invalid`
- * properties of both the widget as well as the from.
- *
- * @param {string} validationKey The validation key to be used when updating the `$error` object.
- * The validation key is what will allow the template to bind to a specific validation error
- * such as `
error for key
`.
- */
-
- /**
- * @ngdoc event
- * @name angular.module.ng.$formFactory.Form#$invalid
- * @eventOf angular.module.ng.$formFactory.Form
- * @eventType listen on form
- * @description
- * Upon receiving the `$invalid` event from the widget update the `$error`, `$valid` and `$invalid`
- * properties of both the widget as well as the from.
- *
- * @param {string} validationKey The validation key to be used when updating the `$error` object.
- * The validation key is what will allow the template to bind to a specific validation error
- * such as `
error for key
`.
- */
-
- /**
- * @ngdoc event
- * @name angular.module.ng.$formFactory.Form#$validate
- * @eventOf angular.module.ng.$formFactory.Form
- * @eventType emit on widget
- * @description
- * Emit the `$validate` event on the widget, giving a widget a chance to emit a
- * `$valid` / `$invalid` event base on its state. The `$validate` event is triggered when the
- * model or the view changes.
- */
-
- /**
- * @ngdoc event
- * @name angular.module.ng.$formFactory.Form#$viewChange
- * @eventOf angular.module.ng.$formFactory.Form
- * @eventType listen on widget
- * @description
- * A widget is responsible for emitting this event whenever the view changes do to user interaction.
- * The event takes a `$viewValue` parameter, which is the new value of the view. This
- * event triggers a call to `$parseView()` as well as `$validate` event on widget.
- *
- * @param {*} viewValue The new value for the view which will be assigned to `widget.$viewValue`.
- */
-
- FormController.$inject = ['$scope', '$injector'];
- function FormController($scope, $injector) {
- this.$injector = $injector;
-
- var form = this.form = $scope,
- $error = form.$error = {};
-
- form.$on('$destroy', function(event){
- var widget = event.targetScope;
- if (widget.$widgetId) {
- delete form[widget.$widgetId];
- }
- forEach($error, removeWidget, widget);
- });
-
- form.$on('$valid', function(event, error){
- var widget = event.targetScope;
- delete widget.$error[error];
- propertiesUpdate(widget);
- removeWidget($error[error], error, widget);
- });
-
- form.$on('$invalid', function(event, error){
- var widget = event.targetScope;
- addWidget(error, widget);
- widget.$error[error] = true;
- propertiesUpdate(widget);
- });
-
- propertiesUpdate(form);
- form.$createWidget = bind(this, this.$createWidget);
-
- function removeWidget(queue, errorKey, widget) {
- if (queue) {
- widget = widget || this; // so that we can be used in forEach;
- for (var i = 0, length = queue.length; i < length; i++) {
- if (queue[i] === widget) {
- queue.splice(i, 1);
- if (!queue.length) {
- delete $error[errorKey];
- }
- }
- }
- propertiesUpdate(form);
- }
- }
-
- function addWidget(errorKey, widget) {
- var queue = $error[errorKey];
- if (queue) {
- for (var i = 0, length = queue.length; i < length; i++) {
- if (queue[i] === widget) {
- return;
- }
- }
- } else {
- $error[errorKey] = queue = [];
- }
- queue.push(widget);
- propertiesUpdate(form);
- }
- }
-
-
- /**
- * @ngdoc method
- * @name $createWidget
- * @methodOf angular.module.ng.$formFactory.Form
- * @description
- *
- * Use form's `$createWidget` instance method to create new widgets. The widgets can be created
- * using an alias which makes the accessible from the form and available for data-binding,
- * useful for displaying validation error messages.
- *
- * The creation of a widget sets up:
- *
- * - `$watch` of `expression` on `model` scope. This code path syncs the model to the view.
- * The `$watch` listener will:
- *
- * - assign the new model value of `expression` to `widget.$modelValue`.
- * - call `widget.$parseModel` method if present. The `$parseModel` is responsible for copying
- * the `widget.$modelValue` to `widget.$viewValue` and optionally converting the data.
- * (For example to convert a number into string)
- * - emits `$validate` event on widget giving a widget a chance to emit `$valid` / `$invalid`
- * event.
- * - call `widget.$render()` method on widget. The `$render` method is responsible for
- * reading the `widget.$viewValue` and updating the DOM.
- *
- * - Listen on `$viewChange` event from the `widget`. This code path syncs the view to the model.
- * The `$viewChange` listener will:
- *
- * - assign the value to `widget.$viewValue`.
- * - call `widget.$parseView` method if present. The `$parseView` is responsible for copying
- * the `widget.$viewValue` to `widget.$modelValue` and optionally converting the data.
- * (For example to convert a string into number)
- * - emits `$validate` event on widget giving a widget a chance to emit `$valid` / `$invalid`
- * event.
- * - Assign the `widget.$modelValue` to the `expression` on the `model` scope.
- *
- * - Creates these set of properties on the `widget` which are updated as a response to the
- * `$valid` / `$invalid` events:
- *
- * - `$error` - object - validation errors will be published as keys on this object.
- * Data-binding to this property is useful for displaying the validation errors.
- * - `$valid` - boolean - true if there are no validation errors
- * - `$invalid` - boolean - opposite of `$valid`.
- * @param {Object} params Named parameters:
- *
- * - `scope` - `{Scope}` - The scope to which the model for this widget is attached.
- * - `model` - `{string}` - The name of the model property on model scope.
- * - `controller` - {WidgetController} - The controller constructor function.
- * The controller constructor should create these instance methods.
- * - `$parseView()`: optional method responsible for copying `$viewVale` to `$modelValue`.
- * The method may fire `$valid`/`$invalid` events.
- * - `$parseModel()`: optional method responsible for copying `$modelVale` to `$viewValue`.
- * The method may fire `$valid`/`$invalid` events.
- * - `$render()`: required method which needs to update the DOM of the widget to match the
- * `$viewValue`.
- *
- * - `controllerArgs` - `{Array}` (Optional) - Any extra arguments will be curried to the
- * WidgetController constructor.
- * - `onChange` - `{(string|function())}` (Optional) - Expression to execute when user changes the
- * value.
- * - `alias` - `{string}` (Optional) - The name of the form property under which the widget
- * instance should be published. The name should be unique for each form.
- * @returns {Widget} Instance of a widget scope.
- */
- FormController.prototype.$createWidget = function(params) {
- var form = this.form,
- modelScope = params.scope,
- onChange = params.onChange,
- alias = params.alias,
- scopeGet = $parse(params.model),
- scopeSet = scopeGet.assign,
- widget = form.$new();
-
- this.$injector.instantiate(params.controller, extend({$scope: widget}, params.controllerArgs));
-
- if (!scopeSet) {
- throw Error("Expression '" + params.model + "' is not assignable!");
- }
-
- widget.$error = {};
- // Set the state to something we know will change to get the process going.
- widget.$modelValue = Number.NaN;
- // watch for scope changes and update the view appropriately
- modelScope.$watch(scopeGet, function(value) {
- if (!equals(widget.$modelValue, value)) {
- widget.$modelValue = value;
- widget.$parseModel ? widget.$parseModel() : (widget.$viewValue = value);
- widget.$emit('$validate');
- widget.$render && widget.$render();
- }
- });
-
- widget.$on('$viewChange', function(event, viewValue){
- if (!equals(widget.$viewValue, viewValue)) {
- widget.$viewValue = viewValue;
- widget.$parseView ? widget.$parseView() : (widget.$modelValue = widget.$viewValue);
- scopeSet(modelScope, widget.$modelValue);
- if (onChange) modelScope.$eval(onChange);
- widget.$emit('$validate');
- }
- });
-
- propertiesUpdate(widget);
-
- // assign the widgetModel to the form
- if (alias && !form.hasOwnProperty(alias)) {
- form[alias] = widget;
- widget.$widgetId = alias;
- } else {
- alias = null;
- }
-
- return widget;
- };
-}
diff --git a/src/widget/form.js b/src/widget/form.js
index deaf38d5..23b07107 100644
--- a/src/widget/form.js
+++ b/src/widget/form.js
@@ -1,5 +1,86 @@
'use strict';
+FormController.$inject = ['$scope', 'name'];
+function FormController($scope, name) {
+ var form = this,
+ errors = form.error = {};
+
+ // publish the form into scope
+ name(this);
+
+ $scope.$on('$destroy', function(event, widget) {
+ if (!widget) return;
+
+ if (widget.widgetId) {
+ delete form[widget.widgetId];
+ }
+ forEach(errors, removeWidget, widget);
+ });
+
+ $scope.$on('$valid', function(event, error, widget) {
+ removeWidget(errors[error], error, widget);
+
+ if (equals(errors, {})) {
+ form.valid = true;
+ form.invalid = false;
+ }
+ });
+
+ $scope.$on('$invalid', function(event, error, widget) {
+ addWidget(error, widget);
+
+ form.valid = false;
+ form.invalid = true;
+ });
+
+ $scope.$on('$viewTouch', function() {
+ form.dirty = true;
+ form.pristine = false;
+ });
+
+ // init state
+ form.dirty = false;
+ form.pristine = true;
+ form.valid = true;
+ form.invalid = false;
+
+ function removeWidget(queue, errorKey, widget) {
+ if (queue) {
+ widget = widget || this; // so that we can be used in forEach;
+ for (var i = 0, length = queue.length; i < length; i++) {
+ if (queue[i] === widget) {
+ queue.splice(i, 1);
+ if (!queue.length) {
+ delete errors[errorKey];
+ }
+ }
+ }
+ }
+ }
+
+ function addWidget(errorKey, widget) {
+ var queue = errors[errorKey];
+ if (queue) {
+ for (var i = 0, length = queue.length; i < length; i++) {
+ if (queue[i] === widget) {
+ return;
+ }
+ }
+ } else {
+ errors[errorKey] = queue = [];
+ }
+ queue.push(widget);
+ }
+}
+
+FormController.prototype.registerWidget = function(widget, alias) {
+ if (alias && !this.hasOwnProperty(alias)) {
+ widget.widgetId = alias;
+ this[alias] = widget;
+ }
+};
+
+
/**
* @ngdoc directive
* @name angular.module.ng.$compileProvider.directive.form
@@ -57,55 +138,54 @@
$scope.text = 'guest';
}
-
+ myForm.input.valid = {{myForm.input.valid}}
+ myForm.input.error = {{myForm.input.error}}
+ myForm.valid = {{myForm.valid}}
+ myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}
+
it('should initialize to model', function() {
expect(binding('value')).toEqual('12');
- expect(binding('myForm.input.$valid')).toEqual('true');
+ expect(binding('myForm.input.valid')).toEqual('true');
});
it('should be invalid if empty', function() {
input('value').enter('');
expect(binding('value')).toEqual('');
- expect(binding('myForm.input.$valid')).toEqual('false');
+ expect(binding('myForm.input.valid')).toEqual('false');
});
it('should be invalid if over max', function() {
input('value').enter('123');
- expect(binding('value')).toEqual('123');
- expect(binding('myForm.input.$valid')).toEqual('false');
- });
-
-
- */
-angularInputType('number', numericRegexpInputType(NUMBER_REGEXP, 'NUMBER'));
-
-
-/**
- * @ngdoc inputType
- * @name angular.inputType.integer
- *
- * @description
- * Text input with integer validation and transformation. Sets the `INTEGER`
- * validation error key if not a valid integer.
- *
- * @param {string} ng:model Assignable angular expression to data-bind to.
- * @param {string=} name Property name of the form under which the widgets is published.
- * @param {string=} min Sets the `MIN` validation error key if the value entered is less then `min`.
- * @param {string=} max Sets the `MAX` validation error key if the value entered is greater then `min`.
- * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered.
- * @param {number=} ng:minlength Sets `MINLENGTH` validation error key if the value is shorter than
- * minlength.
- * @param {number=} ng:maxlength Sets `MAXLENGTH` validation error key if the value is longer than
- * maxlength.
- * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the
- * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for
- * patterns defined as scope expressions.
- * @param {string=} ng:change Angular expression to be executed when input changes due to user
- * interaction with the input element.
- *
- * @example
-
-
-
-