diff options
| author | Igor Minar | 2011-02-15 01:12:45 -0500 |
|---|---|---|
| committer | Igor Minar | 2011-02-15 11:01:53 -0500 |
| commit | 1777110958f76ee4be5760e36c96702223385918 (patch) | |
| tree | 5aa03b246507e66877e5eac69e58e004e244d7a5 | |
| parent | d2089a16335276eecb8d81eb17332c2dff2cf1a2 (diff) | |
| download | angular.js-1777110958f76ee4be5760e36c96702223385918.tar.bz2 | |
split up services into individual files
- split up services into files under src/service
- split up specs into files under test/service
- rewrite all specs so that they don't depend on one global forEach
- get rid of obsolete code and tests in ng:switch
- rename mock $log spec from "$log" to "$log mock"
46 files changed, 2842 insertions, 2642 deletions
@@ -15,7 +15,23 @@ ANGULAR = [ 'src/filters.js', 'src/formatters.js', 'src/validators.js', - 'src/services.js', + 'src/service/cookieStore.js', + 'src/service/cookies.js', + 'src/service/defer.js', + 'src/service/document.js', + 'src/service/exceptionHandler.js', + 'src/service/hover.js', + 'src/service/invalidWidgets.js', + 'src/service/location.js', + 'src/service/log.js', + 'src/service/resource.js', + 'src/service/route.js', + 'src/service/updateView.js', + 'src/service/window.js', + 'src/service/xhr.bulk.js', + 'src/service/xhr.cache.js', + 'src/service/xhr.error.js', + 'src/service/xhr.js', 'src/directives.js', 'src/markups.js', 'src/widgets.js', diff --git a/jsTestDriver-coverage.conf b/jsTestDriver-coverage.conf index 21ee8a2e..b8989811 100644 --- a/jsTestDriver-coverage.conf +++ b/jsTestDriver-coverage.conf @@ -8,6 +8,7 @@ load: - src/Angular.js - src/JSON.js - src/*.js + - src/service/*.js - example/personalLog/*.js - test/testabilityPatch.js - src/scenario/Scenario.js @@ -18,6 +19,7 @@ load: - test/scenario/*.js - test/scenario/output/*.js - test/*.js + - test/service/*.js - example/personalLog/test/*.js exclude: diff --git a/jsTestDriver-jquery.conf b/jsTestDriver-jquery.conf index 217a354a..9ae54022 100644 --- a/jsTestDriver-jquery.conf +++ b/jsTestDriver-jquery.conf @@ -8,6 +8,7 @@ load: - src/Angular.js - src/JSON.js - src/*.js + - src/service/*.js - example/personalLog/*.js - test/testabilityPatch.js - src/scenario/Scenario.js @@ -18,6 +19,7 @@ load: - test/scenario/*.js - test/scenario/output/*.js - test/*.js + - test/service/*.js - example/personalLog/test/*.js exclude: diff --git a/jsTestDriver.conf b/jsTestDriver.conf index 8bb59ef1..204594d4 100644 --- a/jsTestDriver.conf +++ b/jsTestDriver.conf @@ -8,6 +8,7 @@ load: - src/Angular.js - src/JSON.js - src/*.js + - src/service/*.js - example/personalLog/*.js - test/testabilityPatch.js - src/scenario/Scenario.js @@ -18,6 +19,7 @@ load: - test/scenario/*.js - test/scenario/output/*.js - test/*.js + - test/service/*.js - example/personalLog/test/*.js exclude: diff --git a/lib/jsl/jsl.default.conf b/lib/jsl/jsl.default.conf index d3cfe4fe..f4e2af16 100755 --- a/lib/jsl/jsl.default.conf +++ b/lib/jsl/jsl.default.conf @@ -122,7 +122,9 @@ # or "+process Folder\Path\*.htm". # +process src/*.js ++process src/service/*.js +process src/scenario/*.js +process test/*.js ++process test/service/*.js +process test/scenario/*.js diff --git a/src/Injector.js b/src/Injector.js index c7ee6f82..295928bf 100644 --- a/src/Injector.js +++ b/src/Injector.js @@ -76,3 +76,7 @@ function injectService(services, fn) { function injectUpdateView(fn) { return injectService(['$updateView'], fn); } + +function angularServiceInject(name, fn, inject, eager) { + angularService(name, fn, {$inject:inject, $eager:eager}); +} diff --git a/src/angular-bootstrap.js b/src/angular-bootstrap.js index 427c93c8..d6297ccd 100644 --- a/src/angular-bootstrap.js +++ b/src/angular-bootstrap.js @@ -118,7 +118,25 @@ 'AngularPublic.js', // Extension points - 'services.js', + + 'service/cookieStore.js', + 'service/cookies.js', + 'service/defer.js', + 'service/document.js', + 'service/exceptionHandler.js', + 'service/hover.js', + 'service/invalidWidgets.js', + 'service/location.js', + 'service/log.js', + 'service/resource.js', + 'service/route.js', + 'service/updateView.js', + 'service/window.js', + 'service/xhr.bulk.js', + 'service/xhr.cache.js', + 'service/xhr.error.js', + 'service/xhr.js', + 'apis.js', 'filters.js', 'formatters.js', diff --git a/src/service/cookieStore.js b/src/service/cookieStore.js new file mode 100644 index 00000000..089e4578 --- /dev/null +++ b/src/service/cookieStore.js @@ -0,0 +1,64 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$cookieStore + * @requires $cookies + * + * @description + * Provides a key-value (string-object) storage, that is backed by session cookies. + * Objects put or retrieved from this storage are automatically serialized or + * deserialized by angular's toJson/fromJson. + * @example + */ +angularServiceInject('$cookieStore', function($store) { + + return { + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$cookieStore#get + * @methodOf angular.service.$cookieStore + * + * @description + * Returns the value of given cookie key + * + * @param {string} key Id to use for lookup. + * @returns {Object} Deserialized cookie value. + */ + get: function(key) { + return fromJson($store[key]); + }, + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$cookieStore#put + * @methodOf angular.service.$cookieStore + * + * @description + * Sets a value for given cookie key + * + * @param {string} key Id for the `value`. + * @param {Object} value Value to be stored. + */ + put: function(key, value) { + $store[key] = toJson(value); + }, + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$cookieStore#remove + * @methodOf angular.service.$cookieStore + * + * @description + * Remove given cookie + * + * @param {string} key Id of the key-value pair to delete. + */ + remove: function(key) { + delete $store[key]; + } + }; + +}, ['$cookies']); diff --git a/src/service/cookies.js b/src/service/cookies.js new file mode 100644 index 00000000..082b73ab --- /dev/null +++ b/src/service/cookies.js @@ -0,0 +1,89 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$cookies + * @requires $browser + * + * @description + * Provides read/write access to browser's cookies. + * + * Only a simple Object is exposed and by adding or removing properties to/from + * this object, new cookies are created/deleted at the end of current $eval. + * + * @example + */ +angularServiceInject('$cookies', function($browser) { + var rootScope = this, + cookies = {}, + lastCookies = {}, + lastBrowserCookies; + + //creates a poller fn that copies all cookies from the $browser to service & inits the service + $browser.addPollFn(function() { + var currentCookies = $browser.cookies(); + if (lastBrowserCookies != currentCookies) { //relies on browser.cookies() impl + lastBrowserCookies = currentCookies; + copy(currentCookies, lastCookies); + copy(currentCookies, cookies); + rootScope.$eval(); + } + })(); + + //at the end of each eval, push cookies + //TODO: this should happen before the "delayed" watches fire, because if some cookies are not + // strings or browser refuses to store some cookies, we update the model in the push fn. + this.$onEval(PRIORITY_LAST, push); + + return cookies; + + + /** + * Pushes all the cookies from the service to the browser and verifies if all cookies were stored. + */ + function push(){ + var name, + value, + browserCookies, + updated; + + //delete any cookies deleted in $cookies + for (name in lastCookies) { + if (isUndefined(cookies[name])) { + $browser.cookies(name, _undefined); + } + } + + //update all cookies updated in $cookies + for(name in cookies) { + value = cookies[name]; + if (!isString(value)) { + if (isDefined(lastCookies[name])) { + cookies[name] = lastCookies[name]; + } else { + delete cookies[name]; + } + } else if (value !== lastCookies[name]) { + $browser.cookies(name, value); + updated = true; + } + } + + //verify what was actually stored + if (updated){ + updated = false; + browserCookies = $browser.cookies(); + + for (name in cookies) { + if (cookies[name] !== browserCookies[name]) { + //delete or reset all cookies that the browser dropped from $cookies + if (isUndefined(browserCookies[name])) { + delete cookies[name]; + } else { + cookies[name] = browserCookies[name]; + } + updated = true; + } + } + } + } +}, ['$browser']); diff --git a/src/service/defer.js b/src/service/defer.js new file mode 100644 index 00000000..21bf139b --- /dev/null +++ b/src/service/defer.js @@ -0,0 +1,32 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$defer + * @requires $browser + * @requires $exceptionHandler + * @requires $updateView + * + * @description + * Delegates to {@link angular.service.$browser.defer $browser.defer}, but wraps the `fn` function + * into a try/catch block and delegates any exceptions to + * {@link angular.services.$exceptionHandler $exceptionHandler} service. + * + * In tests you can use `$browser.defer.flush()` to flush the queue of deferred functions. + * + * @param {function()} fn A function, who's execution should be deferred. + */ +angularServiceInject('$defer', function($browser, $exceptionHandler, $updateView) { + var scope = this; + + return function(fn) { + $browser.defer(function() { + try { + fn(); + } catch(e) { + $exceptionHandler(e); + } finally { + $updateView(); + } + }); + }; +}, ['$browser', '$exceptionHandler', '$updateView']); diff --git a/src/service/document.js b/src/service/document.js new file mode 100644 index 00000000..93d4d9a5 --- /dev/null +++ b/src/service/document.js @@ -0,0 +1,12 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$document + * @requires $window + * + * @description + * Reference to the browser window.document, but wrapped into angular.element(). + */ +angularServiceInject("$document", function(window){ + return jqLite(window.document); +}, ['$window'], true); diff --git a/src/service/exceptionHandler.js b/src/service/exceptionHandler.js new file mode 100644 index 00000000..dd99a373 --- /dev/null +++ b/src/service/exceptionHandler.js @@ -0,0 +1,22 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$exceptionHandler + * @requires $log + * + * @description + * Any uncaught exception in angular expressions is delegated to this service. + * The default implementation simply delegates to `$log.error` which logs it into + * the browser console. + * + * In unit tests, if `angular-mocks.js` is loaded, this service is overriden by + * {@link angular.mock.service.$exceptionHandler mock $exceptionHandler} + * + * @example + */ +var $exceptionHandlerFactory; //reference to be used only in tests +angularServiceInject('$exceptionHandler', $exceptionHandlerFactory = function($log){ + return function(e) { + $log.error(e); + }; +}, ['$log'], true); diff --git a/src/service/hover.js b/src/service/hover.js new file mode 100644 index 00000000..a7cef71a --- /dev/null +++ b/src/service/hover.js @@ -0,0 +1,56 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$hover + * @requires $browser + * @requires $document + * + * @description + * + * @example + */ +angularServiceInject("$hover", function(browser, document) { + var tooltip, self = this, error, width = 300, arrowWidth = 10, body = jqLite(document[0].body); + browser.hover(function(element, show){ + if (show && (error = element.attr(NG_EXCEPTION) || element.attr(NG_VALIDATION_ERROR))) { + if (!tooltip) { + tooltip = { + callout: jqLite('<div id="ng-callout"></div>'), + arrow: jqLite('<div></div>'), + title: jqLite('<div class="ng-title"></div>'), + content: jqLite('<div class="ng-content"></div>') + }; + tooltip.callout.append(tooltip.arrow); + tooltip.callout.append(tooltip.title); + tooltip.callout.append(tooltip.content); + body.append(tooltip.callout); + } + var docRect = body[0].getBoundingClientRect(), + elementRect = element[0].getBoundingClientRect(), + leftSpace = docRect.right - elementRect.right - arrowWidth; + tooltip.title.text(element.hasClass("ng-exception") ? "EXCEPTION:" : "Validation error..."); + tooltip.content.text(error); + if (leftSpace < width) { + tooltip.arrow.addClass('ng-arrow-right'); + tooltip.arrow.css({left: (width + 1)+'px'}); + tooltip.callout.css({ + position: 'fixed', + left: (elementRect.left - arrowWidth - width - 4) + "px", + top: (elementRect.top - 3) + "px", + width: width + "px" + }); + } else { + tooltip.arrow.addClass('ng-arrow-left'); + tooltip.callout.css({ + position: 'fixed', + left: (elementRect.right + arrowWidth) + "px", + top: (elementRect.top - 3) + "px", + width: width + "px" + }); + } + } else if (tooltip) { + tooltip.callout.remove(); + tooltip = _null; + } + }); +}, ['$browser', '$document'], true); diff --git a/src/service/invalidWidgets.js b/src/service/invalidWidgets.js new file mode 100644 index 00000000..af31d61d --- /dev/null +++ b/src/service/invalidWidgets.js @@ -0,0 +1,67 @@ +/** + * @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.$onEval(PRIORITY_LAST, 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 orphant 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; +}, [], true); diff --git a/src/service/location.js b/src/service/location.js new file mode 100644 index 00000000..31323284 --- /dev/null +++ b/src/service/location.js @@ -0,0 +1,264 @@ +var URL_MATCH = /^(file|ftp|http|https):\/\/(\w+:{0,1}\w*@)?([\w\.-]*)(:([0-9]+))?(\/[^\?#]*)?(\?([^#]*))?(#(.*))?$/, + HASH_MATCH = /^([^\?]*)?(\?([^\?]*))?$/, + DEFAULT_PORTS = {'http': 80, 'https': 443, 'ftp':21}; + +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$location + * @requires $browser + * + * @property {string} href + * @property {string} protocol + * @property {string} host + * @property {number} port + * @property {string} path + * @property {Object.<string|boolean>} search + * @property {string} hash + * @property {string} hashPath + * @property {Object.<string|boolean>} hashSearch + * + * @description + * Parses the browser location url and makes it available to your application. + * Any changes to the url are reflected into $location service and changes to + * $location are reflected to url. + * Notice that using browser's forward/back buttons changes the $location. + * + * @example + <doc:example> + <doc:source> + <a href="#">clear hash</a> | + <a href="#myPath?name=misko">test hash</a><br/> + <input type='text' name="$location.hash"/> + <pre>$location = {{$location}}</pre> + </doc:source> + <doc:scenario> + </doc:scenario> + </doc:example> + */ +angularServiceInject("$location", function($browser) { + var scope = this, + location = {update:update, updateHash: updateHash}, + lastLocation = {}; + + $browser.onHashChange(function() { //register + update($browser.getUrl()); + copy(location, lastLocation); + scope.$eval(); + })(); //initialize + + this.$onEval(PRIORITY_FIRST, sync); + this.$onEval(PRIORITY_LAST, updateBrowser); + + return location; + + // PUBLIC METHODS + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$location#update + * @methodOf angular.service.$location + * + * @description + * Update location object + * Does not immediately update the browser + * Browser is updated at the end of $eval() + * + * @example + <doc:example> + <doc:source> + scope.$location.update('http://www.angularjs.org/path#hash?search=x'); + scope.$location.update({host: 'www.google.com', protocol: 'https'}); + scope.$location.update({hashPath: '/path', hashSearch: {a: 'b', x: true}}); + </doc:source> + <doc:scenario> + </doc:scenario> + </doc:example> + * + * @param {(string|Object)} href Full href as a string or object with properties + */ + function update(href) { + if (isString(href)) { + extend(location, parseHref(href)); + } else { + if (isDefined(href.hash)) { + extend(href, isString(href.hash) ? parseHash(href.hash) : href.hash); + } + + extend(location, href); + + if (isDefined(href.hashPath || href.hashSearch)) { + location.hash = composeHash(location); + } + + location.href = composeHref(location); + } + } + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$location#updateHash + * @methodOf angular.service.$location + * + * @description + * Update location hash part + * @see update() + * + * @example + <doc:example> + <doc:source> + scope.$location.updateHash('/hp') + ==> update({hashPath: '/hp'}) + scope.$location.updateHash({a: true, b: 'val'}) + ==> update({hashSearch: {a: true, b: 'val'}}) + scope.$location.updateHash('/hp', {a: true}) + ==> update({hashPath: '/hp', hashSearch: {a: true}}) + </doc:source> + <doc:scenario> + </doc:scenario> + </doc:example> + * + * @param {(string|Object)} path A hashPath or hashSearch object + * @param {Object=} search A hashSearch object + */ + function updateHash(path, search) { + var hash = {}; + + if (isString(path)) { + hash.hashPath = path; + hash.hashSearch = search || {}; + } else + hash.hashSearch = path; + + hash.hash = composeHash(hash); + + update({hash: hash}); + } + + + // INNER METHODS + + /** + * Synchronizes all location object properties. + * + * User is allowed to change properties, so after property change, + * location object is not in consistent state. + * + * Properties are synced with the following precedence order: + * + * - `$location.href` + * - `$location.hash` + * - everything else + * + * @example + * <pre> + * scope.$location.href = 'http://www.angularjs.org/path#a/b' + * </pre> + * immediately after this call, other properties are still the old ones... + * + * This method checks the changes and update location to the consistent state + */ + function sync() { + if (!equals(location, lastLocation)) { + if (location.href != lastLocation.href) { + update(location.href); + return; + } + if (location.hash != lastLocation.hash) { + var hash = parseHash(location.hash); + updateHash(hash.hashPath, hash.hashSearch); + } else { + location.hash = composeHash(location); + location.href = composeHref(location); + } + update(location.href); + } + } + + + /** + * If location has changed, update the browser + * This method is called at the end of $eval() phase + */ + function updateBrowser() { + sync(); + + if ($browser.getUrl() != location.href) { + $browser.setUrl(location.href); + copy(location, lastLocation); + } + } + + /** + * Compose href string from a location object + * + * @param {Object} loc The location object with all properties + * @return {string} Composed href + */ + function composeHref(loc) { + var url = toKeyValue(loc.search); + var port = (loc.port == DEFAULT_PORTS[loc.protocol] ? _null : loc.port); + + return loc.protocol + '://' + loc.host + + (port ? ':' + port : '') + loc.path + + (url ? '?' + url : '') + (loc.hash ? '#' + loc.hash : ''); + } + + /** + * Compose hash string from location object + * + * @param {Object} loc Object with hashPath and hashSearch properties + * @return {string} Hash string + */ + function composeHash(loc) { + var hashSearch = toKeyValue(loc.hashSearch); + //TODO: temporary fix for issue #158 + return escape(loc.hashPath).replace(/%21/gi, '!').replace(/%3A/gi, ':').replace(/%24/gi, '$') + + (hashSearch ? '?' + hashSearch : ''); + } + + /** + * Parse href string into location object + * + * @param {string} href + * @return {Object} The location object + */ + function parseHref(href) { + var loc = {}; + var match = URL_MATCH.exec(href); + + if (match) { + loc.href = href.replace(/#$/, ''); + loc.protocol = match[1]; + loc.host = match[3] || ''; + loc.port = match[5] || DEFAULT_PORTS[loc.protocol] || _null; + loc.path = match[6] || ''; + loc.search = parseKeyValue(match[8]); + loc.hash = match[10] || ''; + + extend(loc, parseHash(loc.hash)); + } + + return loc; + } + + /** + * Parse hash string into object + * + * @param {string} hash + */ + function parseHash(hash) { + var h = {}; + var match = HASH_MATCH.exec(hash); + + if (match) { + h.hash = hash; + h.hashPath = unescape(match[1] || ''); + h.hashSearch = parseKeyValue(match[3]); + } + + return h; + } +}, ['$browser']); diff --git a/src/service/log.js b/src/service/log.js new file mode 100644 index 00000000..e1e3f2e6 --- /dev/null +++ b/src/service/log.js @@ -0,0 +1,92 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$log + * @requires $window + * + * @description + * Simple service for logging. Default implementation writes the message + * into the browser's console (if present). + * + * The main purpose of this service is to simplify debugging and troubleshooting. + * + * @example + <doc:example> + <doc:source> + <p>Reload this page with open console, enter text and hit the log button...</p> + Message: + <input type="text" name="message" value="Hello World!"/> + <button ng:click="$log.log(message)">log</button> + <button ng:click="$log.warn(message)">warn</button> + <button ng:click="$log.info(message)">info</button> + <button ng:click="$log.error(message)">error</button> + </doc:source> + <doc:scenario> + </doc:scenario> + </doc:example> + */ +var $logFactory; //reference to be used only in tests +angularServiceInject("$log", $logFactory = function($window){ + return { + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$log#log + * @methodOf angular.service.$log + * + * @description + * Write a log message + */ + log: consoleLog('log'), + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$log#warn + * @methodOf angular.service.$log + * + * @description + * Write a warning message + */ + warn: consoleLog('warn'), + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$log#info + * @methodOf angular.service.$log + * + * @description + * Write an information message + */ + info: consoleLog('info'), + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$log#error + * @methodOf angular.service.$log + * + * @description + * Write an error message + */ + error: consoleLog('error') + }; + + function consoleLog(type) { + var console = $window.console || {}; + var logFn = console[type] || console.log || noop; + if (logFn.apply) { + return function(){ + var args = []; + forEach(arguments, function(arg){ + args.push(formatError(arg)); + }); + return logFn.apply(console, args); + }; + } else { + // we are IE, in which case there is nothing we can do + return logFn; + } + } +}, ['$window'], true); diff --git a/src/service/resource.js b/src/service/resource.js new file mode 100644 index 00000000..9e86caa7 --- /dev/null +++ b/src/service/resource.js @@ -0,0 +1,204 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$resource + * @requires $xhr.cache + * + * @description + * Is a factory which creates a resource object that lets you interact with + * [RESTful](http://en.wikipedia.org/wiki/Representational_State_Transfer) server-side data sources. + * + * The returned resource object has action methods which provide high-level behaviors without + * the need to interact with the low level {@link angular.service.$xhr $xhr} service or + * raw XMLHttpRequest. + * + * @param {string} url A parameterized URL template with parameters prefixed by `:` as in + * `/user/:username`. + * + * @param {Object=} paramDefaults Default values for `url` parameters. These can be overridden in + * `actions` methods. + * + * Each key value in the parameter object is first bound to url template if present and then any + * excess keys are appended to the url search query after the `?`. + * + * Given a template `/path/:verb` and parameter `{verb:'greet', salutation:'Hello'}` results in + * URL `/path/greet?salutation=Hello`. + * + * If the parameter value is prefixed with `@` then the value of that parameter is extracted from + * the data object (useful for non-GET operations). + * + * @param {Object.<Object>=} actions Hash with declaration of custom action that should extend the + * default set of resource actions. The declaration should be created in the following format: + * + * {action1: {method:?, params:?, isArray:?, verifyCache:?}, + * action2: {method:?, params:?, isArray:?, verifyCache:?}, + * ...} + * + * Where: + * + * - `action` – {string} – The name of action. This name becomes the name of the method on your + * resource object. + * - `method` – {string} – HTTP request method. Valid methods are: `GET`, `POST`, `PUT`, `DELETE`, + * and `JSON` (also known as JSONP). + * - `params` – {object=} – Optional set of pre-bound parameters for this action. + * - isArray – {boolean=} – If true then the returned object for this action is an array, see + * `returns` section. + * - verifyCache – {boolean=} – If true then whenever cache hit occurs, the object is returned and + * an async request will be made to the server and the resources as well as the cache will be + * updated when the response is received. + * + * @returns {Object} A resource "class" object with methods for the default set of resource actions + * optionally extended with custom `actions`. The default set contains these actions: + * + * { 'get': {method:'GET'}, + * 'save': {method:'POST'}, + * 'query': {method:'GET', isArray:true}, + * 'remove': {method:'DELETE'}, + * 'delete': {method:'DELETE'} }; + * + * Calling these methods invoke an {@link angular.service.$xhr} with the specified http method, + * destination and parameters. When the data is returned from the server then the object is an + * instance of the resource class `save`, `remove` and `delete` actions are available on it as + * methods with the `$` prefix. This allows you to easily perform CRUD operations (create, read, + * update, delete) on server-side data like this: + * <pre> + var User = $resource('/user/:userId', {userId:'@id'}); + var user = User.get({userId:123}, function(){ + user.abc = true; + user.$save(); + }); + </pre> + * + * It is important to realize that invoking a $resource object method immediately returns an + * empty reference (object or array depending on `isArray`). Once the data is returned from the + * server the existing reference is populated with the actual data. This is a useful trick since + * usually the resource is assigned to a model which is then rendered by the view. Having an empty + * object results in no rendering, once the data arrives from the server then the object is + * populated with the data and the view automatically re-renders itself showing the new data. This + * means that in most case one never has to write a callback function for the action methods. + * + * The action methods on the class object or instance object can be invoked with the following + * parameters: + * + * - HTTP GET "class" actions: `Resource.action([parameters], [callback])` + * - non-GET "class" actions: `Resource.action(postData, [parameters], [callback])` + * - non-GET instance actions: `instance.$action([parameters], [callback])` + * + * + * @example + * + * # Credit card resource + * + * <pre> + // Define CreditCard class + var CreditCard = $resource('/user/:userId/card/:cardId', + {userId:123, cardId:'@id'}, { + charge: {method:'POST', params:{charge:true}} + }); + + // We can retrieve a collection from the server + var cards = CreditCard.query(); + // GET: /user/123/card + // server returns: [ {id:456, number:'1234', name:'Smith'} ]; + + var card = cards[0]; + // each item is an instance of CreditCard + expect(card instanceof CreditCard).toEqual(true); + card.name = "J. Smith"; + // non GET methods are mapped onto the instances + card.$save(); + // POST: /user/123/card/456 {id:456, number:'1234', name:'J. Smith'} + // server returns: {id:456, number:'1234', name: 'J. Smith'}; + + // our custom method is mapped as well. + card.$charge({amount:9.99}); + // POST: /user/123/card/456?amount=9.99&charge=true {id:456, number:'1234', name:'J. Smith'} + // server returns: {id:456, number:'1234', name: 'J. Smith'}; + + // we can create an instance as well + var newCard = new CreditCard({number:'0123'}); + newCard.name = "Mike Smith"; + newCard.$save(); + // POST: /user/123/card {number:'0123', name:'Mike Smith'} + // server returns: {id:789, number:'01234', name: 'Mike Smith'}; + expect(newCard.id).toEqual(789); + * </pre> + * + * The object returned from this function execution is a resource "class" which has "static" method + * for each action in the definition. + * + * Calling these methods invoke `$xhr` on the `url` template with the given `method` and `params`. + * When the data is returned from the server then the object is an instance of the resource type and + * all of the non-GET methods are available with `$` prefix. This allows you to easily support CRUD + * operations (create, read, update, delete) on server-side data. + + <pre> + var User = $resource('/user/:userId', {userId:'@id'}); + var user = User.get({userId:123}, function(){ + user.abc = true; + user.$save(); + }); + </pre> + * + * It's worth noting that the callback for `get`, `query` and other method gets passed in the + * response that came from the server, so one could rewrite the above example as: + * + <pre> + var User = $resource('/user/:userId', {userId:'@id'}); + User.get({userId:123}, function(u){ + u.abc = true; + u.$save(); + }); + </pre> + + * # Buzz client + + Let's look at what a buzz client created with the `$resource` service looks like: + <doc:example> + <doc:source> + <script> + function BuzzController($resource) { + this.Activity = $resource( + 'https://www.googleapis.com/buzz/v1/activities/:userId/:visibility/:activityId/:comments', + {alt:'json', callback:'JSON_CALLBACK'}, + {get:{method:'JSON', params:{visibility:'@self'}}, replies: {method:'JSON', params:{visibility:'@self', comments:'@comments'}}} + ); + } + + BuzzController.prototype = { + fetch: function() { + this.activities = this.Activity.get({userId:this.userId}); + }, + expandReplies: function(activity) { + activity.replies = this.Activity.replies({userId:this.userId, activityId:activity.id}); + } + }; + BuzzController.$inject = ['$resource']; + </script> + + <div ng:controller="BuzzController"> + <input name="userId" value="googlebuzz"/> + <button ng:click="fetch()">fetch</button> + <hr/> + <div ng:repeat="item in activities.data.items"> + <h1 style="font-size: 15px;"> + <img src="{{item.actor.thumbnailUrl}}" style="max-height:30px;max-width:30px;"/> + <a href="{{item.actor.profileUrl}}">{{item.actor.name}}</a> + <a href ng:click="expandReplies(item)" style="float: right;">Expand replies: {{item.links.replies[0].count}}</a> + </h1> + {{item.object.content | html}} + <div ng:repeat="reply in item.replies.data.items" style="margin-left: 20px;"> + <img src="{{reply.actor.thumbnailUrl}}" style="max-height:30px;max-width:30px;"/> + <a href="{{reply.actor.profileUrl}}">{{reply.actor.name}}</a>: {{reply.content | html}} + </div> + </div> + </div> + </doc:source> + <doc:scenario> + </doc:scenario> + </doc:example> + */ +angularServiceInject('$resource', function($xhr){ + var resource = new ResourceFactory($xhr); + return bind(resource, resource.route); +}, ['$xhr.cache']); diff --git a/src/service/route.js b/src/service/route.js new file mode 100644 index 00000000..2de484f6 --- /dev/null +++ b/src/service/route.js @@ -0,0 +1,266 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$route + * @requires $location + * + * @property {Object} current Reference to the current route definition. + * @property {Array.<Object>} routes Array of all configured routes. + * + * @description + * Watches `$location.hashPath` and tries to map the hash to an existing route + * definition. It is used for deep-linking URLs to controllers and views (HTML partials). + * + * The `$route` service is typically used in conjunction with {@link angular.widget.ng:view ng:view} + * widget. + * + * @example + This example shows how changing the URL hash causes the <tt>$route</tt> + to match a route against the URL, and the <tt>[[ng:include]]</tt> pulls in the partial. + Try changing the URL in the input box to see changes. + + <doc:example> + <doc:source> + <script> + angular.service('myApp', function($route) { + $route.when('/Book/:bookId', {template:'rsrc/book.html', controller:BookCntl}); + $route.when('/Book/:bookId/ch/:chapterId', {template:'rsrc/chapter.html', controller:ChapterCntl}); + $route.onChange(function() { + $route.current.scope.params = $route.current.params; + }); + }, {$inject: ['$route']}); + + function BookCntl() { + this.name = "BookCntl"; + } + + function ChapterCntl() { + this.name = "ChapterCntl"; + } + </script> + + Chose: + <a href="#/Book/Moby">Moby</a> | + <a href="#/Book/Moby/ch/1">Moby: Ch1</a> | + <a href="#/Book/Gatsby">Gatsby</a> | + <a href="#/Book/Gatsby/ch/4?key=value">Gatsby: Ch4</a><br/> + <input type="text" name="$location.hashPath" size="80" /> + <pre>$location={{$location}}</pre> + <pre>$route.current.template={{$route.current.template}}</pre> + <pre>$route.current.params={{$route.current.params}}</pre> + <pre>$route.current.scope.name={{$route.current.scope.name}}</pre> + <hr/> + <ng:include src="$route.current.template" scope="$route.current.scope"/> + </doc:source> + <doc:scenario> + </doc:scenario> + </doc:example> + */ +angularServiceInject('$route', function(location, $updateView) { + var routes = {}, + onChange = [], + matcher = switchRouteMatcher, + parentScope = this, + dirty = 0, + $route = { + routes: routes, + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$route#onChange + * @methodOf angular.service.$route + * + * @param {function()} fn Function that will be called when `$route.current` changes. + * @returns {function()} The registered function. + * + * @description + * Register a handler function that will be called when route changes + */ + onChange: function(fn) { + onChange.push(fn); + return fn; + }, + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$route#parent + * @methodOf angular.service.$route + * + * @param {Scope} [scope=rootScope] Scope to be used as parent for newly created + * `$route.current.scope` scopes. + * + * @description + * Sets a scope to be used as the parent scope for scopes created on route change. If not + * set, defaults to the root scope. + */ + parent: function(scope) { + if (scope) parentScope = scope; + }, + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$route#when + * @methodOf angular.service.$route + * + * @param {string} path Route path (matched against `$location.hash`) + * @param {Object} params Mapping information to be assigned to `$route.current` on route + * match. + * + * Object properties: + * + * - `controller` – `{function()=}` – Controller fn that should be associated with newly + * created scope. + * - `template` – `{string=}` – path to an html template that should be used by + * {@link angular.widget.ng:view ng:view} or + * {@link angular.widget.ng:include ng:include} widgets. + * - `redirectTo` – {(string|function())=} – value to update + * {@link angular.service.$location $location} hash with and trigger route redirection. + * + * If `redirectTo` is a function, it will be called with the following parameters: + * + * - `{Object.<string>}` - route parameters extracted from the current + * `$location.hashPath` by applying the current route template. + * - `{string}` - current `$location.hash` + * - `{string}` - current `$location.hashPath` + * - `{string}` - current `$location.hashSearch` + * + * The custom `redirectTo` function is expected to return a string which will be used + * to update `$location.hash`. + * + * @returns {Object} route object + * + * @description + * Adds a new route definition to the `$route` service. + */ + when:function (path, params) { + if (isUndefined(path)) return routes; //TODO(im): remove - not needed! + var route = routes[path]; + if (!route) route = routes[path] = {}; + if (params) extend(route, params); + dirty++; + return route; + }, + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$route#otherwise + * @methodOf angular.service.$route + * + * @description + * Sets route definition that will be used on route change when no other route definition + * is matched. + * + * @param {Object} params Mapping information to be assigned to `$route.current`. + */ + otherwise: function(params) { + $route.when(null, params); + }, + + /** + * @workInProgress + * @ngdoc method + * @name angular.service.$route#reload + * @methodOf angular.service.$route + * + * @description + * Causes `$route` service to reload (and recreate the `$route.current` scope) upon the next + * eval even if {@link angular.service.$location $location} hasn't changed. + */ + reload: function() { + dirty++; + } + }; + + + function switchRouteMatcher(on, when, dstName) { + var regex = '^' + when.replace(/[\.\\\(\)\^\$]/g, "\$1") + '$', + params = [], + dst = {}; + forEach(when.split(/\W/), function(param){ + if (param) { + var paramRegExp = new RegExp(":" + param + "([\\W])"); + if (regex.match(paramRegExp)) { + regex = regex.replace(paramRegExp, "([^\/]*)$1"); + params.push(param); + } + } + }); + var match = on.match(new RegExp(regex)); + if (match) { + forEach(params, function(name, index){ + dst[name] = match[index + 1]; + }); + if (dstName) this.$set(dstName, dst); + } + return match ? dst : _null; + } + + + function updateRoute(){ + var childScope, routeParams, pathParams, segmentMatch, key, redir; + + $route.current = _null; + forEach(routes, function(rParams, rPath) { + if (!pathParams) { + if (pathParams = matcher(location.hashPath, rPath)) { + routeParams = rParams; + } + } + }); + + // "otherwise" fallback + routeParams = routeParams || routes[_null]; + + if(routeParams) { + if (routeParams.redirectTo) { + if (isString(routeParams.redirectTo)) { + // interpolate the redirectTo string + redir = {hashPath: '', + hashSearch: extend({}, location.hashSearch, pathParams)}; + + forEach(routeParams.redirectTo.split(':'), function(segment, i) { + if (i==0) { + redir.hashPath += segment; + } else { + segmentMatch = segment.match(/(\w+)(.*)/); + key = segmentMatch[1]; + redir.hashPath += pathParams[key] || location.hashSearch[key]; + redir.hashPath += segmentMatch[2] || ''; + delete redir.hashSearch[key]; + } + }); + } else { + // call custom redirectTo function + redir = {hash: routeParams.redirectTo(pathParams, location.hash, location.hashPath, + location.hashSearch)}; + } + + location.update(redir); + $updateView(); //TODO this is to work around the $location<=>$browser issues + return; + } + + childScope = createScope(parentScope); + $route.current = extend({}, routeParams, { + scope: childScope, + params: extend({}, location.hashSearch, pathParams) + }); + } + + //fire onChange callbacks + forEach(onChange, parentScope.$tryEval); + + if (childScope) { + childScope.$become($route.current.controller); + } + } + + + this.$watch(function(){return dirty + location.hash;}, updateRoute); + + return $route; +}, ['$location', '$updateView']); diff --git a/src/service/updateView.js b/src/service/updateView.js new file mode 100644 index 00000000..603cfa5a --- /dev/null +++ b/src/service/updateView.js @@ -0,0 +1,59 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$updateView + * @requires $browser + * + * @description + * Calling `$updateView` enqueues the eventual update of the view. (Update the DOM to reflect the + * model). The update is eventual, since there are often multiple updates to the model which may + * be deferred. The default update delayed is 25 ms. This means that the view lags the model by + * that time. (25ms is small enough that it is perceived as instantaneous by the user). The delay + * can be adjusted by setting the delay property of the service. + * + * <pre>angular.service('$updateView').delay = 10</pre> + * + * The delay is there so that multiple updates to the model which occur sufficiently close + * together can be merged into a single update. + * + * You don't usually call '$updateView' directly since angular does it for you in most cases, + * but there are some cases when you need to call it. + * + * - `$updateView()` called automatically by angular: + * - Your Application Controllers: Your controller code is called by angular and hence + * angular is aware that you may have changed the model. + * - Your Services: Your service is usually called by your controller code, hence same rules + * apply. + * - May need to call `$updateView()` manually: + * - Widgets / Directives: If you listen to any DOM events or events on any third party + * libraries, then angular is not aware that you may have changed state state of the + * model, and hence you need to call '$updateView()' manually. + * - 'setTimeout'/'XHR': If you call 'setTimeout' (instead of {@link angular.service.$defer}) + * or 'XHR' (instead of {@link angular.service.$xhr}) then you may be changing the model + * without angular knowledge and you may need to call '$updateView()' directly. + * + * NOTE: if you wish to update the view immediately (without delay), you can do so by calling + * {@link scope.$eval} at any time from your code: + * <pre>scope.$root.$eval()</pre> + * + * In unit-test mode the update is instantaneous and synchronous to simplify writing tests. + * + */ + +function serviceUpdateViewFactory($browser){ + var rootScope = this; + var scheduled; + function update(){ + scheduled = false; + rootScope.$eval(); + } + return $browser.isMock ? update : function(){ + if (!scheduled) { + scheduled = true; + $browser.defer(update, serviceUpdateViewFactory.delay); + } + }; +} +serviceUpdateViewFactory.delay = 25; + +angularServiceInject('$updateView', serviceUpdateViewFactory, ['$browser']); diff --git a/src/service/window.js b/src/service/window.js new file mode 100644 index 00000000..2392e7f9 --- /dev/null +++ b/src/service/window.js @@ -0,0 +1,25 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$window + * + * @description + * Is reference to the browser's `window` object. While `window` + * is globally available in JavaScript, it causes testability problems, because + * it is a global variable. In angular we always refer to it through the + * `$window` service, so it may be overriden, removed or mocked for testing. + * + * All expressions are evaluated with respect to current scope so they don't + * suffer from window globality. + * + * @example + <doc:example> + <doc:source> + <input ng:init="$window = $service('$window'); greeting='Hello World!'" type="text" name="greeting" /> + <button ng:click="$window.alert(greeting)">ALERT</button> + </doc:source> + <doc:scenario> + </doc:scenario> + </doc:example> + */ +angularServiceInject("$window", bind(window, identity, window), [], true); diff --git a/src/service/xhr.bulk.js b/src/service/xhr.bulk.js new file mode 100644 index 00000000..9933aa7e --- /dev/null +++ b/src/service/xhr.bulk.js @@ -0,0 +1,61 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$xhr.bulk + * @requires $xhr + * @requires $xhr.error + * @requires $log + * + * @description + * + * @example + */ +angularServiceInject('$xhr.bulk', function($xhr, $error, $log){ + var requests = [], + scope = this; + function bulkXHR(method, url, post, callback) { + if (isFunction(post)) { + callback = post; + post = _null; + } + var currentQueue; + forEach(bulkXHR.urls, function(queue){ + if (isFunction(queue.match) ? queue.match(url) : queue.match.exec(url)) { + currentQueue = queue; + } + }); + if (currentQueue) { + if (!currentQueue.requests) currentQueue.requests = []; + currentQueue.requests.push({method: method, url: url, data:post, callback:callback}); + } else { + $xhr(method, url, post, callback); + } + } + bulkXHR.urls = {}; + bulkXHR.flush = function(callback){ + forEach(bulkXHR.urls, function(queue, url){ + var currentRequests = queue.requests; + if (currentRequests && currentRequests.length) { + queue.requests = []; + queue.callbacks = []; + $xhr('POST', url, {requests:currentRequests}, function(code, response){ + forEach(response, function(response, i){ + try { + if (response.status == 200) { + (currentRequests[i].callback || noop)(response.status, response.response); + } else { + $error(currentRequests[i], response); + } + } catch(e) { + $log.error(e); + } + }); + (callback || noop)(); + }); + scope.$eval(); + } + }); + }; + this.$onEval(PRIORITY_LAST, bulkXHR.flush); + return bulkXHR; +}, ['$xhr', '$xhr.error', '$log']); diff --git a/src/service/xhr.cache.js b/src/service/xhr.cache.js new file mode 100644 index 00000000..e87b127b --- /dev/null +++ b/src/service/xhr.cache.js @@ -0,0 +1,66 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$xhr.cache + * @function + * @requires $xhr + * + * @description + * Acts just like the {@link angular.service.$xhr $xhr} service but caches responses for `GET` + * requests. All cache misses are delegated to the $xhr service. + * + * @property {function()} delegate Function to delegate all the cache misses to. Defaults to + * the {@link angular.service.$xhr $xhr} service. + * @property {object} data The hashmap where all cached entries are stored. + * + * @param {string} method HTTP method. + * @param {string} url Destination URL. + * @param {(string|Object)=} post Request body. + * @param {function(number, (string|Object))} callback Response callback. + * @param {boolean=} [verifyCache=false] If `true` then a result is immediately returned from cache + * (if present) while a request is sent to the server for a fresh response that will update the + * cached entry. The `callback` function will be called when the response is received. + */ +angularServiceInject('$xhr.cache', function($xhr, $defer, $log){ + var inflight = {}, self = this; + function cache(method, url, post, callback, verifyCache){ + if (isFunction(post)) { + callback = post; + post = _null; + } + if (method == 'GET') { + var data, dataCached; + if (dataCached = cache.data[url]) { + $defer(function() { callback(200, copy(dataCached.value)); }); + if (!verifyCache) + return; + } + + if (data = inflight[url]) { + data.callbacks.push(callback); + } else { + inflight[url] = {callbacks: [callback]}; + cache.delegate(method, url, post, function(status, response){ + if (status == 200) + cache.data[url] = { value: response }; + var callbacks = inflight[url].callbacks; + delete inflight[url]; + forEach(callbacks, function(callback){ + try { + (callback||noop)(status, copy(response)); + } catch(e) { + $log.error(e); + } + }); + }); + } + + } else { + cache.data = {}; + cache.delegate(method, url, post, callback); + } + } + cache.data = {}; + cache.delegate = $xhr; + return cache; +}, ['$xhr.bulk', '$defer', '$log']); diff --git a/src/service/xhr.error.js b/src/service/xhr.error.js new file mode 100644 index 00000000..7f8e4a19 --- /dev/null +++ b/src/service/xhr.error.js @@ -0,0 +1,41 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$xhr.error + * @function + * @requires $log + * + * @description + * Error handler for {@link angular.service.$xhr $xhr service}. An application can replaces this + * service with one specific for the application. The default implementation logs the error to + * {@link angular.service.$log $log.error}. + * + * @param {Object} request Request object. + * + * The object has the following properties + * + * - `method` – `{string}` – The http request method. + * - `url` – `{string}` – The request destination. + * - `data` – `{(string|Object)=} – An optional request body. + * - `callback` – `{function()}` – The callback function + * + * @param {Object} response Response object. + * + * The response object has the following properties: + * + * - status – {number} – Http status code. + * - body – {string|Object} – Body of the response. + * + * @example + <doc:example> + <doc:source> + fetch a non-existent file and log an error in the console: + <button ng:click="$service('$xhr')('GET', '/DOESNT_EXIST')">fetch</button> + </doc:source> + </doc:example> + */ +angularServiceInject('$xhr.error', function($log){ + return function(request, response){ + $log.error('ERROR: XHR: ' + request.url, request, response); + }; +}, ['$log']); diff --git a/src/service/xhr.js b/src/service/xhr.js new file mode 100644 index 00000000..2f003398 --- /dev/null +++ b/src/service/xhr.js @@ -0,0 +1,99 @@ +/** + * @workInProgress + * @ngdoc service + * @name angular.service.$xhr + * @function + * @requires $browser + * @requires $xhr.error + * @requires $log + * + * @description + * Generates an XHR request. The $xhr service adds error handling then delegates all requests to + * {@link angular.service.$browser $browser.xhr()}. + * + * @param {string} method HTTP method to use. Valid values are: `GET`, `POST`, `PUT`, `DELETE`, and + * `JSON`. `JSON` is a special case which causes a + * [JSONP](http://en.wikipedia.org/wiki/JSON#JSONP) cross domain request using script tag + * insertion. + * @param {string} url Relative or absolute URL specifying the destination of the request. For + * `JSON` requests, `url` should include `JSON_CALLBACK` string to be replaced with a name of an + * angular generated callback function. + * @param {(string|Object)=} post Request content as either a string or an object to be stringified + * as JSON before sent to the server. + * @param {function(number, (string|Object))} callback A function to be called when the response is + * received. The callback will be called with: + * + * - {number} code [HTTP status code](http://en.wikipedia.org/wiki/List_of_HTTP_status_codes) of + * the response. This will currently always be 200, since all non-200 responses are routed to + * {@link angular.service.$xhr.error} service. + * - {string|Object} response Response object as string or an Object if the response was in JSON + * format. + * + * @example + <doc:example> + <doc:source> + <script> + function FetchCntl($xhr) { + var self = this; + + this.fetch = function() { + self.clear(); + $xhr(self.method, self.url, function(code, response) { + self.code = code; + self.response = response; + }); + }; + + this.clear = function() { + self.code = null; + self.response = null; + }; + } + FetchCntl.$inject = ['$xhr']; + </script> + <div ng:controller="FetchCntl"> + <select name="method"> + <option>GET</option> + <option>JSON</option> + </select> + <input type="text" name="url" value="index.html" size="80"/><br/> + <button ng:click="fetch()">fetch</button> + <button ng:click="clear()">clear</button> + <a href="" ng:click="method='GET'; url='index.html'">sample</a> + <a href="" ng:click="method='JSON'; url='https://www.googleapis.com/buzz/v1/activities/googlebuzz/@self?alt=json&callback=JSON_CALLBACK'">buzz</a> + <pre>code={{code}}</pre> + <pre>response={{response}}</pre> + </div> + </doc:source> + </doc:example> + */ +angularServiceInject('$xhr', function($browser, $error, $log){ + var self = this; + return function(method, url, post, callback){ + if (isFunction(post)) { + callback = post; + post = _null; + } + if (post && isObject(post)) { + post = toJson(post); + } + $browser.xhr(method, url, post, function(code, response){ + try { + if (isString(response) && /^\s*[\[\{]/.exec(response) && /[\}\]]\s*$/.exec(response)) { + response = fromJson(response, true); + } + if (code == 200) { + callback(code, response); + } else { + $error( + {method: method, url:url, data:post, callback:callback}, + {status: code, body:response}); + } + } catch (e) { + $log.error(e); + } finally { + self.$eval(); + } + }); + }; +}, ['$browser', '$xhr.error', '$log']); diff --git a/src/services.js b/src/services.js deleted file mode 100644 index be5189d1..00000000 --- a/src/services.js +++ /dev/null @@ -1,1541 +0,0 @@ -var URL_MATCH = /^(file|ftp|http|https):\/\/(\w+:{0,1}\w*@)?([\w\.-]*)(:([0-9]+))?(\/[^\?#]*)?(\?([^#]*))?(#(.*))?$/, - HASH_MATCH = /^([^\?]*)?(\?([^\?]*))?$/, - DEFAULT_PORTS = {'http': 80, 'https': 443, 'ftp':21}, - EAGER = true; - -function angularServiceInject(name, fn, inject, eager) { - angularService(name, fn, {$inject:inject, $eager:eager}); -} - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$window - * - * @description - * Is reference to the browser's `window` object. While `window` - * is globally available in JavaScript, it causes testability problems, because - * it is a global variable. In angular we always refer to it through the - * `$window` service, so it may be overriden, removed or mocked for testing. - * - * All expressions are evaluated with respect to current scope so they don't - * suffer from window globality. - * - * @example - <doc:example> - <doc:source> - <input ng:init="$window = $service('$window'); greeting='Hello World!'" type="text" name="greeting" /> - <button ng:click="$window.alert(greeting)">ALERT</button> - </doc:source> - <doc:scenario> - </doc:scenario> - </doc:example> - */ -angularServiceInject("$window", bind(window, identity, window), [], EAGER); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$document - * @requires $window - * - * @description - * Reference to the browser window.document, but wrapped into angular.element(). - */ -angularServiceInject("$document", function(window){ - return jqLite(window.document); -}, ['$window'], EAGER); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$location - * @requires $browser - * - * @property {string} href - * @property {string} protocol - * @property {string} host - * @property {number} port - * @property {string} path - * @property {Object.<string|boolean>} search - * @property {string} hash - * @property {string} hashPath - * @property {Object.<string|boolean>} hashSearch - * - * @description - * Parses the browser location url and makes it available to your application. - * Any changes to the url are reflected into $location service and changes to - * $location are reflected to url. - * Notice that using browser's forward/back buttons changes the $location. - * - * @example - <doc:example> - <doc:source> - <a href="#">clear hash</a> | - <a href="#myPath?name=misko">test hash</a><br/> - <input type='text' name="$location.hash"/> - <pre>$location = {{$location}}</pre> - </doc:source> - <doc:scenario> - </doc:scenario> - </doc:example> - */ -angularServiceInject("$location", function($browser) { - var scope = this, - location = {update:update, updateHash: updateHash}, - lastLocation = {}; - - $browser.onHashChange(function() { //register - update($browser.getUrl()); - copy(location, lastLocation); - scope.$eval(); - })(); //initialize - - this.$onEval(PRIORITY_FIRST, sync); - this.$onEval(PRIORITY_LAST, updateBrowser); - - return location; - - // PUBLIC METHODS - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$location#update - * @methodOf angular.service.$location - * - * @description - * Update location object - * Does not immediately update the browser - * Browser is updated at the end of $eval() - * - * @example - <doc:example> - <doc:source> - scope.$location.update('http://www.angularjs.org/path#hash?search=x'); - scope.$location.update({host: 'www.google.com', protocol: 'https'}); - scope.$location.update({hashPath: '/path', hashSearch: {a: 'b', x: true}}); - </doc:source> - <doc:scenario> - </doc:scenario> - </doc:example> - * - * @param {(string|Object)} href Full href as a string or object with properties - */ - function update(href) { - if (isString(href)) { - extend(location, parseHref(href)); - } else { - if (isDefined(href.hash)) { - extend(href, isString(href.hash) ? parseHash(href.hash) : href.hash); - } - - extend(location, href); - - if (isDefined(href.hashPath || href.hashSearch)) { - location.hash = composeHash(location); - } - - location.href = composeHref(location); - } - } - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$location#updateHash - * @methodOf angular.service.$location - * - * @description - * Update location hash part - * @see update() - * - * @example - <doc:example> - <doc:source> - scope.$location.updateHash('/hp') - ==> update({hashPath: '/hp'}) - scope.$location.updateHash({a: true, b: 'val'}) - ==> update({hashSearch: {a: true, b: 'val'}}) - scope.$location.updateHash('/hp', {a: true}) - ==> update({hashPath: '/hp', hashSearch: {a: true}}) - </doc:source> - <doc:scenario> - </doc:scenario> - </doc:example> - * - * @param {(string|Object)} path A hashPath or hashSearch object - * @param {Object=} search A hashSearch object - */ - function updateHash(path, search) { - var hash = {}; - - if (isString(path)) { - hash.hashPath = path; - hash.hashSearch = search || {}; - } else - hash.hashSearch = path; - - hash.hash = composeHash(hash); - - update({hash: hash}); - } - - - // INNER METHODS - - /** - * Synchronizes all location object properties. - * - * User is allowed to change properties, so after property change, - * location object is not in consistent state. - * - * Properties are synced with the following precedence order: - * - * - `$location.href` - * - `$location.hash` - * - everything else - * - * @example - * <pre> - * scope.$location.href = 'http://www.angularjs.org/path#a/b' - * </pre> - * immediately after this call, other properties are still the old ones... - * - * This method checks the changes and update location to the consistent state - */ - function sync() { - if (!equals(location, lastLocation)) { - if (location.href != lastLocation.href) { - update(location.href); - return; - } - if (location.hash != lastLocation.hash) { - var hash = parseHash(location.hash); - updateHash(hash.hashPath, hash.hashSearch); - } else { - location.hash = composeHash(location); - location.href = composeHref(location); - } - update(location.href); - } - } - - - /** - * If location has changed, update the browser - * This method is called at the end of $eval() phase - */ - function updateBrowser() { - sync(); - - if ($browser.getUrl() != location.href) { - $browser.setUrl(location.href); - copy(location, lastLocation); - } - } - - /** - * Compose href string from a location object - * - * @param {Object} loc The location object with all properties - * @return {string} Composed href - */ - function composeHref(loc) { - var url = toKeyValue(loc.search); - var port = (loc.port == DEFAULT_PORTS[loc.protocol] ? _null : loc.port); - - return loc.protocol + '://' + loc.host + - (port ? ':' + port : '') + loc.path + - (url ? '?' + url : '') + (loc.hash ? '#' + loc.hash : ''); - } - - /** - * Compose hash string from location object - * - * @param {Object} loc Object with hashPath and hashSearch properties - * @return {string} Hash string - */ - function composeHash(loc) { - var hashSearch = toKeyValue(loc.hashSearch); - //TODO: temporary fix for issue #158 - return escape(loc.hashPath).replace(/%21/gi, '!').replace(/%3A/gi, ':').replace(/%24/gi, '$') + - (hashSearch ? '?' + hashSearch : ''); - } - - /** - * Parse href string into location object - * - * @param {string} href - * @return {Object} The location object - */ - function parseHref(href) { - var loc = {}; - var match = URL_MATCH.exec(href); - - if (match) { - loc.href = href.replace(/#$/, ''); - loc.protocol = match[1]; - loc.host = match[3] || ''; - loc.port = match[5] || DEFAULT_PORTS[loc.protocol] || _null; - loc.path = match[6] || ''; - loc.search = parseKeyValue(match[8]); - loc.hash = match[10] || ''; - - extend(loc, parseHash(loc.hash)); - } - - return loc; - } - - /** - * Parse hash string into object - * - * @param {string} hash - */ - function parseHash(hash) { - var h = {}; - var match = HASH_MATCH.exec(hash); - - if (match) { - h.hash = hash; - h.hashPath = unescape(match[1] || ''); - h.hashSearch = parseKeyValue(match[3]); - } - - return h; - } -}, ['$browser']); - - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$log - * @requires $window - * - * @description - * Simple service for logging. Default implementation writes the message - * into the browser's console (if present). - * - * The main purpose of this service is to simplify debugging and troubleshooting. - * - * @example - <doc:example> - <doc:source> - <p>Reload this page with open console, enter text and hit the log button...</p> - Message: - <input type="text" name="message" value="Hello World!"/> - <button ng:click="$log.log(message)">log</button> - <button ng:click="$log.warn(message)">warn</button> - <button ng:click="$log.info(message)">info</button> - <button ng:click="$log.error(message)">error</button> - </doc:source> - <doc:scenario> - </doc:scenario> - </doc:example> - */ -var $logFactory; //reference to be used only in tests -angularServiceInject("$log", $logFactory = function($window){ - return { - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$log#log - * @methodOf angular.service.$log - * - * @description - * Write a log message - */ - log: consoleLog('log'), - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$log#warn - * @methodOf angular.service.$log - * - * @description - * Write a warning message - */ - warn: consoleLog('warn'), - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$log#info - * @methodOf angular.service.$log - * - * @description - * Write an information message - */ - info: consoleLog('info'), - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$log#error - * @methodOf angular.service.$log - * - * @description - * Write an error message - */ - error: consoleLog('error') - }; - - function consoleLog(type) { - var console = $window.console || {}; - var logFn = console[type] || console.log || noop; - if (logFn.apply) { - return function(){ - var args = []; - forEach(arguments, function(arg){ - args.push(formatError(arg)); - }); - return logFn.apply(console, args); - }; - } else { - // we are IE, in which case there is nothing we can do - return logFn; - } - } -}, ['$window'], EAGER); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$exceptionHandler - * @requires $log - * - * @description - * Any uncaught exception in angular expressions is delegated to this service. - * The default implementation simply delegates to `$log.error` which logs it into - * the browser console. - * - * In unit tests, if `angular-mocks.js` is loaded, this service is overriden by - * {@link angular.mock.service.$exceptionHandler mock $exceptionHandler} - * - * @example - */ -var $exceptionHandlerFactory; //reference to be used only in tests -angularServiceInject('$exceptionHandler', $exceptionHandlerFactory = function($log){ - return function(e) { - $log.error(e); - }; -}, ['$log'], EAGER); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$updateView - * @requires $browser - * - * @description - * Calling `$updateView` enqueues the eventual update of the view. (Update the DOM to reflect the - * model). The update is eventual, since there are often multiple updates to the model which may - * be deferred. The default update delayed is 25 ms. This means that the view lags the model by - * that time. (25ms is small enough that it is perceived as instantaneous by the user). The delay - * can be adjusted by setting the delay property of the service. - * - * <pre>angular.service('$updateView').delay = 10</pre> - * - * The delay is there so that multiple updates to the model which occur sufficiently close - * together can be merged into a single update. - * - * You don't usually call '$updateView' directly since angular does it for you in most cases, - * but there are some cases when you need to call it. - * - * - `$updateView()` called automatically by angular: - * - Your Application Controllers: Your controller code is called by angular and hence - * angular is aware that you may have changed the model. - * - Your Services: Your service is usually called by your controller code, hence same rules - * apply. - * - May need to call `$updateView()` manually: - * - Widgets / Directives: If you listen to any DOM events or events on any third party - * libraries, then angular is not aware that you may have changed state state of the - * model, and hence you need to call '$updateView()' manually. - * - 'setTimeout'/'XHR': If you call 'setTimeout' (instead of {@link angular.service.$defer}) - * or 'XHR' (instead of {@link angular.service.$xhr}) then you may be changing the model - * without angular knowledge and you may need to call '$updateView()' directly. - * - * NOTE: if you wish to update the view immediately (without delay), you can do so by calling - * {@link scope.$eval} at any time from your code: - * <pre>scope.$root.$eval()</pre> - * - * In unit-test mode the update is instantaneous and synchronous to simplify writing tests. - * - */ - -function serviceUpdateViewFactory($browser){ - var rootScope = this; - var scheduled; - function update(){ - scheduled = false; - rootScope.$eval(); - } - return $browser.isMock ? update : function(){ - if (!scheduled) { - scheduled = true; - $browser.defer(update, serviceUpdateViewFactory.delay); - } - }; -} -serviceUpdateViewFactory.delay = 25; - -angularServiceInject('$updateView', serviceUpdateViewFactory, ['$browser']); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$hover - * @requires $browser - * @requires $document - * - * @description - * - * @example - */ -angularServiceInject("$hover", function(browser, document) { - var tooltip, self = this, error, width = 300, arrowWidth = 10, body = jqLite(document[0].body); - browser.hover(function(element, show){ - if (show && (error = element.attr(NG_EXCEPTION) || element.attr(NG_VALIDATION_ERROR))) { - if (!tooltip) { - tooltip = { - callout: jqLite('<div id="ng-callout"></div>'), - arrow: jqLite('<div></div>'), - title: jqLite('<div class="ng-title"></div>'), - content: jqLite('<div class="ng-content"></div>') - }; - tooltip.callout.append(tooltip.arrow); - tooltip.callout.append(tooltip.title); - tooltip.callout.append(tooltip.content); - body.append(tooltip.callout); - } - var docRect = body[0].getBoundingClientRect(), - elementRect = element[0].getBoundingClientRect(), - leftSpace = docRect.right - elementRect.right - arrowWidth; - tooltip.title.text(element.hasClass("ng-exception") ? "EXCEPTION:" : "Validation error..."); - tooltip.content.text(error); - if (leftSpace < width) { - tooltip.arrow.addClass('ng-arrow-right'); - tooltip.arrow.css({left: (width + 1)+'px'}); - tooltip.callout.css({ - position: 'fixed', - left: (elementRect.left - arrowWidth - width - 4) + "px", - top: (elementRect.top - 3) + "px", - width: width + "px" - }); - } else { - tooltip.arrow.addClass('ng-arrow-left'); - tooltip.callout.css({ - position: 'fixed', - left: (elementRect.right + arrowWidth) + "px", - top: (elementRect.top - 3) + "px", - width: width + "px" - }); - } - } else if (tooltip) { - tooltip.callout.remove(); - tooltip = _null; - } - }); -}, ['$browser', '$document'], EAGER); - -/** - * @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.$onEval(PRIORITY_LAST, 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 orphant 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; -}, [], EAGER); - - - -function switchRouteMatcher(on, when, dstName) { - var regex = '^' + when.replace(/[\.\\\(\)\^\$]/g, "\$1") + '$', - params = [], - dst = {}; - forEach(when.split(/\W/), function(param){ - if (param) { - var paramRegExp = new RegExp(":" + param + "([\\W])"); - if (regex.match(paramRegExp)) { - regex = regex.replace(paramRegExp, "([^\/]*)$1"); - params.push(param); - } - } - }); - var match = on.match(new RegExp(regex)); - if (match) { - forEach(params, function(name, index){ - dst[name] = match[index + 1]; - }); - if (dstName) this.$set(dstName, dst); - } - return match ? dst : _null; -} - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$route - * @requires $location - * - * @property {Object} current Reference to the current route definition. - * @property {Array.<Object>} routes Array of all configured routes. - * - * @description - * Watches `$location.hashPath` and tries to map the hash to an existing route - * definition. It is used for deep-linking URLs to controllers and views (HTML partials). - * - * The `$route` service is typically used in conjunction with {@link angular.widget.ng:view ng:view} - * widget. - * - * @example - This example shows how changing the URL hash causes the <tt>$route</tt> - to match a route against the URL, and the <tt>[[ng:include]]</tt> pulls in the partial. - Try changing the URL in the input box to see changes. - - <doc:example> - <doc:source> - <script> - angular.service('myApp', function($route) { - $route.when('/Book/:bookId', {template:'rsrc/book.html', controller:BookCntl}); - $route.when('/Book/:bookId/ch/:chapterId', {template:'rsrc/chapter.html', controller:ChapterCntl}); - $route.onChange(function() { - $route.current.scope.params = $route.current.params; - }); - }, {$inject: ['$route']}); - - function BookCntl() { - this.name = "BookCntl"; - } - - function ChapterCntl() { - this.name = "ChapterCntl"; - } - </script> - - Chose: - <a href="#/Book/Moby">Moby</a> | - <a href="#/Book/Moby/ch/1">Moby: Ch1</a> | - <a href="#/Book/Gatsby">Gatsby</a> | - <a href="#/Book/Gatsby/ch/4?key=value">Gatsby: Ch4</a><br/> - <input type="text" name="$location.hashPath" size="80" /> - <pre>$location={{$location}}</pre> - <pre>$route.current.template={{$route.current.template}}</pre> - <pre>$route.current.params={{$route.current.params}}</pre> - <pre>$route.current.scope.name={{$route.current.scope.name}}</pre> - <hr/> - <ng:include src="$route.current.template" scope="$route.current.scope"/> - </doc:source> - <doc:scenario> - </doc:scenario> - </doc:example> - */ -angularServiceInject('$route', function(location, $updateView) { - var routes = {}, - onChange = [], - matcher = switchRouteMatcher, - parentScope = this, - dirty = 0, - $route = { - routes: routes, - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$route#onChange - * @methodOf angular.service.$route - * - * @param {function()} fn Function that will be called when `$route.current` changes. - * @returns {function()} The registered function. - * - * @description - * Register a handler function that will be called when route changes - */ - onChange: function(fn) { - onChange.push(fn); - return fn; - }, - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$route#parent - * @methodOf angular.service.$route - * - * @param {Scope} [scope=rootScope] Scope to be used as parent for newly created - * `$route.current.scope` scopes. - * - * @description - * Sets a scope to be used as the parent scope for scopes created on route change. If not - * set, defaults to the root scope. - */ - parent: function(scope) { - if (scope) parentScope = scope; - }, - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$route#when - * @methodOf angular.service.$route - * - * @param {string} path Route path (matched against `$location.hash`) - * @param {Object} params Mapping information to be assigned to `$route.current` on route - * match. - * - * Object properties: - * - * - `controller` – `{function()=}` – Controller fn that should be associated with newly - * created scope. - * - `template` – `{string=}` – path to an html template that should be used by - * {@link angular.widget.ng:view ng:view} or - * {@link angular.widget.ng:include ng:include} widgets. - * - `redirectTo` – {(string|function())=} – value to update - * {@link angular.service.$location $location} hash with and trigger route redirection. - * - * If `redirectTo` is a function, it will be called with the following parameters: - * - * - `{Object.<string>}` - route parameters extracted from the current - * `$location.hashPath` by applying the current route template. - * - `{string}` - current `$location.hash` - * - `{string}` - current `$location.hashPath` - * - `{string}` - current `$location.hashSearch` - * - * The custom `redirectTo` function is expected to return a string which will be used - * to update `$location.hash`. - * - * @returns {Object} route object - * - * @description - * Adds a new route definition to the `$route` service. - */ - when:function (path, params) { - if (isUndefined(path)) return routes; //TODO(im): remove - not needed! - var route = routes[path]; - if (!route) route = routes[path] = {}; - if (params) extend(route, params); - dirty++; - return route; - }, - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$route#otherwise - * @methodOf angular.service.$route - * - * @description - * Sets route definition that will be used on route change when no other route definition - * is matched. - * - * @param {Object} params Mapping information to be assigned to `$route.current`. - */ - otherwise: function(params) { - $route.when(null, params); - }, - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$route#reload - * @methodOf angular.service.$route - * - * @description - * Causes `$route` service to reload (and recreate the `$route.current` scope) upon the next - * eval even if {@link angular.service.$location $location} hasn't changed. - */ - reload: function() { - dirty++; - } - }; - function updateRoute(){ - var childScope, routeParams, pathParams, segmentMatch, key, redir; - - $route.current = _null; - forEach(routes, function(rParams, rPath) { - if (!pathParams) { - if (pathParams = matcher(location.hashPath, rPath)) { - routeParams = rParams; - } - } - }); - - // "otherwise" fallback - routeParams = routeParams || routes[_null]; - - if(routeParams) { - if (routeParams.redirectTo) { - if (isString(routeParams.redirectTo)) { - // interpolate the redirectTo string - redir = {hashPath: '', - hashSearch: extend({}, location.hashSearch, pathParams)}; - - forEach(routeParams.redirectTo.split(':'), function(segment, i) { - if (i==0) { - redir.hashPath += segment; - } else { - segmentMatch = segment.match(/(\w+)(.*)/); - key = segmentMatch[1]; - redir.hashPath += pathParams[key] || location.hashSearch[key]; - redir.hashPath += segmentMatch[2] || ''; - delete redir.hashSearch[key]; - } - }); - } else { - // call custom redirectTo function - redir = {hash: routeParams.redirectTo(pathParams, location.hash, location.hashPath, - location.hashSearch)}; - } - - location.update(redir); - $updateView(); //TODO this is to work around the $location<=>$browser issues - return; - } - - childScope = createScope(parentScope); - $route.current = extend({}, routeParams, { - scope: childScope, - params: extend({}, location.hashSearch, pathParams) - }); - } - - //fire onChange callbacks - forEach(onChange, parentScope.$tryEval); - - if (childScope) { - childScope.$become($route.current.controller); - } - } - - this.$watch(function(){return dirty + location.hash;}, updateRoute); - - return $route; -}, ['$location', '$updateView']); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$xhr - * @function - * @requires $browser - * @requires $xhr.error - * @requires $log - * - * @description - * Generates an XHR request. The $xhr service adds error handling then delegates all requests to - * {@link angular.service.$browser $browser.xhr()}. - * - * @param {string} method HTTP method to use. Valid values are: `GET`, `POST`, `PUT`, `DELETE`, and - * `JSON`. `JSON` is a special case which causes a - * [JSONP](http://en.wikipedia.org/wiki/JSON#JSONP) cross domain request using script tag - * insertion. - * @param {string} url Relative or absolute URL specifying the destination of the request. For - * `JSON` requests, `url` should include `JSON_CALLBACK` string to be replaced with a name of an - * angular generated callback function. - * @param {(string|Object)=} post Request content as either a string or an object to be stringified - * as JSON before sent to the server. - * @param {function(number, (string|Object))} callback A function to be called when the response is - * received. The callback will be called with: - * - * - {number} code [HTTP status code](http://en.wikipedia.org/wiki/List_of_HTTP_status_codes) of - * the response. This will currently always be 200, since all non-200 responses are routed to - * {@link angular.service.$xhr.error} service. - * - {string|Object} response Response object as string or an Object if the response was in JSON - * format. - * - * @example - <doc:example> - <doc:source> - <script> - function FetchCntl($xhr) { - var self = this; - - this.fetch = function() { - self.clear(); - $xhr(self.method, self.url, function(code, response) { - self.code = code; - self.response = response; - }); - }; - - this.clear = function() { - self.code = null; - self.response = null; - }; - } - FetchCntl.$inject = ['$xhr']; - </script> - <div ng:controller="FetchCntl"> - <select name="method"> - <option>GET</option> - <option>JSON</option> - </select> - <input type="text" name="url" value="index.html" size="80"/><br/> - <button ng:click="fetch()">fetch</button> - <button ng:click="clear()">clear</button> - <a href="" ng:click="method='GET'; url='index.html'">sample</a> - <a href="" ng:click="method='JSON'; url='https://www.googleapis.com/buzz/v1/activities/googlebuzz/@self?alt=json&callback=JSON_CALLBACK'">buzz</a> - <pre>code={{code}}</pre> - <pre>response={{response}}</pre> - </div> - </doc:source> - </doc:example> - */ -angularServiceInject('$xhr', function($browser, $error, $log){ - var self = this; - return function(method, url, post, callback){ - if (isFunction(post)) { - callback = post; - post = _null; - } - if (post && isObject(post)) { - post = toJson(post); - } - $browser.xhr(method, url, post, function(code, response){ - try { - if (isString(response) && /^\s*[\[\{]/.exec(response) && /[\}\]]\s*$/.exec(response)) { - response = fromJson(response, true); - } - if (code == 200) { - callback(code, response); - } else { - $error( - {method: method, url:url, data:post, callback:callback}, - {status: code, body:response}); - } - } catch (e) { - $log.error(e); - } finally { - self.$eval(); - } - }); - }; -}, ['$browser', '$xhr.error', '$log']); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$xhr.error - * @function - * @requires $log - * - * @description - * Error handler for {@link angular.service.$xhr $xhr service}. An application can replaces this - * service with one specific for the application. The default implementation logs the error to - * {@link angular.service.$log $log.error}. - * - * @param {Object} request Request object. - * - * The object has the following properties - * - * - `method` – `{string}` – The http request method. - * - `url` – `{string}` – The request destination. - * - `data` – `{(string|Object)=} – An optional request body. - * - `callback` – `{function()}` – The callback function - * - * @param {Object} response Response object. - * - * The response object has the following properties: - * - * - status – {number} – Http status code. - * - body – {string|Object} – Body of the response. - * - * @example - <doc:example> - <doc:source> - fetch a non-existent file and log an error in the console: - <button ng:click="$service('$xhr')('GET', '/DOESNT_EXIST')">fetch</button> - </doc:source> - </doc:example> - */ -angularServiceInject('$xhr.error', function($log){ - return function(request, response){ - $log.error('ERROR: XHR: ' + request.url, request, response); - }; -}, ['$log']); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$xhr.bulk - * @requires $xhr - * @requires $xhr.error - * @requires $log - * - * @description - * - * @example - */ -angularServiceInject('$xhr.bulk', function($xhr, $error, $log){ - var requests = [], - scope = this; - function bulkXHR(method, url, post, callback) { - if (isFunction(post)) { - callback = post; - post = _null; - } - var currentQueue; - forEach(bulkXHR.urls, function(queue){ - if (isFunction(queue.match) ? queue.match(url) : queue.match.exec(url)) { - currentQueue = queue; - } - }); - if (currentQueue) { - if (!currentQueue.requests) currentQueue.requests = []; - currentQueue.requests.push({method: method, url: url, data:post, callback:callback}); - } else { - $xhr(method, url, post, callback); - } - } - bulkXHR.urls = {}; - bulkXHR.flush = function(callback){ - forEach(bulkXHR.urls, function(queue, url){ - var currentRequests = queue.requests; - if (currentRequests && currentRequests.length) { - queue.requests = []; - queue.callbacks = []; - $xhr('POST', url, {requests:currentRequests}, function(code, response){ - forEach(response, function(response, i){ - try { - if (response.status == 200) { - (currentRequests[i].callback || noop)(response.status, response.response); - } else { - $error(currentRequests[i], response); - } - } catch(e) { - $log.error(e); - } - }); - (callback || noop)(); - }); - scope.$eval(); - } - }); - }; - this.$onEval(PRIORITY_LAST, bulkXHR.flush); - return bulkXHR; -}, ['$xhr', '$xhr.error', '$log']); - - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$defer - * @requires $browser - * @requires $log - * - * @description - * Delegates to {@link angular.service.$browser.defer $browser.defer}, but wraps the `fn` function - * into a try/catch block and delegates any exceptions to - * {@link angular.services.$exceptionHandler $exceptionHandler} service. - * - * In tests you can use `$browser.defer.flush()` to flush the queue of deferred functions. - * - * @param {function()} fn A function, who's execution should be deferred. - */ -angularServiceInject('$defer', function($browser, $exceptionHandler, $updateView) { - var scope = this; - - return function(fn) { - $browser.defer(function() { - try { - fn(); - } catch(e) { - $exceptionHandler(e); - } finally { - $updateView(); - } - }); - }; -}, ['$browser', '$exceptionHandler', '$updateView']); - - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$xhr.cache - * @function - * @requires $xhr - * - * @description - * Acts just like the {@link angular.service.$xhr $xhr} service but caches responses for `GET` - * requests. All cache misses are delegated to the $xhr service. - * - * @property {function()} delegate Function to delegate all the cache misses to. Defaults to - * the {@link angular.service.$xhr $xhr} service. - * @property {object} data The hashmap where all cached entries are stored. - * - * @param {string} method HTTP method. - * @param {string} url Destination URL. - * @param {(string|Object)=} post Request body. - * @param {function(number, (string|Object))} callback Response callback. - * @param {boolean=} [verifyCache=false] If `true` then a result is immediately returned from cache - * (if present) while a request is sent to the server for a fresh response that will update the - * cached entry. The `callback` function will be called when the response is received. - */ -angularServiceInject('$xhr.cache', function($xhr, $defer, $log){ - var inflight = {}, self = this; - function cache(method, url, post, callback, verifyCache){ - if (isFunction(post)) { - callback = post; - post = _null; - } - if (method == 'GET') { - var data, dataCached; - if (dataCached = cache.data[url]) { - $defer(function() { callback(200, copy(dataCached.value)); }); - if (!verifyCache) - return; - } - - if (data = inflight[url]) { - data.callbacks.push(callback); - } else { - inflight[url] = {callbacks: [callback]}; - cache.delegate(method, url, post, function(status, response){ - if (status == 200) - cache.data[url] = { value: response }; - var callbacks = inflight[url].callbacks; - delete inflight[url]; - forEach(callbacks, function(callback){ - try { - (callback||noop)(status, copy(response)); - } catch(e) { - $log.error(e); - } - }); - }); - } - - } else { - cache.data = {}; - cache.delegate(method, url, post, callback); - } - } - cache.data = {}; - cache.delegate = $xhr; - return cache; -}, ['$xhr.bulk', '$defer', '$log']); - - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$resource - * @requires $xhr.cache - * - * @description - * Is a factory which creates a resource object that lets you interact with - * [RESTful](http://en.wikipedia.org/wiki/Representational_State_Transfer) server-side data sources. - * - * The returned resource object has action methods which provide high-level behaviors without - * the need to interact with the low level {@link angular.service.$xhr $xhr} service or - * raw XMLHttpRequest. - * - * @param {string} url A parameterized URL template with parameters prefixed by `:` as in - * `/user/:username`. - * - * @param {Object=} paramDefaults Default values for `url` parameters. These can be overridden in - * `actions` methods. - * - * Each key value in the parameter object is first bound to url template if present and then any - * excess keys are appended to the url search query after the `?`. - * - * Given a template `/path/:verb` and parameter `{verb:'greet', salutation:'Hello'}` results in - * URL `/path/greet?salutation=Hello`. - * - * If the parameter value is prefixed with `@` then the value of that parameter is extracted from - * the data object (useful for non-GET operations). - * - * @param {Object.<Object>=} actions Hash with declaration of custom action that should extend the - * default set of resource actions. The declaration should be created in the following format: - * - * {action1: {method:?, params:?, isArray:?, verifyCache:?}, - * action2: {method:?, params:?, isArray:?, verifyCache:?}, - * ...} - * - * Where: - * - * - `action` – {string} – The name of action. This name becomes the name of the method on your - * resource object. - * - `method` – {string} – HTTP request method. Valid methods are: `GET`, `POST`, `PUT`, `DELETE`, - * and `JSON` (also known as JSONP). - * - `params` – {object=} – Optional set of pre-bound parameters for this action. - * - isArray – {boolean=} – If true then the returned object for this action is an array, see - * `returns` section. - * - verifyCache – {boolean=} – If true then whenever cache hit occurs, the object is returned and - * an async request will be made to the server and the resources as well as the cache will be - * updated when the response is received. - * - * @returns {Object} A resource "class" object with methods for the default set of resource actions - * optionally extended with custom `actions`. The default set contains these actions: - * - * { 'get': {method:'GET'}, - * 'save': {method:'POST'}, - * 'query': {method:'GET', isArray:true}, - * 'remove': {method:'DELETE'}, - * 'delete': {method:'DELETE'} }; - * - * Calling these methods invoke an {@link angular.service.$xhr} with the specified http method, - * destination and parameters. When the data is returned from the server then the object is an - * instance of the resource class `save`, `remove` and `delete` actions are available on it as - * methods with the `$` prefix. This allows you to easily perform CRUD operations (create, read, - * update, delete) on server-side data like this: - * <pre> - var User = $resource('/user/:userId', {userId:'@id'}); - var user = User.get({userId:123}, function(){ - user.abc = true; - user.$save(); - }); - </pre> - * - * It is important to realize that invoking a $resource object method immediately returns an - * empty reference (object or array depending on `isArray`). Once the data is returned from the - * server the existing reference is populated with the actual data. This is a useful trick since - * usually the resource is assigned to a model which is then rendered by the view. Having an empty - * object results in no rendering, once the data arrives from the server then the object is - * populated with the data and the view automatically re-renders itself showing the new data. This - * means that in most case one never has to write a callback function for the action methods. - * - * The action methods on the class object or instance object can be invoked with the following - * parameters: - * - * - HTTP GET "class" actions: `Resource.action([parameters], [callback])` - * - non-GET "class" actions: `Resource.action(postData, [parameters], [callback])` - * - non-GET instance actions: `instance.$action([parameters], [callback])` - * - * - * @example - * - * # Credit card resource - * - * <pre> - // Define CreditCard class - var CreditCard = $resource('/user/:userId/card/:cardId', - {userId:123, cardId:'@id'}, { - charge: {method:'POST', params:{charge:true}} - }); - - // We can retrieve a collection from the server - var cards = CreditCard.query(); - // GET: /user/123/card - // server returns: [ {id:456, number:'1234', name:'Smith'} ]; - - var card = cards[0]; - // each item is an instance of CreditCard - expect(card instanceof CreditCard).toEqual(true); - card.name = "J. Smith"; - // non GET methods are mapped onto the instances - card.$save(); - // POST: /user/123/card/456 {id:456, number:'1234', name:'J. Smith'} - // server returns: {id:456, number:'1234', name: 'J. Smith'}; - - // our custom method is mapped as well. - card.$charge({amount:9.99}); - // POST: /user/123/card/456?amount=9.99&charge=true {id:456, number:'1234', name:'J. Smith'} - // server returns: {id:456, number:'1234', name: 'J. Smith'}; - - // we can create an instance as well - var newCard = new CreditCard({number:'0123'}); - newCard.name = "Mike Smith"; - newCard.$save(); - // POST: /user/123/card {number:'0123', name:'Mike Smith'} - // server returns: {id:789, number:'01234', name: 'Mike Smith'}; - expect(newCard.id).toEqual(789); - * </pre> - * - * The object returned from this function execution is a resource "class" which has "static" method - * for each action in the definition. - * - * Calling these methods invoke `$xhr` on the `url` template with the given `method` and `params`. - * When the data is returned from the server then the object is an instance of the resource type and - * all of the non-GET methods are available with `$` prefix. This allows you to easily support CRUD - * operations (create, read, update, delete) on server-side data. - - <pre> - var User = $resource('/user/:userId', {userId:'@id'}); - var user = User.get({userId:123}, function(){ - user.abc = true; - user.$save(); - }); - </pre> - * - * It's worth noting that the callback for `get`, `query` and other method gets passed in the - * response that came from the server, so one could rewrite the above example as: - * - <pre> - var User = $resource('/user/:userId', {userId:'@id'}); - User.get({userId:123}, function(u){ - u.abc = true; - u.$save(); - }); - </pre> - - * # Buzz client - - Let's look at what a buzz client created with the `$resource` service looks like: - <doc:example> - <doc:source> - <script> - function BuzzController($resource) { - this.Activity = $resource( - 'https://www.googleapis.com/buzz/v1/activities/:userId/:visibility/:activityId/:comments', - {alt:'json', callback:'JSON_CALLBACK'}, - {get:{method:'JSON', params:{visibility:'@self'}}, replies: {method:'JSON', params:{visibility:'@self', comments:'@comments'}}} - ); - } - - BuzzController.prototype = { - fetch: function() { - this.activities = this.Activity.get({userId:this.userId}); - }, - expandReplies: function(activity) { - activity.replies = this.Activity.replies({userId:this.userId, activityId:activity.id}); - } - }; - BuzzController.$inject = ['$resource']; - </script> - - <div ng:controller="BuzzController"> - <input name="userId" value="googlebuzz"/> - <button ng:click="fetch()">fetch</button> - <hr/> - <div ng:repeat="item in activities.data.items"> - <h1 style="font-size: 15px;"> - <img src="{{item.actor.thumbnailUrl}}" style="max-height:30px;max-width:30px;"/> - <a href="{{item.actor.profileUrl}}">{{item.actor.name}}</a> - <a href ng:click="expandReplies(item)" style="float: right;">Expand replies: {{item.links.replies[0].count}}</a> - </h1> - {{item.object.content | html}} - <div ng:repeat="reply in item.replies.data.items" style="margin-left: 20px;"> - <img src="{{reply.actor.thumbnailUrl}}" style="max-height:30px;max-width:30px;"/> - <a href="{{reply.actor.profileUrl}}">{{reply.actor.name}}</a>: {{reply.content | html}} - </div> - </div> - </div> - </doc:source> - <doc:scenario> - </doc:scenario> - </doc:example> - */ -angularServiceInject('$resource', function($xhr){ - var resource = new ResourceFactory($xhr); - return bind(resource, resource.route); -}, ['$xhr.cache']); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$cookies - * @requires $browser - * - * @description - * Provides read/write access to browser's cookies. - * - * Only a simple Object is exposed and by adding or removing properties to/from - * this object, new cookies are created/deleted at the end of current $eval. - * - * @example - */ -angularServiceInject('$cookies', function($browser) { - var rootScope = this, - cookies = {}, - lastCookies = {}, - lastBrowserCookies; - - //creates a poller fn that copies all cookies from the $browser to service & inits the service - $browser.addPollFn(function() { - var currentCookies = $browser.cookies(); - if (lastBrowserCookies != currentCookies) { //relies on browser.cookies() impl - lastBrowserCookies = currentCookies; - copy(currentCookies, lastCookies); - copy(currentCookies, cookies); - rootScope.$eval(); - } - })(); - - //at the end of each eval, push cookies - //TODO: this should happen before the "delayed" watches fire, because if some cookies are not - // strings or browser refuses to store some cookies, we update the model in the push fn. - this.$onEval(PRIORITY_LAST, push); - - return cookies; - - - /** - * Pushes all the cookies from the service to the browser and verifies if all cookies were stored. - */ - function push(){ - var name, - value, - browserCookies, - updated; - - //delete any cookies deleted in $cookies - for (name in lastCookies) { - if (isUndefined(cookies[name])) { - $browser.cookies(name, _undefined); - } - } - - //update all cookies updated in $cookies - for(name in cookies) { - value = cookies[name]; - if (!isString(value)) { - if (isDefined(lastCookies[name])) { - cookies[name] = lastCookies[name]; - } else { - delete cookies[name]; - } - } else if (value !== lastCookies[name]) { - $browser.cookies(name, value); - updated = true; - } - } - - //verify what was actually stored - if (updated){ - updated = false; - browserCookies = $browser.cookies(); - - for (name in cookies) { - if (cookies[name] !== browserCookies[name]) { - //delete or reset all cookies that the browser dropped from $cookies - if (isUndefined(browserCookies[name])) { - delete cookies[name]; - } else { - cookies[name] = browserCookies[name]; - } - updated = true; - } - } - } - } -}, ['$browser']); - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$cookieStore - * @requires $cookies - * - * @description - * Provides a key-value (string-object) storage, that is backed by session cookies. - * Objects put or retrieved from this storage are automatically serialized or - * deserialized by angular's toJson/fromJson. - * @example - */ -angularServiceInject('$cookieStore', function($store) { - - return { - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$cookieStore#get - * @methodOf angular.service.$cookieStore - * - * @description - * Returns the value of given cookie key - * - * @param {string} key Id to use for lookup. - * @returns {Object} Deserialized cookie value. - */ - get: function(key) { - return fromJson($store[key]); - }, - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$cookieStore#put - * @methodOf angular.service.$cookieStore - * - * @description - * Sets a value for given cookie key - * - * @param {string} key Id for the `value`. - * @param {Object} value Value to be stored. - */ - put: function(key, value) { - $store[key] = toJson(value); - }, - - /** - * @workInProgress - * @ngdoc method - * @name angular.service.$cookieStore#remove - * @methodOf angular.service.$cookieStore - * - * @description - * Remove given cookie - * - * @param {string} key Id of the key-value pair to delete. - */ - remove: function(key) { - delete $store[key]; - } - }; - -}, ['$cookies']); diff --git a/src/widgets.js b/src/widgets.js index 6482f455..461684ac 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -743,6 +743,7 @@ angularWidget('ng:include', function(element){ </doc:scenario> </doc:example> */ +//TODO(im): remove all the code related to using and inline equals var ngSwitch = angularWidget('ng:switch', function (element){ var compiler = this, watchExpr = element.attr("on"), @@ -805,8 +806,7 @@ var ngSwitch = angularWidget('ng:switch', function (element){ }, { equals: function(on, when) { return ''+on == when; - }, - route: switchRouteMatcher + } }); diff --git a/test/angular-mocksSpec.js b/test/angular-mocksSpec.js index fabc47ea..f49c53b1 100644 --- a/test/angular-mocksSpec.js +++ b/test/angular-mocksSpec.js @@ -120,7 +120,7 @@ describe('TzDate', function() { }); }); -describe('$log', function() { +describe('$log mock', function() { var $log; beforeEach(function() { $log = MockLogFactory(); diff --git a/test/service/cookieStoreSpec.js b/test/service/cookieStoreSpec.js new file mode 100644 index 00000000..0a493470 --- /dev/null +++ b/test/service/cookieStoreSpec.js @@ -0,0 +1,39 @@ +describe('$cookieStore', function() { + var scope, $browser, $cookieStore; + + beforeEach(function() { + scope = angular.scope(); + $cookieStore = scope.$service('$cookieStore'); + $browser = scope.$service('$browser'); + }); + + afterEach(function(){ + dealoc(scope); + }); + + + it('should serialize objects to json', function() { + $cookieStore.put('objectCookie', {id: 123, name: 'blah'}); + scope.$eval(); //force eval in test + expect($browser.cookies()).toEqual({'objectCookie': '{"id":123,"name":"blah"}'}); + }); + + + it('should deserialize json to object', function() { + $browser.cookies('objectCookie', '{"id":123,"name":"blah"}'); + $browser.poll(); + expect($cookieStore.get('objectCookie')).toEqual({id: 123, name: 'blah'}); + }); + + + it('should delete objects from the store when remove is called', function() { + $cookieStore.put('gonner', { "I'll":"Be Back"}); + scope.$eval(); //force eval in test + $browser.poll(); + expect($browser.cookies()).toEqual({'gonner': '{"I\'ll":"Be Back"}'}); + + $cookieStore.remove('gonner'); + scope.$eval(); + expect($browser.cookies()).toEqual({}); + }); +}); diff --git a/test/service/cookiesSpec.js b/test/service/cookiesSpec.js new file mode 100644 index 00000000..11551393 --- /dev/null +++ b/test/service/cookiesSpec.js @@ -0,0 +1,98 @@ +describe('$cookies', function() { + var scope, $browser; + + beforeEach(function() { + $browser = new MockBrowser(); + $browser.cookieHash['preexisting'] = 'oldCookie'; + scope = angular.scope(null, angular.service, {$browser: $browser}); + scope.$cookies = scope.$service('$cookies'); + }); + + afterEach(function(){ + dealoc(scope); + }); + + + it('should provide access to existing cookies via object properties and keep them in sync', + function(){ + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); + + // access internal cookie storage of the browser mock directly to simulate behavior of + // document.cookie + $browser.cookieHash['brandNew'] = 'cookie'; + $browser.poll(); + + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'brandNew':'cookie'}); + + $browser.cookieHash['brandNew'] = 'cookie2'; + $browser.poll(); + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'brandNew':'cookie2'}); + + delete $browser.cookieHash['brandNew']; + $browser.poll(); + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); + }); + + + it('should create or update a cookie when a value is assigned to a property', function() { + scope.$cookies.oatmealCookie = 'nom nom'; + scope.$eval(); + + expect($browser.cookies()). + toEqual({'preexisting': 'oldCookie', 'oatmealCookie':'nom nom'}); + + scope.$cookies.oatmealCookie = 'gone'; + scope.$eval(); + + expect($browser.cookies()). + toEqual({'preexisting': 'oldCookie', 'oatmealCookie': 'gone'}); + }); + + + it('should drop or reset any cookie that was set to a non-string value', function() { + scope.$cookies.nonString = [1, 2, 3]; + scope.$cookies.nullVal = null; + scope.$cookies.undefVal = undefined; + scope.$cookies.preexisting = function(){}; + scope.$eval(); + expect($browser.cookies()).toEqual({'preexisting': 'oldCookie'}); + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); + }); + + + it('should remove a cookie when a $cookies property is deleted', function() { + scope.$cookies.oatmealCookie = 'nom nom'; + scope.$eval(); + $browser.poll(); + expect($browser.cookies()). + toEqual({'preexisting': 'oldCookie', 'oatmealCookie':'nom nom'}); + + delete scope.$cookies.oatmealCookie; + scope.$eval(); + + expect($browser.cookies()).toEqual({'preexisting': 'oldCookie'}); + }); + + + it('should drop or reset cookies that browser refused to store', function() { + var i, longVal; + + for (i=0; i<5000; i++) { + longVal += '*'; + } + + //drop if no previous value + scope.$cookies.longCookie = longVal; + scope.$eval(); + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); + + + //reset if previous value existed + scope.$cookies.longCookie = 'shortVal'; + scope.$eval(); + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'longCookie': 'shortVal'}); + scope.$cookies.longCookie = longVal; + scope.$eval(); + expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'longCookie': 'shortVal'}); + }); +}); diff --git a/test/service/deferSpec.js b/test/service/deferSpec.js new file mode 100644 index 00000000..932c3661 --- /dev/null +++ b/test/service/deferSpec.js @@ -0,0 +1,69 @@ +describe('$defer', function() { + var scope, $browser, $defer, $exceptionHandler; + + beforeEach(function(){ + scope = angular.scope({}, angular.service, + {'$exceptionHandler': jasmine.createSpy('$exceptionHandler')}); + $browser = scope.$service('$browser'); + $defer = scope.$service('$defer'); + $exceptionHandler = scope.$service('$exceptionHandler'); + }); + + afterEach(function(){ + dealoc(scope); + }); + + + it('should delegate functions to $browser.defer', function() { + var counter = 0; + $defer(function() { counter++; }); + + expect(counter).toBe(0); + + $browser.defer.flush(); + expect(counter).toBe(1); + + $browser.defer.flush(); //does nothing + expect(counter).toBe(1); + + expect($exceptionHandler).not.toHaveBeenCalled(); + }); + + + it('should delegate exception to the $exceptionHandler service', function() { + $defer(function() {throw "Test Error";}); + expect($exceptionHandler).not.toHaveBeenCalled(); + + $browser.defer.flush(); + expect($exceptionHandler).toHaveBeenCalledWith("Test Error"); + }); + + + it('should call eval after each callback is executed', function() { + var eval = this.spyOn(scope, '$eval').andCallThrough(); + + $defer(function() {}); + expect(eval).wasNotCalled(); + + $browser.defer.flush(); + expect(eval).wasCalled(); + + eval.reset(); //reset the spy; + + $defer(function() {}); + $defer(function() {}); + $browser.defer.flush(); + expect(eval.callCount).toBe(2); + }); + + + it('should call eval even if an exception is thrown in callback', function() { + var eval = this.spyOn(scope, '$eval').andCallThrough(); + + $defer(function() {throw "Test Error";}); + expect(eval).wasNotCalled(); + + $browser.defer.flush(); + expect(eval).wasCalled(); + }); +}); diff --git a/test/service/documentSpec.js b/test/service/documentSpec.js new file mode 100644 index 00000000..bd92023d --- /dev/null +++ b/test/service/documentSpec.js @@ -0,0 +1,17 @@ +describe('$document', function() { + var scope; + + beforeEach(function(){ + scope = angular.scope(); + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it("should inject $document", function(){ + expect(scope.$service('$document')).toEqual(jqLite(document)); + }); +}); diff --git a/test/service/exceptionHandlerSpec.js b/test/service/exceptionHandlerSpec.js new file mode 100644 index 00000000..59349065 --- /dev/null +++ b/test/service/exceptionHandlerSpec.js @@ -0,0 +1,23 @@ +describe('$exceptionHandler', function() { + var scope; + + beforeEach(function(){ + scope = angular.scope(); + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it('should log errors', function(){ + var scope = createScope({}, {$exceptionHandler: $exceptionHandlerFactory}, + {$log: $logMock}), + $log = scope.$service('$log'), + $exceptionHandler = scope.$service('$exceptionHandler'); + + $exceptionHandler('myError'); + expect($log.error.logs.shift()).toEqual(['myError']); + }); +}); diff --git a/test/service/hoverSpec.js b/test/service/hoverSpec.js new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/service/hoverSpec.js @@ -0,0 +1 @@ + diff --git a/test/service/invalidWidgetsSpec.js b/test/service/invalidWidgetsSpec.js new file mode 100644 index 00000000..b6b2da61 --- /dev/null +++ b/test/service/invalidWidgetsSpec.js @@ -0,0 +1,39 @@ +describe('$invalidWidgets', function() { + var scope; + + beforeEach(function(){ + scope = angular.scope(); + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it("should count number of invalid widgets", function(){ + scope = compile('<input name="price" ng:required ng:validate="number"></input>'); + jqLite(document.body).append(scope.$element); + scope.$init(); + var $invalidWidgets = scope.$service('$invalidWidgets'); + expect($invalidWidgets.length).toEqual(1); + + scope.price = 123; + scope.$eval(); + expect($invalidWidgets.length).toEqual(0); + + scope.$element.remove(); + scope.price = 'abc'; + scope.$eval(); + expect($invalidWidgets.length).toEqual(0); + + jqLite(document.body).append(scope.$element); + scope.price = 'abcd'; //force revalidation, maybe this should be done automatically? + scope.$eval(); + expect($invalidWidgets.length).toEqual(1); + + jqLite(document.body).html(''); + scope.$eval(); + expect($invalidWidgets.length).toEqual(0); + }); +}); diff --git a/test/service/locationSpec.js b/test/service/locationSpec.js new file mode 100644 index 00000000..050875b1 --- /dev/null +++ b/test/service/locationSpec.js @@ -0,0 +1,299 @@ +describe('$location', function() { + var scope, $location, $browser; + + beforeEach(function(){ + scope = angular.scope(); + $location = scope.$service('$location'); + $browser = scope.$service('$browser'); + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it("should update location object immediately when update is called", function() { + var href = 'http://host:123/p/a/t/h.html?query=value#path?key=value&flag&key2='; + $location.update(href); + expect($location.href).toEqual(href); + expect($location.protocol).toEqual("http"); + expect($location.host).toEqual("host"); + expect($location.port).toEqual("123"); + expect($location.path).toEqual("/p/a/t/h.html"); + expect($location.search).toEqual({query:'value'}); + expect($location.hash).toEqual('path?key=value&flag&key2='); + expect($location.hashPath).toEqual('path'); + expect($location.hashSearch).toEqual({key: 'value', flag: true, key2: ''}); + }); + + + it('should update location when browser url changed', function() { + var origUrl = $location.href; + expect(origUrl).toEqual($browser.getUrl()); + + var newUrl = 'http://somenew/url#foo'; + $browser.setUrl(newUrl); + $browser.poll(); + expect($location.href).toEqual(newUrl); + }); + + + it('should update browser at the end of $eval', function() { + var origBrowserUrl = $browser.getUrl(); + $location.update('http://www.angularjs.org/'); + $location.update({path: '/a/b'}); + expect($location.href).toEqual('http://www.angularjs.org/a/b'); + expect($browser.getUrl()).toEqual(origBrowserUrl); + scope.$eval(); + expect($browser.getUrl()).toEqual('http://www.angularjs.org/a/b'); + }); + + + it('should update hashPath and hashSearch on hash update', function(){ + $location.update('http://server/#path?a=b'); + expect($location.hashPath).toEqual('path'); + expect($location.hashSearch).toEqual({a:'b'}); + + $location.update({hash: ''}); + expect($location.hashPath).toEqual(''); + expect($location.hashSearch).toEqual({}); + }); + + + it('should update hash on hashPath or hashSearch update', function() { + $location.update('http://server/#path?a=b'); + scope.$eval(); + $location.update({hashPath: '', hashSearch: {}}); + + expect($location.hash).toEqual(''); + }); + + + it('should update hashPath and hashSearch on $location.hash change upon eval', function(){ + $location.update('http://server/#path?a=b'); + scope.$eval(); + + $location.hash = ''; + scope.$eval(); + + expect($location.href).toEqual('http://server/'); + expect($location.hashPath).toEqual(''); + expect($location.hashSearch).toEqual({}); + }); + + + it('should update hash on $location.hashPath or $location.hashSearch change upon eval', + function() { + $location.update('http://server/#path?a=b'); + scope.$eval(); + $location.hashPath = ''; + $location.hashSearch = {}; + + scope.$eval(); + + expect($location.href).toEqual('http://server/'); + expect($location.hash).toEqual(''); + }); + + + it('should sync $location upon eval before watches are fired', function(){ + scope.$location = scope.$service('$location'); //publish to the scope for $watch + + var log = ''; + scope.$watch('$location.hash', function(){ + log += this.$location.hashPath + ';'; + }); + expect(log).toEqual(';'); + + log = ''; + scope.$location.hash = '/abc'; + scope.$eval(); + expect(scope.$location.hash).toEqual('/abc'); + expect(log).toEqual('/abc;'); + }); + + + describe('sync', function() { + + it('should update hash with escaped hashPath', function() { + $location.hashPath = 'foo=bar'; + scope.$eval(); + expect($location.hash).toBe('foo%3Dbar'); + }); + + + it('should give $location.href the highest precedence', function() { + $location.hashPath = 'hashPath'; + $location.hashSearch = {hash:'search'}; + $location.hash = 'hash'; + $location.port = '333'; + $location.host = 'host'; + $location.href = 'https://hrefhost:23/hrefpath'; + + scope.$eval(); + + expect($location).toEqualData({href: 'https://hrefhost:23/hrefpath', + protocol: 'https', + host: 'hrefhost', + port: '23', + path: '/hrefpath', + search: {}, + hash: '', + hashPath: '', + hashSearch: {} + }); + }); + + + it('should give $location.hash second highest precedence', function() { + $location.hashPath = 'hashPath'; + $location.hashSearch = {hash:'search'}; + $location.hash = 'hash'; + $location.port = '333'; + $location.host = 'host'; + $location.path = '/path'; + + scope.$eval(); + + expect($location).toEqualData({href: 'http://host:333/path#hash', + protocol: 'http', + host: 'host', + port: '333', + path: '/path', + search: {}, + hash: 'hash', + hashPath: 'hash', + hashSearch: {} + }); + }); + }); + + + describe('update()', function() { + + it('should accept hash object and update only given properties', function() { + $location.update("http://host:123/p/a/t/h.html?query=value#path?key=value&flag&key2="); + $location.update({host: 'new', port: 24}); + + expect($location.host).toEqual('new'); + expect($location.port).toEqual(24); + expect($location.protocol).toEqual('http'); + expect($location.href).toEqual("http://new:24/p/a/t/h.html?query=value#path?key=value&flag&key2="); + }); + + + it('should remove # if hash is empty', function() { + $location.update('http://www.angularjs.org/index.php#'); + expect($location.href).toEqual('http://www.angularjs.org/index.php'); + }); + + + it('should clear hash when updating to hash-less URL', function() { + $location.update('http://server'); + expect($location.href).toBe('http://server'); + expect($location.hash).toBe(''); + }); + }); + + + describe('updateHash()', function() { + + it('should accept single string argument to update path', function() { + $location.updateHash('path'); + expect($location.hash).toEqual('path'); + expect($location.hashPath).toEqual('path'); + }); + + + it('should reset hashSearch when updating with a single string', function() { + $location.updateHash({foo:'bar'}); //set some initial state for hashSearch + + $location.updateHash('path'); + expect($location.hashPath).toEqual('path'); + expect($location.hashSearch).toEqual({}); + }); + + + it('should accept single object argument to update search', function() { + $location.updateHash({a: 'b'}); + expect($location.hash).toEqual('?a=b'); + expect($location.hashSearch).toEqual({a: 'b'}); + }); + + + it('should accept path string and search object arguments to update both', function() { + $location.updateHash('path', {a: 'b'}); + expect($location.hash).toEqual('path?a=b'); + expect($location.hashSearch).toEqual({a: 'b'}); + expect($location.hashPath).toEqual('path'); + }); + + + it('should update href and hash when updating to empty string', function() { + $location.updateHash(''); + expect($location.href).toBe('http://server'); + expect($location.hash).toBe(''); + + scope.$eval(); + + expect($location.href).toBe('http://server'); + expect($location.hash).toBe(''); + }); + }); + + + describe('URL_MATCH', function() { + + it('should parse basic url', function() { + var match = URL_MATCH.exec('http://www.angularjs.org/path?search#hash?x=x'); + + expect(match[1]).toEqual('http'); + expect(match[3]).toEqual('www.angularjs.org'); + expect(match[6]).toEqual('/path'); + expect(match[8]).toEqual('search'); + expect(match[10]).toEqual('hash?x=x'); + }); + + + it('should parse file://', function(){ + var match = URL_MATCH.exec('file:///Users/Shared/misko/work/angular.js/scenario/widgets.html'); + + expect(match[1]).toEqual('file'); + expect(match[3]).toEqual(''); + expect(match[5]).toBeFalsy(); + expect(match[6]).toEqual('/Users/Shared/misko/work/angular.js/scenario/widgets.html'); + expect(match[8]).toBeFalsy(); + }); + + + it('should parse url with "-" in host', function(){ + var match = URL_MATCH.exec('http://a-b1.c-d.09/path'); + + expect(match[1]).toEqual('http'); + expect(match[3]).toEqual('a-b1.c-d.09'); + expect(match[5]).toBeFalsy(); + expect(match[6]).toEqual('/path'); + expect(match[8]).toBeFalsy(); + }); + + + it('should parse host without "/" at the end', function() { + var match = URL_MATCH.exec('http://host.org'); + expect(match[3]).toEqual('host.org'); + + match = URL_MATCH.exec('http://host.org#'); + expect(match[3]).toEqual('host.org'); + + match = URL_MATCH.exec('http://host.org?'); + expect(match[3]).toEqual('host.org'); + }); + + + it('should match with just "/" path', function() { + var match = URL_MATCH.exec('http://server/#?book=moby'); + + expect(match[10]).toEqual('?book=moby'); + }); + }); +}); diff --git a/test/service/logSpec.js b/test/service/logSpec.js new file mode 100644 index 00000000..5d8fa0db --- /dev/null +++ b/test/service/logSpec.js @@ -0,0 +1,100 @@ +describe('$log', function() { + var scope; + + beforeEach(function(){ + scope = angular.scope(); + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it('should use console if present', function(){ + var logger = ""; + function log(){ logger+= 'log;'; } + function warn(){ logger+= 'warn;'; } + function info(){ logger+= 'info;'; } + function error(){ logger+= 'error;'; } + var scope = createScope({}, {$log: $logFactory}, + {$exceptionHandler: rethrow, + $window: {console: {log: log, + warn: warn, + info: info, + error: error}}}), + $log = scope.$service('$log'); + + $log.log(); + $log.warn(); + $log.info(); + $log.error(); + expect(logger).toEqual('log;warn;info;error;'); + }); + + + it('should use console.log() if other not present', function(){ + var logger = ""; + function log(){ logger+= 'log;'; } + var scope = createScope({}, {$log: $logFactory}, + {$window: {console:{log:log}}, + $exceptionHandler: rethrow}); + var $log = scope.$service('$log'); + $log.log(); + $log.warn(); + $log.info(); + $log.error(); + expect(logger).toEqual('log;log;log;log;'); + }); + + + it('should use noop if no console', function(){ + var scope = createScope({}, {$log: $logFactory}, + {$window: {}, + $exceptionHandler: rethrow}), + $log = scope.$service('$log'); + $log.log(); + $log.warn(); + $log.info(); + $log.error(); + }); + + + describe('$log.error', function(){ + var e, $log, errorArgs; + + beforeEach(function(){ + e = new Error(''); + e.message = undefined; + e.sourceURL = undefined; + e.line = undefined; + e.stack = undefined; + + $log = $logFactory({console:{error:function(){ + errorArgs = arguments; + }}}); + }); + + + it('should pass error if does not have trace', function(){ + $log.error('abc', e); + expect(errorArgs).toEqual(['abc', e]); + }); + + + it('should print stack', function(){ + e.stack = 'stack'; + $log.error('abc', e); + expect(errorArgs).toEqual(['abc', 'stack']); + }); + + + it('should print line', function(){ + e.message = 'message'; + e.sourceURL = 'sourceURL'; + e.line = '123'; + $log.error('abc', e); + expect(errorArgs).toEqual(['abc', 'message\nsourceURL:123']); + }); + }); +}); diff --git a/test/service/resourceSpec.js b/test/service/resourceSpec.js new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/service/resourceSpec.js @@ -0,0 +1 @@ + diff --git a/test/service/routeSpec.js b/test/service/routeSpec.js new file mode 100644 index 00000000..95258cc8 --- /dev/null +++ b/test/service/routeSpec.js @@ -0,0 +1,228 @@ +describe('$route', function() { + var scope; + + beforeEach(function(){ + scope = angular.scope(); + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it('should route and fire change event', function(){ + var log = '', + $location, $route; + + function BookChapter() { + this.log = '<init>'; + } + scope = compile('<div></div>').$init(); + $location = scope.$service('$location'); + $route = scope.$service('$route'); + $route.when('/Book/:book/Chapter/:chapter', {controller: BookChapter, template:'Chapter.html'}); + $route.when('/Blank'); + $route.onChange(function(){ + log += 'onChange();'; + }); + $location.update('http://server#/Book/Moby/Chapter/Intro?p=123'); + scope.$eval(); + expect(log).toEqual('onChange();'); + expect($route.current.params).toEqual({book:'Moby', chapter:'Intro', p:'123'}); + expect($route.current.scope.log).toEqual('<init>'); + var lastId = $route.current.scope.$id; + + log = ''; + $location.update('http://server#/Blank?ignore'); + scope.$eval(); + expect(log).toEqual('onChange();'); + expect($route.current.params).toEqual({ignore:true}); + expect($route.current.scope.$id).not.toEqual(lastId); + + log = ''; + $location.update('http://server#/NONE'); + scope.$eval(); + expect(log).toEqual('onChange();'); + expect($route.current).toEqual(null); + + $route.when('/NONE', {template:'instant update'}); + scope.$eval(); + expect($route.current.template).toEqual('instant update'); + }); + + + it('should return fn registered with onChange()', function() { + var scope = angular.scope(), + $route = scope.$service('$route'), + fn = function() {}; + + expect($route.onChange(fn)).toBe(fn); + }); + + + it('should allow routes to be defined with just templates without controllers', function() { + var scope = angular.scope(), + $location = scope.$service('$location'), + $route = scope.$service('$route'), + onChangeSpy = jasmine.createSpy('onChange'); + + $route.when('/foo', {template: 'foo.html'}); + $route.onChange(onChangeSpy); + expect($route.current).toBeNull(); + expect(onChangeSpy).not.toHaveBeenCalled(); + + $location.updateHash('/foo'); + scope.$eval(); + + expect($route.current.template).toEqual('foo.html'); + expect($route.current.controller).toBeUndefined(); + expect(onChangeSpy).toHaveBeenCalled(); + }); + + + it('should handle unknown routes with "otherwise" route definition', function() { + var scope = angular.scope(), + $location = scope.$service('$location'), + $route = scope.$service('$route'), + onChangeSpy = jasmine.createSpy('onChange'); + + function NotFoundCtrl() {this.notFoundProp = 'not found!'} + + $route.when('/foo', {template: 'foo.html'}); + $route.otherwise({template: '404.html', controller: NotFoundCtrl}); + $route.onChange(onChangeSpy); + expect($route.current).toBeNull(); + expect(onChangeSpy).not.toHaveBeenCalled(); + + $location.updateHash('/unknownRoute'); + scope.$eval(); + + expect($route.current.template).toBe('404.html'); + expect($route.current.controller).toBe(NotFoundCtrl); + expect($route.current.scope.notFoundProp).toBe('not found!'); + expect(onChangeSpy).toHaveBeenCalled(); + + onChangeSpy.reset(); + $location.updateHash('/foo'); + scope.$eval(); + + expect($route.current.template).toEqual('foo.html'); + expect($route.current.controller).toBeUndefined(); + expect($route.current.scope.notFoundProp).toBeUndefined(); + expect(onChangeSpy).toHaveBeenCalled(); + }); + + + describe('redirection', function() { + + it('should support redirection via redirectTo property by updating $location', function() { + var scope = angular.scope(), + $location = scope.$service('$location'), + $browser = scope.$service('$browser'), + $route = scope.$service('$route'), + onChangeSpy = jasmine.createSpy('onChange'); + + $route.when('', {redirectTo: '/foo'}); + $route.when('/foo', {template: 'foo.html'}); + $route.when('/bar', {template: 'bar.html'}); + $route.when('/baz', {redirectTo: '/bar'}); + $route.otherwise({template: '404.html'}); + $route.onChange(onChangeSpy); + expect($route.current).toBeNull(); + expect(onChangeSpy).not.toHaveBeenCalled(); + + scope.$eval(); //triggers initial route change - match the redirect route + $browser.defer.flush(); //triger route change - match the route we redirected to + + expect($location.hash).toBe('/foo'); + expect($route.current.template).toBe('foo.html'); + expect(onChangeSpy.callCount).toBe(1); + + onChangeSpy.reset(); + $location.updateHash(''); + scope.$eval(); //match the redirect route + update $browser + $browser.defer.flush(); //match the route we redirected to + + expect($location.hash).toBe('/foo'); + expect($route.current.template).toBe('foo.html'); + expect(onChangeSpy.callCount).toBe(1); + + onChangeSpy.reset(); + $location.updateHash('/baz'); + scope.$eval(); //match the redirect route + update $browser + $browser.defer.flush(); //match the route we redirected to + + expect($location.hash).toBe('/bar'); + expect($route.current.template).toBe('bar.html'); + expect(onChangeSpy.callCount).toBe(1); + }); + + + it('should interpolate route variables in the redirected hashPath from the original hashPath', + function() { + var scope = angular.scope(), + $location = scope.$service('$location'), + $browser = scope.$service('$browser'), + $route = scope.$service('$route'); + + $route.when('/foo/:id/foo/:subid/:extraId', {redirectTo: '/bar/:id/:subid/23'}); + $route.when('/bar/:id/:subid/:subsubid', {template: 'bar.html'}); + scope.$eval(); + + $location.updateHash('/foo/id1/foo/subid3/gah'); + scope.$eval(); //triggers initial route change - match the redirect route + $browser.defer.flush(); //triger route change - match the route we redirected to + + expect($location.hash).toBe('/bar/id1/subid3/23?extraId=gah'); + expect($route.current.template).toBe('bar.html'); + }); + + + it('should interpolate route variables in the redirected hashPath from the original hashSearch', + function() { + var scope = angular.scope(), + $location = scope.$service('$location'), + $browser = scope.$service('$browser'), + $route = scope.$service('$route'); + + $route.when('/bar/:id/:subid/:subsubid', {template: 'bar.html'}); + $route.when('/foo/:id/:extra', {redirectTo: '/bar/:id/:subid/99'}); + scope.$eval(); + + $location.hash = '/foo/id3/eId?subid=sid1&appended=true'; + scope.$eval(); //triggers initial route change - match the redirect route + $browser.defer.flush(); //triger route change - match the route we redirected to + + expect($location.hash).toBe('/bar/id3/sid1/99?appended=true&extra=eId'); + expect($route.current.template).toBe('bar.html'); + }); + + + it('should allow custom redirectTo function to be used', function() { + var scope = angular.scope(), + $location = scope.$service('$location'), + $browser = scope.$service('$browser'), + $route = scope.$service('$route'); + + $route.when('/bar/:id/:subid/:subsubid', {template: 'bar.html'}); + $route.when('/foo/:id', + {redirectTo: customRedirectFn}); + scope.$eval(); + + $location.hash = '/foo/id3?subid=sid1&appended=true'; + scope.$eval(); //triggers initial route change - match the redirect route + $browser.defer.flush(); //triger route change - match the route we redirected to + + expect($location.hash).toBe('custom'); + + function customRedirectFn(routePathParams, hash, hashPath, hashSearch) { + expect(routePathParams).toEqual({id: 'id3'}); + expect(hash).toEqual($location.hash); + expect(hashPath).toEqual($location.hashPath); + expect(hashSearch).toEqual($location.hashSearch); + return 'custom'; + } + }); + }); +}); diff --git a/test/service/updateViewSpec.js b/test/service/updateViewSpec.js new file mode 100644 index 00000000..beca355e --- /dev/null +++ b/test/service/updateViewSpec.js @@ -0,0 +1,61 @@ +describe('$updateView', function() { + var scope, browser, evalCount, $updateView; + + beforeEach(function(){ + browser = new MockBrowser(); + // Pretend that you are real Browser so that we see the delays + browser.isMock = false; + browser.defer = jasmine.createSpy('defer'); + + scope = angular.scope(null, null, {$browser:browser}); + $updateView = scope.$service('$updateView'); + scope.$onEval(function(){ evalCount++; }); + evalCount = 0; + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it('should eval root scope after a delay', function(){ + $updateView(); + expect(evalCount).toEqual(0); + expect(browser.defer).toHaveBeenCalled(); + expect(browser.defer.mostRecentCall.args[1]).toEqual(25); + browser.defer.mostRecentCall.args[0](); + expect(evalCount).toEqual(1); + }); + + + it('should allow changing of delay time', function(){ + var oldValue = angular.service('$updateView').delay; + angular.service('$updateView').delay = 50; + $updateView(); + expect(evalCount).toEqual(0); + expect(browser.defer).toHaveBeenCalled(); + expect(browser.defer.mostRecentCall.args[1]).toEqual(50); + angular.service('$updateView').delay = oldValue; + }); + + + it('should ignore multiple requests for update', function(){ + $updateView(); + $updateView(); + expect(evalCount).toEqual(0); + expect(browser.defer).toHaveBeenCalled(); + expect(browser.defer.callCount).toEqual(1); + browser.defer.mostRecentCall.args[0](); + expect(evalCount).toEqual(1); + }); + + + it('should update immediatelly in test/mock mode', function(){ + scope = angular.scope(); + scope.$onEval(function(){ evalCount++; }); + expect(evalCount).toEqual(0); + scope.$service('$updateView')(); + expect(evalCount).toEqual(1); + }); +}); diff --git a/test/service/windowSpec.js b/test/service/windowSpec.js new file mode 100644 index 00000000..e968f560 --- /dev/null +++ b/test/service/windowSpec.js @@ -0,0 +1,17 @@ +describe('$window', function() { + var scope; + + beforeEach(function(){ + scope = angular.scope(); + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + it("should inject $window", function(){ + expect(scope.$service('$window')).toBe(window); + }); +}); diff --git a/test/service/xhr.bulkSpec.js b/test/service/xhr.bulkSpec.js new file mode 100644 index 00000000..89429a91 --- /dev/null +++ b/test/service/xhr.bulkSpec.js @@ -0,0 +1,69 @@ +describe('$xhr.bulk', function() { + var scope, $browser, $browserXhr, $log, $xhrBulk, $xhrError, log; + + beforeEach(function(){ + scope = angular.scope({}, angular.service, { + '$xhr.error': $xhrError = jasmine.createSpy('$xhr.error'), + '$log': $log = {} + }); + $browser = scope.$service('$browser'); + $browserXhr = $browser.xhr; + $xhrBulk = scope.$service('$xhr.bulk'); + log = ''; + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + function callback(code, response) { + expect(code).toEqual(200); + log = log + toJson(response) + ';'; + } + + + it('should collect requests', function(){ + $xhrBulk.urls["/"] = {match:/.*/}; + $xhrBulk('GET', '/req1', null, callback); + $xhrBulk('POST', '/req2', {post:'data'}, callback); + + $browserXhr.expectPOST('/', { + requests:[{method:'GET', url:'/req1', data: null}, + {method:'POST', url:'/req2', data:{post:'data'} }] + }).respond([ + {status:200, response:'first'}, + {status:200, response:'second'} + ]); + $xhrBulk.flush(function(){ log += 'DONE';}); + $browserXhr.flush(); + expect(log).toEqual('"first";"second";DONE'); + }); + + + it('should handle non 200 status code by forwarding to error handler', function(){ + $xhrBulk.urls['/'] = {match:/.*/}; + $xhrBulk('GET', '/req1', null, callback); + $xhrBulk('POST', '/req2', {post:'data'}, callback); + + $browserXhr.expectPOST('/', { + requests:[{method:'GET', url:'/req1', data: null}, + {method:'POST', url:'/req2', data:{post:'data'} }] + }).respond([ + {status:404, response:'NotFound'}, + {status:200, response:'second'} + ]); + $xhrBulk.flush(function(){ log += 'DONE';}); + $browserXhr.flush(); + + expect($xhrError).wasCalled(); + var cb = $xhrError.mostRecentCall.args[0].callback; + expect(typeof cb).toEqual($function); + expect($xhrError).wasCalledWith( + {url:'/req1', method:'GET', data:null, callback:cb}, + {status:404, response:'NotFound'}); + + expect(log).toEqual('"second";DONE'); + }); +}); diff --git a/test/service/xhr.cacheSpec.js b/test/service/xhr.cacheSpec.js new file mode 100644 index 00000000..82b33b72 --- /dev/null +++ b/test/service/xhr.cacheSpec.js @@ -0,0 +1,128 @@ +describe('$xhr.cache', function() { + var scope, $browser, $browserXhr, cache, log; + + beforeEach(function(){ + scope = angular.scope(); + $browser = scope.$service('$browser'); + $browserXhr = $browser.xhr; + cache = scope.$service('$xhr.cache'); + log = ''; + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + function callback(code, response) { + expect(code).toEqual(200); + log = log + toJson(response) + ';'; + } + + + it('should cache requests', function(){ + $browserXhr.expectGET('/url').respond('first'); + cache('GET', '/url', null, callback); + $browserXhr.flush(); + + $browserXhr.expectGET('/url').respond('ERROR'); + cache('GET', '/url', null, callback); + $browser.defer.flush(); + expect(log).toEqual('"first";"first";'); + + cache('GET', '/url', null, callback, false); + $browser.defer.flush(); + expect(log).toEqual('"first";"first";"first";'); + }); + + + it('should first return cache request, then return server request', function(){ + $browserXhr.expectGET('/url').respond('first'); + cache('GET', '/url', null, callback, true); + $browserXhr.flush(); + + $browserXhr.expectGET('/url').respond('ERROR'); + cache('GET', '/url', null, callback, true); + $browser.defer.flush(); + expect(log).toEqual('"first";"first";'); + + $browserXhr.flush(); + expect(log).toEqual('"first";"first";"ERROR";'); + }); + + + it('should serve requests from cache', function(){ + cache.data.url = {value:'123'}; + cache('GET', 'url', null, callback); + $browser.defer.flush(); + expect(log).toEqual('"123";'); + + cache('GET', 'url', null, callback, false); + $browser.defer.flush(); + expect(log).toEqual('"123";"123";'); + }); + + + it('should keep track of in flight requests and request only once', function(){ + scope.$service('$xhr.bulk').urls['/bulk'] = { + match:function(url){ + return url == '/url'; + } + }; + $browserXhr.expectPOST('/bulk', { + requests:[{method:'GET', url:'/url', data: null}] + }).respond([ + {status:200, response:'123'} + ]); + cache('GET', '/url', null, callback); + cache('GET', '/url', null, callback); + cache.delegate.flush(); + $browserXhr.flush(); + expect(log).toEqual('"123";"123";'); + }); + + + it('should clear cache on non GET', function(){ + $browserXhr.expectPOST('abc', {}).respond({}); + cache.data.url = {value:123}; + cache('POST', 'abc', {}); + expect(cache.data.url).toBeUndefined(); + }); + + + it('should call callback asynchronously for both cache hit and cache miss', function() { + $browserXhr.expectGET('/url').respond('+'); + cache('GET', '/url', null, callback); + expect(log).toEqual(''); //callback hasn't executed + + $browserXhr.flush(); + expect(log).toEqual('"+";'); //callback has executed + + cache('GET', '/url', null, callback); + expect(log).toEqual('"+";'); //callback hasn't executed + + $browser.defer.flush(); + expect(log).toEqual('"+";"+";'); //callback has executed + }); + + + it('should call eval after callbacks for both cache hit and cache miss execute', function() { + var eval = this.spyOn(scope, '$eval').andCallThrough(); + + $browserXhr.expectGET('/url').respond('+'); + cache('GET', '/url', null, callback); + expect(eval).wasNotCalled(); + + $browserXhr.flush(); + expect(eval).wasCalled(); + + eval.reset(); //reset the spy + + cache('GET', '/url', null, callback); + expect(eval).wasNotCalled(); + + $browser.defer.flush(); + expect(eval).wasCalled(); + }); +}); diff --git a/test/service/xhr.errorSpec.js b/test/service/xhr.errorSpec.js new file mode 100644 index 00000000..da1b102e --- /dev/null +++ b/test/service/xhr.errorSpec.js @@ -0,0 +1,36 @@ +describe('$xhr.error', function() { + var scope, $browser, $browserXhr, $xhr, $xhrError, log; + + beforeEach(function(){ + scope = angular.scope({}, angular.service, { + '$xhr.error': $xhrError = jasmine.createSpy('$xhr.error') + }); + $browser = scope.$service('$browser'); + $browserXhr = $browser.xhr; + $xhr = scope.$service('$xhr'); + log = ''; + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + function callback(code, response) { + expect(code).toEqual(200); + log = log + toJson(response) + ';'; + } + + + it('should handle non 200 status codes by forwarding to error handler', function(){ + $browserXhr.expectPOST('/req', 'MyData').respond(500, 'MyError'); + $xhr('POST', '/req', 'MyData', callback); + $browserXhr.flush(); + var cb = $xhrError.mostRecentCall.args[0].callback; + expect(typeof cb).toEqual($function); + expect($xhrError).wasCalledWith( + {url:'/req', method:'POST', data:'MyData', callback:cb}, + {status:500, body:'MyError'}); + }); +}); diff --git a/test/service/xhrSpec.js b/test/service/xhrSpec.js new file mode 100644 index 00000000..35861a92 --- /dev/null +++ b/test/service/xhrSpec.js @@ -0,0 +1,47 @@ +describe('$xhr', function() { + var scope, $browser, $browserXhr, $log, $xhr, log; + + beforeEach(function(){ + scope = angular.scope({}, angular.service, { '$log': $log = {} }); + $browser = scope.$service('$browser'); + $browserXhr = $browser.xhr; + $xhr = scope.$service('$xhr'); + log = ''; + }); + + + afterEach(function(){ + dealoc(scope); + }); + + + function callback(code, response) { + expect(code).toEqual(200); + log = log + toJson(response) + ';'; + } + + + it('should forward the request to $browser and decode JSON', function(){ + $browserXhr.expectGET('/reqGET').respond('first'); + $browserXhr.expectGET('/reqGETjson').respond('["second"]'); + $browserXhr.expectPOST('/reqPOST', {post:'data'}).respond('third'); + + $xhr('GET', '/reqGET', null, callback); + $xhr('GET', '/reqGETjson', null, callback); + $xhr('POST', '/reqPOST', {post:'data'}, callback); + + $browserXhr.flush(); + + expect(log).toEqual('"third";["second"];"first";'); + }); + + + it('should handle exceptions in callback', function(){ + $log.error = jasmine.createSpy('$log.error'); + $browserXhr.expectGET('/reqGET').respond('first'); + $xhr('GET', '/reqGET', null, function(){ throw "MyException"; }); + $browserXhr.flush(); + + expect($log.error).wasCalledWith("MyException"); + }); +}); diff --git a/test/servicesSpec.js b/test/servicesSpec.js deleted file mode 100644 index 0186684e..00000000 --- a/test/servicesSpec.js +++ /dev/null @@ -1,1081 +0,0 @@ -describe("service", function(){ - var scope, $xhrError, $log, mockServices, $browser, $browserXhr, $xhrBulk, $xhr; - - beforeEach(function(){ - $xhrError = jasmine.createSpy('$xhr.error'); - $log = {}; - scope = createScope({}, angularService, { - '$xhr.error': $xhrError, - '$log': $log - }); - $browser = scope.$service('$browser'); - $browserXhr = $browser.xhr; - $xhrBulk = scope.$service('$xhr.bulk'); - $xhr = scope.$service('$xhr'); - }); - - afterEach(function(){ - dealoc(scope); - }); - - - - it("should inject $window", function(){ - expect(scope.$service('$window')).toEqual(window); - }); - - describe("$log", function(){ - it('should use console if present', function(){ - var logger = ""; - function log(){ logger+= 'log;'; } - function warn(){ logger+= 'warn;'; } - function info(){ logger+= 'info;'; } - function error(){ logger+= 'error;'; } - var scope = createScope({}, {$log: $logFactory}, - {$exceptionHandler: rethrow, - $window: {console: {log: log, - warn: warn, - info: info, - error: error}}}), - $log = scope.$service('$log'); - - $log.log(); - $log.warn(); - $log.info(); - $log.error(); - expect(logger).toEqual('log;warn;info;error;'); - }); - - it('should use console.log() if other not present', function(){ - var logger = ""; - function log(){ logger+= 'log;'; } - var scope = createScope({}, {$log: $logFactory}, - {$window: {console:{log:log}}, - $exceptionHandler: rethrow}); - var $log = scope.$service('$log'); - $log.log(); - $log.warn(); - $log.info(); - $log.error(); - expect(logger).toEqual('log;log;log;log;'); - }); - - it('should use noop if no console', function(){ - var scope = createScope({}, {$log: $logFactory}, - {$window: {}, - $exceptionHandler: rethrow}), - $log = scope.$service('$log'); - $log.log(); - $log.warn(); - $log.info(); - $log.error(); - }); - - describe('error', function(){ - var e, $log, errorArgs; - beforeEach(function(){ - e = new Error(''); - e.message = undefined; - e.sourceURL = undefined; - e.line = undefined; - e.stack = undefined; - - $log = $logFactory({console:{error:function(){ - errorArgs = arguments; - }}}); - }); - - it('should pass error if does not have trace', function(){ - $log.error('abc', e); - expect(errorArgs).toEqual(['abc', e]); - }); - - it('should print stack', function(){ - e.stack = 'stack'; - $log.error('abc', e); - expect(errorArgs).toEqual(['abc', 'stack']); - }); - - it('should print line', function(){ - e.message = 'message'; - e.sourceURL = 'sourceURL'; - e.line = '123'; - $log.error('abc', e); - expect(errorArgs).toEqual(['abc', 'message\nsourceURL:123']); - }); - - }); - - }); - - describe("$exceptionHandler", function(){ - it('should log errors', function(){ - var scope = createScope({}, {$exceptionHandler: $exceptionHandlerFactory}, - {$log: $logMock}), - $log = scope.$service('$log'), - $exceptionHandler = scope.$service('$exceptionHandler'); - - $exceptionHandler('myError'); - expect($log.error.logs.shift()).toEqual(['myError']); - }); - }); - - describe("$location", function(){ - var $location; - - beforeEach(function() { - $location = scope.$service('$location'); - }); - - - it("should update location object immediately when update is called", function() { - var href = 'http://host:123/p/a/t/h.html?query=value#path?key=value&flag&key2='; - $location.update(href); - expect($location.href).toEqual(href); - expect($location.protocol).toEqual("http"); - expect($location.host).toEqual("host"); - expect($location.port).toEqual("123"); - expect($location.path).toEqual("/p/a/t/h.html"); - expect($location.search).toEqual({query:'value'}); - expect($location.hash).toEqual('path?key=value&flag&key2='); - expect($location.hashPath).toEqual('path'); - expect($location.hashSearch).toEqual({key: 'value', flag: true, key2: ''}); - }); - - - it('should update location when browser url changed', function() { - var origUrl = $location.href; - expect(origUrl).toEqual($browser.getUrl()); - - var newUrl = 'http://somenew/url#foo'; - $browser.setUrl(newUrl); - $browser.poll(); - expect($location.href).toEqual(newUrl); - }); - - - it('should update browser at the end of $eval', function() { - var origBrowserUrl = $browser.getUrl(); - $location.update('http://www.angularjs.org/'); - $location.update({path: '/a/b'}); - expect($location.href).toEqual('http://www.angularjs.org/a/b'); - expect($browser.getUrl()).toEqual(origBrowserUrl); - scope.$eval(); - expect($browser.getUrl()).toEqual('http://www.angularjs.org/a/b'); - }); - - - it('should update hashPath and hashSearch on hash update', function(){ - $location.update('http://server/#path?a=b'); - expect($location.hashPath).toEqual('path'); - expect($location.hashSearch).toEqual({a:'b'}); - - $location.update({hash: ''}); - expect($location.hashPath).toEqual(''); - expect($location.hashSearch).toEqual({}); - }); - - - it('should update hash on hashPath or hashSearch update', function() { - $location.update('http://server/#path?a=b'); - scope.$eval(); - $location.update({hashPath: '', hashSearch: {}}); - - expect($location.hash).toEqual(''); - }); - - - it('should update hashPath and hashSearch on $location.hash change upon eval', function(){ - $location.update('http://server/#path?a=b'); - scope.$eval(); - - $location.hash = ''; - scope.$eval(); - - expect($location.href).toEqual('http://server/'); - expect($location.hashPath).toEqual(''); - expect($location.hashSearch).toEqual({}); - }); - - - it('should update hash on $location.hashPath or $location.hashSearch change upon eval', - function() { - $location.update('http://server/#path?a=b'); - scope.$eval(); - $location.hashPath = ''; - $location.hashSearch = {}; - - scope.$eval(); - - expect($location.href).toEqual('http://server/'); - expect($location.hash).toEqual(''); - }); - - - it('should sync $location upon eval before watches are fired', function(){ - scope.$location = scope.$service('$location'); //publish to the scope for $watch - - var log = ''; - scope.$watch('$location.hash', function(){ - log += this.$location.hashPath + ';'; - }); - expect(log).toEqual(';'); - - log = ''; - scope.$location.hash = '/abc'; - scope.$eval(); - expect(scope.$location.hash).toEqual('/abc'); - expect(log).toEqual('/abc;'); - }); - - - describe('sync', function() { - it('should update hash with escaped hashPath', function() { - $location.hashPath = 'foo=bar'; - scope.$eval(); - expect($location.hash).toBe('foo%3Dbar'); - }); - - - it('should give $location.href the highest precedence', function() { - $location.hashPath = 'hashPath'; - $location.hashSearch = {hash:'search'}; - $location.hash = 'hash'; - $location.port = '333'; - $location.host = 'host'; - $location.href = 'https://hrefhost:23/hrefpath'; - - scope.$eval(); - - expect($location).toEqualData({href: 'https://hrefhost:23/hrefpath', - protocol: 'https', - host: 'hrefhost', - port: '23', - path: '/hrefpath', - search: {}, - hash: '', - hashPath: '', - hashSearch: {} - }); - }); - - - it('should give $location.hash second highest precedence', function() { - $location.hashPath = 'hashPath'; - $location.hashSearch = {hash:'search'}; - $location.hash = 'hash'; - $location.port = '333'; - $location.host = 'host'; - $location.path = '/path'; - - scope.$eval(); - - expect($location).toEqualData({href: 'http://host:333/path#hash', - protocol: 'http', - host: 'host', - port: '333', - path: '/path', - search: {}, - hash: 'hash', - hashPath: 'hash', - hashSearch: {} - }); - }); - }); - - describe('update()', function() { - it('should accept hash object and update only given properties', function() { - $location.update("http://host:123/p/a/t/h.html?query=value#path?key=value&flag&key2="); - $location.update({host: 'new', port: 24}); - - expect($location.host).toEqual('new'); - expect($location.port).toEqual(24); - expect($location.protocol).toEqual('http'); - expect($location.href).toEqual("http://new:24/p/a/t/h.html?query=value#path?key=value&flag&key2="); - }); - - it('should remove # if hash is empty', function() { - $location.update('http://www.angularjs.org/index.php#'); - expect($location.href).toEqual('http://www.angularjs.org/index.php'); - }); - - it('should clear hash when updating to hash-less URL', function() { - $location.update('http://server'); - expect($location.href).toBe('http://server'); - expect($location.hash).toBe(''); - }); - }); - - - describe('updateHash()', function() { - it('should accept single string argument to update path', function() { - $location.updateHash('path'); - expect($location.hash).toEqual('path'); - expect($location.hashPath).toEqual('path'); - }); - - it('should reset hashSearch when updating with a single string', function() { - $location.updateHash({foo:'bar'}); //set some initial state for hashSearch - - $location.updateHash('path'); - expect($location.hashPath).toEqual('path'); - expect($location.hashSearch).toEqual({}); - }); - - it('should accept single object argument to update search', function() { - $location.updateHash({a: 'b'}); - expect($location.hash).toEqual('?a=b'); - expect($location.hashSearch).toEqual({a: 'b'}); - }); - - it('should accept path string and search object arguments to update both', function() { - $location.updateHash('path', {a: 'b'}); - expect($location.hash).toEqual('path?a=b'); - expect($location.hashSearch).toEqual({a: 'b'}); - expect($location.hashPath).toEqual('path'); - }); - - it('should update href and hash when updating to empty string', function() { - $location.updateHash(''); - expect($location.href).toBe('http://server'); - expect($location.hash).toBe(''); - - scope.$eval(); - - expect($location.href).toBe('http://server'); - expect($location.hash).toBe(''); - }); - }); - }); - - describe("$invalidWidgets", function(){ - it("should count number of invalid widgets", function(){ - scope = compile('<input name="price" ng:required ng:validate="number"></input>'); - jqLite(document.body).append(scope.$element); - scope.$init(); - var $invalidWidgets = scope.$service('$invalidWidgets'); - expect($invalidWidgets.length).toEqual(1); - - scope.price = 123; - scope.$eval(); - expect($invalidWidgets.length).toEqual(0); - - scope.$element.remove(); - scope.price = 'abc'; - scope.$eval(); - expect($invalidWidgets.length).toEqual(0); - - jqLite(document.body).append(scope.$element); - scope.price = 'abcd'; //force revalidation, maybe this should be done automatically? - scope.$eval(); - expect($invalidWidgets.length).toEqual(1); - - jqLite(document.body).html(''); - scope.$eval(); - expect($invalidWidgets.length).toEqual(0); - }); - }); - - - describe("$route", function(){ - it('should route and fire change event', function(){ - var log = '', - $location, $route; - - function BookChapter() { - this.log = '<init>'; - } - scope = compile('<div></div>').$init(); - $location = scope.$service('$location'); - $route = scope.$service('$route'); - $route.when('/Book/:book/Chapter/:chapter', {controller: BookChapter, template:'Chapter.html'}); - $route.when('/Blank'); - $route.onChange(function(){ - log += 'onChange();'; - }); - $location.update('http://server#/Book/Moby/Chapter/Intro?p=123'); - scope.$eval(); - expect(log).toEqual('onChange();'); - expect($route.current.params).toEqual({book:'Moby', chapter:'Intro', p:'123'}); - expect($route.current.scope.log).toEqual('<init>'); - var lastId = $route.current.scope.$id; - - log = ''; - $location.update('http://server#/Blank?ignore'); - scope.$eval(); - expect(log).toEqual('onChange();'); - expect($route.current.params).toEqual({ignore:true}); - expect($route.current.scope.$id).not.toEqual(lastId); - - log = ''; - $location.update('http://server#/NONE'); - scope.$eval(); - expect(log).toEqual('onChange();'); - expect($route.current).toEqual(null); - - $route.when('/NONE', {template:'instant update'}); - scope.$eval(); - expect($route.current.template).toEqual('instant update'); - }); - - it('should return fn registered with onChange()', function() { - var scope = angular.scope(), - $route = scope.$service('$route'), - fn = function() {}; - - expect($route.onChange(fn)).toBe(fn); - }); - - it('should allow routes to be defined with just templates without controllers', function() { - var scope = angular.scope(), - $location = scope.$service('$location'), - $route = scope.$service('$route'), - onChangeSpy = jasmine.createSpy('onChange'); - - $route.when('/foo', {template: 'foo.html'}); - $route.onChange(onChangeSpy); - expect($route.current).toBeNull(); - expect(onChangeSpy).not.toHaveBeenCalled(); - - $location.updateHash('/foo'); - scope.$eval(); - - expect($route.current.template).toEqual('foo.html'); - expect($route.current.controller).toBeUndefined(); - expect(onChangeSpy).toHaveBeenCalled(); - }); - - it('should handle unknown routes with "otherwise" route definition', function() { - var scope = angular.scope(), - $location = scope.$service('$location'), - $route = scope.$service('$route'), - onChangeSpy = jasmine.createSpy('onChange'); - - function NotFoundCtrl() {this.notFoundProp = 'not found!'} - - $route.when('/foo', {template: 'foo.html'}); - $route.otherwise({template: '404.html', controller: NotFoundCtrl}); - $route.onChange(onChangeSpy); - expect($route.current).toBeNull(); - expect(onChangeSpy).not.toHaveBeenCalled(); - - $location.updateHash('/unknownRoute'); - scope.$eval(); - - expect($route.current.template).toBe('404.html'); - expect($route.current.controller).toBe(NotFoundCtrl); - expect($route.current.scope.notFoundProp).toBe('not found!'); - expect(onChangeSpy).toHaveBeenCalled(); - - onChangeSpy.reset(); - $location.updateHash('/foo'); - scope.$eval(); - - expect($route.current.template).toEqual('foo.html'); - expect($route.current.controller).toBeUndefined(); - expect($route.current.scope.notFoundProp).toBeUndefined(); - expect(onChangeSpy).toHaveBeenCalled(); - }); - }); - - - describe('redirection', function() { - - it('should support redirection via redirectTo property by updating $location', function() { - var scope = angular.scope(), - $location = scope.$service('$location'), - $browser = scope.$service('$browser'), - $route = scope.$service('$route'), - onChangeSpy = jasmine.createSpy('onChange'); - - $route.when('', {redirectTo: '/foo'}); - $route.when('/foo', {template: 'foo.html'}); - $route.when('/bar', {template: 'bar.html'}); - $route.when('/baz', {redirectTo: '/bar'}); - $route.otherwise({template: '404.html'}); - $route.onChange(onChangeSpy); - expect($route.current).toBeNull(); - expect(onChangeSpy).not.toHaveBeenCalled(); - - scope.$eval(); //triggers initial route change - match the redirect route - $browser.defer.flush(); //triger route change - match the route we redirected to - - expect($location.hash).toBe('/foo'); - expect($route.current.template).toBe('foo.html'); - expect(onChangeSpy.callCount).toBe(1); - - onChangeSpy.reset(); - $location.updateHash(''); - scope.$eval(); //match the redirect route + update $browser - $browser.defer.flush(); //match the route we redirected to - - expect($location.hash).toBe('/foo'); - expect($route.current.template).toBe('foo.html'); - expect(onChangeSpy.callCount).toBe(1); - - onChangeSpy.reset(); - $location.updateHash('/baz'); - scope.$eval(); //match the redirect route + update $browser - $browser.defer.flush(); //match the route we redirected to - - expect($location.hash).toBe('/bar'); - expect($route.current.template).toBe('bar.html'); - expect(onChangeSpy.callCount).toBe(1); - }); - - it('should interpolate route variables in the redirected hashPath from the original hashPath', - function() { - var scope = angular.scope(), - $location = scope.$service('$location'), - $browser = scope.$service('$browser'), - $route = scope.$service('$route'); - - $route.when('/foo/:id/foo/:subid/:extraId', {redirectTo: '/bar/:id/:subid/23'}); - $route.when('/bar/:id/:subid/:subsubid', {template: 'bar.html'}); - scope.$eval(); - - $location.updateHash('/foo/id1/foo/subid3/gah'); - scope.$eval(); //triggers initial route change - match the redirect route - $browser.defer.flush(); //triger route change - match the route we redirected to - - expect($location.hash).toBe('/bar/id1/subid3/23?extraId=gah'); - expect($route.current.template).toBe('bar.html'); - }); - - it('should interpolate route variables in the redirected hashPath from the original hashSearch', - function() { - var scope = angular.scope(), - $location = scope.$service('$location'), - $browser = scope.$service('$browser'), - $route = scope.$service('$route'); - - $route.when('/bar/:id/:subid/:subsubid', {template: 'bar.html'}); - $route.when('/foo/:id/:extra', {redirectTo: '/bar/:id/:subid/99'}); - scope.$eval(); - - $location.hash = '/foo/id3/eId?subid=sid1&appended=true'; - scope.$eval(); //triggers initial route change - match the redirect route - $browser.defer.flush(); //triger route change - match the route we redirected to - - expect($location.hash).toBe('/bar/id3/sid1/99?appended=true&extra=eId'); - expect($route.current.template).toBe('bar.html'); - }); - - it('should allow custom redirectTo function to be used', function() { - var scope = angular.scope(), - $location = scope.$service('$location'), - $browser = scope.$service('$browser'), - $route = scope.$service('$route'); - - $route.when('/bar/:id/:subid/:subsubid', {template: 'bar.html'}); - $route.when('/foo/:id', - {redirectTo: customRedirectFn}); - scope.$eval(); - - $location.hash = '/foo/id3?subid=sid1&appended=true'; - scope.$eval(); //triggers initial route change - match the redirect route - $browser.defer.flush(); //triger route change - match the route we redirected to - - expect($location.hash).toBe('custom'); - - function customRedirectFn(routePathParams, hash, hashPath, hashSearch) { - expect(routePathParams).toEqual({id: 'id3'}); - expect(hash).toEqual($location.hash); - expect(hashPath).toEqual($location.hashPath); - expect(hashSearch).toEqual($location.hashSearch); - return 'custom'; - } - }); - }); - - - describe('$defer', function() { - var $defer, $exceptionHandler; - - beforeEach(function(){ - scope = createScope({}, angularService, { - '$exceptionHandler': jasmine.createSpy('$exceptionHandler') - }); - - $browser = scope.$service('$browser'); - $defer = scope.$service('$defer'); - $exceptionHandler = scope.$service('$exceptionHandler'); - }); - - - it('should delegate functions to $browser.defer', function() { - var counter = 0; - $defer(function() { counter++; }); - - expect(counter).toBe(0); - - $browser.defer.flush(); - expect(counter).toBe(1); - - $browser.defer.flush(); //does nothing - expect(counter).toBe(1); - - expect($exceptionHandler).not.toHaveBeenCalled(); - }); - - - it('should delegate exception to the $exceptionHandler service', function() { - $defer(function() {throw "Test Error";}); - expect($exceptionHandler).not.toHaveBeenCalled(); - - $browser.defer.flush(); - expect($exceptionHandler).toHaveBeenCalledWith("Test Error"); - }); - - - it('should call eval after each callback is executed', function() { - var eval = this.spyOn(scope, '$eval').andCallThrough(); - - $defer(function() {}); - expect(eval).wasNotCalled(); - - $browser.defer.flush(); - expect(eval).wasCalled(); - - eval.reset(); //reset the spy; - - $defer(function() {}); - $defer(function() {}); - $browser.defer.flush(); - expect(eval.callCount).toBe(2); - }); - - - it('should call eval even if an exception is thrown in callback', function() { - var eval = this.spyOn(scope, '$eval').andCallThrough(); - - $defer(function() {throw "Test Error";}); - expect(eval).wasNotCalled(); - - $browser.defer.flush(); - expect(eval).wasCalled(); - }); - }); - - - describe('$xhr', function(){ - var log; - function callback(code, response) { - expect(code).toEqual(200); - log = log + toJson(response) + ';'; - } - - beforeEach(function(){ - log = ''; - }); - - it('should forward the request to $browser and decode JSON', function(){ - $browserXhr.expectGET('/reqGET').respond('first'); - $browserXhr.expectGET('/reqGETjson').respond('["second"]'); - $browserXhr.expectPOST('/reqPOST', {post:'data'}).respond('third'); - - $xhr('GET', '/reqGET', null, callback); - $xhr('GET', '/reqGETjson', null, callback); - $xhr('POST', '/reqPOST', {post:'data'}, callback); - - $browserXhr.flush(); - - expect(log).toEqual('"third";["second"];"first";'); - }); - - it('should handle non 200 status codes by forwarding to error handler', function(){ - $browserXhr.expectPOST('/req', 'MyData').respond(500, 'MyError'); - $xhr('POST', '/req', 'MyData', callback); - $browserXhr.flush(); - var cb = $xhrError.mostRecentCall.args[0].callback; - expect(typeof cb).toEqual($function); - expect($xhrError).wasCalledWith( - {url:'/req', method:'POST', data:'MyData', callback:cb}, - {status:500, body:'MyError'}); - }); - - it('should handle exceptions in callback', function(){ - $log.error = jasmine.createSpy('$log.error'); - $browserXhr.expectGET('/reqGET').respond('first'); - $xhr('GET', '/reqGET', null, function(){ throw "MyException"; }); - $browserXhr.flush(); - - expect($log.error).wasCalledWith("MyException"); - }); - - describe('bulk', function(){ - it('should collect requests', function(){ - $xhrBulk.urls["/"] = {match:/.*/}; - $xhrBulk('GET', '/req1', null, callback); - $xhrBulk('POST', '/req2', {post:'data'}, callback); - - $browserXhr.expectPOST('/', { - requests:[{method:'GET', url:'/req1', data: null}, - {method:'POST', url:'/req2', data:{post:'data'} }] - }).respond([ - {status:200, response:'first'}, - {status:200, response:'second'} - ]); - $xhrBulk.flush(function(){ log += 'DONE';}); - $browserXhr.flush(); - expect(log).toEqual('"first";"second";DONE'); - }); - - it('should handle non 200 status code by forwarding to error handler', function(){ - $xhrBulk.urls['/'] = {match:/.*/}; - $xhrBulk('GET', '/req1', null, callback); - $xhrBulk('POST', '/req2', {post:'data'}, callback); - - $browserXhr.expectPOST('/', { - requests:[{method:'GET', url:'/req1', data: null}, - {method:'POST', url:'/req2', data:{post:'data'} }] - }).respond([ - {status:404, response:'NotFound'}, - {status:200, response:'second'} - ]); - $xhrBulk.flush(function(){ log += 'DONE';}); - $browserXhr.flush(); - - expect($xhrError).wasCalled(); - var cb = $xhrError.mostRecentCall.args[0].callback; - expect(typeof cb).toEqual($function); - expect($xhrError).wasCalledWith( - {url:'/req1', method:'GET', data:null, callback:cb}, - {status:404, response:'NotFound'}); - - expect(log).toEqual('"second";DONE'); - }); - }); - - describe('cache', function(){ - var cache; - beforeEach(function(){ cache = scope.$service('$xhr.cache'); }); - - it('should cache requests', function(){ - $browserXhr.expectGET('/url').respond('first'); - cache('GET', '/url', null, callback); - $browserXhr.flush(); - - $browserXhr.expectGET('/url').respond('ERROR'); - cache('GET', '/url', null, callback); - $browser.defer.flush(); - expect(log).toEqual('"first";"first";'); - - cache('GET', '/url', null, callback, false); - $browser.defer.flush(); - expect(log).toEqual('"first";"first";"first";'); - }); - - it('should first return cache request, then return server request', function(){ - $browserXhr.expectGET('/url').respond('first'); - cache('GET', '/url', null, callback, true); - $browserXhr.flush(); - - $browserXhr.expectGET('/url').respond('ERROR'); - cache('GET', '/url', null, callback, true); - $browser.defer.flush(); - expect(log).toEqual('"first";"first";'); - - $browserXhr.flush(); - expect(log).toEqual('"first";"first";"ERROR";'); - }); - - it('should serve requests from cache', function(){ - cache.data.url = {value:'123'}; - cache('GET', 'url', null, callback); - $browser.defer.flush(); - expect(log).toEqual('"123";'); - - cache('GET', 'url', null, callback, false); - $browser.defer.flush(); - expect(log).toEqual('"123";"123";'); - }); - - it('should keep track of in flight requests and request only once', function(){ - scope.$service('$xhr.bulk').urls['/bulk'] = { - match:function(url){ - return url == '/url'; - } - }; - $browserXhr.expectPOST('/bulk', { - requests:[{method:'GET', url:'/url', data: null}] - }).respond([ - {status:200, response:'123'} - ]); - cache('GET', '/url', null, callback); - cache('GET', '/url', null, callback); - cache.delegate.flush(); - $browserXhr.flush(); - expect(log).toEqual('"123";"123";'); - }); - - it('should clear cache on non GET', function(){ - $browserXhr.expectPOST('abc', {}).respond({}); - cache.data.url = {value:123}; - cache('POST', 'abc', {}); - expect(cache.data.url).toBeUndefined(); - }); - - it('should call callback asynchronously for both cache hit and cache miss', function() { - $browserXhr.expectGET('/url').respond('+'); - cache('GET', '/url', null, callback); - expect(log).toEqual(''); //callback hasn't executed - - $browserXhr.flush(); - expect(log).toEqual('"+";'); //callback has executed - - cache('GET', '/url', null, callback); - expect(log).toEqual('"+";'); //callback hasn't executed - - $browser.defer.flush(); - expect(log).toEqual('"+";"+";'); //callback has executed - }); - - it('should call eval after callbacks for both cache hit and cache miss execute', function() { - var eval = this.spyOn(scope, '$eval').andCallThrough(); - - $browserXhr.expectGET('/url').respond('+'); - cache('GET', '/url', null, callback); - expect(eval).wasNotCalled(); - - $browserXhr.flush(); - expect(eval).wasCalled(); - - eval.reset(); //reset the spy - - cache('GET', '/url', null, callback); - expect(eval).wasNotCalled(); - - $browser.defer.flush(); - expect(eval).wasCalled(); - }); - }); - - }); - - - describe('$cookies', function() { - - var scope, $browser; - - beforeEach(function() { - $browser = new MockBrowser(); - $browser.cookieHash['preexisting'] = 'oldCookie'; - scope = createScope(null, angularService, {$browser: $browser}); - scope.$cookies = scope.$service('$cookies'); - }); - - - it('should provide access to existing cookies via object properties and keep them in sync', - function(){ - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); - - // access internal cookie storage of the browser mock directly to simulate behavior of - // document.cookie - $browser.cookieHash['brandNew'] = 'cookie'; - $browser.poll(); - - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'brandNew':'cookie'}); - - $browser.cookieHash['brandNew'] = 'cookie2'; - $browser.poll(); - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'brandNew':'cookie2'}); - - delete $browser.cookieHash['brandNew']; - $browser.poll(); - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); - }); - - - it('should create or update a cookie when a value is assigned to a property', function() { - scope.$cookies.oatmealCookie = 'nom nom'; - scope.$eval(); - - expect($browser.cookies()). - toEqual({'preexisting': 'oldCookie', 'oatmealCookie':'nom nom'}); - - scope.$cookies.oatmealCookie = 'gone'; - scope.$eval(); - - expect($browser.cookies()). - toEqual({'preexisting': 'oldCookie', 'oatmealCookie': 'gone'}); - }); - - - it('should drop or reset any cookie that was set to a non-string value', function() { - scope.$cookies.nonString = [1, 2, 3]; - scope.$cookies.nullVal = null; - scope.$cookies.undefVal = undefined; - scope.$cookies.preexisting = function(){}; - scope.$eval(); - expect($browser.cookies()).toEqual({'preexisting': 'oldCookie'}); - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); - }); - - - it('should remove a cookie when a $cookies property is deleted', function() { - scope.$cookies.oatmealCookie = 'nom nom'; - scope.$eval(); - $browser.poll(); - expect($browser.cookies()). - toEqual({'preexisting': 'oldCookie', 'oatmealCookie':'nom nom'}); - - delete scope.$cookies.oatmealCookie; - scope.$eval(); - - expect($browser.cookies()).toEqual({'preexisting': 'oldCookie'}); - }); - - - it('should drop or reset cookies that browser refused to store', function() { - var i, longVal; - - for (i=0; i<5000; i++) { - longVal += '*'; - } - - //drop if no previous value - scope.$cookies.longCookie = longVal; - scope.$eval(); - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie'}); - - - //reset if previous value existed - scope.$cookies.longCookie = 'shortVal'; - scope.$eval(); - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'longCookie': 'shortVal'}); - scope.$cookies.longCookie = longVal; - scope.$eval(); - expect(scope.$cookies).toEqual({'preexisting': 'oldCookie', 'longCookie': 'shortVal'}); - }); - }); - - - describe('$cookieStore', function() { - - it('should serialize objects to json', function() { - scope.$service('$cookieStore').put('objectCookie', {id: 123, name: 'blah'}); - scope.$eval(); //force eval in test - expect($browser.cookies()).toEqual({'objectCookie': '{"id":123,"name":"blah"}'}); - }); - - - it('should deserialize json to object', function() { - $browser.cookies('objectCookie', '{"id":123,"name":"blah"}'); - $browser.poll(); - expect(scope.$service('$cookieStore').get('objectCookie')).toEqual({id: 123, name: 'blah'}); - }); - - - it('should delete objects from the store when remove is called', function() { - scope.$service('$cookieStore').put('gonner', { "I'll":"Be Back"}); - scope.$eval(); //force eval in test - expect($browser.cookies()).toEqual({'gonner': '{"I\'ll":"Be Back"}'}); - }); - - }); - - - describe('URL_MATCH', function() { - - it('should parse basic url', function() { - var match = URL_MATCH.exec('http://www.angularjs.org/path?search#hash?x=x'); - - expect(match[1]).toEqual('http'); - expect(match[3]).toEqual('www.angularjs.org'); - expect(match[6]).toEqual('/path'); - expect(match[8]).toEqual('search'); - expect(match[10]).toEqual('hash?x=x'); - }); - - it('should parse file://', function(){ - var match = URL_MATCH.exec('file:///Users/Shared/misko/work/angular.js/scenario/widgets.html'); - - expect(match[1]).toEqual('file'); - expect(match[3]).toEqual(''); - expect(match[5]).toBeFalsy(); - expect(match[6]).toEqual('/Users/Shared/misko/work/angular.js/scenario/widgets.html'); - expect(match[8]).toBeFalsy(); - }); - - it('should parse url with "-" in host', function(){ - var match = URL_MATCH.exec('http://a-b1.c-d.09/path'); - - expect(match[1]).toEqual('http'); - expect(match[3]).toEqual('a-b1.c-d.09'); - expect(match[5]).toBeFalsy(); - expect(match[6]).toEqual('/path'); - expect(match[8]).toBeFalsy(); - }); - - it('should parse host without "/" at the end', function() { - var match = URL_MATCH.exec('http://host.org'); - expect(match[3]).toEqual('host.org'); - - match = URL_MATCH.exec('http://host.org#'); - expect(match[3]).toEqual('host.org'); - - match = URL_MATCH.exec('http://host.org?'); - expect(match[3]).toEqual('host.org'); - }); - - it('should match with just "/" path', function() { - var match = URL_MATCH.exec('http://server/#?book=moby'); - - expect(match[10]).toEqual('?book=moby'); - }); - }); - - describe('$updateView', function(){ - var scope, browser, evalCount, $updateView; - - beforeEach(function(){ - browser = new MockBrowser(); - // Pretend that you are real Browser so that we see the delays - browser.isMock = false; - browser.defer = jasmine.createSpy('defer'); - - scope = angular.scope(null, null, {$browser:browser}); - $updateView = scope.$service('$updateView'); - scope.$onEval(function(){ evalCount++; }); - evalCount = 0; - }); - - it('should eval root scope after a delay', function(){ - $updateView(); - expect(evalCount).toEqual(0); - expect(browser.defer).toHaveBeenCalled(); - expect(browser.defer.mostRecentCall.args[1]).toEqual(25); - browser.defer.mostRecentCall.args[0](); - expect(evalCount).toEqual(1); - }); - - it('should allow changing of delay time', function(){ - var oldValue = angular.service('$updateView').delay; - angular.service('$updateView').delay = 50; - $updateView(); - expect(evalCount).toEqual(0); - expect(browser.defer).toHaveBeenCalled(); - expect(browser.defer.mostRecentCall.args[1]).toEqual(50); - angular.service('$updateView').delay = oldValue; - }); - - it('should ignore multiple requests for update', function(){ - $updateView(); - $updateView(); - expect(evalCount).toEqual(0); - expect(browser.defer).toHaveBeenCalled(); - expect(browser.defer.callCount).toEqual(1); - browser.defer.mostRecentCall.args[0](); - expect(evalCount).toEqual(1); - }); - - it('should update immediatelly in test/mock mode', function(){ - scope = angular.scope(); - scope.$onEval(function(){ evalCount++; }); - expect(evalCount).toEqual(0); - scope.$service('$updateView')(); - expect(evalCount).toEqual(1); - }); - }); -}); diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index 12fe7b64..9aa3b95d 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -601,23 +601,8 @@ describe("widget", function(){ expect(element.text()).toEqual('one'); }); - it("should match urls", function(){ - var scope = angular.compile('<ng:switch on="url" using="route:params"><div ng:switch-when="/Book/:name">{{params.name}}</div></ng:switch>'); - scope.url = '/Book/Moby'; - scope.$init(); - expect(scope.$element.text()).toEqual('Moby'); - dealoc(scope); - }); - - it("should match sandwich ids", function(){ - var scope = {}; - var match = angular.widget('NG:SWITCH').route.call(scope, '/a/123/b', '/a/:id'); - expect(match).toBeFalsy(); - }); - it('should call change on switch', function(){ var scope = angular.compile('<ng:switch on="url" change="name=\'works\'"><div ng:switch-when="a">{{name}}</div></ng:switch>'); - var cleared = false; scope.url = 'a'; scope.$init(); expect(scope.name).toEqual(undefined); |
