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 --- src/Angular.js | 105 +++-- src/Browser.js | 10 +- src/Scope.js | 3 +- src/angular-bootstrap.js | 3 - src/apis.js | 173 ++++--- src/directives.js | 86 ++-- src/filters.js | 113 +++-- src/formatters.js | 202 -------- src/jqLite.js | 15 +- src/markups.js | 24 +- src/parser.js | 43 +- src/scenario/Scenario.js | 2 +- src/scenario/dsl.js | 22 +- src/service/formFactory.js | 394 ++++++++++++++++ src/service/invalidWidgets.js | 69 --- src/service/log.js | 3 +- src/service/resource.js | 3 +- src/service/route.js | 6 +- src/service/window.js | 2 +- src/service/xhr.js | 5 +- src/validators.js | 482 ------------------- src/widget/form.js | 81 ++++ src/widget/input.js | 773 ++++++++++++++++++++++++++++++ src/widget/select.js | 427 +++++++++++++++++ src/widgets.js | 1033 +++-------------------------------------- 25 files changed, 2104 insertions(+), 1975 deletions(-) delete mode 100644 src/formatters.js create mode 100644 src/service/formFactory.js delete mode 100644 src/service/invalidWidgets.js delete mode 100644 src/validators.js create mode 100644 src/widget/form.js create mode 100644 src/widget/input.js create mode 100644 src/widget/select.js (limited to 'src') diff --git a/src/Angular.js b/src/Angular.js index caa51a06..7c218c6e 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -55,7 +55,6 @@ function fromCharCode(code) { return String.fromCharCode(code); } var _undefined = undefined, _null = null, $$scope = '$scope', - $$validate = '$validate', $angular = 'angular', $array = 'array', $boolean = 'boolean', @@ -93,12 +92,10 @@ var _undefined = undefined, angularDirective = extensionMap(angular, 'directive'), /** @name angular.widget */ angularWidget = extensionMap(angular, 'widget', lowercase), - /** @name angular.validator */ - angularValidator = extensionMap(angular, 'validator'), - /** @name angular.fileter */ + /** @name angular.filter */ angularFilter = extensionMap(angular, 'filter'), - /** @name angular.formatter */ - angularFormatter = extensionMap(angular, 'formatter'), + /** @name angular.service */ + angularInputType = extensionMap(angular, 'inputType', lowercase), /** @name angular.service */ angularService = extensionMap(angular, 'service'), angularCallbacks = extensionMap(angular, 'callbacks'), @@ -156,10 +153,18 @@ function forEach(obj, iterator, context) { return obj; } -function forEachSorted(obj, iterator, context) { +function sortedKeys(obj) { var keys = []; - for (var key in obj) keys.push(key); - keys.sort(); + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + keys.push(key); + } + } + return keys.sort(); +} + +function forEachSorted(obj, iterator, context) { + var keys = sortedKeys(obj) for ( var i = 0; i < keys.length; i++) { iterator.call(context, obj[keys[i]], keys[i]); } @@ -180,7 +185,6 @@ function formatError(arg) { } /** - * @description * A consistent way of creating unique IDs in angular. The ID is a sequence of alpha numeric * characters such as '012ABC'. The reason why we are not using simply a number counter is that * the number string gets longer over time, and it can also overflow, where as the the nextId @@ -599,20 +603,33 @@ function isLeafNode (node) { * @example * * - Salutation:
- Name:
- -
- - The master object is NOT equal to the form object. - -
master={{master}}
-
form={{form}}
+ +
+ Salutation:
+ Name:
+ +
+ + The master object is NOT equal to the form object. + +
master={{master}}
+
form={{form}}
+
*
* it('should print that initialy the form object is NOT equal to master', function() { - expect(element('.doc-example-live input[name="master.salutation"]').val()).toBe('Hello'); - expect(element('.doc-example-live input[name="master.name"]').val()).toBe('world'); + expect(element('.doc-example-live input[ng\\:model="master.salutation"]').val()).toBe('Hello'); + expect(element('.doc-example-live input[ng\\:model="master.name"]').val()).toBe('world'); expect(element('.doc-example-live span').css('display')).toBe('inline'); }); @@ -691,20 +708,31 @@ function copy(source, destination){ * @example * * - Salutation:
- Name:
-
- - The greeting object is - NOT equal to - {salutation:'Hello', name:'world'}. - -
greeting={{greeting}}
+ +
+ Salutation:
+ Name:
+
+ + The greeting object is + NOT equal to + {salutation:'Hello', name:'world'}. + +
greeting={{greeting}}
+
*
* it('should print that initialy greeting is equal to the hardcoded value object', function() { - expect(element('.doc-example-live input[name="greeting.salutation"]').val()).toBe('Hello'); - expect(element('.doc-example-live input[name="greeting.name"]').val()).toBe('world'); + expect(element('.doc-example-live input[ng\\:model="greeting.salutation"]').val()).toBe('Hello'); + expect(element('.doc-example-live input[ng\\:model="greeting.name"]').val()).toBe('world'); expect(element('.doc-example-live span').css('display')).toBe('none'); }); @@ -915,24 +943,19 @@ function angularInit(config, document){ if (config.css) $browser.addCss(config.base_url + config.css); - else if(msie<8) - $browser.addJs(config.ie_compat, config.ie_compat_id); scope.$apply(); } } -function angularJsConfig(document, config) { +function angularJsConfig(document) { bindJQuery(); var scripts = document.getElementsByTagName("script"), + config = {}, match; - config = extend({ - ie_compat_id: 'ng-ie-compat' - }, config); for(var j = 0; j < scripts.length; j++) { match = (scripts[j].src || "").match(rngScript); if (match) { config.base_url = match[1]; - config.ie_compat = match[1] + 'angular-ie-compat' + (match[2] || '') + '.js'; extend(config, parseKeyValue(match[6])); eachAttribute(jqLite(scripts[j]), function(value, name){ if (/^ng:/.exec(name)) { @@ -974,11 +997,13 @@ function assertArg(arg, name, reason) { (reason || "required")); throw error; } + return arg; } function assertArgFn(arg, name) { - assertArg(isFunction(arg), name, 'not a function, got ' + + assertArg(isFunction(arg), name, 'not a function, got ' + (typeof arg == 'object' ? arg.constructor.name : typeof arg)); + return arg; } diff --git a/src/Browser.js b/src/Browser.js index ed12441a..77d1c684 100644 --- a/src/Browser.js +++ b/src/Browser.js @@ -105,7 +105,7 @@ function Browser(window, document, body, XHR, $log, $sniffer) { window[callbackId].data = data; }; - var script = self.addJs(url.replace('JSON_CALLBACK', callbackId), null, function() { + var script = self.addJs(url.replace('JSON_CALLBACK', callbackId), function() { if (window[callbackId].data) { completeOutstandingRequest(callback, 200, window[callbackId].data); } else { @@ -442,24 +442,18 @@ function Browser(window, document, body, XHR, $log, $sniffer) { * @methodOf angular.service.$browser * * @param {string} url Url to js file - * @param {string=} domId Optional id for the script tag * * @description * Adds a script tag to the head. */ - self.addJs = function(url, domId, done) { + self.addJs = function(url, done) { // we can't use jQuery/jqLite here because jQuery does crazy shit with script elements, e.g.: // - fetches local scripts via XHR and evals them // - adds and immediately removes script elements from the document - // - // We need addJs to be able to add angular-ie-compat.js which is very special and must remain - // part of the DOM so that the embedded images can reference it. jQuery's append implementation - // (v1.4.2) fubars it. var script = rawDocument.createElement('script'); script.type = 'text/javascript'; script.src = url; - if (domId) script.id = domId; if (msie) { script.onreadystatechange = function() { diff --git a/src/Scope.js b/src/Scope.js index c5f5bf1b..e4fc0622 100644 --- a/src/Scope.js +++ b/src/Scope.js @@ -354,7 +354,8 @@ Scope.prototype = { // circuit it with === operator, only when === fails do we use .equals if ((value = watch.get(current)) !== (last = watch.last) && !equals(value, last)) { dirty = true; - watch.fn(current, watch.last = copy(value), last); + watch.last = copy(value); + watch.fn(current, value, last); } } catch (e) { current.$service('$exceptionHandler')(e); diff --git a/src/angular-bootstrap.js b/src/angular-bootstrap.js index f9d9643d..7a1752d2 100644 --- a/src/angular-bootstrap.js +++ b/src/angular-bootstrap.js @@ -101,9 +101,6 @@ var config = angularJsConfig(document); - // angular-ie-compat.js needs to be pregenerated for development with IE<8 - config.ie_compat = serverPath + '../build/angular-ie-compat.js'; - angularInit(config, document); } diff --git a/src/apis.js b/src/apis.js index bec54b8e..6a5bf6c4 100644 --- a/src/apis.js +++ b/src/apis.js @@ -103,9 +103,16 @@ var angularArray = { * @example -
-
- Index of '{{bookName}}' in the list {{books}} is {{books.$indexOf(bookName)}}. + +
+
+ Index of '{{bookName}}' in the list {{books}} is {{books.$indexOf(bookName)}}. +
it('should correctly calculate the initial index', function() { @@ -146,17 +153,29 @@ var angularArray = { * @example - + +
- - - + + + - + @@ -166,8 +185,8 @@ var angularArray = { //TODO: these specs are lame because I had to work around issues #164 and #167 it('should initialize and calculate the totals', function() { - expect(repeater('.doc-example-live table tr', 'item in invoice.items').count()).toBe(3); - expect(repeater('.doc-example-live table tr', 'item in invoice.items').row(1)). + expect(repeater('table.invoice tr', 'item in invoice.items').count()).toBe(3); + expect(repeater('table.invoice tr', 'item in invoice.items').row(1)). toEqual(['$99.50']); expect(binding("invoice.items.$sum('qty*cost')")).toBe('$99.50'); expect(binding("invoice.items.$sum('qty*cost')")).toBe('$99.50'); @@ -178,7 +197,7 @@ var angularArray = { using('.doc-example-live tr:nth-child(3)').input('item.qty').enter('20'); using('.doc-example-live tr:nth-child(3)').input('item.cost').enter('100'); - expect(repeater('.doc-example-live table tr', 'item in invoice.items').row(2)). + expect(repeater('table.invoice tr', 'item in invoice.items').row(2)). toEqual(['$2,000.00']); expect(binding("invoice.items.$sum('qty*cost')")).toBe('$2,099.50'); }); @@ -297,7 +316,7 @@ var angularArray = { {name:'Adam', phone:'555-5678'}, {name:'Julie', phone:'555-8765'}]"> - Search: + Search:
QtyDescriptionCostTotal
{{item.qty * item.cost | currency}} [X]
add itemadd item Total: {{invoice.items.$sum('qty*cost') | currency}}
@@ -306,9 +325,9 @@ var angularArray = {
NamePhone

- Any:
- Name only
- Phone only
+ Any:
+ Name only
+ Phone only
@@ -442,22 +461,29 @@ var angularArray = { * with objects created from user input. - [add empty] - [add 'John'] - [add 'Mary'] - -
    -
  • - - - [X] -
  • -
-
people = {{people}}
+ +
+ [add empty] + [add 'John'] + [add 'Mary'] + +
    +
  • + + + [X] +
  • +
+
people = {{people}}
+
beforeEach(function() { @@ -466,7 +492,7 @@ var angularArray = { it('should create an empty record when "add empty" is clicked', function() { element('.doc-example-live a:contains("add empty")').click(); - expect(binding('people')).toBe('people = [{\n "name":"",\n "sex":null}]'); + expect(binding('people')).toBe('people = [{\n }]'); }); it('should create a "John" record when "add \'John\'" is clicked', function() { @@ -521,7 +547,7 @@ var angularArray = {
  • {{item.name}}: points= - +

Number of items which have one point: {{ items.$count('points==1') }}

@@ -585,49 +611,56 @@ var angularArray = { * @example -
- -
Sorting predicate = {{predicate}}; reverse = {{reverse}}
-
- [ unsorted ] -
NamePhone
- - - - - - - - - - -
Name - (^)Phone NumberAge
{{friend.name}}{{friend.phone}}{{friend.age}}
+ +
+
Sorting predicate = {{predicate}}; reverse = {{reverse}}
+
+ [ unsorted ] + + + + + + + + + + + +
Name + (^)Phone NumberAge
{{friend.name}}{{friend.phone}}{{friend.age}}
+
it('should be reverse ordered by aged', function() { expect(binding('predicate')).toBe('Sorting predicate = -age; reverse = '); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.age')). + expect(repeater('table.friend', 'friend in friends').column('friend.age')). toEqual(['35', '29', '21', '19', '10']); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.name')). + expect(repeater('table.friend', 'friend in friends').column('friend.name')). toEqual(['Adam', 'Julie', 'Mike', 'Mary', 'John']); }); it('should reorder the table when user selects different predicate', function() { element('.doc-example-live a:contains("Name")').click(); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.name')). + expect(repeater('table.friend', 'friend in friends').column('friend.name')). toEqual(['Adam', 'John', 'Julie', 'Mary', 'Mike']); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.age')). + expect(repeater('table.friend', 'friend in friends').column('friend.age')). toEqual(['35', '10', '29', '19', '21']); element('.doc-example-live a:contains("Phone")').click(); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.phone')). + expect(repeater('table.friend', 'friend in friends').column('friend.phone')). toEqual(['555-9876', '555-8765', '555-5678', '555-4321', '555-1212']); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.name')). + expect(repeater('table.friend', 'friend in friends').column('friend.name')). toEqual(['Mary', 'Julie', 'Adam', 'Mike', 'John']); }); @@ -704,14 +737,20 @@ var angularArray = { * @example -
- Limit [1,2,3,4,5,6,7,8,9] to: + +
+ Limit {{numbers}} to:

Output: {{ numbers.$limitTo(limit) | json }}

it('should limit the numer array to first three items', function() { - expect(element('.doc-example-live input[name=limit]').val()).toBe('3'); + expect(element('.doc-example-live input[ng\\:model=limit]').val()).toBe('3'); expect(binding('numbers.$limitTo(limit) | json')).toEqual('[1,2,3]'); }); @@ -840,7 +879,7 @@ var angularFunction = { * Hash of a: * string is string * number is number as string - * object is either result of calling $$hashKey function on the object or uniquely generated id, + * object is either result of calling $$hashKey function on the object or uniquely generated id, * that is also assigned to the $$hashKey property of the object. * * @param obj @@ -864,7 +903,9 @@ function hashKey(obj) { /** * HashMap which can use objects as keys */ -function HashMap(){} +function HashMap(array){ + forEach(array, this.put, this); +} HashMap.prototype = { /** * Store key value pair diff --git a/src/directives.js b/src/directives.js index dd67ddc7..852d04cd 100644 --- a/src/directives.js +++ b/src/directives.js @@ -19,8 +19,6 @@ * to `ng:bind`, but uses JSON key / value pairs to do so. * * {@link angular.directive.ng:bind-template ng:bind-template} - Replaces the text value of an * element with a specified template. - * * {@link angular.directive.ng:change ng:change} - Executes an expression when the value of an - * input widget changes. * * {@link angular.directive.ng:class ng:class} - Conditionally set a CSS class on an element. * * {@link angular.directive.ng:class-even ng:class-even} - Like `ng:class`, but works in * conjunction with {@link angular.widget.@ng:repeat} to affect even rows in a collection. @@ -133,16 +131,16 @@ angularDirective("ng:init", function(expression){ };
- Name: + Name: [ greet ]
Contact:
  • - - + [ clear | X ]
  • @@ -153,16 +151,16 @@ angularDirective("ng:init", function(expression){ it('should check controller', function(){ expect(element('.doc-example-live div>:input').val()).toBe('John Smith'); - expect(element('.doc-example-live li[ng\\:repeat-index="0"] input').val()) + expect(element('.doc-example-live li:nth-child(1) input').val()) .toBe('408 555 1212'); - expect(element('.doc-example-live li[ng\\:repeat-index="1"] input').val()) + expect(element('.doc-example-live li:nth-child(2) input').val()) .toBe('john.smith@example.org'); element('.doc-example-live li:first a:contains("clear")').click(); expect(element('.doc-example-live li:first input').val()).toBe(''); element('.doc-example-live li:last a:contains("add")').click(); - expect(element('.doc-example-live li[ng\\:repeat-index="2"] input').val()) + expect(element('.doc-example-live li:nth-child(3) input').val()) .toBe('yourname@example.org'); }); @@ -200,8 +198,15 @@ angularDirective("ng:controller", function(expression){ * Enter a name in the Live Preview text box; the greeting below the text box changes instantly. - Enter name:
    - Hello ! + +
    + Enter name:
    + Hello ! +
    it('should check ng:bind', function(){ @@ -320,9 +325,17 @@ function compileBindTemplate(template){ * Try it here: enter text in text box and watch the greeting change. - Salutation:
    - Name:
    -
    
    +       
    +       
    + Salutation:
    + Name:
    +
    
    +       
    it('should check ng:bind', function(){ @@ -351,13 +364,6 @@ angularDirective("ng:bind-template", function(expression, element){ }; }); -var REMOVE_ATTRIBUTES = { - 'disabled':'disabled', - 'readonly':'readOnly', - 'checked':'checked', - 'selected':'selected', - 'multiple':'multiple' -}; /** * @ngdoc directive * @name angular.directive.ng:bind-attr @@ -395,9 +401,16 @@ var REMOVE_ATTRIBUTES = { * Enter a search string in the Live Preview text box and then click "Google". The search executes instantly. - Google for: - - Google + +
    + Google for: + + Google +
    it('should check ng:bind-attr', function(){ @@ -417,18 +430,15 @@ angularDirective("ng:bind-attr", function(expression){ var values = scope.$eval(expression); for(var key in values) { var value = compileBindTemplate(values[key])(scope, element), - specialName = REMOVE_ATTRIBUTES[lowercase(key)]; + specialName = BOOLEAN_ATTR[lowercase(key)]; if (lastValue[key] !== value) { lastValue[key] = value; if (specialName) { if (toBoolean(value)) { element.attr(specialName, specialName); - element.attr('ng-' + specialName, value); } else { element.removeAttr(specialName); - element.removeAttr('ng-' + specialName); } - (element.data($$validate)||noop)(); } else { element.attr(key, value); } @@ -505,12 +515,22 @@ angularDirective("ng:click", function(expression, element){ * @example -
    + + Enter text and hit enter: - + +
    list={{list}}
    -
    list={{list}}
    it('should check ng:submit', function(){ @@ -537,7 +557,7 @@ function ngClass(selector) { return function(element) { this.$watch(expression, function(scope, newVal, oldVal) { if (selector(scope.$index)) { - element.removeClass(isArray(oldVal) ? oldVal.join(' ') : oldVal) + element.removeClass(isArray(oldVal) ? oldVal.join(' ') : oldVal); element.addClass(isArray(newVal) ? newVal.join(' ') : newVal); } }); @@ -689,7 +709,7 @@ angularDirective("ng:class-even", ngClass(function(i){return i % 2 === 1;})); * @example - Click me:
    + Click me:
    Show: I show up when your checkbox is checked.
    Hide: I hide when your checkbox is checked.
    @@ -730,7 +750,7 @@ angularDirective("ng:show", function(expression, element){ * @example - Click me:
    + Click me:
    Show: I show up when you checkbox is checked?
    Hide: I hide when you checkbox is checked?
    diff --git a/src/filters.js b/src/filters.js index c5d886ea..0fcd442b 100644 --- a/src/filters.js +++ b/src/filters.js @@ -48,9 +48,16 @@ * @example -
    - default currency symbol ($): {{amount | currency}}
    - custom currency identifier (USD$): {{amount | currency:"USD$"}} + +
    +
    + default currency symbol ($): {{amount | currency}}
    + custom currency identifier (USD$): {{amount | currency:"USD$"}} +
    it('should init with 1234.56', function(){ @@ -93,10 +100,17 @@ angularFilter.currency = function(amount, currencySymbol){ * @example - Enter number:
    - Default formatting: {{val | number}}
    - No fractions: {{val | number:0}}
    - Negative number: {{-val | number:4}} + +
    + Enter number:
    + Default formatting: {{val | number}}
    + No fractions: {{val | number:0}}
    + Negative number: {{-val | number:4}} +
    it('should format numbers', function(){ @@ -462,36 +476,43 @@ angularFilter.uppercase = uppercase; * @example - Snippet: - - - - - - - - - - - - - - - - - - - - - -
    FilterSourceRendered
    html filter -
    <div ng:bind="snippet | html">
    </div>
    -
    -
    -
    no filter
    <div ng:bind="snippet">
    </div>
    unsafe html filter
    <div ng:bind="snippet | html:'unsafe'">
    </div>
    + +
    + Snippet: + + + + + + + + + + + + + + + + + + + + + +
    FilterSourceRendered
    html filter +
    <div ng:bind="snippet | html">
    </div>
    +
    +
    +
    no filter
    <div ng:bind="snippet">
    </div>
    unsafe html filter
    <div ng:bind="snippet | html:'unsafe'">
    </div>
    +
    it('should sanitize the html snippet ', function(){ @@ -543,12 +564,18 @@ angularFilter.html = function(html, option){ * @example - Snippet: + +
    + Snippet: diff --git a/src/formatters.js b/src/formatters.js deleted file mode 100644 index 2fadc9d7..00000000 --- a/src/formatters.js +++ /dev/null @@ -1,202 +0,0 @@ -'use strict'; - -/** - * @workInProgress - * @ngdoc overview - * @name angular.formatter - * @description - * - * Formatters are used for translating data formats between those used for display and those used - * for storage. - * - * Following is the list of built-in angular formatters: - * - * * {@link angular.formatter.boolean boolean} - Formats user input in boolean format - * * {@link angular.formatter.json json} - Formats user input in JSON format - * * {@link angular.formatter.list list} - Formats user input string as an array - * * {@link angular.formatter.number number} - Formats user input strings as a number - * * {@link angular.formatter.trim trim} - Trims extras spaces from end of user input - * - * For more information about how angular formatters work, and how to create your own formatters, - * see {@link guide/dev_guide.templates.formatters Understanding Angular Formatters} in the angular - * Developer Guide. - */ - -function formatter(format, parse) {return {'format':format, 'parse':parse || format};} -function toString(obj) { - return (isDefined(obj) && obj !== null) ? "" + obj : obj; -} - -var NUMBER = /^\s*[-+]?\d*(\.\d*)?\s*$/; - -angularFormatter.noop = formatter(identity, identity); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.json - * - * @description - * Formats the user input as JSON text. - * - * @returns {?string} A JSON string representation of the model. - * - * @example - - -
    - -
    data={{data}}
    -
    -
    - - it('should format json', function(){ - expect(binding('data')).toEqual('data={\n \"name\":\"misko\",\n \"project\":\"angular\"}'); - input('data').enter('{}'); - expect(binding('data')).toEqual('data={\n }'); - }); - -
    - */ -angularFormatter.json = formatter(toJson, function(value){ - return fromJson(value || 'null'); -}); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.boolean - * - * @description - * Use boolean formatter if you wish to store the data as boolean. - * - * @returns {boolean} Converts to `true` unless user enters (blank), `f`, `false`, `0`, `no`, `[]`. - * - * @example - - - Enter truthy text: - - -
    value={{value}}
    -
    - - it('should format boolean', function(){ - expect(binding('value')).toEqual('value=false'); - input('value').enter('truthy'); - expect(binding('value')).toEqual('value=true'); - }); - -
    - */ -angularFormatter['boolean'] = formatter(toString, toBoolean); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.number - * - * @description - * Use number formatter if you wish to convert the user entered string to a number. - * - * @returns {number} Number from the parsed string. - * - * @example - - - Enter valid number: - -
    value={{value}}
    -
    - - it('should format numbers', function(){ - expect(binding('value')).toEqual('value=1234'); - input('value').enter('5678'); - expect(binding('value')).toEqual('value=5678'); - }); - -
    - */ -angularFormatter.number = formatter(toString, function(obj){ - if (obj == null || NUMBER.exec(obj)) { - return obj===null || obj === '' ? null : 1*obj; - } else { - throw "Not a number"; - } -}); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.list - * - * @description - * Use list formatter if you wish to convert the user entered string to an array. - * - * @returns {Array} Array parsed from the entered string. - * - * @example - - - Enter a list of items: - - -
    value={{value}}
    -
    - - it('should format lists', function(){ - expect(binding('value')).toEqual('value=["chair","table"]'); - this.addFutureAction('change to XYZ', function($window, $document, done){ - $document.elements('.doc-example-live :input:last').val(',,a,b,').trigger('change'); - done(); - }); - expect(binding('value')).toEqual('value=["a","b"]'); - }); - -
    - */ -angularFormatter.list = formatter( - function(obj) { return obj ? obj.join(", ") : obj; }, - function(value) { - var list = []; - forEach((value || '').split(','), function(item){ - item = trim(item); - if (item) list.push(item); - }); - return list; - } -); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.trim - * - * @description - * Use trim formatter if you wish to trim extra spaces in user text. - * - * @returns {String} Trim excess leading and trailing space. - * - * @example - - - Enter text with leading/trailing spaces: - - -
    value={{value|json}}
    -
    - - it('should format trim', function(){ - expect(binding('value')).toEqual('value="book"'); - this.addFutureAction('change to XYZ', function($window, $document, done){ - $document.elements('.doc-example-live :input:last').val(' text ').trigger('change'); - done(); - }); - expect(binding('value')).toEqual('value="text"'); - }); - -
    - */ -angularFormatter.trim = formatter( - function(obj) { return obj ? trim("" + obj) : ""; } -); diff --git a/src/jqLite.js b/src/jqLite.js index 5f761f92..0052055c 100644 --- a/src/jqLite.js +++ b/src/jqLite.js @@ -100,6 +100,10 @@ function camelCase(name) { ///////////////////////////////////////////// // jQuery mutation patch +// +// In conjunction with bindJQuery intercepts all jQuery's DOM destruction apis and fires a +// $destroy event on all DOM nodes being removed. +// ///////////////////////////////////////////// function JQLitePatchJQueryRemove(name, dispatchThis) { @@ -129,7 +133,9 @@ function JQLitePatchJQueryRemove(name, dispatchThis) { } else { fireEvent = !fireEvent; } - for(childIndex = 0, childLength = (children = element.children()).length; childIndex < childLength; childIndex++) { + for(childIndex = 0, childLength = (children = element.children()).length; + childIndex < childLength; + childIndex++) { list.push(jQuery(children[childIndex])); } } @@ -283,7 +289,10 @@ var JQLitePrototype = JQLite.prototype = { // these functions return self on setter and // value on get. ////////////////////////////////////////// -var SPECIAL_ATTR = makeMap("multiple,selected,checked,disabled,readonly,required"); +var BOOLEAN_ATTR = {}; +forEach('multiple,selected,checked,disabled,readOnly,required'.split(','), function(value, key) { + BOOLEAN_ATTR[lowercase(value)] = value; +}); forEach({ data: JQLiteData, @@ -331,7 +340,7 @@ forEach({ }, attr: function(element, name, value){ - if (SPECIAL_ATTR[name]) { + if (BOOLEAN_ATTR[name]) { if (isDefined(value)) { if (!!value) { element[name] = true; diff --git a/src/markups.js b/src/markups.js index 1adad3e0..40f4322b 100644 --- a/src/markups.js +++ b/src/markups.js @@ -163,7 +163,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * This example uses `link` variable inside `href` attribute: -
    +
    link 1 (link, don't reload)
    link 2 (link, don't reload)
    link 3 (link, reload!)
    @@ -262,8 +262,8 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example - Click me to toggle:
    - + Click me to toggle:
    +
    it('should toggle button', function() { @@ -292,7 +292,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example - Check me to check both:
    + Check me to check both:
    @@ -323,7 +323,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example - Check me check multiple:
    + Check me check multiple:

    + Check me to make text readonly:
    @@ -388,7 +388,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example - Check me to select:
    + Check me to select:
    +
    +
    editorForm = {{editorForm}}
    + +
    + + it('should enter invalid HTML', function(){ + expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-valid/); + input('html').enter('<'); + expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-invalid/); + }); + +
    + */ +angularServiceInject('$formFactory', function(){ + + + /** + * @ngdoc proprety + * @name rootForm + * @propertyOf angular.service.$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(this); + + + /** + * @ngdoc method + * @name forElement + * @methodOf angular.service.$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.widget.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) { + return (parent || formFactory.rootForm).$new(FormController); + } + +}); + +function propertiesUpdate(widget) { + widget.$valid = !(widget.$invalid = + !(widget.$readonly || widget.$disabled || equals(widget.$error, {}))); +} + +/** + * @ngdoc property + * @name $error + * @propertyOf angular.service.$formFactory + * @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 ]`. + */ + +/** + * @workInProgress + * @ngdoc property + * @name $invalid + * @propertyOf angular.service.$formFactory + * @description + * Property of the form and widget instance. + * + * True if any of the widgets of the form are invalid. + */ + +/** + * @workInProgress + * @ngdoc property + * @name $valid + * @propertyOf angular.service.$formFactory + * @description + * Property of the form and widget instance. + * + * True if all of the widgets of the form are valid. + */ + +/** + * @ngdoc event + * @name angular.service.$formFactory#$valid + * @eventOf angular.service.$formFactory + * @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.service.$formFactory#$invalid + * @eventOf angular.service.$formFactory + * @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.service.$formFactory#$validate + * @eventOf angular.service.$formFactory + * @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.service.$formFactory#$viewChange + * @eventOf angular.service.$formFactory + * @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`. + */ + +function FormController(){ + var form = this, + $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); + + 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.service.$formFactory + * @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, + modelScope = params.scope, + onChange = params.onChange, + alias = params.alias, + scopeGet = parser(params.model).assignable(), + scopeSet = scopeGet.assign, + widget = this.$new(params.controller, params.controllerArgs); + + 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 (scope, 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/service/invalidWidgets.js b/src/service/invalidWidgets.js deleted file mode 100644 index 7c1b2a9f..00000000 --- a/src/service/invalidWidgets.js +++ /dev/null @@ -1,69 +0,0 @@ -'use strict'; - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$invalidWidgets - * - * @description - * Keeps references to all invalid widgets found during validation. - * Can be queried to find whether there are any invalid widgets currently displayed. - * - * @example - */ -angularServiceInject("$invalidWidgets", function(){ - var invalidWidgets = []; - - - /** Remove an element from the array of invalid widgets */ - invalidWidgets.markValid = function(element){ - var index = indexOf(invalidWidgets, element); - if (index != -1) - invalidWidgets.splice(index, 1); - }; - - - /** Add an element to the array of invalid widgets */ - invalidWidgets.markInvalid = function(element){ - var index = indexOf(invalidWidgets, element); - if (index === -1) - invalidWidgets.push(element); - }; - - - /** Return count of all invalid widgets that are currently visible */ - invalidWidgets.visible = function() { - var count = 0; - forEach(invalidWidgets, function(widget){ - count = count + (isVisible(widget) ? 1 : 0); - }); - return count; - }; - - - /* At the end of each eval removes all invalid widgets that are not part of the current DOM. */ - this.$watch(function() { - for(var i = 0; i < invalidWidgets.length;) { - var widget = invalidWidgets[i]; - if (isOrphan(widget[0])) { - invalidWidgets.splice(i, 1); - if (widget.dealoc) widget.dealoc(); - } else { - i++; - } - } - }); - - - /** - * Traverses DOM element's (widget's) parents and considers the element to be an orphan if one of - * it's parents isn't the current window.document. - */ - function isOrphan(widget) { - if (widget == window.document) return false; - var parent = widget.parentNode; - return !parent || isOrphan(parent); - } - - return invalidWidgets; -}); diff --git a/src/service/log.js b/src/service/log.js index 09945732..3dacd117 100644 --- a/src/service/log.js +++ b/src/service/log.js @@ -18,12 +18,13 @@

    Reload this page with open console, enter text and hit the log button...

    Message: - + diff --git a/src/service/resource.js b/src/service/resource.js index f6e0be18..915f2d92 100644 --- a/src/service/resource.js +++ b/src/service/resource.js @@ -160,6 +160,7 @@
    - +
    diff --git a/src/service/route.js b/src/service/route.js index 73c73b04..b78cca91 100644 --- a/src/service/route.js +++ b/src/service/route.js @@ -260,7 +260,8 @@ angularServiceInject('$route', function($location, $routeParams) { function updateRoute() { var next = parseRoute(), - last = $route.current; + last = $route.current, + Controller; if (next && last && next.$route === last.$route && equals(next.pathParams, last.pathParams) && !next.reloadOnSearch && !forceReload) { @@ -283,7 +284,8 @@ angularServiceInject('$route', function($location, $routeParams) { } } else { copy(next.params, $routeParams); - next.scope = parentScope.$new(next.controller); + (Controller = next.controller) && inferInjectionArgs(Controller); + next.scope = parentScope.$new(Controller); } } rootScope.$broadcast('$afterRouteChange', next, last); diff --git a/src/service/window.js b/src/service/window.js index 2f3f677a..9795e4fc 100644 --- a/src/service/window.js +++ b/src/service/window.js @@ -17,7 +17,7 @@ * @example - + diff --git a/src/service/xhr.js b/src/service/xhr.js index 09e7d070..4981c078 100644 --- a/src/service/xhr.js +++ b/src/service/xhr.js @@ -111,6 +111,7 @@
    - - +
    diff --git a/src/validators.js b/src/validators.js deleted file mode 100644 index 72a995fc..00000000 --- a/src/validators.js +++ /dev/null @@ -1,482 +0,0 @@ -'use strict'; - -/** - * @workInProgress - * @ngdoc overview - * @name angular.validator - * @description - * - * Most of the built-in angular validators are used to check user input against defined types or - * patterns. You can easily create your own custom validators as well. - * - * Following is the list of built-in angular validators: - * - * * {@link angular.validator.asynchronous asynchronous()} - Provides asynchronous validation via a - * callback function. - * * {@link angular.validator.date date()} - Checks user input against default date format: - * "MM/DD/YYYY" - * * {@link angular.validator.email email()} - Validates that user input is a well-formed email - * address. - * * {@link angular.validator.integer integer()} - Validates that user input is an integer - * * {@link angular.validator.json json()} - Validates that user input is valid JSON - * * {@link angular.validator.number number()} - Validates that user input is a number - * * {@link angular.validator.phone phone()} - Validates that user input matches the pattern - * "1(123)123-1234" - * * {@link angular.validator.regexp regexp()} - Restricts valid input to a specified regular - * expression pattern - * * {@link angular.validator.url url()} - Validates that user input is a well-formed URL. - * - * For more information about how angular validators work, and how to create your own validators, - * see {@link guide/dev_guide.templates.validators Understanding Angular Validators} in the angular - * Developer Guide. - */ - -extend(angularValidator, { - 'noop': function() { return null; }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.regexp - * @description - * Use regexp validator to restrict the input to any Regular Expression. - * - * @param {string} value value to validate - * @param {string|regexp} expression regular expression. - * @param {string=} msg error message to display. - * @css ng-validation-error - * - * @example - - - - Enter valid SSN: -
    - -
    -
    - - it('should invalidate non ssn', function(){ - var textBox = element('.doc-example-live :input'); - expect(textBox.prop('className')).not().toMatch(/ng-validation-error/); - expect(textBox.val()).toEqual('123-45-6789'); - input('ssn').enter('123-45-67890'); - expect(textBox.prop('className')).toMatch(/ng-validation-error/); - }); - -
    - * - */ - 'regexp': function(value, regexp, msg) { - if (!value.match(regexp)) { - return msg || - "Value does not match expected format " + regexp + "."; - } else { - return null; - } - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.number - * @description - * Use number validator to restrict the input to numbers with an - * optional range. (See integer for whole numbers validator). - * - * @param {string} value value to validate - * @param {int=} [min=MIN_INT] minimum value. - * @param {int=} [max=MAX_INT] maximum value. - * @css ng-validation-error - * - * @example - - - Enter number:
    - Enter number greater than 10:
    - Enter number between 100 and 200:
    -
    - - it('should invalidate number', function(){ - var n1 = element('.doc-example-live :input[name=n1]'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('n1').enter('1.x'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - var n2 = element('.doc-example-live :input[name=n2]'); - expect(n2.prop('className')).not().toMatch(/ng-validation-error/); - input('n2').enter('9'); - expect(n2.prop('className')).toMatch(/ng-validation-error/); - var n3 = element('.doc-example-live :input[name=n3]'); - expect(n3.prop('className')).not().toMatch(/ng-validation-error/); - input('n3').enter('201'); - expect(n3.prop('className')).toMatch(/ng-validation-error/); - }); - -
    - * - */ - 'number': function(value, min, max) { - var num = 1 * value; - if (num == value) { - if (typeof min != $undefined && num < min) { - return "Value can not be less than " + min + "."; - } - if (typeof min != $undefined && num > max) { - return "Value can not be greater than " + max + "."; - } - return null; - } else { - return "Not a number"; - } - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.integer - * @description - * Use number validator to restrict the input to integers with an - * optional range. (See integer for whole numbers validator). - * - * @param {string} value value to validate - * @param {int=} [min=MIN_INT] minimum value. - * @param {int=} [max=MAX_INT] maximum value. - * @css ng-validation-error - * - * @example - - - Enter integer:
    - Enter integer equal or greater than 10:
    - Enter integer between 100 and 200 (inclusive):
    -
    - - it('should invalidate integer', function(){ - var n1 = element('.doc-example-live :input[name=n1]'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('n1').enter('1.1'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - var n2 = element('.doc-example-live :input[name=n2]'); - expect(n2.prop('className')).not().toMatch(/ng-validation-error/); - input('n2').enter('10.1'); - expect(n2.prop('className')).toMatch(/ng-validation-error/); - var n3 = element('.doc-example-live :input[name=n3]'); - expect(n3.prop('className')).not().toMatch(/ng-validation-error/); - input('n3').enter('100.1'); - expect(n3.prop('className')).toMatch(/ng-validation-error/); - }); - -
    - */ - 'integer': function(value, min, max) { - var numberError = angularValidator['number'](value, min, max); - if (numberError) return numberError; - if (!("" + value).match(/^\s*[\d+]*\s*$/) || value != Math.round(value)) { - return "Not a whole number"; - } - return null; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.date - * @description - * Use date validator to restrict the user input to a valid date - * in format in format MM/DD/YYYY. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - - - Enter valid date: - - - - it('should invalidate date', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('123/123/123'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - - - * - */ - 'date': function(value) { - var fields = /^(\d\d?)\/(\d\d?)\/(\d\d\d\d)$/.exec(value); - var date = fields ? new Date(fields[3], fields[1]-1, fields[2]) : 0; - return (date && - date.getFullYear() == fields[3] && - date.getMonth() == fields[1]-1 && - date.getDate() == fields[2]) - ? null - : "Value is not a date. (Expecting format: 12/31/2009)."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.email - * @description - * Use email validator if you wist to restrict the user input to a valid email. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - - - Enter valid email: - - - - it('should invalidate email', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('a@b.c'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - - - * - */ - 'email': function(value) { - if (value.match(/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/)) { - return null; - } - return "Email needs to be in username@host.com format."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.phone - * @description - * Use phone validator to restrict the input phone numbers. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - - - Enter valid phone number: - - - - it('should invalidate phone', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('+12345678'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - - - * - */ - 'phone': function(value) { - if (value.match(/^1\(\d\d\d\)\d\d\d-\d\d\d\d$/)) { - return null; - } - if (value.match(/^\+\d{2,3} (\(\d{1,5}\))?[\d ]+\d$/)) { - return null; - } - return "Phone number needs to be in 1(987)654-3210 format in North America " + - "or +999 (123) 45678 906 internationally."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.url - * @description - * Use phone validator to restrict the input URLs. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - - - Enter valid URL: - - - - it('should invalidate url', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('abc://server/path'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - - - * - */ - 'url': function(value) { - if (value.match(/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/)) { - return null; - } - return "URL needs to be in http://server[:port]/path format."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.json - * @description - * Use json validator if you wish to restrict the user input to a valid JSON. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - - - - - - it('should invalidate json', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('json').enter('{name}'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - - - * - */ - 'json': function(value) { - try { - fromJson(value); - return null; - } catch (e) { - return e.toString(); - } - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.asynchronous - * @description - * Use asynchronous validator if the validation can not be computed - * immediately, but is provided through a callback. The widget - * automatically shows a spinning indicator while the validity of - * the widget is computed. This validator caches the result. - * - * @param {string} value value to validate - * @param {function(inputToValidate,validationDone)} validate function to call to validate the state - * of the input. - * @param {function(data)=} [update=noop] function to call when state of the - * validator changes - * - * @paramDescription - * The `validate` function (specified by you) is called as - * `validate(inputToValidate, validationDone)`: - * - * * `inputToValidate`: value of the input box. - * * `validationDone`: `function(error, data){...}` - * * `error`: error text to display if validation fails - * * `data`: data object to pass to update function - * - * The `update` function is optionally specified by you and is - * called by on input change. Since the - * asynchronous validator caches the results, the update - * function can be called without a call to `validate` - * function. The function is called as `update(data)`: - * - * * `data`: data object as passed from validate function - * - * @css ng-input-indicator-wait, ng-validation-error - * - * @example - - - - This input is validated asynchronously: -
    - -
    -
    - - it('should change color in delayed way', function(){ - var textBox = element('.doc-example-live :input'); - expect(textBox.prop('className')).not().toMatch(/ng-input-indicator-wait/); - expect(textBox.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('X'); - expect(textBox.prop('className')).toMatch(/ng-input-indicator-wait/); - sleep(.6); - expect(textBox.prop('className')).not().toMatch(/ng-input-indicator-wait/); - expect(textBox.prop('className')).toMatch(/ng-validation-error/); - }); - -
    - * - */ - /* - * cache is attached to the element - * cache: { - * inputs : { - * 'user input': { - * response: server response, - * error: validation error - * }, - * current: 'current input' - * } - * } - * - */ - 'asynchronous': function(input, asynchronousFn, updateFn) { - if (!input) return; - var scope = this; - var element = scope.$element; - var cache = element.data('$asyncValidator'); - if (!cache) { - element.data('$asyncValidator', cache = {inputs:{}}); - } - - cache.current = input; - - var inputState = cache.inputs[input], - $invalidWidgets = scope.$service('$invalidWidgets'); - - if (!inputState) { - cache.inputs[input] = inputState = { inFlight: true }; - $invalidWidgets.markInvalid(scope.$element); - element.addClass('ng-input-indicator-wait'); - asynchronousFn(input, function(error, data) { - inputState.response = data; - inputState.error = error; - inputState.inFlight = false; - if (cache.current == input) { - element.removeClass('ng-input-indicator-wait'); - $invalidWidgets.markValid(element); - } - element.data($$validate)(); - }); - } else if (inputState.inFlight) { - // request in flight, mark widget invalid, but don't show it to user - $invalidWidgets.markInvalid(scope.$element); - } else { - (updateFn||noop)(inputState.response); - } - return inputState.error; - } - -}); diff --git a/src/widget/form.js b/src/widget/form.js new file mode 100644 index 00000000..bc34bf0d --- /dev/null +++ b/src/widget/form.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * @workInProgress + * @ngdoc widget + * @name angular.widget.form + * + * @description + * Angular widget that creates a form scope using the + * {@link angular.service.$formFactory $formFactory} API. The resulting form scope instance is + * attached to the DOM element using the jQuery `.data()` method under the `$form` key. + * See {@link guide/dev_guide.forms forms} on detailed discussion of forms and widgets. + * + * + * # Alias: `ng:form` + * + * In angular forms can be nested. This means that the outer form is valid when all of the child + * forms are valid as well. However browsers do not allow nesting of `
    ` elements, for this + * reason angular provides `` alias which behaves identical to `` but allows + * element nesting. + * + * + * @example + + + +
    + + text: + Required! + + text = {{text}}
    + 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('text')).toEqual('guest'); + 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'); + }); + +
    + */ +angularWidget('form', function(form){ + this.descend(true); + this.directives(true); + return annotate('$formFactory', function($formFactory, formElement) { + var name = formElement.attr('name'), + parentForm = $formFactory.forElement(formElement), + form = $formFactory(parentForm); + formElement.data('$form', form); + formElement.bind('submit', function(event){ + event.preventDefault(); + }); + if (name) { + this[name] = form; + } + watch('valid'); + watch('invalid'); + function watch(name) { + form.$watch('$' + name, function(scope, value) { + formElement[value ? 'addClass' : 'removeClass']('ng-' + name); + }); + } + }); +}); + +angularWidget('ng:form', angularWidget('form')); diff --git a/src/widget/input.js b/src/widget/input.js new file mode 100644 index 00000000..f82027f4 --- /dev/null +++ b/src/widget/input.js @@ -0,0 +1,773 @@ +'use strict'; + + +var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; +var EMAIL_REGEXP = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/; +var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; +var INTEGER_REGEXP = /^\s*(\-|\+)?\d+\s*$/; + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.text + * + * @description + * Standard HTML text input with angular data binding. + * + * @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=} required Sets `REQUIRED` validation error key if the value is not entered. + * @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. + * + * @example + + + +
    +
    + Single word: + + Required! + + Single word only! + + text = {{text}}
    + 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('text')).toEqual('guest'); + 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'); + }); + + it('should be invalid if multi word', function() { + input('text').enter('hello world'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + +
    + */ + + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.email + * + * @description + * Text input with email validation. Sets the `EMAIL` validation error key if not a valid email + * address. + * + * @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=} required Sets `REQUIRED` validation error key if the value is not entered. + * @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. + * + * @example + + + +
    +
    + Email: + + Required! + + Not valid email! + + text = {{text}}
    + myForm.input.$valid = {{myForm.input.$valid}}
    + myForm.input.$error = {{myForm.input.$error}}
    + myForm.$valid = {{myForm.$valid}}
    + myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}
    + myForm.$error.EMAIL = {{!!myForm.$error.EMAIL}}
    +
    +
    + + it('should initialize to model', function() { + expect(binding('text')).toEqual('me@example.com'); + 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'); + }); + + it('should be invalid if not email', function() { + input('text').enter('xxx'); + expect(binding('text')).toEqual('xxx'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + +
    + */ +angularInputType('email', function() { + var widget = this; + this.$on('$validate', function(event){ + var value = widget.$viewValue; + widget.$emit(!value || value.match(EMAIL_REGEXP) ? "$valid" : "$invalid", "EMAIL"); + }); +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.url + * + * @description + * Text input with URL validation. Sets the `URL` validation error key if the content is not a + * valid URL. + * + * @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=} required Sets `REQUIRED` validation error key if the value is not entered. + * @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. + * + * @example + + + +
    +
    + URL: + + Required! + + Not valid url! + + text = {{text}}
    + myForm.input.$valid = {{myForm.input.$valid}}
    + myForm.input.$error = {{myForm.input.$error}}
    + myForm.$valid = {{myForm.$valid}}
    + myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}
    + myForm.$error.url = {{!!myForm.$error.url}}
    +
    +
    + + it('should initialize to model', function() { + expect(binding('text')).toEqual('http://google.com'); + 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'); + }); + + it('should be invalid if not url', function() { + input('text').enter('xxx'); + expect(binding('text')).toEqual('xxx'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + +
    + */ +angularInputType('url', function() { + var widget = this; + this.$on('$validate', function(event){ + var value = widget.$viewValue; + widget.$emit(!value || value.match(URL_REGEXP) ? "$valid" : "$invalid", "URL"); + }); +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.list + * + * @description + * Text input that converts between comma-seperated string into an array of strings. + * + * @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=} required Sets `REQUIRED` validation error key if the value is not entered. + * @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. + * + * @example + + + +
    +
    + List: + + Required! + + names = {{names}}
    + 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('names')).toEqual('["igor","misko","vojta"]'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('names').enter(''); + expect(binding('names')).toEqual('[]'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + +
    + */ +angularInputType('list', function() { + function parse(viewValue) { + var list = []; + forEach(viewValue.split(/\s*,\s*/), function(value){ + if (value) list.push(trim(value)); + }); + return list; + } + this.$parseView = function() { + isString(this.$viewValue) && (this.$modelValue = parse(this.$viewValue)); + }; + this.$parseModel = function() { + var modelValue = this.$modelValue; + if (isArray(modelValue) + && (!isString(this.$viewValue) || !equals(parse(this.$viewValue), modelValue))) { + this.$viewValue = modelValue.join(', '); + } + }; +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.number + * + * @description + * Text input with number validation and transformation. Sets the `NUMBER` validation + * error if not a valid number. + * + * @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 {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. + * + * @example + + + +
    +
    + Number: + + Required! + + Not valid number! + + value = {{value}}
    + 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'); + }); + + it('should be invalid if empty', function() { + input('value').enter(''); + expect(binding('value')).toEqual(''); + 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')); + +/** + * @workInProgress + * @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 {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. + * + * @example + + + +
    +
    + Integer: + + Required! + + Not valid integer! + + value = {{value}}
    + 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'); + }); + + it('should be invalid if empty', function() { + input('value').enter('1.2'); + expect(binding('value')).toEqual('12'); + 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('integer', numericRegexpInputType(INTEGER_REGEXP, 'INTEGER')); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.checkbox + * + * @description + * HTML checkbox. + * + * @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=} true-value The value to which the expression should be set when selected. + * @param {string=} false-value The value to which the expression should be set when not selected. + * + * @example + + + +
    +
    + Value1:
    + Value2:
    + + value1 = {{value1}}
    + value2 = {{value2}}
    +
    +
    + + it('should change state', function() { + expect(binding('value1')).toEqual('true'); + expect(binding('value2')).toEqual('YES'); + + input('value1').check(); + input('value2').check(); + expect(binding('value1')).toEqual('false'); + expect(binding('value2')).toEqual('NO'); + }); + +
    + */ +angularInputType('checkbox', function (inputElement) { + var widget = this, + trueValue = inputElement.attr('true-value'), + falseValue = inputElement.attr('false-value'); + + if (!isString(trueValue)) trueValue = true; + if (!isString(falseValue)) falseValue = false; + + inputElement.bind('click', function() { + widget.$apply(function() { + widget.$emit('$viewChange', inputElement[0].checked); + }); + }); + + widget.$render = function() { + inputElement[0].checked = widget.$viewValue; + }; + + widget.$parseModel = function() { + widget.$viewValue = this.$modelValue === trueValue; + }; + + widget.$parseView = function() { + widget.$modelValue = widget.$viewValue ? trueValue : falseValue; + }; + +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.radio + * + * @description + * HTML radio. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string} value The value to which the expression should be set when selected. + * @param {string=} name Property name of the form under which the widgets is published. + * + * @example + + + +
    +
    + Red
    + Green
    + Blue
    + + color = {{color}}
    +
    +
    + + it('should change state', function() { + expect(binding('color')).toEqual('blue'); + + input('color').select('red'); + expect(binding('color')).toEqual('red'); + }); + +
    + */ +angularInputType('radio', function(inputElement) { + var widget = this, + value = inputElement.attr('value'); + + //correct the name + inputElement.attr('name', widget.$id + '@' + inputElement.attr('name')); + inputElement.bind('click', function() { + widget.$apply(function() { + if (inputElement[0].checked) { + widget.$emit('$viewChange', value); + } + }); + }); + + widget.$render = function() { + inputElement[0].checked = value == widget.$viewValue; + }; + + if (inputElement[0].checked) { + widget.$viewValue = value; + } +}); + + +function numericRegexpInputType(regexp, error) { + return function(inputElement) { + var widget = this, + min = 1 * (inputElement.attr('min') || Number.MIN_VALUE), + max = 1 * (inputElement.attr('max') || Number.MAX_VALUE); + + widget.$on('$validate', function(event){ + var value = widget.$viewValue, + filled = value && trim(value) != '', + valid = isString(value) && value.match(regexp); + + widget.$emit(!filled || valid ? "$valid" : "$invalid", error); + filled && (value = 1 * value); + widget.$emit(valid && value < min ? "$invalid" : "$valid", "MIN"); + widget.$emit(valid && value > max ? "$invalid" : "$valid", "MAX"); + }); + + widget.$parseView = function() { + if (widget.$viewValue.match(regexp)) { + widget.$modelValue = 1 * widget.$viewValue; + } else if (widget.$viewValue == '') { + widget.$modelValue = null; + } + }; + + widget.$parseModel = function() { + if (isNumber(widget.$modelValue)) { + widget.$viewValue = '' + widget.$modelValue; + } + }; + }; +} + + +var HTML5_INPUTS_TYPES = makeMap( + "search,tel,url,email,datetime,date,month,week,time,datetime-local,number,range,color," + + "radio,checkbox,text,button,submit,reset,hidden"); + + +/** + * @workInProgress + * @ngdoc widget + * @name angular.widget.input + * + * @description + * HTML input element widget with angular data-binding. Input widget follows HTML5 input types + * and polyfills the HTML5 validation behavior for older browsers. + * + * The {@link angular.inputType custom angular.inputType}s provides a short hand for declaring new + * inputs. This is a shart hand for text-box based inputs, and there is no need to go through the + * full {@link angular.service.$formFactory $formFactory} widget lifecycle. + * + * + * @param {string} type Widget types as defined by {@link angular.inputType}. If the + * type is in the format of `@ScopeType` then `ScopeType` is loaded from the + * current scope, allowing quick definition of type. + * @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=} required Sets `REQUIRED` validation error key if the value is not entered. + * @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. + * + * @example + + + +
    +
    + text: + + Required! + + text = {{text}}
    + 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('text')).toEqual('guest'); + 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'); + }); + +
    + */ +angularWidget('input', function (inputElement){ + this.directives(true); + this.descend(true); + var modelExp = inputElement.attr('ng:model'); + return modelExp && + annotate('$defer', '$formFactory', function($defer, $formFactory, inputElement){ + var form = $formFactory.forElement(inputElement), + // We have to use .getAttribute, since jQuery tries to be smart and use the + // type property. Trouble is some browser change unknown to text. + type = inputElement[0].getAttribute('type') || 'text', + TypeController, + modelScope = this, + patternMatch, widget, + pattern = trim(inputElement.attr('ng:pattern')), + loadFromScope = type.match(/^\s*\@\s*(.*)/); + + + if (!pattern) { + patternMatch = valueFn(true); + } else { + if (pattern.match(/^\/(.*)\/$/)) { + pattern = new RegExp(pattern.substring(1, pattern.length - 2)); + patternMatch = function(value) { + return pattern.test(value); + } + } else { + patternMatch = function(value) { + var patternObj = modelScope.$eval(pattern); + if (!patternObj || !patternObj.test) { + throw new Error('Expected ' + pattern + ' to be a RegExp but was ' + patternObj); + } + return patternObj.test(value); + } + } + } + + type = lowercase(type); + TypeController = (loadFromScope + ? (assertArgFn(this.$eval(loadFromScope[1]), loadFromScope[1])).$unboundFn + : angularInputType(type)) || noop; + + if (!HTML5_INPUTS_TYPES[type]) { + try { + // jquery will not let you so we have to go to bare metal + inputElement[0].setAttribute('type', 'text'); + } catch(e){ + // also turns out that ie8 will not allow changing of types, but since it is not + // html5 anyway we can ignore the error. + } + } + + !TypeController.$inject && (TypeController.$inject = []); + widget = form.$createWidget({ + scope: modelScope, + model: modelExp, + onChange: inputElement.attr('ng:change'), + alias: inputElement.attr('name'), + controller: TypeController, + controllerArgs: [inputElement]}); + + widget.$pattern = + watchElementProperty(this, widget, 'required', inputElement); + watchElementProperty(this, widget, 'readonly', inputElement); + watchElementProperty(this, widget, 'disabled', inputElement); + + + widget.$pristine = !(widget.$dirty = false); + + widget.$on('$validate', function(event) { + var $viewValue = trim(widget.$viewValue); + var inValid = widget.$required && !$viewValue; + var missMatch = $viewValue && !patternMatch($viewValue); + if (widget.$error.REQUIRED != inValid){ + widget.$emit(inValid ? '$invalid' : '$valid', 'REQUIRED'); + } + if (widget.$error.PATTERN != missMatch){ + widget.$emit(missMatch ? '$invalid' : '$valid', 'PATTERN'); + } + }); + + forEach(['valid', 'invalid', 'pristine', 'dirty'], function (name) { + widget.$watch('$' + name, function(scope, value) { + inputElement[value ? 'addClass' : 'removeClass']('ng-' + name); + } + ); + }); + + inputElement.bind('$destroy', function() { + widget.$destroy(); + }); + + if (type != 'checkbox' && type != 'radio') { + // TODO (misko): checkbox / radio does not really belong here, but until we can do + // widget registration with CSS, we are hacking it this way. + widget.$render = function() { + inputElement.val(widget.$viewValue || ''); + }; + + inputElement.bind('keydown change', function(event){ + var key = event.keyCode; + if (/*command*/ key != 91 && + /*modifiers*/ !(15 < key && key < 19) && + /*arrow*/ !(37 < key && key < 40)) { + $defer(function() { + widget.$dirty = !(widget.$pristine = false); + var value = trim(inputElement.val()); + if (widget.$viewValue !== value ) { + widget.$emit('$viewChange', value); + } + }); + } + }); + } + }); + +}); + +angularWidget('textarea', angularWidget('input')); + + +function watchElementProperty(modelScope, widget, name, element) { + var bindAttr = fromJson(element.attr('ng:bind-attr') || '{}'), + match = /\s*{{(.*)}}\s*/.exec(bindAttr[name]); + widget['$' + name] = + // some browsers return true some '' when required is set without value. + isString(element.prop(name)) || !!element.prop(name) || + // this is needed for ie9, since it will treat boolean attributes as false + !!element[0].attributes[name]; + if (bindAttr[name] && match) { + modelScope.$watch(match[1], function(scope, value){ + widget['$' + name] = !!value; + widget.$emit('$validate'); + }); + } +} + diff --git a/src/widget/select.js b/src/widget/select.js new file mode 100644 index 00000000..f397180e --- /dev/null +++ b/src/widget/select.js @@ -0,0 +1,427 @@ +'use strict'; + +/** + * @workInProgress + * @ngdoc widget + * @name angular.widget.select + * + * @description + * HTML `SELECT` element with angular data-binding. + * + * # `ng:options` + * + * Optionally `ng:options` attribute can be used to dynamically generate a list of `
    + + + it('should check ng:options', function(){ + expect(binding('color')).toMatch('red'); + select('color').option('0'); + expect(binding('color')).toMatch('black'); + using('.nullable').select('color').option(''); + expect(binding('color')).toMatch('null'); + }); + +
    + */ + + + //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+(.*)$/; + + +angularWidget('select', function (element){ + this.directives(true); + this.descend(true); + return element.attr('ng:model') && annotate('$formFactory', function($formFactory, selectElement){ + var modelScope = this, + match, + form = $formFactory.forElement(selectElement), + multiple = selectElement.attr('multiple'), + optionsExp = selectElement.attr('ng:options'), + modelExp = selectElement.attr('ng:model'), + widget = form.$createWidget({ + scope: this, + model: modelExp, + onChange: selectElement.attr('ng:change'), + alias: selectElement.attr('name'), + controller: optionsExp ? Options : (multiple ? Multiple : Single)}); + + selectElement.bind('$destroy', function(){ widget.$destroy(); }); + + widget.$pristine = !(widget.$dirty = false); + + watchElementProperty(modelScope, widget, 'required', selectElement); + watchElementProperty(modelScope, widget, 'readonly', selectElement); + watchElementProperty(modelScope, widget, 'disabled', selectElement); + + widget.$on('$validate', function(){ + var valid = !widget.$required || !!widget.$modelValue; + if (valid && multiple && widget.$required) valid = !!widget.$modelValue.length; + if (valid !== !widget.$error.REQUIRED) { + widget.$emit(valid ? '$valid' : '$invalid', 'REQUIRED'); + } + }); + + widget.$on('$viewChange', function(){ + widget.$pristine = !(widget.$dirty = true); + }); + + forEach(['valid', 'invalid', 'pristine', 'dirty'], function (name) { + widget.$watch('$' + name, function(scope, value) { + selectElement[value ? 'addClass' : 'removeClass']('ng-' + name); + }); + }); + + //////////////////////////// + + function Multiple(){ + var widget = this; + + this.$render = function(){ + var items = new HashMap(this.$viewValue); + forEach(selectElement.children(), function(option){ + option.selected = isDefined(items.get(option.value)); + }); + }; + + selectElement.bind('change', function (){ + widget.$apply(function(){ + var array = []; + forEach(selectElement.children(), function(option){ + if (option.selected) { + array.push(option.value); + } + }); + widget.$emit('$viewChange', array); + }); + }); + + } + + function Single(){ + var widget = this; + + 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(){ + var widget = this, + match; + + if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) { + throw Error( + "Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + + " but got '" + optionsExp + "'."); + } + + var widgetScope = this, + displayFn = expressionCompile(match[2] || match[1]), + valueName = match[4] || match[6], + keyName = match[5], + groupByFn = expressionCompile(match[3] || ''), + valueFn = expressionCompile(match[2] ? match[1] : valueName), + valuesFn = expressionCompile(match[7]), + // we can't just jqLite('
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Filter{{input2|json}}
    radioString - <input type="radio" name="input3" value="A">
    - <input type="radio" name="input3" value="B"> -
    - - - {{input3|json}}
    checkboxBoolean<input type="checkbox" name="input4" value="checked">{{input4|json}}
    pulldownString - <select name="input5">
    -   <option value="c">C</option>
    -   <option value="d">D</option>
    - </select>
    -
    - - {{input5|json}}
    multiselectArray - <select name="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('null'); - input('input3').select('A'); - expect(binding('input3')).toEqual('"A"'); - input('input3').select('B'); - expect(binding('input3')).toEqual('"B"'); - }); - 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"]'); - }); - - - */ - -function modelAccessor(scope, element) { - var expr = element.attr('name'); - var exprFn, assignFn; - if (expr) { - exprFn = parser(expr).assignable(); - assignFn = exprFn.assign; - if (!assignFn) throw new Error("Expression '" + expr + "' is not assignable."); - return { - get: function() { - return exprFn(scope); - }, - set: function(value) { - if (value !== undefined) { - assignFn(scope, value); - } - } - }; - } -} - -function modelFormattedAccessor(scope, element) { - var accessor = modelAccessor(scope, element), - formatterName = element.attr('ng:format') || NOOP, - formatter = compileFormatter(formatterName); - if (accessor) { - return { - get: function() { - return formatter.format(scope, accessor.get()); - }, - set: function(value) { - return accessor.set(formatter.parse(scope, value)); - } - }; - } -} - -function compileValidator(expr) { - return parser(expr).validator()(); -} - -function compileFormatter(expr) { - return parser(expr).formatter()(); -} - -/** - * @workInProgress - * @ngdoc widget - * @name angular.widget.@ng:validate - * - * @description - * The `ng:validate` attribute widget validates the user input. If the input does not pass - * validation, the `ng-validation-error` CSS class and the `ng:error` attribute are set on the input - * element. Check out {@link angular.validator validators} to find out more. - * - * @param {string} validator The name of a built-in or custom {@link angular.validator validator} to - * to be used. - * - * @element INPUT - * @css ng-validation-error - * - * @example - * This example shows how the input element becomes red when it contains invalid input. Correct - * the input to make the error disappear. - * - - - I don't validate: -
    - - I need an integer or nothing: -
    -
    - - it('should check ng:validate', function(){ - expect(element('.doc-example-live :input:last').prop('className')). - toMatch(/ng-validation-error/); - - input('value').enter('123'); - expect(element('.doc-example-live :input:last').prop('className')). - not().toMatch(/ng-validation-error/); - }); - -
    - */ -/** - * @workInProgress - * @ngdoc widget - * @name angular.widget.@ng:required - * - * @description - * The `ng:required` attribute widget validates that the user input is present. It is a special case - * of the {@link angular.widget.@ng:validate ng:validate} attribute widget. - * - * @element INPUT - * @css ng-validation-error - * - * @example - * This example shows how the input element becomes red when it contains invalid input. Correct - * the input to make the error disappear. - * - - - I cannot be blank:
    -
    - - it('should check ng:required', function(){ - expect(element('.doc-example-live :input').prop('className')). - toMatch(/ng-validation-error/); - input('value').enter('123'); - expect(element('.doc-example-live :input').prop('className')). - not().toMatch(/ng-validation-error/); - }); - -
    - */ -/** - * @workInProgress - * @ngdoc widget - * @name angular.widget.@ng:format - * - * @description - * The `ng:format` attribute widget formats stored data to user-readable text and parses the text - * back to the stored form. You might find this useful, for example, if you collect user input in a - * text field but need to store the data in the model as a list. Check out - * {@link angular.formatter formatters} to learn more. - * - * @param {string} formatter The name of the built-in or custom {@link angular.formatter formatter} - * to be used. - * - * @element INPUT - * - * @example - * This example shows how the user input is converted from a string and internally represented as an - * array. - * - - - Enter a comma separated list of items: - -
    list={{list}}
    -
    - - it('should check ng:format', function(){ - expect(binding('list')).toBe('list=["table","chairs","plate"]'); - input('list').enter(',,, a ,,,'); - expect(binding('list')).toBe('list=["a"]'); - }); - -
    - */ -function valueAccessor(scope, element) { - var validatorName = element.attr('ng:validate') || NOOP, - validator = compileValidator(validatorName), - requiredExpr = element.attr('ng:required'), - formatterName = element.attr('ng:format') || NOOP, - formatter = compileFormatter(formatterName), - format, parse, lastError, required, - invalidWidgets = scope.$service('$invalidWidgets') || {markValid:noop, markInvalid:noop}; - if (!validator) throw "Validator named '" + validatorName + "' not found."; - format = formatter.format; - parse = formatter.parse; - if (requiredExpr) { - scope.$watch(requiredExpr, function(scope, newValue) { - required = newValue; - validate(); - }); - } else { - required = requiredExpr === ''; - } - - element.data($$validate, validate); - return { - get: function(){ - if (lastError) - elementError(element, NG_VALIDATION_ERROR, null); - try { - var value = parse(scope, element.val()); - validate(); - return value; - } catch (e) { - lastError = e; - elementError(element, NG_VALIDATION_ERROR, e); - } - }, - set: function(value) { - var oldValue = element.val(), - newValue = format(scope, value); - if (oldValue != newValue) { - element.val(newValue || ''); // needed for ie - } - validate(); - } - }; - - function validate() { - var value = trim(element.val()); - if (element[0].disabled || element[0].readOnly) { - elementError(element, NG_VALIDATION_ERROR, null); - invalidWidgets.markValid(element); - } else { - var error, validateScope = inherit(scope, {$element:element}); - error = required && !value - ? 'Required' - : (value ? validator(validateScope, value) : null); - elementError(element, NG_VALIDATION_ERROR, error); - lastError = error; - if (error) { - invalidWidgets.markInvalid(element); - } else { - invalidWidgets.markValid(element); - } - } - } -} - -function checkedAccessor(scope, element) { - var domElement = element[0], elementValue = domElement.value; - return { - get: function(){ - return !!domElement.checked; - }, - set: function(value){ - domElement.checked = toBoolean(value); - } - }; -} - -function radioAccessor(scope, element) { - var domElement = element[0]; - return { - get: function(){ - return domElement.checked ? domElement.value : null; - }, - set: function(value){ - domElement.checked = value == domElement.value; - } - }; -} - -function optionsAccessor(scope, element) { - var formatterName = element.attr('ng:format') || NOOP, - formatter = compileFormatter(formatterName); - return { - get: function(){ - var values = []; - forEach(element[0].options, function(option){ - if (option.selected) values.push(formatter.parse(scope, option.value)); - }); - return values; - }, - set: function(values){ - var keys = {}; - forEach(values, function(value){ - keys[formatter.format(scope, value)] = true; - }); - forEach(element[0].options, function(option){ - option.selected = keys[option.value]; - }); - } - }; -} - -function noopAccessor() { return { get: noop, set: noop }; } - -/* - * TODO: refactor - * - * The table below is not quite right. In some cases the formatter is on the model side - * and in some cases it is on the view side. This is a historical artifact - * - * The concept of model/view accessor is useful for anyone who is trying to develop UI, and - * so it should be exposed to others. There should be a form object which keeps track of the - * accessors and also acts as their factory. It should expose it as an object and allow - * the validator to publish errors to it, so that the the error messages can be bound to it. - * - */ -var textWidget = inputWidget('keydown change', modelAccessor, valueAccessor, initWidgetValue(), true), - INPUT_TYPE = { - 'text': textWidget, - 'textarea': textWidget, - 'hidden': textWidget, - 'password': textWidget, - 'checkbox': inputWidget('click', modelFormattedAccessor, checkedAccessor, initWidgetValue(false)), - 'radio': inputWidget('click', modelFormattedAccessor, radioAccessor, radioInit), - 'select-one': inputWidget('change', modelAccessor, valueAccessor, initWidgetValue(null)), - 'select-multiple': inputWidget('change', modelAccessor, optionsAccessor, initWidgetValue([])) -// 'file': fileWidget??? - }; - - -function initWidgetValue(initValue) { - return function (model, view) { - var value = view.get(); - if (!value && isDefined(initValue)) { - value = copy(initValue); - } - if (isUndefined(model.get()) && isDefined(value)) { - model.set(value); - } - }; -} - -function radioInit(model, view, element) { - var modelValue = model.get(), viewValue = view.get(), input = element[0]; - input.checked = false; - input.name = this.$id + '@' + input.name; - if (isUndefined(modelValue)) { - model.set(modelValue = null); - } - if (modelValue == null && viewValue !== null) { - model.set(viewValue); - } - view.set(modelValue); -} - -/** - * @workInProgress - * @ngdoc directive - * @name angular.directive.ng:change - * - * @description - * The directive executes an expression whenever the input widget changes. - * - * @element INPUT - * @param {expression} expression to execute. - * - * @example - * @example - - -
    - - changeCount {{textCount}}
    - - changeCount {{checkboxCount}}
    -
    - - it('should check ng:change', function(){ - expect(binding('textCount')).toBe('0'); - expect(binding('checkboxCount')).toBe('0'); - - using('.doc-example-live').input('text').enter('abc'); - expect(binding('textCount')).toBe('1'); - expect(binding('checkboxCount')).toBe('0'); - - - using('.doc-example-live').input('checkbox').check(); - expect(binding('textCount')).toBe('1'); - expect(binding('checkboxCount')).toBe('1'); - }); - -
    - */ -function inputWidget(events, modelAccessor, viewAccessor, initFn, textBox) { - return annotate('$defer', function($defer, element) { - var scope = this, - model = modelAccessor(scope, element), - view = viewAccessor(scope, element), - ngChange = element.attr('ng:change') || noop, - lastValue; - if (model) { - initFn.call(scope, model, view, element); - scope.$eval(element.attr('ng:init') || noop); - element.bind(events, function(event){ - function handler(){ - var value = view.get(); - if (!textBox || value != lastValue) { - model.set(value); - lastValue = model.get(); - scope.$eval(ngChange); - } - } - event.type == 'keydown' ? $defer(handler) : scope.$apply(handler); - }); - scope.$watch(model.get, function(scope, value) { - if (!equals(lastValue, value)) { - view.set(lastValue = value); - } - }); - } - }); -} - -function inputWidgetSelector(element){ - this.directives(true); - this.descend(true); - return INPUT_TYPE[lowercase(element[0].type)] || noop; -} - -angularWidget('input', inputWidgetSelector); -angularWidget('textarea', inputWidgetSelector); - - -/** - * @workInProgress - * @ngdoc directive - * @name angular.directive.ng:options - * - * @description - * Dynamically generate a list of `
-
- Color (null not allowed): -
- - Color (null allowed): -
- -

- - Color grouped by shade: -
- - - Select bogus.
-
- Currently selected: {{ {selected_color:color} }} -
-
-
- - - it('should check ng:options', function(){ - expect(binding('color')).toMatch('red'); - select('color').option('0'); - expect(binding('color')).toMatch('black'); - using('.nullable').select('color').option(''); - expect(binding('color')).toMatch('null'); - }); - - - */ -// 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+(.*)$/; -angularWidget('select', function(element){ - this.descend(true); - this.directives(true); - - var isMultiselect = element.attr('multiple'), - expression = element.attr('ng:options'), - onChange = expressionCompile(element.attr('ng:change') || ""), - match; - - if (!expression) { - return inputWidgetSelector.call(this, element); - } - if (! (match = expression.match(NG_OPTIONS_REGEXP))) { - throw Error( - "Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + - " but got '" + expression + "'."); - } - - var displayFn = expressionCompile(match[2] || match[1]), - valueName = match[4] || match[6], - keyName = match[5], - groupByFn = expressionCompile(match[3] || ''), - valueFn = expressionCompile(match[2] ? match[1] : valueName), - valuesFn = expressionCompile(match[7]), - // we can't just jqLite(' - - - - url of the template: {{url}} -
- + +
+ + url of the template: {{template.url}} +
+ +
it('should load template1.html', function(){ - expect(element('.doc-example-live ng\\:include').text()). + expect(element('.doc-example-live .ng-include').text()). toBe('Content of template1.html\n'); }); it('should load template2.html', function(){ - select('url').option('examples/ng-include/template2.html'); - expect(element('.doc-example-live ng\\:include').text()). + select('template').option('1'); + expect(element('.doc-example-live .ng-include').text()). toBe('Content of template2.html\n'); }); it('should change to blank', function(){ - select('url').option(''); - expect(element('.doc-example-live ng\\:include').text()).toEqual(''); + select('template').option(''); + expect(element('.doc-example-live .ng-include').text()).toEqual(''); }); @@ -1064,30 +160,34 @@ angularWidget('ng:include', function(element){ * @example - - switch={{switch}} - - -
Settings Div
- Home Span - default -
- + +
+ + selection={{selection}} +
+ +
Settings Div
+ Home Span + default +
+
it('should start in settings', function(){ expect(element('.doc-example-live ng\\:switch').text()).toEqual('Settings Div'); }); it('should change to home', function(){ - select('switch').option('home'); + select('selection').option('home'); expect(element('.doc-example-live ng\\:switch').text()).toEqual('Home Span'); }); it('should select deafault', function(){ - select('switch').option('other'); + select('selection').option('other'); expect(element('.doc-example-live ng\\:switch').text()).toEqual('default'); }); @@ -1568,27 +668,36 @@ angularWidget('ng:view', function(element) { * @example - Person 1:
- Person 2:
- Number of People:
- - - Without Offset: - -
- - - With Offset(2): - - + +
+ Person 1:
+ Person 2:
+ Number of People:
+ + + Without Offset: + +
+ + + With Offset(2): + + +
it('should show correct pluralized string', function(){ -- cgit v1.2.3