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)
This commit is contained in:
Vojta Jina 2012-02-15 17:16:02 -08:00
parent e23fa768aa
commit 21c725f1a1
18 changed files with 2330 additions and 2206 deletions

1
angularFiles.js vendored
View file

@ -23,7 +23,6 @@ angularFiles = {
'src/service/filter/filters.js',
'src/service/filter/limitTo.js',
'src/service/filter/orderBy.js',
'src/service/formFactory.js',
'src/service/interpolate.js',
'src/service/location.js',
'src/service/log.js',

View file

@ -32,61 +32,3 @@ All `inputType` widgets support:
- **`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.
# Example
<doc:example>
<doc:source>
<script>
angular.inputType('json', function(element, scope) {
scope.$parseView = function() {
try {
this.$modelValue = angular.fromJson(this.$viewValue);
if (this.$error.JSON) {
this.$emit('$valid', 'JSON');
}
} catch (e) {
this.$emit('$invalid', 'JSON');
}
}
scope.$parseModel = function() {
this.$viewValue = angular.toJson(this.$modelValue);
}
});
function Ctrl($scope) {
$scope.data = {
framework:'angular',
codenames:'supper-powers'
}
$scope.required = false;
$scope.disabled = false;
$scope.readonly = false;
}
</script>
<div ng:controller="Ctrl">
<form name="myForm">
<input type="json" ng:model="data" size="80"
ng:required="{{required}}" ng:disabled="{{disabled}}"
ng:readonly="{{readonly}}"/><br/>
Required: <input type="checkbox" ng:model="required"> <br/>
Disabled: <input type="checkbox" ng:model="disabled"> <br/>
Readonly: <input type="checkbox" ng:model="readonly"> <br/>
<pre>data={{data}}</pre>
<pre>myForm={{myForm}}</pre>
</form>
</div>
</doc:source>
<doc:scenario>
it('should invalidate on wrong input', function() {
expect(element('form[name=myForm]').prop('className')).toMatch('ng-valid');
input('data').enter('{}');
expect(binding('data')).toEqual('{}');
input('data').enter('{');
expect(element('form[name=myForm]').prop('className')).toMatch('ng-invalid');
});
</doc:scenario>
</doc:example>

View file

@ -52,7 +52,7 @@ detection, and preventing invalid form submission.
};
$scope.isSaveDisabled = function() {
return $scope.myForm.$invalid || angular.equals(master, $scope.form);
return $scope.myForm.invalid || angular.equals(master, $scope.form);
};
$scope.cancel();

View file

@ -138,7 +138,7 @@ The following example demonstrates:
};
$scope.isSaveDisabled = function() {
return $scope.userForm.$invalid || angular.equals($scope.master, $scope.form);
return $scope.userForm.invalid || angular.equals($scope.master, $scope.form);
};
$scope.cancel();
@ -150,7 +150,7 @@ The following example demonstrates:
<label>Name:</label><br/>
<input type="text" name="customer" ng:model="form.customer" required/>
<span class="error" ng:show="userForm.customer.$error.REQUIRED">
<span class="error" ng:show="userForm.customer.error.REQUIRED">
Customer name is required!</span>
<br/><br/>
@ -165,15 +165,15 @@ The following example demonstrates:
<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">
<span class="error" ng:show="addressForm.invalid">
Incomplete address:
<span class="error" ng:show="addressForm.state.$error.REQUIRED">
<span class="error" ng:show="addressForm.state.error.REQUIRED">
Missing state!</span>
<span class="error" ng:show="addressForm.state.$error.PATTERN">
<span class="error" ng:show="addressForm.state.error.PATTERN">
Invalid state!</span>
<span class="error" ng:show="addressForm.zip.$error.REQUIRED">
<span class="error" ng:show="addressForm.zip.error.REQUIRED">
Missing zip!</span>
<span class="error" ng:show="addressForm.zip.$error.PATTERN">
<span class="error" ng:show="addressForm.zip.error.PATTERN">
Invalid zip!</span>
</span>
</ng:form>
@ -284,56 +284,38 @@ This example shows how to implement a custom HTML editor widget in Angular.
$scope.htmlContent = '<b>Hello</b> <i>World</i>!';
}
HTMLEditorWidget.$inject = ['$scope', '$element', '$sanitize'];
function HTMLEditorWidget(scope, element, $sanitize) {
scope.$parseModel = function() {
// need to protect for script injection
try {
scope.$viewValue = $sanitize(
scope.$modelValue || '');
if (this.$error.HTML) {
// we were invalid, but now we are OK.
scope.$emit('$valid', 'HTML');
}
} catch (e) {
// if HTML not parsable invalidate form.
scope.$emit('$invalid', 'HTML');
}
}
angular.module('formModule', []).directive('ngHtmlEditor', function ($sanitize) {
return {
require: 'ngModel',
link: function(scope, elm, attr, ctrl) {
attr.$set('contentEditable', true);
scope.$render = function() {
element.html(this.$viewValue);
}
ctrl.$render = function() {
elm.html(ctrl.viewValue);
};
element.bind('keyup', function() {
scope.$apply(function() {
scope.$emit('$viewChange', element.html());
});
});
}
ctrl.formatters.push(function(value) {
try {
value = $sanitize(value || '');
ctrl.emitValidity('HTML', true);
} catch (e) {
ctrl.emitValidity('HTML', false);
}
angular.module('formModule', [], function($compileProvider){
$compileProvider.directive('ngHtmlEditorModel', function ($formFactory) {
return function(scope, element, attr) {
var form = $formFactory.forElement(element),
widget;
element.attr('contentEditable', true);
widget = form.$createWidget({
scope: scope,
model: attr.ngHtmlEditorModel,
controller: HTMLEditorWidget,
controllerArgs: {$element: element}});
// if the element is destroyed, then we need to
// notify the form.
element.bind('$destroy', function() {
widget.$destroy();
});
};
});
elm.bind('keyup', function() {
scope.$apply(function() {
ctrl.read(elm.html());
});
});
}
};
});
</script>
<form name='editorForm' ng:controller="EditorCntl">
<div ng:html-editor-model="htmlContent"></div>
<div ng:html-editor ng:model="htmlContent"></div>
<hr/>
HTML: <br/>
<textarea ng:model="htmlContent" cols="80"></textarea>

View file

@ -101,7 +101,7 @@
<div id="sidebar">
<input type="text" ng:model="search" id="search-box" placeholder="search the docs"
tabindex="1" accesskey="s">
tabindex="1" accesskey="s" ng:bind-immediate>
<ul id="content-list" ng:class="sectionId" ng:cloak>
<li ng:repeat="page in pages | filter:search" ng:class="getClass(page)">

View file

@ -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

View file

@ -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);

View file

@ -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));

View file

@ -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();
});
};

View file

@ -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.
*
<doc:example module="formModule">
<doc:source>
<script>
function EditorCntl($scope) {
$scope.htmlContent = '<b>Hello</b> <i>World</i>!';
}
HTMLEditorWidget.$inject = ['$scope', '$element', '$sanitize'];
function HTMLEditorWidget(scope, element, $sanitize) {
scope.$parseModel = function() {
// need to protect for script injection
try {
scope.$viewValue = $sanitize(
scope.$modelValue || '');
if (this.$error.HTML) {
// we were invalid, but now we are OK.
scope.$emit('$valid', 'HTML');
}
} catch (e) {
// if HTML not parsable invalidate form.
scope.$emit('$invalid', 'HTML');
}
}
scope.$render = function() {
element.html(this.$viewValue);
}
element.bind('keyup', function() {
scope.$apply(function() {
scope.$emit('$viewChange', element.html());
});
});
}
angular.module('formModule', [], function($compileProvider){
$compileProvider.directive('ngHtmlEditorModel', function ($formFactory) {
return function(scope, element, attr) {
var form = $formFactory.forElement(element),
widget;
element.attr('contentEditable', true);
widget = form.$createWidget({
scope: scope,
model: attr.ngHtmlEditorModel,
controller: HTMLEditorWidget,
controllerArgs: {$element: element}});
// if the element is destroyed, then we need to
// notify the form.
element.bind('$destroy', function() {
widget.$destroy();
});
};
});
});
</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|json}}</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>
*/
/**
* @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 `<div ng:show="form.$error.KEY">error for key</div>`.
*/
/**
* @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 `<div ng:show="form.$error.KEY">error for key</div>`.
*/
/**
* @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;
};
}

View file

@ -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';
}
</script>
<div ng:controller="Ctrl">
<form name="myForm">
text: <input type="text" name="input" ng:model="text" required>
<span class="error" ng:show="myForm.text.$error.REQUIRED">Required!</span>
</form>
<form name="myForm" ng:controller="Ctrl">
text: <input type="text" name="input" ng:model="text" required>
<span class="error" ng:show="myForm.input.error.REQUIRED">Required!</span>
<tt>text = {{text}}</tt><br/>
<tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/>
<tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/>
<tt>myForm.$valid = {{myForm.$valid}}</tt><br/>
<tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/>
</div>
<tt>myForm.input.valid = {{myForm.input.valid}}</tt><br/>
<tt>myForm.input.error = {{myForm.input.error}}</tt><br/>
<tt>myForm.valid = {{myForm.valid}}</tt><br/>
<tt>myForm.error.REQUIRED = {{!!myForm.error.REQUIRED}}</tt><br/>
</form>
</doc:source>
<doc:scenario>
it('should initialize to model', function() {
expect(binding('text')).toEqual('guest');
expect(binding('myForm.input.$valid')).toEqual('true');
expect(binding('myForm.input.valid')).toEqual('true');
});
it('should be invalid if empty', function() {
input('text').enter('');
expect(binding('text')).toEqual('');
expect(binding('myForm.input.$valid')).toEqual('false');
expect(binding('myForm.input.valid')).toEqual('false');
});
</doc:scenario>
</doc:example>
*/
var ngFormDirective = ['$formFactory', function($formFactory) {
var ngFormDirective = [function() {
return {
name: 'form',
restrict: 'E',
scope: true,
inject: {
name: 'accessor'
},
controller: FormController,
compile: function() {
return {
pre: function(scope, formElement, attr) {
var name = attr.name,
parentForm = $formFactory.forElement(formElement),
form = $formFactory(parentForm);
formElement.data('$form', form);
formElement.bind('submit', function(event){
pre: function(scope, formElement, attr, controller) {
formElement.data('$form', controller);
formElement.bind('submit', function(event) {
if (!attr.action) event.preventDefault();
});
if (name) {
scope[name] = form;
}
watch('valid');
watch('invalid');
function watch(name) {
form.$watch('$' + name, function(value) {
forEach(['valid', 'invalid', 'dirty', 'pristine'], function(name) {
scope.$watch(function() {
return controller[name];
}, function(value) {
formElement[value ? 'addClass' : 'removeClass']('ng-' + name);
});
}
});
}
};
}

File diff suppressed because it is too large Load diff

View file

@ -123,87 +123,79 @@
*/
var ngOptionsDirective = valueFn({ terminal: true });
var selectDirective = ['$formFactory', '$compile', '$parse',
function($formFactory, $compile, $parse){
var selectDirective = ['$compile', '$parse', function($compile, $parse) {
//00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777
var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/;
return {
restrict: 'E',
link: function(modelScope, selectElement, attr) {
if (!attr.ngModel) return;
var form = $formFactory.forElement(selectElement),
multiple = attr.multiple,
optionsExp = attr.ngOptions,
modelExp = attr.ngModel,
widget = form.$createWidget({
scope: modelScope,
model: modelExp,
onChange: attr.ngChange,
alias: attr.name,
controller: ['$scope', optionsExp ? Options : (multiple ? Multiple : Single)]});
require: '?ngModel',
link: function(scope, element, attr, ctrl) {
if (!ctrl) return;
selectElement.bind('$destroy', function() { widget.$destroy(); });
var multiple = attr.multiple,
optionsExp = attr.ngOptions;
widget.$pristine = !(widget.$dirty = false);
// required validator
if (multiple && (attr.required || attr.ngRequired)) {
var requiredValidator = function(value) {
ctrl.emitValidity('REQUIRED', !attr.required || (value && value.length));
return value;
};
widget.$on('$validate', function() {
var valid = !attr.required || !!widget.$modelValue;
if (valid && multiple && attr.required) valid = !!widget.$modelValue.length;
if (valid !== !widget.$error.REQUIRED) {
widget.$emit(valid ? '$valid' : '$invalid', 'REQUIRED');
}
});
ctrl.parsers.push(requiredValidator);
ctrl.formatters.unshift(requiredValidator);
widget.$on('$viewChange', function() {
widget.$pristine = !(widget.$dirty = true);
});
forEach(['valid', 'invalid', 'pristine', 'dirty'], function(name) {
widget.$watch('$' + name, function(value) {
selectElement[value ? 'addClass' : 'removeClass']('ng-' + name);
attr.$observe('required', function() {
requiredValidator(ctrl.viewValue);
});
});
}
if (optionsExp) Options(scope, element, ctrl);
else if (multiple) Multiple(scope, element, ctrl);
else Single(scope, element, ctrl);
////////////////////////////
function Multiple(widget) {
widget.$render = function() {
var items = new HashMap(this.$viewValue);
forEach(selectElement.children(), function(option){
function Single(scope, selectElement, ctrl) {
ctrl.render = function() {
selectElement.val(ctrl.viewValue);
};
selectElement.bind('change', function() {
scope.$apply(function() {
ctrl.touch();
ctrl.read(selectElement.val());
});
});
}
function Multiple(scope, selectElement, ctrl) {
ctrl.render = function() {
var items = new HashMap(ctrl.viewValue);
forEach(selectElement.children(), function(option) {
option.selected = isDefined(items.get(option.value));
});
};
selectElement.bind('change', function() {
widget.$apply(function() {
scope.$apply(function() {
var array = [];
forEach(selectElement.children(), function(option){
forEach(selectElement.children(), function(option) {
if (option.selected) {
array.push(option.value);
}
});
widget.$emit('$viewChange', array);
ctrl.touch();
ctrl.read(array);
});
});
}
function Single(widget) {
widget.$render = function() {
selectElement.val(widget.$viewValue);
};
selectElement.bind('change', function() {
widget.$apply(function() {
widget.$emit('$viewChange', selectElement.val());
});
});
widget.$viewValue = selectElement.val();
}
function Options(widget) {
function Options(scope, selectElement, ctrl) {
var match;
if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) {
@ -234,15 +226,15 @@ var selectDirective = ['$formFactory', '$compile', '$parse',
// developer declared null option, so user should be able to select it
nullOption = jqLite(option).remove();
// compile the element since there might be bindings in it
$compile(nullOption)(modelScope);
$compile(nullOption)(scope);
}
});
selectElement.html(''); // clear contents
selectElement.bind('change', function() {
widget.$apply(function() {
scope.$apply(function() {
var optionGroup,
collection = valuesFn(modelScope) || [],
collection = valuesFn(scope) || [],
locals = {},
key, value, optionElement, index, groupIndex, length, groupLength;
@ -259,7 +251,7 @@ var selectDirective = ['$formFactory', '$compile', '$parse',
key = optionElement.val();
if (keyName) locals[keyName] = key;
locals[valueName] = collection[key];
value.push(valueFn(modelScope, locals));
value.push(valueFn(scope, locals));
}
}
}
@ -272,17 +264,21 @@ var selectDirective = ['$formFactory', '$compile', '$parse',
} else {
locals[valueName] = collection[key];
if (keyName) locals[keyName] = key;
value = valueFn(modelScope, locals);
value = valueFn(scope, locals);
}
}
if (isDefined(value) && modelScope.$viewVal !== value) {
widget.$emit('$viewChange', value);
ctrl.touch();
if (ctrl.viewValue !== value) {
ctrl.read(value);
}
});
});
widget.$watch(render);
widget.$render = render;
ctrl.render = render;
// TODO(vojta): can't we optimize this ?
scope.$watch(render);
function render() {
var optionGroups = {'':[]}, // Temporary location for the option groups before we render them
@ -291,8 +287,8 @@ var selectDirective = ['$formFactory', '$compile', '$parse',
optionGroup,
option,
existingParent, existingOptions, existingOption,
modelValue = widget.$modelValue,
values = valuesFn(modelScope) || [],
modelValue = ctrl.modelValue,
values = valuesFn(scope) || [],
keys = keyName ? sortedKeys(values) : values,
groupLength, length,
groupIndex, index,
@ -313,20 +309,20 @@ var selectDirective = ['$formFactory', '$compile', '$parse',
// We now build up the list of options we need (we merge later)
for (index = 0; length = keys.length, index < length; index++) {
locals[valueName] = values[keyName ? locals[keyName]=keys[index]:index];
optionGroupName = groupByFn(modelScope, locals) || '';
optionGroupName = groupByFn(scope, locals) || '';
if (!(optionGroup = optionGroups[optionGroupName])) {
optionGroup = optionGroups[optionGroupName] = [];
optionGroupNames.push(optionGroupName);
}
if (multiple) {
selected = selectedSet.remove(valueFn(modelScope, locals)) != undefined;
selected = selectedSet.remove(valueFn(scope, locals)) != undefined;
} else {
selected = modelValue === valueFn(modelScope, locals);
selected = modelValue === valueFn(scope, locals);
selectedSet = selectedSet || selected; // see if at least one item is selected
}
optionGroup.push({
id: keyName ? keys[index] : index, // either the index into array or key from object
label: displayFn(modelScope, locals) || '', // what will be seen by the user
label: displayFn(scope, locals) || '', // what will be seen by the user
selected: selected // determine if we should be selected
});
}

View file

@ -156,7 +156,7 @@ describe('Binder', function() {
expect(html.indexOf('action="foo();"')).toBeGreaterThan(0);
});
it('RepeaterAdd', inject(function($rootScope, $compile, $browser) {
it('RepeaterAdd', inject(function($rootScope, $compile) {
element = $compile('<div><input type="text" ng:model="item.x" ng:repeat="item in items"></div>')($rootScope);
$rootScope.items = [{x:'a'}, {x:'b'}];
$rootScope.$apply();
@ -166,8 +166,7 @@ describe('Binder', function() {
expect(second.val()).toEqual('b');
first.val('ABC');
browserTrigger(first, 'keydown');
$browser.defer.flush();
browserTrigger(first, 'blur');
expect($rootScope.items[0].x).toEqual('ABC');
}));

View file

@ -1,206 +0,0 @@
'use strict';
describe('$formFactory', function() {
it('should have global form', inject(function($rootScope, $formFactory) {
expect($formFactory.rootForm).toBeTruthy();
expect($formFactory.rootForm.$createWidget).toBeTruthy();
}));
describe('new form', function() {
var form;
var scope;
var log;
function WidgetCtrl($formFactory, $scope) {
log += '<init>';
$scope.$render = function() {
log += '$render();';
};
$scope.$on('$validate', function(e){
log += '$validate();';
});
this.$formFactory = $formFactory;
}
WidgetCtrl.$inject = ['$formFactory', '$scope'];
WidgetCtrl.prototype = {
getFormFactory: function() {
return this.$formFactory;
}
};
beforeEach(inject(function($rootScope, $formFactory) {
log = '';
scope = $rootScope.$new();
form = $formFactory(scope);
}));
describe('$createWidget', function() {
var widget;
beforeEach(function() {
widget = form.$createWidget({
scope:scope,
model:'text',
alias:'text',
controller:WidgetCtrl
});
});
describe('data flow', function() {
it('should have status properties', inject(function($rootScope, $formFactory) {
expect(widget.$error).toEqual({});
expect(widget.$valid).toBe(true);
expect(widget.$invalid).toBe(false);
}));
it('should update view when model changes', inject(function($rootScope, $formFactory) {
scope.text = 'abc';
scope.$digest();
expect(log).toEqual('<init>$validate();$render();');
expect(widget.$modelValue).toEqual('abc');
scope.text = 'xyz';
scope.$digest();
expect(widget.$modelValue).toEqual('xyz');
}));
});
describe('validation', function() {
it('should update state on error', inject(function($rootScope, $formFactory) {
widget.$emit('$invalid', 'E');
expect(widget.$valid).toEqual(false);
expect(widget.$invalid).toEqual(true);
widget.$emit('$valid', 'E');
expect(widget.$valid).toEqual(true);
expect(widget.$invalid).toEqual(false);
}));
it('should have called the model setter before the validation', inject(function($rootScope, $formFactory) {
var modelValue;
widget.$on('$validate', function() {
modelValue = scope.text;
});
widget.$emit('$viewChange', 'abc');
expect(modelValue).toEqual('abc');
}));
describe('form', function() {
it('should invalidate form when widget is invalid', inject(function($rootScope, $formFactory) {
expect(form.$error).toEqual({});
expect(form.$valid).toEqual(true);
expect(form.$invalid).toEqual(false);
widget.$emit('$invalid', 'REASON');
expect(form.$error.REASON).toEqual([widget]);
expect(form.$valid).toEqual(false);
expect(form.$invalid).toEqual(true);
var widget2 = form.$createWidget({
scope:scope, model:'text',
alias:'text',
controller:WidgetCtrl
});
widget2.$emit('$invalid', 'REASON');
expect(form.$error.REASON).toEqual([widget, widget2]);
expect(form.$valid).toEqual(false);
expect(form.$invalid).toEqual(true);
widget.$emit('$valid', 'REASON');
expect(form.$error.REASON).toEqual([widget2]);
expect(form.$valid).toEqual(false);
expect(form.$invalid).toEqual(true);
widget2.$emit('$valid', 'REASON');
expect(form.$error).toEqual({});
expect(form.$valid).toEqual(true);
expect(form.$invalid).toEqual(false);
}));
});
});
describe('id assignment', function() {
it('should default to name expression', inject(function($rootScope, $formFactory) {
expect(form.text).toEqual(widget);
}));
it('should use ng:id', inject(function($rootScope, $formFactory) {
widget = form.$createWidget({
scope:scope,
model:'text',
alias:'my.id',
controller:WidgetCtrl
});
expect(form['my.id']).toEqual(widget);
}));
it('should not override existing names', inject(function($rootScope, $formFactory) {
var widget2 = form.$createWidget({
scope:scope,
model:'text',
alias:'text',
controller:WidgetCtrl
});
expect(form.text).toEqual(widget);
expect(widget2).not.toEqual(widget);
}));
});
describe('dealocation', function() {
it('should dealocate', inject(function($rootScope, $formFactory) {
var widget2 = form.$createWidget({
scope:scope,
model:'text',
alias:'myId',
controller:WidgetCtrl
});
expect(form.myId).toEqual(widget2);
var widget3 = form.$createWidget({
scope:scope,
model:'text',
alias:'myId',
controller:WidgetCtrl
});
expect(form.myId).toEqual(widget2);
widget3.$destroy();
expect(form.myId).toEqual(widget2);
widget2.$destroy();
expect(form.myId).toBeUndefined();
}));
it('should remove invalid fields from errors, when child widget removed', inject(function($rootScope, $formFactory) {
widget.$emit('$invalid', 'MyError');
expect(form.$error.MyError).toEqual([widget]);
expect(form.$invalid).toEqual(true);
widget.$destroy();
expect(form.$error.MyError).toBeUndefined();
expect(form.$invalid).toEqual(false);
}));
});
});
});
});

View file

@ -1,122 +1,205 @@
'use strict';
describe('form', function() {
var doc;
var doc, widget, scope, $compile;
beforeEach(module(function($compileProvider) {
$compileProvider.directive('storeModelCtrl', function() {
return {
require: 'ngModel',
link: function(scope, elm, attr, ctrl) {
widget = ctrl;
}
};
});
}));
beforeEach(inject(function($injector) {
$compile = $injector.get('$compile');
scope = $injector.get('$rootScope');
}));
afterEach(function() {
dealoc(doc);
});
it('should attach form to DOM', inject(function($rootScope, $compile) {
doc = angular.element('<form>');
$compile(doc)($rootScope);
it('should instantiate form and attach it to DOM', function() {
doc = $compile('<form>')(scope);
expect(doc.data('$form')).toBeTruthy();
}));
expect(doc.data('$form') instanceof FormController).toBe(true);
});
it('should prevent form submission', inject(function($rootScope, $compile) {
it('should remove the widget when element removed', function() {
doc = $compile(
'<form name="form">' +
'<input type="text" name="alias" ng:model="value" store-model-ctrl/>' +
'</form>')(scope);
var form = scope.form;
widget.emitValidity('REQUIRED', false);
expect(form.alias).toBe(widget);
expect(form.error.REQUIRED).toEqual([widget]);
doc.find('input').remove();
expect(form.error.REQUIRED).toBeUndefined();
expect(form.alias).toBeUndefined();
});
it('should prevent form submission', function() {
var startingUrl = '' + window.location;
doc = angular.element('<form name="myForm"><input type=submit val=submit>');
$compile(doc)($rootScope);
doc = jqLite('<form name="myForm"><input type="submit" value="submit" />');
$compile(doc)(scope);
browserTrigger(doc.find('input'));
waitsFor(
function() { return true; },
'let browser breath, so that the form submision can manifest itself', 10);
runs(function() {
expect('' + window.location).toEqual(startingUrl);
});
}));
});
it('should not prevent form submission if action attribute present',
inject(function($compile, $rootScope) {
it('should not prevent form submission if action attribute present', function() {
var callback = jasmine.createSpy('submit').andCallFake(function(event) {
expect(event.isDefaultPrevented()).toBe(false);
event.preventDefault();
});
doc = angular.element('<form name="x" action="some.py" />');
$compile(doc)($rootScope);
doc = $compile('<form name="x" action="some.py" />')(scope);
doc.bind('submit', callback);
browserTrigger(doc, 'submit');
expect(callback).toHaveBeenCalledOnce();
}));
});
it('should publish form to scope', inject(function($rootScope, $compile) {
doc = angular.element('<form name="myForm"></form>');
$compile(doc)($rootScope);
expect($rootScope.myForm).toBeTruthy();
it('should publish form to scope', function() {
doc = $compile('<form name="myForm"></form>')(scope);
expect(scope.myForm).toBeTruthy();
expect(doc.data('$form')).toBeTruthy();
expect(doc.data('$form')).toEqual($rootScope.myForm);
}));
expect(doc.data('$form')).toEqual(scope.myForm);
});
it('should have ng-valide/ng-invalid style', inject(function($rootScope, $compile) {
doc = angular.element('<form name="myForm"><input type=text ng:model=text required>');
$compile(doc)($rootScope);
$rootScope.text = 'misko';
$rootScope.$digest();
it('should allow name to be an expression', function() {
doc = $compile('<form name="obj.myForm"></form>')(scope);
expect(doc.hasClass('ng-valid')).toBe(true);
expect(doc.hasClass('ng-invalid')).toBe(false);
$rootScope.text = '';
$rootScope.$digest();
expect(doc.hasClass('ng-valid')).toBe(false);
expect(doc.hasClass('ng-invalid')).toBe(true);
}));
expect(scope.obj).toBeDefined();
expect(scope.obj.myForm).toBeTruthy();
});
it('should chain nested forms', inject(function($rootScope, $compile) {
doc = angular.element(
'<ng:form name=parent>' +
'<ng:form name=child>' +
'<input type=text ng:model=text name=text>' +
it('should chain nested forms', function() {
doc = jqLite(
'<ng:form name="parent">' +
'<ng:form name="child">' +
'<input type="text" ng:model="text" name="text">' +
'</ng:form>' +
'</ng:form>');
$compile(doc)($rootScope);
var parent = $rootScope.parent;
var child = $rootScope.child;
$compile(doc)(scope);
var parent = scope.parent;
var child = scope.child;
var input = child.text;
input.$emit('$invalid', 'MyError');
expect(parent.$error.MyError).toEqual([input]);
expect(child.$error.MyError).toEqual([input]);
input.emitValidity('MyError', false);
expect(parent.error.MyError).toEqual([input]);
expect(child.error.MyError).toEqual([input]);
input.$emit('$valid', 'MyError');
expect(parent.$error.MyError).toBeUndefined();
expect(child.$error.MyError).toBeUndefined();
}));
input.emitValidity('MyError', true);
expect(parent.error.MyError).toBeUndefined();
expect(child.error.MyError).toBeUndefined();
});
it('should chain nested forms in repeater', inject(function($rootScope, $compile) {
doc = angular.element(
it('should chain nested forms in repeater', function() {
doc = jqLite(
'<ng:form name=parent>' +
'<ng:form ng:repeat="f in forms" name=child>' +
'<input type=text ng:model=text name=text>' +
'</ng:form>' +
'</ng:form>');
$compile(doc)($rootScope);
$rootScope.forms = [1];
$rootScope.$digest();
$compile(doc)(scope);
var parent = $rootScope.parent;
scope.$apply(function() {
scope.forms = [1];
});
var parent = scope.parent;
var child = doc.find('input').scope().child;
var input = child.text;
expect(parent).toBeDefined();
expect(child).toBeDefined();
expect(input).toBeDefined();
input.$emit('$invalid', 'myRule');
expect(input.$error.myRule).toEqual(true);
expect(child.$error.myRule).toEqual([input]);
expect(parent.$error.myRule).toEqual([input]);
input.emitValidity('myRule', false);
expect(input.error.myRule).toEqual(true);
expect(child.error.myRule).toEqual([input]);
expect(parent.error.myRule).toEqual([input]);
input.$emit('$valid', 'myRule');
expect(parent.$error.myRule).toBeUndefined();
expect(child.$error.myRule).toBeUndefined();
}));
input.emitValidity('myRule', true);
expect(parent.error.myRule).toBeUndefined();
expect(child.error.myRule).toBeUndefined();
});
it('should publish widgets', function() {
doc = jqLite('<form name="form"><input type="text" name="w1" ng:model="some" /></form>');
$compile(doc)(scope);
var widget = scope.form.w1;
expect(widget).toBeDefined();
expect(widget.pristine).toBe(true);
expect(widget.dirty).toBe(false);
expect(widget.valid).toBe(true);
expect(widget.invalid).toBe(false);
});
describe('validation', function() {
beforeEach(function() {
doc = $compile(
'<form name="form">' +
'<input type="text" ng:model="name" name="name" store-model-ctrl/>' +
'</form>')(scope);
scope.$digest();
});
it('should have ng-valid/ng-invalid css class', function() {
expect(doc).toBeValid();
widget.emitValidity('ERROR', false);
scope.$apply();
expect(doc).toBeInvalid();
widget.emitValidity('ANOTHER', false);
scope.$apply();
widget.emitValidity('ERROR', true);
scope.$apply();
expect(doc).toBeInvalid();
widget.emitValidity('ANOTHER', true);
scope.$apply();
expect(doc).toBeValid();
});
it('should have ng-pristine/ng-dirty css class', function() {
expect(doc).toBePristine();
widget.touch();
scope.$apply();
expect(doc).toBeDirty();
});
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff